리소스 관리와 상태 팁 — 한 줄 한 줄 직접 구현
Direct3D 11에서 만드는 버퍼·텍스처·셰이더·상태 객체는 전부 COM 객체입니다.
COM 객체는 "참조 카운트"라는 숫자로 자기 수명을 스스로 관리하는데, 이 숫자를 손으로 맞추다 보면
누수(Release를 안 함)나 이중 해제(Release를 두 번 함)가 반드시 생깁니다.
이 페이지에서는 (1) ComPtr이라는 스마트 포인터가 그 숫자를 자동으로 맞춰 주는 원리를
한 줄씩 뜯어보고, (2) 프레임마다 반복되는 상태 전환 비용이 왜 드로우 콜 자체보다
비싼지, 그리고 정렬·배칭·인스턴싱으로 그걸 어떻게 줄이는지를 입문자 눈높이로 최대한 자세히 설명합니다.
코드 로직은 원본 그대로 두고, 설명만 극한으로 늘렸습니다.
0. 왜 "수명"과 "상태"가 별도의 주제인가
그래픽스 코드를 처음 짜면 "삼각형을 화면에 띄우는 것"에만 집중하게 됩니다. 그런데 실제 게임/엔진은 수백 개의 메시, 수십 장의 텍스처, 여러 개의 셰이더와 상태 객체를 매 프레임 다룹니다. 이때 두 가지가 성능과 안정성을 좌우합니다.
- 수명(lifetime) — GPU에 올린 리소스가 언제 반납되는가. 너무 일찍 반납하면 크래시, 너무 늦게(=안 함) 반납하면 메모리 누수. 이걸 사람이 손으로 맞추면 반드시 틀립니다.
- 상태(state) — 그리기 직전에 GPU에게 "이 셰이더로, 이 텍스처를 써서, 이 블렌드 규칙으로 그려라"라고 알려 주는 설정 묶음. 이 설정을 바꾸는 비용이 의외로 큽니다.
아래 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()→ 카운트 +1 - 다 쓰고 포인터를 버릴 때
Release()→ 카운트 −1
문제는 이 AddRef/Release를 사람이 손으로 맞춰야 한다는 것입니다.
함수가 중간에 return하거나 예외가 나면 Release를 빼먹기 쉽고(누수), 실수로 두 번
부르면 이미 파괴된 메모리를 또 건드립니다(이중 해제 → 크래시). C++의 RAII(Resource Acquisition
Is Initialization: "객체가 살아 있는 동안만 자원을 쥔다") 관용구를 쓰면 이 짝을 컴파일러가 대신
맞춰 줍니다. 그 도구가 바로 Microsoft::WRL::ComPtr입니다.
ComPtr<T>이 소멸(스코프 탈출)하면 소멸자가 자동으로Release()호출ComPtr을 복사하면 자동AddRef(), 대입하면 기존 것Release()후 새 것AddRef()- 생성 API에는
ReleaseAndGetAddressOf()로 out 파라미터(T**)를 넘겨 소유권을 받는다 - 바인딩 등 "잠깐 빌려 쓸" 때는
Get()으로 원시 포인터만 꺼낸다(카운트 안 건드림)
#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>(스마트 포인터)로
선언했다는 점입니다.
원리 → 직관 → 코드 매핑으로 풀어 보겠습니다.
- 원리:
ComPtr<T>은 내부에T* ptr_하나만 들고 있는 아주 얇은 래퍼입니다. 하지만 소멸자·복사 생성자·대입 연산자가 특별하게 정의돼 있어, 객체의 생애 주기 이벤트마다ptr_->AddRef()/ptr_->Release()를 대신 불러 줍니다. - 기하 직관(그림글): COM 객체를 여러 사람이 잡을 수 있는 손잡이 달린 상자라고
생각하세요. 상자에는 "몇 명이 잡고 있나" 카운터가 붙어 있습니다. 원시 포인터는 카운터를 무시하고
손을 대는 것이라, 마지막 사람이 손을 놓을 때 카운터를 못 줄여 상자가 영원히 남습니다(누수).
ComPtr은 손을 대면 자동으로 +1, 손을 떼면 자동으로 −1 하는 장갑입니다. - 코드 매핑:
ComPtr<ID3D11InputLayout> layout;처럼 필드로 두기만 해도, 나중에Mesh m;이 스코프를 벗어나 소멸할 때 컴파일러가 만든~Mesh()가vbo.~ComPtr(),ebo.~ComPtr(),layout.~ComPtr()을 차례로 호출하고, 각각이 자기가 쥔 리소스의Release()를 부릅니다.
UINT indexCount = 0;의 UINT는 Windows에서 unsigned int(32비트 부호
없는 정수)의 별칭입니다. 인덱스 개수는 음수가 될 수 없으므로 부호 없는 타입이 자연스럽고, D3D11의
드로우 함수들이 UINT를 받기 때문에 타입을 맞춰 두는 것이 캐스팅을 줄입니다.
클래스 내 멤버 초기화(= 0)를 붙여 두면, Mesh를 만들 때
깜빡하고 값을 안 넣어도 쓰레기 값이 아니라 항상 0에서 시작합니다.
ID3D11InputLayout(입력 레이아웃)은 이 코드에서 필드로만 선언되고 실제로 채워지지는
않습니다(정점 셰이더 바이트코드가 있어야 만들 수 있어 별도 단계에서 채웁니다). 여기서는 "메시가
소유해야 할 리소스 목록"의 일부로서 자리만 잡아 둔 것이고, 채워지든 비어 있든 소멸 시 안전하게
처리된다는 점만 기억하면 됩니다(빈 ComPtr은 nullptr이라 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이 이상한 요청으로
해석해 생성이 실패할 수 있습니다. 디스크립터는 항상 {}로 시작하세요.
vbd.ByteWidth = vbytes;— 버퍼의 총 크기(바이트). GPU가 이만큼 메모리를 할당합니다.vbd.Usage = D3D11_USAGE_IMMUTABLE;— "만든 뒤 절대 바뀌지 않음". 정점 데이터가 런타임에 안 바뀌는 정적 메시라면 이게 최선입니다. 이유는 아래 성능 callout에서.vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;— 이 버퍼를 정점 버퍼로 파이프라인에 묶겠다는 선언. 인덱스 버퍼는D3D11_BIND_INDEX_BUFFER, 상수 버퍼는D3D11_BIND_CONSTANT_BUFFER입니다. 이 플래그가 있어야 나중에 해당 슬롯에 바인딩할 수 있습니다.
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 }의 세 값은 순서대로 이 구조체의 세 필드에
대응합니다.
- 첫째
pSysMem = verts— 복사해 올 원본 데이터의 주소. - 둘째
SysMemPitch = 0— 한 행의 바이트(2D 텍스처용). 버퍼는 1차원이라 의미가 없어 0. - 셋째
SysMemSlicePitch = 0— 한 슬라이스의 바이트(3D 텍스처용). 역시 버퍼엔 무의미해 0.
핵심 줄:
device->CreateBuffer(&vbd, &vinit, m.vbo.ReleaseAndGetAddressOf());
- 1번째 인자
&vbd— 방금 채운 설계도의 주소(const D3D11_BUFFER_DESC*). C++에서&변수는 "그 변수의 주소". API가 구조체를 포인터로 받으므로 주소를 넘깁니다. - 2번째 인자
&vinit— 초기 데이터 설명의 주소.IMMUTABLE이라 여기에nullptr을 주면 생성이 실패합니다. - 3번째 인자
m.vbo.ReleaseAndGetAddressOf()— 완성된 버퍼 포인터를 받을 out 파라미터. 타입은ID3D11Buffer**(포인터의 포인터)입니다. 이 부분이 오늘의 핵심이라 아래에서 따로 설명합니다.
1.7 out 파라미터: 왜 ReleaseAndGetAddressOf()인가
원리: D3D11의 생성 API는 반환값으로 HRESULT(성공/실패 코드)를 돌려주고,
진짜 결과물(만들어진 버퍼)은 마지막 인자에 주소를 써 넣는 방식으로 돌려줍니다. 그러려면 API는
"네가 이 포인터를 저장할 변수의 주소를 다오"라고 요구합니다. 즉 ID3D11Buffer* 변수가
아니라 그 변수의 주소인 ID3D11Buffer** 변수의주소가 필요합니다.
ComPtr은 이 상황을 위해 두 종류의 주소 꺼내기를 제공합니다.
GetAddressOf()— 내부 포인터의 주소만 그냥 돌려줌. 만약 이ComPtr이 이미 무언가를 쥐고 있었다면, 그 위에 새 포인터를 덮어써서 옛것을 놓치고 누수합니다.ReleaseAndGetAddressOf()— 먼저 기존에 쥔 게 있으면Release()로 놔주고, 그다음 깨끗해진 슬롯의 주소를 돌려줌. 재사용해도 안전.
기하 직관(그림글): ComPtr을 "한 손"이라고 합시다. 생성 API는 그 손에 새
상자를 쥐여 줍니다. GetAddressOf는 이미 다른 상자를 쥔 손에 그대로 새 상자를
얹는 것이라 원래 상자가 바닥에 굴러떨어져 사라집니다(참조 카운트를 못 줄여 누수). ReleaseAndGetAddressOf는
먼저 손을 비운 뒤 새 상자를 받으므로 안전합니다. 이 코드에서 m.vbo는 처음엔 비어 있어
둘의 차이가 안 나지만, 버릇을 ReleaseAndGetAddressOf로 들이는 것이
나중에 같은 ComPtr을 재사용할 때 사고를 막습니다.
코드 매핑: 흔히 쓰는 매크로/관용구로 m.vbo.ReleaseAndGetAddressOf()와
완전히 동등한 짧은 표기가 &m.vbo입니다(원본 주석의 "&를 넘기면"이
바로 이 뜻). ComPtr은 operator&를 ReleaseAndGetAddressOf로
오버로딩해 두었기 때문입니다. 즉 CreateBuffer(&vbd, &vinit, &m.vbo)라고 써도
같은 동작입니다.
ComPtr에 원시 포인터를 담을 땐 .Get()(참조 카운트 안 건드림, 바인딩용)과
.GetAddressOf()/.ReleaseAndGetAddressOf()(생성 out 파라미터용)를 혼동하지 마세요.
이미 리소스를 담은 ComPtr에 &(=ReleaseAndGetAddressOf) 없이 GetAddressOf로
새 객체를 받으면 이전 객체가 Release되지 않아 누수됩니다.
1.8 값 반환과 이동 — 복사 없는 소유권 이전
m.indexCount = icount; 뒤 return m;이 핵심입니다. 주석대로 "복사 없이 소유권
이전"이 어떻게 가능한지 봅시다.
- 원리:
return m;에서 지역 변수m은 곧 사라질 운명이므로 컴파일러는 이동(move)을 적용합니다.ComPtr의 이동 생성자는 내부 포인터를 그대로 옮기고 원본은nullptr로 만들 뿐,AddRef/Release를 전혀 부르지 않습니다. 게다가 컴파일러의 NRVO(Named Return Value Optimization)가 적용되면 이동조차 생략되고 호출자의 변수에 곧바로 만들어집니다. - 직관: 상자를 손에서 손으로 건네주는 것은 "복제(AddRef)+원본 폐기(Release)"가 아니라 그냥 넘기기입니다. 카운터를 건드릴 필요가 없죠.
- 코드 매핑: 그래서
Mesh mesh = createMesh(...);한 줄로 안전하게 소유권을 받아 오고,mesh가 스코프를 벗어나는 순간vbo/ebo/layout이 자동으로Release됩니다. 수동 해제 코드가 한 줄도 없습니다.
마지막 주석 "Mesh가 소멸하면 vbo/ebo/layout이 각각 Release"가 이 절 전체의 결론입니다.
원시 포인터였다면 소멸자에서 if(vbo) vbo->Release();를 세 번, 그것도 예외 안전하게 써야
했을 코드가 통째로 사라진 것입니다.
1.9 Get() vs GetAddressOf() — 바인딩 때 헷갈리지 않기
나중에 이 메시를 그릴 때는 소유권을 넘기는 게 아니라 잠깐 빌려주는 것이라
.Get()을 씁니다. 예를 들어 즉시 컨텍스트에 정점 버퍼를 묶을 때:
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); // 인덱스 개수만큼 그린다
mesh.vbo.Get()— 내부 원시 포인터를 그대로 돌려줌. 참조 카운트를 건드리지 않으므로 여기서 받은vb를 나중에Release하면 안 됩니다(소유권은 여전히mesh.vbo에 있음).IASetVertexBuffers는 여러 슬롯을 한 번에 받을 수 있어 포인터의 배열을 원합니다. 그래서&vb(단일 포인터의 주소 = 길이 1짜리 배열처럼)를 넘깁니다.stride는 정점 하나의 바이트 크기,offset은 어디서부터 읽을지입니다.IASetIndexBuffer의 두 번째 인자DXGI_FORMAT_R16_UINT는 인덱스가UINT16임을 GPU에 알립니다(생성 때UINT16으로 만들었으니 일치시켜야 합니다).UINT32였다면DXGI_FORMAT_R32_UINT.
정리: "만들 때는 &(=ReleaseAndGetAddressOf)로 소유권을 받고,
쓸 때는 Get()으로 빌린다." 이 두 문장만 기억하면 ComPtr 사용의 90%가 해결됩니다.
1.10 손으로 따라가는 참조 카운트 예제
숫자로 확인하면 확실해집니다. CreateBuffer가 성공한 직후 버퍼의 카운트를 1이라고 합시다
(생성 API는 카운트 1인 객체를 돌려줍니다).
createMesh안m.vbo가 카운트 1짜리 버퍼를 쥠. count = 1return m;→ 이동이라 카운트 변화 없음. 호출자mesh.vbo가 이어받음. count = 1- 그리기에서
mesh.vbo.Get()→ 빌리기라 변화 없음. count = 1 mesh가 스코프 탈출 →~ComPtr이Release(). count = 0 → 파괴, VRAM 반납
만약 3단계에서 실수로 ID3D11Buffer* leaked = mesh.vbo.Get(); 뒤 그 leaked를
또 다른 ComPtr에 Attach 없이 넣거나, 반대로 Get()으로 받은 걸
Release()해 버리면 카운트가 어긋나 크래시로 이어집니다. ComPtr 규칙만 지키면
이 표가 항상 아귀가 맞습니다.
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가 상태 전환에 허덕이며 프레임이 떨어집니다.
2.2 네 가지 최적화 전략
실전 최적화의 한 문장 요약은 "같은 상태로 최대한 몰아서 그리기"입니다. 구체적으로 네 가지입니다.
- 정렬 후 드로우: 오브젝트를 셰이더 → 텍스처(SRV) → 메시 순으로 정렬해 같은 상태끼리 연속 배치.
- 상태 객체는 미리 생성: 래스터/블렌드/뎁스스텐실 State object는 프레임마다 만들지 말고 초기화 때 만들어 두고
RSSetState등으로 바인딩만 교체. - 중복 바인딩 제거: 마지막에 바인딩한 셰이더/SRV 포인터를 캐시해, 같은 것이면
*Set*호출을 건너뛴다. - 인스턴싱: 같은 메시를 수백 개 그린다면
DrawIndexedInstanced로 드로우 콜을 1회로 압축.
2.3 전략 1 — 상태로 정렬하기
원리: 상태 전환 횟수는 "정렬 후 인접한 것들이 얼마나 같은 상태를 공유하는가"로 결정됩니다. 셰이더가 가장 바꾸기 비싸고, 그다음 텍스처, 그다음 정점/인덱스 버퍼 순이므로 비싼 순서를 바깥 정렬 키로 둡니다.
직관(그림글): 옷을 갤 때 "색깔별 → 종류별"로 묶으면 서랍을 여닫는 횟수가 줄듯이,
드로우도 셰이더별로 크게 묶고 그 안에서 텍스처별로, 다시 그 안에서 메시별로 묶으면 *Set*
호출이 급감합니다.
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 참조)로 받는 이유는 복사 비용 없이 읽기만 하기
위함입니다.
2.4 전략 2 — 상태 객체는 초기화 때 한 번만
원리: ID3D11RasterizerState·ID3D11BlendState·ID3D11DepthStencilState는
"불변 상태 객체"입니다. 생성 비용(드라이버가 조합을 검증·컴파일)이 크지만, 한 번 만들면 바인딩
(RSSetState 등)은 매우 쌉니다. 그러니 프레임 루프 밖(초기화)에서 만들고,
루프 안에서는 포인터만 교체합니다.
// [초기화] 딱 한 번 — 자주 쓰는 상태 조합을 미리 굳혀 둔다
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* 호출을 건너뛰면 드라이버로
가는 명령 수가 더 줄어듭니다.
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는 각 원소를 복사 없이 순회하는 관용구입니다.
*Set*
함수 진입 자체(함수 호출·인자 마샬링 비용)를 아낄 수 있습니다. 정렬 + 리던던시 필터는 거의 공짜로
얻는 최적화라 기본으로 깔고 가세요.
2.6 전략 4 — 인스턴싱으로 드로우 콜 1회 압축
원리: 같은 메시(예: 나무 한 그루)를 위치만 바꿔 500그루 그린다면, 정렬·필터를 해도
드로우 콜은 500번입니다. 인스턴싱은 "이 메시를 N번, 인스턴스마다 이 데이터를 써서
그려라"를 한 번의 호출로 지시합니다(DrawIndexedInstanced). 500번의 드로우 콜이
1번이 됩니다.
직관(그림글): 도장(메시)은 하나인데 잉크 색·위치(인스턴스 데이터)만 바꿔 여러 번 찍는 것과 같습니다. GPU에게 "도장 한 개, 찍을 자리 500개"를 통째로 넘기는 셈이라 CPU가 500번 명령할 필요가 없습니다.
// 인스턴스별 데이터(예: 각 그루의 월드 행렬) — 두 번째 정점 버퍼로 올린다
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);
코드 매핑, 인자 하나씩:
IASetVertexBuffers(0, 2, buffers, strides, offsets)— 두 개의 정점 버퍼를 슬롯 0·1에 동시에 묶습니다. 슬롯 0은 메시의 정점(모든 인스턴스가 공유), 슬롯 1은 인스턴스별 데이터.strides/offsets가 각각 배열인 이유는 버퍼마다 정점/인스턴스 하나의 크기가 다르기 때문입니다.DrawIndexedInstanced(indexCountPerInstance, instanceCount, startIndex, baseVertex, startInstance)— 첫 인자는 인스턴스 하나당 인덱스 수(mesh.indexCount), 둘째가 인스턴스 수(500). 나머지 세 0은 인덱스/정점/인스턴스의 시작 오프셋으로, 전체를 처음부터 그릴 땐 모두 0입니다.InstanceData::world가XMFLOAT4X4인 것에 주목하세요. 계산은XMMATRIX로 하되 저장·업로드는XMFLOAT4X4로 하며, 우리 규약(행벡터v'=v*M, HLSLmul(v, M), 행 우선 저장)에 맞추려면 셰이더로 보내기 전XMMatrixTranspose로 전치해 채워야 합니다.
D3D11_INPUT_PER_INSTANCE_DATA(정점당이 아니라 인스턴스당 전진)로 지정해야
합니다. 4×4 행렬은 float4 네 줄로 나눠 시맨틱(WORLD0..WORLD3)을 부여하는 것이
관례입니다. 자세한 입력 레이아웃 구성은 정점 셰이더 페이지를 참고하세요.
2.7 종합 — 왜 이 모든 게 D3D12/Vulkan으로 이어지나
정렬·리던던시 필터·인스턴싱은 모두 "CPU가 드라이버에 명령을 밀어 넣는 비용"을 줄이는 같은 목표를 향합니다. 그런데 D3D11에서 이 비용을 아무리 줄여도, 드라이버가 매 호출마다 하는 검증·상태 추적이라는 구조적 오버헤드는 남습니다. 그래서 D3D12·Vulkan은 상태를 통째로 굳힌 PSO(Pipeline State Object)로 검증을 미리 끝내고, 명령을 여러 스레드에서 미리 기록해 런타임 오버헤드를 최소화합니다. 이 페이지에서 익힌 "상태를 미리 만들어 두고 바인딩만 교체한다"는 감각이 그대로 PSO로 확장됩니다 — 다음 페이지에서 그 대응을 표로 정리합니다.