← 개념 · 데모 가이드 | 구현 허브 | 5장. 그래픽스 API | 리소스 관리와 상태 팁
IMPLEMENTATION · CHAPTER 5

리소스 관리와 상태 팁 — 한 줄 한 줄 직접 구현

Direct3D 11에서 만드는 버퍼·텍스처·셰이더·상태 객체는 전부 COM 객체입니다. COM 객체는 "참조 카운트"라는 숫자로 자기 수명을 스스로 관리하는데, 이 숫자를 손으로 맞추다 보면 누수(Release를 안 함)나 이중 해제(Release를 두 번 함)가 반드시 생깁니다. 이 페이지에서는 (1) ComPtr이라는 스마트 포인터가 그 숫자를 자동으로 맞춰 주는 원리를 한 줄씩 뜯어보고, (2) 프레임마다 반복되는 상태 전환 비용이 왜 드로우 콜 자체보다 비싼지, 그리고 정렬·배칭·인스턴싱으로 그걸 어떻게 줄이는지를 입문자 눈높이로 최대한 자세히 설명합니다. 코드 로직은 원본 그대로 두고, 설명만 극한으로 늘렸습니다.

0. 왜 "수명"과 "상태"가 별도의 주제인가

그래픽스 코드를 처음 짜면 "삼각형을 화면에 띄우는 것"에만 집중하게 됩니다. 그런데 실제 게임/엔진은 수백 개의 메시, 수십 장의 텍스처, 여러 개의 셰이더와 상태 객체를 매 프레임 다룹니다. 이때 두 가지가 성능과 안정성을 좌우합니다.

아래 1절은 수명 문제를 ComPtr로, 2절은 상태 문제를 정렬·배칭으로 해결합니다. 두 주제 모두 "런타임(D3D11 드라이버)이 대신 해 주던 일"을 이해하는 것이 핵심이라, 다음 페이지인 D3D12·Vulkan 비교의 밑바탕이 되기도 합니다.

1. ComPtr: 스마트 포인터가 Release를 자동 호출

1.1 무엇을 만드나 — 그리고 왜 필요한가

무엇을 만드나: COM 리소스(버퍼·레이아웃 등)를 원시 포인터가 아니라 ComPtr<T>이라는 스마트 포인터로 들고 다니는 Mesh 구조체와, 그것을 채워 주는 createMesh 함수를 만듭니다.

왜 필요한가: D3D11의 모든 리소스는 ID3D11Buffer, ID3D11Texture2D처럼 ID3D11...로 시작하는 COM 인터페이스입니다. COM 객체는 내부에 "지금 나를 몇 곳에서 붙잡고 있나"를 세는 참조 카운트를 가지고 있고, 이 숫자가 0이 되는 순간 자기 자신을 파괴하며 GPU 메모리를 반납합니다. 규칙은 두 가지뿐입니다.

문제는 이 AddRef/Release사람이 손으로 맞춰야 한다는 것입니다. 함수가 중간에 return하거나 예외가 나면 Release를 빼먹기 쉽고(누수), 실수로 두 번 부르면 이미 파괴된 메모리를 또 건드립니다(이중 해제 → 크래시). C++의 RAII(Resource Acquisition Is Initialization: "객체가 살아 있는 동안만 자원을 쥔다") 관용구를 쓰면 이 짝을 컴파일러가 대신 맞춰 줍니다. 그 도구가 바로 Microsoft::WRL::ComPtr입니다.

  1. ComPtr<T>이 소멸(스코프 탈출)하면 소멸자가 자동으로 Release() 호출
  2. ComPtr을 복사하면 자동 AddRef(), 대입하면 기존 것 Release() 후 새 것 AddRef()
  3. 생성 API에는 ReleaseAndGetAddressOf()로 out 파라미터(T**)를 넘겨 소유권을 받는다
  4. 바인딩 등 "잠깐 빌려 쓸" 때는 Get()으로 원시 포인터만 꺼낸다(카운트 안 건드림)
d3d_resources.hppcpp
#include <d3d11.h>
#include <wrl/client.h>
using Microsoft::WRL::ComPtr;

