ConstantBuffer / IndexBuffer DX12 구현 및 SpriteRenderer 파이프라인 연결
https://github.com/eazuooz/YamYam_Engine/commit/126ebdd2d66ca990f6bd4baf21cf83741898d80e
전환 작업이 중단된 시점의 엔진 상태를 보면, 겉으로는 렌더링이 되는 것처럼 보였다. 삼각형이 화면에 나왔고 크래시도 없었다. 하지만 실제로는 많은 부분이 빈 껍데기였다.
가장 심각한 문제는 ConstantBuffer였다. DX11 시절에는 SetData()가 GPU 버퍼에 데이터를 올리고, Bind()가 셰이더 슬롯에 연결해줬다. 그런데 DX12로 전환하면서 이 두 함수의 내부가 전부 주석 처리되어, 빈 함수가 되어버렸다. 즉, 매 프레임 Transform::Bind()를 호출해 World / View / Projection 행렬을 열심히 계산해 구조체에 담아도, 그 데이터는 GPU에 단 한 바이트도 전달되지 않고 있었다.
그 결과 셰이더는 초기화되지 않은 메모리를 읽고 있었고, 오브젝트는 카메라나 트랜스폼 설정과 무관하게 NDC 원점 근처에 고정된 채 렌더링되고 있었다.
IndexBuffer도 마찬가지였다. Create()는 인덱스 개수만 저장할 뿐 GPU 버퍼를 만들지 않았고, Bind()는 아무것도 하지 않았다. 그 상태에서 DrawInstanced(6, 1, 0, 0)을 호출했는데, 이 함수는 인덱스 버퍼 없이 버텍스 버퍼에서 순서대로 6개를 읽는다. 그런데 직전에 RectMesh를 4개 버텍스로 변경했기 때문에 존재하지 않는 버텍스 4번, 5번을 읽게 됐고, 그 결과 절반만 제대로 된 삼각형이 나오고 나머지는 깨진 쓰레기 픽셀이 출력됐다.
정리하면, 화면에 무언가 그려지고 있었지만 올바른 이유로 그려지는 것이 아무것도 없는 상태였다.
DX11에서 상수 버퍼는 비교적 단순했다. D3D11_USAGE_DYNAMIC으로 버퍼를 만들고, Map()으로 열어서 데이터를 쓰고, Unmap()으로 닫은 뒤, VSSetConstantBuffers()로 슬롯에 연결하면 됐다. API가 내부적으로 많은 것을 추상화해줬다.
DX12는 다르다. DX12는 CPU와 GPU가 메모리를 어떻게 공유할지를 개발자가 직접 선택해야 한다. 이를 위해 힙(Heap) 타입이라는 개념이 있다.
힙은 크게 세 가지가 있다. DEFAULT 힙은 GPU만 접근할 수 있는 고속 VRAM 영역이다. UPLOAD 힙은 CPU가 쓰고 GPU가 읽는 영역으로, CPU에서 데이터를 올리기에 적합하지만 GPU 접근 속도는 DEFAULT보다 느리다. READBACK 힙은 GPU가 쓰고 CPU가 읽는 경우에 사용한다.
상수 버퍼는 매 프레임 CPU에서 행렬 데이터를 올려야 하므로 UPLOAD 힙이 적합하다. 정적인 메시 데이터라면 DEFAULT 힙에 올리는 게 맞지만, 상수 버퍼처럼 동적으로 갱신되는 데이터는 UPLOAD 힙을 사용한다.
CPU 메모리 GPU 메모리
┌─────────────────┐ ┌─────────────────────────┐
│ C++ 코드 │ │ DEFAULT Heap (VRAM) │
│ TransformCB │ │ Mesh VB / IB 등 │
│ {World, View, │ │ (GPU 전용, 고속) │
│ Projection} │ └─────────────────────────┘
└────────┬────────┘
│ memcpy ┌─────────────────────────┐
└──────────────────────▶│ UPLOAD Heap │
│ ConstantBuffer │
│ (CPU ↔ GPU 공유 영역) │
└────────────┬────────────┘
│ GPU가 읽음
▼
Vertex Shader b0 슬롯

그림 1. ConstantBuffer 흐름
UPLOAD 힙의 가장 큰 특징은 Map()을 한 번 호출하면, 버퍼가 살아있는 동안 포인터를 계속 유지할 수 있다는 것이다. DX11처럼 매번 Map/Unmap을 반복할 필요 없이, 포인터를 멤버 변수(mMappedData)로 저장해두고 memcpy로 데이터만 밀어 넣으면 된다. 이를 Persistent Map 방식이라고 한다.
// 초기화 시 한 번만
buffer->Map(0, &readRange, &mMappedData);
// 매 프레임 SetData() 호출 시
memcpy(mMappedData, data, mSize);