← 개념 · 데모 가이드 | 구현 허브 | 5장. 그래픽스 API | D3D11의 두 축 — 디바이스와 즉시 컨텍스트
IMPLEMENTATION · CHAPTER 5

D3D11의 두 축 — 디바이스와 즉시 컨텍스트 — 한 줄 한 줄 직접 구현

Direct3D 11을 처음 열어 보면 ID3D11DeviceID3D11DeviceContext라는 두 인터페이스가 나옵니다. 이름이 비슷해서 헷갈리기 쉽지만, 둘은 역할이 완전히 다릅니다. 디바이스는 리소스를 만드는 공장이고, 컨텍스트는 그 리소스를 파이프라인에 꽂고 그림을 그리는 작업자입니다. 이 두 축을 확실히 구분하는 순간, D3D11 코드가 왜 "만들기 → 꽂기 → 그리기"라는 리듬을 반복하는지 한눈에 보이기 시작합니다. 이 페이지에서는 그 리듬을 이루는 표준 렌더 루프를, 함수 시그니처와 인자 하나하나까지 입문자 눈높이로 해부합니다.

1. 두 인터페이스: 공장과 작업자

D3D11의 API 표면은 크게 두 덩어리로 나뉩니다. 하나는 디바이스(ID3D11Device), 다른 하나는 즉시 컨텍스트(ID3D11DeviceContext)입니다. 이 둘의 이름은 비슷하지만 담당하는 일이 겹치지 않도록 설계되어 있습니다.

OpenGL을 먼저 배운 분이라면 이 분리가 낯설 수 있습니다. OpenGL은 glBindBuffer로 "현재 바인딩된 대상"을 바꾸고 glBufferData로 그 대상을 채우는, 이른바 전역 상태 머신(global state machine)입니다. "지금 무엇이 바인딩되어 있느냐"라는 숨은 문맥에 의존하죠. 반면 D3D11은 객체를 명시적으로 만든 뒤, 그 객체 포인터를 컨텍스트의 슬롯에 직접 꽂는 모델입니다. 숨은 전역 상태가 적어 코드가 길어지는 대신, "지금 무엇이 바인딩되어 있는가"가 코드에 그대로 드러나 추적하기 쉽습니다.

  1. 생성(device->Create*)D3D11_*_DESC로 리소스 스펙을 채워 넘기면 완성된 COM 객체(ID3D11Buffer 등)를 돌려받습니다. 이 시점에 메모리·내용이 확정됩니다.
  2. 바인딩(context->*Set*) — 그 객체 포인터를 파이프라인 스테이지 슬롯(IA/VS/PS/RS/OM …)에 꽂습니다.
  3. 드로우(context->Draw*) — 지금까지 꽂힌 상태 스냅샷으로 GPU가 그립니다.
  4. 해제(COM 참조 카운트)ComPtr가 스코프를 벗어나면 Release()가 자동 호출됩니다. OpenGL의 glDelete* 같은 명시적 삭제가 필요 없습니다.

파이프라인 스테이지와 슬롯이라는 개념

바인딩을 이해하려면 파이프라인 스테이지(stage)라는 그림을 먼저 머릿속에 그려야 합니다. GPU가 삼각형 하나를 화면 픽셀로 바꾸는 과정은 컨베이어 벨트처럼 여러 단계로 나뉩니다. 대표적으로:

