MAKE · WRITE · SHARE

Unity-RenderGraph机制分析

RenderGraph(渲染图)将一帧中的 Pass 与资源读写关系显式记录下来,使渲染管线可以在执行前分析整帧任务。

传统 SRP 写法里,ScriptableRenderPass 往往在执行阶段直接申请临时 RT、设置 RenderTarget、读写纹理并提交 CommandBuffer。这样写很直观,但管线看到的是一串已经排好的命令,很难全局判断哪些资源可以复用、哪些 Pass 没有贡献、哪些 Render Target 切换可以合并。RenderGraph 把这件事拆成两步:先声明 Pass 与资源依赖,再由系统编译执行计划。

Unity URP RenderGraph 执行思路

1. 从命令列表到资源依赖图

RenderGraph 可以理解成一张有向图:

  1. 节点是一个个渲染 Pass。
  2. 边是资源读写依赖,例如某个 Pass 写入 Color Texture,后面的 Bloom Pass 读取它。
  3. 资源是图里的对象,例如 Texture、Buffer、RendererList,而不是手动管理生命周期的裸 RTHandle。

这种组织方式的关键变化是:Pass 不再只告诉 GPU “现在做什么”,而是先告诉管线“我要读什么、写什么、产出什么”。管线拿到这些信息后,可以在执行前分析整帧。

例如一个后处理链路可能写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Opaque Pass
写入 CameraColor

Bloom Prefilter
读取 CameraColor
写入 BloomMip0

Bloom Blur
读取 BloomMip0
写入 BloomMip1

Final Composite
读取 CameraColor、BloomMip1
写入 BackBuffer

如果使用传统命令式写法,临时纹理的申请、释放、读写关系通常散落在各个 Pass 里。RenderGraph 会把这些依赖集中记录下来,然后计算资源生命周期:BloomMip0 的逻辑生命周期只需要活到 Bloom Blur 结束;后续是否复用资源对象或底层内存,取决于具体后端与描述兼容性。

这也是 RenderGraph 相比普通 Pass 队列更有价值的地方。它不是简单地把 Execute() 改名,而是让管线拥有了优化所需的信息。

原学习笔记对 Compile 阶段的三步概括

原笔记配图:原学习笔记对 Compile 阶段的三步概括。实际 RenderGraph 还包含依赖、生命周期、同步与后端实现等工作,不能把此图当作完整实现。

2. URP 中 RenderGraph 的位置

在较新的 URP 里,自定义 Pass 的入口从过去常见的 Execute() 逐渐转向 RecordRenderGraph()。直观理解:

1
2
3
4
5
6
7
8
9
10
11
UniversalRenderPipeline
驱动 Camera 渲染

UniversalRenderer
组织一帧内置 Pass 和自定义 Feature

ScriptableRenderPass.RecordRenderGraph
把当前 Pass 的资源读写声明到 RenderGraph

RenderGraph
编译整帧图并执行

ScriptableRendererFeature 的职责没有消失。它仍然负责创建并注入自定义 ScriptableRenderPass。变化主要发生在 Pass 内部:以前在 Execute() 里直接拿 CommandBuffer 干活,现在要在 RecordRenderGraph() 里声明图节点、资源和真正执行函数。

一个很简化的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class CustomPass : ScriptableRenderPass
{
class PassData
{
public TextureHandle source;
public TextureHandle destination;
public Material material;
}

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
using var builder = renderGraph.AddRasterRenderPass<PassData>(
"Custom Blit Pass",
out var passData);

// 从 frameData 里拿当前相机资源,实际字段名会随 URP 版本变化。
var resourceData = frameData.Get<UniversalResourceData>();
passData.source = resourceData.activeColorTexture;

var desc = renderGraph.GetTextureDesc(passData.source);
desc.name = "CustomBlitTexture";
passData.destination = renderGraph.CreateTexture(desc);

builder.UseTexture(passData.source);
builder.SetRenderAttachment(passData.destination, 0);
builder.SetRenderFunc((PassData data, RasterGraphContext context) =>
{
// 在这里记录真正的绘制或 Blit 命令。
});
}
}

这段代码不应该当成跨版本模板直接复制。URP 的资源数据类、Blit 工具和 RenderGraph API 在不同 Unity 6 小版本里有调整。重点是它体现了 RenderGraph 的写法分工:

  1. RecordRenderGraph() 负责描述这个 Pass。
  2. PassData 保存执行阶段需要的数据。
  3. UseTexture()、SetRenderAttachment() 等调用负责声明资源读写。
  4. SetRenderFunc() 里的函数才是真正记录 GPU 命令的地方。

