[Computer Graphics with DirectX 12] 06. Tessellation

2024. 4. 29. 23:01Programming/Computer Graphics

06. Tessellation 목표
  • 테셀레이션을 이용한 지형 세분화
    • 카메라의 거리에 따른 지형의 테셀레이션 계수 조절
    • 4차 베지에 곡면을 이용한 지형 렌더링
  • GetHeight 함수 재구현 
    • 지형의 높이가 셰이더 단에서 결정됨에 따른 GetHeight 함수 재구현

 
기존의 지형은 높이 맵의 높이를 받아와 서로 연결하는 형태로 구현되었습니다.
결과는 나쁘지 않았지만 높이의 변화가 선형으로 이뤄지므로 가까이서 관찰하면 분명 어색한 부분이 존재함을 확인할 수 있었습니다. 이는 높이 맵을 더욱 세분화하고 삼각형을 가까운 간격으로 연결하여 해결할 수 있습니다. 
그러나 이러한 구현을 위해 많은 메모리와 연산을 필요로 합니다. 가령 0.1 간격으로 지형을 렌더링한다면 1 간격으로 렌더링 할 때에 비해 100배의 메모리가 필요할 것입니다.
 
지형 뿐 아니라 각종 주변 환경을 렌더링함에 있어 이는 일종의 딜레마입니다. 고다각형 메쉬를 이용하여 별로 중요하지 않은 주변 환경을 높은 품질로 렌더링하기에는 메모리와 CPU 자원이 아깝습니다. 저다각형 메쉬를 이용하여 낮은 품질로 렌더링하기에는 밈이 되어버린 모 게임의 포도 모델링처럼 게임의 퀄리티 저하가 우려됩니다.

 

 

GitHub - SH4MDEL/DirectX12-ComputerGraphics

Contribute to SH4MDEL/DirectX12-ComputerGraphics development by creating an account on GitHub.

github.com

 

위 리포지토리에서 전체 소스코드를 확인하실 수 있습니다.
이 프로젝트의 소스코드는 06. Tessellation입니다.


테셀레이션 구현

class Mesh
TerrainMesh::TerrainMesh(const ComPtr<ID3D12Device>& device,
	const ComPtr<ID3D12GraphicsCommandList>& commandList, const wstring& fileName) : 
	m_patchLength {4}
{
	m_primitiveTopology = D3D_PRIMITIVE_TOPOLOGY_25_CONTROL_POINT_PATCHLIST;
	LoadMesh(device, commandList, fileName);
}

 
Terrain 메쉬가 렌더링 될 때 삼각형 대신 25개의 제어점을 사용합니다. 따라서 먼저 프리미티브 토폴로지를 수정해 주었습니다.
추가로 패치의 길이를 나타내는 변수 m_patchLength를 하나 정의해 주었습니다. 제어점 간의 거리는 이전과 같은 1이므로 패치 하나의 길이는 4가 됩니다.
 

void TerrainMesh::LoadMesh(const ComPtr<ID3D12Device>& device, 
	const ComPtr<ID3D12GraphicsCommandList>& commandList, const wstring& fileName)
{
	...
	vector<DetailVertex> vertices;
	for (int z = 0; z < m_length - 1; ++z) {
		for (int x = 0; x < m_length - 1; ++x) {
			FLOAT nx = static_cast<FLOAT>(x - half);
			FLOAT nz = static_cast<FLOAT>(z - half);
			FLOAT dx = static_cast<FLOAT>(x) * m_grid;
			FLOAT dz = 1.f - static_cast<FLOAT>(z) * m_grid;

			vertices.emplace_back(XMFLOAT3{ nx, m_height[z][x], nz },
				XMFLOAT2{ dx, dz }, XMFLOAT2{ 0.f, 1.f });
			vertices.emplace_back(XMFLOAT3{ nx, m_height[z + 1][x], nz + 1 },
				XMFLOAT2{ dx, dz - m_grid }, XMFLOAT2{ 0.f, 0.f });
			vertices.emplace_back(XMFLOAT3{ nx + 1, m_height[z + 1][x + 1], nz + 1 },
				XMFLOAT2{ dx + m_grid, dz - m_grid }, XMFLOAT2{ 1.f, 0.f });

			vertices.emplace_back(XMFLOAT3{ nx, m_height[z][x], nz },
				XMFLOAT2{ dx, dz }, XMFLOAT2{ 0.f, 1.f });
			vertices.emplace_back(XMFLOAT3{ nx + 1, m_height[z + 1][x + 1], nz + 1 },
				XMFLOAT2{ dx + m_grid, dz - m_grid }, XMFLOAT2{ 1.f, 0.f });
			vertices.emplace_back(XMFLOAT3{ nx + 1, m_height[z][x + 1], nz },
				XMFLOAT2{ dx + m_grid, dz }, XMFLOAT2{ 1.f, 1.f });
		}
	}
	...
}

 
이전 프로젝트까지 지형 정점을 정의해 주던 코드입니다. Z축, X축이 증가하는 방향으로 순회해 주며 삼각형 두 개씩을 생성해 주고 있습니다. 즉 먼저 Z가 0인 경우의 모든 삼각형을 처리해 주고, Z가 1인 경우의 모든 삼각형을 처리해 주며 진행하는 방식입니다.
이제는 제어점 25개를 하나의 패치로 하여 정점 셰이더에 입력해 주어야 합니다.
 

 
예를 들어 위 그림과 같이 257X257 크기의 높이 맵에서 첫 번째 패치를 (-127, 127)부터 (-123, 123)까지 4X4 크기의 정점 25개로 정의한다면, 이 25개의 패치를 순서대로 정점 셰이더의 입력으로 보내 줘야 합니다.
그렇기에 이전과 같이 For문 두 번을 순회하는 방법으로는 모든 패치를 생성해 주기 복잡해집니다. 
 