// ComPtr는 COM의 참조 카운트를 RAII로 감싼다.
// - 스코프를 벗어나면 소멸자가 Release()를 호출 → 카운트 0이면 GPU 리소스 반납
// - 복사하면 AddRef, 대입하면 이전 것 Release — 이중 해제가 구조적으로 불가능
struct Mesh {
    ComPtr<ID3D11Buffer>      vbo;    // 정점 버퍼
    ComPtr<ID3D11Buffer>      ebo;    // 인덱스 버퍼
    ComPtr<ID3D11InputLayout> layout; // 정점 레이아웃
    UINT indexCount = 0;
};

// 생성: &를 넘기면 out 파라미터로 채워지고, ComPtr가 소유권을 갖는다.
Mesh createMesh(ID3D11Device* device, const void* verts, UINT vbytes,
                const UINT16* idx, UINT icount) {
    Mesh m;
    D3D11_BUFFER_DESC vbd{};
    vbd.ByteWidth = vbytes;
    vbd.Usage     = D3D11_USAGE_IMMUTABLE;          // 만든 뒤 바뀌지 않음
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    D3D11_SUBRESOURCE_DATA vinit{ verts, 0, 0 };
    device->CreateBuffer(&vbd, &vinit, m.vbo.ReleaseAndGetAddressOf());

    D3D11_BUFFER_DESC ibd{};
    ibd.ByteWidth = icount * sizeof(UINT16);
    ibd.Usage     = D3D11_USAGE_IMMUTABLE;
    ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
    D3D11_SUBRESOURCE_DATA iinit{ idx, 0, 0 };
    device->CreateBuffer(&ibd, &iinit, m.ebo.ReleaseAndGetAddressOf());

    m.indexCount = icount;
    return m; // ComPtr는 이동 가능 — 반환 시 복사 없이 소유권 이전
}
// Mesh가 소멸하면 vbo/ebo/layout이 각각 Release — 수동 해제 코드가 전혀 없다.

1.2 헤더와 using — 도구를 손에 쥐기

#include <d3d11.h>ID3D11Buffer, ID3D11Device, D3D11_BUFFER_DESC 같은 D3D11의 모든 타입 선언을 끌어옵니다. #include <wrl/client.h>는 Windows Runtime C++ Template Library(WRL)의 한 조각으로, ComPtr이 여기 들어 있습니다. WRL은 Windows SDK에 기본 포함이라 별도 설치가 필요 없습니다.

using Microsoft::WRL::ComPtr;Microsoft::WRL::ComPtr<...>이라고 매번 길게 쓰지 않고 그냥 ComPtr<...>이라고 쓰겠다는 이름 축약입니다. 전체 네임스페이스를 using namespace ...로 여는 것보다 딱 필요한 이름 하나만 여는 이 방식이 이름 충돌을 줄여 더 안전합니다.

1.3 Mesh 구조체 — 소유권을 타입에 새겨 넣기

Mesh는 정점 버퍼(vbo), 인덱스 버퍼(ebo), 입력 레이아웃(layout), 그리고 인덱스 개수(indexCount)를 담는 단순한 묶음입니다. 여기서 결정적인 부분은 세 리소스를 ID3D11Buffer*(원시 포인터)가 아니라 ComPtr<ID3D11Buffer>(스마트 포인터)로 선언했다는 점입니다.

원리 → 직관 → 코드 매핑으로 풀어 보겠습니다.

UINT indexCount = 0;UINT는 Windows에서 unsigned int(32비트 부호 없는 정수)의 별칭입니다. 인덱스 개수는 음수가 될 수 없으므로 부호 없는 타입이 자연스럽고, D3D11의 드로우 함수들이 UINT를 받기 때문에 타입을 맞춰 두는 것이 캐스팅을 줄입니다. 클래스 내 멤버 초기화(= 0)를 붙여 두면, Mesh를 만들 때 깜빡하고 값을 안 넣어도 쓰레기 값이 아니라 항상 0에서 시작합니다.

참고 ID3D11InputLayout(입력 레이아웃)은 이 코드에서 필드로만 선언되고 실제로 채워지지는 않습니다(정점 셰이더 바이트코드가 있어야 만들 수 있어 별도 단계에서 채웁니다). 여기서는 "메시가 소유해야 할 리소스 목록"의 일부로서 자리만 잡아 둔 것이고, 채워지든 비어 있든 소멸 시 안전하게 처리된다는 점만 기억하면 됩니다(빈 ComPtrnullptr이라 Release를 부르지 않습니다).

1.4 createMesh 시그니처 — 인자 하나하나