3. RenderGraph 能优化什么

3. 1 临时资源生命周期

屏幕空间效果经常产生大量临时纹理。HBAO、SSR、Bloom、TAA、Color Grading、Depth Pyramid 这类功能都可能需要中间 RT。传统写法里,开发者需要自己决定何时申请、何时释放、是否复用。

RenderGraph 通过读写声明可以知道资源最后一次被使用在哪里。一个临时纹理如果只在两个相邻 Pass 之间传递,就不必占用整帧生命周期。生命周期不重叠提供复用机会,但“资源池复用同描述纹理”和“不同资源做底层堆内存 aliasing”不是同一能力,不能仅凭使用 RenderGraph 就保证后者。

这对移动端尤其重要。很多移动 GPU 对带宽和 Render Target 存取非常敏感,多申请一张全分辨率 HDR RT,成本不只是显存占用,还包括清屏、Load、Store 和 Tile Memory 与系统内存之间的数据交换。

3. 2 Pass 剔除

如果一个 Pass 的输出没有被后续 Pass 或最终 BackBuffer 使用,它理论上可以被剔除。RenderGraph 能从图依赖里发现这类无效节点。

当然,这个优化有前提:Pass 不能隐式产生外部副作用。比如写全局状态、读写不受图管理的资源、依赖某个外部 CommandBuffer 副作用,都可能让管线无法安全剔除。因此 RenderGraph 写法鼓励把输入输出显式声明出来。

3. 3 Native Render Pass 合并

在支持的图形 API 和平台上,URP 可以结合 RenderGraph 信息尝试合并 Native Render Pass。这个优化在 TBDR(Tile-Based Deferred Rendering,基于 Tile 的延迟渲染)移动 GPU 上很有意义。

如果多个 Pass 对同一组附件进行连续操作,并且中间没有必须落回系统内存的依赖,管线可以减少 Render Target 的 Load/Store。对移动端来说,这类优化有时比减少一点 ALU 更重要,因为瓶颈经常在带宽和片上/片外交互。

但这不是“用了 RenderGraph 就一定更快”。如果 Pass 之间频繁采样前一个 Pass 的颜色结果,或者需要把颜色 RT 当普通 Texture 读取,就可能打断合并条件。RenderGraph 提供的是分析和调度基础,具体收益仍然取决于资源访问模式、平台和效果实现。

4. 自定义 Pass 迁移时要注意什么

4. 1 不要把旧 Execute 代码原样塞进 RenderFunc

最常见误区是把旧版 Execute() 里的逻辑整体搬到 SetRenderFunc()。这样虽然可能跑起来,但很容易失去 RenderGraph 的意义。

需要拆开看:

  1. 创建临时纹理:尽量改成 renderGraph.CreateTexture()。
  2. 声明输入纹理:用 UseTexture() 或对应 builder API。
  3. 声明输出附件:用 SetRenderAttachment() 或对应写入 API。
  4. 真正绘制命令:放到 SetRenderFunc()。

也就是说,资源关系在外面声明,命令记录在里面执行。

4. 2 PassData 只放必要数据

PassData 是执行函数的数据桥。它适合放 TextureHandle、BufferHandle、RendererListHandle、材质引用、少量参数。不要把大对象、临时容器或依赖当前帧外部状态的复杂对象随手塞进去。

RenderGraph 的一个设计目标是让执行阶段更可预测。PassData 越小、越明确,越容易看出这个 Pass 的输入输出。

4. 3 区分 TextureHandle 和真实纹理

TextureHandle 不是普通 RenderTexture。它更像图里的资源引用。实际底层资源什么时候创建、是否复用、什么时候释放,由 RenderGraph 在编译和执行阶段决定。

这意味着不要在记录阶段假设自己已经拿到了真实 GPU 纹理,也不要在图外长期保存某个临时 TextureHandle。如果资源需要跨帧存在,例如历史帧 TAA、SSR history、上一帧深度等,通常要使用管线允许的持久资源管理方式,再在每帧导入或声明给 RenderGraph 使用。

4. 4 注意版本差异

Unity RenderGraph 在 URP 中的接入经历过迁移阶段。Unity 6 里 RenderGraph 已经是 URP 的重要路径,但具体 API 名称、资源数据类型和示例代码会随 URP 包版本变化。写教程或项目封装时,不建议只记住某一段网上代码,而应该围绕这几个稳定概念组织:

1
2
3
4
5
声明 Pass
声明资源读写
保存执行数据
设置执行函数
让 RenderGraph 编译执行

只要这个模型清楚,API 小变化通常比较容易适配。

