D3D12 · Vulkan · OpenGL · Metal · WebGPU 개념 매핑 — 한 줄 한 줄 직접 구현
이 페이지는 "D3D11에서 익힌 개념이 다른 API로 얼마나 그대로 이식되는가"를 한 항목씩 뜯어봅니다.
Vertex Buffer·Pipeline·Command·Descriptor·Barrier·Present — 여섯 개의 축을 잡고, 각 API가 같은 개념을
어떤 이름·어떤 코드로 부르는지 1:1로 대응시킵니다. 개념 자체는 이름만 바뀔 뿐 대부분 그대로 이어지므로,
D3D11 코드 한 줄을 붙잡고 "이게 D3D12에서는 무슨 줄이 되는가"를 따라가면 저수준 API의 장벽이 훨씬 낮아집니다.
규약은 이 책 전체와 동일합니다: 행벡터 v' = v · M(HLSL mul(v, M)), 행 우선 저장,
왼손 좌표계, 클립 z 범위 [0,1], 텍스처 V축 위→아래.
0. 큰 그림 — API는 "명시성의 스펙트럼"이다
다섯 개의 API를 외우기 전에, 이들을 하나의 축 위에 놓는 감각을 먼저 잡는 게 중요합니다. 그 축은 "드라이버가 얼마나 대신 결정해 주는가 ↔ 개발자가 얼마나 직접 결정하는가"입니다. 왼쪽 끝은 편하지만 느리고 불투명하며, 오른쪽 끝은 빠르고 투명하지만 코드가 폭발합니다.
- OpenGL — 가장 암묵적. 전역 상태 머신에 플래그를 하나씩 토글하고, 메모리·동기화는 드라이버가 알아서 처리한다. 코드는 가장 짧지만 "지금 어떤 상태인지"가 코드 어디에도 명확히 없다.
- D3D11 — 적당히 명시적. 리소스는 명시적으로
Create*로 만들지만, 상태 전이·메모리·동기화는 여전히 런타임이 대신한다. "무엇을 그리는가"에 집중하게 해 주는 중간 지점. - WebGPU — 저수준 개념(파이프라인·바인드그룹·커맨드 인코더)을 도입하되, 안전장치와 자동 배리어를 남겨 둔 "완만한 명시성". 저수준 세계로 가는 가장 부드러운 입구.
- Metal — Apple 전용. WebGPU와 비슷한 추상화 수준이지만 플랫폼에 밀착돼 있고, 일부 동기화(Resource Hazard Tracking)를 자동으로 해 줄 수도, 끌 수도 있다.
- D3D12 / Vulkan — 가장 명시적. 메모리 할당, 리소스 상태 전이(배리어), 큐 동기화(펜스/세마포어), 파이프라인 사전 컴파일(PSO), 디스크립터 힙까지 전부 개발자 몫. 드라이버 오버헤드가 최소라 멀티스레드로 명령을 기록해 최고 성능을 얻는다.
왜 D3D12·Vulkan은 코드가 폭발하는가 — 수치로 감 잡기
"첫 삼각형까지 D3D11은 ~200줄, Vulkan은 ~1,000줄"이라는 악명은 과장이 아닙니다. 하지만 그 800줄이 새로운 기능이 아니라 D3D11 런타임이 몰래 해 주던 일의 청구서라는 점을 알면 덜 무섭습니다. 대략적인 항목별 "추가 청구서"는 이렇습니다.
| D3D11이 자동으로 하던 일 | 저수준에서 직접 해야 하는 코드 | 대략 줄 수 |
|---|---|---|
| GPU 메모리 할당·타입 선택 | 메모리 타입 쿼리 → 힙 생성 → 리소스 바인딩 | +100~200 |
| 리소스 상태 전이(자동 배리어) | 드로우/복사마다 배리어 수동 삽입 | +50~150 |
| CPU-GPU 동기화 | 펜스/세마포어 생성·시그널·대기 | +50~100 |
| 파이프라인 상태 검증·조합 | PSO를 모든 조합마다 사전 컴파일 | +150~300 |
| 디스크립터(리소스 바인딩) 관리 | 디스크립터 힙/셋 할당·기록·바인딩 | +100~200 |
1. 전체 개념 매핑 표 — 한눈에 보는 대응
먼저 전체 지도를 펼쳐 둡니다. 아래 표의 각 행(row)은 "같은 일을 하는 개념"이고, 각 열(column)은 API입니다. 왼쪽 D3D11 열을 기준점 삼아 오른쪽으로 눈을 옮기면, 같은 개념이 API마다 어떤 이름으로 불리는지 보입니다. 이어지는 절들에서 이 표의 각 행을 실제 코드와 함께 한 줄씩 해부합니다.
| D3D11 (기준) | D3D12 | Vulkan | OpenGL | Metal | WebGPU |
|---|---|---|---|---|---|
| Vertex Buffer + Input Layout IASetVertexBuffers |
Vertex Buffer + Input Layout(PSO) | Vertex Buffer + VertexInputState |
VAO / VBO | MTLBuffer + Vertex Descriptor |
Vertex Buffer + vertexBufferLayout |
| Shader + State object VS/PS/PSSet* + RSSetState 등 |
Pipeline State Object (PSO) | VkPipeline |
Program(glUseProgram) + glEnable |
MTLRenderPipelineState |
GPURenderPipeline |
| 즉시 컨텍스트 ID3D11DeviceContext |
Command List + Command Queue | Command Buffer + Queue | 암묵적 전역 상태 머신 | MTLCommandBuffer |
GPUCommandEncoder |
| Constant Buffer / SRV cbuffer + PSSetShaderResources |
Root Signature + Descriptor Heap | Descriptor Set + Pipeline Layout | UBO / glUniform |
Argument Buffer / setBuffer |
Bind Group + Bind Group Layout |
| 암묵적 동기화 런타임이 알아서 |
Resource Barrier + Fence | VkBarrier + VkFence/Semaphore |
암묵적(드라이버가 알아서) | MTLFence / Resource Hazard |
암묵적(런타임이 배리어 삽입) |
DXGI Swap Chain PresentswapChain->Present |
Swap Chain Present |
vkQueuePresentKHR |
glfwSwapBuffers |
presentDrawable |
Surface Present(자동) |
2. 1행 — Vertex Buffer + Input Layout
무엇을 만드나: "정점 데이터가 담긴 GPU 메모리(Vertex Buffer)"와 "그 바이트 배치를 셰이더 입력에 연결하는 규칙(Input Layout)". 왜 필요한가: GPU는 메모리 안의 바이트 나열만 봅니다. "이 12바이트는 position(float3)이고, 다음 8바이트는 texcoord(float2)"라는 해석 규칙을 알려 주지 않으면 정점을 그릴 수 없습니다. 이 규칙이 바로 Input Layout입니다.
D3D11 원본 — 정점 구조체 · 버퍼 생성 · 입력 레이아웃
// 1) CPU 측 정점 구조체 — 메모리 배치가 곧 GPU가 읽을 바이트 순서
struct Vertex {
DirectX::XMFLOAT3 position; // 12바이트: x, y, z
DirectX::XMFLOAT2 uv; // 8바이트: u, v (총 stride = 20바이트)
};
Vertex verts[3] = {
{ { 0.0f, 0.5f, 0.0f }, { 0.5f, 0.0f } },
{ { 0.5f, -0.5f, 0.0f }, { 1.0f, 1.0f } },
{ {-0.5f, -0.5f, 0.0f }, { 0.0f, 1.0f } },
};
// 2) 버퍼 스펙(DESC)을 채워 device로 GPU 메모리를 생성
D3D11_BUFFER_DESC bd = {};
bd.Usage = D3D11_USAGE_IMMUTABLE; // 생성 후 GPU에서 읽기 전용
bd.ByteWidth = sizeof(verts); // 20 * 3 = 60바이트
bd.BindFlags = D3D11_BIND_VERTEX_BUFFER; // 정점 버퍼로 쓴다고 선언
D3D11_SUBRESOURCE_DATA init = {};
init.pSysMem = verts; // 복사해 넣을 CPU 원본
Microsoft::WRL::ComPtr<ID3D11Buffer> vb;
device->CreateBuffer(&bd, &init, &vb);
// 3) 바이트 배치 → 셰이더 입력 시맨틱 매핑 규칙(Input Layout)
D3D11_INPUT_ELEMENT_DESC layout[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
Microsoft::WRL::ComPtr<ID3D11InputLayout> il;
device->CreateInputLayout(layout, 2, vsBytecode, vsByteLen, &il);
// 4) 드로우 직전, 컨텍스트 슬롯에 바인딩
UINT stride = sizeof(Vertex), offset = 0;
ctx->IASetVertexBuffers(0, 1, vb.GetAddressOf(), &stride, &offset);
ctx->IASetInputLayout(il.Get());
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
줄 단위 해부
① 정점 구조체 struct Vertex. C++ 구조체의 멤버는 선언한 순서대로 메모리에
쌓입니다. XMFLOAT3 position은 4바이트 float 세 개 = 12바이트, 그 뒤 XMFLOAT2 uv는
float 두 개 = 8바이트. 합쳐서 한 정점이 20바이트를 차지하고, 이 20이 곧 stride(정점
하나에서 다음 정점까지의 간격)입니다. GPU는 "20바이트씩 건너뛰며 읽어라"라는 이 값만 보고 배열을 순회합니다.
기하 직관: 정점 버퍼를 표(table)로 상상하세요. 각 행이 정점 하나이고, 행의 폭이 20바이트, 한 행 안에서 앞 12칸은 위치, 뒤 8칸은 UV입니다. Input Layout은 "이 표의 몇 번째 바이트부터가 어떤 열인가"를 적어 둔 표 머리글(header)인 셈입니다.
② D3D11_BUFFER_DESC bd = {}. = {}는 구조체 전체를 0으로 초기화하는
집합 초기화입니다(쓰레기 값 방지). Usage = IMMUTABLE은 "생성 후 절대 안 바꾼다"는 약속이라
드라이버가 가장 빠른 GPU 전용 메모리에 놓을 수 있습니다. ByteWidth = sizeof(verts)는 60바이트,
BindFlags = D3D11_BIND_VERTEX_BUFFER는 용도 선언입니다. CreateBuffer에 이 스펙과
초기 데이터 포인터를 넘기면 GPU 메모리 한 덩어리가 완성돼 ComPtr로 돌아옵니다.
③ D3D11_INPUT_ELEMENT_DESC의 각 필드. 이 줄이 이 절의 핵심입니다. 한 줄씩 뜯으면:
"POSITION"— 셰이더 입력의 시맨틱 이름. HLSL의float3 pos : POSITION과 문자열로 매칭됩니다.0— 시맨틱 인덱스(같은 이름이 여러 개일 때 구분). 여기선 0.DXGI_FORMAT_R32G32B32_FLOAT— 이 원소가 32비트 float 3개(=float3)라는 포맷.0— 입력 슬롯 번호(정점 버퍼를 여러 개 꽂을 때의 슬롯). 여기선 0번 버퍼.0/12— AlignedByteOffset. POSITION은 정점의 0바이트부터, TEXCOORD는 12바이트부터 시작. 이 12가 위 구조체에서 position이 차지한 12바이트와 정확히 일치해야 합니다.D3D11_INPUT_PER_VERTEX_DATA— 정점마다 다른 데이터(인스턴싱용 PER_INSTANCE와 대비).
AlignedByteOffset을 실수로 잘못 넣으면(예: TEXCOORD를 8로) 컴파일도 되고 크래시도 안 나지만
화면이 조용히 깨집니다. UV가 엉뚱한 바이트에서 읽혀 텍스처가 뒤틀립니다. 구조체 멤버 순서를
바꿨다면 이 오프셋도 반드시 함께 고쳐야 합니다. D3D11_APPEND_ALIGNED_ELEMENT를 쓰면 오프셋을
자동 계산해 이 실수를 막을 수 있습니다.
대응하는 HLSL 정점 셰이더 입력
struct VSIn {
float3 pos : POSITION; // Input Layout의 "POSITION"과 이름으로 연결
float2 uv : TEXCOORD; // Input Layout의 "TEXCOORD"와 연결
};
struct VSOut {
float4 pos : SV_Position; // 클립 공간 좌표(필수 시스템 시맨틱)
float2 uv : TEXCOORD;
};
VSOut VSMain(VSIn v) {
VSOut o;
o.pos = float4(v.pos, 1.0); // 이 예제는 이미 클립 공간 좌표라 그대로 통과
o.uv = v.uv;
return o;
}
연결의 정체: C++의 D3D11_INPUT_ELEMENT_DESC 문자열 "POSITION"과
HLSL의 : POSITION은 같은 문자열로 짝지어집니다. CreateInputLayout이
정점 셰이더 바이트코드(vsBytecode)를 함께 받는 이유가 이것입니다 — 셰이더가 요구하는 입력 시맨틱과
레이아웃이 맞는지 그 자리에서 검증하기 위함입니다. 규약대로 정점→클립 변환이 필요하면 여기서
mul(float4(v.pos,1), mvp)로 행벡터 × 행렬을 하지만, 이 예제는 입력이 이미 클립
공간이라 그대로 통과시켰습니다.
다른 API에서는 이 두 개념이 어떻게 나뉘는가
| API | 정점 버퍼 | 입력 레이아웃이 사는 곳 |
|---|---|---|
| D3D11 | ID3D11Buffer | 독립 객체 ID3D11InputLayout (드로우 직전 바인딩) |
| D3D12 | ID3D12Resource + Vertex Buffer View | PSO 안에 흡수 (InputLayout 필드) |
| Vulkan | VkBuffer | 파이프라인 안 VkPipelineVertexInputStateCreateInfo |
| OpenGL | VBO | VAO + glVertexAttribPointer(상태에 기록) |
| Metal | MTLBuffer | MTLVertexDescriptor (파이프라인에 연결) |
| WebGPU | Vertex Buffer | vertexBufferLayout (파이프라인 기술자에) |
3. 2행 — Shader + State object → PSO(Pipeline State Object)
무엇을 만드나: "무엇을 어떻게 그릴지"를 정하는 파이프라인 상태. 왜 필요한가: 셰이더·래스터·블렌드·뎁스 설정이 서로 호환되는지 GPU가 검증해야 하는데, D3D11은 이 검증을 드로우마다, D3D12/Vulkan은 미리 한 번 하고 굳혀 둡니다. 이 "굳힌 덩어리"가 PSO입니다.
D3D11 원본 — 셰이더와 상태 객체를 따로 만들어 따로 바인딩
// (A) 셰이더 — 스테이지마다 독립 객체
Microsoft::WRL::ComPtr<ID3D11VertexShader> vs;
Microsoft::WRL::ComPtr<ID3D11PixelShader> ps;
device->CreateVertexShader(vsBytecode, vsLen, nullptr, &vs);
device->CreatePixelShader (psBytecode, psLen, nullptr, &ps);
// (B) 래스터라이저 상태 — 컬링/채우기 방식을 하나의 불변 객체로
D3D11_RASTERIZER_DESC rd = {};
rd.FillMode = D3D11_FILL_SOLID; // 면 채우기(와이어프레임 아님)
rd.CullMode = D3D11_CULL_BACK; // 뒷면 제거
rd.FrontCounterClockwise = FALSE; // 시계방향(CW)이 앞면 = 왼손 좌표계 관례
Microsoft::WRL::ComPtr<ID3D11RasterizerState> rs;
device->CreateRasterizerState(&rd, &rs);
// (C) 뎁스스텐실 상태 — 깊이 테스트 규칙
D3D11_DEPTH_STENCIL_DESC dsd = {};
dsd.DepthEnable = TRUE;
dsd.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
dsd.DepthFunc = D3D11_COMPARISON_LESS; // z가 작을수록 앞(가까움)
Microsoft::WRL::ComPtr<ID3D11DepthStencilState> dss;
device->CreateDepthStencilState(&dsd, &dss);
// (D) 드로우 직전 — 네 조각을 각각 슬롯에 꽂는다
ctx->VSSetShader(vs.Get(), nullptr, 0);
ctx->PSSetShader(ps.Get(), nullptr, 0);
ctx->RSSetState(rs.Get());
ctx->OMSetDepthStencilState(dss.Get(), 0);
ctx->Draw(3, 0);
줄 단위 해부
(A) 셰이더가 스테이지마다 독립 객체. D3D11은 OpenGL처럼 VS·PS를 하나의 "Program"으로 링크하지
않습니다. CreateVertexShader·CreatePixelShader가 각각 별개의 COM 객체를 돌려주고,
드로우 때 VSSetShader·PSSetShader로 따로 꽂습니다. 덕분에 VS는 그대로 두고 PS만
교체하는 조합이 자유롭습니다.
(B) CullMode = CULL_BACK + FrontCounterClockwise = FALSE. 이 두 줄이
"어느 면이 앞면인가"를 정합니다. 우리 규약은 왼손 좌표계이고, 왼손 좌표계에서 화면을 향한 앞면은
정점이 시계방향(CW)으로 감깁니다. FrontCounterClockwise = FALSE는 "CW가 앞면"이라는
뜻이고, CULL_BACK은 그 반대인 뒷면(CCW로 보이는 면)을 버립니다. 삼각형의 뒷면 절반을 그리지 않아
성능을 아끼는 것이 컬링입니다.
CullMode = D3D11_CULL_NONE으로 바꿔 양면을 다 그려
보세요. 그때 보인다면 문제는 지오메트리가 아니라 와인딩 방향입니다.
(C) DepthFunc = COMPARISON_LESS. 새 픽셀의 z가 이미 저장된 z 보다 작을 때만
통과시켜 덮어씁니다. 우리 규약의 클립 z 범위는 [0,1]이고 0이 가장 가까움이므로, "작을수록 앞"인
LESS가 맞습니다. (OpenGL 기본은 z 범위 [-1,1]이라 이 감각이 미묘하게 다릅니다.)
(D) 네 조각을 따로 바인딩. 여기가 D3D11의 정체성입니다. 셰이더 2개 + 래스터 상태 + 뎁스 상태를
각각 슬롯에 꽂은 다음 Draw를 부르면, 그 순간의 스냅샷으로 GPU가 그립니다.
런타임은 이 조합이 유효한지 드로우 시점에 검증합니다 — 이 반복 검증이 바로 D3D12/Vulkan이 없애려는 비용입니다.
D3D12에서는 이 네 조각이 하나의 PSO로 합쳐진다
// D3D11에서 따로 만들던 것들이 하나의 DESC 안으로 모인다
D3D12_GRAPHICS_PIPELINE_STATE_DESC pso = {};
pso.pRootSignature = rootSig.Get(); // ← D3D11의 리소스 바인딩 규약
pso.VS = { vsBytecode, vsLen }; // ← CreateVertexShader
pso.PS = { psBytecode, psLen }; // ← CreatePixelShader
pso.InputLayout = { layout, 2 }; // ← CreateInputLayout (2절!)
pso.RasterizerState.CullMode = D3D12_CULL_MODE_BACK; // ← RASTERIZER_DESC
pso.RasterizerState.FrontCounterClockwise = FALSE;
pso.DepthStencilState.DepthEnable = TRUE; // ← DEPTH_STENCIL_DESC
pso.DepthStencilState.DepthFunc = D3D12_COMPARISON_FUNC_LESS;
pso.BlendState = /* ... */; // ← BLEND_DESC
pso.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
pso.NumRenderTargets = 1;
pso.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
pso.DSVFormat = DXGI_FORMAT_D32_FLOAT;
pso.SampleDesc.Count = 1;
Microsoft::WRL::ComPtr<ID3D12PipelineState> pipeline;
device->CreateGraphicsPipelineState(&pso, IID_PPV_ARGS(&pipeline));
// 드로우 때는 통째로 하나만 바인딩
cmdList->SetPipelineState(pipeline.Get());
1:1 대응이 그대로 보입니다. 오른쪽 주석을 보세요 — pso.VS는 D3D11의
CreateVertexShader, pso.InputLayout은 2절의 CreateInputLayout,
pso.RasterizerState는 CreateRasterizerState, pso.DepthStencilState는
CreateDepthStencilState에 각각 대응합니다. D3D11에서 네 번 만들고 네 번 바인딩하던
것이, D3D12에서는 한 번 만들고(초기화 때) 한 번 바인딩(드로우 때)으로 바뀝니다.
| API | 파이프라인 객체 | 포함되는 것 |
|---|---|---|
| D3D11 | 없음 (조각별 상태 객체) | VS/PS/RS/DSS/BS를 따로 바인딩 |
| D3D12 | ID3D12PipelineState | 셰이더+입력레이아웃+RS+DSS+BS+RT포맷 |
| Vulkan | VkPipeline | 위 전부 + 뷰포트/렌더패스 호환성까지 |
| OpenGL | glUseProgram + glEnable | Program은 하나로, 상태는 전역 토글 |
| Metal | MTLRenderPipelineState | 셰이더+정점기술자+블렌드+RT포맷 (RS/DSS는 별도) |
| WebGPU | GPURenderPipeline | 셰이더+레이아웃+RS+DSS+BS를 기술자 하나로 |
4. 3행 — 즉시 컨텍스트 → Command List + Queue
무엇을 만드나: GPU에게 시킬 명령의 목록. 왜 필요한가: D3D11의 즉시 컨텍스트는 명령을 부르는 즉시 GPU로 흘려보내는 단일 통로라 멀티스레드로 명령을 쌓기 어렵습니다. D3D12/Vulkan은 명령을 먼저 목록(리스트/버퍼)에 기록했다가 나중에 큐에 제출하는 2단계로 나눠, 여러 스레드가 동시에 목록을 채우게 합니다.
D3D11 — 부르는 즉시 실행되는 즉시 컨텍스트
// ctx는 ID3D11DeviceContext (즉시 컨텍스트)
float clear[4] = { 0.1f, 0.1f, 0.12f, 1.0f };
ctx->ClearRenderTargetView(rtv.Get(), clear); // 이 줄에서 바로 큐잉
ctx->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
ctx->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get());
ctx->RSSetViewports(1, &viewport);
ctx->Draw(3, 0); // 그리기 명령
swapChain->Present(1, 0); // 화면에 내보내기
각 ctx->... 호출은 부르는 순간 내부 명령 스트림에 들어갑니다. 개발자 눈에는
"즉시 실행"처럼 보이고, 스레드 하나가 순서대로 부르는 단순한 모델입니다. 편하지만, 여러 CPU 코어로 명령
기록을 나눌 수 없다는 한계가 있습니다.
D3D12 — 기록(Command List)과 제출(Queue)을 분리
// 1) 기록 시작 — 할당자를 리셋하고 커맨드 리스트를 연다
cmdAlloc->Reset();
cmdList->Reset(cmdAlloc.Get(), pipeline.Get());
// 2) 명령을 "기록"한다 (아직 GPU는 아무것도 안 함)
cmdList->ClearRenderTargetView(rtvHandle, clear, 0, nullptr);
cmdList->OMSetRenderTargets(1, &rtvHandle, FALSE, &dsvHandle);
cmdList->RSSetViewports(1, &viewport);
cmdList->SetPipelineState(pipeline.Get());
cmdList->DrawInstanced(3, 1, 0, 0);
cmdList->Close(); // 기록 종료
// 3) 큐에 "제출"해야 비로소 GPU가 실행한다
ID3D12CommandList* lists[] = { cmdList.Get() };
cmdQueue->ExecuteCommandLists(1, lists);
swapChain->Present(1, 0);
핵심 차이는 2단계 분리입니다. D3D11에서 ctx->Draw 한 줄이 하던 일이, D3D12에서는
"cmdList에 기록 → Close → cmdQueue->ExecuteCommandLists로
제출"의 세 단계가 됩니다. Reset은 명령을 담을 버퍼(할당자)를 비우는 것이고,
Close는 "이 목록은 다 채웠다"는 봉인입니다. 제출 전까지 GPU는 놀고 있습니다.
| API | 기록 | 제출 |
|---|---|---|
| D3D11 | 즉시 컨텍스트 — 부르는 즉시 (분리 없음) | |
| D3D12 | ID3D12GraphicsCommandList | ID3D12CommandQueue::ExecuteCommandLists |
| Vulkan | VkCommandBuffer | vkQueueSubmit |
| OpenGL | 암묵적 전역 상태 머신 (분리 없음) | |
| Metal | MTLCommandBuffer + Encoder | commit |
| WebGPU | GPUCommandEncoder | queue.submit() |
5. 4행 — Constant Buffer / SRV → Descriptor(디스크립터)
무엇을 만드나: "셰이더가 어떤 리소스를 어떤 슬롯에서 읽는가"의 연결. 왜 필요한가:
셰이더 안의 cbuffer나 Texture2D는 이름표일 뿐이고, 실제 GPU 메모리와 연결해 줘야
합니다. D3D11은 이 연결을 *Set* 한 줄로 하지만, D3D12/Vulkan은 "디스크립터"라는 명시적
기술자를 힙에 만들어 바인딩합니다.
D3D11 — cbuffer와 텍스처를 슬롯에 직접 꽂기
// 상수 버퍼: 행벡터 규약이므로 CPU에서 transpose 후 업로드
struct CBData { DirectX::XMFLOAT4X4 mvp; };
CBData cb;
DirectX::XMMATRIX m = world * view * proj; // 행벡터: v' = v*(W*V*P)
DirectX::XMStoreFloat4x4(&cb.mvp, XMMatrixTranspose(m)); // cbuffer는 열우선 → 전치
ctx->UpdateSubresource(cbuffer.Get(), 0, nullptr, &cb, 0, 0);
// 슬롯 b0에 상수 버퍼, 슬롯 t0에 텍스처(SRV), s0에 샘플러
ctx->VSSetConstantBuffers(0, 1, cbuffer.GetAddressOf()); // register(b0)
ctx->PSSetShaderResources (0, 1, srv.GetAddressOf()); // register(t0)
ctx->PSSetSamplers (0, 1, samp.GetAddressOf()); // register(s0)
슬롯 번호가 곧 register. VSSetConstantBuffers(0, ...)의 첫 인자 0은
HLSL의 register(b0)과 짝입니다. 마찬가지로 PSSetShaderResources(0, ...)는
register(t0), PSSetSamplers(0, ...)는 register(s0). D3D11은 이 매핑을
슬롯 번호만으로 처리하는 아주 단순한 모델입니다.
XMMatrixTranspose가 왜 필요한지 반드시 짚고 가세요. 우리 규약은 행벡터
v' = v · M이고 DirectXMath도 행벡터 기반이지만, HLSL cbuffer의 float4x4
기본 저장은 열 우선(column-major)입니다. 그래서 CPU의 행 우선 행렬을 그대로 올리면 셰이더에서
뒤집혀 읽힙니다. XMMatrixTranspose로 한 번 전치해 올리고, 셰이더에서는 mul(v, mvp)
(행벡터 × 행렬)로 곱하면 규약이 일관됩니다. 이 전치를 빼먹으면 모델이 뒤틀리거나 화면에서 사라집니다.
대응 HLSL — register 슬롯
cbuffer Transform : register(b0) { float4x4 mvp; }; // ← 슬롯 b0
Texture2D tex : register(t0); // ← 슬롯 t0
SamplerState samp : register(s0); // ← 슬롯 s0
float4 PSMain(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target {
return tex.Sample(samp, uv); // V축 위→아래 규약: uv.y=0이 텍스처 상단
}
register(b0)·(t0)·(s0)이 각각 C++의 슬롯 0과 일대일로
묶입니다. 그리고 규약대로 텍스처 V축은 위→아래이므로 uv.y = 0이 이미지의 맨 위입니다
(OpenGL은 아래→위라 정반대 — 포팅할 때 뒤집힌 텍스처의 단골 원인).
D3D12 — Root Signature + Descriptor Heap
// (A) 무엇이 어느 슬롯에 오는지 "규약(Root Signature)"을 먼저 선언
// ≈ D3D11의 register 배치를 명시적 객체로 만든 것
D3D12_ROOT_PARAMETER params[2] = {};
params[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV; // b0 자리
params[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; // t0(텍스처)
// ... 시그니처 직렬화 후 CreateRootSignature(...)
// (B) 디스크립터 힙 — 리소스 "핸들"을 담는 배열
D3D12_DESCRIPTOR_HEAP_DESC hd = {};
hd.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
hd.NumDescriptors = 8;
hd.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;
device->CreateDescriptorHeap(&hd, IID_PPV_ARGS(&heap));
// (C) 힙 안에 SRV를 "새겨 넣기" — D3D11이 자동으로 하던 일
device->CreateShaderResourceView(texture.Get(), &srvDesc, heap->GetCPUDescriptorHandleForHeapStart());
// (D) 드로우 때 바인딩
cmdList->SetGraphicsRootSignature(rootSig.Get());
cmdList->SetDescriptorHeaps(1, heap.GetAddressOf());
cmdList->SetGraphicsRootConstantBufferView(0, cbuffer->GetGPUVirtualAddress()); // b0
cmdList->SetGraphicsRootDescriptorTable(1, heap->GetGPUDescriptorHandleForHeapStart()); // t0
D3D11에서 PSSetShaderResources(0, 1, srv) 한 줄이 하던 일이 D3D12에서는 (A)~(D)
네 단계로 펼쳐집니다. Root Signature는 "어떤 슬롯에 무엇이 온다"는 계약(D3D11의 register
배치를 객체로 만든 것), Descriptor Heap은 리소스 핸들을 담는 GPU 가시 배열,
CreateShaderResourceView는 D3D11이 내부적으로 하던 SRV 생성을 힙의 특정 칸에 직접 새기는
일입니다. 결국 같은 매핑을 하지만, D3D11이 숨겨 두던 각 단계를 전부 손으로 조립하는 것입니다.
| API | 바인딩 계약 | 실제 바인딩 단위 |
|---|---|---|
| D3D11 | register 슬롯 번호(암묵) | *Set* 한 줄 |
| D3D12 | Root Signature | Descriptor Heap + Table |
| Vulkan | VkPipelineLayout | VkDescriptorSet |
| OpenGL | uniform location(암묵) | UBO / glUniform* |
| Metal | 함수 인자 인덱스 | Argument Buffer / setBuffer |
| WebGPU | Bind Group Layout | Bind Group |
6. 5행 — 암묵적 동기화 → Barrier + Fence
무엇을 만드나: "GPU가 서로 밟지 않게 하는 순서 보장". 왜 필요한가: GPU는 명령을 병렬·비동기로 실행합니다. 방금 렌더 타깃으로 쓴 텍스처를 곧바로 셰이더 입력으로 읽으면, 쓰기가 끝나기 전에 읽어 깨진 결과가 나올 수 있습니다. 이 위험(hazard)을 막는 것이 배리어와 펜스입니다. D3D11은 이걸 전부 런타임이 자동으로 해 주지만, D3D12/Vulkan은 개발자가 명시합니다.
D3D11 — 아무것도 안 해도 된다
// 1) 오프스크린 RT에 그린다
ctx->OMSetRenderTargets(1, offscreenRTV.GetAddressOf(), nullptr);
ctx->Draw(/* 씬 */ 36, 0);
// 2) 방금 그린 텍스처를 곧바로 셰이더에서 읽는다 — 배리어? 펜스? 필요 없음
ctx->OMSetRenderTargets(1, backRTV.GetAddressOf(), nullptr);
ctx->PSSetShaderResources(0, 1, offscreenSRV.GetAddressOf()); // 런타임이 알아서 순서 보장
ctx->Draw(3, 0);
D3D11 런타임은 "이 텍스처가 방금 렌더 타깃이었고 이제 셰이더 입력이 된다"는 상태 전이를 스스로 감지해 필요한 동기화를 삽입합니다. 개발자는 순서만 맞게 쓰면 되고 배리어 코드가 전혀 없습니다. 편하지만, 런타임이 보수적으로 동기화를 넣어 불필요한 대기가 생길 수 있다는 대가가 있습니다.
D3D12 — 상태 전이를 손으로 선언(Barrier), CPU-GPU 대기를 손으로(Fence)
// (A) 리소스 배리어: "RENDER_TARGET 상태 → PIXEL_SHADER_RESOURCE 상태"로 전이 선언
D3D12_RESOURCE_BARRIER b = {};
b.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
b.Transition.pResource = offscreenTex.Get();
b.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; // 방금까지 RT였다
b.Transition.StateAfter = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; // 이제 읽을 것
b.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
cmdList->ResourceBarrier(1, &b); // ← D3D11이 자동으로 하던 딱 이 줄
// (B) 펜스: 이 프레임의 GPU 작업이 끝날 때까지 CPU가 기다리게 하기
const UINT64 target = ++fenceValue;
cmdQueue->Signal(fence.Get(), target); // GPU가 여기 도달하면 fence를 target으로 올림
if (fence->GetCompletedValue() < target) { // 아직 안 끝났으면
fence->SetEventOnCompletion(target, fenceEvent);
WaitForSingleObject(fenceEvent, INFINITE); // GPU가 따라올 때까지 CPU 대기
}
(A) 배리어 = 상태 전이 선언. StateBefore와 StateAfter를 명시해
"이 리소스는 방금까지 렌더 타깃(RENDER_TARGET)이었고 이제 픽셀 셰이더가 읽는 리소스
(PIXEL_SHADER_RESOURCE)가 된다"고 GPU에게 알립니다. GPU는 이 지점에서 앞선 쓰기가 끝나기를
보장한 뒤 다음 읽기를 진행합니다. D3D11 런타임이 자동으로 넣어 주던 것이 바로 이 한 줄입니다.
StateBefore를 틀리게 쓰면 (예: 실제로는 RT였는데 COMMON이라고 적으면) 디버그 레이어가 경고를
쏟아내고, 릴리즈에서는 드라이버·GPU마다 다른 미정의 동작이 됩니다. 화면이 어떤 GPU에서는
멀쩡하고 다른 GPU에서는 깨진다면 배리어 상태 불일치를 가장 먼저 의심하세요. D3D12/Vulkan에서 초보가 가장
많이 삽질하는 지점입니다.
(B) 펜스 = CPU-GPU 시간 맞추기. Signal(fence, target)은 "GPU가 지금까지의 명령을
다 처리하면 fence 값을 target으로 올려라"는 예약입니다. CPU는 GetCompletedValue()로
현재 값을 확인하고, 아직 목표에 못 미치면 이벤트로 잠들었다가 GPU가 따라오면 깨어납니다. 이 장치가 없으면
CPU가 GPU보다 너무 앞서가 아직 GPU가 쓰는 중인 버퍼를 CPU가 덮어써 화면이 찢어집니다.
D3D11은 이 프레임 페이싱도 런타임이 대신 해 줬습니다.
| API | GPU 내부 순서(Barrier) | CPU-GPU 동기(Fence) |
|---|---|---|
| D3D11 | 자동 | 자동 |
| D3D12 | ResourceBarrier | ID3D12Fence |
| Vulkan | vkCmdPipelineBarrier | VkFence/Semaphore |
| OpenGL | 자동(드라이버) | glFenceSync(선택) |
| Metal | MTLFence/자동 hazard | completion handler |
| WebGPU | 자동(런타임이 배리어 삽입) | onSubmittedWorkDone |
7. 6행 — Swap Chain Present
무엇을 만드나: "다 그린 뒷면(back buffer)을 화면에 내보내기". 왜 필요한가: 화면에 직접 그리면 그리는 도중이 보여 깜빡입니다. 그래서 보이지 않는 뒷버퍼에 완성한 뒤, 한 번에 앞으로 교체(swap)합니다. 이 교체가 Present이고, 개념은 모든 API에서 거의 동일합니다.
// swapChain은 IDXGISwapChain
swapChain->Present(1, 0);
// ^ ^
// | └ Flags (0 = 일반)
// └ SyncInterval: 1 = VSync 켬(수직동기), 0 = 끔(테어링 허용)
첫 인자 SyncInterval이 곧 VSync. 1은 모니터 주사율에 맞춰 교체해
화면 찢김(테어링)을 없애지만 프레임레이트가 주사율(예: 60Hz)에 묶입니다. 0은 즉시 교체해 최고
FPS를 뽑지만 테어링이 생길 수 있습니다. 이 한 숫자의 의미는 다른 API에서도 이름만 바뀌어 그대로 있습니다.
| API | Present 호출 | VSync 제어 |
|---|---|---|
| D3D11 / D3D12 | swapChain->Present(sync, 0) | 첫 인자 SyncInterval |
| Vulkan | vkQueuePresentKHR | Present Mode(FIFO/MAILBOX/IMMEDIATE) |
| OpenGL | glfwSwapBuffers | glfwSwapInterval(1) |
| Metal | presentDrawable | Display Link / 자동 |
| WebGPU | 자동(프레임 끝에) | 브라우저 vsync에 맞춤 |
8. 학습 경로 — D3D11로 개념, 그다음 목적별로
여섯 개 행을 다 훑었으니 결론이 자연스럽게 나옵니다. 개념은 D3D11에서 익히고, 목적에 맞는 API로 옮겨가는 것이 가장 빠릅니다. MVP·래스터화·깊이·조명·텍스처·오프스크린 렌더 타깃 같은 렌더링의 본질은 어느 API에서나 같고, 위에서 봤듯 각 개념이 이름만 바뀌어 그대로 이어집니다. D3D11이 그 개념들을 비교적 적은 코드로, 그러면서도 "명시적 리소스 생성"이라는 현대적 감각과 함께 실험하게 해 줍니다.
| 목표 | 추천 API | 이유 |
|---|---|---|
| Windows 게임 · AAA · 최신 기능 | D3D12 | 윈도우·Xbox 1급 지원, 풍부한 툴(PIX), 콘솔과의 접점. D3D11 개념이 거의 그대로 이어짐. |
| 크로스플랫폼 고성능 | Vulkan | 가장 낮은 오버헤드·가장 명시적. 데스크톱·모바일·리눅스 전부 커버하지만 진입 장벽이 가장 높음. |
| 웹 / 크로스플랫폼 | WebGPU | 저수준 개념(파이프라인·바인드그룹)을 현대적·안전한 형태로 도입. Vulkan보다 훨씬 완만. |
| Apple 생태계(macOS/iOS) | Metal | Apple 플랫폼의 유일한 1급 API. Objective-C/Swift와 밀착, 툴링 우수. |
| 크로스플랫폼 · 학습 | OpenGL | 가장 적은 코드로 렌더링 본질만 실험. 단 D3D11보다 더 암묵적이라 대규모 앱에선 디버깅이 어렵다. |
한 문장으로: D3D11은 "무엇을 그리는가"에 집중하게 해 주면서도 리소스를 명시적으로 만드는 감각을 심어 주고, D3D12·Vulkan은 "언제 어떤 메모리에 어떻게 동기화하며 그리는가"까지 당신에게 맡깁니다. 전자를 확실히 이해한 사람만이 후자의 명시성을 감당할 수 있습니다. 이 페이지의 여섯 개 매핑이 그 다리가 되기를 바랍니다.