2024. 4. 14. 18:30ㆍProgramming/Computer Graphics
04. Geometry Shader 목표
- 기하 셰이더를 이용한 빌보드 구현
- 빌보드를 통한 수풀 표현
- 수풀은 항상 지형 위에 그려지도록 구현
- 루트 상수 대신 루트 서술자를 사용하도록 수정
- 상수 버퍼 뷰 루트 서술자를 이용해 루트 파라미터 전송
프로젝트 4에서는 기하 셰이더를 이용해 빌보드를 구현하고, 빌보드를 이용하여 풀을 렌더링해 보겠습니다. 이 과정에서 항상 지형 위에 수풀이 렌더링될 수 있도록 구현하겠습니다.
또한 지금까지는 월드 변환 행렬, 뷰 변환 행렬, 투영 변환 행렬 등을 루트 상수(Root Constant)를 이용해 셰이더로 전달하였습니다. 하나의 루트 시그니처에 전송 가능한 루트 파라미터의 최대 크기는 4 X 64바이트이므로, 행렬 세 개로만 최대 크기의 75%를 사용하게 됩니다. 이 공간을 절약하기 위해 루트 상수 대신 루트 서술자를 사용하여 루트 파라미터를 전송하도록 수정하겠습니다.
GitHub - SH4MDEL/DirectX12-ComputerGraphics
Contribute to SH4MDEL/DirectX12-ComputerGraphics development by creating an account on GitHub.
github.com
위 리포지토리에서 전체 소스코드를 확인하실 수 있습니다.
이 프로젝트의 소스코드는 04. Geometry Shader입니다.
시작 전 주요 수정사항
HLSL
Texture2D g_texture0 : register(t0);
Texture2D g_texture1 : register(t1);
TextureCube g_textureCube : register(t2);
이전 프로젝트에서는 한 파이프라인에서 두 개의 텍스처 자원을 사용할 수 있도록 루트 시그니처를 수정하였습니다.
그런데 이후 하나의 픽셀 셰이더에서 세 개 이상의 텍스처 자원을 사용할 일이 생긴다면 수정할 부분이 많아집니다.
따라서 셰이더에서 텍스처 대신 텍스처 배열을 활용하여 좀 더 일반적인 상황에서 사용할 수 있도록 수정해 보겠습니다.
Texture2D g_texture[2] : register(t0);
TextureCube g_textureCube : register(t2);
다음과 같이 텍스처 배열을 선언하고 두 개의 원소를 갖도록 수정해도 좋지만 이후 텍스처 배열의 길이를 늘려야 할 일이 생긴다면 g_textureCube의 레지스터를 뒤로 밀어 줘야 합니다.
TextureCube g_textureCube : register(t0);
Texture2D g_texture[2] : register(t1);
따라서 이처럼 순서만 바꿔 주었습니다. 나중에 텍스처 배열의 길이를 늘릴 일이 생긴다 하더라도 g_textureCube 또는 g_texture의 레지스터를 바꿔 줄 필요는 없게 되었습니다.
class GameFramework
void GameFramework::CreateRootSignature()
{
CD3DX12_DESCRIPTOR_RANGE descriptorRange[2];
descriptorRange[DescriptorRange::TextureCube].Init(
D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0, 0);
descriptorRange[DescriptorRange::Texture].Init(
D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 2, 1, 0);
...
}
수정된 사항에 맞게 루트 시그니처 또한 수정해 주었습니다. Texture를 나타내는 Descriptor Range의 서술자 개수가 2로 늘어났음을 주목해 주시면 됩니다.
class Texture
class Texture
{
...
private:
UINT m_srvDescriptorSize;
ComPtr<ID3D12DescriptorHeap> m_srvDescriptorHeap;
vector<pair<ComPtr<ID3D12Resource>, UINT>> m_textures;
vector<ComPtr<ID3D12Resource>> m_textureUploadBuffer;
};
이전까지는 텍스처마다 서로 다른 Register Space를 사용했습니다. 따라서 위처럼 텍스처 배열에 각 텍스처 자원이 어떤 레지스터 공간을 사용하는지 기록해 주었습니다.
class Texture
{
...
private:
UINT m_srvDescriptorSize;
ComPtr<ID3D12DescriptorHeap> m_srvDescriptorHeap;
UINT m_rootParameterIndex;
vector<ComPtr<ID3D12Resource>> m_textures;
vector<ComPtr<ID3D12Resource>> m_textureUploadBuffer;
};
이제 텍스처 배열을 사용하여 텍스처 배열을 사용하는 모든 텍스처가 같은 레지스터 공간을 사용하게 되었기 때문에 하나의 레지스터 공간 변수만 관리해 주도록 수정하였습니다.
HLSL
float4 DETAIL_PIXEL(DETAIL_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);
}
이처럼 인덱스로 텍스처 자원에 접근할 수 있습니다.
상수 버퍼 뷰 루트 서술자 구현
class GameObject
void GameObject::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
XMFLOAT4X4 worldMatrix;
XMStoreFloat4x4(&worldMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_worldMatrix)));
commandList->SetGraphicsRoot32BitConstants(RootParameter::GameObject, 16, &worldMatrix, 0);
if (m_texture) m_texture->UpdateShaderVariable(commandList);
}
class Camera
void Camera::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
XMStoreFloat4x4(&m_viewMatrix,
XMMatrixLookAtLH(XMLoadFloat3(&m_eye), XMLoadFloat3(&m_at), XMLoadFloat3(&m_up)));
XMFLOAT4X4 viewMatrix;
XMStoreFloat4x4(&viewMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_viewMatrix)));
commandList->SetGraphicsRoot32BitConstants(RootParameter::Camera, 16, &viewMatrix, 0);
XMFLOAT4X4 projectionMatrix;
XMStoreFloat4x4(&projectionMatrix, XMMatrixTranspose(XMLoadFloat4x4(&m_projectionMatrix)));
commandList->SetGraphicsRoot32BitConstants(RootParameter::Camera, 16, &projectionMatrix, 16);
}
HLSL
cbuffer GameObject : register(b0)
{
matrix g_worldMatrix : packoffset(c0);
};
cbuffer Camera : register(b1)
{
matrix g_viewMatrix : packoffset(c0);
matrix g_projectionMatrix : packoffset(c4);
};
이전 프로젝트까지 루트 상수를 통해 셰이더로 전송되었던 루트 파라미터는 GameObject 클래스가 관리하는 월드 변환 행렬과 Camera 클래스가 관리하는 뷰 변환 행렬, 투영 변환 행렬입니다.
GameObject가 소유한 월드 변환 행렬은 register(b0)에 대응하고, Camera가 소유한 뷰 변환 행렬과 투영 변환 행렬은 register(b1)에 대응합니다. 이를 SetGraphicsRoot32BitConstants 메소드를 사용하여 셰이더로 전송해 주었습니다.
루트 상수 대신 상수 버퍼를 이용하는 방식은 이보다 조금 복잡합니다. 정점 버퍼나 인덱스 버퍼, Texture 클래스 때 처럼 리소스를 생성하고, 이 리소스를 초기화해주는 과정을 거쳐야 합니다.
따라서 상수 버퍼를 생성하고, 이를 갱신해주는 역할을 전담할 템플릿 클래스 UploadBuffer를 따로 만들어 주겠습니다.
class UploadBuffer
struct BufferBase {};
template <typename T> requires derived_from<T, BufferBase>
class UploadBuffer
{
public:
UploadBuffer(const ComPtr<ID3D12Device>& device, UINT rootParameterIndex,
BOOL isConstantBuffer = false, UINT count = 1);
~UploadBuffer();
void UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList, const T& data) const;
private:
void Copy(const T& data, UINT index = 0) const;
private:
ComPtr<ID3D12Resource> m_uploadBuffer;
T* m_data;
UINT m_byteSize;
UINT m_rootParameterIndex;
BOOL m_isConstantBuffer;
};
Mesh 클래스를 템플릿화 할 때와 마찬가지로 상수 버퍼에 담을 자료의 형식을 개별적인 구조체로 정의하고, 이를 템플릿 인수로 넘겨주는 방식으로 구현할 것입니다.
상수 버퍼에 담을 자료들 간의 관계를 정의하기 위해 일단 아무 일도 하지 않는 BufferBase라는 구조체를 선언해 두고 concept-requires를 이용하여 이 구조체를 상속받는 구조체만을 UploadBuffer의 템플릿 인수로 넘겨줄 수 있도록 하였습니다.
상술했듯 정점 버퍼나 인덱스 버퍼, 텍스처와 같이 상수 버퍼 또한 리소스를 생성하고, 초기화해 주는 과정을 거쳐야 합니다. 다만 한 번 메모리에 올리면 바뀔 일이 없는 정점 버퍼와 인덱스 버퍼, 텍스처 등과는 달리 상수 버퍼의 내용은 매 프레임마다 갱신됩니다. 따라서 디폴트 힙 대신 업로드 힙에 만드는 것이 타당합니다. 그렇기에 임시 업로드용 힙과 디폴트 힙을 둘 다 생성했던 이전과는 달리, 업로드 힙만 선언해 주었습니다.
template<typename T> requires derived_from<T, BufferBase>
inline UploadBuffer<T>::UploadBuffer(const ComPtr<ID3D12Device>& device,
UINT rootParameterIndex, BOOL isConstantBuffer) :
m_rootParameterIndex{ rootParameterIndex }, m_isConstantBuffer{ isConstantBuffer }
{
if (m_isConstantBuffer) m_byteSize = ((sizeof(T) + 255) & ~255);
else m_byteSize = sizeof(T);
Utiles::ThrowIfFailed(device->CreateCommittedResource(
&CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD),
D3D12_HEAP_FLAG_NONE,
&CD3DX12_RESOURCE_DESC::Buffer(m_byteSize),
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&m_uploadBuffer)));
Utiles::ThrowIfFailed((m_uploadBuffer->Map(0, nullptr, reinterpret_cast<void**>(&m_data))));
}
UploadBuffer 클래스의 생성자입니다. 인자로 상수 버퍼인지 여부(isConstantBuffer)를 받고 있는데, 지금은 이 클래스를 상수 버퍼 하나를 담는 용도로만 사용하지만 나중에 Shader Resource, Unordered Access 루트 서술자로 이용할 가능성도 있기에 우선 추가해 두었습니다.
Shader Resource, Unordered Access와 다르게 상수 버퍼의 크기는 반드시 256바이트의 배수여야 합니다. 비트 연산인 (sizeof(T) + 255 & ~255)는 sizeof(T)보다는 크면서 가장 작은 256바이트의 배수를 돌려줍니다.
뒤이어 CreateCommittedResource 메소드를 이용해 업로드 버퍼를 생성해 주었고, Map 메소드를 통해 CPU 메모리의 데이터를 상수 버퍼에 올리기 위한 T* 타입 m_data의 주소를 전달해 주었습니다.
template<typename T> requires derived_from<T, BufferBase>
inline void UploadBuffer<T>::UpdateShaderVariable(
const ComPtr<ID3D12GraphicsCommandList>& commandList, const T& data) const
{
Copy(data);
commandList->SetGraphicsRootConstantBufferView(
m_rootParameterIndex, m_uploadBuffer->GetGPUVirtualAddress());
}
template<typename T> requires derived_from<T, BufferBase>
inline void UploadBuffer<T>::Copy(const T& data) const
{
memcpy(m_data, &data, sizeof(T));
}
클래스 외부에서 T 타입 data 변수에 셰이더로 전송하고자 하는 데이터를 채워 UpdateShaderVariable 메소드를 호출하면 data의 주소를 m_data에 복사하고, SetGraphicsRootConstantBufferView 메소드를 호출하여 루트 서술자를 파이프라인에 묶습니다.
class GameObject
struct ObjectData : public BufferBase
{
XMFLOAT4X4 worldMatrix;
};
class GameObject
{
public:
...
protected:
...
unique_ptr<UploadBuffer<ObjectData>> m_constantBuffer;
};
예를 들면 GameObject 클래스는 월드 변환 행렬을 셰이더에 전송해야 합니다.
이를 위해 BufferBase 구조체를 상속받는 ObjectData 구조체를 선언하여 월드 변환 행렬 하나만을 지정해 주고, 이를 템플릿 인수로 사용하는 UploadBuffer 하나를 선언해 주면 됩니다.
GameObject::GameObject(const ComPtr<ID3D12Device>& device) :
m_right{1.f, 0.f, 0.f}, m_up{0.f, 1.f, 0.f}, m_front{0.f, 0.f, 1.f}
{
XMStoreFloat4x4(&m_worldMatrix, XMMatrixIdentity());
m_constantBuffer = make_unique<UploadBuffer<ObjectData>>(device, (UINT)RootParameter::GameObject, true);
}
void GameObject::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList) const
{
ObjectData buffer;
XMStoreFloat4x4(&buffer.worldMatrix,
XMMatrixTranspose(XMLoadFloat4x4(&m_worldMatrix)));
m_constantBuffer->UpdateShaderVariable(commandList, buffer);
if (m_texture) m_texture->UpdateShaderVariable(commandList);
}
GameObject의 생성자에서는 UploadBuffer 객체를 동적 할당해 줍니다.
렌더링 시점에 셰이더로 전송하고자 하는 데이터를 ObjectData에 채워 UploadBuffer 인스턴스에 전달해 주면 루트 상수를 사용할 때와 마찬가지로 루트 파라미터가 셰이더로 잘 전송됩니다.
class Camera
struct CameraData : public BufferBase
{
XMFLOAT4X4 viewMatrix;
XMFLOAT4X4 projectionMatrix;
XMFLOAT3 eye;
};
class Camera
{
public:
...
protected:
...
unique_ptr<UploadBuffer<CameraData>> m_constantBuffer;
};
Camera::Camera(const ComPtr<ID3D12Device>& device) : m_eye{ 0.f, 0.f, 0.f }, m_at{0.f, 0.f, 1.f}, m_up{0.f, 1.f, 0.f},
m_u{1.f, 0.f, 0.f}, m_v{0.f, 1.f, 0.f}, m_n{0.f, 0.f, 1.f}
{
XMStoreFloat4x4(&m_viewMatrix, XMMatrixIdentity());
XMStoreFloat4x4(&m_projectionMatrix, XMMatrixIdentity());
m_constantBuffer = make_unique<UploadBuffer<CameraData>>(device, (UINT)RootParameter::Camera, true);
}
void Camera::UpdateShaderVariable(const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
XMStoreFloat4x4(&m_viewMatrix,
XMMatrixLookAtLH(XMLoadFloat3(&m_eye), XMLoadFloat3(&m_at), XMLoadFloat3(&m_up)));
CameraData buffer;
XMStoreFloat4x4(&buffer.viewMatrix,
XMMatrixTranspose(XMLoadFloat4x4(&m_viewMatrix)));
XMStoreFloat4x4(&buffer.projectionMatrix,
XMMatrixTranspose(XMLoadFloat4x4(&m_projectionMatrix)));
buffer.eye = m_eye;
m_constantBuffer->UpdateShaderVariable(commandList, buffer);
}
Camera 클래스 역시 같은 방법으로 수정해 주었습니다.
더불어 빌보드가 항상 카메라가 있는 곳을 바라보게 하기 위해 카메라의 위치(m_eye)를 추가로 셰이더에 전송해 주었습니다.
class GameFramework
void GameFramework::CreateRootSignature()
{
...
CD3DX12_ROOT_PARAMETER rootParameter[4];
rootParameter[RootParameter::GameObject].InitAsConstantBufferView(0, 0, D3D12_SHADER_VISIBILITY_ALL);
rootParameter[RootParameter::Camera].InitAsConstantBufferView(1, 0, D3D12_SHADER_VISIBILITY_ALL);
...
}
마지막으로 루트 시그니처를 생성할 때 채워줬던 루트 파라미터 구조체를 루트 상수용에서 루트 서술자용으로 바꿔 주면 됩니다.
기하 셰이더 구현
이전까지 프로젝트에서 버텍스 셰이더의 출력은 래스터라이저의 입력으로 들어갔습니다.
파이프라인에 기하 셰이더(Geometry Shader)를 추가하면 버텍스 셰이더의 출력이 기하 셰이더의 입력으로 들어가게 됩니다. (기하 셰이더의 출력은 래스터라이저의 입력으로 들어갑니다.)
버텍스 셰이더에서는 정점 하나를 입력받아 그대로 출력해야 했습니다. 기하 셰이더에서는 입력받은 정점 하나를 정점 여러 개로 분할해 래스터라이저로 보내거나, 아예 정점을 파괴하여 래스터라이저로 보내지 않을 수 있습니다. 따라서 CPU에게는 비교적 단순한 정점만을 처리하게 하고 셰이더에서 이를 복잡한 정점으로 분할하여 CPU의 연산 및 메모리 부담을 줄일 수 있습니다.
이번 프로젝트에서는 기하 셰이더를 이용해 빌보드를 구현해 보겠습니다. 빌보드는 3차원 공간에 3차원 모델 대신 2차원 이미지를 렌더링하되 그 이미지가 항상 카메라를 바라보도록 하여 그럴듯한 눈속임을 내는 기법입니다. 특히 파티클 등을 렌더링할 때 유용합니다.
기하 셰이더 없이 빌보드를 구현한다면 2차원 이미지를 렌더링하기 위해 정점 네 개로 이루어진 삼각형 두 개의 정보를 버텍스 셰이더의 입력으로 보내야 할 것입니다. 이번 프로젝트에서는 정점 하나만을 버텍스 셰이더의 입력으로 보내고, 기하 셰이더에서 이를 정점 네 개로 분할하여 CPU의 연산 부담을 줄여 보겠습니다.
Exporter
void CreateBillboardMesh()
{
struct Vertex
{
XMFLOAT3 position;
XMFLOAT2 size;
};
vector<Vertex> vertices(1, {XMFLOAT3{0.f, 0.f, 0.f}, XMFLOAT2{1.f, 1.f}});
ofstream out("../Resources/Meshes/BillboardMesh.binary", ios::binary);
out << vertices.size();
out.write(reinterpret_cast<const char*>(vertices.data()), sizeof(Vertex) * vertices.size());
}
int main()
{
CreateBillboardMesh();
}
먼저 파일 입력을 위한 정점 정보를 출력해 주었습니다.
정점 하나만을 버텍스 셰이더의 입력으로 보낼 것이기 때문에 딱 하나의 정점만 생성해 주면 됩니다.
class Mesh
template <typename T> requires derived_from<T, VertexBase>
class Mesh : public MeshBase
{
public:
Mesh() = default;
Mesh(const ComPtr<ID3D12Device>& device,
const ComPtr<ID3D12GraphicsCommandList>& commandList, const wstring& fileName,
D3D12_PRIMITIVE_TOPOLOGY primitiveTopology = D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
~Mesh() override = default;
protected:
...
};
class Scene
inline void Scene::BuildMeshes(const ComPtr<ID3D12Device>& device,
const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
...
auto billboardMesh = make_shared<Mesh<TextureVertex>>(device, commandList,
TEXT("../Resources/Meshes/billboardMesh.binary"), D3D_PRIMITIVE_TOPOLOGY_POINTLIST);
m_meshes.insert({ "BILLBOARD", billboardMesh });
}
그리고 출력한 파일을 다시 메모리에 올리기 위한 코드를 추가해 주었습니다.
이전까지는 프리미티브 토폴로지로 D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST만을 사용했습니다. 그렇기에 Mesh 클래스 내부에서 별도의 프리미티브 토폴로지 변수를 관리해주지 않았습니다.
빌보드 렌더링을 위해 정점 기하구조로 삼각형 대신 점 하나를 보내고자 합니다. 이제 한 프로젝트에서 서로 다른 종류의 프리미티브 토폴로지를 사용하기 때문에 Mesh 클래스에 프리미티브 토폴로지 변수를 추가하고, 생성자로 프리미티브 토폴로지를 전송해줄 수 있도록 수정하였습니다.
class Shader
BillboardShader::BillboardShader(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 },
{ "SIZE", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, 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, mgsByteCode, mpsByteCode;
Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("shader.hlsl"), nullptr,
D3D_COMPILE_STANDARD_FILE_INCLUDE, "BILLBOARD_VERTEX", "vs_5_1", compileFlags, 0, &mvsByteCode, nullptr));
Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("shader.hlsl"), nullptr,
D3D_COMPILE_STANDARD_FILE_INCLUDE, "BILLBOARD_GEOMETRY", "gs_5_1", compileFlags, 0, &mgsByteCode, nullptr));
Utiles::ThrowIfFailed(D3DCompileFromFile(TEXT("shader.hlsl"), nullptr,
D3D_COMPILE_STANDARD_FILE_INCLUDE, "BILLBOARD_PIXEL", "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.GS = {
reinterpret_cast<BYTE*>(mgsByteCode->GetBufferPointer()),
mgsByteCode->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.BlendState.AlphaToCoverageEnable = true;
psoDesc.SampleMask = UINT_MAX;
psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_POINT;
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)));
}
빌보드 렌더링을 위한 파이프라인 상태 객체를 관리하는 BillboardShader 클래스를 정의해 주었습니다.
다른 Shader 클래스와 다른 점은 크게 세 가지입니다. 먼저 기하 셰이더를 파이프라인에 포함시켜 주었고, 프리미티브 토폴로지 타입을 D3D12_PRIMITIVE_TOPOLOGY_TYPE_POINT로 설정해 주었습니다. 또한 수풀 텍스처는 일반적인 텍스처와 달리 이미지 가장자리의 알파 채널이 0으로 설정되어 있고, 알파 채널이 1인 부분의 색을 이용하는 방법으로 사용하기 때문에 알파 포괄도 변환(AlphaToCoverage)을 활성화 해 주었습니다.
HLSL
BILLBOARD_GEOMETRY_INPUT BILLBOARD_VERTEX(BILLBOARD_VERTEX_INPUT input)
{
BILLBOARD_GEOMETRY_INPUT output;
output.position = mul(float4(input.position, 1.0f), g_worldMatrix);
output.size = input.size;
return output;
}
지금까지 버텍스 셰이더에서는 모델 좌표계 상의 정점에 월드 변환, 뷰 변환, 투영 변환을 실시해준 후 결과를 래스터라이저에 보냈습니다.
이번 프로젝트에서는 기하 셰이더에서 월드 좌표계 상의 점을 기준으로 새 점을 생성해 주는 과정이 필요하기 때문에 버텍스 셰이더에서 미리 뷰 변환과 투영 변환을 하면 안 됩니다.
[maxvertexcount(4)]
void BILLBOARD_GEOMETRY(point BILLBOARD_GEOMETRY_INPUT input[1],
inout TriangleStream<BILLBOARD_PIXEL_INPUT> outStream)
{
float3 up = float3(0.f, 1.f, 0.f);
float3 front = normalize(g_cameraPosition - input[0].position.xyz);
float3 right = cross(up, front);
float halfWidth = input[0].size.x * 0.5f;
float halfHeight = input[0].size.y * 0.5f;
float4 vertices[4];
vertices[0] = float4(input[0].position.xyz + halfWidth * right - halfHeight * up, 1.0f);
vertices[1] = float4(input[0].position.xyz + halfWidth * right + halfHeight * up, 1.0f);
vertices[2] = float4(input[0].position.xyz - halfWidth * right - halfHeight * up, 1.0f);
vertices[3] = float4(input[0].position.xyz - halfWidth * right + halfHeight * up, 1.0f);
float2 uv[4] = { float2(0.f, 1.f), float2(0.f, 0.f), float2(1.f, 1.f), float2(1.f, 0.f) };
BILLBOARD_PIXEL_INPUT output;
[unroll]
for (int i = 0; i < 4; ++i) {
output.position = mul(vertices[i], g_viewMatrix);
output.position = mul(output.position, g_projectionMatrix);
output.uv = uv[i];
outStream.Append(output);
}
}
기하 셰이더입니다.
상단의 [maxvertexcount(4)]는 입력으로 들어오는 도형이 새로 생성할 최대 정점의 수입니다. 저는 정점을 입력으로 받아 빌보드를 이루는 사각형을 출력할 예정이므로 최대 정점의 수는 4로 지정해 주었습니다.
기하 셰이더의 입력 부분을 살펴보면 버텍스 셰이더나 픽셀 셰이더와 달리 두 개의 매개변수를 받는다는 것을 알 수 있습니다. 출력 역시 정점이 아니라 void입니다.
[maxvertexcount(4)]
void BILLBOARD_GEOMETRY(point BILLBOARD_GEOMETRY_INPUT input[1],
inout TriangleStream<BILLBOARD_PIXEL_INPUT> outStream)
기하 셰이더는 정점 하나가 여러 정점으로 확장되거나 폐기될 수 있기 때문에 정점 셰이더처럼 정점 정보를 그대로 return해 주지 않습니다. 대신 inout 수정자가 붙어 있는 출력 전용 매개변수(TriangleStream)에 정점을 채워 주는 방식을 사용합니다.
또한 입력 역시 정점 뿐 아니라 선, 삼각형처럼 완성된 기하구조를 배열의 형태로 한 번에 받을 수 있습니다. 이 때 키워드로 point, line, triangle처럼 받을 도형의 종류를 반드시 입력해 줘야 합니다. 이번 프로젝트에서는 선이나 삼각형은 받지 않고, 점 하나만을 입력으로 받기 때문에 point를 입력해 주었습니다.
float3 up = float3(0.f, 1.f, 0.f);
float3 front = normalize(g_cameraPosition - input[0].position.xyz);
float3 right = cross(up, front);
float halfWidth = input[0].size.x * 0.5f;
float halfHeight = input[0].size.y * 0.5f;
이제 점의 위치 정보를 바탕으로 실제 사각형을 생성해 보겠습니다.
상술했다싶이 빌보드는 항상 카메라를 바라봐야 합니다. 따라서 빌보드 사각형의 look 벡터는 카메라의 위치에서 빌보드의 위치를 뺀 결과를 정규화한 값이 됩니다. 빌보드 사각형의 up 벡터를 (0.0, 1.0, 0.0)이라고 한다면 이 둘을 외적하여 right 벡터 또한 쉽게 구해줄 수 있습니다.
float4 vertices[4];
vertices[0] = float4(input[0].position.xyz + halfWidth * right - halfHeight * up, 1.0f);
vertices[1] = float4(input[0].position.xyz + halfWidth * right + halfHeight * up, 1.0f);
vertices[2] = float4(input[0].position.xyz - halfWidth * right - halfHeight * up, 1.0f);
vertices[3] = float4(input[0].position.xyz - halfWidth * right + halfHeight * up, 1.0f);
float2 uv[4] = { float2(0.f, 1.f), float2(0.f, 0.f), float2(1.f, 1.f), float2(1.f, 0.f) };
빌보드의 위치와 right 벡터, up 벡터를 알고 있으면 이 정보를 바탕으로 월드 좌표계상의 네 정점 좌표를 쉽게 구할 수 있습니다. 빌보드는 카메라를 바라보고 있으므로 카메라가 바라보는 방향 기준 빌보드 좌하단의 좌표는 빌보드 너비에 right 벡터를 곱한 값을 더해 주고, 빌보드 높이에 up 벡터를 곱한 값을 빼 준 값이 됩니다.
이 빌보드에 텍스처를 입혀야 하므로 uv 좌표 또한 설정해 줘야 합니다. uv 좌표계는 좌측 상단이 (0, 0)이고, 우측 하단이 (1, 1)이라는 점만 유의해 주면서 설정해 주었습니다.
BILLBOARD_PIXEL_INPUT output;
[unroll]
for (int i = 0; i < 4; ++i) {
output.position = mul(vertices[i], g_viewMatrix);
output.position = mul(output.position, g_projectionMatrix);
output.uv = uv[i];
outStream.Append(output);
}
마지막으로 이렇게 구한 월드 좌표에 뷰 변환 행렬, 투영 변환 행렬을 곱하여 출력 전용 매개변수(TriangleStream)에 채워 주면 됩니다.
float4 BILLBOARD_PIXEL(BILLBOARD_PIXEL_INPUT input) : SV_TARGET
{
return g_texture[0].Sample(g_sampler, input.uv);
}
픽셀 셰이더의 구현은 이전과 같습니다.
이제 원하는 위치에 풀을 렌더링 할 수 있게 되었습니다. 카메라를 회전시켜도 빌보드가 항상 카메라를 바라보기 때문에 3차원 공간에 존재하는 2차원 이미지임에도 생각처럼 큰 어색함이 느껴지지 않습니다.
지금 풀은 허공에 떠 있는데 이보다는 지형 위에 풀이 나 있는 것이 자연스러우므로 지형 위에 수풀을 위치시켜 보겠습니다.
class TerrainMesh
class TerrainMesh : public Mesh<DetailVertex>
{
public:
TerrainMesh(const ComPtr<ID3D12Device>& device,
const ComPtr<ID3D12GraphicsCommandList>& commandList, const wstring& fileName,
D3D12_PRIMITIVE_TOPOLOGY primitiveTopology = D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
~TerrainMesh() override = default;
FLOAT GetHeight(FLOAT x, FLOAT z) const;
private:
void LoadMesh(const ComPtr<ID3D12Device>& device,
const ComPtr<ID3D12GraphicsCommandList>& commandList, const wstring& fileName) override;
private:
vector<vector<FLOAT>> m_height;
INT m_length;
FLOAT m_grid;
};
TerrainMesh를 구현할 때 이차원 배열 m_height에 높이 맵의 높이 정보를 저장해 두었습니다.
이 배열을 참조하면 (x, z) 좌표가 정수인 높이 맵 상의 위치를 쉽게 구할 수 있습니다.
예를 들어 다음과 같은 7x7 높이 맵이 있다고 가정하면 (x, z) 좌표 (-3, -3) 지점의 높이는 9가 되고, 바로 오른쪽의 (-2, -3) 지점의 높이는 8이 됩니다.
(x, z) 좌표가 실수인 경우의 높이 역시 정수인 경우보다는 복잡하지만 충분히 구할 수 있습니다. 가령 (x, z) 좌표가 (-2.4, -3)인 지점의 높이는 (-3, -3) 지점의 높이와 (-2, -3) 지점의 높이를 0.4:0.6 비율로 보간한 8.4가 될 것입니다.
FLOAT TerrainMesh::GetHeight(FLOAT x, FLOAT z) const
{
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 size_t nx = static_cast<size_t>(x + m_length / 2);
const size_t nz = static_cast<size_t>(z + m_length / 2);
const XMFLOAT2 percentage = XMFLOAT2{ x - floor(x), z - floor(z) };
if (percentage.x >= percentage.y) {
return lerp(lerp(m_height[nz][nx], m_height[nz][nx + 1], percentage.x),
m_height[nz + 1][nx + 1], percentage.y);
}
return lerp(lerp(m_height[nz][nx], m_height[nz + 1][nx], percentage.y),
m_height[nz + 1][nx + 1], percentage.x);
}
특정 (x, 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 size_t nx = static_cast<size_t>(x + m_length / 2);
const size_t nz = static_cast<size_t>(z + m_length / 2);
m_length는 지형의 길이입니다. 7x7 높이 맵이 있다고 가정하면 m_length는 7이 됩니다. 지형의 중앙이 (0, 0)이므로 지형의 범위는 [-3, 3]입니다.
제 프로젝트에서는 257x257 크기의 높이 맵을 사용하므로 지형의 범위는 [-128, 128]입니다. 입력받은 (x, z) 좌표가 이 범위를 벗어난다면 먼저 0을 리턴해 주었습니다.
지형의 범위는 음수부터 시작하지만 배열의 좌표는 양수부터 시작합니다. 따라서 입력받은 (x, z) 좌표에 대응하는 m_height 배열의 인덱스를 찾으려면 m_height / 2를 더해주는 작업을 먼저 해 줘야 합니다.
const XMFLOAT2 percentage = XMFLOAT2{ x - floor(x), z - floor(z) };
if (percentage.x >= percentage.y) {
return lerp(lerp(m_height[nz][nx], m_height[nz][nx + 1], percentage.x),
m_height[nz + 1][nx + 1], percentage.y);
}
return lerp(lerp(m_height[nz][nx], m_height[nz + 1][nx], percentage.y),
m_height[nz + 1][nx + 1], percentage.x);
percentage 변수는
class Scene
inline void Scene::BuildTextures(const ComPtr<ID3D12Device>& device,
const ComPtr<ID3D12GraphicsCommandList>& commandList)
{
...
auto grass0Texture = make_shared<Texture>(device, commandList,
TEXT("../Resources/Textures/Grass01.dds"), RootParameter::Texture);
m_textures.insert({ "GRASS0", grass0Texture });
auto grass1Texture = make_shared<Texture>(device, commandList,
TEXT("../Resources/Textures/Grass02.dds"), RootParameter::Texture);
m_textures.insert({ "GRASS1", grass1Texture });
auto grass2Texture = make_shared<Texture>(device, commandList,
TEXT("../Resources/Textures/Grass03.dds"), RootParameter::Texture);
m_textures.insert({ "GRASS2", grass2Texture });
auto grass3Texture = make_shared<Texture>(device, commandList,
TEXT("../Resources/Textures/Grass04.dds"), RootParameter::Texture);
m_textures.insert({ "GRASS3", grass3Texture });
}
다음과 같이 서로 다른 수풀 텍스처 네 종류를 로딩해 주었습니다.
inline void Scene::BuildObjects(const ComPtr<ID3D12Device>& device)
{
...
for (int x = -125; x <= 125; x += 5) {
for (int z = -125; z <= 125; z += 5) {
FLOAT fx = static_cast<FLOAT>(x);
FLOAT fz = static_cast<FLOAT>(z);
auto grass = make_shared<GameObject>(device);
grass->SetMesh(m_meshes["BILLBOARD"]);
string name = "GRASS" + to_string(abs(x * z) % 4);
grass->SetTexture(m_textures[name]);
grass->SetPosition(XMFLOAT3{ fx, m_terrain->GetHeight(fx, fz), fz });
m_grasses.push_back(grass);
}
}
}
그리고 지형 전체에 수풀이 렌더링 될 수 있도록 길이 5 간격으로 일정하게 풀을 심어 주었습니다.
풀의 높이는 지형의 GetHeight 함수를 이용해 구해 줍니다.
결과
지형 위에 풀이 잘 렌더링되고 있습니다만 너무 띄엄 띄엄 배치되어 있어 어색합니다. 풀의 배치 간격을 5에서 1로 줄여 보겠습니다.
for (int x = -125; x <= 125; x += 1) {
for (int z = -125; z <= 125; z += 1) {
FLOAT fx = static_cast<FLOAT>(x);
FLOAT fz = static_cast<FLOAT>(z);
auto grass = make_shared<GameObject>(device);
grass->SetMesh(m_meshes["BILLBOARD"]);
string name = "GRASS" + to_string(abs(x * z) % 4);
grass->SetTexture(m_textures[name]);
grass->SetPosition(XMFLOAT3{ fx, m_terrain->GetHeight(fx, fz), fz });
m_grasses.push_back(grass);
}
}
이전보다 그럴듯해졌지만 프로젝트를 실행할 때 시간이 너무 오래 걸리게 되었고, 렉이 너무 심해 정상적으로 조작할 수 없었습니다.
그도 그럴 것이, 거리 5 간격으로 배치되어 있던 빌보드의 간격을 1로 줄였으니 실제 렌더링되는 빌보드의 개수는 25배 늘어났습니다. -125부터 125까지 251 길이에 1 간격으로 빌보드를 배치했으므로 약 63000개의 빌보드를 배치하였고 이 63000개의 오브젝트에 매 프레임 렌더링 명령을 내려주고 있습니다.
또한 오브젝트가 생성될 때 마다 월드 변환 행렬을 셰이더로 전송하기 위한 업로드 버퍼를 생성하므로 63000개의 업로드 버퍼를 생성하기 위한 부하 역시 존재합니다.
다음 프로젝트에서는 인스턴싱을 이용해 이러한 문제를 해소해 보겠습니다.
'Programming > Computer Graphics' 카테고리의 다른 글
[Computer Graphics with DirectX 12] 06. Tessellation (0) | 2024.04.29 |
---|---|
[Computer Graphics with DirectX 12] 05. Instancing (0) | 2024.04.25 |
[Computer Graphics with DirectX 12] 03. Terrain (0) | 2024.03.06 |
[Computer Graphics with DirectX 12] 02. Texture (0) | 2024.02.26 |
[Computer Graphics with DirectX 12] 01. Framework - Update, Render (0) | 2024.02.20 |