D3D11의 두 축 — 디바이스와 즉시 컨텍스트 — 한 줄 한 줄 직접 구현
Direct3D 11을 처음 열어 보면 ID3D11Device와 ID3D11DeviceContext라는 두 인터페이스가 나옵니다.
이름이 비슷해서 헷갈리기 쉽지만, 둘은 역할이 완전히 다릅니다. 디바이스는 리소스를 만드는 공장이고,
컨텍스트는 그 리소스를 파이프라인에 꽂고 그림을 그리는 작업자입니다. 이 두 축을 확실히 구분하는 순간,
D3D11 코드가 왜 "만들기 → 꽂기 → 그리기"라는 리듬을 반복하는지 한눈에 보이기 시작합니다.
이 페이지에서는 그 리듬을 이루는 표준 렌더 루프를, 함수 시그니처와 인자 하나하나까지 입문자 눈높이로 해부합니다.
1. 두 인터페이스: 공장과 작업자
D3D11의 API 표면은 크게 두 덩어리로 나뉩니다. 하나는 디바이스(ID3D11Device),
다른 하나는 즉시 컨텍스트(ID3D11DeviceContext)입니다. 이 둘의 이름은 비슷하지만
담당하는 일이 겹치지 않도록 설계되어 있습니다.
- 디바이스는 팩토리입니다. 메서드 이름이 거의 다
Create*로 시작합니다 (CreateBuffer,CreateInputLayout,CreateVertexShader…). GPU 위에 실제 리소스를 할당하고, 그 리소스를 가리키는 COM 객체 포인터를 돌려줍니다. "무엇을 만들지"를D3D11_*_DESC구조체에 적어 넘기면, 디바이스가 그 스펙대로 메모리를 확보해 줍니다. - 즉시 컨텍스트는 실행기입니다. 메서드 이름이
*Set*(바인딩),Draw*(드로우),Clear*(초기화),Map/Unmap(CPU↔GPU 데이터 전송)으로 이뤄집니다. 이미 만들어진 리소스를 파이프라인의 각 스테이지 슬롯에 꽂고, 지금까지 꽂힌 상태로 GPU에게 "그려!"라고 명령합니다.
OpenGL을 먼저 배운 분이라면 이 분리가 낯설 수 있습니다. OpenGL은 glBindBuffer로 "현재 바인딩된 대상"을 바꾸고
glBufferData로 그 대상을 채우는, 이른바 전역 상태 머신(global state machine)입니다.
"지금 무엇이 바인딩되어 있느냐"라는 숨은 문맥에 의존하죠. 반면 D3D11은 객체를 명시적으로 만든 뒤,
그 객체 포인터를 컨텍스트의 슬롯에 직접 꽂는 모델입니다. 숨은 전역 상태가 적어 코드가 길어지는 대신,
"지금 무엇이 바인딩되어 있는가"가 코드에 그대로 드러나 추적하기 쉽습니다.
- 생성(
device->Create*) —D3D11_*_DESC로 리소스 스펙을 채워 넘기면 완성된 COM 객체(ID3D11Buffer등)를 돌려받습니다. 이 시점에 메모리·내용이 확정됩니다. - 바인딩(
context->*Set*) — 그 객체 포인터를 파이프라인 스테이지 슬롯(IA/VS/PS/RS/OM …)에 꽂습니다. - 드로우(
context->Draw*) — 지금까지 꽂힌 상태 스냅샷으로 GPU가 그립니다. - 해제(COM 참조 카운트) —
ComPtr가 스코프를 벗어나면Release()가 자동 호출됩니다. OpenGL의glDelete*같은 명시적 삭제가 필요 없습니다.
파이프라인 스테이지와 슬롯이라는 개념
바인딩을 이해하려면 파이프라인 스테이지(stage)라는 그림을 먼저 머릿속에 그려야 합니다. GPU가 삼각형 하나를 화면 픽셀로 바꾸는 과정은 컨베이어 벨트처럼 여러 단계로 나뉩니다. 대표적으로:
- IA (Input Assembler) — 정점 버퍼의 바이트들을 읽어 "정점"이라는 단위로 조립하는 입구 단계. 여기에 정점 버퍼·인덱스 버퍼·입력 레이아웃·토폴로지를 꽂습니다.
- VS (Vertex Shader) — 정점마다 실행되어 위치를 변환하는 단계. 정점 셰이더와 그 상수 버퍼를 꽂습니다.
- RS (Rasterizer) — 삼각형을 픽셀 조각(fragment)으로 쪼개는 단계. 래스터라이저 상태·뷰포트를 꽂습니다.
- PS (Pixel Shader) — 픽셀마다 실행되어 최종 색을 계산하는 단계. 픽셀 셰이더·텍스처·상수 버퍼를 꽂습니다.
- OM (Output Merger) — 계산된 색을 렌더 타깃에 합성하는 출구 단계. 렌더 타깃(RTV)·깊이 타깃(DSV)·블렌드/뎁스 상태를 꽂습니다.
슬롯(slot)은 각 스테이지가 가진 번호가 매겨진 꽂이 구멍입니다. 예를 들어 정점 버퍼는 IA 스테이지의 슬롯 0, 1, 2…에
여러 개를 동시에 꽂을 수 있고, 상수 버퍼는 셰이더 스테이지마다 b0, b1 … 이라는 슬롯을 가집니다.
*Set* 계열 함수의 첫 번째 인자가 대부분 "슬롯 시작 번호"인 이유가 바로 이것입니다. HLSL 셰이더 쪽에서
register(b0)이라고 선언한 상수 버퍼는, C++ 쪽에서 슬롯 0에 PSSetConstantBuffers(0, ...)로 꽂은 버퍼와 짝을 이룹니다.
상태 객체: 전역 플래그 대신 불변 블록
D3D11의 또 하나의 철학은 대부분의 렌더 상태를 불변(immutable) 상태 객체로 미리 굳혀 둔다는 점입니다.
OpenGL은 glEnable(GL_BLEND), glBlendFunc(...)처럼 전역 플래그를 하나씩 토글합니다.
이렇게 하면 "지금 블렌딩이 켜져 있나? 함수는 뭐로 설정돼 있나?"를 추적하기 어렵고, 어떤 코드가 몰래 상태를 바꿔 놓으면 버그를 찾기 힘듭니다.
D3D11은 래스터·블렌드·뎁스스텐실 설정을 통째로 하나의 객체로 만들어 둡니다.
ID3D11RasterizerState, ID3D11BlendState, ID3D11DepthStencilState가 그것입니다.
한 번 만들면 내용이 바뀌지 않는(immutable) 블록이라, 드로우 직전에 RSSetState / OMSetBlendState /
OMSetDepthStencilState로 통째로 교체만 하면 됩니다. "이 오브젝트는 알파 블렌딩 상태 A로, 저 오브젝트는 불투명 상태 B로"처럼
상태 세트를 미리 만들어 두고 드로우마다 갈아 끼우는 방식이라, 상태 관리가 명확하고 GPU 내부에서도 최적화하기 쉽습니다.
주요 객체 종류
| 객체 | 바인딩 API | 역할 |
|---|---|---|
| Buffer (Vertex/Index/Constant) | IASetVertexBuffers / IASetIndexBuffer / VSSetConstantBuffers | 정점·인덱스·상수(유니폼) 데이터를 담는 GPU 메모리 덩어리 |
| Input Layout | IASetInputLayout | 정점 버퍼의 바이트 배치를 셰이더 입력 시맨틱에 매핑(OpenGL의 VAO+VertexAttribPointer 역할) |
| Texture2D + SRV | PSSetShaderResources | 이미지 데이터. 셰이더에서 읽으려면 ShaderResourceView로 감싸 바인딩 |
| RTV / DSV | OMSetRenderTargets | 렌더 결과를 받는 컬러 타깃(RTV)과 깊이/스텐실 타깃(DSV) |
| Shader (VS/PS/…) | VSSetShader / PSSetShader | 스테이지별 컴파일된 셰이더. OpenGL의 링크된 Program 대신 스테이지마다 독립 객체 |
| State object | RSSetState / OMSetBlendState / OMSetDepthStencilState | 래스터·블렌드·뎁스스텐실 설정을 굳힌 불변 상태 블록 |
2. 표준 렌더 루프 골격
이제 앞의 개념들을 실제 코드로 꿰어 봅시다. 거의 모든 D3D11 프로그램은 아래 순서를 프레임마다 반복합니다.
- 지우기 — 컬러 타깃(RTV)과 깊이 타깃(DSV)을 초기화해 이전 프레임 흔적을 없앱니다.
- 셰이더·입력 레이아웃 바인딩 — 어떤 셰이더로, 정점을 어떻게 해석할지 정합니다.
- 정점 버퍼·상수 버퍼 바인딩 — 그릴 데이터와 유니폼 값을 슬롯에 꽂습니다.
- 드로우 — 지금까지 꽂힌 상태로 GPU에게 그리라고 명령합니다.
- Present — 다 그린 백버퍼를 화면에 표시합니다.
코드는 루프 밖에서 한 번만 하는 초기화(생성)와 매 프레임 반복하는 루프(바인딩·드로우)로 나뉩니다. 이 구분이 곧 "디바이스로 만들고 → 컨텍스트로 그린다"라는 두 축의 분업을 그대로 보여줍니다. (디바이스·스왑체인 자체를 만드는 부분은 지면상 생략하고 이후 절에서 다룹니다.)
using Microsoft::WRL::ComPtr;
// ── 초기화(루프 밖, 한 번만) ─────────────────────────────
ComPtr<ID3D11Buffer> vbo, cbuffer;
ComPtr<ID3D11InputLayout> layout;
ComPtr<ID3D11VertexShader> vs;
ComPtr<ID3D11PixelShader> ps;
// 1) 정점 버퍼 생성: DESC를 채워 device->CreateBuffer
D3D11_BUFFER_DESC vbd{};
vbd.ByteWidth = sizeof(verts);
vbd.Usage = D3D11_USAGE_DEFAULT;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
D3D11_SUBRESOURCE_DATA vinit{ verts, 0, 0 };
device->CreateBuffer(&vbd, &vinit, &vbo);
// 2) 입력 레이아웃: 정점 바이트 배치를 셰이더 시맨틱에 매핑 (OpenGL의 VAO 역할)
D3D11_INPUT_ELEMENT_DESC elems[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
device->CreateInputLayout(elems, 1, vsBlob->GetBufferPointer(),
vsBlob->GetBufferSize(), &layout);
// 3) 셰이더 생성 (D3DCompile 결과 blob에서, 4장 참고)
device->CreateVertexShader(vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(), nullptr, &vs);
device->CreatePixelShader (psBlob->GetBufferPointer(), psBlob->GetBufferSize(), nullptr, &ps);
// 4) 상수 버퍼(유니폼): 매 프레임 갱신할 색상 하나
D3D11_BUFFER_DESC cbd{};
cbd.ByteWidth = 16; // float4, 16바이트 정렬
cbd.Usage = D3D11_USAGE_DYNAMIC; // CPU가 Map으로 갱신
cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
device->CreateBuffer(&cbd, nullptr, &cbuffer);
// 깊이 테스트는 뎁스스텐실 상태 객체 + DSV로 (뒤에서 상세히)
// ── 프레임 루프 (Win32 메시지 펌프) ──────────────────────
MSG msg{};
while (msg.message != WM_QUIT) {
if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); continue; }
// (a) 지우기: 컬러 타깃(RTV) + 깊이 타깃(DSV)를 초기화
const float clear[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
context->ClearRenderTargetView(rtv.Get(), clear);
context->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
context->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get());
// (b) 셰이더 + 입력 레이아웃 바인딩
context->IASetInputLayout(layout.Get());
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
context->VSSetShader(vs.Get(), nullptr, 0);
context->PSSetShader(ps.Get(), nullptr, 0);
// (c) 상수 버퍼 갱신: Map으로 색상 값을 밀어 넣고 슬롯 b0에 바인딩
D3D11_MAPPED_SUBRESOURCE m{};
context->Map(cbuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &m);
float color[4] = { 0.9f, 0.4f, 0.2f, 1.0f };
memcpy(m.pData, color, sizeof(color));
context->Unmap(cbuffer.Get(), 0);
context->PSSetConstantBuffers(0, 1, cbuffer.GetAddressOf());
// (d) 정점 버퍼 바인딩: stride/offset과 함께 슬롯 0에
UINT stride = 3 * sizeof(float), offset = 0;
context->IASetVertexBuffers(0, 1, vbo.GetAddressOf(), &stride, &offset);
// (e) 드로우: 지금까지 꽂힌 상태 스냅샷으로 GPU가 그린다
context->Draw(3, 0);
// (f) Present: 백버퍼 ↔ 프론트버퍼 교체 → 화면에 표시
swapChain->Present(1, 0); // 첫 인자 = SyncInterval (1 = V-Sync ON)
}
이 코드는 길어 보이지만, 실은 "디바이스로 만든다 → 컨텍스트로 꽂고 그린다"라는 한 문장의 구체화입니다.
앞부분(1~4번)은 전부 device->Create*이고, 루프 안(a~f)은 전부 context->·swapChain->입니다.
이제 이 두 덩어리를 한 줄씩 뜯어봅시다.
3. 초기화 파트 해부 — 디바이스가 만드는 것들
3-1. ComPtr — 잊어도 새지 않는 스마트 포인터
using Microsoft::WRL::ComPtr;
ComPtr<ID3D11Buffer> vbo, cbuffer;
ComPtr<ID3D11InputLayout> layout;
ComPtr<ID3D11VertexShader> vs;
ComPtr<ID3D11PixelShader> ps;
D3D11 객체는 모두 COM(Component Object Model) 객체입니다. COM 객체는 내부에 참조 카운트(reference count)라는
숫자를 하나 들고 있어서, 이 객체를 참조하는 곳이 늘면 AddRef()로 +1, 참조를 놓으면 Release()로 -1을 합니다.
카운트가 0이 되면 객체가 스스로 메모리를 해제합니다. 문제는 Release()를 손으로 정확히 짝 맞춰 호출하기가 무척 까다롭다는 점입니다.
한 번 빠뜨리면 메모리 누수, 두 번 부르면 크래시죠.
ComPtr<T>는 이 참조 카운트 관리를 자동으로 해 주는 스마트 포인터입니다. Microsoft::WRL(Windows Runtime Library) 네임스페이스에
들어 있고, <wrl/client.h>를 포함하면 씁니다. using Microsoft::WRL::ComPtr;은 매번 긴 네임스페이스를 쓰지 않도록
이름만 현재 스코프로 끌어오는 선언입니다(using namespace로 통째로 끌어오는 것보다 안전합니다). C++ 표준의 std::shared_ptr이
자기만의 카운터로 일반 객체를 관리한다면, ComPtr은 COM 객체가 스스로 들고 있는 참조 카운트를 이용한다는 점이 다릅니다.
ComPtr<ID3D11Buffer> vbo, cbuffer;— 아직 아무 객체도 안 가리키는 빈 포인터 두 개를 선언합니다. 내부 원시 포인터는nullptr입니다.- 이 변수가 스코프를 벗어나 소멸하면 소멸자가 자동으로
Release()를 호출합니다. 그래서 OpenGL의glDeleteBuffers같은 명시적 삭제가 필요 없습니다. - 대입(
a = b) 시에는AddRef()(b가 가리키던 객체의 카운트 +1)와 기존 a가 가리키던 객체의Release()가 알아서 일어납니다.
ComPtr에서 값을 받을 때 자주 쓰는 두 메서드를 헷갈리면 안 됩니다.
.Get()은 원시 포인터(T*)를 그대로 꺼내 "읽기용"으로 넘길 때(예: context->VSSetShader(vs.Get(), ...)) 쓰고,
.GetAddressOf()는 포인터의 주소(T**)를 넘겨 "여기에 결과를 채워 달라" 하거나 배열 시작 주소로 쓸 때 씁니다.
&vbo처럼 ComPtr에 직접 &를 붙이면 내부적으로 기존 참조를 Release()하고 T**를 돌려주므로, 새로 만든 객체를 받는 Create*의 마지막 인자로 적합합니다.
3-2. 정점 버퍼 생성 — DESC로 스펙을 적어 CreateBuffer
D3D11_BUFFER_DESC vbd{};
vbd.ByteWidth = sizeof(verts);
vbd.Usage = D3D11_USAGE_DEFAULT;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
D3D11_SUBRESOURCE_DATA vinit{ verts, 0, 0 };
device->CreateBuffer(&vbd, &vinit, &vbo);
무엇을 만드나: 삼각형의 정점 좌표들이 담긴 배열 verts를 GPU 메모리로 올려 정점 버퍼를 만듭니다.
왜 필요한가: GPU는 CPU 메모리의 배열을 직접 못 읽습니다. 반드시 GPU가 접근 가능한 버퍼로 복사해 두어야
정점 셰이더가 정점들을 읽어 화면에 그릴 수 있습니다.
D3D11에서 리소스를 만드는 방식은 항상 똑같습니다. ① *_DESC 구조체에 "어떤 리소스인지"를 적고 →
② 초기 데이터가 있으면 SUBRESOURCE_DATA에 담고 → ③ device->Create*에 둘 다 넘긴다. 한 줄씩 봅시다.
D3D11_BUFFER_DESC vbd{};— 버퍼 스펙 구조체를 선언합니다. 끝의{}는 값 초기화(value-initialization)로, 구조체의 모든 멤버를 0으로 채웁니다. 이게 중요한 이유는 뒤에서 명시적으로 안 건드린 멤버들(CPUAccessFlags,MiscFlags등)이 쓰레기 값이 아니라 0으로 보장되기 때문입니다. 0이 곧 "해당 기능 안 씀"을 의미하는 경우가 많아, 이 습관이 미묘한 버그를 막아 줍니다.vbd.ByteWidth = sizeof(verts);— 버퍼의 전체 크기(바이트)입니다.sizeof(verts)는 배열 전체 크기(정점 개수 × 정점 하나 크기)를 컴파일 타임에 계산합니다. 예를 들어float verts[9](정점 3개 × xyz 3개)라면9 × 4 = 36바이트입니다.vbd.Usage = D3D11_USAGE_DEFAULT;— 이 버퍼는 GPU가 주로 쓰고, CPU는 만든 뒤 손대지 않는 정적 데이터라는 뜻입니다. 삼각형 정점처럼 한 번 올리면 안 바뀌는 데이터에 적합하고, GPU 접근이 가장 빠릅니다.vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;— 이 버퍼를 정점 버퍼 용도로 쓰겠다고 선언합니다. D3D11은 용도를 미리 알아야 그에 맞게 메모리를 배치합니다. 같은 버퍼를 인덱스 버퍼로도 쓰려면| D3D11_BIND_INDEX_BUFFER처럼 플래그를 OR로 결합합니다.D3D11_SUBRESOURCE_DATA vinit{ verts, 0, 0 };— 버퍼를 만들면서 동시에 채울 초기 데이터를 지정합니다. 세 멤버는 순서대로{ pSysMem, SysMemPitch, SysMemSlicePitch }입니다.pSysMem = verts는 복사해 올 CPU 메모리 주소이고, 뒤의 두 값(행 간격·슬라이스 간격)은 1차원 버퍼에서는 의미가 없어 0입니다(2D/3D 텍스처에서만 씁니다).device->CreateBuffer(&vbd, &vinit, &vbo);— 실제 생성입니다. 세 인자를 하나씩 보면:
·&vbd— "무엇을 만들지" 스펙 구조체의 주소.
·&vinit— "무엇으로 채울지" 초기 데이터의 주소. (초기 데이터 없이 빈 버퍼를 만들려면nullptr.)
·&vbo— "만든 결과를 어디에 받을지".ComPtr에&를 붙였으므로ID3D11Buffer**가 넘어가 완성된 객체 포인터가 여기에 채워집니다.
D3D11_USAGE_DEFAULT 버퍼는 Map으로 CPU가 매 프레임 갱신할 수 없습니다.
갱신하려 하면 실패합니다. CPU가 자주 바꾸는 데이터(상수 버퍼 등)는 뒤의 상수 버퍼처럼 D3D11_USAGE_DYNAMIC + D3D11_CPU_ACCESS_WRITE로 만들어야 합니다.
3-3. 입력 레이아웃 — 바이트를 시맨틱에 매핑
D3D11_INPUT_ELEMENT_DESC elems[] = {
{ "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
device->CreateInputLayout(elems, 1, vsBlob->GetBufferPointer(),
vsBlob->GetBufferSize(), &layout);
무엇을 만드나: 정점 버퍼 안의 납작한 바이트 나열이 셰이더 입장에서 어떤 필드(위치·색·노멀 …)인지 알려 주는 "지도"를 만듭니다.
왜 필요한가: GPU는 0x3F800000 0x00000000 ...라는 바이트만 볼 뿐, 이게 위치인지 색인지 모릅니다.
입력 레이아웃이 "처음 12바이트는 POSITION이고 float 3개다"라고 알려 줘야 정점 셰이더가 올바르게 읽습니다. OpenGL의 VAO + glVertexAttribPointer와 정확히 같은 역할입니다.
D3D11_INPUT_ELEMENT_DESC의 일곱 멤버를, 예제의 { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 }에 대입해 봅시다.
| 멤버 | 예제 값 | 의미 |
|---|---|---|
SemanticName | "POSITION" | HLSL 정점 셰이더 입력의 : POSITION 시맨틱과 이름으로 짝을 맺습니다. |
SemanticIndex | 0 | 같은 시맨틱을 여러 개 쓸 때 구분(TEXCOORD0, TEXCOORD1 …). 하나뿐이면 0. |
Format | R32G32B32_FLOAT | 이 필드가 32비트 float 3개(=12바이트)임을 뜻합니다. R/G/B는 색이 아니라 그냥 "채널 3개"의 관례적 이름입니다. |
InputSlot | 0 | 어느 정점 버퍼 슬롯에서 읽을지. IASetVertexBuffers로 꽂은 슬롯 번호와 맞춥니다. |
AlignedByteOffset | 0 | 정점 구조체 안에서 이 필드가 시작하는 바이트 위치. 첫 필드라 0입니다. |
InputSlotClass | PER_VERTEX_DATA | 정점마다 다른 데이터(대부분 이것). 인스턴싱에서는 PER_INSTANCE_DATA를 씁니다. |
InstanceDataStepRate | 0 | 인스턴스 데이터 갱신 주기. 정점 단위 데이터면 0입니다. |
CreateInputLayout의 인자도 봅시다. elems는 방금 만든 원소 배열, 1은 원소 개수입니다.
그다음 vsBlob->GetBufferPointer()와 GetBufferSize()가 중요한데, D3D11은 컴파일된 정점 셰이더 바이트코드를
함께 받아 "이 레이아웃이 실제 셰이더 입력과 맞는지" 검증합니다. 그래서 레이아웃 생성에 셰이더 blob이 필요합니다.
마지막 &layout이 완성된 ID3D11InputLayout을 받습니다.
Format과 실제 정점 데이터의 크기가 어긋나면 삼각형이 찌그러지거나 안 나옵니다.
R32G32B32_FLOAT는 정확히 12바이트를 소비합니다. 정점에 위치(12) + 색(12)이 있는데 AlignedByteOffset을 색에 12로 안 적으면
색이 위치 바이트를 겹쳐 읽습니다. 여러 필드를 쓸 땐 오프셋을 D3D11_APPEND_ALIGNED_ELEMENT로 두면 자동 누적돼 실수를 줄일 수 있습니다.
3-4. 셰이더 생성 — blob에서 스테이지 객체로
device->CreateVertexShader(vsBlob->GetBufferPointer(), vsBlob->GetBufferSize(), nullptr, &vs);
device->CreatePixelShader (psBlob->GetBufferPointer(), psBlob->GetBufferSize(), nullptr, &ps);
무엇을 만드나: 4장에서 D3DCompile로 얻은 컴파일 결과(바이트코드 blob)를, 파이프라인에 꽂을 수 있는
스테이지별 셰이더 객체로 변환합니다. 왜 필요한가: blob은 그냥 바이트 덩어리라 그대로는 못 꽂습니다.
디바이스가 이를 GPU용 셰이더 객체로 등록해 줘야 VSSetShader로 바인딩할 수 있습니다.
OpenGL과의 큰 차이가 여기 드러납니다. OpenGL은 정점·픽셀 셰이더를 하나의 Program으로 링크해서 씁니다. D3D11은
스테이지마다 독립된 객체(ID3D11VertexShader, ID3D11PixelShader …)라, 정점 셰이더는 그대로 두고 픽셀 셰이더만 갈아 끼우는 조합이 자유롭습니다.
인자를 보면: 앞의 두 개는 blob의 시작 주소와 크기, 세 번째 nullptr은 클래스 링키지(동적 셰이더 링킹) 자리로 안 쓰면 nullptr,
마지막 &vs/&ps가 완성된 객체를 받습니다.
3-5. 상수 버퍼 — 매 프레임 바뀌는 유니폼
D3D11_BUFFER_DESC cbd{};
cbd.ByteWidth = 16; // float4, 16바이트 정렬
cbd.Usage = D3D11_USAGE_DYNAMIC; // CPU가 Map으로 갱신
cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
device->CreateBuffer(&cbd, nullptr, &cbuffer);
무엇을 만드나: 셰이더에 넘길 "유니폼" 값(여기서는 색상 float4 하나)을 담을, 매 프레임 CPU가 갱신할 수 있는 버퍼입니다.
왜 필요한가: 시간에 따라 변하는 값(색, 변환 행렬, 카메라 위치 …)을 셰이더에 전달하려면, 상수로 하드코딩할 수 없으니
CPU가 매 프레임 값을 밀어 넣을 수 있는 통로가 필요합니다. 그게 상수 버퍼입니다.
정점 버퍼와 Usage·플래그가 다른 점에 주목하세요. 이 버퍼는 "만든 뒤에도 CPU가 계속 쓴다"가 핵심입니다.
cbd.ByteWidth = 16;—float4(float 4개 × 4바이트 = 16바이트)입니다. 상수 버퍼는 크기가 반드시 16바이트의 배수여야 한다는 규칙이 있어, 정확히 딱 맞습니다. 색이float3(12바이트)여도 16으로 올림(패딩)해야 합니다. 이 16바이트 정렬 규칙은 GPU가 상수를 16바이트 레지스터 단위로 읽기 때문입니다.cbd.Usage = D3D11_USAGE_DYNAMIC;— CPU가 자주 쓰고 GPU가 읽는 용도입니다. 매 프레임Map으로 값을 갱신하려면 이 Usage여야 합니다.cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;— 상수 버퍼 용도임을 선언합니다.cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;— CPU가 쓰기 접근을 하겠다는 허가입니다.DYNAMIC+WRITE가Map을 쓰기 위한 짝입니다.device->CreateBuffer(&cbd, nullptr, &cbuffer);— 두 번째 인자가nullptr인 점에 주목하세요. 정점 버퍼와 달리 초기 데이터 없이 빈 버퍼로 만듭니다. 어차피 매 프레임Map으로 채울 것이기 때문입니다.
CreateBuffer가 실패하거나 셰이더가 값을 어긋나게 읽습니다.
또한 HLSL의 cbuffer 안에서 float3 뒤에 float를 붙이면 16바이트 경계를 넘지 않게 같은 16바이트 슬롯에 묶이는(packing) 규칙이 있어,
C++ 구조체와 HLSL cbuffer의 메모리 배치를 정확히 일치시키지 않으면 값이 밀립니다. C++ 쪽 구조체에 명시적 패딩 멤버를 넣어 맞추는 습관이 안전합니다.
4. 프레임 루프 파트 해부 — 컨텍스트가 그리는 순서
이제 매 프레임 반복되는 루프를 (a)~(f) 순서대로 봅니다. 이 순서에는 이유가 있습니다. 지운 뒤에 그려야 이전 프레임이 안 남고, 바인딩한 뒤에 드로우해야 GPU가 무엇으로 그릴지 알며, 다 그린 뒤에 Present해야 완성된 화면이 나갑니다.
4-0. Win32 메시지 펌프 — 창을 살아 있게 유지
MSG msg{};
while (msg.message != WM_QUIT) {
if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); continue; }
// ... 렌더링 ...
}
이 부분은 D3D가 아니라 Win32 윈도우 시스템의 코드입니다. 운영체제는 창에 일어나는 사건(마우스 클릭, 크기 변경, 닫기 버튼 …)을 메시지 큐에 쌓아 둡니다. 프로그램이 이 큐를 주기적으로 비워 주지 않으면 창이 "응답 없음"으로 얼어붙습니다.
PeekMessage(&msg, ..., PM_REMOVE)— 큐에 메시지가 있으면 하나 꺼내(PM_REMOVE)msg에 담고true를 반환합니다. 없으면 즉시false를 반환하고 넘어갑니다(블로킹하는GetMessage와 다른 핵심 차이). 그래서 메시지가 없는 대부분의 프레임에는 곧장 렌더링으로 갑니다.TranslateMessage는 키 입력을 문자 메시지로 변환하고,DispatchMessage는 메시지를 창 프로시저(WndProc)로 전달합니다.continue;— 메시지를 처리한 프레임은 렌더링을 건너뛰고 다음 반복으로 갑니다. 큐를 다 비운 뒤에야 렌더링이 돌아, 입력 반응성이 유지됩니다.- 사용자가 창을 닫으면
WM_QUIT가 들어와while조건이 거짓이 되어 루프가 끝납니다.
4-1. (a) 지우기와 렌더 타깃 바인딩
const float clear[4] = { 0.06f, 0.07f, 0.10f, 1.0f };
context->ClearRenderTargetView(rtv.Get(), clear);
context->ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0);
context->OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get());
무엇을 하나: 이번 프레임을 그리기 전에 캔버스를 깨끗이 지우고, 그림이 담길 타깃을 OM 스테이지에 꽂습니다.
const float clear[4] = { 0.06f, 0.07f, 0.10f, 1.0f };— 배경으로 채울 색을 RGBA 순서로 지정합니다. 각 값은 0.0~1.0 범위로, 여기서는 어두운 남색(R=0.06, G=0.07, B=0.10)에 알파 1.0(완전 불투명)입니다. 숫자를 그대로 읽으면 "거의 검정에 가까운 짙은 청회색"입니다.ClearRenderTargetView(rtv.Get(), clear)— 컬러 타깃(RTV)의 모든 픽셀을 위 색으로 덮어씁니다.rtv.Get()은 앞서 설명한 대로 원시 포인터를 읽기용으로 넘기는 것입니다.ClearDepthStencilView(dsv.Get(), D3D11_CLEAR_DEPTH, 1.0f, 0)— 깊이 타깃(DSV)을 초기화합니다. 세 번째 인자1.0f는 깊이 버퍼를 "가장 먼 값" 1.0으로 채우라는 뜻입니다. 규약상 클립 z는 [0, 1] 범위이고 1.0이 가장 멂이라, 이렇게 초기화해야 이후 그려질 모든 오브젝트가 "이보다는 가깝다"며 깊이 테스트를 통과합니다. 마지막0은 스텐실 초기값입니다. 두 번째 인자D3D11_CLEAR_DEPTH는 "깊이만 지우고 스텐실은 건드리지 마라"는 플래그입니다.OMSetRenderTargets(1, rtv.GetAddressOf(), dsv.Get())— 결과를 받을 타깃을 OM 스테이지에 꽂습니다. 첫 인자1은 컬러 타깃 개수, 둘째rtv.GetAddressOf()는 RTV 배열의 시작 주소(ID3D11RenderTargetView* const*), 셋째dsv.Get()은 깊이 타깃입니다. 여러 개의 RTV를 동시에 쓰는 MRT(Multiple Render Targets)에서는 첫 인자가 2, 3…이 됩니다.
참고로 순서상 Clear가 OMSetRenderTargets보다 앞에 있어도 됩니다. Clear* 함수는 인자로 받은 타깃을 직접 지우지,
"현재 바인딩된 타깃"을 지우는 게 아니기 때문입니다. 이것도 D3D11이 전역 상태에 덜 의존한다는 증거입니다.
4-2. (b) 셰이더와 입력 레이아웃 바인딩
context->IASetInputLayout(layout.Get());
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
context->VSSetShader(vs.Get(), nullptr, 0);
context->PSSetShader(ps.Get(), nullptr, 0);
IASetInputLayout(layout.Get())— 앞서 만든 입력 레이아웃을 IA 스테이지에 꽂아, 정점 바이트를 어떻게 해석할지 정합니다.IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST)— 정점들을 어떻게 이어 도형을 만들지 정합니다.TRIANGLELIST는 정점 3개마다 독립된 삼각형 하나로 묶습니다(0,1,2가 첫 삼각형, 3,4,5가 둘째 …). 선을 그리려면LINELIST, 삼각형들이 변을 공유하면TRIANGLESTRIP을 씁니다.VSSetShader(vs.Get(), nullptr, 0)/PSSetShader(ps.Get(), nullptr, 0)— 각각 정점·픽셀 셰이더를 해당 스테이지에 꽂습니다. 뒤의 두 인자(nullptr, 0)는 클래스 인스턴스 배열과 개수로, 동적 셰이더 링킹을 안 쓰면 항상nullptr, 0입니다.
4-3. (c) 상수 버퍼 갱신 — Map / memcpy / Unmap
D3D11_MAPPED_SUBRESOURCE m{};
context->Map(cbuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &m);
float color[4] = { 0.9f, 0.4f, 0.2f, 1.0f };
memcpy(m.pData, color, sizeof(color));
context->Unmap(cbuffer.Get(), 0);
context->PSSetConstantBuffers(0, 1, cbuffer.GetAddressOf());
무엇을 하나: GPU 메모리에 있는 상수 버퍼를 CPU가 잠깐 "열어(Map)" 새 색상 값을 써넣고 다시 "닫는(Unmap)" 과정입니다.
왜 이렇게 하나: GPU 버퍼는 CPU가 직접 포인터로 못 만집니다. Map이 임시로 CPU가 접근 가능한 주소를 빌려주면,
거기에 데이터를 복사한 뒤 Unmap으로 반납해야 GPU가 안전하게 그 버퍼를 읽습니다.
D3D11_MAPPED_SUBRESOURCE m{};—Map결과를 받을 구조체입니다. 핵심 멤버는m.pData(빌린 메모리의 시작 주소)입니다.Map(cbuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &m)— 인자 순서대로:
·cbuffer.Get()— 열 버퍼.
·0— 서브리소스 인덱스(버퍼는 서브리소스가 하나뿐이라 0).
·D3D11_MAP_WRITE_DISCARD— "기존 내용은 버리고 통째로 새로 쓰겠다"는 모드. 이게 중요한 성능 힌트입니다. GPU가 아직 이전 버퍼를 읽는 중이어도, 드라이버가 새 메모리 블록을 즉석에서 내줘 CPU가 기다리지 않게 해 줍니다.
·0— 추가 플래그(없음).
·&m— 결과(빌린 주소)를 받을 구조체.float color[4] = { 0.9f, 0.4f, 0.2f, 1.0f };— 셰이더에 넘길 색상. RGBA로 읽으면 (R=0.9, G=0.4, B=0.2) → 따뜻한 주황색, 알파 1.0(불투명)입니다.memcpy(m.pData, color, sizeof(color))— 빌린 GPU 메모리(m.pData)에 CPU의 색 배열을 16바이트(sizeof(float[4])) 복사합니다.Unmap(cbuffer.Get(), 0)— 접근을 끝내고 버퍼를 GPU에 반납합니다. 이걸 빠뜨리면 GPU가 버퍼를 못 읽어 값이 반영되지 않거나 디버그 레이어가 경고합니다.PSSetConstantBuffers(0, 1, cbuffer.GetAddressOf())— 이 상수 버퍼를 픽셀 셰이더의 슬롯 0(b0)에 꽂습니다. 첫 인자0이 시작 슬롯,1이 개수입니다. HLSL 쪽에서cbuffer Params : register(b0)로 선언한 것과 이 슬롯 0이 짝을 이룹니다.
4-4. HLSL 쪽 — 상수 버퍼를 받는 픽셀 셰이더
위 C++가 슬롯 b0에 꽂은 색을, HLSL에서는 아래처럼 받습니다. C++의 register(b0) ↔ PSSetConstantBuffers(0, ...) 대응을 눈으로 확인하세요.
// C++에서 PSSetConstantBuffers(0, ...)로 꽂은 버퍼가 여기 b0에 들어옵니다.
cbuffer Params : register(b0)
{
float4 uColor; // C++의 color[4] = {0.9, 0.4, 0.2, 1.0} 가 그대로 들어옴
};
float4 PSMain(float4 pos : SV_Position) : SV_Target
{
return uColor; // 모든 픽셀을 이 색으로 칠함
}
cbuffer Params : register(b0)— 상수 버퍼 블록을 슬롯b0에 바인딩합니다. C++가PSSetConstantBuffers(0, ...)로 꽂은 슬롯 0과 일치해야 값이 들어옵니다.float4 uColor;— 16바이트짜리 float 4개. C++에서memcpy로 채운{0.9, 0.4, 0.2, 1.0}이 이 변수에 그대로 매핑됩니다. 메모리 배치가 1:1로 일치하기에 별도 파싱 없이 곧장 읽힙니다.float4 PSMain(float4 pos : SV_Position) : SV_Target— 픽셀 셰이더의 진입점입니다. 입력SV_Position은 래스터라이저가 채워 주는 픽셀의 화면 좌표, 반환의: SV_Target은 "이 값을 렌더 타깃(RTV)에 쓰라"는 시스템 시맨틱입니다.SV_접두사는 System-Value, 즉 파이프라인이 의미를 아는 특수 시맨틱을 뜻합니다.return uColor;— 픽셀 위치와 무관하게 모든 픽셀을 같은 색으로 칠합니다. 그래서 삼각형이 단색 주황으로 나옵니다.
v' = v * M, HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 z [0, 1], 텍스처 V 위→아래를 따릅니다.
이 예제에는 행렬이 없지만, 상수 버퍼로 변환 행렬을 넘길 때는 DirectXMath가 행벡터를 쓰므로 cbuffer 업로드 전에 XMMatrixTranspose로 전치해 주어야 HLSL mul(v, M)과 맞습니다. 이 규약은 다음 절들에서 반복됩니다.
4-5. (d) 정점 버퍼 바인딩 — stride와 offset
UINT stride = 3 * sizeof(float), offset = 0;
context->IASetVertexBuffers(0, 1, vbo.GetAddressOf(), &stride, &offset);
UINT stride = 3 * sizeof(float)— 정점 하나의 바이트 크기입니다. 정점이 float 3개(xyz)라3 × 4 = 12바이트입니다. GPU가 다음 정점으로 넘어갈 때 포인터를 12바이트씩 전진시키라는 뜻입니다. 위치 뒤에 색까지 있다면6 * sizeof(float) = 24가 되어야 합니다.UINT offset = 0— 버퍼 안에서 첫 정점이 시작하는 바이트 위치입니다. 버퍼 맨 앞부터 읽으므로 0입니다. 한 버퍼에 여러 메시를 몰아 넣고 뒤쪽 메시부터 읽을 때만 0이 아닌 값을 씁니다.IASetVertexBuffers(0, 1, vbo.GetAddressOf(), &stride, &offset)— 정점 버퍼를 IA 슬롯 0에 꽂습니다. 인자 순서: 시작 슬롯0, 버퍼 개수1, 버퍼 배열 주소, stride 배열 주소, offset 배열 주소. stride/offset을 배열 주소(&)로 넘기는 이유는 여러 버퍼를 동시에 꽂을 때 각 버퍼가 자기 stride/offset을 갖도록 배열로 받기 때문입니다.
stride를 실제 정점 크기와 다르게 주면 GPU가 정점 경계를 잘못 잡아 삼각형이 어긋나거나 화면이 깨집니다.
입력 레이아웃의 Format 합계(여기선 12바이트)와 stride가 일치해야 합니다. 이 두 곳을 따로 관리하다 어긋나는 게 흔한 버그입니다.
4-6. (e) 드로우 — 상태 스냅샷으로 그리기
context->Draw(3, 0);
무엇을 하나: 지금까지 꽂아 둔 모든 상태(입력 레이아웃·셰이더·정점 버퍼·상수 버퍼·타깃)를 하나의 스냅샷으로 확정하고, GPU에게 그리라고 명령합니다. 인자를 봅시다.
- 첫 인자
3— 그릴 정점 개수입니다. 삼각형 하나 = 정점 3개. - 둘째 인자
0— 시작 정점 인덱스입니다. 버퍼의 0번 정점부터 읽습니다. - 토폴로지가
TRIANGLELIST이므로, GPU는 정점 3개(0,1,2)를 한 삼각형으로 조립해 래스터라이즈하고, 각 픽셀마다 픽셀 셰이더를 실행해 주황색을 칠합니다.
구체적으로 세어 봅시다. 화면에 삼각형이 대략 N개의 픽셀을 덮는다면, 정점 셰이더는 3번(정점마다),
픽셀 셰이더는 약 N번(덮은 픽셀마다) 실행됩니다. 정점 대비 픽셀 셰이더 실행 횟수가 압도적으로 많아, 픽셀 셰이더 비용이 성능을 좌우하는 이유가 여기 있습니다.
OMSetRenderTargets를 안 해 RTV가 안 꽂혀 아무 데도 안 그려짐,
② IASetInputLayout/IASetPrimitiveTopology를 빠뜨려 정점 해석이 안 됨,
③ ClearDepthStencilView를 빠뜨려 이전 프레임 깊이가 남아 새 오브젝트가 전부 깊이 테스트에서 탈락.
검은 화면이면 이 3개부터 확인하세요.
4-7. (f) Present — 완성된 화면 내보내기
swapChain->Present(1, 0); // 첫 인자 = SyncInterval (1 = V-Sync ON)
다 그린 백버퍼를 프론트버퍼(모니터가 표시 중인 버퍼)와 교체해 화면에 내보냅니다.
첫 인자 1이 SyncInterval(V-Sync 제어), 둘째 0이 추가 플래그입니다. 이 부분은 아래 더블 버퍼링 절에서 더 자세히 다룹니다.
5. 더블 버퍼링과 V-Sync
화면에 그리는 중인 버퍼(백버퍼)와 모니터에 표시 중인 버퍼(프론트버퍼)를 분리한 것이 더블 버퍼링입니다.
만약 버퍼가 하나뿐이라면, 그리는 도중의 반쯤 완성된 화면이 모니터에 그대로 나가 깜빡임과 찢김이 생깁니다.
D3D11에서는 DXGI 스왑체인이 이 버퍼들을 관리하고, 그리기가 끝나면 swapChain->Present로 통째로 교체하므로
사용자는 반쯤 그려진 화면(테어링)을 보지 않습니다.
교체 타이밍을 모니터 주사율에 맞추는 것이 V-Sync입니다. 모니터는 초당 정해진 횟수(예: 60Hz = 초당 60번)만 화면을 갱신하는데,
그 갱신과 무관한 타이밍에 버퍼를 바꾸면 한 화면에 두 프레임이 섞여 테어링(tearing, 가로로 찢긴 선)이 보입니다.
D3D11에서는 Present의 첫 인자 SyncInterval로 이 타이밍을 제어합니다.
swapChain->Present(1, 0); // 1 = V-Sync ON (60Hz 모니터면 ~60fps로 상한)
swapChain->Present(0, 0); // 0 = V-Sync OFF (무제한 fps, 테어링 가능 — 벤치마크용)
swapChain->Present(2, 0); // 2 = 주사율의 1/2 (60Hz → 30fps)
Present(1, 0)— SyncInterval 1. 모니터 갱신 1번마다 1번 교체합니다. 60Hz면 최대 60fps로 상한이 걸리고 테어링이 없습니다. 가장 흔한 배포 설정입니다.Present(0, 0)— SyncInterval 0. 갱신을 안 기다리고 즉시 교체합니다. fps가 무제한이라 성능 측정에 좋지만 테어링이 생깁니다.Present(2, 0)— SyncInterval 2. 모니터 갱신 2번마다 1번 교체. 60Hz에서 30fps로 상한이 걸립니다. 무거운 씬을 안정적인 30fps로 고정할 때 씁니다.
DXGI_PRESENT_ALLOW_TEARING을 씁니다.)
6. DirectXMath / D3D API 1:1 대응 정리
이 페이지의 핵심 호출들을, 실무에서 함께 쓰는 API·개념과 짝지어 정리합니다. 직접 만들며 원리를 이해한 뒤에는 검증된 라이브러리와 표준 헬퍼를 쓰는 것이 안전합니다.
| 이 페이지의 호출 | 대응 개념 / API | 비고 |
|---|---|---|
device->CreateBuffer | OpenGL glGenBuffers+glBufferData | D3D는 DESC로 스펙을 미리 확정, OpenGL은 바인딩 후 채움 |
CreateInputLayout | OpenGL VAO+glVertexAttribPointer | 바이트 배치를 셰이더 시맨틱에 매핑 |
Map/Unmap(DYNAMIC) | OpenGL glMapBufferRange / glBufferSubData | 매 프레임 유니폼 갱신 통로 |
cbuffer : register(b0) | PSSetConstantBuffers(0, ...) | HLSL 슬롯 번호 ↔ C++ 슬롯 번호가 일치해야 함 |
| 상수 버퍼에 담을 행렬 | XMMatrixTranspose 후 업로드 | DirectXMath는 행벡터, HLSL mul(v,M)과 맞추려면 전치 |
float4/16바이트 정렬 | XMFLOAT4 / XMVECTOR | XMVECTOR는 16바이트 SIMD 정렬 타입 |
ComPtr 자동 해제 | COM AddRef/Release | std::shared_ptr과 달리 객체 자체의 참조 카운트 이용 |
swapChain->Present | OpenGL SwapBuffers / EGL eglSwapBuffers | SyncInterval이 V-Sync 제어 |
Create*·Map의 HRESULT를
반드시 검사하고(다음 절 "디버깅과 검증"에서 다룹니다), 디바이스·스왑체인 생성과 리사이즈 처리, 리소스 수명 관리를 더 견고하게 갖춰야 합니다.