「Custom SRP」:几何可见性与批次优化
系列第 2 篇。本篇承接 Note 1 中对
RendererListHandle的接口定义,深入到几何可见性与批次合并的具体机制——回答”哪些对象会被画”和”GPU 提交效率如何被压榨”两个核心问题。
TL;DR
- 三段式数据流:
CullingResults→RendererListDesc→ 引擎调度的批次提交。开发者只声明渲染意图,剔除与批次决策由引擎完成。 - 现代批次仅两条主线:SRP Batcher(材质数据 CBUFFER 持久化)与 GPU Instancing(同 Mesh+Material 实例合并)。Dynamic Batching 在 RendererList 模式下被强制禁用,GPU Instancing 被强制启用——开发者已无配置权限。
- SRP Batcher 不减少 Draw Call 数量,减少的是 SetPass 与材质状态切换的 CPU 成本。GPU Instancing 才真正减少 Draw Call 总数。两者收益模型不同,覆盖场景互补。
- Shader 兼容性是底线:
UnityPerMaterial与UnityPerDrawCBUFFER 布局必须严格遵守,违反任何一条都会让该 Shader 退回到逐对象的慢路径。
1. 可见性裁剪
每个 Camera 渲染开始时,先通过 ScriptableRenderContext.Cull 拿到本帧的可见性快照:
1 | |
CullingParameters 暴露的关键参数:
shadowDistance— 决定哪些 Renderer 进入阴影投射列表。这个值往往比farClipPlane小得多,这样可以避免远处对象产生不必要的阴影开销。cullingMask— Layer 位掩码。lodBias— LOD 选择的全局缩放(配合 LOD Group)。isOrthographic— 影响视锥剔除的几何形态。
CullingResults 是一个值类型(struct),包含可见 Renderer 列表、可见光源列表、可见 Reflection Probe 列表等。它在整个 Render Graph 录制阶段被多次引用——所有 RendererList 的创建都以它为输入。
需要强调一点:剔除是视锥与遮挡级别的,不是材质级别的。同一个 CullingResults 会被光照、阴影、几何 Pass 各自消费一次,但每次消费的”过滤条件”由 RendererListDesc 的另外几个字段决定。
2. RendererListDesc:渲染配置的现代封装
RendererListDesc 是从 CullingResults 到具体 Pass 之间的过滤与配置层。一个典型的不透明几何描述:
1 | |
四个字段决定了”画什么、按什么顺序画、需要哪些每对象数据”:
2.1 ShaderTagId:Pass 白名单
ShaderTagId 是 Shader 中 LightMode 标签的封装。RendererListDesc 接受一个或多个 ShaderTagId——只有 Shader Pass 的 LightMode 命中其中之一,该对象才会被渲染。
Custom SRP 中常用的 LightMode 标识:
| ShaderTagId | 用途 |
|---|---|
SRPDefaultUnlit |
不参与光照计算的默认 Pass |
CustomLit |
自定义 PBR 主光照 Pass |
ShadowCaster |
阴影 Atlas 渲染 |
Meta |
GI 烘焙时的 albedo/emission 贡献 |
调试用的 Unsupported Shaders Pass 会传入一组 Built-in 时代的 LightMode 数组(Always、ForwardBase、PrepassBase 等),让那些非自定义 Shader 的对象也能以 magenta 形式显示出来。
2.2 SortingCriteria:排序的语义
SortingCriteria 决定 Renderer 的提交顺序,对性能与正确性都有影响:
CommonOpaque不透明几何标准排序:先按RenderType分组、再按 Material、Mesh 排序,最后按相机距离从前到后。前向排序最大化 Early-Z 剔除;按 Material/Mesh 排序最大化 SRP Batcher 与 Instancing 命中率。CommonTransparent半透明几何标准排序:从后到前(远到近)。这是 alpha blend 正确性的硬性要求,性能上无法避免破坏 batch。- 其他细粒度选项(
OptimizeStateChanges/BackToFront/RenderQueue等)在特殊场景下用,比如纯 UI 渲染或自定义透明度排序。
2.3 RenderQueueRange:队列分区
RenderQueueRange 用于按 Material 的 RenderQueue 值切分对象集合:
RenderQueueRange.opaque≈[0, 2500],对应 Background / Geometry / AlphaTest 队列。RenderQueueRange.transparent≈[2501, 5000],对应 Transparent 及之后的队列。
不透明几何 Pass 与半透明几何 Pass 通过这个字段在同一份 CullingResults 上做不重叠切片。Cutout(AlphaTest)虽然有透明度概念,但由于支持 Early-Z,仍归入 opaque 区间。
2.4 PerObjectData:每对象数据需求声明
PerObjectData 是位掩码,告诉引擎本次渲染需要为每个对象准备哪些”附加数据”。最常用的几个:
ReflectionProbes— 自动选择最近的两个 Reflection Probe 并写入unity_SpecCube0/1Lightmaps— 写入unity_LightmapSTShadowMask— 写入unity_ShadowMask相关变量LightProbe/OcclusionProbe— 写入球谐系数LightProbeProxyVolume/OcclusionProbeProxyVolume— 用于 LPPV 的体积探针
Custom SRP 在 Forward+ 时代之后,原本通过 LightData 和 LightIndices 传递的 per-object 光源索引已被 Tile-based 光源分配替代,所以这两个 flag 不再使用。这种简化在 Note 4(直接光照与 Tiled Forward+)会展开。
声明哪些 PerObjectData 直接关系到 Shader 端能读到哪些 unity_* 变量。漏声明会导致 Shader 读到默认值,画面错误难以定位——这是 Custom SRP 中最典型的”Shader 没坏但渲染错”的来源。
3. 批次系统:现代实现的两条主线
Render Graph + RendererList 模式下,批次决策已经从”开发者勾选选项”退化为引擎自动判定。每个 Renderer 走以下决策路径:
flowchart TD
A[Renderer 进入 RendererList] --> B{使用了
MaterialPropertyBlock?}
B -->|是| D{Shader 启用了
GPU Instancing?}
B -->|否| C{Shader 兼容
SRP Batcher?}
C -->|是| E[SRP Batcher 路径
持久化 CBUFFER]
C -->|否| D
D -->|是 + 同 Mesh+Material| F[GPU Instancing 路径
合并为单次 Draw]
D -->|否| G[标准 SetPass + Draw
慢路径]
style E fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style F fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style G fill:#ffebee,stroke:#c62828
两条快路径覆盖了现代项目几乎全部场景。慢路径只在 Shader 不兼容 SRP Batcher 且未启用 Instancing 的情况下出现——这通常是写错了,不是设计选择。
3.1 SRP Batcher:CBUFFER 持久化
SRP Batcher 的核心机制不是”合并 Draw Call”,而是”消除 Draw Call 之间的状态切换开销”。
工作流程:
- 第一次绘制某 Material 时,CPU 把材质属性打包成 CBUFFER 上传到 GPU 显存
- CBUFFER 在 GPU 端持久存在——只要材质数据不变就不需要重新上传
- 后续绘制相同 Shader 的对象时,CPU 只发出”切换 CBUFFER offset + Draw”两条指令
- GPU 端跳过 Material binding 的全部 fixed-function 状态准备
收益的根源在 CPU 端:传统模式下,每次切换 Material 都要触发 driver 重新组织 RenderState、绑定 texture、更新 uniform buffer——这些都是几百到上千个 ns 的开销。SRP Batcher 把它压到几十 ns。
观察 Frame Debugger,多个走 SRP Batcher 的对象会被聚合为一个 “SRP Batch” 节点,下方列出参与的 Mesh 与对应的 Material。Draw Call 数量本身没变——单个 SRP Batch 节点可能展开为几十次 DrawMesh,但 SetPass 调用只计一次。
启用方式只有一行:
1 | |
绝大多数现代项目都在 RenderPipelineAsset 的构造路径里默认开启。
3.2 GPU Instancing:减少 Draw Call 总数
GPU Instancing 的逻辑与 SRP Batcher 正交:当多个 Renderer 使用完全相同的 Mesh + Material时,引擎可以发出单个 DrawMeshInstanced 指令,让 GPU 在一次 Draw 中绘制 N 个实例。
每实例的差异通过两种方式注入:
MaterialPropertyBlock:CPU 端为每个对象设置不同属性(颜色、自定义参数),引擎把它们打包成 instance buffer 一次上传unity_ObjectToWorld:每实例的世界变换矩阵自动写入 instance buffer
GPU Instancing 真正减少了 Draw Call 数量——这对 GPU 命令处理器和 driver 都有收益。但它的适用面比 SRP Batcher 窄:必须同 Mesh 同 Material。典型场景是植被、子弹、UI 元素、粒子。
3.3 RendererList 模式下的强制配置
这是 Render Graph 时代的关键变化:RendererListDesc 不暴露 enableDynamicBatching 和 enableInstancing 字段。引擎默认:
- Dynamic Batching 永远禁用
- GPU Instancing 永远启用
理由直白:Dynamic Batching 是为低端 GPU 时代设计的,把多个小 Mesh 在 CPU 端合并到一个 Vertex Buffer 再提交。在 SRP Batcher 与 GPU Instancing 都可用的现代环境下,它的 CPU 合并开销已超过收益。GPU Instancing 没有任何禁用的理由——不兼容的 Shader 不会受影响,兼容的 Shader 默认获益。
这条简化让上层代码消失了一对配置项,开发者只需关心 Shader 写得对不对。
3.4 Static Batching:构建期的另一种存在
Static 标记的物体在场景构建时被合并为大 Mesh,运行时作为一整个对象提交。它不在 RendererList 的决策路径上——本质是”一个超大 Renderer”。
代价是构建期内存翻倍(原 Mesh + 合并 Mesh 都要保留)和运行时缺乏剔除粒度(合并后的大 Mesh 整体进出视锥)。在大场景中需要谨慎使用,通常配合 OcclusionCulling 才能避免反向劣化。
📱 移动端的态度更激进:目前重度手游项目几乎全面废弃大范围的 Static Batching。合并出的超大 Mesh 会让包体与运行时峰值显存双双激增,且在 TBR 架构下失去了细粒度的 Tile 级剔除收益——超大 Mesh 即使大部分顶点不可见,整体也会被塞入 binning 阶段,浪费几何处理预算和片上内存带宽。移动端的现代实践是让 SRP Batcher + GPU Instancing 覆盖绝大多数批次需求,Static Batching 严格限制在”小尺寸、强相邻”的物体集合(同一房间的固定家具、单一建筑的固定装饰),并配合 Occlusion Culling 才考虑启用。
4. Shader 端的批次兼容性
Shader 是否走 SRP Batcher / GPU Instancing 完全由其 HLSL 代码结构决定。两种快路径有不同的代码契约。
4.1 SRP Batcher 兼容性
两条硬性要求:
所有材质属性必须放入唯一的 UnityPerMaterial CBUFFER
1 | |
⚠️ HLSL 常量缓冲区的 16 字节(4 个 float)对齐潜规则:CBUFFER 中每个变量都不能跨越 16 字节边界。当连续声明
float3紧跟float2、或float紧跟float3时,编译器会在前者后面自动插入 padding,把后者推到下一个 16 字节边界。这种隐式 padding 不仅会让 CPU 端通过 memcpy 写入数据时产生显存错位,在某些 Unity 版本上还会直接导致 SRP Batcher 兼容性检测失败(因为编译器看到的实际 buffer 布局与代码声明不一致)。安全做法是按float4 → float3 → float2 → float的尺寸递减顺序声明字段,或显式用float4把零散小字段打包合并。
材质属性散落在 CBUFFER 之外,或分布在多个 CBUFFER 中,都会让该 Shader 在 Inspector 中显示 “Not compatible with SRP Batcher”。
所有 per-object 引擎变量必须放入 UnityPerDraw CBUFFER,且必须包含特定的固定字段
1 | |
注意 unity_LODFade:即使 Shader 不支持 LOD Cross-fade,这个字段也必须声明——它是 UnityPerDraw 的固定布局成员。漏声明会让整个 buffer offset 错位。
字段顺序在 Catlike Coding 教程中按 Unity 官方约定排列。理论上顺序不严格要求,但与官方一致能避免未来 Unity 升级带来的微妙问题。
4.2 GPU Instancing 兼容性
启用 Instancing 需要在 Shader 中加入 #pragma multi_compile_instancing,并把材质属性改为 instanced 形式:
1 | |
Vertex 与 Fragment 中需要传递 instance ID:
1 | |
UNITY_SETUP_INSTANCE_ID 必须在每个 Shader 函数入口调用,否则 UNITY_ACCESS_INSTANCED_PROP 会读到错误数据。这是初学者最常踩的坑。
4.3 两条路径的优先级
SRP Batcher 优先于 GPU Instancing。如果一个对象同时满足两个条件,引擎走 SRP Batcher。原因是 SRP Batcher 不要求 Mesh 相同——覆盖面更广。
但有一个例外:使用 MaterialPropertyBlock 的对象会自动从 SRP Batcher 路径退出。底层原因值得说透——SRP Batcher 的核心前提是”材质数据在 GPU 端持久化、跨对象共享”,而 MaterialPropertyBlock 强行在 per-object 粒度注入差异化属性,这直接打破了数据共享假设,迫使引擎回退到逐对象更新 CBUFFER 的慢路径。为了避免这种性能塌方,引擎自动把这类对象移交给 GPU Instancing 路径:MPB 中的属性被打包成 instance buffer,反而成为 Instancing 的天然驱动力。这是为什么”用 MPB 替代 Material 修改”在性能上几乎无损——不是 MPB 没有代价,而是引擎为它准备了另一条同样高效的路径。
实践推论:
- 普通对象走 SRP Batcher——这是默认路径,只要 Shader 写对了就生效
- 大量同款实例(草、植被、子弹)显式用
MaterialPropertyBlock触发 Instancing - 不要用
Material.Lerp或运行时改Material属性——这会无差别破坏 batch;改用MaterialPropertyBlock
4.4 兼容性常见破坏源
| 问题 | 后果 |
|---|---|
材质属性写在 UnityPerMaterial 之外 |
Shader 不兼容 SRP Batcher |
UnityPerDraw 漏掉 unity_LODFade 等固定字段 |
Shader 不兼容 SRP Batcher |
运行时调用 material.SetColor |
该 Renderer 退出 SRP Batcher |
| 使用 Shader Graph 后手动加属性但未重编 | 兼容性状态错位 |
| 同 Material 不同 keyword 变体 | 各变体单独 batch,无法跨变体合并 |
每条都可以在 Inspector 中通过 “Not compatible with SRP Batcher” 提示验证。开发期养成查看这个状态的习惯。
5. LOD Group
LOD(Level of Detail)系统通过屏幕占比自动选择 Mesh 精度。它与批次系统的协作关系是 Note 中容易被忽略但很关键的部分。
5.1 LOD 选择机制
每个 LODGroup 组件持有多个 LOD 级别,每级关联一个或多个 Renderer 与一个屏幕高度阈值:
- 对象在屏幕上的相对高度大于阈值 → 当前级别可见
- 低于最低级别阈值 → Culled(完全不渲染)
实际计算受两个全局参数影响:
QualitySettings.lodBias— 全局乘数。值越大越倾向于使用高精度 LOD(更”清晰”),值越小越激进降级(更”省”)QualitySettings.maximumLODLevel— 强制的最高级别上限,比如设为 1 让所有对象至少跳过 LOD0
LOD 的选择在 Cull 阶段完成,结果直接体现在 CullingResults 中——LOD 没被选中的 Renderer 不会出现在 RendererList 里,等同于被剔除。
5.2 Cross-fade Dithering
LOD 切换时直接跳变会很扎眼,Cross-fade 模式让相邻级别在过渡区间共存渲染,通过 dithered 透明度做平滑切换。
启用 Cross-fade 后,过渡区间内同时绘制 LODn 和 LODn+1。每个对象通过 unity_LODFade.x(取值 -1 到 1)拿到当前 fade 因子,在 Shader 中通过 dither 做 alpha test:
1 | |
InterleavedGradientNoise 是 Jorge Jimenez 提出的稳定屏幕空间 dither 函数——同一像素位置每帧给出相同噪声值,避免动态闪烁。fade > 0 时正向 clip(淡入新 LOD),fade < 0 时反向 clip(淡出旧 LOD)。
Shader 端启用方式:
1 | |
LOD Group 在 inspector 里把 Fade Mode 设为 Cross-Fade 即可触发。
5.3 LOD 与批次的协作
dithered cross-fade 相对其他 fade 模式(如 animated)的关键优势:它不破坏 GPU Instancing。
原因是同一 LOD 级别内所有对象的 unity_LODFade.x 值相同——它们仍然可以合并为单个 instance batch。如果用 animated fade(每对象不同 fade 因子),instance buffer 中每个实例需要独立的 fade 数据,会破坏 batching 假设。
实践:
- 植被、石头等密集对象:LOD Group + Cross-Fade Dithered + GPU Instancing 三件套
- 主角、关键道具:可以用 SpeedTree 风格的 vertex animation fade,但代价是每对象单独绘制
6. 调试与性能验证
6.1 Frame Debugger 中的批次识别
打开 Frame Debugger(Window / Analysis / Frame Debugger),可以直观看到批次合并情况:
SRP Batch节点:多个对象被合并为一个 SRP Batcher 段。展开可看包含的 Mesh 列表与统一使用的 Shader Variant。Draw Mesh (instanced)节点:GPU Instancing 命中。详情面板显示实例数量。- 单独的
Draw Mesh节点:未命中任何快路径。需要排查 Shader 兼容性或 MaterialPropertyBlock 使用。
每个节点会显示”Why this draw call can’t be batched with the previous one”——这是最直接的诊断工具。常见原因包括 keyword 不同、Material 不同、MaterialPropertyBlock 内容不同等。
6.2 Stats 面板分解
Game 视图右上角的 Stats 面板提供宏观批次数据:
- Batches — 本帧的总 batch 数。SRP Batch 计为单个,但内部包含多个 DrawMesh。
- Saved by batching — 因 batch 节省的 Draw Call 数量。这个数字真正反映批次系统的收益。
- SetPass calls — Pipeline 状态切换次数。SRP Batcher 的核心收益就是降低这个数字。
诊断节奏:先看 SetPass calls 是否合理(典型移动端目标 < 50),再看 Batches,最后看具体节点。
7. TA Takeaway:批次优化的本质是减少状态切换
批次系统经常被误解为”减少 Draw Call 数量”。实际上现代 GPU 单帧能轻松处理上万次 Draw Call——真正的瓶颈是 CPU 端 driver 准备命令的开销和 GPU 端 Pipeline State Object 切换的开销。
7.1 状态切换才是 CPU 杀手
每次 SetPass 都隐含 driver 层面的复杂工作:
- 解析 Material 的 RenderState(混合方程、深度测试、剔除模式)
- 绑定 Shader 程序(VS + PS + 可能的 GS/HS/DS)
- 上传或更新 Material 的 uniform buffer
- 重绑定纹理、采样器
- 在 D3D12/Vulkan 后端,可能触发 PSO(Pipeline State Object)查找或创建
这些操作是几百 ns 到几 μs 量级。一帧 1000 个不同 Material 的 SetPass 就可能吃掉 1-3 ms 的 CPU 时间——这在 60 FPS 预算(16.7 ms)和 120 FPS 预算(8.3 ms)下是不可接受的。
SRP Batcher 把”切换 Material”压缩为”切换 CBUFFER offset”——CBUFFER 已在 GPU 端持久化,CPU 只需提交一个轻量指令。这是为什么相同 Shader、不同 Material 的对象数量级再大都不会成为 CPU 瓶颈。
7.2 SRP Batcher vs GPU Instancing 收益模型对比
| 维度 | SRP Batcher | GPU Instancing |
|---|---|---|
| 减少 Draw Call 数量 | 不减少 | 大幅减少(N→1) |
| 减少 SetPass 数量 | 大幅减少 | 部分减少 |
| 减少 CPU driver 开销 | 主要收益 | 次要收益 |
| 减少 GPU 命令处理开销 | 轻微 | 主要收益 |
| Mesh 必须相同 | 否 | 是 |
| Material 必须相同 | 否 | 是 |
| Shader Variant 必须相同 | 是 | 是 |
| 典型适用场景 | 多样的中量级对象 | 大量同款实例 |
这两个系统不是替代关系——一个项目通常 SRP Batcher 覆盖 80% 的对象,GPU Instancing 处理剩余的密集实例(草、子弹、UI、特效)。
7.3 实践原则
- 第一原则:保证所有自定义 Shader 兼容 SRP Batcher。这是免费的性能。Inspector 中的兼容性状态应该是开发期 daily check。
- 不要运行时改 Material 属性。需要动态变化时一律用
MaterialPropertyBlock——它会让对象退出 SRP Batcher 但进入 GPU Instancing 路径,性能不会塌方。 - 大量实例对象(>50 个同款)显式启用 GPU Instancing。植被系统、粒子系统、UI 列表都属于这一类。
- LOD Cross-Fade 永远用 dithered 模式——保留 instancing 能力。
- Stats 面板的 SetPass calls 是项目体检指标。移动端目标 < 50,主机端 < 200。超出阈值优先排查 Shader 变体爆炸或 MaterialPropertyBlock 滥用。
- SRP Batcher 不是银弹。Shader Variant 数量爆炸(过多 multi_compile)会让 batch 在 variant 边界断裂——这一点会在 Note 8(工程架构)展开讨论 keyword 收敛策略。
后续每篇笔记的 Pass 实现都默认遵循上述原则。当某个 Shader 看似简单但渲染慢时,第一反应应该是:检查它的 SRP Batcher 兼容性。
关键 API 速查
CSHARP
1 | |
HLSL
1 | |