슬롯(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 LayoutIASetInputLayout정점 버퍼의 바이트 배치를 셰이더 입력 시맨틱에 매핑(OpenGL의 VAO+VertexAttribPointer 역할)
Texture2D + SRVPSSetShaderResources이미지 데이터. 셰이더에서 읽으려면 ShaderResourceView로 감싸 바인딩
RTV / DSVOMSetRenderTargets렌더 결과를 받는 컬러 타깃(RTV)과 깊이/스텐실 타깃(DSV)
Shader (VS/PS/…)VSSetShader / PSSetShader스테이지별 컴파일된 셰이더. OpenGL의 링크된 Program 대신 스테이지마다 독립 객체
State objectRSSetState / OMSetBlendState / OMSetDepthStencilState래스터·블렌드·뎁스스텐실 설정을 굳힌 불변 상태 블록

2. 표준 렌더 루프 골격

이제 앞의 개념들을 실제 코드로 꿰어 봅시다. 거의 모든 D3D11 프로그램은 아래 순서를 프레임마다 반복합니다.

  1. 지우기 — 컬러 타깃(RTV)과 깊이 타깃(DSV)을 초기화해 이전 프레임 흔적을 없앱니다.
  2. 셰이더·입력 레이아웃 바인딩 — 어떤 셰이더로, 정점을 어떻게 해석할지 정합니다.
  3. 정점 버퍼·상수 버퍼 바인딩 — 그릴 데이터와 유니폼 값을 슬롯에 꽂습니다.
  4. 드로우 — 지금까지 꽂힌 상태로 GPU에게 그리라고 명령합니다.
  5. Present — 다 그린 백버퍼를 화면에 표시합니다.

코드는 루프 밖에서 한 번만 하는 초기화(생성)매 프레임 반복하는 루프(바인딩·드로우)로 나뉩니다. 이 구분이 곧 "디바이스로 만들고 → 컨텍스트로 그린다"라는 두 축의 분업을 그대로 보여줍니다. (디바이스·스왑체인 자체를 만드는 부분은 지면상 생략하고 이후 절에서 다룹니다.)

render_loop.cppcpp
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 — 잊어도 새지 않는 스마트 포인터

render_loop.cpp (발췌)cpp
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이 자기만의 카운터로 일반 객체를 관리한다면, ComPtrCOM 객체가 스스로 들고 있는 참조 카운트를 이용한다는 점이 다릅니다.

함정 ComPtr에서 값을 받을 때 자주 쓰는 두 메서드를 헷갈리면 안 됩니다. .Get()원시 포인터(T*)를 그대로 꺼내 "읽기용"으로 넘길 때(예: context->VSSetShader(vs.Get(), ...)) 쓰고, .GetAddressOf()포인터의 주소(T**)를 넘겨 "여기에 결과를 채워 달라" 하거나 배열 시작 주소로 쓸 때 씁니다. &vbo처럼 ComPtr에 직접 &를 붙이면 내부적으로 기존 참조를 Release()하고 T**를 돌려주므로, 새로 만든 객체를 받는 Create*의 마지막 인자로 적합합니다.

3-2. 정점 버퍼 생성 — DESC로 스펙을 적어 CreateBuffer

render_loop.cpp (발췌)cpp
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_USAGE_DEFAULT 버퍼는 Map으로 CPU가 매 프레임 갱신할 수 없습니다. 갱신하려 하면 실패합니다. CPU가 자주 바꾸는 데이터(상수 버퍼 등)는 뒤의 상수 버퍼처럼 D3D11_USAGE_DYNAMIC + D3D11_CPU_ACCESS_WRITE로 만들어야 합니다.

3-3. 입력 레이아웃 — 바이트를 시맨틱에 매핑

render_loop.cpp (발췌)cpp
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 시맨틱과 이름으로 짝을 맺습니다.
SemanticIndex0같은 시맨틱을 여러 개 쓸 때 구분(TEXCOORD0, TEXCOORD1 …). 하나뿐이면 0.
FormatR32G32B32_FLOAT이 필드가 32비트 float 3개(=12바이트)임을 뜻합니다. R/G/B는 색이 아니라 그냥 "채널 3개"의 관례적 이름입니다.
InputSlot0어느 정점 버퍼 슬롯에서 읽을지. IASetVertexBuffers로 꽂은 슬롯 번호와 맞춥니다.
AlignedByteOffset0정점 구조체 안에서 이 필드가 시작하는 바이트 위치. 첫 필드라 0입니다.
InputSlotClassPER_VERTEX_DATA정점마다 다른 데이터(대부분 이것). 인스턴싱에서는 PER_INSTANCE_DATA를 씁니다.
InstanceDataStepRate0인스턴스 데이터 갱신 주기. 정점 단위 데이터면 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에서 스테이지 객체로

render_loop.cpp (발췌)cpp
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. 상수 버퍼 — 매 프레임 바뀌는 유니폼

render_loop.cpp (발췌)cpp
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가 계속 쓴다"가 핵심입니다.

함정 상수 버퍼 크기를 16의 배수로 안 맞추면 CreateBuffer가 실패하거나 셰이더가 값을 어긋나게 읽습니다. 또한 HLSL의 cbuffer 안에서 float3 뒤에 float를 붙이면 16바이트 경계를 넘지 않게 같은 16바이트 슬롯에 묶이는(packing) 규칙이 있어, C++ 구조체와 HLSL cbuffer의 메모리 배치를 정확히 일치시키지 않으면 값이 밀립니다. C++ 쪽 구조체에 명시적 패딩 멤버를 넣어 맞추는 습관이 안전합니다.

4. 프레임 루프 파트 해부 — 컨텍스트가 그리는 순서

이제 매 프레임 반복되는 루프를 (a)~(f) 순서대로 봅니다. 이 순서에는 이유가 있습니다. 지운 뒤에 그려야 이전 프레임이 안 남고, 바인딩한 뒤에 드로우해야 GPU가 무엇으로 그릴지 알며, 다 그린 뒤에 Present해야 완성된 화면이 나갑니다.

4-0. Win32 메시지 펌프 — 창을 살아 있게 유지

render_loop.cpp (발췌)cpp
MSG msg{};
while (msg.message != WM_QUIT) {
    if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); continue; }
    // ... 렌더링 ...
}