void TerrainMesh::CreatePatch(vector<TerrainVertex>& vertices, 
	INT zStart, INT zEnd, INT xStart, INT xEnd)
{
	constexpr INT dx[] = { -1, 0, 1, -1, 1, -1, 0, 1 };
	constexpr INT dz[] = { 1, 1, 1, 0, 0, -1, -1, -1 };
	for (INT z : views::iota(zEnd, zStart + 1) | views::reverse) {
		for (INT x : views::iota(xStart, xEnd + 1)) {
			const XMFLOAT2 uv0{ static_cast<FLOAT>(x) / (m_length - 1),
				1.f - static_cast<FLOAT>(z) / (m_length - 1) };
			const XMFLOAT2 uv1{ static_cast<FLOAT>(x - xStart) / m_patchLength,
				static_cast<FLOAT>(zStart - z) / m_patchLength };

			FLOAT nx = static_cast<FLOAT>(x - m_length / 2);
			FLOAT nz = static_cast<FLOAT>(z - m_length / 2);
			vertices.emplace_back(
				XMFLOAT3{ nx, m_height[z][x], nz }, uv0, uv1);
		}
	}
}

 
따라서 먼저 패치의 범위를 받아 25개의 정점을 정의해주는 CreatePatch 함수를 정의하였습니다.
감소하는 방향의 zStart, zEnd와 증가하는 방향의 xStart, xEnd를 매개 변수로 받아 [zStart, zEnd], [xStart, xEnd] 범위의 제어점을 정의해 줍니다. 이 범위는 [-m_length/2, m_length/2]가 아니라 [0, m_length) 사이의 값이 들어오도록 구현할 예정이므로 이 부분을 고려해 작성해 주었습니다.
 

void TerrainMesh::LoadMesh(const ComPtr<ID3D12Device>& device, 
	const ComPtr<ID3D12GraphicsCommandList>& commandList, const wstring& fileName)
{
	...
    
	vector<TerrainVertex> vertices;
	for (INT pz = m_length - 1; pz >= m_patchLength; pz -= m_patchLength) {
		for (INT px = 0; px < m_length - m_patchLength; px += m_patchLength) {
			CreatePatch(vertices, pz, pz - m_patchLength, px, px + m_patchLength);
		}
	}
    	...
}

 
LoadMesh 함수에서는 패치의 길이 간격으로 이중 For문을 돌며 CreatePatch 함수를 호출해 줍니다.
예를 들어 257X257 길이의 높이 맵을 생성한다면 첫 번째 루프에서 zStart는 256, zEnd는 252, xStart는 0, xEnd는 4가 되어 4X4 크기의 패치를 이루는 제어점 25개를 순차적으로 정점 벡터에 삽입합니다.
 

