[3D Programming with DirectX12] Chapter 14 : The Tessellation Stage

DirectX12의 Tessellation 대해 소개합니다.

테셀레이션 스테이지

테셀레이션 이미지
테셀레이션 이미지

테셀레이션은 원래 평면도형을 빈틈없이 붙이는 방식을 말한다. 그래픽스에서의 테셀레이션 스테이지는 버택스 셰이더에서 만들어진 삼각형을 더 작은 삼각형으로 쪼개는 과정이다.

테셀레이션 스테이지는 버택스 셰이더와 픽셀셰이더 사이에 있는데 이곳에서 정점을 추가하고 추가적인 계산이 가능하다. 물론 이것 역시 필수적인 단계는 아니다.

그냥 처음부터 버택스를 많이 만들면 되는 문제 아닌가?

맞는 말이지만 이렇게 따로 만든 데에는 세 가지 이유가 있다.

  • LOD(Level Of Detail)의 적용에 용이함.
  • 물리, 애니메이션에 효율성이 높아짐
  • 메모리를 아낄 수 있음.

— (LOD는 멀리 있는 물체의 폴리곤을 줄여서 성능을 높이는 기술이다.) —

테셀레이션 스테이지 단계 설명
테셀레이션 스테이지 단계 설명

원래 알고있던 VS(버택스 셰이더)와 PS(프래그먼트 셰이더)사이에 처음보는 세단계가 있다. 차근차근 과정을 알아보자.

헐 셰이더 (Hull Shader)

헐 셰이더에서는 VS에서 전달받은 도형을 어떻게 나눌 것인지 정의한다. VS에서는 각 정점(버택스)라고 불리는 것을 해당 스테이지에서는 Control Point로 바꿔 부른다. 그 이유는 정점의 위치를 변화시키거나 개수를 늘리기 위한 스테이지이기 때문이라고 짐작해본다.

헐 셰이더는 두 가지 단계로 구분된다.

1. Constant Hull Shader

PatchTess / ConstantHS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct	PatchTess
{
  float	EdgeTess[4] : SV_TessFactor; // 모서리 divide 파라미터
  float	InsideTess[2] : SV_InsideTessFactor; // 내부 divide 파라미터
};

PatchTess ConstantHS(InputPatch<VertexOut, 4> patch,
uint patchID : SV_PrimitiveID)
{
  PatchTess pt;

  pt.EdgeTess[0] = 3;
  pt.EdgeTess[1] = 3;
  pt.EdgeTess[2] = 3;
  pt.EdgeTess[3] = 3;
  pt.InsideTess[0] = 3;
  pt.InsideTess[1] = 3;
  return pt;
}

Patch : 상수 헐 셰이더에 한번에 들어오는 점의 집합

Tessellation factor : 도형을 어떻게 나눌 것인지 정하는 파라미터

상수 헐 셰이더에서는 들어온 patch를 이용해서 Tessellation factor를 정하고 내보내는 역할을 한다. PatchTess에 보면 4개의 인덱스를 가진 Edgetess, 2개의 인덱스를 가진 InsideTess가 있다.

2. Control Point Hull Shader

HullOut / HS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct	HullOut
{
  float3 PosL : POSITION;
};

[domain(“quad”)] // 패치의 모양 결정
[partitioning(“integer”)] // 
[outputtopology(“triangle_cw”)] // culling face 결정
[outputcontrolpoints(4)]
[patchconstantfunc(“ConstantHS”)]
[maxtessfactor(64.0f)]
HullOut	HS(InputPatch<VertexOut,4>	p,
uint i : SV_OutputControlPointID,
uint patchId : SV_PrimitiveID)
{
  HullOut	hout;
  hout.PosL	=	p[i].PosL;
  return	hout;
}

해당 셰이더에서는 상수 헐 셰이더에서 전달받은 컨트롤 포인트들의 위치를 제어할 수 있다.

테셀레이션

여기서는 실제로 도형이 어떻게 나누어지는지 알아보자. 패치는 quad, triangle 두 종류가 가장 대표적으로 쓰인다. quad는 4개의 edgeTess, 2개의 InsideTess를 사용하고 triangle은 3개, 1개를 사용한다.

quad 테셀레이션
quad 테셀레이션
triangle 테셀레이션
triangle 테셀레이션

edgeTess를 기준으로 모서리를 자르고 InsideTess를 기준으로 내부 레이어를 만드는 느낌으로 생각하면 될 것이다. 물론 이것은 하드웨어가 알아서 해주는 작업이기 때문에 우리는 Tessellation factor만 잘 넘겨주면 된다.

Tessellation factor 결정 시 고려사항

Tessellation factor에 따라 하드웨어가 알아서 나눠주기 때문에 factor를 잘 선택해야한다. 고려사항은 다음과 같다.

  • 카메라로부터의 거리
  • 화면의 해상도
  • 카메라의 시점
  • Roughness (일반적으로 거친 표면이 더 많은 테셀레이션 factor를 요구함)

