Render Graph 架构设计 2026:依赖追踪与内存别名的统一框架
把渲染管线的设计语义与执行调度解耦到一张 DAG 上,从 Frostbite 到 UE5,Render Graph 是现代引擎共享的基础设施。
约 41 分钟阅读12,267 字4 次阅读博主

把渲染管线的设计语义与执行调度解耦到一张 DAG 上,从 Frostbite 到 UE5,Render Graph 是现代引擎共享的基础设施。

当我们把 GPU Driven Pipeline、Visibility Buffer、Mesh Shader、ReSTIR 这些 2026 年主流引擎的核心特性逐一拆开,会发现它们背后其实共享同一个工程问题:绘制命令的录制、依赖资源的状态转换、以及多线程并行录制之间的耦合。当一个 pass 写一张 G-buffer、另一个 pass 读它做 deferred lighting 时,命令缓冲的插入顺序必须遵循资源依赖;当我们尝试把 G-buffer 录制、光照计算、后处理拆到三条线程并行执行时,依赖关系的正确性又成为头号风险。这正是 Render Graph 要解决的根本问题:把"我应该按什么顺序、以何种状态、向谁写入哪些资源"这一组决策,从手写顺序代码提升为可被图算法自动推导的声明式抽象。
更具体地,传统渲染器在 pass 之间手工插入 barrier(DX12 resource barrier / Vulkan pipeline barrier / Metal memory barrier)、手工管理 render target 的绑定与解绑、手工计算 texture 生命周期并决定何时 unbind、何时可以 alias 到同一块内存。当 pass 数量膨胀到 50+、多 pass 之间频繁切换 RT、跨多个 async compute queue 时,这种手工维护的成本会以 O(n²) 增长——一个不起眼的 reorder 错误,会在 Xbox Series 上触发 TDR、在 PS5 上触发 GPU hang、在 Mali 上触发 driver 内部 ASSERT。Render Graph 的核心承诺是:让开发者只声明"我需要这张图作为输入、输出到这张图",由图引擎自动推导出执行顺序、barrier 插入、aliasing 决策。这听起来像调度器的常规任务,但当我们把 ray tracing、mesh shading、compute shader 这类新 pass 类型纳入考虑,就会发现它远不止"做个拓扑排序"那么简单。
我们用 Frostbite 在 GDC 2017 上给出的数字做个量级感:当他们把 FrameGraph 落地到《星战 前线 II》的 Frostbite 引擎后,每帧的 render target 数量从约 280 张降到约 90 张(aliasing 后),VRAM 占用降低约 35%,且 GBuffer 录制时间在多线程场景下降约 40%。这个收益不仅来自内存复用,更来自"不必再为 aliasing 决策写大量手工代码"后,开发者可以放心地把 pass 数量做得更细——细粒度的 pass 又是 deferred pipeline / forward+ / clustered shading / visibility buffer 等高级特性能够落地的前提。这就是为什么 Render Graph 不只是一个内部抽象、而是 2026 年现代引擎默认的渲染管线骨架。
Render Graph 的核心数据结构是一个有向无环图(DAG),节点是 pass,边是资源依赖。我们把它形式化为三元组 <P, R, E>:
struct Pass {
name: string
inputs: ResourceHandle[] // 读
outputs: ResourceHandle[] // 写
execute: (cmd: CommandBuffer) => void
}
struct Resource {
handle: ResourceHandle
desc: TextureDesc | BufferDesc
first_use: PassId // 首次声明的 pass
last_use: PassId // 最后消费的 pass
transient: boolean // 是否允许 aliasing
}
struct Edge {
from: PassId // 写者
to: PassId // 读者
resource: ResourceHandle
access: AccessType
}
这个三元组对应到不同的图形 API:
VkRenderPass + subpass / VkComputePass;Resource 对应 VkImage / VkBuffer handle;Edge 对应 subpass dependency(自动) + VkImageMemoryBarrier(手动跨 pass)。ID3D12Resource*;Edge 对应 D3D12_RESOURCE_BARRIER。MTLRenderCommandEncoder / MTLComputeCommandEncoder;Resource 对应 MTLTexture / MTLBuffer;Edge 对应 MTLBlitCommandEncoder + MTLResource.usage = ...。值得注意的是,Vulkan 的 subpass 是 API 层级的 Render Graph 子集——subpass 之间共享同一个 VkRenderPass,允许 tile-based GPU 把中间结果保留在 tile memory 而非写回主存。Render Graph 需要识别这些 subpass 模式并相应调整 aliasing 决策(subpass 内不允许 alias),这是后续 §3 的核心话题。
Handle 与物理内存的解耦是 Render Graph 最重要的工程抽象。传统引擎里 texture 是裸指针 + ref count,aliasing 必须手工安排释放 / 重分配时机;Render Graph 里 resource 是逻辑 handle,所有物理分配 / 释放 / aliasing 决策由图的尾端 cull pass 在帧末统一完成。
一个 resource 的 lifetime = [first_use_pass_id, last_use_pass_id],当图构建器把所有 pass 排好拓扑序后,每个 resource 的 lifetime 就被唯一确定。Aliasing 的核心规则是:两个 resource 在物理内存层可以复用,仅当它们的 lifetime 不相交。
// Frostbite 风格 transient resource pool 的核心 API
TransientMemoryPool::Suballocate(
desc: TextureDesc,
first_use: PassId,
last_use: PassId
) -> TextureHandle {
auto& free_list = m_free_slots[desc.width * desc.height * desc.format];
for (auto& slot : free_list) {
if (!slot.overlaps_with(first_use, last_use)) {
slot.assign(first_use, last_use);
return slot.handle;
}
}
return allocate_new_slot(); // 池满,向 OS 申请
}
当我们把一张 4K RGBA16F 的 G-buffer 与一张相同尺寸的 velocity buffer 排在同一帧的不同时间窗口里执行(前者用于 deferred lighting,后者用于 TAA),它们可以被 alias 到同一块 64MB 显存。当 pass 数量达到 100+、纹理类型超过 30 种时,aliasing 命中率(即"多少资源被复用了物理内存")在移动端能稳定在 50-70%,桌面端 30-50%。这意味着 30% 以上的 VRAM 峰值被压平。
但 aliasing 决策有三大陷阱:
Frostbite 的 FrameGraph 实现对这三类陷阱都有明确决策树,UE 的 RenderGraph 在 5.0+ 引入 RDG_RESOURCE_FLAG 来让 pass 作者声明 subpass 亲和性。这些决策正确时,aliasing 收益立竿见影;决策错误时,会触发 driver 内部 ASSERT 或 tile memory 重新刷新,性能反而下降。
Aliasing 决策的工程化流程实际包含四个子步骤。第一步是 lifetime 计算:图构建器对每个 resource 收集所有 producer 与 consumer 的 pass id,取 first_use 与 last_use 形成 [first, last] 区间。第二步是分配决策:在 transient pool 里寻找一个空闲 slot,要求该 slot 的物理区间与新 resource 的 lifetime 不重叠——这一步通常用 first-fit 或 best-fit 算法,best-fit 在大多数 benchmark 上能拿到 5-8% 的命中率提升。第三步是物理分配:对所有分配好 slot 的 resource 做实际的 VkAllocateMemory / ID3D12Heap::Alloc / MTLHeap suballocate;如果池满,则向 OS 申请新 memory。第四步是回收:当帧结束、resource handle 析构时,物理 slot 归还到 pool 的 free list,而不是直接释放回 OS。
Heap-aware aliasing 是 2026 年的新趋势——传统 aliasing 把所有 resource 视作同质,但现代 GPU 有多个 memory heap(如 Xbox Series 的 10GB + 6GB split),把频繁 aliasing 的 resource 放在带宽较高的 heap(6GB 段)、把生命周期稳定的大 resource(如 G-buffer 的全分辨率 target)放在容量较大的 heap(10GB 段),能进一步压低 L2 cache miss 与 memory traffic。这一思想在 PS5 的 GNM 引擎、Frostbite 的次世代 FrameGraph 演进里都有体现。
依赖追踪的输入是所有 pass 的输入/输出声明,输出是按依赖关系排序的执行顺序。这是一个标准的 DAG 拓扑排序问题,但有几个工程关键点:
显式依赖 vs 隐式依赖:显式依赖由 pass 作者通过 read(resource) / write(resource) 声明;隐式依赖由 RHI 推断——例如当你调用 cmd.bindRenderTarget(rtA) 然后再调用 cmd.bindRenderTarget(rtB),RHI 自动在中间插入一个对 rtA 的 store barrier、对 rtB 的 load barrier。这种隐式依赖往往成为 Render Graph 的意外陷阱——开发者以为他在做无依赖的两个 pass,但 RHI 已经偷偷建了边。
Kahn 算法 vs DFS:主流引擎用 Kahn(BFS)做拓扑排序,因为它对动态 barrier 插入更友好(每条边出队时即可插入 barrier)。DFS 在 cycle detection 上更直接,但容易栈溢出(pass 数 200+ 时递归深度可能不够)。Unity SRP RenderGraph 用 Kahn,UE 用 DFS + cycle break。
Cycle detection:当 pass A 写资源 X,pass B 读 X 写 Y,pass C 读 Y 写 X 时,依赖图出现环。Render Graph 必须检测出环并报错——通常是 bug,让开发者用 scratch resource 拆开 X 即可。
// Kahn 算法伪代码
queue = [passes_with_no_inputs]
sorted = []
while (!queue.empty()) {
p = queue.pop()
sorted.push(p)
for (succ : p.successors()) {
succ.remove_input(p)
if (succ.inputs_empty()) queue.push(succ)
}
}
if (sorted.size() != passes.size()) {
throw "cycle detected in render graph"
}
return sorted
UE RenderGraph 的实战细节:它通过 RDG_PASS_FLAG 让开发者声明 pass 的"作用范围"——NeverCull(永不 cull,即使没输出也给 graph 贡献 edge)、RasterOrder(强制光栅顺序)、Parallel(允许多线程并行)。这些 flag 影响 DAG 构建时的边决策。例如,NeverCull 的 pass 即使在最后一帧没人读它的输出,也会作为 dependency node 留在图里,用于维持 barrier 正确性(常见的"看起来 cull 掉却触发 TDR"的 bug 就是漏了 NeverCull)。
Unity SRP RenderGraph 在 2022 LTS 起成为默认管线(替代旧的 SRP ScriptableRenderContext),它在 HDRP 里把大约 80 个 pass 全部声明到 RenderGraph 上,每帧构建一张图,再多线程录制到 CommandBuffer。它的 GraphViewer 是一个独立的可视化工具,开发者可以在 Editor 里实时看到 pass 之间的依赖图、每个 resource 的 lifetime 条。
多线程录制是 Render Graph 的"性能回报"所在——也是"工程复杂度爆炸"的根源。核心约束:每个 pass 内部的 execute(cmd) 闭包必须在同一个线程上录制完成(GPU command buffer 不是线程安全的),但不同的 pass 可以在不同线程上并行录制。
// 多线程 Render Graph 录制的标准模式
void RenderGraph::Execute() {
// 阶段 1:拓扑排序已确定 pass 顺序
auto sorted_passes = topological_sort(m_passes);
// 阶段 2:按 pass 依赖切分 worker
// 可并行的 pass(彼此无 dependency)放到不同 worker
auto groups = partition_into_parallel_groups(sorted_passes);
// 阶段 3:每个 worker 录制自己的 group
std::vector<CommandBuffer> group_buffers(groups.size());
parallel_for(groups.size(), [&](size_t i) {
for (auto* pass : groups[i]) {
pass->execute(group_buffers[i]); // 闭包在 worker 内串行
}
});
// 阶段 4:合并 group buffers + 插入 barrier 到最终 command stream
for (size_t i = 0; i < groups.size(); ++i) {
m_final_cmd_buffer->merge(group_buffers[i], /*insert barriers*/ true);
}
// 阶段 5:提交给 RHI queue
m_rhi_queue.submit(m_final_cmd_buffer);
}
frame pipelining 是进一步压榨性能的招数:当 GPU 还在执行第 N 帧时,CPU 已经可以开始第 N+1 帧的 Render Graph 构建 + 多线程录制。这是 Frostbite 在 GDC 2015 提出的"two-frame latency"模式——代价是 N+1 帧的渲染目标视图可能与上一帧的资源重叠(场景还没渲染完),所以 frame pipelining 通常只在 render target aliasing 安全且场景结构稳定的部分启用。
re-ordering barrier 是另一个微妙话题:现代 GPU(特别是 NVIDIA Ampere / Ada、AMD RDNA 3、Apple M3)允许在同一个 queue 内对无依赖的 pass 重排执行,以提升 cache 命中率或合并 barrier。但跨 queue(graphics ↔ compute / graphics ↔ present)的重排必须显式 barrier——Render Graph 必须识别 queue 边界,不能把不同 queue 的 pass 混着排。
实战数据:Frostbite 在 PS5 上跑《FIFA 21》时,把 RenderGraph + 多线程录制 + frame pipelining 三件套叠起来,CPU 录制时间从约 11ms 降到约 3.5ms(下降约 68%)。UE5 在 Lumen 启用场景下,CPU 录制开销基本稳定在 2-3ms,主要瓶颈是 Lumen 的 surface cache 刷新而非 RenderGraph 本身。
移动端 GPU(ARM Mali、Qualcomm Adreno、PowerVR IMG)的渲染管线与桌面端有根本差异——它们是 tile-based deferred renderer (TBDR),先把所有 geometry 写入 tile binner,然后在 tile 上完成 fragment shader,最后把 framebuffer 写入主存。这意味着:
Console 适配:
Switch / PS5 / Xbox Series 的 async queue 各有差异:PS5 有 3 个 async compute queue + 1 个 graphics queue,Xbox Series 有 2 个 compute queue + 1 个 copy queue + 1 个 graphics queue。Render Graph 在调度时必须显式区分 queue,避免"误把 compute pass 排到 graphics queue"导致的 GPU idle。
// 移动端 tile-friendly Render Graph 决策
enum class TileLoadStoreAction {
Load, // 保留 tile 内容(用于 read-modify-write)
Store, // 写回主存
Clear, // pass 开始时清空
DontCare, // 既不 load 也不 store,driver 自由优化
};
ResourceDesc RenderGraph::compute_tile_friendly_desc(
ResourceHandle res,
const PassInfo& pass
) {
if (pass.is_subpass_internal(res)) {
return { /* tile memory only, no main memory */ };
}
if (!res.written_after_this_pass()) {
return { .load_action = DontCare, .store_action = DontCare };
}
return { .load_action = Load, .store_action = Store };
}
四款主流商业引擎的 Render Graph 实现差异非常典型,几乎可以作为设计取舍的"四象限"样本:
TransientMemory::Suballocate API。UseTexture(rt),编译器自动推导。RenderPass 脚本的 [Shader] 标签)。RDG_PASS_FLAG 让开发者控制 cull / raster order。GraphBuilder.AddPass<TPassData>)。| 引擎 | 设计哲学 | 学习曲线 | aliasing 激进度 | 多线程录制收益 |
|---|---|---|---|---|
| Frostbite | 透明 | 高 | 极高 | 高 |
| UE RenderGraph | 平衡 | 中 | 中高 | 中高 |
| Unity SRP RG | 易用 | 低 | 中 | 中 |
| RE Engine | 自研 | 极高 | 中 | 高 |
一个粗略的经验法则:如果你的团队规模小、生命周期短、希望快速上线 → Unity SRP RG;如果你的团队规模大、性能要求极致、需要 long-tail 平台支持 → Frostbite / UE 路径;如果你的工作室有 3A 跨平台目标 → UE + 自研扩展层。
Render Graph 的"设计阶段"(写代码)与"运行阶段"(看 GPU 在做什么)之间需要一个高质量的可视化桥梁,否则开发者面对 100+ pass + 几十个 resource + 复杂的 aliasing 关系,会陷入"看 CommandBuffer 列表完全无法理解整体行为"的困境。
主流 Render Graph profiler 应具备的能力:
RHI.GraphicsDebugger 也提供类似能力)。// UE RenderGraph 的 RDG_EVENT_SCOPE 宏
RDG_EVENT_SCOPE(GraphBuilder, "GBufferPass");
// 该宏自动把 pass 范围写入 RHI profiler
// 在 Unreal Insights 里能看到这个 scope 的 GPU 时间和 dependency
// Unity SRP RenderGraph 的可视化
RenderGraph.AddPass<MyPassData>(
"MyPass", // pass 名(用于 GraphViewer)
passData,
sampler,
(context, data) => { /* execute */ }
);
自研 Render Graph 的最低 profiler 要求:至少实现前 4 项。Capture & replay 可以后续迭代。
自研引擎团队面对的第一个问题是:要不要从一开始就上 Render Graph? 答案取决于团队规模与项目周期。
经验上,一个 MV-RG 的实现需要约 3000-5000 行 C++(不含 RHI 层),覆盖:
如果你刚开始一个新引擎,MV-RG 是值得的初始投入——后续的 VRAM 优化、多线程录制、跨平台移植都依赖它。但如果你是一个已有引擎在做"现代化改造",需要权衡改造成本与收益。
一个不上 Render Graph 的传统渲染器在 2026 年的处境:
wgpu + render-graph crate),让任何 RHI 后端都能复用。Mozilla 的 WebGPU 团队、Embark Studios 的 infinitree-rdg、gfx-rs 的 wgpu-render-graph 都是这个方向。最后给读者三条建议。
先读 Frostbite GDC 2017 - FrameGraph: Practical Implementation(公开论文 + 视频),它把 pass / resource / aliasing 的概念讲得最透。然后读 UE RenderGraph 源码(Engine/Source/Runtime/RenderCore/Public/RenderGraph*),看它怎么处理 RDG_PASS_FLAG、RDG_RESOURCE_FLAG、subpass 亲和性。最后读 Unity SRP RenderGraph 文档 + GraphViewer(Unity 官方有完整手册),看"易用优先"的设计取舍。最后再回到自己的引擎/项目,决定是 fork 一个还是从零写。
不要为了 Render Graph 而 Render Graph。如果你的项目 pass 数量 < 30、单一平台、生命周期 < 1 年,传统 command buffer 直接录制可能是更优解——Render Graph 的工程成本在小项目上无法摊薄。多线程录制是结果,不是目标——很多团队把 Render Graph 当成"多线程录制工具",但实际收益来自 aliasing + 拓扑排序 + 自动 barrier 插入,多线程只是其中之一。如果你只需要多线程录制,cmd buffer 录制分 worker + 主线程 merge 是一个 200 行代码就能搞定的轻量方案,不需要完整的 Render Graph。
Render Graph 的真正价值在于它把渲染管线的"设计"与"执行"解耦。设计阶段开发者只关心 pass 的语义("我在做 G-buffer"、"我在做 deferred lighting"),执行阶段由图引擎决定顺序、barrier、aliasing、多线程调度。这种解耦让渲染器在面对新硬件(RT 加速器、mesh shader、tile-based GPU)时,只需添加新的 pass 类型 + 新的 RHI adapter,不必重写已有 pass——这是 Render Graph 在 2026 年成为现代引擎标配的根本原因。
Render Graph 不是一个"画图算法",而是现代渲染器用来解耦"渲染语义"与"执行调度"的声明式骨架——它把资源生命周期、依赖追踪、多线程录制、跨平台内存别名这四个原本各自为战的工程问题统一到一张 DAG 上,是 2026 年 Frostbite / UE5 / Unity SRP / RE Engine 共享的渲染管线基础设施,也是自研引擎从一开始就值得投入的 3000-5000 行 C++ 工程量。
Conversation
0 条