class Shader
TerrainShader::TerrainShader(const ComPtr<ID3D12Device>& device,
	const ComPtr<ID3D12RootSignature>& rootSignature)
{
	vector<D3D12_INPUT_ELEMENT_DESC> inputLayout = {
	{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
	{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 },
	{ "TEXCOORD", 1, DXGI_FORMAT_R32G32_FLOAT, 0, 20, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0 }};

#if defined(_DEBUG)
	UINT compileFlags = D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#else
	UINT compileFlags = 0;
#endif

	ComPtr<ID3DBlob> mvsByteCode, mhsByteCode, mdsByteCode, mpsByteCode;
	Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("Shader/terrain.hlsl"), nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE, "VERTEX_MAIN", "vs_5_1", compileFlags, 0, &mvsByteCode, nullptr));
	Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("Shader/terrain.hlsl"), nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE, "HULL_MAIN", "hs_5_1", compileFlags, 0, &mhsByteCode, nullptr));
	Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("Shader/terrain.hlsl"), nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE, "DOMAIN_MAIN", "ds_5_1", compileFlags, 0, &mdsByteCode, nullptr));
	Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("Shader/terrain.hlsl"), nullptr,
		D3D_COMPILE_STANDARD_FILE_INCLUDE, "PIXEL_MAIN", "ps_5_1", compileFlags, 0, &mpsByteCode, nullptr));

	D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc{};
	psoDesc.InputLayout = { inputLayout.data(), (UINT)inputLayout.size() };
	psoDesc.pRootSignature = rootSignature.Get();
	psoDesc.VS = {
		reinterpret_cast<BYTE*>(mvsByteCode->GetBufferPointer()),
		mvsByteCode->GetBufferSize() };
	psoDesc.HS = {
		reinterpret_cast<BYTE*>(mhsByteCode->GetBufferPointer()),
		mhsByteCode->GetBufferSize() };
	psoDesc.DS = {
		reinterpret_cast<BYTE*>(mdsByteCode->GetBufferPointer()),
		mdsByteCode->GetBufferSize() };
	psoDesc.PS = {
		reinterpret_cast<BYTE*>(mpsByteCode->GetBufferPointer()),
		mpsByteCode->GetBufferSize() };
	psoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
	psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
	psoDesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
	psoDesc.SampleMask = UINT_MAX;
	psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_PATCH;
	psoDesc.NumRenderTargets = 1;
	psoDesc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
	psoDesc.DSVFormat = DXGI_FORMAT_D24_UNORM_S8_UINT;
	psoDesc.SampleDesc.Count = 1;
	psoDesc.Flags = D3D12_PIPELINE_STATE_FLAG_NONE;
	Utiles::ThrowIfFailed(device->CreateGraphicsPipelineState(&psoDesc, IID_PPV_ARGS(&m_pipelineState)));
}

 
테셀레이션을 위해 파이프라인 상태 객체 수정 역시 필요합니다. 기존의 정점 셰이더, 픽셀 셰이더 뿐 아니라 헐 셰이더, 도메인 셰이더를 추가로 등록해 주었습니다.
또한 프리미티브 토폴로지 타입 변수를 D3D12_PRIMITIVE_TOPOLOGY_TYPE_PATCH로 바꿔 주었습니다.
 

HLSL
HULL_INPUT VERTEX_MAIN(VERTEX_INPUT input)
{
    HULL_INPUT output;
    output.position = float4(input.position, 1.0f);
    output.uv0 = input.uv0;
    output.uv1 = input.uv1;

    return output;
}

 
본격적으로 셰이더 쪽을 구현해 보겠습니다. 정점 셰이더에서는 월드 변환을 포함해 아무 변환도 실시하지 않고 들어온 값을 그대로 헐 셰이더로 넘겨 줍니다.
 

struct PatchTess
{
    float EdgeTess[4] : SV_TessFactor;
    float InsideTess[2] : SV_InsideTessFactor;
};

float CalcTessFactor(float4 center)
{
    float3 d = distance(g_cameraPosition, center.xyz);
    const float d0 = 20.f;
    const float d1 = 100.f;
    return clamp(20.f * saturate((d1 - d) / (d1 - d0)), 3.f, 64.f);
}

