Render Graph:原理与实现
Render Graph:原理与实现
在 Unity 6 URP 中,你已经会在 RecordRenderGraph 里声明纹理、添加 Pass,再用 SetRenderFunc 填入绘制逻辑。接下来真正值得理解的是:框架如何利用这些声明,判断哪些工作要执行、临时纹理能活多久,以及两次 GPU 访问之间需要什么同步?
本文从这套熟悉的接口出发,先手工编译一帧,再写出一个最小实现。前半篇不要求读懂编译器数据结构;后半篇才讨论资源覆盖和底层同步。Unity 接口以 Unity 6.0/Core RP 17.0 为边界,通用实现是独立教学模型,不代表 Unity 的内部源码。
一、你写的 RecordRenderGraph,究竟记录了什么
1.1 记录的是工作说明,执行时才生成命令
看一个熟悉的片段。假设 source、destination 已是本图的有效句柄,PassData 中有 source 字段;这里省略纹理描述符和 Renderer Feature 的接入代码。
// 位于 RecordRenderGraph 中;示意一次拷贝的记录。
using (var builder = renderGraph.AddRasterRenderPass<PassData>(
"Copy", out var passData))
{
passData.source = source;
builder.UseTexture(source, AccessFlags.Read);
builder.SetRenderAttachment(destination, 0, AccessFlags.Write);
builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
{
Blitter.BlitTexture(context.cmd, data.source,
new Vector4(1, 1, 0, 0), 0, false);
});
}执行到 SetRenderFunc 这一行时,Blit 并没有运行。框架保存回调,等图完成分析、这个 Pass 被确定需要执行时,才调用它向命令缓冲录制命令。CPU 录制完成之后,GPU 还要经过提交和实际执行。
Unity 官方将声明输入输出与生成绘制命令分开介绍;这正是从 API 用法进入 Render Graph 原理的入口。Unity 6.0:编写 Render Graph Pass
1.2 把熟悉的 API 翻译成通用概念
| Unity 中的动作 | 给框架留下的信息 | 尚未发生的事 |
|---|---|---|
RecordRenderGraph 添加 Pass | 本帧可能需要的一项工作 | 不代表这项工作一定会执行 |
获取或创建 TextureHandle | 图内资源的身份与描述 | 句柄本身不是像素,也不保证新纹理已分配 |
UseTexture(..., Read) | 这个 Pass 需要读取该资源 | 不会现在就采样 |
SetRenderAttachment(..., Write) | 这个 Pass 将使用某个颜色输出 | 不会现在就绘制 |
SetRenderFunc | 将来如何生成该 Pass 的命令 | 不会现在就调用回调 |
逻辑资源就是图里登记的“这张纹理”;物理资源是执行时实际使用的 GPU 对象和内存。Imported 资源可能早已存在,图内临时资源则可以延后取得,两者都可以用句柄引用。
把句柄放进 PassData 是为了让回调拿到它;向 Builder 声明访问是为了让图分析它。二者职责不同。框架不能可靠地从任意回调或 Shader 中猜出所有真实访问,尤其是偷偷读取的全局纹理。
因此,声明与执行必须一致:只声明读却实际写,或回调使用了没有声明的资源,都会破坏后续推导的前提。句柄及资源生命周期的区别也见 Core RP 17.0:Render Graph fundamentals。
二、先看两 Pass:为什么声明读写之后就能优化
假设你要做一次可分离模糊:先横向模糊,再纵向模糊。相机颜色已经准备好,最终输出会被后续渲染使用。
| Pass | 读取什么 | 写入什么 |
|---|---|---|
| 横向模糊 | CameraColor | BlurTemp |
| 纵向模糊 | BlurTemp | Output |
仅凭这张表,框架就能回答三个问题:
- 纵向模糊必须在横向模糊之后,因为它需要前者的结果。
- BlurTemp 只需覆盖这两个 Pass 的使用区间;之后若有兼容的临时资源,可以考虑复用内存。
- 如果 Output 不再被任何保留的工作使用,而且这两个 Pass 没有图外副作用,整条模糊链都可以不执行。
这里的副作用指图外能够观察到的行为,例如写入外部持有的目标。剔除只对结果无人使用、又没有这种行为的工作成立。
第二个 Pass 也可以写回 CameraColor:CameraColor → BlurTemp → CameraColor。这是两个 Pass 之间的读后覆盖,不是同一个 Pass 一边采样一边写同一张纹理。为先理解普通数据流,后文主例使用独立输出。
优化的依据是整帧声明提供的证据。框架不用理解模糊核的数学含义,也能知道“哪个结果有人要”“谁必须等谁”。但它不能据此承诺自动找到最快排序,或者把两个模糊 Pass 合成一个原生 Render Pass。
如果你只想会用 Unity RenderGraph,读到这里即可:完整声明输入输出,把命令放在执行回调中,并确保需要的结果接入后续消费者或正确的外部输出路径。Unity 实践延伸阅读:URP Render Graph。继续阅读会解释这些规则在框架内部怎样落地。
三、把 Render Graph 拆成三层
Render Graph 用逻辑图说明谁依赖谁,用资源计划说明资源何时存在、能否复用,再用执行计划说明按什么顺序、带什么同步去执行。
| 层次 | 关心的问题 | 两 Pass 例子中的答案 |
|---|---|---|
| 逻辑图 | 谁需要谁的内容,哪些结果必须保留 | 纵向模糊需要 BlurTemp,Output 是所需结果 |
| 资源计划 | 何时开始使用、何时结束、分配在哪里 | BlurTemp 覆盖两个 Pass 的使用区间 |
| 执行计划 | 如何排列工作,访问之间如何同步 | 先横向后纵向,保证临时结果可被安全读取 |
这里的编译是把声明转换成上述计划,不是编译 Shader。图形化显示只是这份计划的一种观察方式。
下面的模型只处理一个同时支持绘制和计算的图形队列;Compute Dispatch 也提交在这条队列上。纹理按整个资源跟踪,不拆分 mip、layer 或 aspect。这样可以先把一帧看清,再讨论多队列等扩展。
四、手工编译一帧:每一步产出什么
4.1 输入:一条自然的渲染主链
主链使用 Depth → GBuffer → Lighting → Bloom → Present。GBuffer 存储表面信息,Lighting 生成 HDR 颜色,Bloom 提取并模糊亮部,Present 合成 HDR 与 Bloom 后写入最终目标。
为便于阅读,GBuffer 汇总表示为一张纹理,Bloom 汇总为一个 Pass;真实管线可以展开成多张附件和多级模糊。这个例子说明调度关系,不规定具体渲染算法。
箭头表示内容的生产与消费,虚线只是突出 Debug 分支。Debug 读取 GBuffer 并生成可视化纹理,但当前没有显示、导出或保存它,因此这一分支没有外部可观察结果。
4.2 第一步:从 Builder 得到记录表
此时没有必要创建所有 GPU 纹理,先保存“做什么”和“用什么”。
| 声明序号 | Pass | 读 | 写 | 必须保留的理由 |
|---|---|---|---|---|
| 0 | Depth | 场景数据 | 深度 D | 暂无,取决于消费者 |
| 1 | GBuffer | D,用于只读深度测试 | 表面信息 G | 暂无 |
| 2 | Lighting | D、G,Compute 采样 | HDR 颜色 H,Storage 写入 | 暂无 |
| 3 | Bloom | H,Compute 采样 | Bloom 结果 B,Storage 写入 | 暂无 |
| 4 | Present | H、B,Fragment 采样 | 外部目标 BB,颜色附件写入 | 输出到图外 |
| 5 | Debug | G,Fragment 采样 | 调试纹理 X | 无 |
场景几何和帧常量在这里假设已经就绪且只读;若它们也由图内 Pass 更新,就必须同样登记。D、G、H、B、X 都是本图临时资源。
BB 是 Imported(导入)资源:对象由外部提供,图借来访问。完成后将 BB 的最终内容和呈现状态 Export(导出) 给宿主;导出表达的是结果还要交给图外使用,和“资源从哪里来”是两回事。
本文把 Present Pass 定义为最终合成工作,真正的平台呈现由宿主完成。名字叫 Present 并不会自动保留它;保留依据是输出声明和图外副作用。
4.3 第二步:把读取关系变成依赖边
从记录表中找每个输入的生产者,就能得到下表。依赖边表示某项工作必须先于另一项工作。
| 读取发生在哪里 | 内容来自哪里 | 得到的边 |
|---|---|---|
| GBuffer 读 D | Depth | Depth → GBuffer |
| Lighting 读 D、G | Depth、GBuffer | Depth → Lighting;GBuffer → Lighting |
| Bloom 读 H | Lighting | Lighting → Bloom |
| Present 读 H、B | Lighting、Bloom | Lighting → Present;Bloom → Present |
| Debug 读 G | GBuffer | GBuffer → Debug |
现在已经能回答“Lighting 为什么不能先执行”,还不需要讨论显存槽或 Vulkan。当前主例每个临时资源只写一次;多次覆盖的补充约束放到第五节。
4.4 第三步:从最终结果倒着找,剔除 Debug
剔除(Cull) 就是去掉对所需结果没有贡献的工作。由 BB 的输出开始反向找:Present 需要 Lighting 和 Bloom,Lighting 需要 Depth 和 GBuffer,Bloom 也需要 Lighting。
最终保留 Depth、GBuffer、Lighting、Bloom、Present。Debug 没有进入这条反向追溯路径,因此其回调不执行,X 不分配,也不用为 X 生成屏障。
注意方向:GBuffer 被保留,不意味着所有读取它的 Pass 都必须保留。要保留的是生成所需结果的前驱,不是前驱的所有后继。
若后来把 X 接到屏幕合成或导出,Debug 才成为必要工作。这比看到 Pass 消失就一律禁用剔除更能解释问题所在。
4.5 第四步:形成最终执行序
拓扑排序是把依赖关系排成一个序列,保证每条边的起点都在终点之前。本例结果为:
执行下标 0 1 2 3 4
Pass Depth → GBuffer → Lighting → Bloom → Present若多个 Pass 都已满足前置条件,本文选择声明序号最小的一个。这叫稳定地遵循声明序,是便于预测和排查的保守策略,不是寻找性能最优解。
至此只确定了逻辑先后。CPU 按这个顺序录制命令,不等于 GPU 的写入已经对后续读取可见;这要在第七步落实。
4.6 第五步:把使用记录变成生命周期时间线
生命周期在这里指资源从第一次到最后一次被存活 Pass 使用的区间。先剔除、再排序、最后计算,才能避免把无用 Debug 的访问也算进去。
| 资源 | 0 Depth | 1 GBuffer | 2 Lighting | 3 Bloom | 4 Present | 使用区间 |
|---|---|---|---|---|---|---|
| D | 写 | 读 | 读 | — | — | [0, 2] |
| G | — | 写 | 读 | — | — | [1, 2] |
| H | — | — | 写 | 读 | 读 | [2, 4] |
| B | — | — | — | 写 | 读 | [3, 4] |
| BB | 外部持有 | 外部持有 | 外部持有 | 外部持有 | 写并导出 | 图外所有权 |
| X | — | — | — | — | — | 无,不分配 |
第一次写入也算使用。区间包括两个端点:G 和 H 都出现在 Lighting 中,因此不能同时使用同一块内存。
4.7 第六步:从区间安排内存槽
内存别名(Aliasing) 指不同逻辑资源在不重叠的使用区间共享同一块物理内存。它不要求两张纹理的内容相同。
G 在下标 2 后不再使用,B 从下标 3 开始使用。因此,若它们满足后端兼容要求,可以得到如下计划:
| 内存位置 | 使用者 | 分配理由 |
|---|---|---|
| 瞬态槽 0 | D | 深度描述符单独处理 |
| 瞬态槽 1 | G,然后是 B | 区间不重叠且假设兼容 |
| 瞬态槽 2 | H | 与 G、B 都有区间重叠 |
| 外部对象 | BB | 不进入瞬态池 |
这是一种有条件的示意分配。为手算别名,本例可令 G 与 B 使用相同的尺寸、格式和 usage 组合;真实 Bloom 常有不同尺寸,未必能与 G 复用。即使描述符一致,也须检查设备返回的内存大小、对齐、类型及别名支持。
判定区间不重叠使用 old.lastUse < next.firstUse。等于不行,因为那表示两个资源在同一个 Pass 中仍然同时使用。
别名不传递数据:槽交给 B 后,不能把原有 GBuffer 像素当作 B 的有效内容;Bloom 必须重新定义其输出。需要 G 的数据就声明读取 G,不能依赖显存残留。
如果将 G 额外导出,本文会将它固定为独立分配,取消 G/B 的复用。Imported、Exported 和跨帧历史资源都不进入普通瞬态别名池。底层约束见 Vulkan 资源创建与内存别名规则。
4.8 第七步:把先后关系落实为 Barrier 表
Barrier(屏障) 是让 GPU 访问遵守计划的同步机制。先只看 GBuffer 写完 G、Lighting 把 G 当纹理读取这一处:
| 要解决的事 | 用这个例子解释 |
|---|---|
| 执行顺序 | Lighting 的相关读取必须等待 GBuffer 的相关写入 |
| 内存可见性 | 颜色附件写出的值必须对 Compute 的纹理采样可见 |
| 状态/布局 | G 从颜色附件用途进入适合采样的状态/布局 |
这三件事常可由同一条原生屏障一起表达。只把 GBuffer 排在前面,没有完成后两件事;只看布局是否变化,也不足以判断是否需要同步。
主例最终得到的关键边界如下。表中是约束摘要,不声称一行恰好对应一次 API 调用。
| 放置位置 | 输入信息 | 需要输出的同步计划 |
|---|---|---|
| 各临时资源首次写入前 | 内容尚未定义 | 准备写入布局,由清除或完整写入定义内容 |
| GBuffer 前 | D 刚完成深度写入 | 深度写 → 只读深度测试 |
| Lighting 前 | D、G 将被 Compute 采样 | 深度写、颜色写对 Compute 读取可见,准备采样布局 |
| Bloom 前 | H 刚被 Compute 写入 | Storage 写 → Compute 采样 |
| Bloom 前 | G/B 计划共用内存槽 | 等待 G 的旧访问完成,再准备 B 的首次写入 |
| Present 前 | H、B 将被 Fragment 采样 | Lighting、Bloom 的写入对 Fragment 读取可见 |
| Present 前与图末尾 | BB 有外部入口、出口状态 | 准备颜色附件写入,再转换到呈现出口 |
H 已经被 Bloom 读过,仍不能仅凭“上一次也是读”跳过对 Present 的分析:必须确保 Lighting 的写入也对 Fragment 阶段可见。D 从深度测试转为 Compute 采样时同理。
BB 的获取等待和呈现信号由宿主处理;导入资源本身不会完成同步。图内屏障也不能替代窗口系统的 acquire/present 协议。Khronos Vulkan 同步示例
4.9 一帧编译的完整产物
到这里,你已经走通了基本流程。接下来的版本号、冲突类型和代码,只是在补全这套流程的适用范围,不会换一套理解方式。
五、为什么只记录生产者还不够
5.1 单独看一次覆盖,不给主链添加工作纹理
前面的资源都只写一次,所以“找到输入生产者”就足以解释内容依赖。现在增加一个独立小例子:同一张临时纹理 T 被重新使用。
| 声明序 | 工作 | 内容含义 |
|---|---|---|
| A | 完整写 T | 产生第一份内容 |
| B | 读 T,并写出一个被导出的结果 | 必须读到 A 的内容 |
| C | 完整覆盖 T,覆盖结果也被导出 | 产生第二份内容,不需要第一份像素 |
A、B、C 都有保留理由。只看生产者能找到 A → B,却解释不了为什么 C 必须等 B 读完:B 与 C 使用的是同一个物理对象,提前覆盖就会破坏 B 的输入。
5.2 版本记录内容身份,不自动复制纹理
将 A 写出的内容记为 T:v1,C 写出的内容记为 T:v2。资源版本回答的是“这次读取要哪次写入的结果”,不是“分配了第几张纹理”。
本文让同一逻辑资源的版本都映射到同一物理对象。C 覆盖后,v1 不再可读;若希望新旧内容同时存在,就要创建另一张纹理,并明确复制或重新计算。
教学接口据此限制:记录时只能读取当前最新且已定义的版本;Write 完整定义新内容;ReadWrite 读取旧内容再修改,作为一个复合访问保存。不能把未初始化纹理拿来读改写。
附件 Load、混合或局部写入如果需要保留旧像素,就不是本文的纯 Write。本文最小接口只展开 Storage 的 ReadWrite,不支持同 Pass 采样并写回同一颜色附件。
5.3 三种访问冲突各补哪条边
| 名称 | 通俗含义 | 小例子的顺序约束 |
|---|---|---|
| RAW,Read After Write | 先产生内容,再读取 | A → B |
| WAR,Write After Read | 先读完旧内容,再覆盖 | B → C |
| WAW,Write After Write | 先前写入与后续覆盖不能危险交叠 | A → C |
RAW 同时表达内容依赖和访问顺序;在完整覆盖语义下,WAR/WAW 约束顺序,却不表示 C 需要 A 的数值。
所以编译器要区分两类边:剔除沿内容边反向查找;排序使用包含访问冲突的顺序边。否则,一个无人读取的旧写入可能仅因 WAW 被误保留。
剔除后还应重新扫描存活访问。例如“读 T → 无用覆盖 → 必需覆盖”中,中间覆盖被删除后,必须直接连接“读 T → 必需覆盖”。只删除旧边而不重建,会丢失约束。
以上都是逻辑约束。Vulkan 中纯 WAR 通常只需执行依赖,RAW/WAW 需要相应内存依赖;涉及布局转换时,还要满足转换本身的访问要求。Vulkan 同步规范
六、把访问意图映射到 Vulkan
现在再看底层枚举,就能把它们对应到已经理解的三个问题:哪个阶段等待哪个阶段、什么访问需要可见、资源处于什么布局。
下表使用 Vulkan 1.3/Synchronization2 的概念,省略枚举前缀和后缀,仅作映射摘要,不是可直接复制的初始化代码。其他后端应生成自己的状态与屏障。
| 访问意图 | 典型阶段 | 访问掩码 | 典型布局 |
|---|---|---|---|
| 颜色附件写 | COLOR_ATTACHMENT_OUTPUT | COLOR_ATTACHMENT_WRITE | COLOR_ATTACHMENT_OPTIMAL |
| 深度附件写 | EARLY_FRAGMENT_TESTS、LATE_FRAGMENT_TESTS | DEPTH_STENCIL_ATTACHMENT_READ / WRITE | DEPTH_STENCIL_ATTACHMENT_OPTIMAL |
| 只读深度测试 | EARLY_FRAGMENT_TESTS、LATE_FRAGMENT_TESTS | DEPTH_STENCIL_ATTACHMENT_READ | DEPTH_STENCIL_READ_ONLY_OPTIMAL |
| Shader 采样 | 实际使用的 Compute 或 Fragment 等阶段 | SHADER_SAMPLED_READ | 颜色用 SHADER_READ_ONLY_OPTIMAL,深度可用只读深度布局 |
| Storage 读/写 | 实际 Shader 阶段 | SHADER_STORAGE_READ / WRITE | GENERAL |
| 呈现出口 | 外部呈现边界,非普通 Shader 阶段 | 按提交与呈现协议处理 | PRESENT_SRC_KHR |
因此,GBuffer → Lighting 对应的是颜色附件输出阶段的写入,变为 Compute 阶段的采样读取;不能把前者误写成 Fragment Shader 写,也不能把所有采样都固定为 Fragment。
深度写的后端掩码可包含读,因为深度测试会访问附件;这不等于 Pass 需要上一个图版本的内容。内容是否保留与具体硬件访问类型,需要分别描述。
连续 Storage 写与 Storage 读即使一直处于 GENERAL,也仍需要写后读同步。反过来,两次只读若需要不同布局,布局转换仍须等待旧布局下的访问结束。具体字段与适用条件见 Khronos 同步示例和 Vulkan 同步规范。
七、最小实现:先看骨架,再补每一步
7.1 用四十行左右把整个过程接起来
对主例,编译结果应该是五个 Pass、没有 X、条件满足时 G/B 同槽,以及前面列出的同步边界。先用这些结果约束实现,再考虑容器怎么选。
以下是 C++ 风格伪代码;辅助函数代表明确的职责,不是完整可编译库。后端负责 API 对象、内存要求和原生屏障。为了聚焦流程,省略错误类型、分配器和描述符缓存。
CompiledGraph Compile(const Graph& graph) {
Validate(graph); // 拒绝未定义读取、无效声明
auto data = BuildContentEdges(graph); // 找每个输入版本的生产者
auto roots = ObservableRoots(graph); // 导出结果、外部副作用
auto live = MarkProducers(roots, data); // 只沿内容边反向保留
auto accesses = InDeclarationOrder(live);
auto edges = BuildOrderEdges(accesses); // 剔除后重建访问冲突
auto order = StableTopologicalSort(live, edges);
RequireAllEdgesSatisfied(order, edges);
auto life = ComputeLifetimes(order);
PinImportsAndExports(life, graph);
auto slots = AllocateCompatibleSlots(life);
auto barriers = PlanBarriers(order, slots, graph.boundaries);
return {order, life, slots, barriers, graph.exports, graph.boundaries};
}
Submission Execute(const CompiledGraph& plan, Backend& backend) {
auto resources = backend.BindImportsAndReserveSlots(plan);
auto cmd = backend.BeginCommands();
for (auto i : Indices(plan.order)) {
resources.MaterializeFirstUses(i);
backend.EmitBarriers(cmd, plan.barriers.Before(i), resources);
auto pass = plan.order[i];
Context context{cmd, resources, pass};
pass.execute(context);
resources.EndLogicalUses(i); // 不立即销毁 GPU 对象
}
resources.MaterializeBoundaryOnlyExports(plan.exports);
backend.EmitBarriers(cmd, plan.barriers.AtEnd(), resources);
backend.EndCommands(cmd);
auto submission = backend.Submit(cmd, plan.ExternalWaits());
resources.PublishExports(plan.exports, submission);
resources.RetireTransientsAfterCompletion(submission);
return submission;
}Compile 生成计划,Execute 落实计划。交换链 acquire、呈现等待和完成信号接入由宿主与后端协作完成,不藏在某个 Pass 回调里。
7.2 记录:只保存后续分析真正需要的信息
记录 GBuffer 后,应能回答“读 D、写 G、如何生成命令”;记录 Lighting 后,应能回答“读 D/G、写 H”。最小数据可归纳如下:
| 记录项 | 必需信息 |
|---|---|
| 资源 | ID、描述符、外部对象及入口状态、是否导出 |
| 内容版本 | 所属资源、版本号、生产者、内容是否有效 |
| 一次访问 | 读入版本、写出版本、读写方式、用途、Shader 阶段 |
| Pass | 声明序号、访问列表、副作用标记、执行回调 |
auto g = graph.CreateTexture(gbufferDesc); // 未定义内容,不分配像素
graph.AddPass("GBuffer", [&](Builder& b) {
auto d = b.Read(depth, DepthRead);
g = b.Write(g, ColorAttachment);
return [d, output = g](Context& ctx) {
DrawGBufferWithClear(ctx.cmd, ctx.Resolve(d), ctx.Resolve(output));
};
});这里 setup 立即调用,返回的执行回调稍后调用;按值捕获句柄,避免变量后来更新成别的版本。Filament 的 setup/execute 区分可作为真实接口的对照,本文回调形式仍是教学设计。Filament Framegraph 设计说明
验证至少拒绝:跨图或过期句柄、未定义读取、覆盖后再声明旧版本读取、不匹配的 usage、遗漏的 Shader 阶段。同 Pass 对同一资源只保存一个规范化访问项;Storage ReadWrite 同时记录输入和输出,不能拆成互相冲突的两条公开调用。
Imported 初始内容是否有效必须明确提供;同一外部对象不能重复导入成互不相关的资源 ID。本文要求外部写入显式标记副作用,Export 引用已定义的最终版本,导出后不再访问该资源。
7.3 依赖:按版本找内容,按资源找覆盖冲突
主例会得到第四节的五组读取关系;覆盖小例则应额外得到 B → C 和 A → C。两者分别由下列扫描负责:
// 内容边:用于剔除;有效的 Imported 初始内容可以没有图内生产者。
for (auto pass : graph.passes)
for (auto read : pass.reads)
if (auto writer = ProducerOf(read.version))
dataEdges.AddUnique(writer, pass);
// 顺序边:只扫描剔除后保留的 Pass。
for (auto pass : InDeclarationOrder(live)) {
for (auto a : pass.accesses) {
auto id = a.resourceId;
if (a.reads && ProducerOf(a.input))
orderEdges.AddUnique(ProducerOf(a.input), pass); // RAW
if (a.writes) {
if (lastWriter[id])
orderEdges.AddUnique(lastWriter[id], pass); // WAW
for (auto reader : readers[id])
orderEdges.AddUnique(reader, pass); // WAR
readers[id].clear();
lastWriter[id] = pass;
} else if (a.reads) {
readers[id].insert(pass);
}
}
}这里的读者集合保存“上次写入之后的只读 Pass”。ReadWrite 作为一次写入更新 lastWriter,因此不会把自己加入集合再产生自依赖。边按端点去重,诊断信息仍可保留多个原因。
7.4 剔除和排序:只保留需要的工作,再验证先后
本例必须留下五个 Pass;Debug 是 GBuffer 的消费者,却不是输出的生产链之一。算法从根反向走内容边:
work = ExportWritersAndSideEffectPasses();
while (!work.empty()) {
auto pass = work.pop();
if (!live.insert(pass).wasInserted) continue;
for (auto producer : dataEdges.ProducersOf(pass))
work.push(producer);
}然后用 Kahn 拓扑排序:入度表示还有几个前置 Pass 没处理;就绪队列按声明序号选最小值。这里给出算法要点,省略重复的容器定义。
ready = PassesWithZeroIndegree(live); // 按声明序号组织的小根堆
while (!ready.empty()) {
auto pass = ready.pop();
order.push_back(pass);
for (auto next : orderEdges.UniqueSuccessors(pass))
if (--indegree[next] == 0) ready.push(next);
}
Require(order.size() == live.size(), "依赖存在环,报告未排节点及边");本文只允许读取已声明的内容,合法资源边本来就向声明序号更大的 Pass 延伸。保留排序与环检查便于验证,也为未来的显式依赖留出位置;它不意味着当前例子需要复杂重排。
7.5 生命周期与别名:在最终执行序上扫描
主例应算出 G 为 [1, 2]、H 为 [2, 4]、B 为 [3, 4]。对每次访问取执行下标的最小值与最大值即可;同一资源的所有内容版本合并到同一个区间。
for (auto i : Indices(order))
for (auto a : order[i].accesses)
life[a.resourceId].Include(i); // first=min,last=max
PinImportsAndExports(life); // 外部对象及导出结果不进别名池
for (auto r : SortByFirstUseThenId(life)) {
if (r.imported) { BindExternal(r); continue; }
if (r.pinned || !BackendAllowsAlias(r)) { BindDedicated(r); continue; }
auto slot = FindCompatibleSlot(r, [&](Slot s) {
return s.lastUse < life[r].firstUse;
});
if (!slot) slot = NewSlot(r.desc);
if (slot.previousOwner)
AddAliasHandoff(slot.previousOwner, r, life[r].firstUse);
Bind(r, slot);
slot.UpdateOwnerAndLastUse(r, life[r].lastUse);
}“兼容”在本模型中要求描述符一致,并通过后端内存约束检查。没有存活使用也没有导出引用的资源不进入 life;只在边界导出的 Imported 资源也要登记并绑定。
内存槽计划仅说明哪里可以共享,还没有建立 GPU 同步。G/B 的交接必须同时传给下一步的屏障规划;后端不支持别名时退回独立分配。
7.6 Barrier:保守地覆盖冲突和布局变化
主例最容易漏掉的是 H 的第二位读者 Present。最小实现可保存同一资源的全部已发生访问:每次读取考虑此前写入,每次写入考虑此前读写;需要布局转换时,也等待旧布局的全部使用者。
state = InitialStatesFromImportsOrUndefined();
history = InitialAccessHistoryFromImports();
for (auto boundary : PassBoundariesAndGraphEnd(order)) {
for (auto handoff : slots.HandoffsAt(boundary))
PlanAliasHandoff(handoff, history, state, plan);
for (auto event : AccessesOrExportsAt(boundary)) {
auto id = event.resourceId;
auto next = MapAccessToBackendState(event);
bool transition = state[id].layout != next.layout;
auto prior = Filter(history[id], [&](Event old) {
return old.writes || event.writes || transition;
});
plan.AddDependencyAndTransition(boundary, prior, state[id], next);
state[id] = next;
history[id].push_back(event);
}
}AddDependencyAndTransition 必须根据来源访问生成执行/内存依赖,并覆盖布局转换;不能只存一对新旧布局。导出的最终状态是边界事件,Imported 的入口状态和等待条件也是输入。这个历史扫描可能重复同步,目的是容易核对,而非屏障数量最少。
PlanAliasHandoff 要保证旧对象的访问先结束,再允许新对象初始化,并生成后端所需的内存依赖。Vulkan 的共享内存危险不能仅靠新图像上的布局转换解决;还须落实别名对象创建、绑定及内存依赖规则。
新别名对象不继承旧内容;若选择复用同一个 API 对象,则继续跟踪它的真实状态。丢弃内容不等于旧访问已经结束,不能用 Undefined 消除同步责任。Vulkan 资源规范
7.7 Execute:把“逻辑结束”和“GPU 完成”分开
执行到 Bloom 前,应准备 B 的对象与 G/B 交接屏障,再调用 Bloom 回调;回调通过 Resolve 把句柄转为实际资源。Resolve 检查句柄属于当前 Pass 的声明和生命周期,不能要求它等于整帧记录结束时的最新版本。
| 时间点 | 意味着什么 | 可以做什么 |
|---|---|---|
| CPU 录制完某个 Pass | 命令已写入缓冲 | 继续录制,不能据此销毁 GPU 对象 |
| 到达图内 lastUse | 后续 Pass 不再使用该逻辑资源 | 规划带同步的内存交接 |
| GPU 提交完成 | 本次提交不再访问相关对象 | 按所有权规则回收瞬态对象 |
因此,EndLogicalUses 只结束逻辑访问;真正回收要等待提交完成。Imported 仍由外部所有者持有,Exported 的结果和完成凭据交给外部。多帧在途时,也不能因为 CPU 开始下一帧就覆盖上一帧尚在使用的内存。
资源首次/最后使用与回调执行的职责划分,可对照 Filament 官方 FrameGraph.cpp。若回调内部需要先写后读、并切换同一资源的用途,应拆成多个图 Pass;图入口处的一次准备无法表达任意内部同步。
八、如何验证实现确实符合这套推导
先打印编译产物,与手工结果核对,再用图形 API 验证层和目标设备检查 GPU 行为。下表是应满足的断言,不是本文声称已经运行过的测试或性能数据。
| 改动或检查 | 预期结果 |
|---|---|
| 编译主例 | 保留五个 Pass,Debug、X 消失 |
| 检查每条存活顺序边 | 起点执行下标严格小于终点 |
| 检查 G/H 和 H/B | 生命周期重叠,不得同槽 |
| 检查兼容的 G/B | 允许同槽,Bloom 前存在别名交接计划 |
| 额外导出 G | G/B 复用取消,G 内容保留到图外 |
| 刚 CreateTexture 就读取 | 报未定义读取,不能假定初值为黑 |
| 在覆盖后声明读取旧版本 | 报错,版本号不会保存旧像素 |
| 单独编译 A/B/C 覆盖例 | 同时存在 RAW、WAR、WAW 顺序约束 |
| 删除中间无用覆盖 | 重建读者到下一次存活覆盖的约束 |
| 检查 GBuffer → Lighting | 包含颜色写到 Compute 采样的可见性与布局处理 |
| 检查 H → Present | 不因 Bloom 已读过 H 就遗漏 Fragment 可见性 |
| 检查资源回收 | CPU 录制结束不会触发仍在使用对象的销毁 |
如果输出正确,再测量 CPU 图编译开销、执行的 Pass 数、峰值资源分配、屏障与 GPU 帧时间。别名主要减少内存占用需求,并不会自动减少 Shader 运算或纹理访问。
管线短且资源固定时,图系统的维护和编译成本未必划算;当效果组合多、临时资源密集、同步责任分散时,集中分析的价值更明显。收益应来自目标设备测量,不能从“用了图”直接推导。
九、进阶扩展:哪些前提需要重新考虑
基本模型走通后,再逐项扩大范围。不要把一条队列上的执行下标直接搬到并行调度中。
| 扩展 | 需要补上的能力 |
|---|---|
| Async Compute | 分配队列、分析并发,明确哪些工作真的能够重叠 |
| 跨队列同步 | signal/wait、内存可见性、必要的队列所有权转移;重算生命周期与别名安全条件 |
| 子资源跟踪 | 按 mip、layer、aspect 或缓冲范围判断访问重叠 |
| 并行命令录制 | 线程安全、描述符分配、命令列表边界与有序提交 |
| 原生 Render Pass 合并 | 附件、load/store、输入附件、采样反馈及后端限制 |
| 历史资源与多帧在途 | 外部所有权、完成条件、乒乓资源,逐帧重新导入 |
| 动态分辨率与图缓存 | 描述符、外部状态或图结构变化时使相关计划失效 |
原生 Pass 合并不是自动合并 Draw Call,也不是有依赖就一定能合并。本文既不实现合并,也不承诺最优排序;这些优化都需要额外条件和成本判断。
Unreal RDG 官方文档介绍了异步计算、资源转换、资源提取等机制,可用于理解生产系统如何扩大能力范围。该在线文档会更新,应按项目所用引擎版本核对。Unreal Render Dependency Graph
十、继续阅读一手实现
| 想继续追的问题 | 一手资料 |
|---|---|
| Unity 如何区分声明和执行 | Unity 6.0 编写 Pass、Core RP 17.0 基础 |
| setup、句柄与执行回调怎样组织 | Filament 设计说明、FrameGraph.h |
| 成熟引擎如何处理图与外部资源 | Unreal RDG |
| FrameGraph 架构的设计背景 | Frostbite:FrameGraph,GDC 2017 |
| 同步与别名在 API 层有什么硬约束 | Vulkan 同步规范、资源创建规范 |
Filament 链接指向 main 分支,Vulkan 链接指向在线规范;阅读具体实现时应固定项目采用的版本。先对照“输入声明 → 编译产物 → 执行责任”这条路径,再深入各引擎不同的数据结构和优化策略。