Domain Shader

DomainOut / DS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct DomainOut
{
  float4 PosH : SV_POSITION;
};
[domain(“quad”)] // 패치의 모양 결정
DomainOut DS(PatchTess patchTess,	
float2 uv : SV_DomainLocation,	
const OutputPatch<HullOut,4> quad)
{
  DomainOut dout;
  float3 v1 = lerp(quad[0].PosL, quad[1].PosL, uv.x);	
  float3 v2 = lerp(quad[2].PosL, quad[3].PosL, uv.x);	
  float3 p = lerp(v1, v2, uv.y);	
  float4 posW = mul(float4(p, 1.0f), gWorld);
  dout.PosH = mul(posW, gViewProj);
  return dout;
}

도메인 셰이더는 테셀레이션 스테이지에서 생성된 각 vertex마다 한번씩 호출된다. 파라미터로 받은 매개변수 좌표와 제어점을 이용해서 실제 3D정점 위치를 계산해야한다.

Tessellating a Quad

Quad 테셀레이션 전체 구조
Quad 테셀레이션 전체 구조

결국 전체적인 Tessellation의 과정은 다음과 같다고 볼 수 있다.

  1. VS에서 Control Point를 넘겨주면 Constant HS에서 tessellation factor를 만든다.
  2. Control Point HS에서 Control Point들을 조정한다.
  3. Tessellation Stage에서는 하드웨어가 자동으로 도형을 나누어준다.
  4. Domain Shader에서는 정점을 하나씩 돌면서 정점의 위치를 조정한다.

3차 베지어 Quad Patch

이번에는 테셀레이션을 이용해서 곡면을 만드는 방법을 알아보자. 이를 위해서 베지어 곡선을 알아야 한다.

Quad 테셀레이션 전체 구조
Quad 테셀레이션 전체 구조

Blender나 Unity등등을 다루다 보면 그래프를 조절하는 방법중에 베지어 곡선을 본 적이 있을 것이다. 단순히 로그, 지수 형태로 선을 조절하지 않고 내가 원하는대로 곡선을 만들 수 있다는 장점이 있다.

포인트는 여러개의 점을 기준으로 선을 만들어내고, 복잡한 곡선일 수록 점이 여러개 필요하다.

일직선으로 위치하지 않은 점 $\mathbf{p}_0$, $\mathbf{p}_1$, $\mathbf{p}_2$가 있다고 하자.

\[\mathbf{p}_0^1 = (1-t)\mathbf{p}_0 + t\mathbf{p}_1\] \[\mathbf{p}_1^1 = (1-t)\mathbf{p}_1 + t\mathbf{p}_2\] \[\begin{aligned} \mathbf{p}(t) &= (1-t)\mathbf{p}_0^1 + t\mathbf{p}_1^1 \\ &= (1-t)\bigl((1-t)\mathbf{p}_0 + t\mathbf{p}_1\bigr) + t\bigl((1-t)\mathbf{p}_1 + t\mathbf{p}_2\bigr) \\ &= (1-t)^2\mathbf{p}_0 + 2(1-t)t\mathbf{p}_1 + t^2\mathbf{p}_2 \end{aligned}\]

파라미터 $\mathbf{t}$로 선형보간을 하면 세 점 사이에 위치를 구할 수 있다. 해당 공식을 2차 베지어 곡선이라고 부른다.

t = 0.50

조절점을 드래그해서 곡선을 바꿔보세요

Quad 테셀레이션 전체 구조

3차 베지어 곡선도 위와 같은 방식으로 구할 수 있다. 최종 공식은 다음과 같다.

\[\begin{aligned} \mathbf{p}(t) &= (1-t)^3\mathbf{p}_0 + 3t(1-t)^2\mathbf{p}_1 + 3t^2(1-t)\mathbf{p}_2 + t^3\mathbf{p}_3 \end{aligned}\]
t = 0.50

조절점을 드래그해서 곡선을 바꿔보세요

3차 베지어 평면

어쨋든 해당 곡선들을 이용해서 4*4 쿼드 평면에 베지어 공식을 적용시키면 다음과 같은 곡면을 그릴 수 있게 된다.

연습문제

2번. 거리에 따라 정이십면체를 Tessellate해서 구를 그려라.

정이십면체는 삼각형으로 이루어져있으므로 예제의 quad가 아니라 triangle로 패치와 factor를 설정해주어야 한다.

  1. GeometryGenerator 클래스를 이용해 정20면체 Geometry 생성
  2. Constant Hull Shader에서 카메라와 구의 거리를 받아와서 거리에 따라 Tessellation factor 설정
  3. Domain Shader에서 각 점을 normalize한 후 반지름 곱해주기 (구의 모양 완성)
2번 문제 결과 화면

4번. [partitioning(“fractional_even / odd”)]를 사용해서 테셀레이션을 구현하라.