PatchTess CONSTANT_HULL(InputPatch<HULL_INPUT, 25> patch, uint patchID : SV_PrimitiveID)
{
    PatchTess output;
    
    output.EdgeTess[0] = CalcTessFactor(0.5f * mul((patch[0].position + patch[4].position), g_worldMatrix));
    output.EdgeTess[1] = CalcTessFactor(0.5f * mul((patch[0].position + patch[20].position), g_worldMatrix));
    output.EdgeTess[2] = CalcTessFactor(0.5f * mul((patch[4].position + patch[24].position), g_worldMatrix));
    output.EdgeTess[3] = CalcTessFactor(0.5f * mul((patch[20].position + patch[24].position), g_worldMatrix));
    
    output.InsideTess[0] = CalcTessFactor(0.25f * mul((patch[0].position + patch[4].position +
        patch[20].position + patch[24].position), g_worldMatrix));
    output.InsideTess[1] = output.InsideTess[0];
    
    return output;
}

 
헐 셰이더에 대해 설명 드리기 전에 먼저 패치 상수 함수(Patch Constant Function)에 대해 설명드리겠습니다.
헐 셰이더의 가장 큰 역할은 입력되는 패치를 얼마나 쪼갤 지 결정하는 것인데 그 일을 바로 이 패치 상수 함수에서 담당합니다.
 
패치 상수 함수는 패치마다 한 번씩 실행되어 패치 전체를 입력으로 받습니다. 그리고 구조체 형태로 이 패치를 얼마나 쪼갤 지 결정하여 반환합니다. 반환할 구조체 내에는 반드시 SV_TessFactor, SV_InsideTessFactor라는 Semantic Value를 가진 필드들이 담겨 있어야 합니다. 사각형 패치의 경우 SV_TessFactor 필드는 4개짜리 배열, SV_InsideTessFactor 필드는 2개짜리 배열 형태로 구성해야 합니다. 저는 PatchTess라는 구조체 내에 이를 정의해 주었습니다.
 
PatchTess 내 6개의 필드들을 어떻게 채워 주느냐에 따라 패치가 어떻게 쪼개질 지 결정됩니다. SV_TessFactor 필드는 사각형의 외곽 쪽이 어떻게 쪼개질지를 결정하며, SV_InsideTessFactor 필드는 사각형의 내부가 어떻게 쪼개질지를 결정합니다. 이 값은 최대 64까지 입력할 수 있는데 1로 설정할 경우 제어점들이 전혀 쪼개지지 않고, 64로 설정할 경우 64번 쪼개집니다.
 

float CalcTessFactor(float4 center)
{
    float3 d = distance(g_cameraPosition, center.xyz);
    const float d0 = 20.f;
    const float d1 = 100.f;
    return clamp(20.f * saturate((d1 - d) / (d1 - d0)), 3.f, 64.f);
}

 
상술했듯 카메라에 멀리 떨어진 지형은 크게 쪼개고, 카메라에 가까운 지형은 잘게 쪼개도록 구현하였습니다. 제어점들은 아직 월드 변환이 되지 않은 값들이므로 먼저 월드 변환을 실시해 주었습니다. 그리고 제어점의 거리가 20보다 가까우면 테셀레이션 계수를 64로 설정해 주었고 100보다도 멀면 3으로 설정했습니다. 그 사이의 거리를 갖는 제어점들은 사이값을 보간하여 설정해 주었습니다.
 

[domain("quad")]
[partitioning("fractional_even")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(25)]
[patchconstantfunc("CONSTANT_HULL")]
[maxtessfactor(64.0f)]
DOMAIN_INPUT HULL_MAIN(InputPatch<HULL_INPUT, 25> p,
    uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID)
{
    DOMAIN_INPUT output;
    output.position = p[i].position;
    output.uv0 = p[i].uv0;
    output.uv1 = p[i].uv1;
    return output;
}

 
헐 셰이더입니다. 함수 상단에 여러 속성들이 입력되어 있는데, 이 부분에 대해 먼저 설명드리겠습니다.
 