이 부분은 D3D가 아니라 Win32 윈도우 시스템의 코드입니다. 운영체제는 창에 일어나는 사건(마우스 클릭, 크기 변경, 닫기 버튼 …)을 메시지 큐에 쌓아 둡니다. 프로그램이 이 큐를 주기적으로 비워 주지 않으면 창이 "응답 없음"으로 얼어붙습니다.

4-1. (a) 지우기와 렌더 타깃 바인딩

render_loop.cpp (발췌)cpp
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 스테이지에 꽂습니다.

참고로 순서상 ClearOMSetRenderTargets보다 앞에 있어도 됩니다. Clear* 함수는 인자로 받은 타깃을 직접 지우지, "현재 바인딩된 타깃"을 지우는 게 아니기 때문입니다. 이것도 D3D11이 전역 상태에 덜 의존한다는 증거입니다.

4-2. (b) 셰이더와 입력 레이아웃 바인딩

render_loop.cpp (발췌)cpp
context->IASetInputLayout(layout.Get());
context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
context->VSSetShader(vs.Get(), nullptr, 0);
context->PSSetShader(ps.Get(), nullptr, 0);

4-3. (c) 상수 버퍼 갱신 — Map / memcpy / Unmap

render_loop.cpp (발췌)cpp
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가 안전하게 그 버퍼를 읽습니다.

4-4. HLSL 쪽 — 상수 버퍼를 받는 픽셀 셰이더

위 C++가 슬롯 b0에 꽂은 색을, HLSL에서는 아래처럼 받습니다. C++의 register(b0)PSSetConstantBuffers(0, ...) 대응을 눈으로 확인하세요.

