지난 글에서는 DirectX에서 렌더링을 하기 위한 이론을 정리해보았다.
이제는 실제로 프로그래밍을 하며 오브젝트를 렌더링하여 보자.
DirectX의 렌더링 과정을 결코 쉽다곤 할 수 없겠지만, 이전 글에서 공부한 렌더링파이프라인 이론을 이해한대로 천천히 코드로 구현한다고 생각하면 어렵지 않게 구현이 가능하다.
입력 조립기 단계부터 순서대로 구현하여보자.
입력 조립기 단계에서는 이론에서 설명하였듯 정점의 정보를 DirectX에 넘겨주어야 한다.
이를 위해 정점의 정보를 저장할 구조체를 선언하여 준다.
struct Vertex {
XMFLOAT3 Pos;
XMFLOAT3 Color;
}
우리는 정점의 정보가 어떤 역할을 해야할지 알고 있지만, DirectX는 우리가 넘겨준 정점의 정보가 어떤 역할을 해야하는지 알지 못한다. 이를 위해 정점 구조체의 각 성분으로 무엇을 해야 하는지를 DirectX에게 알려주는 작업을 해줄 것이다.
이를 위한 수단이 입력 배치 서술이다.
vector<D3D12_INPUT_ELEMENT_DESC> mInputLayout;
구조체에 성분이 더 추가될 수 있기에 입력 배치 서술을 벡터로 선언해주었다.
mInputLayout =
{
{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0}, // 0 ~ 11 (12)
{"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0} // 12 ~ 27 (16)
};
우리가 사용할 정점의 정보를 벡터에 똑같이 넣어주면 된다. 다만 5번째 인자로 들어가는 시작 지점은 꼭 주의하여 넣어주어야한다.
현재 상태에서는 POSITION은 FLOAT3이므로 12바이트 크기를 차지하게 된다. 따라서 COLOR의 시작 지점을 12로 맞춰주어야 한다.
응용 프로그램에서 정점과 같은 원소들을 GPU에 넘겨줄 때에는 항상 버퍼를 사용해야 한다. 따라서 정점의 정보를 저장하는 버텍스 버퍼와 인덱스의 정보를 저장하는 인덱스 버퍼를 만들어야 한다.
// 정점 버퍼 뷰
D3D12_VERTEX_BUFFER_VIEW VertexBufferView;
ComPtr<ID3D12Resource> VertexBuffer = nullptr;
// 인덱스 버퍼 뷰
D3D12_INDEX_BUFFER_VIEW IndexBufferView;
ComPtr<ID3D12Resource> IndexBuffer = nullptr;
// 정점의 갯수
int VertexCount = 0;
// 인덱스의 갯수
int IndexCount = 0;
관련 변수들을 설정해준 후 우리가 GPU에 넘겨줄 정점 정보와 인덱스 정보를 기록하여 준다.
정육면체의 모습을 잘 생각하여 정점 정보와 인덱스 정보를 넣어주면 된다. 앞면만 제대로 렌더링 될 수 있도록 인덱스 작성 순서를 주의해야한다.
// 정점 정보
std::array<Vertex, 8> vertices = {
Vertex({XMFLOAT3(-0.5f, 0.5f, 0.5f), XMFLOAT4(Colors::Magenta)}),
Vertex({XMFLOAT3(0.5f, 0.5f, 0.5f), XMFLOAT4(Colors::Yellow)}),
Vertex({XMFLOAT3(0.5f, 0.5f, -0.5f), XMFLOAT4(Colors::Blue)}),
Vertex({XMFLOAT3(-0.5f, 0.5f, -0.5f), XMFLOAT4(Colors::Red)}),
Vertex({XMFLOAT3(-0.5f, -0.5f, 0.5f), XMFLOAT4(Colors::Magenta)}),
Vertex({XMFLOAT3(0.5f, -0.5f, 0.5f), XMFLOAT4(Colors::Yellow)}),
Vertex({XMFLOAT3(0.5f, -0.5f, -0.5f), XMFLOAT4(Colors::Blue)}),
Vertex({XMFLOAT3(-0.5f, -0.5f, -0.5f), XMFLOAT4(Colors::Red)}),
};
// 인덱스 정보
std::array<std::uint16_t, 18> indices = {
0, 1, 2,
0, 2, 3,
3, 2, 6,
3, 6, 7,
6, 5, 4,
6, 4, 7,
2, 5, 6,
2, 1, 5,
1, 0, 4,
1, 4, 5,
0, 3, 7,
0, 7, 4
};
이제 기록할 정보를 GPU에 보내기 위한 정점 버퍼와 인덱스 버퍼를 만들어주면 파이프라인에 정보를 넘겨줄 준비는 끝나게 된다.
// 정점 버퍼 생성
VertexCount = (UINT)vertices.size();
const UINT vbByteSize = VertexCount * sizeof(Vertex);
D3D12_HEAP_PROPERTIES heapProperty = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
D3D12_RESOURCE_DESC desc = CD3DX12_RESOURCE_DESC::Buffer(vbByteSize)
md3dDevice->CreateCommittedResource(
&heapProperty,
D3D12_HEAP_FLAG_NONE,
&desc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&VertexBufferGPU)
);
void* vertexDataBuffer = nullptr;
CD3DX12_RANGE vertexRange(0, 0);
VertexBufferGPU->Map(0, &vertexRange, &vertexDataBuffer);
::memcpy(vertexDataBuffer, &vertices, vbByteSize);
VertexBufferGPU->Unmap(0, nullptr);
VertexBufferView.BufferLocation = VertexBufferGPU->GetGPUVirtualAddress();
VertexBufferView.StrideInBytes = sizeof(Vertex);
VertexBufferView.SizeInBytes = vbByteSize;
// 인덱스 버퍼 생성
IndexCount = (UINT)indices.size();
const UINT ibByteSize = IndexCount * sizeof(std::uint16_t);
heapProperty = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
desc = CD3DX12_RESOURCE_DESC::Buffer(ibByteSize);
md3dDevice->CreateCommittedResource(
&heapProperty,
D3D12_HEAP_FLAG_NONE,
&desc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&IndexBufferGPU)
);
void* indexDataBuffer = nullptr;
CD3DX12_RANGE indexRange(0, 0);
IndexBufferGPU->Map(0, &indexRange, &indexDataBuffer);
::memcpy(indexDataBuffer, &indices, ibByteSize); IndexBufferGPU->Unmap(0, nullptr);
IndexBufferView.BufferLocation = IndexBufferGPU->GetGPUVirtualAddress();
IndexBufferView.Format = DXGI_FORMAT_R16_UINT;
IndexBufferView.SizeInBytes = ibByteSize;
GPU에 들어간 정보를 가공하기 위해서 쉐이더를 직접 프로그래밍해주어야 한다.
아직은 기본적인 렌더링만 할 것이기에, 입력된 정보에 아무런 가공을 하지 않고 다시 리턴하여주는 정점 쉐이더와 픽셀 쉐이더를 만들어줄 것이다.
struct VertexIn {
float3 PosL : POSITION;
float4 Color : COLOR;
};
struct VertexOut {
float4 PosH : SV_POSITION;
float4 Color : COLOR;
};
VertexOut VS(VertexIn vin) {
VertexOut vout;
vout.PosH = float4(vin.PosL, 1.0f);
vout.Color = vin.Color;
return vout;
}
float4 PS(VertexOut pin) : SV_TARGET {
return pin.Color;
}
프로그래밍한 쉐이더를 사용하기 위해 C++코드에서 접근할 수 있도록 변수로 만들어 준 후 컴파일 하는 작업을 거쳐준다.
ComPtr<ID3DBlob> mvsByteCode = nullptr;
ComPtr<ID3DBlob> mpsByteCode = nullptr;
mvsByteCode = d3dUtil::CompileShader(L"Color.hlsl", nullptr, "VS", "vs_5_0");
mpsByteCode = d3dUtil::CompileShader(L"Color.hlsl", nullptr, "PS", "ps_5_0");
쉐이더를 만들었으니, 쉐이더에서 참조하는 GPU 자원 즉 상수 버퍼또한 만들어줘야 한다.
상수버퍼는 CPU가 프레임당 한 번 갱신하는 것이 일반적이므로, CPU가 버퍼의 내용을 기록할 수 있도록 기본 힙이 아닌 업로드 힙에 만들어야 한다.
또한, 상수버퍼는 특별한 하드웨어 요구조건이 있는데, 크기가 반드시 최소 하드웨어 할당 크기는 256바이트의 배수여야 한다는 것이다. 따라 비트연산을 통해 256의 배수를 구해서 돌려줄 것이다.
상수 버퍼에 보낼 데이터 또한 구조체로 만들어준다.
struct ObjectConstants {
XMFLOAT4X4 WorldViewProj = MathHelper::Identity4x4();
}
상수 버퍼의 값을 받을 것은 쉐이더이기에 쉐이더에도 상수버퍼의 값을 받을 수 있도록 구조체를 선언하여 준다.
cbuffer cbPerObject : register(b0) {
float4x4 gWorldViewProj;
}
우선 기본적인 상수버퍼 변수를 선언하여 준다.
ComPtr<ID3D12Resource> mObjectCB = nullptr;
BYTE* mObjectMappedData = nullptr;
UINT mObjectByteSize = 0;
상수버퍼에 WorldViewProj 행렬을 계산하여 넘겨줄 것이기에 계산을 위한 변수들도 추가로 선언하여 준다.
// 월드 / 시야 / 투영 행렬
XMFLOAT4X4 mWorld = MathHelper::Identity4x4();
XMFLOAT4X4 mView = MathHelper::Identity4x4();
XMFLOAT4X4 mProj = MathHelper::Identity4x4();
// 구면 좌표 제어 값
float mTheta = 1.5f * XM_PI;
float mPhi = XM_PIDIV4;
float mRadius = 5.0f;
// 마우스 좌표
POINT mLastMousePos = { 0, 0 };
UINT size = sizeof(ObjectConstants);
mObjectByteSize = (size + 255) & ~255;
D3D12_HEAP_PROPERTIES heapProperty = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
D3D12_RESOURCE_DESC desc = CD3DX12_RESOURCE_DESC::Buffer(mObjectByteSize);
md3dDevice->CreateCommittedResource(
&heapProperty,
D3D12_HEAP_FLAG_NONE,
&desc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&mObjectCB)
);
mObjectCB->Map(0, nullptr, reinterpret_cast<void**>(&mObjectMappedData));
사이즈를 256의 배수로 두기 위해 비트 연산으로 byte 사이즈를 계산해주었다. 상수 버퍼는 프레임마다 갱신되어 작성되어야 하니 Map으로 열어둔 후 UnMap을 해줄 필요는 없다.
투영 행렬 같은 경우는 윈도우의 사이즈가 바뀔 때 새로 갱신할 것이므로 창이 리사이즈되는 시점에 갱신해준다.
// 종횡비 계산 후 투영 행렬 계산
XMMATRIX P = XMMatrixPerspectiveFovLH(0.25f * MathHelper::Pi, AspectRatio(), 1.0f, 1000.0f);
XMStoreFloat4x4(&mProj, P);
월드 행렬과 뷰 행렬은 업데이트에서 계산하여 준다.
// 구면 좌표를 직교 좌표로 변환한다.
float x = mRadius * sinf(mPhi) * cosf(mTheta);
float z = mRadius * sinf(mPhi) * sinf(mTheta);
float y = mRadius * cosf(mPhi);
// 시야 행렬을 구축한다.
XMVECTOR pos = XMVectorSet(x, y, z, 1.0f); XMVECTOR target = XMVectorZero();
XMVECTOR up = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
XMMATRIX view = XMMatrixLookAtLH(pos, target, up);
XMStoreFloat4x4(&mView, view);
XMMATRIX world = XMLoadFloat4x4(&mWorld);
XMMATRIX proj = XMLoadFloat4x4(&mProj);
XMMATRIX worldViewProj = world * view * proj;
이어서 업데이트 시점에 계산한 행렬을 상수 버퍼에 기록하여 준다.
ObjectConstants objConstants;
XMStoreFloat4x4(&objConstants.WorldViewProj, XMMatrixTranspose(worldViewProj));
memcpy(&mObjectMappedData[0], &objConstants, sizeof(ObjectConstants));
루트 시그니처를 이용해 쉐이더에서 받기로 지정한 b0 레지스터에 자원을 바인딩하여 준다.
// 루트 시그니처
ComPtr<ID3D12RootSignature> mRootSignature = nullptr;
CD3DX12_ROOT_PARAMETER param[1];
param[0].InitAsConstantBufferView(0); // 0번 -> b0 -> CBV
쉐이더에서 지정해주었던 b0 레지스터에 상수버퍼를 바인딩 하여준다.
D3D12_ROOT_SIGNATURE_DESC sigDesc = CD3DX12_ROOT_SIGNATURE_DESC(_countof(param), param);
sigDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
ComPtr<ID3DBlob> blobSignature;
ComPtr<ID3DBlob> blobError;
::D3D12SerializeRootSignature(
&sigDesc,
D3D_ROOT_SIGNATURE_VERSION_1,
&blobSignature,
&blobError
);
그 후 루트 시그니처에 대한 서술자를 만들고, 루트 시그니처를 만들어준다.
md3dDevice->CreateRootSignature(
0,
blobSignature->GetBufferPointer(),
blobSignature->GetBufferSize(),
IID_PPV_ARGS(&mRootSignature)
);
지금까지 여러 렌더링 준비 과정을 진행하였지만, 만들어진 객체들을 실제로 사용하기 위해 렌더링 파이프라인에 묶는 작업은 아직 진행하지 않았다.
렌더링 파이프라인의 상태를 제어하는 대부분의 객체는 파이프라인 상태 객체(PSO)라 부르는 집합체를 통해 지정되게 된다.
파이프라인 상태 객체를 만들어주어 렌더링 준비를 마무리하자.
나중에 Transparent세팅을 하게 될 경우 해당 오브젝트는 다른 파이프라인에서 돌아야하기에 PSO가 여러개 필요해진다. 하지만 지금은 기본적인 렌더링만 진행할 것 이기에, 한 개의 PSO만 선언하여도 무방하다.
// 파이프라인 상태 객체
ComPtr<ID3D12PipelineState> mPSO = nullptr;
// 파이프라인 상태를 생성한다.
D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc;
ZeroMemory(&psoDesc, sizeof(D3D12_GRAPHICS_PIPELINE_STATE_DESC));
psoDesc.InputLayout = { mInputLayout.data(), (UINT)mInputLayout.size() };
psoDesc.pRootSignature = mRootSignature.Get();
psoDesc.VS = {
reinterpret_cast<BYTE*>(mvsByteCode->GetBufferPointer()),
mvsByteCode->GetBufferSize()
};
psoDesc.PS = {
reinterpret_cast<BYTE*>(mpsByteCode->GetBufferPointer()),
mpsByteCode->GetBufferSize()
};
psoDesc.RasterizerState = CD3DX12_RASTERIZER_DESC(D3D12_DEFAULT);
psoDesc.BlendState = CD3DX12_BLEND_DESC(D3D12_DEFAULT);
psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
psoDesc.SampleMask = UINT_MAX;
psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
psoDesc.NumRenderTargets = 1;
psoDesc.RTVFormats[0] = mBackBufferFormat;
psoDesc.SampleDesc.Count = m4xMsaaState ? 4 : 1;
psoDesc.SampleDesc.Quality = m4xMsaaState ? (m4xMsaaQuality - 1) : 0;
psoDesc.DSVFormat = mDepthStencilFormat;
ThrowIfFailed(md3dDevice->CreateGraphicsPipelineState(&psoDesc, IID_PPV_ARGS(&mPSO)));
이것저것 적을게 많아, 복잡한 과정처럼 보이지만 지금까지 만들어준 객체들을 등록하고, 세팅해준 포맷들을 넣어주는 간단한 작업이다.
드디어, 모든 렌더링 준비가 끝나 이제 렌더링 코드만 작성해주면 된다.
렌더링을 위해 미리 만들어둔 CommandList에 루트 시그니처와 상수 버퍼 뷰를 설정한 후, 입력 조립기에 정점 정보를 넣어준다.
// to do : Rendering mCommandList->SetPipelineState(mPSO.Get());
// 루트 시그니처와 상수 버퍼 뷰을 설정한다.
mCommandList->SetGraphicsRootSignature(mRootSignature.Get());
D3D12_GPU_VIRTUAL_ADDRESS objCBAddress = mObjectCB->GetGPUVirtualAddress();
mCommandList->SetGraphicsRootConstantBufferView(0, objCBAddress);
// 박스의 정점 정보를 묶어 입력 조립기에 셋팅한다.
mCommandList->IASetVertexBuffers(0, 1, &VertexBufferView);
mCommandList->IASetIndexBuffer(&IndexBufferView);
mCommandList->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
마지막으로 인덱스 정보에 맞게 오브젝트를 렌더링한다.
// 인덱스 정보에 맞게 오브젝트를 렌더링한다.
mCommandList->DrawIndexedInstanced(IndexCount, 1, 0, 0, 0);
모든 작업이 끝난 후 프로젝트를 빌드하면 다음과 같이 상자하나가 성공적으로 렌더링 될 것이다.