Mesh createMesh(ID3D11Device* device, const void* verts, UINT vbytes, const UINT16* idx, UINT icount) 을 부분별로 해부합니다.

인자타입의미와 그렇게 쓴 이유
device ID3D11Device* 리소스를 생성하는 공장. D3D11에서 리소스 생성은 항상 디바이스가 담당합니다(그리기는 컨텍스트가 담당). 원시 포인터인 이유: 이 함수는 디바이스를 빌려 쓰기만 하고 소유권을 가지지 않기 때문입니다. 소유권은 호출자에게 있습니다.
verts const void* 정점 데이터의 시작 주소. void*인 이유: 정점 포맷(위치만, 위치+색, 위치+UV…)이 무엇이든 바이트 덩어리로 통째로 복사하면 되므로 구체 타입을 알 필요가 없습니다. const: 이 함수는 원본을 읽기만 하고 고치지 않겠다는 약속입니다.
vbytes UINT verts가 가리키는 데이터의 총 바이트 수. void*는 크기 정보가 없으니 별도로 받아야 합니다.
idx const UINT16* 인덱스 배열. UINT16(16비트, 0~65535)은 정점이 6.5만 개 이하일 때 메모리를 아끼는 흔한 선택입니다. 그 이상이면 UINT32를 씁니다.
icount UINT 인덱스의 개수(바이트가 아니라 원소 수). 바이트 수는 icount * sizeof(UINT16)로 계산합니다.

반환 타입이 포인터가 아니라 값(Mesh)인 점이 중요합니다. 안에 ComPtr이 들어 있어 값으로 돌려줘도 복사 비용이 걱정되지만, 아래 1.7에서 보듯 이동(move)으로 처리되어 실제 AddRef/Release가 일어나지 않습니다. 그래서 "값 반환"이 깔끔하면서도 효율적입니다.

1.5 BUFFER_DESC — 버퍼의 설계도를 채우기

D3D11_BUFFER_DESC vbd{};{}집합 초기화(aggregate/value initialization)로, 구조체의 모든 필드를 0으로 밀어 줍니다. 이게 중요한 이유: D3D11_BUFFER_DESC에는 CPUAccessFlags, MiscFlags, StructureByteStride처럼 지금 안 쓰는 필드도 있는데, {}로 0으로 밀어 두지 않으면 스택 쓰레기 값이 들어가 D3D11이 이상한 요청으로 해석해 생성이 실패할 수 있습니다. 디스크립터는 항상 {}로 시작하세요.

성능 D3D11_USAGE_IMMUTABLE은 드라이버에게 "이 데이터는 GPU 전용 메모리(VRAM)에 두고 CPU가 다시 건드릴 일 없다"고 알려 주는 강한 힌트라, 가장 빠른 접근을 얻습니다. 매 프레임 바뀌는 데이터라면 D3D11_USAGE_DEFAULT(+ UpdateSubresource)나 D3D11_USAGE_DYNAMIC(+ Map/Unmap)을 써야 합니다. 단, IMMUTABLE은 생성 시 초기 데이터(pInitialData)를 반드시 함께 줘야 합니다 — 나중에 채울 방법이 없으니까요.

1.6 SUBRESOURCE_DATA와 CreateBuffer — 실제 생성

D3D11_SUBRESOURCE_DATA vinit{ verts, 0, 0 };는 "버퍼를 만들 때 이 데이터로 채워라"를 담는 구조체입니다. 집합 초기화 { verts, 0, 0 }의 세 값은 순서대로 이 구조체의 세 필드에 대응합니다.

핵심 줄: device->CreateBuffer(&vbd, &vinit, m.vbo.ReleaseAndGetAddressOf());

1.7 out 파라미터: 왜 ReleaseAndGetAddressOf()인가

원리: D3D11의 생성 API는 반환값으로 HRESULT(성공/실패 코드)를 돌려주고, 진짜 결과물(만들어진 버퍼)은 마지막 인자에 주소를 써 넣는 방식으로 돌려줍니다. 그러려면 API는 "네가 이 포인터를 저장할 변수의 주소를 다오"라고 요구합니다. 즉 ID3D11Buffer* 변수가 아니라 그 변수의 주소인 ID3D11Buffer** 변수의주소가 필요합니다.

ComPtr은 이 상황을 위해 두 종류의 주소 꺼내기를 제공합니다.

