Clear 화면에 색이 섞인 삼각형을 추가합니다
앞 글에서 GPU를 고르고 명령 목록과 백버퍼를 준비했습니다. 이번에는 위치와 색을 가진 정점 세 개를 GPU에 보내고, 상수 버퍼의 행렬로 삼각형을 움직여 보겠습니다. 화면의 색이 어떻게 생기는지뿐 아니라 데이터를 저장하는 버퍼 → 그 데이터의 접근 정보 → 셰이더 연결 → Draw 명령을 하나의 흐름으로 읽는 것이 목표입니다.
코드는 개념별 생성·기록 부분을 발췌한 학습 예제입니다. device, Queue, Allocator, SwapChain, 백버퍼 RTV, viewport, scissor는 앞의 초기화에서 준비한 객체를 사용합니다. 각 생성 블록은 초기화 함수로 나누고, 생성한 리소스·뷰·PSO는 프레임과 종료까지 살아 있는 멤버에 보관합니다. 같은 지역 변수명을 한 함수에 반복 선언하는 형태로 붙이지 않습니다.
Rasterization은 삼각형 등의 도형을 화면 샘플로 변환하는 과정입니다. Mesh Shader도 출력한 도형을 래스터라이저로 보낼 수 있으므로 Rasterization의 반대 개념이 아닙니다. Ray Tracing과 함께 사용하는 혼합 렌더링도 가능합니다. 이 글은 IA → VS → Rasterizer → PS → OM 경로를 설명합니다.
Descriptor Heaps
위 그림의 heap을 모두 같은 메모리로 생각하면 혼동하기 쉽습니다. Resource에는 실제 정점·색·행렬 데이터가 있고, Descriptor Heap에는 그 Resource를 어떤 범위와 형식으로 읽거나 쓸지 적은 설명이 있습니다. 주소록과 그 주소에 있는 물건이 다른 것처럼, descriptor 자체에 삼각형 정점이 들어 있는 것은 아닙니다.
CBV/SRV/UAV와 Sampler Heap은 shader-visible로 만들어 GPU가 읽게 할 수 있습니다. RTV/DSV는 CPU handle로 출력 명령에 지정합니다. 이번에는 백버퍼의 RTV heap과 셰이더 상수용 CBV heap을 구별해서 사용합니다.
예전(D3D11)과의 차이점
Direct3D 11 | Direct3D 12 |
뷰(View)를 자동으로 관리 | 뷰를 직접 생성하고 힙에 저장해야 함 |
시스템이 리소스 바인딩 처리 | 개발자가 명시적으로 디스크립터 바인딩 |
ID3D12DescriptorHeap* renderTargetViewHeap;
D3D12_DESCRIPTOR_HEAP_DESC rtvHeapDesc = {};
rtvHeapDesc.NumDescriptors = backbufferCount;
rtvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV;
rtvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_NONE;
ThrowIfFailed(device->CreateDescriptorHeap(
&rtvHeapDesc, IID_PPV_ARGS(&renderTargetViewHeap)));
C++
복사
이 heap에는 백버퍼 수만큼 RTV 자리를 확보합니다. 생성만으로 각 슬롯이 백버퍼를 가리키지는 않습니다. 앞 글의 GetBuffer와 CreateRenderTargetView로 각 자리를 채운 뒤 OMSetRenderTargets에 현재 프레임의 CPU handle을 전달합니다. 앞에서 이미 RTV heap을 만들었다면 그 객체를 재사용합니다.
Root Signature
루트 시그니처는 리소스 유형을 정의합니다. 예를들어 상수버퍼, 구조화버퍼, 샘플러, 텍스처 등등을 나타냅니다.
셰이더(Shader)가 사용할 수 있는 리소스들의 레이아웃(구조와 종류)을 GPU에게 알려주는 계약서 같은 것
“셰이더가 어떤 자원(CBV, SRV, UAV, 샘플러 등)을 쓸 건지, 어디에 배치돼 있을지를 정의”하는 구조
RTV(Render Target View)는 루트 시그니처로 정의하지 않는다.
왜 그런가?루트 시그니처는 GPU의 셰이더 프로그램이 접근할 리소스(SRV, UAV, CBV, Sampler 등) 를 정의하는 "셰이더 바인딩 구조"야.
반면에 RTV는 셰이더에서 직접 접근하지 않아.•
RTV는 렌더 타겟으로 출력되는 대상, 즉 파이프라인의 출력 스테이지(Output Merger, OM) 에서만 사용돼.
•
셰이더는 RTV에 직접 접근하지 않고, 셰이더가 계산한 픽셀 결과를 GPU가 RTV에 쓰는 것이야.
셰이더의 b0와 root parameter 0을 연결합니다
이 예제는 Vertex Shader의 b0 상수 버퍼 하나를 descriptor table로 연결합니다. b0는 HLSL 레지스터 번호이고 root parameter 0은 CPU가 Command List에서 설정할 루트 슬롯 번호입니다. 숫자가 우연히 둘 다 0이지만 같은 번호 체계는 아닙니다.
// 학습 예제는 Root Signature 1.0으로 구성합니다.
// ComPtr와 ThrowIfFailed는 예제 공통 헬퍼입니다.
D3D12_DESCRIPTOR_RANGE range = {};
range.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_CBV;
range.NumDescriptors = 1;
range.BaseShaderRegister = 0;
D3D12_ROOT_PARAMETER parameter = {};
parameter.ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE;
parameter.ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX;
parameter.DescriptorTable = {1, &range};
D3D12_ROOT_SIGNATURE_DESC desc = {};
desc.NumParameters = 1;
desc.pParameters = ¶meter;
desc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
ComPtr<ID3DBlob> signature, error;
HRESULT hr = D3D12SerializeRootSignature(&desc, D3D_ROOT_SIGNATURE_VERSION_1,
signature.GetAddressOf(), error.GetAddressOf());
if (error) OutputDebugStringA(static_cast<const char*>(error->GetBufferPointer()));
ThrowIfFailed(hr);
ID3D12RootSignature* rootSignature = nullptr;
ThrowIfFailed(device->CreateRootSignature(0, signature->GetBufferPointer(),
signature->GetBufferSize(), IID_PPV_ARGS(&rootSignature)));
// 1.1 구조를 사용하면 지원 버전 확인과 1.0 변환/직렬화 경로도 함께 필요합니다.
C++
복사
range는 b0부터 CBV 한 개를 읽겠다는 범위입니다. parameter는 그 범위를 담은 table 하나이고, ShaderVisibility=VERTEX로 VS에서 사용합니다. 루트 시그니처를 직렬화하고 Device 객체로 만든 뒤 PSO와 Draw 양쪽에서 연결합니다.
여기에는 아직 어떤 상수 버퍼의 주소도 들어 있지 않습니다. Root Signature는 연결 모양을 정하고, 뒤에서 만든 CBV descriptor와 SetGraphicsRootDescriptorTable 호출이 실제 데이터를 선택합니다. RTV는 셰이더 입력 table이 아니라 출력 타깃이므로 이 루트 파라미터에 넣지 않습니다.
Heaps(Resource)
Resource Heap은 버퍼·텍스처가 사용할 메모리입니다. DEFAULT는 GPU 작업 중심, UPLOAD는 CPU에서 쓰기, READBACK은 GPU 복사 결과를 CPU가 읽는 용도로 사용합니다. 아래 Committed Resource는 별도 명시적 Heap 생성 없이 리소스와 메모리를 함께 확보하는 방식입니다.
// d3dx12.h 헬퍼를 사용하는 버퍼 업로드 예시입니다.
// sourceData는 비어 있지 않은 std::vector<uint8_t>입니다.
if (sourceData.empty()) throw std::runtime_error("Empty upload");
const auto uploadProps = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
const auto uploadDesc = CD3DX12_RESOURCE_DESC::Buffer(sourceData.size());
ComPtr<ID3D12Resource> uploadBuffer;
ThrowIfFailed(device->CreateCommittedResource(&uploadProps, D3D12_HEAP_FLAG_NONE,
&uploadDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr,
IID_PPV_ARGS(&uploadBuffer)));
void* mapped = nullptr;
const D3D12_RANGE noCpuRead{0, 0};
ThrowIfFailed(uploadBuffer->Map(0, &noCpuRead, &mapped));
std::memcpy(mapped, sourceData.data(), sourceData.size());
const D3D12_RANGE written{0, sourceData.size()};
uploadBuffer->Unmap(0, &written);
// 목적 DEFAULT 버퍼에 CopyBufferRegion을 기록한 경우 GPU 복사 완료까지 유지합니다.
// 텍스처는 GetCopyableFootprints와 RowPitch를 사용하며 이 memcpy만으로 충분하지 않습니다.
const auto readbackProps = CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_READBACK);
ComPtr<ID3D12Resource> readbackBuffer;
ThrowIfFailed(device->CreateCommittedResource(&readbackProps, D3D12_HEAP_FLAG_NONE,
&uploadDesc, D3D12_RESOURCE_STATE_COPY_DEST, nullptr,
IID_PPV_ARGS(&readbackBuffer)));
// GPU 복사 명령 제출 → 해당 Fence 완료 → Map으로 읽기 순서입니다.
// Map이 GPU 복사를 자동으로 기다려 주는 것은 아닙니다.
C++
복사
UPLOAD는 CPU가 데이터를 써서 GPU에 전달할 때, READBACK은 GPU가 복사한 결과를 CPU가 읽을 때 사용하는 메모리 방향입니다. DEFAULT는 GPU 작업용 리소스에 주로 사용합니다. 작은 입문 삼각형은 UPLOAD 버퍼에서 직접 읽게 해 복사 단계를 줄여 이해할 수 있지만, 큰 정적 메시의 최종 배치까지 항상 UPLOAD로 정하라는 뜻은 아닙니다.
Map의 {0,0}은 CPU가 읽지 않겠다는 범위 정보입니다. 반환된 주소에 memcpy로 쓰고 Unmap의 written 범위로 기록 구간을 알립니다. Map/Unmap은 GPU 완료를 기다리는 호출이 아닙니다. CPU가 데이터를 덮어쓰기 전에 이전 GPU 읽기가 끝났는지는 Fence로 별도 확인합니다.
자체적으로 관리하는 GPU Heap 을 생성하면 메모리를 더 효율적으로 활용할수도 있습니다. 다만 메모리 관리라는것이 생각보다 번거로운 작업이기 떄문에 라이브러리를 활용하는걸 추천합니다.
Vertex Buffer
정점 버퍼는 정점셰이더에서 사용 할수 있는 정점 데이터를 저장해둔 공간입니다. 버텍스 버퍼, 인덱스 버퍼, 상수 버퍼 등 모든 버퍼 오브젝트는 Directx12에서 ID3D12Resource 입니다.
정점 한 개는 24바이트입니다
위치 float3은 12바이트, 색 float3도 12바이트이므로 Vertex의 stride는 24입니다. 정점 세 개의 데이터는 72바이트입니다. 아래의 Resource는 그 72바이트를 보관하고 VertexBufferView는 시작 GPU 주소·전체 바이트 수·한 정점 간격을 알려 줍니다.
// 💾 Declare Data
struct Vertex
{
float position[3];
float color[3];
};
Vertex vertexBufferData[3] = {{{1.0f, -1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}},
{{-1.0f, -1.0f, 0.0f}, {0.0f, 1.0f, 0.0f}},
{{0.0f, 1.0f, 0.0f}, {0.0f, 0.0f, 1.0f}}};
// 👋 Declare Handles
ID3D12Resource* vertexBuffer;
D3D12_VERTEX_BUFFER_VIEW vertexBufferView;
const UINT vertexBufferSize = sizeof(vertexBufferData);
D3D12_HEAP_PROPERTIES heapProps;
heapProps.Type = D3D12_HEAP_TYPE_UPLOAD;
heapProps.CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN;
heapProps.MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN;
heapProps.CreationNodeMask = 1;
heapProps.VisibleNodeMask = 1;
D3D12_RESOURCE_DESC vertexBufferResourceDesc;
vertexBufferResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
vertexBufferResourceDesc.Alignment = 0;
vertexBufferResourceDesc.Width = vertexBufferSize;
vertexBufferResourceDesc.Height = 1;
vertexBufferResourceDesc.DepthOrArraySize = 1;
vertexBufferResourceDesc.MipLevels = 1;
vertexBufferResourceDesc.Format = DXGI_FORMAT_UNKNOWN;
vertexBufferResourceDesc.SampleDesc.Count = 1;
vertexBufferResourceDesc.SampleDesc.Quality = 0;
vertexBufferResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
vertexBufferResourceDesc.Flags = D3D12_RESOURCE_FLAG_NONE;
ThrowIfFailed(device->CreateCommittedResource(
&heapProps, D3D12_HEAP_FLAG_NONE, &vertexBufferResourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&vertexBuffer)));
// 📄 Copy the triangle data to the vertex buffer.
UINT8* pVertexDataBegin;
// 🙈 We do not intend to read from this resource on the CPU.
D3D12_RANGE readRange;
readRange.Begin = 0;
readRange.End = 0;
ThrowIfFailed(vertexBuffer->Map(0, &readRange,
reinterpret_cast<void**>(&pVertexDataBegin)));
memcpy(pVertexDataBegin, vertexBufferData, sizeof(vertexBufferData));
vertexBuffer->Unmap(0, nullptr);
// 👀 Initialize the vertex buffer view.
vertexBufferView.BufferLocation = vertexBuffer->GetGPUVirtualAddress();
vertexBufferView.StrideInBytes = sizeof(Vertex);
vertexBufferView.SizeInBytes = vertexBufferSize;
C++
복사
정점 배열의 첫 물체는 화면 오른쪽 아래에 빨강, 왼쪽 아래에 초록, 위쪽에 파랑을 배치합니다. 지금은 World·View·Projection을 단위 행렬로 시작하므로 입력 좌표가 바로 클립 공간에 도달합니다. VertexBufferView는 descriptor heap의 슬롯이 아니라 IASetVertexBuffers에 값으로 넘기는 뷰 구조체입니다.
Index Buffer
인덱스 버퍼는 그려지는 메쉬의 정점의 순서가 담겨져 있습니다. 정점을 그리는 순서라고 생각하면 됩니다.
인덱스는 정점 데이터가 아니라 참조 순서입니다
uint32_t 세 개 [0,1,2]는 앞 버퍼의 첫째·둘째·셋째 정점으로 삼각형 하나를 만들라는 뜻입니다. 각 값은 좌표가 아니라 정점 번호입니다. 이번 삼각형 하나에서는 메모리 절약 효과가 없지만, 이후 두 삼각형이 정점을 공유하는 메시에서도 같은 Indexed Draw 경로를 사용하려고 연결합니다.
// 💾 Declare Data
uint32_t indexBufferData[3] = {0, 1, 2};
// 👋 Declare Handles
ID3D12Resource* indexBuffer;
D3D12_INDEX_BUFFER_VIEW indexBufferView;
const UINT indexBufferSize = sizeof(indexBufferData);
D3D12_HEAP_PROPERTIES heapProps;
heapProps.Type = D3D12_HEAP_TYPE_UPLOAD;
heapProps.CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN;
heapProps.MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN;
heapProps.CreationNodeMask = 1;
heapProps.VisibleNodeMask = 1;
D3D12_RESOURCE_DESC vertexBufferResourceDesc;
vertexBufferResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
vertexBufferResourceDesc.Alignment = 0;
vertexBufferResourceDesc.Width = indexBufferSize;
vertexBufferResourceDesc.Height = 1;
vertexBufferResourceDesc.DepthOrArraySize = 1;
vertexBufferResourceDesc.MipLevels = 1;
vertexBufferResourceDesc.Format = DXGI_FORMAT_UNKNOWN;
vertexBufferResourceDesc.SampleDesc.Count = 1;
vertexBufferResourceDesc.SampleDesc.Quality = 0;
vertexBufferResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
vertexBufferResourceDesc.Flags = D3D12_RESOURCE_FLAG_NONE;
ThrowIfFailed(device->CreateCommittedResource(
&heapProps, D3D12_HEAP_FLAG_NONE, &vertexBufferResourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&indexBuffer)));
// 📄 Copy data to DirectX 12 driver memory:
UINT8* pVertexDataBegin;
// 🙈 We do not intend to read from this resource on the CPU.
D3D12_RANGE readRange;
readRange.Begin = 0;
readRange.End = 0;
ThrowIfFailed(indexBuffer->Map(0, &readRange,
reinterpret_cast<void**>(&pVertexDataBegin)));
memcpy(pVertexDataBegin, indexBufferData, sizeof(indexBufferData));
indexBuffer->Unmap(0, nullptr);
// 👀 Initialize the index buffer view.
indexBufferView.BufferLocation = indexBuffer->GetGPUVirtualAddress();
indexBufferView.Format = DXGI_FORMAT_R32_UINT;
indexBufferView.SizeInBytes = indexBufferSize;
C++
복사
Format=R32_UINT는 GPU가 인덱스 하나를 4바이트 unsigned 정수로 읽게 합니다. uint16_t 배열로 바꾼다면 Format도 R16_UINT로 맞춰야 합니다. VertexBuffer의 stride와 달리 IndexBufferView는 Format으로 요소 크기를 결정합니다. DrawIndexedInstanced의 첫 인자는 바이트 수 12가 아니라 인덱스 개수 3입니다.
Constant Buffer
상수버퍼는 CPU → GPU 쉐이더 단계로 전송할수 있는 버퍼입니다. 일반적으론 행렬 정보나, 색상, 위치 등등 프로그래머가 원하는 값을 전달 할 수 있습니다.
192바이트의 행렬을 256바이트 자리로 배정합니다
4×4 float 행렬은 64바이트입니다. Projection, Model, View 세 개는 192바이트이지만 DX12 CBV의 크기·주소는 256바이트 정렬 조건에 맞춰야 합니다. (192+255)&~255는 256이 됩니다. 나중에 다음 Draw의 상수를 놓는다면 같은 주소에 덮는 대신 다음 256바이트 자리로 이동할 수 있습니다.
// 💾 Declare Data
struct
{
DirectX::XMFLOAT4X4 projectionMatrix;
DirectX::XMFLOAT4X4 modelMatrix;
DirectX::XMFLOAT4X4 viewMatrix;
} cbVS = {};
// 아래 HLSL row_major + 행벡터 mul과 맞춥니다. GLM 열벡터 예제와 혼합하지 않습니다.
DirectX::XMStoreFloat4x4(&cbVS.projectionMatrix, DirectX::XMMatrixIdentity());
DirectX::XMStoreFloat4x4(&cbVS.modelMatrix, DirectX::XMMatrixIdentity());
DirectX::XMStoreFloat4x4(&cbVS.viewMatrix, DirectX::XMMatrixIdentity());
// 👋 Declare Handles
ID3D12Resource* constantBuffer;
ID3D12DescriptorHeap* constantBufferHeap;
UINT8* mappedConstantBuffer;
// 🧊 Create the Constant Buffer
D3D12_HEAP_PROPERTIES heapProps;
heapProps.Type = D3D12_HEAP_TYPE_UPLOAD;
heapProps.CPUPageProperty = D3D12_CPU_PAGE_PROPERTY_UNKNOWN;
heapProps.MemoryPoolPreference = D3D12_MEMORY_POOL_UNKNOWN;
heapProps.CreationNodeMask = 1;
heapProps.VisibleNodeMask = 1;
D3D12_DESCRIPTOR_HEAP_DESC heapDesc = {};
heapDesc.NumDescriptors = 1;
heapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
heapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
ThrowIfFailed(device->CreateDescriptorHeap(&heapDesc,
IID_PPV_ARGS(&constantBufferHeap)));
D3D12_RESOURCE_DESC cbResourceDesc;
cbResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER;
cbResourceDesc.Alignment = 0;
cbResourceDesc.Width = (sizeof(cbVS) + 255) & ~255;
cbResourceDesc.Height = 1;
cbResourceDesc.DepthOrArraySize = 1;
cbResourceDesc.MipLevels = 1;
cbResourceDesc.Format = DXGI_FORMAT_UNKNOWN;
cbResourceDesc.SampleDesc.Count = 1;
cbResourceDesc.SampleDesc.Quality = 0;
cbResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR;
cbResourceDesc.Flags = D3D12_RESOURCE_FLAG_NONE;
ThrowIfFailed(device->CreateCommittedResource(
&heapProps, D3D12_HEAP_FLAG_NONE, &cbResourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&constantBuffer)));
constantBufferHeap->SetName(L"Constant Buffer Upload Resource Heap");
// 👓 Create our Constant Buffer View
D3D12_CONSTANT_BUFFER_VIEW_DESC cbvDesc = {};
cbvDesc.BufferLocation = constantBuffer->GetGPUVirtualAddress();
cbvDesc.SizeInBytes =
(sizeof(cbVS) + 255) & ~255; // CB size is required to be 256-byte aligned.
D3D12_CPU_DESCRIPTOR_HANDLE
cbvHandle(constantBufferHeap->GetCPUDescriptorHandleForHeapStart());
cbvHandle.ptr = cbvHandle.ptr + device->GetDescriptorHandleIncrementSize(
D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV) *
0;
device->CreateConstantBufferView(&cbvDesc, cbvHandle);
// 🙈 We do not intend to read from this resource on the CPU.
D3D12_RANGE readRange;
readRange.Begin = 0;
readRange.End = 0;
ThrowIfFailed(constantBuffer->Map(
0, &readRange, reinterpret_cast<void**>(&mappedConstantBuffer)));
memcpy(mappedConstantBuffer, &cbVS, sizeof(cbVS));
const D3D12_RANGE writtenRange{0, sizeof(cbVS)};
constantBuffer->Unmap(0, &writtenRange);
C++
복사
C++ 구조체의 순서는 Projection → Model → View이고 바로 아래 HLSL도 같은 순서입니다. CPU 메모리를 복사하는 코드와 셰이더가 해석하는 구조가 다르면 값이 올바르게 연결되지 않습니다. 처음에는 세 행렬을 Identity로 채워 정점 데이터 자체가 보이는지 확인합니다.
CBV는 constantBuffer의 GPU 주소와 256바이트 크기를 가리키고, shader-visible constantBufferHeap의 첫 슬롯에 기록됩니다. CPU handle은 CreateConstantBufferView의 기록 위치이고 GPU handle은 Draw에서 table 시작점을 지정할 때 사용합니다. HLSL의 16바이트 패킹과 DX12 CBV의 256바이트 정렬은 서로 다른 규칙입니다.
Vertex Shader
정점 쉐이더는 정점(vertex)별로 실행되며 주어진 오브젝트를 변형하고 움직이게 하는 작업들을 주로 실행합니다.
// HLSL: Vertex Shader
cbuffer cb : register(b0)
{
row_major float4x4 projectionMatrix : packoffset(c0);
row_major float4x4 modelMatrix : packoffset(c4);
row_major float4x4 viewMatrix : packoffset(c8);
};
struct VertexInput
{
float3 inPos : POSITION;
float3 inColor : COLOR;
};
struct VertexOutput
{
float3 color : COLOR;
float4 position : SV_Position;
};
VertexOutput main(VertexInput vertexInput)
{
float3 inColor = vertexInput.inColor;
float3 inPos = vertexInput.inPos;
float3 outColor = inColor;
float4 position = mul(float4(inPos, 1.0f), mul(modelMatrix, mul(viewMatrix, projectionMatrix)));
VertexOutput output;
output.position = position;
output.color = outColor;
return output;
}
C++
복사
POSITION과 COLOR는 C++ 멤버 이름을 검색하는 문자열이 아닙니다. PSO의 InputLayout이 byte offset 0과 12를 해당 시맨틱으로 연결합니다. VS는 위치를 Model → View → Projection 순서로 변환하고 SV_Position으로 출력합니다. row_major와 행벡터 mul을 맞췄으므로 위 C++ 행렬을 전치하지 않습니다.
색은 VS에서 그대로 넘깁니다. 삼각형 내부의 색이 자연스럽게 섞이는 과정은 이후 래스터라이저의 보간에서 일어납니다. VS가 모든 화면 픽셀을 하나씩 칠하는 구조가 아닙니다.
HLSL 컴파일은 D3D12 Device의 기능이 아닙니다. 아래 예제는 D3DCompiler의 D3DCompileFromFile을 사용합니다. Mesh Shader 등 Shader Model 6 기능은 뒤 문서에서 DXC를 사용합니다.
HLSL 파일을 바이트코드로 준비합니다
아래는 Shader 컴파일 함수 내부입니다. filename은 HLSL 경로, entryPoint는 이 글의 셰이더 함수 이름 main, target은 VS에서 vs_5_0, PS에서 ps_5_0을 전달합니다. 반환된 blob 두 개를 vertexShaderBlob과 pixelShaderBlob으로 보관해 PSO에 넣습니다.
UINT compileFlags = 0;
#if defined(_DEBUG)
compileFlags |= D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION;
#endif
ComPtr<ID3DBlob> shaderBlob;
ComPtr<ID3DBlob> errorBlob;
HRESULT hr = D3DCompileFromFile(
filename.c_str(), // 파일 경로
nullptr, // 매크로 정의
D3D_COMPILE_STANDARD_FILE_INCLUDE, // include 허용
entryPoint.c_str(), // 엔트리 포인트 (예: "VSMain")
target.c_str(), // 타겟 프로파일 (예: "vs_5_0")
compileFlags, // 컴파일 플래그
0, // 효과 컴파일 없음
&shaderBlob,
&errorBlob
);
if (FAILED(hr))
{
if (errorBlob)
{
std::cerr << "Shader Compile Error: " << (char*)errorBlob->GetBufferPointer() << std::endl;
}
throw std::runtime_error("Shader compilation failed.");
}
return shaderBlob;
C++
복사
컴파일러는 Device와 별개입니다. 실패하면 errorBlob의 메시지를 먼저 확인하고 PSO 생성으로 진행하지 않습니다. shader model 6 기능을 사용하는 Mesh Shader 예제는 뒤에서 DXC를 사용하지만, 이 VS/PS 입문 예제는 D3DCompiler 경로로 설명합니다.
Pixel Shader
픽쉘 쉐이더는 모니터에 출력될 픽셀들을 연산하는 쉐이더 입니다.
// HLSL: Pixel Shader
struct PixelInput
{
float3 color : COLOR;
};
struct PixelOutput
{
float4 attachment0 : SV_Target0;
};
PixelOutput main(PixelInput pixelInput)
{
float3 inColor = pixelInput.color;
PixelOutput output;
output.attachment0 = float4(inColor, 1.0f);
return output;
}
C++
복사
PS는 보간된 RGB를 받아 알파 1을 붙여 반환합니다. SV_Target0은 출력 슬롯 0을 뜻합니다. Root Signature의 parameter 0이나 HLSL b0와 무관합니다. 이 예제의 PS는 별도 리소스를 읽지 않고 VS에서 전달된 색만 사용합니다.
Pipeline State
레스터라이제이션 파이프 라인입니다. 각각의 실행단계가 어떤 순서로 실행되는지 볼수 있습니다.
11에서는 context 에 파이프라인을 세팅해주면 끝났습니다. 하지만 12에서는 파이프라인 객체(PSO)를 만들어서 사용해야 합니다.
DX12 파이프라인 설계 시 주의할 점
1.
셰이더·Blend·Depth 등 PSO에 포함된 상태가 달라지면 다른 PSO가 필요합니다. viewport, scissor, descriptor 바인딩처럼 Command List에서 설정하는 상태는 별도입니다.
(ex: 픽셀 셰이더만 바뀌어도 PSO를 다시 생성해야 함)
2.
루트 시그니처는 PSO에 포함되므로 셰이더가 사용하는 모든 리소스를 고려해서 설계해야 합니다.
3.
자주 쓰는 PSO는 미리 만들고 캐시해 재사용합니다. 모든 조합을 초기화 때 만들 필요는 없으며, 필요한 조합만 생성하거나 비동기 준비하는 정책도 가능합니다.
이 정점·셰이더·출력 형식을 한 조합으로 만듭니다
PSO에는 InputLayout, Root Signature, VS/PS, Rasterizer, Blend, Depth, 출력 포맷과 샘플 수를 함께 넣습니다. 이 예제는 컬링·깊이를 꺼서 정점 순서나 깊이 설정 때문에 삼각형이 사라지는 변수를 줄입니다. RTV 포맷과 SampleDesc는 실제 백버퍼와 맞아야 합니다.
// 👋 Declare handles
ID3D12PipelineState* pipelineState;
// ⚗️ Define the Graphics Pipeline
D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc = {};
// 🔣 Input Assembly
D3D12_INPUT_ELEMENT_DESC inputElementDescs[] = {
{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0},
{"COLOR", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12,
D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0}};
psoDesc.InputLayout = {inputElementDescs, _countof(inputElementDescs)};
// 🦄 Resources
psoDesc.pRootSignature = rootSignature;
// 🔺 Vertex Shader
D3D12_SHADER_BYTECODE vsBytecode;
vsBytecode.pShaderBytecode = vertexShaderBlob->GetBufferPointer();
vsBytecode.BytecodeLength = vertexShaderBlob->GetBufferSize();
psoDesc.VS = vsBytecode;
// 🖌️ Pixel Shader
D3D12_SHADER_BYTECODE psBytecode;
psBytecode.pShaderBytecode = pixelShaderBlob->GetBufferPointer();
psBytecode.BytecodeLength = pixelShaderBlob->GetBufferSize();
psoDesc.PS = psBytecode;
// 🟨 Rasterization
D3D12_RASTERIZER_DESC rasterDesc;
rasterDesc.FillMode = D3D12_FILL_MODE_SOLID;
rasterDesc.CullMode = D3D12_CULL_MODE_NONE;
rasterDesc.FrontCounterClockwise = FALSE;
rasterDesc.DepthBias = D3D12_DEFAULT_DEPTH_BIAS;
rasterDesc.DepthBiasClamp = D3D12_DEFAULT_DEPTH_BIAS_CLAMP;
rasterDesc.SlopeScaledDepthBias = D3D12_DEFAULT_SLOPE_SCALED_DEPTH_BIAS;
rasterDesc.DepthClipEnable = TRUE;
rasterDesc.MultisampleEnable = FALSE;
rasterDesc.AntialiasedLineEnable = FALSE;
rasterDesc.ForcedSampleCount = 0;
rasterDesc.ConservativeRaster = D3D12_CONSERVATIVE_RASTERIZATION_MODE_OFF;
psoDesc.RasterizerState = rasterDesc;
psoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
// 🌀 Color/Blend
D3D12_BLEND_DESC blendDesc;
blendDesc.AlphaToCoverageEnable = FALSE;
blendDesc.IndependentBlendEnable = FALSE;
const D3D12_RENDER_TARGET_BLEND_DESC defaultRenderTargetBlendDesc = {
FALSE,
FALSE,
D3D12_BLEND_ONE,
D3D12_BLEND_ZERO,
D3D12_BLEND_OP_ADD,
D3D12_BLEND_ONE,
D3D12_BLEND_ZERO,
D3D12_BLEND_OP_ADD,
D3D12_LOGIC_OP_NOOP,
D3D12_COLOR_WRITE_ENABLE_ALL,
};
for (UINT i = 0; i < D3D12_SIMULTANEOUS_RENDER_TARGET_COUNT; ++i)
blendDesc.RenderTarget[i] = defaultRenderTargetBlendDesc;
psoDesc.BlendState = blendDesc;
// 🌑 Depth/Stencil State
psoDesc.DepthStencilState = CD3DX12_DEPTH_STENCIL_DESC(D3D12_DEFAULT);
psoDesc.DepthStencilState.DepthEnable = FALSE;
psoDesc.DepthStencilState.StencilEnable = FALSE;
psoDesc.SampleMask = UINT_MAX;
// 🖼️ Output
psoDesc.NumRenderTargets = 1;
psoDesc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
psoDesc.SampleDesc.Count = 1;
// 🌟 Create the raster pipeline state
ThrowIfFailed(device->CreateGraphicsPipelineState(
&psoDesc, IID_PPV_ARGS(&pipelineState)));
// 실패를 출력만 하고 Draw로 진행하지 않습니다.
C++
복사
POSITION은 offset 0, COLOR는 offset 12이고 둘 다 float3입니다. 이는 앞 Vertex의 24바이트 구조와 일치합니다. PrimitiveTopologyType=TRIANGLE은 PSO의 큰 종류이고, 실제 TRIANGLELIST 선택은 Command List에서 합니다. viewport·scissor·리소스 table 시작점도 Draw마다 설정할 수 있는 상태이며 PSO를 만들었다고 자동으로 정해지지 않습니다.
Encoding Commands
Draw 를 호출하려면 명령을 작성할 공간이 필요합니다. Command List는 베리어, 루트 시그니처, 파이프라인 커맨드등 여러명령을 담을 수 있는 객체입니다.
// 앞에서 생성한 유효한 commandAllocator와 pipelineState를 사용합니다.
ID3D12GraphicsCommandList* commandList = nullptr;
ThrowIfFailed(device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT,
commandAllocator, pipelineState, IID_PPV_ARGS(&commandList)));
// 이 예제는 다음 setupCommands에서 Reset하므로 최초 열린 목록을 먼저 닫습니다.
ThrowIfFailed(commandList->Close());
C++
복사
CreateCommandList는 열린 기록 상태로 생성하므로 프레임 함수에서 Reset할 계획이라면 먼저 Close합니다. 앞 초기화에서 만든 List를 사용할 때는 다시 생성하지 않고 그 객체에 같은 초기 Close 조건을 적용합니다. Allocator와 List를 매 프레임 새로 생성하는 예제가 아닙니다.
주소를 연결한 뒤 실제 Draw를 기록합니다
이 블록은 setupCommands 함수의 본문입니다. surfaceSize는 앞 초기화의 scissor rect, rtvDescriptorSize는 RTV heap의 increment size입니다. 프레임 시작 전에 이전 GPU 사용이 끝났다는 조건을 만족한 후 실행합니다.
// setupCommands() 함수 내부 발췌입니다.
// 이전 GPU 실행 완료를 Fence로 확인한 후 Allocator를 재사용합니다.
ThrowIfFailed(commandAllocator->Reset());
// 🖌️ Begin using the Raster Graphics Pipeline
ThrowIfFailed(commandList->Reset(commandAllocator, pipelineState));
// 🔳 Setup Resources
commandList->SetGraphicsRootSignature(rootSignature);
ID3D12DescriptorHeap* pDescriptorHeaps[] = {constantBufferHeap};
commandList->SetDescriptorHeaps(_countof(pDescriptorHeaps), pDescriptorHeaps);
D3D12_GPU_DESCRIPTOR_HANDLE
cbvHandle(constantBufferHeap->GetGPUDescriptorHandleForHeapStart());
commandList->SetGraphicsRootDescriptorTable(0, cbvHandle);
// 🖼️ Indicate that the back buffer will be used as a render target.
D3D12_RESOURCE_BARRIER renderTargetBarrier;
renderTargetBarrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
renderTargetBarrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
renderTargetBarrier.Transition.pResource = renderTargets[frameIndex];
renderTargetBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
renderTargetBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
renderTargetBarrier.Transition.Subresource =
D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
commandList->ResourceBarrier(1, &renderTargetBarrier);
D3D12_CPU_DESCRIPTOR_HANDLE
rtvHandle(renderTargetViewHeap->GetCPUDescriptorHandleForHeapStart());
rtvHandle.ptr = rtvHandle.ptr + (frameIndex * rtvDescriptorSize);
commandList->OMSetRenderTargets(1, &rtvHandle, FALSE, nullptr);
// 🎥 Record raster commands.
const float clearColor[] = {0.2f, 0.2f, 0.2f, 1.0f};
commandList->RSSetViewports(1, &viewport);
commandList->RSSetScissorRects(1, &surfaceSize);
commandList->ClearRenderTargetView(rtvHandle, clearColor, 0, nullptr);
commandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
commandList->IASetVertexBuffers(0, 1, &vertexBufferView);
commandList->IASetIndexBuffer(&indexBufferView);
commandList->DrawIndexedInstanced(3, 1, 0, 0, 0);
// 🖼️ Indicate that the back buffer will now be used to present.
D3D12_RESOURCE_BARRIER presentBarrier;
presentBarrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
presentBarrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE;
presentBarrier.Transition.pResource = renderTargets[frameIndex];
presentBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
presentBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
presentBarrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
commandList->ResourceBarrier(1, &presentBarrier);
ThrowIfFailed(commandList->Close());
C++
복사
SetDescriptorHeaps는 GPU가 접근할 heap을 고르고, SetGraphicsRootDescriptorTable(0, cbvHandle)은 root parameter 0의 시작 위치를 정합니다. Root Signature에 table을 선언하는 것, heap에 CBV를 만드는 것, Draw에서 시작 handle을 설정하는 것이 이 지점에서 만납니다.
그다음 백버퍼를 쓰기 상태로 전환하고 RTV·viewport·scissor를 지정합니다. VB/IB와 TRIANGLELIST를 설정한 후 인덱스 세 개로 인스턴스 하나를 그립니다. 마지막에 백버퍼를 PRESENT로 되돌리고 Close해야 Queue에 제출할 수 있습니다. Clear만 보인다면 먼저 PSO 생성 성공과 이 Draw 직전의 바인딩을 확인합니다.
Render
렌더링은 상수버퍼 데이터를 변경하고, 실행할 command list들을 제출하고, win32에서 출력가능하도록 스왑체인을 사용하여 화면에 그려주면 됩니다.
Identity로 확인한 다음 Z축 회전을 넣습니다
render의 첫 실행은 세 행렬을 Identity로 둔 결과부터 확인해도 됩니다. 이어 아래처럼 Model을 Z축 회전으로 바꾸면 삼각형이 화면 평면에서 회전합니다. View·Projection을 단위 행렬로 둔 입문 예제라 Y축으로 회전시키면 일부 정점이 z<0 클립 영역으로 들어갈 수 있어, 처음 실험은 Z축을 사용합니다.
// 단순한 한 프레임 대기 예제: GPU/CPU 병렬성을 높이는 최종 구현은 아닙니다.
// fence(ID3D12Fence*), fenceEvent, fenceValue=1은 초기화 단계에서 준비합니다.
void render()
{
static auto previous = std::chrono::steady_clock::now();
static float angle = 0.0f;
const auto now = std::chrono::steady_clock::now();
angle += std::chrono::duration<float>(now - previous).count();
previous = now;
DirectX::XMStoreFloat4x4(&cbVS.modelMatrix, DirectX::XMMatrixRotationZ(angle));
const D3D12_RANGE noRead{0, 0};
ThrowIfFailed(constantBuffer->Map(0, &noRead,
reinterpret_cast<void**>(&mappedConstantBuffer)));
std::memcpy(mappedConstantBuffer, &cbVS, sizeof(cbVS));
const D3D12_RANGE written{0, sizeof(cbVS)};
constantBuffer->Unmap(0, &written);
setupCommands(); // 앞 절의 기록 코드를 이 함수에 둡니다.
ID3D12CommandList* lists[] = {commandList};
commandQueue->ExecuteCommandLists(1, lists);
ThrowIfFailed(swapchain->Present(1, 0));
const UINT64 submittedValue = fenceValue++;
ThrowIfFailed(commandQueue->Signal(fence, submittedValue));
if (fence->GetCompletedValue() < submittedValue)
{
ThrowIfFailed(fence->SetEventOnCompletion(submittedValue, fenceEvent));
if (WaitForSingleObject(fenceEvent, INFINITE) != WAIT_OBJECT_0)
throw std::runtime_error("Fence wait failed");
}
frameIndex = swapchain->GetCurrentBackBufferIndex();
}
// 매 호출 끝의 대기가 다음 호출의 CB 갱신/Allocator Reset을 보호합니다.
// 대기를 제거하려면 프레임 슬롯별 메모리와 Fence 관리가 먼저 필요합니다.
C++
복사
angle은 프레임마다 고정량을 더하는 대신 실제 경과 초를 더하므로 약 1 radian/초로 증가합니다. 이 프레임 끝에서 GPU 완료를 기다리기 때문에 다음 호출의 상수 버퍼 덮어쓰기와 Allocator Reset이 보호됩니다. 지금은 원리를 보기 쉬운 직렬 실행이며, 이 대기를 없애려면 프레임 슬롯별 업로드 영역과 Fence부터 분리해야 합니다.
Destroy Handles
마지막으로 렌더링에 사용된 모든객체들의 메모리를 해제해주면 됩니다.
ComPtr는 CPU 측 COM 소유권을 관리합니다. 마지막 참조를 놓기 전에 GPU 사용이 끝났는지는 Fence로 별도 확인해야 합니다. raw pointer 예제는 GPU 완료 후 각 소유 참조를 Release하고 이벤트 핸들을 CloseHandle해야 합니다.
화면에서 데이터의 연결을 확인합니다
처음에는 세 정점의 빨강·초록·파랑이 삼각형 내부에서 보간되는지 봅니다. Color만 바꾸면 모양은 유지되고 색만 달라져야 합니다. Model을 Z축 회전으로 바꾸면 모양 전체가 함께 돌아갑니다. CBV의 크기·table 연결을 잘못하면 행렬 변경이 보이지 않거나 잘못된 위치가 나오므로 정점 데이터와 상수 데이터의 경로를 나눠 확인합니다.
이 글은 CBV descriptor table 하나로 관계를 배우는 예제입니다. 현재 YamYam Transform 구현은 root CBV와 Draw별 256바이트 구간을 사용합니다. 개념은 같지만 바인딩 API가 다릅니다. 다음 구현에서는 왜 여러 Draw·여러 프레임·Scene/Game 카메라에 각기 다른 상수 데이터가 필요한지 실제 엔진 코드로 연결합니다.


















