DX12 Frame Resource와 Constant Buffer
CPU와 GPU가 겹쳐 일하도록 프레임별 자원 수명 관리하기
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
CPU가 Frame 2를 기록하는 동안 GPU는 Frame 0을 실행하고 있을 수 있다. 이 두 Timeline을 겹치게 하려면 Command Allocator, Constant Buffer 영역, Transient Descriptor를 프레임별로 분리하고 재사용 시점을 Fence로 확인해야 한다.
전체 대기는 정확하지만 느리다
CPU: record F0 → wait ────────→ record F1 → wait
GPU: execute F0 → execute F1
매 프레임 WaitForGPU()를 호출하면 CPU가 항상 직전 프레임의 GPU 완료를 기다린다. 이 구조는 초기화와 Resize처럼 전체 완료가 필요한 경계에서는 유용하지만 일반 프레임 루프의 최종 설계로 남겨서는 안 된다.
CPU: record F0 → record F1 → record F2 → F0 slot만 필요하면 wait
GPU: execute F0 → execute F1 → execute F2
핵심은 전체 GPU를 기다리지 않고 지금 재사용할 Slot의 Fence만 확인하는 것이다.
Frame Context에 재사용 단위를 묶는다
struct FrameContext
{
ComPtr<ID3D12CommandAllocator> commandAllocator;
UINT64 fenceValue = 0;
UINT64 constantBufferOffset = 0;
UINT transientDescriptorBegin = 0;
UINT transientDescriptorCount = 0;
};
std::array<FrameContext, kFrameCount> frames;
Swap Chain Buffer 인덱스와 Frame Context 인덱스를 같게 쓸 수는 있지만 개념은 다르다. Back Buffer는 표시할 이미지고, Frame Context는 CPU가 다시 쓰려면 GPU 완료가 필요한 메모리와 기록 자원의 묶음이다.
처음에는 수를 같게 두면 단순하지만, 후에 Offscreen Rendering이나 다른 Present 정책을 추가할 때 둘을 하나의 실체로 가정하지 않는 편이 안전하다.
프레임 시작에 해당 Slot만 확인한다
FrameContext& frame = frames[frameIndex];
if (fence->GetCompletedValue() < frame.fenceValue)
{
ThrowIfFailed(fence->SetEventOnCompletion(
frame.fenceValue, fenceEvent));
WaitForSingleObject(fenceEvent, INFINITE);
}
ThrowIfFailed(frame.commandAllocator->Reset());
ThrowIfFailed(commandList->Reset(
frame.commandAllocator.Get(), pipelineState.Get()));
CommandAllocator::Reset은 이전에 기록한 명령을 GPU가 더 이상 참조하지 않을 때만 안전하다. CommandList::Reset과 조건을 혼동하면 안 된다. Command List 객체는 실행 중이어도 적절한 Allocator로 다시 기록할 수 있지만, Allocator 메모리는 해당 GPU 작업이 끝날 때까지 재사용할 수 없다.
Constant Buffer를 프레임별 Slice로 나눈다
Constant Buffer View의 시작 주소와 크기는 256 Byte 단위를 맞춰야 한다.
constexpr UINT64 AlignConstantBuffer(UINT64 size)
{
return (size + 255) & ~UINT64(255);
}
struct SceneConstants
{
DirectX::XMFLOAT4X4 viewProjection;
DirectX::XMFLOAT4 cameraPosition;
};
const UINT64 sliceSize = AlignConstantBuffer(sizeof(SceneConstants));
const UINT64 frameOffset = UINT64(frameIndex) * sliceSize;
std::memcpy(mappedConstantBuffer + frameOffset,
&constants,
sizeof(constants));
const D3D12_GPU_VIRTUAL_ADDRESS gpuAddress =
constantBuffer->GetGPUVirtualAddress() + frameOffset;
commandList->SetGraphicsRootConstantBufferView(0, gpuAddress);
HLSL Constant Buffer의 멤버 Packing 규칙과 CBV 시작/크기의 256 Byte 정렬은 다른 규칙이다. C++ Struct의 멤버 Offset을 확인하고, 프레임 Slice의 시작은 256 Byte로 올린다.
Frame 0의 Constant를 GPU가 읽는 중에 CPU가 Frame 1 값을 같은 주소에 쓰면 화면이 떨리거나 드물게 오래된 값이 나올 수 있다. 프레임별 Slice는 이 쓰기-읽기 경쟁을 분리한다.
프레임 끝에 Fence 값을 저장한다
ThrowIfFailed(commandList->Close());
ID3D12CommandList* lists[] = { commandList.Get() };
commandQueue->ExecuteCommandLists(1, lists);
ThrowIfFailed(swapChain->Present(1, 0));
const UINT64 submittedFence = ++nextFenceValue;
ThrowIfFailed(commandQueue->Signal(fence.Get(), submittedFence));
frame.fenceValue = submittedFence;
frameIndex = swapChain->GetCurrentBackBufferIndex();
frame.fenceValue는 현재 프레임 Slot을 GPU가 다 쓴 시점을 표시한다. 다음에 같은 Slot이 돌아왔을 때 Completed Value가 이 값에 도달했다면 Allocator, Constant Slice, Transient Descriptor를 모두 재사용할 수 있다.
프레임 지연과 Frame Resource 수를 구분한다
Frame Context를 세 개 두었다고 Input Latency가 정확히 세 프레임인 것은 아니다. Swap Chain 대기 정책, Present Mode, CPU/GPU Workload, Display Refresh, Driver Queueing이 모두 지연에 영향을 준다.
Frame Resource 수를 늘리면 CPU가 GPU보다 잠시 앞서는 것을 허용할 수 있지만 메모리와 입력 지연이 늘 수 있다. 더 많으면 항상 빠르다는 규칙은 없다.
대기 원인을 측정한다
frameIndex
slotFence
completedFence
CPU wait duration
command recording duration
present duration
대기가 생길 때마다 이 값을 남기면 GPU가 지속적으로 느린지, 특정 프레임의 Upload나 Shader Compilation이 증가했는지, CPU가 필요 이상으로 앞서고 있는지 판단할 수 있다.
Frame Resource는 “세 벌 복사한다”는 패턴이 아니라 재사용 위험을 같은 Fence 시점으로 묶는 소유권 단위다.
공식 자료
- Command List 실행과 동기화
- D3D11에서 D3D12로 이식 — CPU/GPU Synchronization
- Descriptor 생성 — Constant Buffer View
- Buffer 내 Suballocation
확인일: 2026-08-15