기하 직관(그림글): ComPtr을 "한 손"이라고 합시다. 생성 API는 그 손에 새 상자를 쥐여 줍니다. GetAddressOf는 이미 다른 상자를 쥔 손에 그대로 새 상자를 얹는 것이라 원래 상자가 바닥에 굴러떨어져 사라집니다(참조 카운트를 못 줄여 누수). ReleaseAndGetAddressOf는 먼저 손을 비운 뒤 새 상자를 받으므로 안전합니다. 이 코드에서 m.vbo는 처음엔 비어 있어 둘의 차이가 안 나지만, 버릇을 ReleaseAndGetAddressOf로 들이는 것이 나중에 같은 ComPtr을 재사용할 때 사고를 막습니다.

코드 매핑: 흔히 쓰는 매크로/관용구로 m.vbo.ReleaseAndGetAddressOf()와 완전히 동등한 짧은 표기가 &m.vbo입니다(원본 주석의 "&를 넘기면"이 바로 이 뜻). ComPtroperator&ReleaseAndGetAddressOf로 오버로딩해 두었기 때문입니다. 즉 CreateBuffer(&vbd, &vinit, &m.vbo)라고 써도 같은 동작입니다.

함정 ComPtr에 원시 포인터를 담을 땐 .Get()(참조 카운트 안 건드림, 바인딩용)과 .GetAddressOf()/.ReleaseAndGetAddressOf()(생성 out 파라미터용)를 혼동하지 마세요. 이미 리소스를 담은 ComPtr&(=ReleaseAndGetAddressOf) 없이 GetAddressOf로 새 객체를 받으면 이전 객체가 Release되지 않아 누수됩니다.

1.8 값 반환과 이동 — 복사 없는 소유권 이전

m.indexCount = icount;return m;이 핵심입니다. 주석대로 "복사 없이 소유권 이전"이 어떻게 가능한지 봅시다.

마지막 주석 "Mesh가 소멸하면 vbo/ebo/layout이 각각 Release"가 이 절 전체의 결론입니다. 원시 포인터였다면 소멸자에서 if(vbo) vbo->Release();를 세 번, 그것도 예외 안전하게 써야 했을 코드가 통째로 사라진 것입니다.

1.9 Get() vs GetAddressOf() — 바인딩 때 헷갈리지 않기

나중에 이 메시를 그릴 때는 소유권을 넘기는 게 아니라 잠깐 빌려주는 것이라 .Get()을 씁니다. 예를 들어 즉시 컨텍스트에 정점 버퍼를 묶을 때:

draw.cpp (바인딩 예시)cpp
UINT stride = sizeof(Vertex);   // 정점 하나의 바이트 수
UINT offset = 0;                // 버퍼 시작에서의 오프셋
ID3D11Buffer* vb = mesh.vbo.Get();          // 원시 포인터를 "빌린다"(AddRef 안 함)
ctx->IASetVertexBuffers(0, 1, &vb, &stride, &offset);
ctx->IASetIndexBuffer(mesh.ebo.Get(), DXGI_FORMAT_R16_UINT, 0);
ctx->DrawIndexed(mesh.indexCount, 0, 0);     // 인덱스 개수만큼 그린다

정리: "만들 때는 &(=ReleaseAndGetAddressOf)로 소유권을 받고, 쓸 때는 Get()으로 빌린다." 이 두 문장만 기억하면 ComPtr 사용의 90%가 해결됩니다.

1.10 손으로 따라가는 참조 카운트 예제

숫자로 확인하면 확실해집니다. CreateBuffer가 성공한 직후 버퍼의 카운트를 1이라고 합시다 (생성 API는 카운트 1인 객체를 돌려줍니다).

  1. createMeshm.vbo가 카운트 1짜리 버퍼를 쥠. count = 1
  2. return m; → 이동이라 카운트 변화 없음. 호출자 mesh.vbo가 이어받음. count = 1
  3. 그리기에서 mesh.vbo.Get() → 빌리기라 변화 없음. count = 1
  4. mesh가 스코프 탈출 → ~ComPtrRelease(). count = 0 → 파괴, VRAM 반납

만약 3단계에서 실수로 ID3D11Buffer* leaked = mesh.vbo.Get(); 뒤 그 leaked를 또 다른 ComPtrAttach 없이 넣거나, 반대로 Get()으로 받은 걸 Release()해 버리면 카운트가 어긋나 크래시로 이어집니다. ComPtr 규칙만 지키면 이 표가 항상 아귀가 맞습니다.