5. 和传统 SRP Pass 写法的区别

可以用一句话概括:传统写法偏“我现在怎么画”,RenderGraph 写法偏“这一帧有哪些依赖”。

维度 传统 ScriptableRenderPass RenderGraph 写法
Pass 表达 手动按顺序入队并执行 入队后记录成图节点
临时 RT 开发者显式申请释放 图系统根据生命周期管理
资源依赖 隐含在命令顺序里 通过读写声明显式表达
Pass 剔除 很难全局判断 可基于最终依赖判断
移动端带宽优化 依赖手工组织 Pass 更容易分析合并条件
调试方式 看 Frame Debugger 和命令 同时看图结构、资源生命周期、命令

这并不代表传统写法已经没有价值。对于很简单、一次性的渲染逻辑,命令式写法更直接。RenderGraph 的优势会在 Pass 数量变多、临时资源变多、平台差异变复杂时逐渐体现出来。

6. 适合用 RenderGraph 思考的效果

RenderGraph 特别适合资源链路清晰的屏幕空间效果,例如:

  1. Bloom:多级降采样、模糊、合成。
  2. SSAO / HBAO:深度法线输入、AO 中间结果、Blur、Composite。
  3. SSR:深度、法线、颜色、Hit Buffer、Resolve。
  4. TAA:当前帧颜色、Motion Vector、History、输出颜色。
  5. Depth Pyramid:深度输入、多级 Mip 输出。

这些效果的共同点是:输入输出明确,中间纹理多,Pass 之间依赖清楚。RenderGraph 可以把这种链路表达得更结构化。

反过来,如果一个 Pass 强依赖外部状态、跨帧副作用很多、资源生命周期不清晰,迁移到 RenderGraph 时就要先整理数据边界。否则只是换了 API,复杂度并不会消失。

7. 工程上的使用建议

  1. 先画资源链路,再写代码。

    写 RenderGraph Pass 之前,先把输入、输出、中间资源画出来。比如“读 CameraColor 和 Depth,写 AOTexture,再读 AOTexture 合成回 CameraColor”。这比直接开写更不容易漏声明。

  2. 把持久资源和临时资源分开。

    临时纹理交给 RenderGraph 管理;跨帧历史、缓存贴图、长期 Buffer 则用明确的生命周期管理,并在每帧接入图。

  3. 避免隐式副作用。

    如果一个 Pass 修改了全局纹理、全局关键字或图外资源,要非常小心。隐式副作用会削弱 RenderGraph 的优化空间,也会让调试变困难。

  4. 不要为了“看起来现代”强行拆 Pass。

    Pass 越多,图越细,调度信息越丰富,但也可能增加状态切换和理解成本。拆分应该服务于真实资源依赖,而不是把每两行命令都包装成一个 Pass。

  5. 用 Frame Debugger、RenderGraph Viewer 和平台抓帧一起看。

    RenderGraph 能告诉你资源和 Pass 的逻辑关系,但最终性能仍然要看 GPU 抓帧。尤其是移动端,要关注 Render Target Load/Store、Resolve、带宽和 Tile Pass 是否被打断。

8. 同步与并行的实现边界

旧笔记把三张不重叠 RT 简化成“一块显存”,应补充格式、尺寸、样本数、资源池策略和后端 aliasing 支持这些条件。图知道最后使用点,不表示所有后端都会立即释放物理内存。

两个 Pass 在 CPU 上可并行录制,与 GPU 上能否并行执行是两回事。即使 CPU 可以独立录制,执行时仍可能必须等待资源;同样,没有资源依赖也不保证调度器一定使用异步队列。

资源屏障由具体访问和后端状态模型决定,写后写并非天然可以跳过同步。附件 Load/Store 与 MSAA Resolve 也应根据最终用途推导,不把 Clear、DontCare 和 Resolve 简化成固定成本排序。

总结

RenderGraph 将渲染管线的管理重点从命令顺序转向资源依赖。URP 因此能够在执行前掌握 Pass 的读写关系与资源生命周期,为临时纹理复用、Pass 剔除、Native Render Pass 合并和跨平台调度提供条件。

自定义 URP Feature 时,RecordRenderGraph() 负责声明资源关系,SetRenderFunc() 负责记录实际命令。这种组织方式提供了优化空间,却不保证效果自动提速;Pass 划分、资源生命周期和中间纹理规模仍需要由开发者控制。

参考资料

Unity Manual:Introduction to the render graph system in URP

Unity Manual:Write a render pass using the render graph system in URP

Unity Manual:Optimize the render graph system in URP

Unity Manual:Render graph system in URP