[domain("quad")]
[partitioning("fractional_even")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(25)]
[patchconstantfunc("CONSTANT_HULL")]
[maxtessfactor(64.0f)]

 
domain 속성은 헐 셰이더에서 사용할 패치의 유형을 정의하는데, 사각형의 경우 "quad"를 입력해 주면 됩니다.
partitioning 속성은 테셀레이션 계수가 실수 단에서도 작동할지, 정수 단에서만 작동할지를 결정합니다. 만약 이 속성이 "integer"라면 패치 상수 함수에서 정의한 테셀레이션 계수가 실수 형태일지라도 정수부만 적용됩니다. "fractional_even" 혹은 "fractional_odd"로 설정한다면 거리에 따른 테셀레이션이 좀 더 매끄럽게 진행됩니다.
outputtopology 속성은 테셀레이션으로 만들어지는 삼각형의 winding order가 시계 방향을 이룰 지, 반시계 방향을 이룰 지 결정합니다.
outputcontrolpoints 속성은 하나의 입력 패치가 출력할 제어점의 개수입니다. 제 프로젝트에서는 헐 셰이더에서 추가로 제어점이 더해지지는 않으므로 그대로 25를 입력해 주었습니다.
patchconstantfunc에는 위에서 구현한 패치 상수 함수의 이름을 입력해 주시면 됩니다.
maxtessfactor 속성에는 패치 상수 함수에서 결정할 테셀레이션 계수의 최댓값을 입력해 주는데, 제가 사용할 테셀레이션 계수의 최댓값은 물론 64이지만 만약 패치 상수 함수에서 이보다 낮은 최댓값만을 사용한다면 이를 미리 알려줘서 내부적인 최적화가 가능하게 합니다.
 

DOMAIN_INPUT HULL_MAIN(InputPatch<HULL_INPUT, 25> p,
    uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID)
{
    DOMAIN_INPUT output;
    output.position = p[i].position;
    output.uv0 = p[i].uv0;
    output.uv1 = p[i].uv1;
    return output;
}

 
헐 셰이더는 패치 상의 제어점들을 입력으로 받아 제어점들을 출력합니다.
패치를 얼마나 쪼갤 지에 대한 정보는 패치 상수 함수에서 처리했으므로 여기서는 정점 셰이더처럼 들어온 정보를 그대로 출력해 주었습니다.
다만 구현하기에 따라서는 입력 제어점들로부터 추가적인 제어점들을 유도해서 패치 상의 제어점 개수를 늘리는 방식을 사용할 수도 있습니다. 이 경우에는 상술한 outputcontrolpoints 속성을 수정해 줘야 합니다.
 
헐 셰이더의 출력은 테셀레이터의 입력으로 들어갑니다. 테셀레이터 단계는 래스터라이저 단계처럼 고정된 단계로 직접 프로그래밍 할 수 없습니다.
테셀레이터는 패치 상수 함수에서 정한 테셀레이션 계수에 따라 패치들을 실제로 세분합니다. 그리고 그 결과를 도메인 셰이더의 입력으로 보냅니다.
 

[domain("quad")]
PIXEL_INPUT DOMAIN_MAIN(PatchTess patchTess,
    float2 domainLocation : SV_DomainLocation,
    const OutputPatch<DOMAIN_INPUT, 25> patch)
{
    PIXEL_INPUT output;
    
	...
    
    return output;
}

 
테셀레이터를 거쳐 실제 세분이 완료되었습니다. 그러나 이 결과로 생성된 정점을 화면에 출력해도 사용자 입장에서 이것을 눈치챌 수 없을 것입니다. 왜냐하면 패치를 쪼개 많은 정점을 만들었을 뿐, 그 정점은 그대로 패치 위에 있기 때문입니다. 따라서 이 정점의 위치를 적당히 옮겨 줘야 합니다.
도메인 셰이더에는 테셀레이터를 거쳐 세분된 패치(patch)가 입력으로 들어옵니다. 헐 셰이더의 입력으로 들어왔던 패치와 비슷해 보이지만 여기서의 패치는 실제로 테셀레이션되어 세분된 패치라는 차이가 있습니다. 모델 좌표계를 기준으로 헐 셰이더상 패치 간 간격은 1이지만, 도메인 셰이더상 패치 간 간격은 카메라와 패치의 거리에 따라 그보다 훨씬 가까울 수 있음을 생각해 주시면 됩니다.
 
도메인 셰이더는 테셀레이터가 출력한 모든 정점에 대해 한 번씩 호출됩니다. 즉 하나의 패치에 대해 도메인 셰이더는 25번 호출됩니다. 그리고 정점이 속한 패치의 모든 정점 정보가 입력으로 같이 들어옵니다.그렇기에 하나의 정점에서 다른 25개 정점 정보에 모두 접근할 수 있습니다.
그렇다면 하나의 패치 상에서 도메인 셰이더가 25번 실행될 동안 각각의 정점은 어떤 차이를 가질까요?
SV_DomainLocationSemantic Value로 갖는 float2 형식의 매개 변수(domainLocation)를 함수의 입력으로 선언하여 패치 상의 정점 매개변수 좌표를 확인할 수 있습니다.
 