참고 DirectXMath와의 관계. 이 페이지의 주제(리소스 수명)는 수학 라이브러리와 직접 관련은 없지만, 실무에서는 이 Mesh에 곁들여 변환 행렬을 상수 버퍼로 올릴 때 DirectXMath를 씁니다. 그때 XMFLOAT4X4(저장용)와 XMMATRIX(계산용)를 XMLoadFloat4x4/XMStoreFloat4x4로 오가고, cbuffer에 넣기 전 행벡터 규약에 맞춰 XMMatrixTranspose로 전치해 업로드합니다(HLSL에서 mul(v, M)로 곱하기 때문). 리소스는 ComPtr로, 수학은 DirectXMath로 — 두 도구가 각자 검증된 영역을 맡는 것이 실무 권장 조합입니다.

2. 상태 변경 최소화 & 배칭

2.1 무엇이 문제인가 — 드로우 콜보다 비싼 상태 전환

직관과 달리, 화면에 무언가를 그리는 명령(DrawIndexed) 자체는 대개 싼 편입니다. 진짜 비용은 그리기 직전에 GPU 상태를 바꾸는 일에서 나옵니다. 셰이더 교체 (VSSetShader/PSSetShader), 텍스처(SRV)·상수 버퍼 재바인딩, 래스터/블렌드/뎁스 상태 객체 교체가 여기 해당합니다.

왜 비싼가: 이 호출들은 CPU에서 D3D11 드라이버로 명령을 전달하는데, 드라이버는 매번 유효성 검사·내부 상태 재구성·GPU 명령 변환을 합니다. 이 "CPU가 드라이버에 명령을 밀어 넣는 비용"을 드라이버 오버헤드라 부릅니다. 정점 몇 개짜리 물체 500개를 각각 다른 셰이더/텍스처로 그리면, GPU는 한가한데 CPU가 상태 전환에 허덕이며 프레임이 떨어집니다.

성능 드로우 콜 500개가 60fps를 못 내는데 정점 수는 적다면, 병목은 GPU가 아니라 CPU의 드라이버 오버헤드(상태 전환)일 가능성이 높습니다. 상태로 정렬·배칭하는 것만으로 몇 배 빨라지는 경우가 흔합니다. 이 "CPU가 드라이버에 명령을 밀어 넣는 비용"을 근본적으로 줄이려고 나온 것이 바로 다음 절의 D3D12·Vulkan 같은 저수준 API들입니다.

2.2 네 가지 최적화 전략

실전 최적화의 한 문장 요약은 "같은 상태로 최대한 몰아서 그리기"입니다. 구체적으로 네 가지입니다.

2.3 전략 1 — 상태로 정렬하기

원리: 상태 전환 횟수는 "정렬 후 인접한 것들이 얼마나 같은 상태를 공유하는가"로 결정됩니다. 셰이더가 가장 바꾸기 비싸고, 그다음 텍스처, 그다음 정점/인덱스 버퍼 순이므로 비싼 순서를 바깥 정렬 키로 둡니다.

직관(그림글): 옷을 갤 때 "색깔별 → 종류별"로 묶으면 서랍을 여닫는 횟수가 줄듯이, 드로우도 셰이더별로 크게 묶고 그 안에서 텍스처별로, 다시 그 안에서 메시별로 묶으면 *Set* 호출이 급감합니다.

sort_draw.cppcpp
struct DrawItem {
    ID3D11VertexShader* vs;
    ID3D11PixelShader*  ps;
    ID3D11ShaderResourceView* srv;  // 텍스처
    const Mesh* mesh;
};

// 셰이더(가장 비쌈) → 텍스처 → 메시 순으로 정렬 키를 구성
std::sort(items.begin(), items.end(), [](const DrawItem& a, const DrawItem& b){
    if (a.vs != b.vs)   return a.vs   < b.vs;    // 1순위: 정점 셰이더
    if (a.ps != b.ps)   return a.ps   < b.ps;    // 2순위: 픽셀 셰이더
    if (a.srv != b.srv) return a.srv  < b.srv;   // 3순위: 텍스처
    return a.mesh < b.mesh;                       // 4순위: 메시
});