solid_color.hlslhlsl
// 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;   // 모든 픽셀을 이 색으로 칠함
}
주의 이 페이지는 행벡터 규약(v' = v * M, HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 z [0, 1], 텍스처 V 위→아래를 따릅니다. 이 예제에는 행렬이 없지만, 상수 버퍼로 변환 행렬을 넘길 때는 DirectXMath가 행벡터를 쓰므로 cbuffer 업로드 전에 XMMatrixTranspose로 전치해 주어야 HLSL mul(v, M)과 맞습니다. 이 규약은 다음 절들에서 반복됩니다.

4-5. (d) 정점 버퍼 바인딩 — stride와 offset

render_loop.cpp (발췌)cpp
UINT stride = 3 * sizeof(float), offset = 0;
context->IASetVertexBuffers(0, 1, vbo.GetAddressOf(), &stride, &offset);
함정 stride를 실제 정점 크기와 다르게 주면 GPU가 정점 경계를 잘못 잡아 삼각형이 어긋나거나 화면이 깨집니다. 입력 레이아웃의 Format 합계(여기선 12바이트)와 stride가 일치해야 합니다. 이 두 곳을 따로 관리하다 어긋나는 게 흔한 버그입니다.

4-6. (e) 드로우 — 상태 스냅샷으로 그리기

render_loop.cpp (발췌)cpp
context->Draw(3, 0);

무엇을 하나: 지금까지 꽂아 둔 모든 상태(입력 레이아웃·셰이더·정점 버퍼·상수 버퍼·타깃)를 하나의 스냅샷으로 확정하고, GPU에게 그리라고 명령합니다. 인자를 봅시다.

구체적으로 세어 봅시다. 화면에 삼각형이 대략 N개의 픽셀을 덮는다면, 정점 셰이더는 3번(정점마다), 픽셀 셰이더는 약 N번(덮은 픽셀마다) 실행됩니다. 정점 대비 픽셀 셰이더 실행 횟수가 압도적으로 많아, 픽셀 셰이더 비용이 성능을 좌우하는 이유가 여기 있습니다.

함정 드로우 순서를 헷갈리면 화면이 검게 나옵니다. 대표 원인 3가지: ① OMSetRenderTargets를 안 해 RTV가 안 꽂혀 아무 데도 안 그려짐, ② IASetInputLayout/IASetPrimitiveTopology를 빠뜨려 정점 해석이 안 됨, ③ ClearDepthStencilView를 빠뜨려 이전 프레임 깊이가 남아 새 오브젝트가 전부 깊이 테스트에서 탈락. 검은 화면이면 이 3개부터 확인하세요.

4-7. (f) Present — 완성된 화면 내보내기

render_loop.cpp (발췌)cpp
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로 이 타이밍을 제어합니다.

vsync.cppcpp
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)
성능 성능 측정 중이라면 V-Sync를 꺼야 실제 프레임 비용이 보입니다(켜져 있으면 Present에서 대기하느라 CPU가 놀아 측정값이 왜곡됨). 반대로 배포 빌드는 V-Sync ON이 기본입니다 — GPU를 불필요하게 100% 돌려 발열·전력을 낭비하지 않으려면요. (테어링 없는 무제한 fps가 필요하면 플립 모델 스왑체인 + DXGI_PRESENT_ALLOW_TEARING을 씁니다.)

6. DirectXMath / D3D API 1:1 대응 정리

이 페이지의 핵심 호출들을, 실무에서 함께 쓰는 API·개념과 짝지어 정리합니다. 직접 만들며 원리를 이해한 뒤에는 검증된 라이브러리와 표준 헬퍼를 쓰는 것이 안전합니다.

이 페이지의 호출대응 개념 / API비고
device->CreateBufferOpenGL glGenBuffers+glBufferDataD3D는 DESC로 스펙을 미리 확정, OpenGL은 바인딩 후 채움
CreateInputLayoutOpenGL VAO+glVertexAttribPointer바이트 배치를 셰이더 시맨틱에 매핑
Map/Unmap(DYNAMIC)OpenGL glMapBufferRange / glBufferSubData매 프레임 유니폼 갱신 통로
cbuffer : register(b0)PSSetConstantBuffers(0, ...)HLSL 슬롯 번호 ↔ C++ 슬롯 번호가 일치해야 함
상수 버퍼에 담을 행렬XMMatrixTranspose 후 업로드DirectXMath는 행벡터, HLSL mul(v,M)과 맞추려면 전치
float4/16바이트 정렬XMFLOAT4 / XMVECTORXMVECTOR는 16바이트 SIMD 정렬 타입
ComPtr 자동 해제COM AddRef/Releasestd::shared_ptr과 달리 객체 자체의 참조 카운트 이용
swapChain->PresentOpenGL SwapBuffers / EGL eglSwapBuffersSyncInterval이 V-Sync 제어
주의 이 예제 코드들은 학습용으로 최소 뼈대만 담았습니다. 실무에서는 모든 Create*·MapHRESULT를 반드시 검사하고(다음 절 "디버깅과 검증"에서 다룹니다), 디바이스·스왑체인 생성과 리사이즈 처리, 리소스 수명 관리를 더 견고하게 갖춰야 합니다.