技术博客

RDG:Pass 声明与资源生命周期

第 31 / 100 篇 · 2026-01-03 · ue5 · 渲染RDG源码分析

手写 DX12 渲染代码最烦的不是画三角形,是管资源:什么时候插 barrier、哪张 RT 用完了可以给别人用、哪段代码删了之后状态跟踪要不要跟着删。RDG(Render Dependency Graph)把这三件事从"人肉维护"变成了"声明之后自动推导"。源码:RenderGraph/Private/RenderGraphBuilder.cpp(UE5.3)。

编程模型:只描述,不执行

RDG pass 的写法分两段:

// 1. 声明阶段(GameThread/RenderThread 都可)
FRDGTextureDesc Desc = FRDGTextureDesc::Create2D(...);
FRDGTexture* Tex = GraphBuilder.CreateTexture(Desc, TEXT("MyTex"));
FRDGTextureUAV* UAV = GraphBuilder.CreateUAV(Tex);

GraphBuilder.AddPass(
    RDG_EVENT_NAME("MyCompute"),
    ERDGPassFlags::Compute,
    [&UAV](FRHICommandList& RHICmdList)   // 2. 执行体,Execute 时才跑
    {
        // 真正的 RHI 命令
    });

执行体是 lambda,声明阶段不运行。Pass 之间不直接交换指针,而是通过 FRDGBuilder 的句柄(FRDGTexture*、FRDGBuffer*)建立读写关系——参数结构体里塞进去,依赖就登记了。这给出两个硬保证:资源没有"提前释放"的可能(句柄由 builder 持有),pass 执行顺序由依赖图决定而不是代码顺序。

依赖图与拓扑排序

每个 pass 声明它读哪些资源、写哪些资源(通过 Parameters 结构),builder 据此建图:顶点 = pass,边 = 资源流(写者 → 读者)。编译阶段(FRDGBuilder::Compile,RenderGraphBuilder.cpp)做拓扑排序确定执行序列。

数学上看这就是标准的 DAG 处理问题,规模不大但每帧都要跑:设 pass 数 $n$、依赖边数 $m$,Kahn 算法排序 $O(n + m)$。UE 的实际实现比教科书版本多一点工程细节——增量编译(只有依赖变化的 pass 重排)和跨帧复用的 pass 缓存,但骨架就是拓扑排序。重要的是方向:代码书写顺序是"作者脑中的顺序",执行顺序是"数据流的顺序",二者解耦意味着你重排 pass 代码位置不影响正确性。

自动屏障:从读写关系推导同步

传统手写流程里,同一张纹理从 RT 变成纹理采样要插 D3D12_RESOURCE_BARRIER;忘了插,DX12 调试层都不一定立刻报错,表现为偶发花屏。RDG 的做法是把同步声明化:

每个 pass 通过 ERDGResourceAccess(类似 FRDGTextureAccess::Read / Write)声明访问方式,编译阶段为每条资源边计算访问状态转换,生成 RHI 级的 transition 命令插到 pass 边界。推导规则本身简单——记录资源当前状态,遇到新 pass 的声明算目标状态,不同则插转换——但机械且量大,正是人类容易漏而机器不会漏的那类工作。

代价是保守性:RDG 按声明做同步,声明写宽了(声明 Write 实际只读)就白等一个管线刷新。写自定义 pass 时参数声明要贴着实际访问来,这是 RDG 下性能优化的基本功。

Pass 剔除:反向可达性

场景里最贵的经常是那些"这帧根本没生效"的 pass——阴影被关掉的 light、被距离剔除的反射探针。RDG 在建图后先做一步裁剪(Cull):从"最终产出"(通常是 Present 依赖的输出纹理/背缓冲)做反向可达遍历,不可达的 pass 整个删掉,不进入排序也不分配资源。

可达性裁剪是线性复杂度的:$O(n + m)$ 一次 DFS/BFS。收益直接可见:全关 Lumen 的项目里,Lumen 的几十个 pass 每帧都被这一步整体裁掉,一行渲染代码不跑。这比 #if 条件编译好在决策是每帧动态的——同一个 build,相机转进洞穴触发 Lumen 需求时 pass 又活过来,不需要重启。

显存复用:生命周期不相交即可共享

RDG 的瞬态资源(Transient)不进 D3D12 的 heap 管理细节,由 builder 在编译期做生存期分析:资源 $i$ 的活跃区间 = [第一个写的 pass, 最后一个读的 pass],两个资源区间不相交就能共用同一块显存。区间图染色问题:按区间端点排序后贪心分配(首次适应),活跃区间数即并发资源数 $k$,内存池大小从"资源总和"降到"最大并发占用":

$$ \text{PoolSize} = \max_t \Big|\{i : \text{begin}_i \le t \le \text{end}_i\}\Big| $$

复杂帧(SSR + SSAO + 半透明 + 后处理全开会)资源声明了几十张中间纹理,实际并发峰值往往只有几张的量——这就是 RDG 省显存的数量级来源。注意名字要起好:调试显存分析时 pool 里显示的是 Transient 0x...,你的 TEXT("MyTex") 名会出现在生命期统计工具里,乱起名排查时自食其果。

使用纪律

  • Pass 声明的读写要真实,宽声明 = 白等屏障;
  • 资源句柄不跨帧存(FRDG* 只在 builder 生命周期内有效),要跨帧的是普通 RHI 资源,显式管理;
  • GraphBuilder.Execute() 之后的代码不要再碰 RDG 句柄——执行完资源池可能已被回收复用;
  • 调试工具:r.RDG.Debug 1 打印 pass 图与裁剪结果,r.RDG.TransientAllocation 1 看显存复用统计。

RDG 一帧的编译流程:从 pass 声明到可执行序列 声明 Pass 与资源 句柄 + 读写参数 建图 Pass 顶点,资源边 裁剪 从输出反向可达 排序 + 屏障 + 显存分配 拓扑序 / transition / 池化 裁剪示例:Camera 视角下反射探针的 pass 整支被剪掉 GBuffer 光照 后处理 探针捕获 ×3 反射采样 探针被距离剔除 → 反射分支不可达 → 整支剪除,零开销 显存复用:区间不相交 纹理 A ───────┐ 纹理 B ──────── 纹理 C ──┐ ↓ 贪心染色 A、C 共享池槽位 B 单独占一槽 峰值并发 = 池大小