DX12 버텍스·인덱스 버퍼와 업로드
Upload Heap에서 Default Heap으로 Mesh Data를 안전하게 복사하기
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
CPU 메모리에 있는 정점 배열은 GPU Virtual Address를 가진 ID3D12Resource가 아니다. 반복해 읽는 Mesh Data는 Upload Heap을 거쳐 Default Heap으로 복사하고, Copy가 끝날 때까지 Staging Resource를 살려 두어야 한다.
Heap Type은 접근 정책이다
UPLOAD CPU write → GPU read
DEFAULT GPU가 주로 사용
READBACK GPU write → CPU read
Upload Heap은 CPU가 Map 한 뒤 쓰기 편하다. 그러나 이것이 모든 GPU에서 반복 정점 읽기의 최선은 아니다. Discrete GPU에서 반복해 사용할 정적 자원은 Default Heap에 두고, Upload Heap을 전송 경로로 사용하는 설계가 일반적이다.
UMA 시스템은 CPU와 GPU가 물리 메모리를 공유할 수 있지만, 앱이 임의로 동일한 정책을 가정하지 말고 아키텍처와 Feature Support에 따라 경로를 선택한다.
Default Buffer와 Upload Buffer를 만든다
const UINT64 byteSize = sizeof(vertices);
auto bufferDesc = CD3DX12_RESOURCE_DESC::Buffer(byteSize);
auto defaultProperties =
CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_DEFAULT);
ThrowIfFailed(device->CreateCommittedResource(
&defaultProperties,
D3D12_HEAP_FLAG_NONE,
&bufferDesc,
D3D12_RESOURCE_STATE_COPY_DEST,
nullptr,
IID_PPV_ARGS(&vertexBuffer)));
auto uploadProperties =
CD3DX12_HEAP_PROPERTIES(D3D12_HEAP_TYPE_UPLOAD);
ThrowIfFailed(device->CreateCommittedResource(
&uploadProperties,
D3D12_HEAP_FLAG_NONE,
&bufferDesc,
D3D12_RESOURCE_STATE_GENERIC_READ,
nullptr,
IID_PPV_ARGS(&vertexUpload)));
Default Buffer의 초기 상태는 COPY_DEST다. Upload Buffer는 Upload Heap의 제약에 맞게 GENERIC_READ로 만든다. 두 Resource는 크기가 같아도 메모리 접근 정책과 수명이 다르다.
CPU 데이터를 Upload Heap에 쓴다
std::byte* mapped = nullptr;
D3D12_RANGE noRead{ 0, 0 };
ThrowIfFailed(vertexUpload->Map(
0,
&noRead,
reinterpret_cast<void**>(&mapped)));
std::memcpy(mapped, vertices.data(), byteSize);
D3D12_RANGE written{ 0, static_cast<SIZE_T>(byteSize) };
vertexUpload->Unmap(0, &written);
Map은 GPU 작업을 자동으로 Flush하거나 완료를 대기하지 않는다. 이미 GPU가 읽고 있는 Upload 메모리를 덮어쓰지 않게 앱이 Fence로 재사용 시점을 관리해야 한다.
자주 쓰는 Persistent Upload Buffer는 한 번 Map한 포인터를 유지하고 Frame Slice별로 쓸 수 있다. 그래도 각 Slice를 CPU가 다시 쓸 수 있는 시점은 Fence가 결정한다.
Copy와 State Transition을 기록한다
commandList->CopyBufferRegion(
vertexBuffer.Get(), 0,
vertexUpload.Get(), 0,
byteSize);
auto barrier = CD3DX12_RESOURCE_BARRIER::Transition(
vertexBuffer.Get(),
D3D12_RESOURCE_STATE_COPY_DEST,
D3D12_RESOURCE_STATE_VERTEX_AND_CONSTANT_BUFFER);
commandList->ResourceBarrier(1, &barrier);
GPU Timeline은 다음과 같다.
CopyBufferRegion
→ COPY_DEST → VERTEX_AND_CONSTANT_BUFFER
→ Draw에서 Vertex Fetch
→ Fence Signal
Upload Resource는 CopyBufferRegion을 CPU가 기록한 즉시 해제해서는 안 된다. Command Queue에 제출된 Copy가 실제로 끝난 후, 즉 관련 Fence가 완료된 후에 해제할 수 있다.
struct PendingUpload
{
ComPtr<ID3D12Resource> staging;
UINT64 retireFence = 0;
};
로딩 함수 지역 변수로 Upload Resource를 만들고 함수 종료 시 버리는 코드는 GPU 수명 버그를 만든다. Pending Queue에 담고 Completed Fence가 retireFence에 도달했을 때 제거한다.
Vertex Buffer View는 바인딩 정보다
D3D12_VERTEX_BUFFER_VIEW vertexView{};
vertexView.BufferLocation = vertexBuffer->GetGPUVirtualAddress();
vertexView.SizeInBytes = static_cast<UINT>(byteSize);
vertexView.StrideInBytes = sizeof(Vertex);
commandList->IASetVertexBuffers(0, 1, &vertexView);
commandList->IASetPrimitiveTopology(
D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
Vertex Buffer View는 Descriptor Heap에 넣는 Descriptor가 아니라 Command List에 값으로 전달하는 구조체다. GPU Virtual Address, 전체 Byte 크기, 정점 하나의 Stride를 담는다.
Stride가 C++ sizeof(Vertex)와 다르면 다음 정점의 시작 주소를 잘못 계산한다. Input Layout Offset, Vertex Struct Layout, Stride를 하나의 계약으로 검증해야 한다.
Index Buffer로 정점을 재사용한다
const std::array<std::uint16_t, 6> indices =
{
0, 1, 2,
0, 2, 3
};
D3D12_INDEX_BUFFER_VIEW indexView{};
indexView.BufferLocation = indexBuffer->GetGPUVirtualAddress();
indexView.SizeInBytes = sizeof(indices);
indexView.Format = DXGI_FORMAT_R16_UINT;
commandList->IASetIndexBuffer(&indexView);
commandList->DrawIndexedInstanced(6, 1, 0, 0, 0);
Index Format은 실제 데이터 형식과 맞아야 한다. 16-bit 인덱스로 65,535보다 큰 정점 번호를 표현할 수 없다. Mesh를 여러 Draw로 나누거나 32-bit Format을 선택하되 메모리와 Bandwidth 차이를 측정한다.
잘못된 Upload를 찾는 표
| 증상 | 먼저 확인할 것 |
|---|---|
| 정점이 뒤엉켜 나온다 | Stride, Input Offset, SizeInBytes |
| 일부 Mesh만 깨진다 | Index Format, Index 범위, Buffer 크기 |
| 첫 실행만 정상이다 | Upload Resource 수명, Fence |
| Debug Layer에 State 오류가 나온다 | COPY_DEST 전후 State |
| GPU마다 결과가 다르다 | 정렬, Out-of-bounds, 수명 경쟁 |
Upload의 핵심은 memcpy가 아니다. CPU 바이트 → Upload Resource → Copy Command → Default Resource → 소비 State → Fence 퇴역으로 이어지는 전체 수명이 핵심이다.