가령 그림과 같이 정점 25개를 갖는 사각형 패치에서 uv1domainLocation은 (0.0, 0.25)가 되고, uv2domainLocation은 (0.75, 0.5)가 됩니다. 텍스처의 uv 좌표와 완전히 같다고 보시면 됩니다.
uv 값으로 패치 상에서 내 위치를 알 수 있고, 어떤 정점이던 패치 상의 모든 정점 정보를 갖고 있기 때문에 지금 내 위치에서 주위 정점 정보를 얼마만큼의 가중치를 부여하여 보간할지 결정할 수 있습니다.
 
(참고로 도메인 셰이더의 입력 패치로 사각형([domain("quad")]) 대신 삼각형([domain("tri")])이 들어온다면 삼각형 무게중심 좌표를 입력하기 때문에 SV_DomainLocationfloat3 형식으로 설정해 줘야 합니다.)
 
이제 이러한 5x5 패치를 4차 베지에 곡면 형태로 출력해 보도록 하겠습니다.
3차 베지에 곡선은 2차 베지에 곡선 두 개를 선형 보간한 형태이고, 4차 베지에 곡선은 3차 베지에 곡선 두 개를 선형 보간한 형태입니다.
 
$$ B_{i}^{n}(t) = \frac{n!}{i!(n - i)!}t^i(i-t)^{n-i} $$
 
위 함수는 이를 일반화한 n차 베지에 곡선의 번스타인 기저 다항식입니다.
곡선 상의 매개변수 t를 알고 있을 때 n차 베지에 곡선을 이루는 n+1개의 제어점과 i번째 번스타인 기저 다항식을 각각 곱한 후 더해주면 n차 베지에 곡선 상 t 위치를 구할 수 있습니다.
이를 바탕으로 4차 베지에 곡선을 구하기 위한 함수를 작성해 보겠습니다.
 

void BernsteinBasis(float t, out float basis[5])
{
    float invT = 1.f - t;
    basis[0] = invT * invT * invT * invT;
    basis[1] = 4.f * t * invT * invT * invT;
    basis[2] = 6.f * t * t * invT * invT;
    basis[3] = 4.f * t * t * t * invT;
    basis[4] = t * t * t * t;
}

 
매개변수 t를 받아 4차 베지에 곡선의 번스타인 다항식 5개를 돌려주는 함수입니다.
이제 하나의 4차 베지에 곡선을 이루는 점 5개를 알고 있다면 베지에 곡선 상의 위치 또한 구할 수 있게 되었습니다.
 

float3 LineBezierSum(OutputPatch<DOMAIN_INPUT, 25> patch, uint index[5], float basis[5])
{
    float3 sum = float3(0.f, 0.f, 0.f);
    for (int i = 0; i < 5; ++i)
    {
        sum += basis[i] * patch[index[i]].position;
    }
    return sum;
}

 
4차 베지에 곡선을 이루는 점 5개는 그것이 x축 상의 곡선이든 z축 상의 곡선이든 도메인 셰이더의 입력으로 들어온 패치 위에 존재합니다. x축 상의 곡선이라면 0, 1, 2, 3, 4번 정점, 5, 6, 7, 8, 9번 정점같이 5로 나눈 몫이 같은 정점끼리 하나의 곡선을 이룰 것입니다. z축 상의 곡선이라면 0, 5, 10, 15, 20번, 1, 6, 11, 16, 21번 정점같이 5로 나눈 나머지가 같은 정점끼리 하나의 곡선을 이룹니다.
LineBezierSum 함수는 이러한 하나의 곡선을 이루는 5개의 정점 인덱스와 5개의 번스타인 다항식을 입력으로 받아 실제 곡선 상의 위치를 돌려줍니다.
 
이 두 개의 함수로 4차 베지에 곡선상의 위치를 알 수 있게 되었습니다. 그러나 우리는 4차 베지에 곡면을 구해야 합니다.
베지에 곡선상의 한 점을 구하기 위해 5개의 정점이 필요했듯, 베지에 곡면상의 한 점을 구하기 위해 5개의 베지에 곡선이 필요합니다.
 

