Deep Read

性能优化-SRP合批

SetPass Call 为什么贵

CPU 提交一次绘制,真正贵的不是 Draw Call 本身,而是它前面的 SetPass Call——切换渲染状态的那一步。每次 SetPass,CPU 端要:

  1. 选择要使用的 Shader 变体(Variant),绑定对应的管线状态;
  2. 把这个材质的所有属性从 CPU 内存写入 GPU 的常量缓冲区(CBuffer)。

这一步之所以慢,是因为它走的是图形驱动的通用路径:驱动要校验状态合法性、重新组织资源绑定表,材质数据每次都要重新走一遍 CPU→GPU 的上传。场景里材质一多,即使共用同一个 Shader,传统管线也要为每个材质完整做一遍 SetPass。

SRP Batcher 要解决的就是这个:让使用相同 Shader 变体的多个材质共享一次 SetPass。注意它减少的是 SetPass Call,Draw Call 的次数并不变——只是每次 Draw 变得极其廉价。

核心思路:材质数据常驻显存

传统批处理(Static/Dynamic Batching,见 性能优化-静态合批性能优化-静态合批被标记为 Static、共享同一材质的物体,Unity 会在 Build 时把它们的网格预先合并成一套大的顶点/索引缓冲。运行时不再逐物体绑定缓冲、上传变换,而是一次绑定、连续绘制。 要理解它到底省在哪,得先看清 CPU 画一个物体要做几步。 画一个物体的三步 从图形 API(DX/GL)看,绘制一个物体分三步,开销依次递减: 设置渲染状态(SetPass)**:绑定 Shader、纹理、管线状态。走图形驱动的通用路径,要校验状态、重组资源绑定表,是三步里最贵的。 设置逐物体数据**:绑定这个物体的顶点/索引缓冲,把它的模型矩阵上传到 GPU 常量缓冲。 发起 Draw Call**:命令 GPU 按当前状态和缓冲画一次。 场景里成千上万个小物体,CPU 要为每个重复这套流程,提交本身就成了瓶颈。所有合批优化都是在削减这套重复流程里的某一步——分清砍的是哪一步,才知道每种合批解决的是什么问题。 静态合批砍掉的是第二步 前提要先说清:这批物体本来就共享同一材质。所以只要它们被连续绘制,渲染状态不变,SetPass 在合批前后都只有一次。静态合批省的不是 SetPass,而是)的思路是合并网格,把多个物体拼成一个大 Mesh 来减少 Draw Call。SRP Batcher 完全不动几何体,它从数据层面做批处理,依赖现代图形 API(DX11/12、Vulkan、Metal)的持久化常量缓冲区能力。

关键洞察是:材质属性其实很少变化——一个材质的 _BaseColor_Metallic 通常整个游戏过程都不变。SRP Batcher 把这些数据一次性上传到 GPU 上一块持久化的大 CBuffer(Mega Buffer)里常驻,之后只要材质没改动就不再传输,渲染时只需告诉 GPU 去哪个偏移取数据。

前提:Shader 里的数据分离

这套机制能工作,前提是 Shader 明确声明了"哪些数据属于材质、哪些属于物体、哪些属于每帧"。URP 的 Shader 把数据按更新频率划分到不同的 CBuffer:

  • UnityPerFrame:每帧不变的数据,如摄像机矩阵、时间、光照信息,每帧设置一次。
  • UnityPerMaterial:材质属性(颜色、PBR 参数等),SRP Batcher 收集的就是这一块。
  • UnityPerDraw:每个物体都不同的数据,最典型的是世界变换矩阵 unity_ObjectToWorld,每次 Draw 仍需单独设置。

在 URP 的 Shader 源码(如 Lit.hlsl)中能看到 UnityPerMaterial 的声明:

// 在 URP 的 Shader Include 文件中可以找到类似结构
CBUFFER_START(UnityPerMaterial)
    // 所有材质的非纹理属性都定义在这里
    float4 _BaseColor;
    float _Metallic;
    float _Smoothness;
    // ... 其他属性
CBUFFER_END

正是这种按更新频率的分离,让 SRP Batcher 可以"锁定"不变的 UnityPerMaterial 数据,每次绘制只用专门的快速路径更新 UnityPerDraw

工作流程

  1. 识别兼容性:渲染一帧时,URP 遍历所有可见物体,识别出使用相同 Shader 变体的材质。注意是变体、不是 Shader 资源——Keyword 组合不同(带不带法线贴图、开不开雾效)就是不同变体,不兼容。
  2. 构建 Mega Buffer:把所有兼容材质的 UnityPerMaterial 数据块依次填进 Mega Buffer。
  3. 渲染:一次 SetPass 绑定 Shader 和 Mega Buffer,随后连续发起多次 Draw,每次只更新物体自己的 UnityPerDraw 数据和材质数据在 Mega Buffer 中的偏移。

渲染循环的变化

997

生效条件

  • 相同 Shader 变体:材质属性可以不同(颜色、粗糙度各异),但 Shader 代码路径必须完全一致。
  • Shader 兼容 SRP Batcher:所有材质属性声明在 UnityPerMaterial 中,逐物体数据声明在 UnityPerDraw 中——漏一个属性在外面,数据布局无法保证一致,整个 Shader 直接不兼容。Inspector 里选中 Shader 可以看到 "SRP Batcher: compatible" 的标记。
  • 不能使用 MaterialPropertyBlock:MPB 的本意是在不产生材质实例的情况下逐物体覆写属性,但它绕过了 Mega Buffer 的数据布局,会直接让该物体退出 SRP Batch。想逐物体改颜色又保住合批,要么用材质实例,要么改用 shader 里基于实例 ID 的方案。
  • 粒子系统等程序化生成几何体的渲染不走 SRP Batcher 路径。

总结

SRP Batcher 的本质是把"每次绘制都重新上传材质数据"改成"材质数据常驻显存、绘制时按偏移引用":

  1. 核心目标:减少昂贵的 SetPass 状态切换,而不是减少 Draw Call 本身。
  2. 核心原理:数据批处理,而非几何批处理——它不合并网格。
  3. 代价与前提:Shader 必须按更新频率做严格的数据分离,且同批物体共享同一 Shader 变体。

在拥有大量不同材质、但共享 Shader 的场景(风格化植被、建筑群等)中收益最明显。