앞서 Constant Hull Shader를 다시 보면

1
[partitioning(“integer”)]

이 부분이 있었는데 이 “integer”를 “fractional_even” 또는 “fractional_odd”로 바꿔보라는 문제이다.

원래 이런 느낌이었다면

quad 테셀레이션
fractional_odd
triangle 테셀레이션
fractional_even

둘 다 integer보다 더 부드럽게 파티셔닝이 되는 모습이다. 사실 odd 와 even의 차이는 크게 체감하지 못하겠다.

8번. Bezier Patch를 수정해서 라이팅의 효과를 받는 곡면을 그려라. (삼각형의 세 점을 이용하면 normal 벡터를 구할 수 있다.)

이번엔 라인이 아니라 라이팅이 구현된 곡면을 렌더링하는 문제이다. 문제에서 세 점의 외적을 통해 normal을 구하라고 했는데 나는 더 쉬운 방법으로 구현했다.

BernsteinBasis / DS / PS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
float4 BernsteinBasis(float t)
{
    float invT = 1.0f - t;

    return float4( invT * invT * invT,
                   3.0f * t * invT * invT,
                   3.0f * t * t * invT,
                   t * t * t );
}

float4 dBernsteinBasis(float t) // 미분
{
    float invT = 1.0f - t;

    return float4( -3 * invT * invT,
                   3 * invT * invT - 6 * t * invT,
                   6 * t * invT - 3 * t * t,
                   3 * t * t );
}

float3 CubicBezierSum(const OutputPatch<HullOut, 16> bezpatch, float4 basisU, float4 basisV)
{
    float3 sum = float3(0.0f, 0.0f, 0.0f);
    sum  = basisV.x * (basisU.x*bezpatch[0].PosL  + basisU.y*bezpatch[1].PosL  + basisU.z*bezpatch[2].PosL  + basisU.w*bezpatch[3].PosL );
    sum += basisV.y * (basisU.x*bezpatch[4].PosL  + basisU.y*bezpatch[5].PosL  + basisU.z*bezpatch[6].PosL  + basisU.w*bezpatch[7].PosL );
    sum += basisV.z * (basisU.x*bezpatch[8].PosL  + basisU.y*bezpatch[9].PosL  + basisU.z*bezpatch[10].PosL + basisU.w*bezpatch[11].PosL);
    sum += basisV.w * (basisU.x*bezpatch[12].PosL + basisU.y*bezpatch[13].PosL + basisU.z*bezpatch[14].PosL + basisU.w*bezpatch[15].PosL);

    return sum;
}

[domain("quad")]
DomainOut DS(PatchTess patchTess,
             float2 uv : SV_DomainLocation,
             const OutputPatch<HullOut, 16> bezPatch)
{
	DomainOut dout;

	float4 basisU  = BernsteinBasis(uv.x);
	float4 basisV  = BernsteinBasis(uv.y);
	float4 dbasisU = dBernsteinBasis(uv.x);
	float4 dbasisV = dBernsteinBasis(uv.y);

	float3 p    = CubicBezierSum(bezPatch, basisU,  basisV);
	float3 dpdu = CubicBezierSum(bezPatch, dbasisU, basisV);  // u 방향 접선
	float3 dpdv = CubicBezierSum(bezPatch, basisU,  dbasisV); // v 방향 접선

	float3 normal = normalize(cross(dpdu, dpdv));

	float4 posW  = mul(float4(p, 1.0f), gWorld);
	dout.PosH    = mul(posW, gViewProj);
	dout.PosW    = posW.xyz;
	dout.NormalW = mul(normal, (float3x3)gWorld);

	return dout;
}

float4 PS(DomainOut pin) : SV_Target
{
	pin.NormalW = normalize(pin.NormalW);

	float3 toEye = normalize(gEyePosW - pin.PosW);

	float4 ambient = gAmbientLight * gDiffuseAlbedo;

	const float shininess = 1.0f - gRoughness;
	Material mat = { gDiffuseAlbedo, gFresnelR0, shininess };
	float3 shadowFactor = 1.0f;
	float4 directLight = ComputeLighting(gLights, mat, pin.PosW,
		pin.NormalW, toEye, shadowFactor);

	float4 litColor = ambient + directLight;
	litColor.a = gDiffuseAlbedo.a;

	return litColor;
}

dBernsteinBasis는 베지어 곡선을 미분한 값을 구하는 함수인데, 곡선을 미분하면 해당 곡선의 접선이 나온다. 평면에서 u, v에 대해 미분하여 나온 접선 두 개를 외적하면 normal 벡터를 구할 수 있다.

quad 테셀레이션
8번 문제 결과 화면

이후에는 이전 장들에서 배운 라이팅 연산을 이용해 PS를 채워주면 끝난다.

이것으로 14장 정리 완료

태그
← [3D Programming with DirectX12] Chapter 13 : The Compute Shader [농부는 대체되었다] 리뷰 (3.5/5) →