float3 CubicBezierSum(OutputPatch<DOMAIN_INPUT, 25> patch, float basisU[5], float basisV[5])
{
    float3 sum = float3(0.f, 0.f, 0.f);
    for (int i = 0; i < 5; ++i)
    {
        uint index[5] = { i * 5, i * 5 + 1, i * 5 + 2, i * 5 + 3, i * 5 + 4 };
        sum += basisV[i] * LineBezierSum(patch, index, basisU);
    }
    return sum;
}

 
우리는 패치 내 정점의 위치가 되는 정점 매개변수 (u, v)를 알고 있습니다.
먼저 x축 상 정점의 위치를 뜻하는 u만 고려해 본다면 25개의 정점에 대해 서로 다른 5개의 x축 베지에 곡선 상 u의 위치 5개를 구할 수 있습니다.
이 5개의 위치를 이용하여 z축 베지에 곡선 상 v의 위치가 어떨지 구할 수 있습니다. 이것이 바로 4차 베지에 곡선의 (u, v) 상 위치가 됩니다.

 

void BernsteinBasis(float t, out float basis[5])
{
    float invT = 1.f - t;
    basis[0] = invT * invT * invT * invT;
    basis[1] = 4.f * t * invT * invT * invT;
    basis[2] = 6.f * t * t * invT * invT;
    basis[3] = 4.f * t * t * t * invT;
    basis[4] = t * t * t * t;
}

float3 LineBezierSum(OutputPatch<DOMAIN_INPUT, 25> patch, uint index[5], float basis[5])
{
    float3 sum = float3(0.f, 0.f, 0.f);
    for (int i = 0; i < 5; ++i)
    {
        sum += basis[i] * patch[index[i]].position;
    }
    return sum;
}

float3 CubicBezierSum(OutputPatch<DOMAIN_INPUT, 25> patch, float basisU[5], float basisV[5])
{
    float3 sum = float3(0.f, 0.f, 0.f);
    for (int i = 0; i < 5; ++i)
    {
        uint index[5] = { i * 5, i * 5 + 1, i * 5 + 2, i * 5 + 3, i * 5 + 4 };
        sum += basisV[i] * LineBezierSum(patch, index, basisU);
    }
    return sum;
}

[domain("quad")]
PIXEL_INPUT DOMAIN_MAIN(PatchTess patchTess,
    float2 domainLocation : SV_DomainLocation,
    const OutputPatch<DOMAIN_INPUT, 25> patch)
{
    PIXEL_INPUT output;
    
    float basisU[5], basisV[5];
    BernsteinBasis(domainLocation.x, basisU);
    BernsteinBasis(domainLocation.y, basisV);
    
    output.position = float4(CubicBezierSum(patch, basisU, basisV), 1.f);
    output.position = mul(output.position, g_worldMatrix);
    output.position = mul(output.position, g_viewMatrix);
    output.position = mul(output.position, g_projectionMatrix);
    
    output.uv0 = lerp(
    lerp(patch[0].uv0, patch[4].uv0, domainLocation.x),
    lerp(patch[20].uv0, patch[24].uv0, domainLocation.x),
    domainLocation.y);
    output.uv1 = lerp(
    lerp(patch[0].uv1, patch[4].uv1, domainLocation.x),
    lerp(patch[20].uv1, patch[24].uv1, domainLocation.x),
    domainLocation.y);
    
    return output;
}

 

4차 베지에 곡면을 구하는 전체 코드입니다.
이렇게 구해준 베지에 곡면 상 위치에 월드 변환, 뷰 변환, 투영 변환을 실시해 줍니다.
텍스처 출력을 위한 uv 좌표 또한 domainLocation을 보간하여 구해 주었습니다.
 

float4 PIXEL_MAIN(PIXEL_INPUT input) : SV_TARGET
{
    return lerp(g_texture[0].Sample(g_sampler, input.uv0),
    g_texture[1].Sample(g_sampler, input.uv1), 0.5f);
}

 
픽셀 셰이더의 내용은 이전과 같습니다.
 

 
테셀레이션 된 지형의 높이와 빌보드의 높이가 맞지 않아 풀이 지형에 붙어 있지 않고 떠 있는 모습입니다.
빌보드의 높이는 Terrain::GetHeight 함수를 기반으로 구해 주는데 이는 테셀레이션 되기 이전 정점의 높이입니다.
테셀레이션 된 결과를 반영할 수 있도록 GetHeight 함수를 수정해 주겠습니다.
 

