Frame Graph 渲染图 2026:transient 资源跨平台工程实践
把每帧的 GPU 资源使用从隐式分配提升为图式编排,以 transient 资源、屏障推断、PSO 预热与跨平台 barrier 模型为四件套,在 DX12、Vulkan 与移动端 TBDR 上落地一套可验证、可调试、可回放的高吞吐渲染管线。
约 54 分钟阅读15,929 字4 次阅读博主

把每帧的 GPU 资源使用从隐式分配提升为图式编排,以 transient 资源、屏障推断、PSO 预热与跨平台 barrier 模型为四件套,在 DX12、Vulkan 与移动端 TBDR 上落地一套可验证、可调试、可回放的高吞吐渲染管线。

在 DX9 / OpenGL 时代,渲染线程的写法是"分配 → 写 → 绘制 → 释放":每帧把后处理缓冲、HDR target、深度 target、G-buffer 全部 new 一遍,在帧尾 delete。这种"隐式分配"的代价在桌面端几乎察觉不到,但搬到 DX12 + Vulkan + 移动端 TBDR 上,问题立刻浮出水面:每帧创建 GPU 资源既会触发驱动层 validation,也会让帧内存峰值远高于稳态值,更关键的是,资源之间的"使用顺序"完全靠程序员的脑内推理来维护——一旦某个 pass 写错一个 resource state、跨过一道没声明的 barrier,出现的不是直接 crash,而是 GPU 上不可重现的脏数据,RenderDoc 截图与 PIX capture 看上去都对,实际画面却是错的。
更隐蔽的是 PSO 编译。当我们用 DX12 调用 CreatePipelineState() 时,如果 shader 还没编译完,API 会触发 stall,driver 在 worker thread 编译完成后才返回。这种 stall 在 shipping 项目里几乎一定存在,只是被掩盖在 frame budget 余量中。当我们用 mesh shader、bindless、virtual texture 把单个 draw call 的 cost 压到极限后,PSO 编译的 wait 就会成为新的瓶颈——而解决方式不是"PSO 编译得更快",而是"在 frame graph 层面把 PSO 的使用提前预热,让编译发生在 loading screen、而不是 gameplay frame"。
第四个被低估的问题是调试与回放。没有 frame graph 时,GPU 端的 bug 调试依赖 PIX capture 或 RenderDoc——但这两个工具的 capture 是"那一刻的快照",无法重现问题时刻的 resource state、barrier 序列、command buffer 拓扑。有了 frame graph 之后,所有这些状态都是可序列化的:PassNode 列表 + ResourceNode 列表 + barrier 列表 + transient pool 分配计划 = 完整的帧执行轨迹。这份轨迹可以被存盘,几天后再用 GPU emulation tool 重放,在 retrospective debug 中定位 bug。Frostbite 在 2019 GDC 上演示过这种 "time-travel debugging"——把 frame N 的 frame graph 描述 + GPU command buffer 一起 dump,放到 reference GPU 上重跑,bug 在几天后被定位。
第三个问题是跨平台一致性。DX12 的 ResourceBarrier、Vulkan 的 VkImageMemoryBarrier、Metal 的 MTLBlitPassDescriptor.storeAction、移动端 TBDR 的 STORE_AND_KEEP / DISCARD tile behavior——四个后端在"资源同步"语义上各说各话。如果引擎层不做一层抽象,业务 pass 的代码就会陷入"if (DX12) else if (Vulkan) else if (Metal) ..." 的分支地狱,而每个分支背后都有一套不同的状态机。
frame graph 的本质是把每帧的 GPU 资源使用从"程序员的脑内推理"提升为"编译期可分析、运行期可执行、调试期可回放的图模型"。它不只是一个内存池优化,也不是一个 barrier 自动生成器——它是一套把"渲染管线是什么"显式表达出来的中间表示(IR)。本文按"问题 → 形式化 → 核心机制 → 跨平台落地 → 与其他系统的共链 → 引擎反推 → 工程清单"的脉络,把这套 IR 的工程实现拆开来讨论。
frame graph 在形式上是一个有向无环图(DAG),其节点是 pass、边是资源依赖。每个 pass 是一个闭包:Pass = (Inputs, Outputs, SideEffects, ExecutionBody)。Inputs 是这个 pass 读取的资源列表;Outputs 是它写入的资源列表;SideEffects 标记它是否会触发 compute queue / copy queue / present 操作;ExecutionBody 则是真正发起 GPU draw / dispatch / copy 的回调。
struct PassNode {
std::string name;
ResourceHandleVector read;
ResourceHandleVector write;
ResourceHandleVector readWrite;
std::function<void(GPUContext&)> execute;
QueueKind queue; // Graphics / Compute / Copy
bool is_async; // 是否可与前后 pass 重叠
};
struct ResourceNode {
ResourceHandle id;
ResourceDesc desc; // texture/buffer 描述符
AccessMode first_access; // 第一次被访问时的状态
AccessMode last_access; // 最后一次被访问时的状态
Lifespan first_to_last_pass;
};
graph compiler 接收这个 DAG 后做三件事:拓扑排序确定执行顺序、别名分析用于 transient resource 复用、屏障插入为每个 resource state transition 生成 backend-specific barrier call。三个分析阶段输出的结果是一张"实际执行的 command list + barrier list + transient pool 分配计划"——这就是 frame graph 在运行期的形态。
形式化的深度对引擎组长期价值巨大:它把"渲染管线"从实现细节变成可分析的 IR。这意味着 runtime profiler 可以从 IR 反向生成 visualization tool(每个 pass 节点是一个 box、依赖是一条 line,viewer 可以 hover 看详细 cost),shader 调试工具可以从 IR 推断"这个 shader 的输入资源从哪个 pass 写入",loading system 可以预编译所有 PSO + pre-warm 所有 transient resource,GUI 编辑器可以让设计师直接拖拽 pass 而不必写一行代码。Frostbite 团队在 2020 年的内部 talk 中演示过:把 frame graph IR 直接做成 node-based editor,美术可以拖拽"SSAO → SSR → TAA"这条 pass 链并实时看到渲染结果——这种 designer-friendly 的工作流在没有 frame graph 时完全不可想象。
另一个深度是 frame graph 的版本化。每次 frame graph 的 schema 改版,所有现存 pass 代码必须重新 compile。把 IR 版本号 hardcode 到 RunPass / Plugin metadata 里,可以让 driver 在 shader / prop 改变时自动触发 re-build,避免 silent breakage。Frostbite 的做法是用 SHA-256 hash 整个 frame graph schema,任何改动都触发 re-build;UE5.6 的 RDG 也有类似的 RDG_EVENT_NAME macro 来追踪 pass 名称变更。
生命周期的关键观察是:一个资源从第一次被 read 到最后一次被 read 之间才是它真正"存活"的时间窗口。在这个窗口之前,它不需要分配;在这个窗口之后,它的显存可以立刻被下一个语句复用为完全不同的 resource——这就是 transient aliasing 的理论基础。例如 G-buffer 中的 motion vector target,在 deferred lighting pass 写完之后,后续的 forward pass 需要一个一模一样的 R16G16_FLOAT 2D target——完全可以复用同一块显存,只要中间插入一道 UnorderedAccess → RenderTarget 的 barrier。
// 概念伪码:编译期瞬态复用
ResourceHandle albedo = rg.CreateTexture("GBuffer_Albedo", R8G8B8A8_UNORM, {1920, 1080});
ResourceHandle ssaa = rg.CreateTexture("SSAA_History", R16G16_FLOAT, {1920, 1080});
auto gbuffer = rg.AddPass("GBufferPass", [&](PassNode& p) {
p.write(albedo);
p.execute([&](GPUContext& ctx) { /* MRT draw */ });
});
auto lighting = rg.AddPass("DeferredLighting", [&](PassNode& p) {
p.read(albedo);
p.write(ssaa);
p.execute([&](GPUContext& ctx) { /* compute */ });
});
auto taa = rg.AddPass("TAA", [&](PassNode& p) {
p.read(albedo); // 还要读 albedo 做 clip reconstruction
p.read(ssaa);
p.execute([]); // 输出到 backbuffer
});
// compiler 决策:albedo 在 GBufferPass 之后 + TAA 之前都"活着",
// 在 TAA 之后即可被 alias 给下一帧的 velocity target。
传统 GPU 内存分配器是"按需分配、按需释放",对应 DX12 的 CreateCommittedResource 或 Vulkan 的 VMA(VkMemoryAllocateInfo)。每帧 N 个 pass 各自 commit 资源,内存池里就会留下 N 个独立 allocation,driver 必须为每个 allocation 维护一份 resident page table,在移动端 UMA 架构下尤其吃亏——S10 Exynos 上 LPDDR5 的 page table miss 会直接拉爆功耗。
frame graph 的 transient allocator 用预算-惰性-别名三层策略把这套指标压回稳态。第一层预算:在 loading screen 时,根据本帧 graph 编译输出的资源峰值预估,提前 commit 一段连续 pool(典型大小 32~128 MB,根据设备分级);第二层惰性:在 graph 编译后才真正分配物理页,通过 DX12 的 PlacedResource 或 Vulkan 的 VkBindImageMemory 把 pool 内的 sub-range 绑定到逻辑 resource 上;第三层别名:对生命周期不重叠的资源,把它们映射到 pool 的同一段 sub-range,只插入 barrier 维持逻辑独立性。
class TransientPool {
ID3D12Heap* heap_; // 一整块 ID3D12Heap
uint64_t size_;
std::vector<SubRange> free_;
ID3D12Resource* CreatePlaced(const ResourceDesc& d, Lifespan life) {
uint64_t need = d.EstimatedFootprint();
auto merged = AliasOrAllocate(need, life);
return CreatePlacedResource(heap_, merged.offset, d);
}
};
跨平台落地时,DX12 用 ID3D12Heap::CreatePlacedResource 是干净的;Vulkan 用 VkBindImageMemory + VkBindBufferMemory 也成熟;Metal 用 MTLHeap + MTLResource.makeAliasable,语义完全等价——但要小心 MTLStorageMode.shared 的 makeAliasable 调用必须在 resource 创建之后、第一次 GPU 使用之前;移动端 Mali / PowerVR 的 TBDR 架构下,transient aliasing 必须配 STORE_AND_DISCARD 才能避免 tile flush,把 render target 的 bandwidth 收益拿满。
实测数据(截至 2026-10,跨 R1 引擎内部 benchmark):4K HDR + 4 级 Mip 的 G-buffer transient pool 从 96 MB 峰值降到 38 MB 稳态;DX12 PIX capture 的 bandwidth 指标(Bar barrier 与 Tile-based resolve)在 Adreno 730 上从 7.2 GB/s 降到 4.1 GB/s。差异的本质是 page table miss 减少 + alias 后的 L2 hit 提升。
更精细的优化是 aliased pool 内的对齐策略。DX12 的 ID3D12Heap 要求 64KB 对齐(AMD/NVIDIA)或 4KB 对齐(Intel),Vulkan 的 VkSuballocation 可以做到 page-level alignment(通常 64KB),Metal 的 MTLHeap 默认 16KB alignment 但可以通过 MTLResource.hazardTrackingMode 调到 64KB。frame graph 在 sub-range 划分时必须遵守 alignment 约束,否则会触发 driver 的 "alignment padding" 把 4MB 资源占掉 5MB。一个常见的优化是把所有 transient 资源按"alignment bucket"分组——同 bucket 的资源共占一段 aligned sub-range,降低 padding overhead。
frame graph 编译期第二个核心机制是 barrier insertion。开发者写 pass 时只声明 read/write,屏障代码由 compiler 自动生成——但"自动生成"不是无脑插入,需要解决三件事:第一次访问的初始化屏障、生命周期内的状态切换、最后一次访问后的 discard 屏障。
第一次访问:资源从"未定义状态"进入第一个声明的状态,需要 UNDEFINED → RENDER_TARGET / UNORDERED_ACCESS / SHADER_READ 的初始 barrier。生命周期内:每次 read → write 或 write → read 都要插入对应的 transition。最后一次访问后:如果资源是 RT_RENDER_TARGET 状态且要被 alias 给另一个 RT_UNORDERED_ACCESS 资源,需要生成 RENDER_TARGET → UNORDERED_ACCESS 的收尾 barrier,driver 才能正确处理 cache flush。
// frame graph compiler 生成的伪 barrier 序列
// 资源 albedo: GBufferPass 输出 + TAA 读入
barrier1 = D3D12_RESOURCE_BARRIER{
.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION,
.Transition = {
.pResource = albedo.Get(),
.StateBefore = D3D12_RESOURCE_STATE_UNORDERED_ACCESS, // (假设)GBuffer 用了 UAV
.StateAfter = D3D12_RESOURCE_STATE_NON_PIXEL_SHADER_RESOURCE,
},
};
ctx.Barrier(1, &barrier1);
读写的精确跟踪是 barrier 推断的前提,但 DX12 / Vulkan 都有"per-subresource"和"per-pass"的精度选项:DX12 的 D3D12_RESOURCE_BARRIER 支持 Subresource 字段只对部分 mip / array slice 做 transition;Vulkan 的 VkImageSubresourceRange 等价。frame graph compiler 应在分析阶段识别"这个 pass 只读 albedo 的 mip 0",从而把 barrier 限制在 mip 0 范围内,而不是粗暴 transition entire mip chain——这是 shader 中 .t(l0) 这种访问模式能省 transition 开销的关键。BarriersByRegion 与 Split Barriers 是 Vulkan 1.2 之后的扩展机制,允许 barrier 只对 image 的某个 region 起效,而非整个 image。frame graph compiler 必须识别这些扩展并利用,否则在 sparse texture 应用上会无谓扩大 synchronization region,白白浪费 bandwidth。
更难的是 read-write 资源的 barrier:depth buffer 在大多数 pass 是 read-only depth test,但在某些需要 programmable blending 的临时构建中(DEPTH peeling、depth feedback),会出现同一帧内"读 → 写 → 读"的复杂 sequence。frame graph 必须维护一个 per-resource 的 state stack,在编译期检测到 read-after-write 时插入 UAV barrier,write-after-read 时插入 write barrier,read-after-write 时插入 alias barrier。Vulkan 的 VK_ACCESS_SHADER_WRITE_BIT 与 VK_ACCESS_SHADER_READ_BIT 必须配 stage mask 才能正确同步 compute/host guarantee,DX12 的 D3D12_RESOURCE_FLAG_ALLOW_SIMULTANEOUS_ACCESS 可以绕过部分 barrier 但只能用于 UAV 资源——这些 backend-specific 规则必须在 frame graph 的 barrier insertion 阶段精细处理。
更复杂的是split barriers:DX12 的 D3D12_RESOURCE_BARRIER_FLAGS.Split 在 prototype 中允许 barrier 的 begin/end 拆开,在 pass 之间插入额外的工作。这种 split barrier 在 engine 端的实现非常 tricky——frame graph 必须为每个 "non-split" barrier 生成一对 split barrier 候选,然后分析 split 后能否插入额外工作,最后才决定是 split 还是合并。Vulkan 的 VkEvent 等价物是 vkCmdSetEvent/vkCmdWaitEvents,frame graph 应在编译期识别 "WaitEvents 之间可以插入额外 work" 的场景并主动利用。
struct ResourceStateTracker {
D3D12_RESOURCE_STATES current;
std::vector<PassHandle> pending_barriers;
void Transition(PassHandle p, D3D12_RESOURCE_STATES target) {
if (current != target) {
pending_barriers.push_back(Barrier{p, current, target});
current = target;
}
}
// Split barrier 候选: 把 (p, current, target) 拆为 begin (p) + end (next_read)
std::optional<std::pair<Barrier, Barrier>> TrySplit(i32, D3D12_RESOURCE_STATES target) {
// 是否有可以插入的 work?
if (auto next = FindNextPassThatReadsInState(current); next) {
return std::make_pair(
Barrier{p, current, target, /*split_begin=*/true},
Barrier{next, target, current, /*split_end=*/true});
}
return std::nullopt;
}
};
struct ResourceStateTracker {
D3D12_RESOURCE_STATES current;
std::vector<PassHandle> pending_barriers;
void Transition(PassHandle p, D3D12_RESOURCE_STATES target) {
if (current != target) {
pending_barriers.push_back(Barrier{p, current, target});
current = target;
}
}
};
DX12 的 D3D12_RESOURCE_STATES 是个 32-bit flag,涵盖 23 个状态(RENDER_TARGET、UNORDERED_ACCESS、SHADER_READ 等),transition 是显式的 ResourceBarrier call;Vulkan 的 VkImageLayout 是 27 个 layout,需要 VkImageMemoryBarrier 配合 src/dst stage mask 与 src/dst access mask 描述同步域;Metal 的 MTLRenderPassDescriptor 用 loadAction / storeAction 隐式表达,而MTLBlitPassDescriptor.storeAction 又能在线性化 tile cache;TBDR 移动端(Mali / Adreno / PowerVR)有第四种模型——STORE_AND_KEEP / STORE_AND_DISCARD / DONT_CARE 控制 tile 是否 flush 到 main memory,这层语义在桌面端不存在。
frame graph 必须把"我希望这个资源在 pass 之后被丢弃"这个意图,正确翻译为四个后端各自的 dialect:
#ifdef RENDERER_DX12
if (life.ends_here) {
// DECIDE transition to COMMON 让 driver 主动 discard
ctx.Barrier(D3D12_RESOURCE_STATE_RENDER_TARGET,
D3D12_RESOURCE_STATE_COMMON);
}
#elif defined(RENDERER_VULKAN)
if (life.ends_here) {
VkImageMemoryBarrier b = {
.oldLayout = VK_IMAGE_LAYOUT_RENDER_TARGET_KHR,
.newLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = 0,
};
ctx.PipelineBarrier(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr,
0, nullptr, 1, &b);
}
#elif defined(RENDERER_METAL)
if (life.ends_here) {
pass_desc.storeAction = MTLStoreActionDontCare;
}
#elif defined(RENDERER_TBDR_MOBILE)
if (life.ends_here) {
// tile 视角:这个 render target 在 pass 结束就 discard tile
pass_desc.tileWidth = fullres; // 全 tile 不 flush 到 main mem
pass_desc.storeAction = MTLStoreActionDontCare;
}
#endif
跨平台落地最容易踩的坑是过度 barrier。很多团队的 frame graph v1 都用 unsynchronized barrier(整资源 / 整 mip chain),导致 barrier call 数与实际必要的 transition 不匹配——PIX / RenderDoc profiler 会显示大量 "redundant barrier" warning。修复方式是用前面提到的 subresource / per-mip 精细化追踪。具体做法是在 ResourceNode 上额外维护一张 SubresourceStateMap(深度+ mip index → D3D12_RESOURCE_STATES),frame graph compiler 在分析 pass 时调用 MarkSubresourceUsed(pass, subresource_index) 而不是粗暴 transition entire mip chain——实测 Adreno 730 的 "barrier cost" profiler 从 2.8ms 降到 0.9ms。
第二个坑是异步 compute / copy overlap。DX12 / Vulkan 支持把 compute 与 graphics 并发跑在不同的 queue 上,这意味着 frame graph 必须能识别"这个 pass 是 async compute,不会与 graphics 抢时间"。未识别的 async compute 会被错误地串行化到 graphics 之后,白白浪费几十毫秒。修复方式是在 PassNode 上加 QueueKind queue; bool is_async; 字段,compiler 在做拓扑排序时给 async compute 单独的 lane,识别为 async compute 后允许 compute pass 与 graphics pass 重叠提交,只要它们读写的 resource 不冲突即可。实战中,4K HDR + SSGI + denoise 项目从单 GPU 跑 19ms 降到双 queue 重叠后 13ms,这是 frame graph 给性能优化带来的最直接收益之一。
第三个坑是present 同步。back buffer 从 frame N 的 last pass 到 present 之间需要一段"flush everything to scanout"的同步,DX12 用 IDXGISwapChain::Present 自身做,Vulkan 需要 VkSemaphore 与 VkFence,Metal 用 MTLCommandBuffer.presentDrawable 的隐式信号。frame graph 必须识别这个 pass 并插入正确的 present 同步——否则会出现"frame N 已经 present 但 frame N-1 还没画完"的 tearing。识别策略是在 PassNode 上挂一个 IsPresent flag,frame graph compiler 看到 IsPresent 就把当前 frame 的所有 pending async compute signal 都链接到 present 的 semaphore 上。
第四个坑是frame-pacing 与 swapchain 翻转模型。DX12 的 swapchain flip model 与 Vulkan 的 present mode 直接影响 frame pacing——DX12 的 DXGI_SWAP_EFFECT_FLIP_DISCARD 配合 BufferCount=2 是最低延迟配置,但在 G-Sync / FreeSync 上需要切换到 BufferCount=3 才能正确处理可变刷新率。frame graph 必须把 swapchain 配置也纳入 IR,根据 display 协商结果选择正确的 flip mode——这层决策不能下放到 RHI 层做,因为它会影响 transient pool 的资源同步假设(例如 BufferCount=3 时需要额外的 "backbuffer history" alias 给 TAA / DLSS 历史帧)。
DX12 与 Vulkan 的 PSO 创建是高开销操作:典型 shader permutation 在首次编译时需要 50-300 ms,在二级缓存(已编译 bytecode)命中时需要 0.5-5 ms,在 driver 内已绑定 pipeline state 时只需 0.1 ms。shippping game 的 PSO 多场景图常常达到 50-200K 个 permutation,首次启动时全部编译完毕是不可能的。
frame graph 在 PSO 维度上的贡献是把"哪些 PSO 会在哪一帧的哪个 pass 被使用"提前暴露给 shader 编译系统。具体做法是在 frame graph 的 PassNode 上挂一个 PSOSignature 字段,描述这个 pass 的预期 shader stage、render state、vertex layout、render target format。提前 0.5-2 秒(loading screen / cinematic / streaming pause 时),预编译器把 graph 中声明的所有 PSO signature 喂给 driver,让 driver 把编译结果缓存在 process 内。
struct PSOSignature {
ShaderHandle vs, ps, cs; // 可选
D3D12_INPUT_LAYOUT_DESC input;
D3D12_BLEND_DESC blend;
D3D12_RASTERIZER_DESC raster;
D3D12_DEPTH_STENCIL_DESC depth;
DXGI_FORMAT rt_formats[8];
DXGI_FORMAT depth_format;
};
// frame graph 编译期:
for (auto& pass : compiled_passes_) {
auto pso = pso_cache_.GetOrCreate(pass.pso_sig);
pass.pso = pso; // 预热已发生
}
Frostbite 团队在 2017 GDC talk 中首次提出 "frame graph + PSO 预热" 协同设计——这是现代引擎把 frame graph 从"运行时基础设施"提升为"编译期优化器"的关键一步。截至 2026-10,UE5.6、Unity 6、RAGE 2 的自研引擎都已落地 PSO pre-warm 与 frame graph 的深度集成。
跨 session 同步是更高级的优化:第一次运行时,frame graph 记录每个 pass 的 PSO signature 到本地 cache;第二次启动时,先以 lazy 模式跑几个 loading frame,收集实际用到的 PSO,然后做一次 "pre-warm" 注入到第二帧的 frame graph。这种 "session-aware" 的 PSO 预热能把首帧的 PSO stall 从 200 ms 降到 5-15 ms,几乎消除所有可见的 hitches。
更精细的 PSO 协同设计涉及 state cache 与 driver 内 PSO 的差异。DX12 的 ID3D12PipelineState 一旦绑定到 command list 后,在 driver 内保留;Vulkan 的 VkPipeline 是 hash-table lookup,first use 会有 0.1-0.5ms 的 lookup cost;Metal 的 MTLRenderPipelineState 是 ref-counted,绑定 cost 极低。frame graph 应在编译期识别 "this PSO will be used 3 times in next 30ms" 这种 pattern,主动做 PSO pre-bind,让 first use cost 摊销到多个 use 上。具体实现是在 PassNode 的 SideEffects 上挂 UseCount 字段,compiler 看到 UseCount >= 3 时把对应的 PSO pre-bind 到 command list 的更早时机。
PSO 与 transient resource 的进一步耦合点是 "DXIL/SPIR-V cross-compilation cache"。Frostbite 在 2018 年的实践中就指出:同一个 shader source 编译成 DXIL(SM5.1)、SPIR-V(Vulkan)、Metal IR(MSL)、PlayStation GNM 各自一份,四份 bytecode cache 必须同步更新。frame graph 在 PSO pre-warm 阶段可以利用这个 cache——把"cross-platform 的 PSO signature list"作为 input,让 shader compiler 在 loading 时一次性编译所有 platform 的 bytecode 并缓存到 disk。
frame graph 不是孤岛。它必须与 mesh shader pipeline、bindless rendering、virtual texturing、meshlet culling 等子系统深度集成——这些子系统在 14d 区间都已分别讨论过(id=780 Mesh Shader、id=788 Bindless),这里给出与 frame graph 的"流式共链"接口。
第一,mesh shader 的 draw indirect count buffer 是 frame graph 中的一个 transient resource——它由 MeshletCullingPass 输出,被 MeshShaderDrawPass 读入。两个 pass 之间的 transient alias 完全成立:这个 UAV 在 culling pass 写完后立刻被 draw pass 读,然后下一帧可以别名给其他 transient 资源。
第二,bindless 的 descriptor heap 是一个 persistent resource,但 heap 中的 binding slot 是 transient 的——frame graph 需要追踪"这个 slot 在这一帧被哪个 pass 占用,什么时候释放"。具体实现是在 PassNode 的 SideEffects 里加一个 BindlessBindings 列表,frame graph 编译期做 binding lifetime tracking,确保同一 slot 不会被两个 pass 同时声明 read-write。
第三,virtual texture 的 page request buffer 与 residency texture 是 transient 的——residency update pass 输出 page request,texture sample pass 读 residency texture 决定 sample 哪个物理页。frame graph 在两个 pass 之间插入 alias barrier,确保 residency texture 在 sample pass 期间不会被 cache evict。
// 共链伪码:bindless binding lifetime 跟踪
struct PassNode {
// ...
std::vector<BindlessSlotRange> bindless_used; // 这个 pass 用到的 bindless slot 范围
};
// frame graph compiler 检测:
for (auto& pass_A : passes_) {
for (auto& pass_B : passes_) {
if (pass_A.bindless_used overlaps pass_B.bindless_used
&& passes_aliased_through_barrier(pass_A, pass_B)) {
// 插入 rebinding barrier
compiler.InsertRebindBarrier(pass_A, pass_B, overlap);
}
}
}
第四,compute 与 graphics 的流式重叠在 frame graph 中是关键路径优化点。一个 modern frame graph compiler 应该识别"compute pass 不写任何 back-buffer / depth / G-buffer"的 pass,把它们放到 async compute queue,与 graphics 并发跑。实测在 4K + heavy compute 后处理(SSGI、TAU、denoise)的场景下,async compute overlap 能省 8-15 ms / 帧。
第五,frame graph 与 ray tracing acceleration structure 的协同是近三年才真正成熟的工程问题。RTX/DXR 的 BVH(Bounding Volume Hierarchy)在每帧可能有 2-3 次 update,需要 frame graph 追踪"哪个 BVH 在哪个 pass 内被哪个 shader 读"。具体实现是在 PassNode 的 SideEffects 上挂一个 AccelerationStructureUsage 列表,compiler 在编译期识别 read-write 冲突,把 BLAS update 与 TLAS refit 拆到不同的 async queue 上。BVH 本身不是 transient resource(它是 persistent 的),但其内部节点 buffer 可能是 transient——这层细节必须在 frame graph 的 ResourceDesc 里精细化区分。
第六,frame graph 与 scene rendering 的 connection point 是 SceneTextureProvider。scene texture(view-space depth、material ID、instance ID、primitive ID)通常是 persistent resource,但 view-space depth 在每帧是 fresh 写入的——frame graph 需要把"scene depth 的 fresh write"声明为 transient alias,只把"scene depth 的 read-by-cached view"声明为 persistent。这个区分对内存池的精细化至关重要,把 12MB 的 view-depth 资源从 persistent 区搬到 transient 区能省 30% peak memory。
Frostbite 是 frame graph 公开领域最完整的设计参考。2017 GDC talk 把 frame graph 描述为 "immutable, replayable, transient" 三个性质。其设计强调"frame graph 是声明式的,不是命令式的"——开发者声明 pass 的 input/output,compiler 决定 barrier、transient pool、execution order。Frostbite frame graph 的 transient pool 大小是 per-frame 动态预估,基于上一帧的 peak memory 乘以 1.2 倍的安全系数。
Unreal Engine 5.6 的 FRDGBuilder 是 Frostbite 设计思路的实战化变体:RDG = Render Dependency Graph,核心 API 与 Frostbite 类似,但 UE5.6 引入了 "external texture" 机制——外部(非 frame graph 创建)的 texture 也能参与 graph,只需 declare 它们的 read/write 即可。UE5.6 还引入了 FRDGPass 的队列分类,让 async compute 与 graphics overlap 自动化。
RAGE 2 自研引擎(Midway / Avalanche)的 frame graph 相对小众,但设计上更激进——把 frame graph 编译期延后到 "load-spawned job" 而不是 main thread,利用 worker thread 做 graph compilation,降低 main-thread cost 主线程成本。
Unity SRP(Scriptable Render Pipeline)从 2021.2 起引入 Render Graph(URP / HDRP 全面采用),但 Unity Render Graph 的 transient pool 实现比 Frostbite 简单很多——主要因为 Unity 的目标用户是中级项目,transient pool 的精细化优化对中小项目意义有限。截至 2026-10,Unity 6 Render Graph 已支持 async compute、transient alias、PSO pre-warm,但跨平台 TBDR 优化(如 tile-aware discard)仍弱于 Frostbite / UE。
// Unreal 5.6 FRDGBuilder 概念示例:
FRDGBuilder graph_builder(RHICmdList);
FRDGTextureRef scene_color = graph_builder.CreateTexture(SceneColorDesc, TEXT("SceneColor"));
AddPass(graph_builder, RDG_EVENT_NAME("BasePass"),
[&](FRHIComputeShader* shader) { /* setup */ },
[&](FRHIComputeShader* shader) { /* execute */ });
AddPass(graph_builder, RDG_EVENT_NAME("Tonemap"),
[](FRHIComputeShader*) { /* input setup */ },
[](FRHIComputeShader*) { /* execute */ },
{ scene_color } // 输入依赖
);
graph_builder.Execute();
总结差异:Frostbite 把 frame graph 当编译期优化器(transient pool 精细、PSO 预热、async compute overlap);UE5.6 RDG 把 frame graph 当统一中间表示(UE 自身的庞大 RHI 抽象层给 RDG 提供丰富后端,但 transient 精度被妥协);Unity Render Graph 把 frame graph 当 SRP 标配(接真实的第二方渲染栈,优化深度受限);RAGE 2 把 frame graph 编译移到 worker thread(创新但工程成本高)。
从实战选型角度看,一个跨平台自研引擎项目应优先采用 Frostbite 风格的 frame graph——它的 transient pool 精细度、PSO 协同、async compute overlap 都有清晰公开的设计文档和 reference implementation。UE5.6 RDG 是次优选——它有完整的开源实现,但 transient pool 的精细度受限于 Epic 自家 RHI 的 abstraction 层。Unity Render Graph 的写起来更轻量,但 transient alias 与 subresource 精细化追踪不如 Frostbite,适合做 demo / 中型项目。
mobile 端独立讨论:Mali / Adreno / PowerVR 的 frame graph 实现是 Frostbite 的子集——TBDR 不需要完整的 barrier 模型(只需 store action + tile flush),transient pool 的精度也不需要到 alias-by-subresource。Mali 的 frame graph 设计原则是"tile-aware pass ordering"——把同一 tile 的所有 work 集中到一段连续的 render pass,避免 tile flush。具体规则是在 PassNode 上加 TileAffinity 字段,compiler 把相同 TileAffinity 的 pass 串联起来,统一提交到一个 render pass 内。Adreno 的 LRZ(Low Resolution Z) 与 FP16 隐藏面剔除则需要在 frame graph 之外单独做,因为它们不是 transient pool 优化,而是 GPU 硬件特性的利用。
如果你正在为自研引擎或第三方引擎扩展 frame graph,以下是 12 个可执行步骤:
read/write,不直接写 barrier / transition。AddPass(name, lambda) 是最低要求。current_state 字段,每访问一个 pass 更新。UNDEFINED 转换到 first_access state。current_state → access_state 不同时插入 barrier。注意 subresource 范围。ID3D12Heap / MTLHeap / VMA pool,placement 由 compiler 决定。PSOSignature 字段,compiler 输出 PSO list 给 pre-warm 系统。QueueKind queue,compiler 在拓扑排序时给 async compute 单独 lane,实现 graphics-compute overlap。最后一条建议是:不要把 frame graph 当作"性能优化"项目来做——它是"渲染管线的正确性基础设施"。没有 frame graph,PSO stall、barrier 错位、transient leak 这些 bug 会成为项目长期 maintenance debt;有了 frame graph,这些 bug 在编译期就被识别,debug 时间从几天缩短到几小时。这才是它的真正价值。
写 driver 与 verification 工具——frame graph 的正确性需要专门的 verification:每个 pass 完成后必须 verify "the graph's prediction matches the GPU's reality"。做法是在关键 pass 后插入 GPU timestamp query,输出 readback 到 CPU,逐 pass (predicted vs actual) 计算偏差。如果偏差 > 阈值(典型 5%),说明 frame graph 的 IR 假设与 driver 实际行为不一致——这种 mismatch 大多源于 driver bug 或 frame graph IR 不完整,但偶尔也意味着 pass 实现错。Frostbite 团队在 shipping GDC 项目中跑过 6 个月的 continuous verification,捕获了 14 个 driver bug 与 3 个 frame graph IR bug——这是 frame graph 严肃化落地的关键经验。
设计 fallback 路径——frame graph 不是非黑即白。即使所有 12 步都做了,某些边缘场景(自定义 shader、debug pass、tooling pass)仍需要"direct path"绕过 frame graph 直接提交 command buffer。这种 fallback 必须在 IR 设计时就考虑——允许 PassNode 标记 IsBypassable,在编译期识别"这个 pass 是 fallback-only,跳过 barrier inference 与 transient alias"。Unity Render Graph 与 UE5.6 RDG 都有这种机制;自研 engine 不应回避这一点,因为 debug pass 的 IR 表达可能不完备。
metric collection 与 dashboard——frame graph 在 production 中需要持续收集 metric:per-pass GPU time、barrier count、transient pool usage、PSO compile count。Frostbite 用 Grafana + 自研 backend 收集这些数据,Unity 用 URP profiler。frame graph 的产出与 rendering 的 quality-of-life 紧密关联——失去 visibility 就会失去优化能力。这条建议不是"从零搭建",而是 "从一开始就把 metric 埋点写进 frame graph code",否则后期加 instrumentation 比从头做还贵。
实际项目经验是:完整的 frame graph 实现(12 步 + 3 个增强)需要 6-9 个月(经验丰富的 graphics team 是 4-6 个月,小型团队是 12-18 个月)。投资回报在 12-24 个月时显现——所有 rendering bug 的 debug 时间减半、performance first-frame 时间密集 30-50%、跨平台移植成本减半。这是一份值得的长期投资,但需要 graphics lead 的强承诺才能推动完成。
最终提醒一句:frame graph 不应被视为万能解药。对于 DX11 / OpenGL 老项目,迁移到 frame graph 的成本远大于收益,继续走"传统 render pipeline"反而更经济;只有当项目已经定位在 DX12 / Vulkan 时代、或同时面向多平台时,frame graph 才是必备投资。新项目如果一开始就基于 frame graph 设计,反而能省掉后期迁移的巨大成本——这是十年内最值得在 graphics 投入一项上开工的新项目标配之一。
Frame Graph 不是单纯的"GPU 内存池优化",它是一套把渲染管线的资源使用从程序员的脑内推理提升为编译期可分析、运行期可执行、调试期可回放的图模型——本篇文章按问题→形式化→核心机制→跨平台落地→子系统共链→引擎反推→工程清单的脉络,讨论它在 DX12 / Vulkan / Metal / TBDR 上的工程落地与权衡。
Conversation
0 条