去年帮朋友调一个 URP 手游项目,场景里铺了几千个物件,渲染线程 CPU 时间能吃到 22ms,Draw Call 超过 3000。我第一反应是合贴图、砍材质,忙活一阵把 Draw Call 降到了 600 多,然后死活再也降不动。翻 Frame Debugger 翻到眼花,发现场景里有大量物体没有进入 SRP Batch,每一个都单出一条 Draw Call,而这些物体用的明明是同一套 URP/Lit shader。再往下查,问题出在一个表现层脚本上——它用 MaterialPropertyBlock 给物件改颜色。这个 API 单次用起来没什么感觉,一旦成批出现,SRP Batcher 基本等于被你亲手关掉了。
这篇文章就聊透一件事:MBP(MaterialPropertyBlock)在 URP/HDRP 下为什么能废掉 SRP Batcher,以及在不牺牲合批的前提下,有哪些可落地的替换方案。适合正在做移动端 URP 优化的朋友,也适合那些被 Frame Debugger 里一堆"普通 Draw"困扰的人。先说不幸的消息:MBP 和 SRP Batcher 天生不兼容,没有黑科技能让它俩共存;再说好消息:大部分用 MBP 的场景其实都有更好的替代做法,下面逐个拆。
1. SRP Batcher 的合批逻辑:CPU 到底省在了哪里
1.1 传统绘制路径的 CPU 开销在哪儿
在渲染管线的 CPU 侧,每画一个物体都要做一堆状态设置:绑定 shader、绑定贴图、绑定混合状态、上传材质参数。这些工作在传统的逐物体绘制里没法避免,尤其是"上传材质参数"这一步,每个物体都要从 CPU 拷贝数据到 GPU。哪怕场景里一百个物体用的是同一个 shader、同一张贴图,只要材质参数不同,传统路径也要逐个设置一遍,没有任何缓存可言。
这就是 Draw Call 开销的本质。Draw Call 数量只是一层表象,真正的成本是 CPU 在 GPU 命令流里反复做状态切换。所以做性能优化的时候,光看 Batcher Calls 几千几千的没意义,关键看有多少次重复的状态设置。一个极端情况是:你用到了 GPU Instancing,Draw Call 只有几十个,但 CPU 端如果还在逐物体做裁剪、提交矩阵、转换数据,瓶颈照样存在。
1.2 核心机制:把材质参数集中上传
SRP Batcher 的思路和传统路径不一样。它把材质属性统一整理成一块一块的 CBUFFER(常量缓冲区),然后连续绘制时,只更新当前材质对应的小 buffer,而不是整个 shader 状态都重新设置一遍。只要两个物体用的 shader 完全相同,哪怕材质不同也没关系——SRP Batcher 可以在它们之间快速切换,CPU 端的设置成本被压到很低的水平。
配合 URP/HDRP 的 shader 升级,SRP Batcher 让同一 shader 下的海量物体能以极少的渲染状态设置完成绘制。在 Frame Debugger 里你会看到 SRP Batch 的条目,点进去能看到这一个批次里塞了几十个物体。这个合批不要求相同贴图,也不要求相同材质,只要求 shader 兼容 SRP Batcher。这一点很多人会误解,以为"不同材质就不能合批",实际上在 SRP Batcher 体系里材质差异恰恰是被 CBUFFER 机制消化掉的。
1.3 兼容边界在哪里
它不是全能的。一个 shader 要兼容 SRP Batcher,材质属性必须全部声明在UnityPerMaterial这个 CBUFFER 里;如果某个属性游离在 CBUFFER 外面,shader 就挂不上 SRP Batcher。另外一条关键规则是:使用 MaterialPropertyBlock 的渲染器不会被 SRP Batcher 处理。这条规则是官方明确写的,也是本文一切讨论的起点。
所以判断一个 shader 是否能吃到 SRP Batcher 红利,不需要看它有多复杂,只需要确认两点:属性是否都进了 CBUFFER,以及是不是有渲染器在上面挂了 MBP。第二点通常在代码里,排查起来比第一点隐蔽得多。
2. MBP 为什么会成为 SRP Batcher 的例外名单
2.1 MBP 的设计初衷是省内存
MaterialPropertyBlock 解决的是老问题:你有一百个物体共用一份材质,想给其中几个改颜色,又不想 new 出一百份材质实例。这个思路本身没问题,MBP 的内存占用确实低,它只是给 Renderer 挂了一个轻量的属性覆盖层。在 Built-in 渲染管线里,配合动态合批或 GPU Instancing,它有时候还挺好用。
问题出在 URP/HDRP 的 SRP Batcher 体系里。很多项目是从 Built-in 管线升过来的,代码里大量SetPropertyBlock直接搬进 URP,结果性能反而不升反降,就是这个隐藏点的典型体现。
2.2 引擎视角:MBP 的数据不在 CBUFFER 里
SRP Batcher 批量上传的前提是:所有材质属性都在材质对应的 CBUFFER 里,而且引擎可以从 CBUFFER 里直接读取它们。但 MBP 的属性不是挂在 Material 上的,而是挂在 Renderer 上的覆盖数据。引擎想在合批时把这块数据并入统一的常量缓冲区,做不到。于是它的选择很干脆:把带 MBP 的渲染器直接从 SRP Batcher 路径里踢出去,让它走传统的逐物体设置路径。
一个很反直觉的点是:你通过 MBP 设置的属性哪怕只有一个,而且这个属性跟合批判断根本不相关,引擎也不会再让它进 SRP Batch。MBP 像是一张不兼容标签,一旦贴上,Renderer 就失去了 SRP Batcher 的入场券。这也是为什么很多人在 Frame Debugger 里看到"明明同一个 shader,突然有一个物体就掉出去了"。
2.3 打断合批的真实表现:局部掉批,但很痛
要澄清一个常见误解:一个带 MBP 的物体,并不会让整个场景的 SRP Batcher 全部失效,其它不带 MBP 的物体依然能合批。真正的问题是量——如果场景里几十上百个物体都挂了 MBP,它们就全部单独掉一条 Draw Call,合批后的优势被摊薄得所剩无几。再加上 URP 渲染顺序里,SRP Batcher 兼容物体和不兼容物体会反复切换,状态缓存的命中率也会受影响。Frame Debugger 里看到的现象就是:SRP Batch 条目寥寥无几,旁边挤着一大串普通 Draw。
除了掉批,还有一层副作用容易被忽略:老路径在 CPU 端要给每个物体重新排列 shader 状态、贴图状态,这些操作在移动端会被进一步放大。所以"就几个物体用了 MBP"未必是小事,要结合项目运行的硬件平台来判断。
2.4 实战中最容易被滥用的场景
- 对象池里的子弹、敌人受击变红、攻击状态切换
- 拾取高亮、选中描边变色
- 血条、进度条的颜色渐变
- 大面积植被、碎石的随机颜色分布
- 人物装备的稀有度染色
这些场景本身都很常见,第一反应大多是"用 MaterialPropertyBlock 改个属性就好了"。但从合批角度看,这些恰恰是需要重点设计的对象,不能随手就上 MBP。
3. 排查实录:从 Frame Debugger 到定位 MBP 的完整链路
3.1 现象:同一个 shader,Draw Call 却降不下来
我当时调的 URP 项目里,所有物件用的都是 URP/Lit 或其变体,理论上应该能吃到大量 SRP Batch。但 Frame Debugger 打开后,批处理列表里一大半是普通 Draw,SRP Batch 只有可怜的几个。一个自然的验证方法是:在 URP Asset 里把 SRP Batcher 先关掉再看 Frame Debugger,如果关掉前后的 Draw Call 数量几乎没变化,说明这个场景本来就没吃到多少 SRP Batcher 红利。
如果还不好确认,可以开 Profiler 的 Rendering 模块,搜SRP:SRP Batch之类的标记,看看 SRP Batch 的耗时占比。数值低得可怜的时候,基本可以断定大批物体走了非 SRP Batcher 路径。
3.2 Frame Debugger 里对比兼容对象和不兼容对象
在 Frame Debugger 中随便点一个非 SRP Batch 的 Draw Call,右侧面板会显示这个 Renderer 对应的 shader、材质、贴图等信息。重点看它和被 SRP Batch 合批的物体之间差了什么。大多数情况下 shader 是完全一致的,就差在是不是挂了 MaterialPropertyBlock。
另一个直观的对照方式是:把场景里所有 MBP 清空,重新跑一帧,看 Frame Debugger 里的 SRP Batch 条目是不是立刻多起来。如果是,基本实锤是 MBP 的问题。
3.3 用脚本一键扫描场景里的 MBP 使用者
场景稍大之后,靠肉眼找脚本不现实,直接写个 Editor 工具扫一遍最省事。Unity 提供了一个很方便的 API:Renderer.HasPropertyBlock()。
using UnityEngine; using UnityEditor; public static class MbpScanTool { [MenuItem("Tools/Find Renderers With MBP")] public static void Scan() { Renderer[] allRenderers = Object.FindObjectsOfType<Renderer>(true); int count = 0; foreach (Renderer r in allRenderers) { if (r.HasPropertyBlock()) { Debug.Log($"发现 MBP: {r.gameObject.name}", r.gameObject); count++; } } Debug.Log($"共有 {count} 个 Renderer 使用了 MaterialPropertyBlock"); } }这段代码会遍历场景中所有 Renderer,找出挂过 MBP 的对象。扫描结果出来之后,逐个检查是什么脚本在SetPropertyBlock,再决定怎么处理。这里建议在真机或最接近发布的场景里扫,因为 Editor 下有时会因为 Gizmos 或编辑器辅助物体导致误判。
3.4 顺手检查 shader 本身的 SRP Batcher 兼容性
有些项目是 shader 本身就不兼容 SRP Batcher,跟 MBP 无关。要区分这两者,可以在材质面板上点击 shader 的Inspector,查看是否有 "SRP Batcher Compatible" 的标识。如果没有,优先解决 shader 的 CBUFFER 声明问题,否则你把 MBP 全清了合批也起不来。
排查工具和观察点整理成一张表:
| 工具 | 看什么 | 怎么判断 MBP 问题 |
|---|---|---|
| Frame Debugger | Draw Call 类型 | SRP Batch 少,普通 Draw 多 |
| Profiler Rendering | SRP Batch 耗时 | 耗时占比远低于预期 |
| Renderer.HasPropertyBlock 脚本 | 场景 MBP 分布 | 大量 Renderer 挂了 MBP |
| Shader Inspector | SRP Batcher 兼容标记 | 标记缺失说明 shader 本身不兼容 |
4. 可落地的替换方案
4.1 数据入 Mesh:用顶点色和 UV 通道承载变化
很多需要"改颜色"的物体,其实颜色是固定的,只是每颗石头、每根草的颜色不同。这种场景完全不需要运行时脚本去做属性设置,直接烘焙进网格数据里就可以了。
做法很简单,美术在 DCC 软件里把颜色刷到顶点色里,shader 里把顶点色乘上基础色输出。Shader 里大概是这样:
half4 albedo = tex2D(_BaseMap, uv) * _BaseColor; albedo.rgb *= v.color.rgb; // 顶点色叠加如果用 Shader Graph,就用 Vertex Color 节点乘 Base Color,输出到 Albedo 即可。这种做法的好处是零运行时开销,网格数据加载进来是什么样就是什么样,而且完全不影响 SRP Batcher 合批。
一个容易忽略的坑:有些模型导入设置会勾选顶点颜色压缩,或者美术导出时把顶点色丢了,导致运行时颜色不对。排查的时候先在 Mesh Inspector 里确认顶点色数据确实存在,再谈 shader 部分。
如果不想动顶点色,还有一种变体:用 UV 通道存一个颜色索引,shader 里查 LUT 贴图。适合几百个物体需要几百种颜色、但不想给每张贴图都单独做纹理的项目。缺点是美术管线要多一步 UV 约定,而且 shader 要多一次纹理采样。
4.2 改用 Material 实例:SRP Batcher 是允许不同材质合批的
很多开发者第一反应是"改了材质颜色,合批就没了"。这个说法在传统合批里成立,但在 SRP Batcher 体系下不成立。SRP Batcher 兼容的 shader 会把每个 Material 的属性放在各自的 CBUFFER 里,绘制时顺序切换 buffer 成本很低,所以不同 Material 只要 shader 相同,依然可以被 SRP Batch。
所以如果你需要的只是"给一部分物体改颜色",最直接的替代方案是创建几个 Material 实例:
public class ColorChanger : MonoBehaviour { private Material _mat; private void Awake() { Renderer r = GetComponent<Renderer>(); _mat = new Material(r.sharedMaterial); r.sharedMaterial = _mat; } private void OnDestroy() { if (_mat != null) Destroy(_mat); } public void SetColor(Color c) { _mat.SetColor("_BaseColor", c); } }需要注意OnDestroy里的Destroy(_mat),这步很容易漏。创建出来的材质是实例,如果不销毁,场景卸载时会有泄漏。
这种方案的价值在于:内存换合批。MBP 的优势本来是不创建额外材质实例,但代价是丢掉 SRP Batcher;如果你要合批,材质实例的开销就得认。不过材质实例的额外内存其实比想象中小——每个实例主要多一份属性缓冲,纹理对象还是共享的。几十个实例通常问题不大,几百个就要慎重。
4.3 大型同模场景:显式 Instancing 绕开 SRP Batcher 体系
如果物体数量上到几百上千,而且它们共用同一个 mesh 和 material,比如草、碎石、装饰物,那再适合走Graphics.DrawMeshInstanced这一路。它完全不经过 Renderer 组件,也不经过 SRP Batcher,而是直接向 GPU 提交一批矩阵和 per-instance 数据,由 GPU 端实例化绘制。
代码结构大致是这样:
using UnityEngine; public class BatchGrass : MonoBehaviour { public Mesh mesh; public Material material; public int instanceCount = 500; private Matrix4x4[] matrices; private MaterialPropertyBlock mpb; private Color[] colors; private void Start() { matrices = new Matrix4x4[instanceCount]; colors = new Color[instanceCount]; mpb = new MaterialPropertyBlock(); for (int i = 0; i < instanceCount; i++) { Vector3 pos = Random.insideUnitSphere * 10f; Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f); Vector3 scale = Vector3.one * Random.Range(0.8f, 1.2f); matrices[i] = Matrix4x4.TRS(pos, rot, scale); colors[i] = Color.Lerp(Color.green, Color.yellow, Random.value); } } private void Update() { mpb.Clear(); mpb.SetColorArray("_BaseColorArray", colors); Graphics.DrawMeshInstanced(mesh, 0, material, matrices, instanceCount, mpb); } }重点提醒:shader 必须支持 GPU Instancing,而且 per-instance 属性要用 instancing buffer 声明,否则颜色数组传进去不会被实例化地读取。这种方式把大量物体的 CPU 开销压到接近一个 Draw Call 的水平,Transform 组件、Renderer 组件都不需要挂载,内存占用也低。
如果物体更新策略更复杂、需要裁剪或 LOD,可以考虑RenderMeshIndirect加 ComputeBuffer 自己提交 Instance Data,但复杂度会明显上升。核心思路一样:不要走 MBP,走显式数据提交。
4.4 折中:把 MBP 数量压到可接受范围
不是所有 MBP 都必须立刻消灭。如果全场景只有三五个 MBP 物体,带来的额外开销通常可以接受。我一般建议团队做这样几个动作:
- 统计 MBP 对象总数,超过 20 个就开始警惕
- 确认 MBP 没有在
Update里每帧调用,初始化时设一次就够了 - 如果只是临时高亮,用完立刻清掉 MBP,而不是让对象一直挂着
对需要每帧更新颜色、又只有少数对象的血条,保留 MBP 反而是性价比最高的选择——你为这少数几个对象付出的 CPU 成本,远比新增一大堆材质实例来的划算。优化的目标是整体收益,不是教条式地清零 MBP。
5. 方案选型和容易翻车的细节
5.1 四种方案怎么选
| 方案 | 合批方式 | 使用 MBP | 内存 | 适用规模 | 动态改属性 |
|---|---|---|---|---|---|
| 顶点色/UV 烘焙 | SRP Batcher | 否 | 低 | 中大 | 不支持 |
| Material 实例 | SRP Batcher | 否 | 中高 | 小到中(几十) | 支持 |
| DrawMeshInstanced | GPU Instancing | 可配合 | 低 | 大(同 mesh) | 支持,不建议每帧 |
| 少量 MBP | 无合批 | 是 | 最低 | 少量(<20) | 支持,但避免每帧 |
选型的核心判断依据,第一是"这个颜色到底动不动",第二是"对象数量有多少",第三才是"团队能不能接受额外的材质实例"。
如果是完全静态的场景装饰,优先走顶点色烘焙;如果数量不多但确实要动态变色,Material 实例最稳;如果是同模海量物体,GPU Instancing 才是正解;如果只是零散几个 UI 相关染色,保留 MBP 也问题不大。
5.2 翻车细节:renderer.material、材质销毁和属性名
renderer.material这个属性很坑。第一次访问它的时候,Unity 会在背后创建一个材质实例;如果你频繁访问、又和sharedMaterial混用,很容易出现"我明明没 new Material,内存里却多了一堆材质"的情况。替换方案里我刻意显式保存了_mat引用,就是为了避免这个隐式实例化。
材质销毁是另一个高频漏点。运行时new Material出来的实例,在对象销毁时如果不主动Destroy,会跟着场景一直驻留到卸载。项目里如果频繁生成销毁对象,材质泄漏会成为隐藏的内存杀手。
还有个容易让人怀疑人生的细节:MBP 设置的属性名如果压根不存在于 shader 的Properties块,Unity 不会报错,只是默默忽略。排查时如果你改颜色没生效,先确认属性名拼写和 shader 里完全一致,别急着埋怨合批。
5.3 我的建议决策流程
拿到一个"需要变色的物体"需求,我通常会按这个顺序问自己:
- 它的颜色是否在启动时就确定,之后不会再变? → 是,直接烘焙进顶点色或 UV。
- 它是否需要随时动态修改?数量是否超过几十个? → 少,用 Material 实例;多,考虑 Instancing。
- 它是不是和大量同类物体共用同一个 mesh 和 material? → 是,优先显式 Instancing。
- 它只是全场景里很少的几个特殊对象? → 用 MBP 顶着,但做好监控和清理。
这个流程不见得适合所有项目,但能帮你快速过滤掉 90% 的不合理 MBP 使用场景。
优化做到这一步,我最大的体会是:MBP 从来不是一个非黑即白的问题。它本身是很好的工具,只是被用错了场景。SRP Batcher 带来的收益远比大家想象的大,但也只有在彻底理解它的兼容边界、并把数据分流到正确的路径之后,你才能真正把移动端渲染性能压到极限。希望这篇把原理和实战串在一起的记录,能让你少走一点我当初走过的弯路。