void TerrainMesh::BernsteinBasis(FLOAT t, FLOAT* basis)
{
	const FLOAT invT = 1.f - t;
	basis[0] = invT * invT * invT * invT;
	basis[1] = 4.f * t * invT * invT * invT;
	basis[2] = 6.f * t * t * invT * invT;
	basis[3] = 4.f * t * t * t * invT;
	basis[4] = t * t * t * t;
}

FLOAT TerrainMesh::GetBezierSumHeight(INT sx, INT sz, FLOAT* basisU, FLOAT* basisV)
{
	FLOAT sum = 0.f;
	sum += basisV[0] * (basisU[0] * m_height[sz][sx] + 
		basisU[1] * m_height[sz][sx + 1] + basisU[2] * m_height[sz][sx + 2] + 
		basisU[3] * m_height[sz][sx + 3] + basisU[4] * m_height[sz][sx + 4]);
	sum += basisV[1] * (basisU[0] * m_height[sz + 1][sx] + 
		basisU[1] * m_height[sz + 1][sx + 1] + basisU[2] * m_height[sz + 1][sx + 2] + 
		basisU[3] * m_height[sz + 1][sx + 3] + basisU[4] * m_height[sz + 1][sx + 4]);
	sum += basisV[2] * (basisU[0] * m_height[sz + 2][sx] + 
		basisU[1] * m_height[sz + 2][sx + 1] + basisU[2] * m_height[sz + 2][sx + 2] + 
		basisU[3] * m_height[sz + 2][sx + 3] + basisU[4] * m_height[sz + 2][sx + 4]);
	sum += basisV[3] * (basisU[0] * m_height[sz + 3][sx] + 
		basisU[1] * m_height[sz + 3][sx + 1] + basisU[2] * m_height[sz + 3][sx + 2] + 
		basisU[3] * m_height[sz + 3][sx + 3] + basisU[4] * m_height[sz + 3][sx + 4]);
	sum += basisV[4] * (basisU[0] * m_height[sz + 4][sx] + 
		basisU[1] * m_height[sz + 4][sx + 1] + basisU[2] * m_height[sz + 4][sx + 2] + 
		basisU[3] * m_height[sz + 4][sx + 3] + basisU[4] * m_height[sz + 4][sx + 4]);
	return sum;
}

FLOAT TerrainMesh::GetHeight(FLOAT x, FLOAT z)
{
	const XMFLOAT2 range = XMFLOAT2{
		static_cast<FLOAT>(-m_length / 2),
		static_cast<FLOAT>(+m_length / 2) };
	if (range.x > x || range.y < x || range.x > z || range.y < z) return 0.f;
	const INT nx = static_cast<INT>(x + m_length / 2);
	const INT nz = static_cast<INT>(z + m_length / 2);
	const INT sx = nx - nx % 4;
	const INT sz = nz - nz % 4;
	const FLOAT fx = x + m_length / 2;
	const FLOAT fz = z + m_length / 2;

	const XMFLOAT2 domainLocation{ (fx - sx) / 4.f, (fz - sz) / 4.f };

	FLOAT basisU[5], basisV[5];
	BernsteinBasis(domainLocation.x, basisU);
	BernsteinBasis(domainLocation.y, basisV);

	return GetBezierSumHeight(sx, sz, basisU, basisV);
}

 
GetHeight 함수에 많은 수정이 들어갔지만 결국 셰이더 코드와 완전히 동일하게 동작합니다.
패치 상 uv 좌표인 XMFLOAT2 타입 domainLocation 값을 구해주고, 이를 이용해 x축 번스타인 다항식, z축 번스타인 다항식 5개을 구합니다. 그리고 패치 상 정점의 높이와 곱하여 베지어 곡면의 높이를 반환해 주었습니다.

 

TerrainShader::TerrainShader(const ComPtr<ID3D12Device>& device,
	const ComPtr<ID3D12RootSignature>& rootSignature)
{
	...
	psoDesc.RasterizerState.FillMode = D3D12_FILL_MODE_WIREFRAME;
	...
}

 

마지막으로 테셀레이션 되는 모습을 자세히 확인할 수 있도록 와이어프레임 형태로 출력되게 잠시 수정해 주었습니다.


결과

 

 
카메라가 가까이 다가가면 지형 위의 삼각형이 세분되는 모습을 볼 수 있습니다.

 


빌보드 또한 테셀레이션된 지형 위에 잘 얹힌 모습입니다.
 
다음 프로젝트에서는 퐁 모델을 이용해 지역 조명을 구현해 보겠습니다.