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() 改名,而是让管线拥有了优化所需的信息。

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 之间传递,就不必占用整帧生命周期。多个生命周期不重叠的纹理也可能复用同一块底层内存。

这对移动端尤其重要。很多移动 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 是执行函数的数据桥。它适合放 TextureHandleBufferHandleRendererListHandle、材质引用、少量参数。不要把大对象、临时容器或依赖当前帧外部状态的复杂对象随手塞进去。

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 是否被打断。

总结

RenderGraph 的本质是把渲染管线从“命令顺序管理”推进到“资源依赖管理”。它让 URP 在执行前知道一帧里有哪些 Pass、每个 Pass 读写哪些资源、资源生命周期在哪里开始和结束,从而为临时纹理复用、Pass 剔除、Native Render Pass 合并和跨平台调度提供基础。

对写自定义 URP Feature 来说,RenderGraph 最大的心智变化是:不要急着写命令,先把资源关系说清楚。RecordRenderGraph() 是声明阶段,SetRenderFunc() 才是执行阶段。只要理解这条边界,RenderGraph 就不是一套更绕的 API,而是一种更适合复杂渲染管线的组织方式。

真正落地时也不要迷信它。RenderGraph 提供优化可能性,不保证所有效果自动变快。好的 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