화면에 보이는 기능은 없지만, 이후 Bloom이나 SSAO처럼 패스가 많은 기능을 얹기 위한 기반 작업입니다.
| 단계 | 기능 | 해결한 문제 |
| 1 | 지연 해제 큐 | 리사이즈할 때마다 WaitIdele()로 GPU를 멈춰 세움 |
| 2 | 리소스 상태 추적 | 배리어의 이전 상태를 코드에 하드코딩 |
| 3 | Frame Graph | main이 패스 순서, RT 바인딩, 배리어를 전부 직접 관리 |
1. 지연 해제 큐 (Deferred Release)
문제
GPU는 CPU보다 1-2프레임 늦게 실행됩니다. 그래서 리사이즈할 때 옛 텍스처를 바로 지우면, GPU가 아직 읽는 중일 수 있습니다. 그래서 지금까지는 WaitIdle()로 GPU가 끝날때까지 기다림.
해결
지금은 버리고 GPU가 이 시점까지의 작업을 끝낸 뒤에 지우게하는 예약을 펜스값과 함께 정의함.
class DeferredReleaseQueue
{
public:
void Init(std::function<uint64_t()> nextFence) { mNextFence = std::move(nextFence); }
void Release(ComPtr<ID3D12Object> obj) { if (obj) mItems.push_back({ mNextFence(), std::move(obj), nullptr }); }
void Defer(std::function<void()> fn) { mItems.push_back({ mNextFence(), nullptr, std::move(fn) }); }
void Collect(UINT64 completed) // BeginFrame에서 호출
{
while (!mItems.empty() && mItems.front().fence <= completed)
{
if (mItems.front().fn) mItems.front().fn();
mItems.pop_front(); // ComPtr 소멸 = 실제 Release
}
}
void Flush(); // 종료 시
private:
struct Item { UINT64 fence; ComPtr<ID3D12Object> obj; std::function<void()> fn; };
std::deque<Item> mItems;
std::function<uint64_t()> mNextFence;
};
// Renderer::Init
mRelease.Init([this] { return mDevice.LastSignaledValue() + 1; });
// Renderer::BeginFrame
mDevice.WaitForValue(mFrameFence[mFrameIndex]);
mRelease.Collect(mDevice.CompletedValue());
무엇을 미뤄야 하나?
| 대상 | 미룸 | 이유 |
| 텍스처 리소스 | O | GPU가 실행 주에 메모리를 읽음 |
| SRV 디스크립터(shader-visible 힙) | O | GPU가 실행 중에 디스크립터를 읽음. 같은 슬롯을 덮어쓰면 이전 프레임이 엉뚱한 텍스처를 읽음 |
| RTV/DSV (CPU 전용 힙) | X | OMSetRenderTargets 호출 시점에 커맨드 리스트로 복사됨 |
그래서 RenderTarget::Resize는 같은 슬롯을 재사용하지 않고, 매법 새 슬롯을 할당하고 옛 슬롯은 지연 해제합니다.
void RenderTarget::Retire()
{
if (mRtv.cpu.ptr) { mRtvHeap->Free(mRtv); mRtv = {}; } // 즉시
if (mDsv.cpu.ptr) { mDsvHeap->Free(mDsv); mDsv = {}; } // 즉시
mRelease->Release(mColor.Detach()); // 지연
mRelease->Release(mDepth.Detach()); // 지연
if (mSrv.cpu.ptr)
{
DescriptorHeap* heap = mSrvHeap; DescriptorHandle srv = mSrv;
mRelease->Defer([heap, srv]() mutable { heap->Free(srv); }); // 지연
mSrv = {};
}
}
펜스 값을 넣는 순간에 조회하는 이유
처음에는 EndFrame에서 다음 Signal값을 저장해 두려고 했습니다. 하지만 Immediate()와 WaitIdle()도 내부에서 Signal을 합니다. 그 사이에 저장된 값이 이미 완료된 값이 되어 벼러서, GPU가 쓰는 중인데 해제될 수 있습니다. 그래서 넣을 때마다 마지막 Signal + 1을 조회합니다. 다음 Signal은 항상 지금까지 제출된 모든 작업 뒤에 오기 때문입니다.
2. 리소스 상태 추적
문제
Transition(mTex.Get(), PIXEL_SHADER_RESOURCE, RENDER_TARGET); // 이전 상태를 코드가 "가정"
패스 순서가 바뀌거나 같은 함수가 두 번 호출되면, 실제 상태와 가정이 어긋나 디버그 에러가 발생함.
해결
리소스가 자기 상태를 기억하게 함
class GpuResource
{
public:
void Attach(ComPtr<ID3D12Resource> res, D3D12_RESOURCE_STATES initial);
ComPtr<ID3D12Resource> Detach(); // 지연 해제 큐로 넘길 때
ID3D12Resource* Get() const;
private:
friend class BarrierBatch;
ComPtr<ID3D12Resource> mRes;
D3D12_RESOURCE_STATES mState = D3D12_RESOURCE_STATE_COMMON;
};
class BarrierBatch // 모아서 한 번에 제출, 소멸 시 Flush
{
public:
BarrierBatch& Transition(GpuResource& r, D3D12_RESOURCE_STATES target)
{
if (!r.mRes || IsSatisfied(r.mState, target)) return *this; // 이미 그 상태면 건너뜀
mBarriers[mCount++] = CD3DX12_RESOURCE_BARRIER::Transition(r.mRes.Get(), r.mState, target);
r.mState = target;
return *this;
}
...
private:
// 읽기 상태끼리는 OR 조합 가능: 현재 PSR|NON_PSR이면 PSR 요청은 이미 만족
static bool IsSatisfied(D3D12_RESOURCE_STATES cur, D3D12_RESOURCE_STATES target);
};
사용하는 쪽은 목표 상태만 말하면 됨.
// 전
auto b = CD3DX12_RESOURCE_BARRIER::Transition(mTex.Get(), PSR, RENDER_TARGET);
cmd->ResourceBarrier(1, &b);
// 후
BarrierBatch(cmd)
.Transition(mColor, D3D12_RESOURCE_STATE_RENDER_TARGET)
.Transition(mDepth, D3D12_RESOURCE_STATE_DEPTH_WRITE);
한계
- 단일 커맨드 리스트 전체: 기록 순서가 GPU 실행 순서와 같다고 가정함.
- 리소스 전체 단위: 밉이나 배열 슬라이스별 상태는 추적하지 못한다. IBL 생성 코드는 기존 수동 배리어로 유지.
3. 간단한 Frame Graph
아이디어
패스가 어떤 리소스를 어떤 상태로 쓰는지 선언하면, 그래프가 실행 직전에 배리어를 자동으로 넣습니다.
struct ResourceUse
{
GpuResource* res;
D3D12_RESOURCE_STATES state;
static ResourceUse Srv(GpuResource& r); // PIXEL_SHADER_RESOURCE
static ResourceUse Rtv(GpuResource& r); // RENDER_TARGET
static ResourceUse Dsv(GpuResource& r); // DEPTH_WRITE
static ResourceUse Uav(GpuResource& r); // UNORDERED_ACCESS
};
void FrameGraph::Execute(ID3D12GraphicsCommandList* cmd)
{
for (Pass& p : mPasses)
{
{
BarrierBatch batch(cmd);
for (const ResourceUse& u : p.uses) batch.Transition(*u.res, u.state);
} // 패스 실행 전에 한 번에 제출
BeginEvent(cmd, p.name); // PIX / RenderDoc에서 패스 이름으로 보임
p.exec(cmd);
cmd->EndEvent();
}
BarrierBatch batch(cmd); // 그래프 밖(ImGui)에서 쓸 리소스의 최종 상태
for (const ResourceUse& u : mExports) batch.Transition(*u.res, u.state);
}
디버그 빌드에서는 한 패스가 같은 리소스를 서로 다른 상태로 요구하면 assert로 잡습니다.
main.cpp
// 전
sceneRenderer.RenderShadowPass(...);
sceneRT.Begin(cmd); sceneRenderer.Render(...); sky.Render(...); sceneRT.End(cmd);
viewportRT.Begin(cmd); tonemap.Render(...); viewportRT.End(cmd);
// 후
using U = baek::ResourceUse;
gGraph.Reset();
gGraph.AddPass("Shadow", { U::Dsv(shadowMap.Resource()) },
[&](auto* c) { sceneRenderer.RenderShadowPass(c, fi, camera, scene, shadowMap); });
gGraph.AddPass("Scene", { U::Srv(shadowMap.Resource()), U::Rtv(sceneRT.Color()), U::Dsv(sceneRT.Depth()) },
[&](auto* c) { sceneRT.Bind(c, true); sceneRenderer.Render(c, fi, camera, scene); });
gGraph.AddPass("Sky", { U::Rtv(sceneRT.Color()), U::Dsv(sceneRT.Depth()) },
[&](auto* c) { sceneRT.Bind(c, false); sky.Render(...); }); // 이어 그리기: Clear 안 함
gGraph.AddPass("Tonemap", { U::Srv(sceneRT.Color()), U::Rtv(viewportRT.Color()) },
[&](auto* c) { viewportRT.Bind(c, true); tonemap.Render(...); });
gGraph.Export(viewportRT.Color(), D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE);
gGraph.Execute(cmd);
RenderTarget은 전환(Begin)과 바인딩(Bind)을 분리해서, 그래프 안에서는 Bind만 씁니다.
결과

| 패스 | 배리어 | 내용 |
| Shadow | 1 | 섀도우 맵 PSR -> DEPTH_WRITE |
| Scene | 1 | sceneRT PSR -> RT |
| Sky | 0 | Scene과 상태가 같아서 자동으로 건너뜀 |
| Tonemap | 2 | sceneRT RT -> PSR, viewportRT PSR -> RT |
배리어 = 상태 전이가 몇번이 일어나는지에 따라 수가 늘어나는 것을 확인할 수 있음.
'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글
| Shadow Map에서 Cascaded Shadow Maps (0) | 2026.10.13 |
|---|---|
| 스카이박스와 IBL(Image-Based Lighting) (0) | 2026.10.12 |
| glTF 모델 로딩 (0) | 2026.10.09 |
| cgltf API 정리 (사용한것 기준) (0) | 2026.10.09 |
| 라이팅에서 PBR 머티리얼까지 (0) | 2026.10.06 |