코드 매핑: std::sort의 세 번째 인자는 "a가 b보다 앞에 와야 하면 true"를 돌려주는 비교 함수(람다)입니다. 계층적 비교의 관용구는 "같으면 다음 키로 넘어가고, 다르면 그 키로 결정"입니다. a.vs < b.vs처럼 포인터 값 자체를 비교하는 이유: 우리가 원하는 건 특정 순서가 아니라 "같은 것끼리 인접"이므로, 포인터 주소로만 묶어도 목적이 달성됩니다. 람다 인자를 const DrawItem&(const 참조)로 받는 이유는 복사 비용 없이 읽기만 하기 위함입니다.

함정 포인터 값 정렬은 프레임마다 순서가 달라질 수 있어(주소가 재사용되면) 미묘한 비결정성을 부릅니다. 결정적 순서가 필요하면 셰이더/텍스처마다 정수 ID를 부여해 그 ID로 정렬하거나, 여러 상태를 하나의 정수 정렬 키(sort key)로 비트 패킹하는 기법을 씁니다(상용 엔진의 흔한 패턴).

2.4 전략 2 — 상태 객체는 초기화 때 한 번만

원리: ID3D11RasterizerState·ID3D11BlendState·ID3D11DepthStencilState는 "불변 상태 객체"입니다. 생성 비용(드라이버가 조합을 검증·컴파일)이 크지만, 한 번 만들면 바인딩 (RSSetState 등)은 매우 쌉니다. 그러니 프레임 루프 밖(초기화)에서 만들고, 루프 안에서는 포인터만 교체합니다.

states.cppcpp
// [초기화] 딱 한 번 — 자주 쓰는 상태 조합을 미리 굳혀 둔다
ComPtr<ID3D11RasterizerState> rsSolid, rsWire;
D3D11_RASTERIZER_DESC rd{};
rd.FillMode = D3D11_FILL_SOLID;
rd.CullMode = D3D11_CULL_BACK;      // 뒷면 제거(왼손 좌표계 CW 기준)
device->CreateRasterizerState(&rd, &rsSolid);
rd.FillMode = D3D11_FILL_WIREFRAME; // 필드 하나만 바꿔 두 번째 조합
device->CreateRasterizerState(&rd, &rsWire);

// [매 프레임] 만들지 말고 "바인딩만" 교체 — 이건 저렴하다
ctx->RSSetState(rsSolid.Get());

코드 매핑: rd{}로 0-초기화한 뒤 필요한 필드만 채우고, FillMode만 바꿔 두 조합을 만들었습니다. 생성은 초기화 블록에, 바인딩(RSSetState(rsSolid.Get()))만 매 프레임 블록에 둔 대비가 이 절의 핵심입니다. CullMode = D3D11_CULL_BACK은 뒷면을 건너뛰어 픽셀 작업을 절반으로 줄이는 최적화이기도 합니다(우리 규약은 왼손 좌표계이므로 전면/후면 판정의 감김 방향 기준을 프로젝트 전체에서 일관되게 맞춰야 합니다).

함정 매 프레임 CreateRasterizerState를 호출하면 같은 설정인데도 드라이버가 매번 새 객체를 만들어(또는 캐시를 뒤져) 오버헤드가 폭증합니다. "생성은 초기화, 바인딩은 루프"를 어기는 것이 초보자의 가장 흔한 성능 실수입니다.

2.5 전략 3 — 중복 바인딩 제거(리던던시 필터)

원리: 정렬을 해도 인접한 두 드로우가 같은 셰이더/텍스처를 쓰는 구간이 생깁니다. "마지막에 바인딩한 것"을 기억해 두고 같으면 *Set* 호출을 건너뛰면 드라이버로 가는 명령 수가 더 줄어듭니다.

redundancy.cppcpp
ID3D11VertexShader* lastVS = nullptr;
ID3D11PixelShader*  lastPS = nullptr;
ID3D11ShaderResourceView* lastSRV = nullptr;

for (const DrawItem& it : items) {          // 이미 상태로 정렬된 목록
    if (it.vs != lastVS) { ctx->VSSetShader(it.vs, nullptr, 0); lastVS = it.vs; }
    if (it.ps != lastPS) { ctx->PSSetShader(it.ps, nullptr, 0); lastPS = it.ps; }
    if (it.srv != lastSRV) { ctx->PSSetShaderResources(0, 1, &it.srv); lastSRV = it.srv; }
    // ... 정점/인덱스 버퍼 바인딩 후
    ctx->DrawIndexed(it.mesh->indexCount, 0, 0);
}

