「Custom SRP」:渲染管线架构与 Render Graph
Note 1 · 渲染管线架构与 Render Graph
系列首篇。本篇定位是引擎的”执行骨架”——它定义了所有其他系统(光照、阴影、后处理、相机管理)的宿主形态。理解这一篇,后续每个系统的接入方式都不再陌生。
TL;DR
- 声明式资源生命周期:Render Graph 把资源依赖、Pass 顺序、attachment 绑定全部显式化。引擎在编译期完成依赖分析、裁剪冗余 Pass、规划 RT 复用——开发者只声明意图,不管理生命周期。
- Raster Pass 合并机制:禁止 Pass 内切换 RenderTarget 是为了让引擎能把相邻 Pass 合并为单个原生 render pass。在移动端 TBR 架构下,attachment 全程留在片上内存,省下整屏量级的带宽往返(详见 §7)。
- RendererList 剔除前置:所有几何提交统一到
RendererListHandle。剔除可并行调度,空 List 触发 Pass 自动消除——dead-code elimination 在渲染管线层的体现。 - API 演进基线:全文基于 Unity 6.3 的
AddRasterRenderPass/SetRenderAttachment/RecordAndExecute现代范式,已废弃的AddRenderPass与手动SetRenderTarget不再涉及。
1. SRP 的执行模型
SRP 的本质是把渲染管线变成一个用户态实现。Unity 引擎只负责剔除、网格上传、命令提交、原生 API 调用等底层任务,而”什么时候画什么、用什么 RT、按什么顺序”则全部交由 C# 决定。
整个体系建立在两个核心类型之上:
RenderPipelineAsset—ScriptableObject,作为配置容器与工厂,负责创建RenderPipeline实例RenderPipeline— 实际承担渲染工作的对象,每帧由引擎调用其Render方法
1 | |
CustomRenderPipeline 重写 Render(ScriptableRenderContext, List<Camera>),按相机列表依次驱动渲染:
1 | |
ScriptableRenderContext 是与底层引擎之间的桥梁,提供剔除、查询场景资源、提交命令等接口。但在 Render Graph 时代,它退到次要位置——绝大部分工作通过 RenderGraph 对象进行声明式描述,只有命令缓冲的最终提交仍然落到 context.ExecuteCommandBuffer / Submit 上。
2. Render Graph:声明式渲染
2.1 设计动机
传统命令式渲染存在三个无法回避的问题。第一,RT 的生命周期由开发者手动管理,资源复用与释放容易出错或浪费。第二,Pass 之间的依赖关系隐式编码在代码顺序里,引擎无法据此做调度优化。第三,tile-based GPU 想要利用片上内存做 frame-buffer fetch,必须知道哪些 Pass 可以合并到同一原生 render pass 内——而这种结构化信息在传统命令流中是缺失的。
Render Graph 把这些信息全部显式化:每个 Pass 在录制阶段声明它读哪些资源、写哪些资源、绑定哪些 attachment。引擎在执行前先做依赖分析,再决定真正的资源分配、Pass 调度、Pass 合并。
2.2 资源句柄
Render Graph 中所有资源以句柄形式存在:TextureHandle、BufferHandle、RendererListHandle。这些句柄是引用,不是对象——真正的 GPU 资源在 Compile 阶段才被分配,并按生命周期被多个句柄复用。
1 | |
句柄的意义在录制阶段是”占位符”,在执行阶段才被解析为真实的 RenderTargetIdentifier。
2.3 录制与执行分离
整个相机渲染包裹在一对 RecordAndExecute 调用里:
1 | |
using (renderGraph.RecordAndExecute(...))是 Unity 6 引入 Render Graph 后的现代范式。RecordAndExecute返回一个IDisposable:录制阶段在using块内进行,块退出时触发Dispose,引擎在此刻才完成依赖编译并执行图。相对手写Begin/End配对的核心优势是异常安全——录制中途抛错时引擎仍能正确释放资源、关闭 profiling scope,不会留下损坏的图状态。
RecordAndExecute 块结束时,Render Graph 完成四步工作:
- 分析所有 Pass 的输入输出依赖
- 裁剪没有任何下游消费的 Pass(dead code elimination)
- 决定每个资源的实际分配时机和复用方式
- 在原生层执行——Unity 6.3 起默认启用 native render passes
RenderGraphParameters 携带本次录制所需的上下文:
1 | |
rendererListCulling = true 是关键开关——它允许引擎根据 RendererList 是否为空,自动裁剪不会产生绘制的 Pass。
3. Pass 类型与实现模式
3.1 三种 Pass 类型
Render Graph 把 Pass 分为三档,限制由松到紧:
| 类型 | API | 用途 | 关键限制 |
|---|---|---|---|
| Unsafe Pass | AddUnsafePass |
需要任意 CommandBuffer 操作的特殊场景 | 不参与合并、不参与 native pass 优化 |
| Compute Pass | AddComputePass |
纯 Compute Shader 工作(如 LUT 生成、Tile Forward+ 光源分配) | 不能写 attachment |
| Raster Pass | AddRasterRenderPass |
标准光栅化绘制 | Pass 内不允许 SetRenderTarget;attachment 必须显式声明 |
Raster Pass 是核心。它的限制——禁止 Pass 内切换 RT——正是引擎能做合并优化的前提。当连续多个 Raster Pass 渲染到同一组 attachment 时,引擎可以把它们合并为单个原生 render pass,让中间结果留在 tile-based GPU 的片上内存里,避免一次往返显存的带宽开销。
Unsafe Pass 是过渡性兜底:当某个 Pass 暂时无法满足 Raster Pass 的限制时,先用它跑通;后续再逐步收敛到 Raster Pass。Compute Pass 专门用来组织 Compute Shader 的 Dispatch。
3.2 标准 Pass 实现模板
每个 Pass 在 Custom SRP 里通常是一个独立类,约定包含三部分:
- 一个数据结构(Pass Data),由 Render Graph 内部池化复用
- 一个静态
Record方法,在录制阶段调用,负责声明资源与注册执行函数 - 一个静态
Render方法,由 Render Graph 在执行阶段回调
以 GeometryPass 为例:
CSHARP
1 | |
这个模板有几个值得记住的约束。Pass Data 实例由 Render Graph 池化,不要在其上保存帧间状态。Render 函数签名固定为 (PassData, RasterGraphContext),函数体只能通过 context.cmd(一个受限的 RasterCommandBuffer)发出命令。SetRenderFunc 调用必须在 builder 销毁之前完成——这正是 using 语法承担的职责。
3.3 资源声明 API
builder 提供三类资源声明方法:
1 | |
SetRenderAttachment 的 attachment index 在使用 MRT(Multiple Render Target)时区分多个颜色目标。Skybox 这类只写颜色不写深度的 Pass,需要把深度 attachment 的访问权限设为只读:
1 | |
attachment 的绑定与解绑由引擎在 Pass 起止时自动完成。Pass 函数体内调用 SetRenderTarget 是非法的——这条规则一旦违反,所有合并优化都会失效。
3.4 Native Render Passes 与 Pass 合并
Unity 6.3 起,renderGraph.nativeRenderPassesEnabled 默认为 true。引擎主动尝试把相邻 Raster Pass 合并为单个原生 render pass。在 Render Graph Viewer 中,被合并的 Pass 会以蓝色横条标记,并显示合并的判断依据。
合并发生需要满足以下条件:
- 都是 Raster Pass(Unsafe Pass、Compute Pass 一律打断合并)
- attachment 集合视为同一组:颜色与深度的数量、格式、load/store 行为兼容
- 没有外部依赖把它们隔开(比如某个中间 Pass 把 attachment 当纹理采样了)
典型的合并组合:Skybox + 不透明几何(同一对 color/depth);透明几何 + Unsupported Shaders 调试 Pass;颜色与深度的两个独立 Copy Pass。
合并是引擎层的优化,业务代码不需要改动,但业务代码必须正确遵守 Raster Pass 的所有限制——任何错误的 attachment 声明都会让引擎无法判定 Pass 可合并。
4. Renderer Lists:几何提交的统一句柄
RendererList 是几何剔除与提交的统一抽象。在 Render Graph 时代,几何不再通过 context.DrawRenderers 直接提交,而是先创建 RendererListHandle,再绑定到 Pass 上由引擎统一调度。
1 | |
通过 builder.UseRendererList(list) 注册到 Pass 后,引擎在执行时会自动安排剔除任务。这带来两个直接收益。其一,所有 RendererList 的剔除工作可以并行调度——传统 cull-draw-cull-draw 的串行依赖被打破,剔除阶段集中前置,绘制阶段集中后置。其二,空 List 触发 Pass 自动裁剪:如果一个 Pass 仅依赖一个 RendererList,且 List 中无可见对象,整个 Pass 会被引擎在 Compile 阶段直接消除。
执行阶段的提交一行搞定:
1 | |
需要特别记住一条限制:RendererList 不可复用。每个 List 只允许在一个 Pass 中绘制一次。点光源的 6 面 cube shadow 必须创建 6 个独立的 RendererList,即使每个 List 的 ShadowDrawingSettings 完全相同。
阴影 List 与天空盒 List 各有专门构造接口:
1 | |
天空盒还有一个特殊点:它在引擎眼里不算几何,所以默认的”裁剪空 Pass”逻辑会误删 SkyboxPass。需要显式声明该 Pass 不允许被裁剪:
1 | |
5. 相机渲染编排
5.1 单相机执行流
每个 Camera 独立完成一次 Render Graph 录制与执行。整体生命周期如下:
flowchart TD
A([Camera 渲染入口]) --> B[解析 per-camera 配置
Sampler / CameraSettings]
B --> C[context.Cull
→ CullingResults]
C --> D[RecordAndExecute 块开启]
D --> E[Record Phase
SetupPass · Lighting · Shadows
Geometry · Skybox · CopyAttachments
PostFX · Debug · Gizmos
逐一声明资源与执行函数]
E --> F[Compile Phase
依赖分析 · 裁剪空 Pass · RT 复用规划]
F --> G[Execute Phase
Native Render Pass 合并 · 命令录制]
G --> H[ExecuteCommandBuffer · Submit]
H --> I([帧提交完成])
style D fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style E fill:#e3f2fd,stroke:#1976d2
style F fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style G fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
CameraRenderer.Render 是这条生命线的代码实现:
CSHARP
1 | |
整个流程的关键节奏:先做剔除拿到 CullingResults,再用它驱动整个 Render Graph 录制。所有 XxxPass.Record 调用都不立即执行——它们仅在图中登记意图。using 块退出时引擎才真正编译并执行。
5.2 Per-Camera 配置
CustomRenderPipelineCamera 是挂在 Camera GameObject 上的扩展组件,承载相机级别的设置:
1 | |
CameraSettings 通常包含:自定义 final blend 模式、RenderingLayerMask、是否启用 PostFX、Render Scale 倍数等。具体字段会在 Note 8(相机系统与工程架构)展开。
ProfilingSampler 缓存有一个 editor-only 边界:相机改名后,缓存的 Sampler 仍然带着旧名字,Frame Debugger 会显示错误。解决方法是组件 OnEnable 时清除缓存:
1 | |
这样进入 Play Mode 或重新启用相机时,Sampler 会重建。
5.3 隐式相机
除了游戏相机,Render 还会被以下相机调用:Scene View、Material Preview、Realtime Reflection Probe、用户通过 Camera.Render() 主动触发的 manual render。这些相机一般没有 CustomRenderPipelineCamera 组件,需要 fallback:
1 | |
Unity 6 在 release build 中存在一个已知 bug:ProfilingSampler.Get 在某些情况下返回 null,会导致 reflection probe 渲染崩溃。生产环境用一个统一的 catch-all sampler 兜底是当前最稳妥的做法。
5.4 Render Graph Debug Data
Render Graph Viewer 需要相机渲染时启用 debug data 才能呈现内容。但启用会引入额外的内存分配与 CPU 开销,所以只对真正需要观察的相机开启。参考 URP 的做法,跳过预览相机和 manual render 请求:
1 | |
调试完毕后关闭 Render Graph Viewer 窗口,引擎会自动停止 debug data 收集。
6. 调试工具索引
三套工具,各有职责。
Frame Debugger — Window / Analysis / Frame Debugger
观察实际的 GPU 提交序列。每个 DrawCall、SetGlobalTexture、ClearRenderTarget 都展开为一行,可以单步检查中间 RT。Frame Debugger 反映的是命令流,看不到 Render Graph 的资源依赖。需要快速验证”某个像素是怎么画出来的”时首选。
Render Graph Viewer — Window / Analysis / Render Graph Viewer
观察 Pass 之间的依赖关系与资源生命周期。Pass 之间的连线对应资源读写,被裁剪的 Pass 标灰,被合并的 Raster Pass 序列以蓝色横条标记。Unity 6.3 后这是诊断 native render pass 优化是否生效的主要工具。注意打开窗口本身会触发 debug data 分配,调试完关闭。
RenderDoc
当 Frame Debugger 信息不够细时(Compute Shader 的 thread group 行为、tile memory 实际占用、API 提交层的真实顺序),RenderDoc 抓帧分析能给出引擎与 Frame Debugger 都无法呈现的硬件层细节。
7. TA Takeaway:从 API 设计到硬件带宽
Render Graph 的所有限制——Raster Pass 禁止 SetRenderTarget、attachment 必须显式声明、RendererList 不可复用——单看 API 显得繁琐,但每条限制都对应一个移动端硬件层面的带宽收益。
7.1 TBR 架构下的带宽真相
主流移动 GPU(ARM Mali、Qualcomm Adreno、Apple Silicon、PowerVR)普遍采用 TBR / TBDR(Tile-Based / Deferred Rendering)架构。GPU 把屏幕划分为若干小 Tile(典型尺寸 16×16 至 64×64 像素),每个 Tile 在渲染时占用一块 On-chip Memory(片上内存,物理上是 SRAM),其访问延迟与带宽相比主显存(DRAM)有一个数量级的优势。
传统命令式管线频繁切换 RenderTarget 时,硬件被迫执行两个动作:
- Store:把当前 Tile 的 color / depth 数据从片上内存回写到主显存
- Load:把新目标对应的 Tile 数据从主显存加载回片上内存
每次 SetRenderTarget 都隐含全屏所有 Tile 的一轮 store-load 往返。以 1080p、4 byte color + 4 byte depth 计算,单次切换的带宽成本约为 16 MB。一帧若发生 5–10 次切换,叠加多相机栈与后处理链,仅 RT 切换就吞掉数百 MB——而移动端 GPU 的可用带宽预算典型值仅 25–50 GB/s。发热、降频、续航崩塌的根因,往往就是这个。
7.2 Render Graph 限制的硬件意义
把 Raster Pass 的核心限制和 TBR 行为对应起来看:
| API 层限制 | 硬件层意义 |
|---|---|
Pass 内禁止 SetRenderTarget |
保证 attachment 在 Pass 执行期间稳定,不触发中途的 store-load |
SetRenderAttachment 必须显式声明 |
引擎能精确识别”哪些相邻 Pass 共享同一对 color/depth” |
| Native Render Pass 合并 | 共享 attachment 的 Raster Pass 在硬件层被压缩为单个原生 render pass:attachment 全程留在片上内存,整段链只在开始 Load、结束 Store |
Render Graph Viewer 中的蓝色合并条,本质上就是在可视化”这些 Pass 之间省下了多少 store-load 往返”。在移动端项目里,这个数字直接决定 60 FPS 能否稳住、设备温度能否压住、电池续航如何。
7.3 实践推论
结合上述硬件视角,后续 7 篇笔记的所有 Pass 划分都遵循以下隐含原则:
- 几何类 Pass 一律 Raster Pass。Skybox、不透明几何、半透明几何、debug 渲染都走
AddRasterRenderPass。Unsafe Pass 是兜底,不是默认。 - 避免中途采样未结束的 attachment。在某个 Pass 中读取尚未结束的 color/depth attachment 作为纹理,会强制 store 到主存,切断整段合并链。如需要场景颜色或深度的副本,通过专门的
CopyAttachmentsPass显式生成独立纹理,让原 attachment 留在合并链上。 - PostFX 必然打断合并链。后处理需要采样上一阶段结果,store-load 无法避免——这是必要代价。但合并链应该在 PostFX 之前尽可能延长。
这条隐性约束会贯穿所有后续笔记。当某个 Pass 看似可以”图省事用 Unsafe Pass”时,要意识到代价是放弃了整段合并优化——在移动端这通常意味着几个毫秒的 GPU 时间和一个台阶的设备温度。
附录:项目文件与 Pass 类型速查
1 | |
每个 Pass 类的契约一致:私有 Pass Data 字段集合、一个静态 Record 方法负责录制、一个静态 Render 方法由 Render Graph 在执行阶段回调。后续每篇笔记中提到具体 Pass 时,都默认遵循这一形态。
本篇关键 API 速查
CSHARP
1 | |