코드 매핑: lastVS/lastPS/lastSRV가 "직전 상태"를 기억하는 캐시입니다. 각 if (it.x != lastX)는 "바뀌었을 때만 바인딩하고, 캐시를 갱신"하는 전형적인 리던던시 필터입니다. 정렬(전략 1) 덕분에 같은 값이 연속으로 몰려 있어 이 if가 대부분 false가 되고, 그만큼 드라이버 호출이 사라집니다. for (const DrawItem& it : items)const 참조 범위 기반 for는 각 원소를 복사 없이 순회하는 관용구입니다.

성능 D3D11 런타임도 내부적으로 일부 중복 바인딩을 걸러 주지만, 앱 레벨에서 미리 거르면 *Set* 함수 진입 자체(함수 호출·인자 마샬링 비용)를 아낄 수 있습니다. 정렬 + 리던던시 필터는 거의 공짜로 얻는 최적화라 기본으로 깔고 가세요.

2.6 전략 4 — 인스턴싱으로 드로우 콜 1회 압축

원리: 같은 메시(예: 나무 한 그루)를 위치만 바꿔 500그루 그린다면, 정렬·필터를 해도 드로우 콜은 500번입니다. 인스턴싱은 "이 메시를 N번, 인스턴스마다 이 데이터를 써서 그려라"를 한 번의 호출로 지시합니다(DrawIndexedInstanced). 500번의 드로우 콜이 1번이 됩니다.

직관(그림글): 도장(메시)은 하나인데 잉크 색·위치(인스턴스 데이터)만 바꿔 여러 번 찍는 것과 같습니다. GPU에게 "도장 한 개, 찍을 자리 500개"를 통째로 넘기는 셈이라 CPU가 500번 명령할 필요가 없습니다.

instancing.cppcpp
// 인스턴스별 데이터(예: 각 그루의 월드 행렬) — 두 번째 정점 버퍼로 올린다
struct InstanceData { DirectX::XMFLOAT4X4 world; };

UINT strides[2] = { sizeof(Vertex), sizeof(InstanceData) };
UINT offsets[2] = { 0, 0 };
ID3D11Buffer* buffers[2] = { mesh.vbo.Get(), instanceVB.Get() };
ctx->IASetVertexBuffers(0, 2, buffers, strides, offsets); // 슬롯0=정점, 슬롯1=인스턴스
ctx->IASetIndexBuffer(mesh.ebo.Get(), DXGI_FORMAT_R16_UINT, 0);

// 인덱스 개수, 인스턴스 개수(=500), 나머지는 시작 오프셋들
ctx->DrawIndexedInstanced(mesh.indexCount, 500, 0, 0, 0);

코드 매핑, 인자 하나씩:

참고 인스턴스 데이터를 정점 셰이더에서 받으려면 입력 레이아웃에서 해당 슬롯을 D3D11_INPUT_PER_INSTANCE_DATA(정점당이 아니라 인스턴스당 전진)로 지정해야 합니다. 4×4 행렬은 float4 네 줄로 나눠 시맨틱(WORLD0..WORLD3)을 부여하는 것이 관례입니다. 자세한 입력 레이아웃 구성은 정점 셰이더 페이지를 참고하세요.

2.7 종합 — 왜 이 모든 게 D3D12/Vulkan으로 이어지나

정렬·리던던시 필터·인스턴싱은 모두 "CPU가 드라이버에 명령을 밀어 넣는 비용"을 줄이는 같은 목표를 향합니다. 그런데 D3D11에서 이 비용을 아무리 줄여도, 드라이버가 매 호출마다 하는 검증·상태 추적이라는 구조적 오버헤드는 남습니다. 그래서 D3D12·Vulkan은 상태를 통째로 굳힌 PSO(Pipeline State Object)로 검증을 미리 끝내고, 명령을 여러 스레드에서 미리 기록해 런타임 오버헤드를 최소화합니다. 이 페이지에서 익힌 "상태를 미리 만들어 두고 바인딩만 교체한다"는 감각이 그대로 PSO로 확장됩니다 — 다음 페이지에서 그 대응을 표로 정리합니다.