最近处理一个小游戏项目时遇到的情况不是“Draw Call 太高”而是“Draw Call 不高却依旧卡”。UI 面板一打开就掉帧Profile 里能看到大量 Renderer 提交集中在 SetPass、材质常量上传这类环节上。这类问题对小游戏尤其棘手小游戏环境跑在 WebGL 上CPU 预算比原生应用更紧渲染状态切换的代价被放得很大。于是我把跟 SRP 有关的合批机制重新梳理了一遍把容易记混的规则、最常见的断批现场以及快速定位的思路整理成了一份工作笔记。这篇内容就是按这个思路展开的适合做技术美术的同行、小游戏项目主程或者刚接触 URP/SRP 想搞清楚合批原理的开发者。我不会只讲“要开 SRP Batcher”这种结论而是把四种合批的代价、Shader 兼容条件、以及我在实际场景里踩过的坑一起说清楚。1. 小游戏为什么对“合批”这么敏感先算清本项目的账单1.1 16ms CPU 预算里Draw Call 到底吃掉了什么在做小游戏性能优化的时候我习惯先把预算拆成两半渲染线程的 CPU 时间和游戏逻辑的 CPU 时间。小游戏容器普遍是 WebGL 环境Draw Call 增多时CPU 要做的工作并不仅仅是“多提交一条绘制指令”还包括维护 Command Buffer把每个 Renderer 的网格、材质、变换矩阵写入录制流为每个材质准备常量缓冲区哪怕颜色没变只要这个 Renderer 是独立提交的引擎也要检查一遍在 WebGL 的 JS 与底层图形驱动之间传递 Buffer 数据这部分跨层通信的开销远高于原生应用。很多同学看 Draw Call 数字没有破百觉得没问题但如果渲染线程一帧花了 14ms再叠加逻辑线程的 6ms帧率立刻就崩了。小游戏场景里合批不是为了“让数字好看”而是为了把 CPU 侧的重复状态上传压下去。1.2 “DC 不高但很卡”的典型状态我在项目里见过一种非常典型的状态Renderer 数量大概一两百个Draw Call 在 80 上下按理说移动端完全扛得住但真机帧率只有 30fps 上下波动。用 Frame Debugger 一看一堆网格使用的是同一个 Shader材质却是一个物体一个材质实例。表面上看只有 80 个 Draw Call实际上每帧要为 80 个材质实例分别上传“看似相同”的常量数据。有些 Shader 里的_BaseColor明明一模一样但因为材质对象不同引擎仍然无法复用缓存。这里就是一个容易踩的误区合批不是只看最终提交了几条绘制命令还要看渲染状态有没有被引擎缓存下来。小游戏的 CPU 更弱跨层通信更贵所以这种“伪合批”造成的开销会被放大得特别明显。想清楚这一点之后我再看 Frame Debugger 里的 DrawCall 列表就不会只数格子了而是重点观察相邻两个格子之间有没有出现SetPass、BindConstantBuffer这类状态切换。出现得越频繁说明合批的缓存效率越差。2. SRP 下四种“合批”各有代价别把它们混为一谈我把合批方式分成四类记忆静态合批、动态合批、GPU Instancing、SRP Batcher。它们的名字里都带“批”但底层逻辑完全不同代价也不一样。混在一起记项目里十有八九会出问题。2.1 静态合批用内存换 CPU构建期打包静态合批是最“物理”的一种方式。Unity 在构建或运行时会把标记为Static且使用相同材质的网格合并成一个大网格之后提交时只提交一次顶点数据。它的优点是简单粗暴对 CPU 和 GPU 都很友好。缺点也很明显内存/显存占用会增加因为合并后的网格与原始网格会同时存在加载时间变长小游戏包体体积也可能明显变大所有静态物体一旦合并就失去了独立变换的能力。在小游戏场景里我一般不把静态合批当作首选。小游戏对包体和内存的预算卡得很死静态合批很容易在内存上偷家。但如果是完全不动的建筑物、山体、路面这类场景并且网格的三角面数不算夸张静态合批仍然有位置属于“以空间换时间”的典型操作。2.2 动态合批最容易触发也最容易失效动态合批是把多个小网格在运行时临时合并。它的触发条件非常苛刻我将它总结为网格必须标记为可读不能是只用于渲染的压缩网格单个网格的顶点数不能超过阈值通常按顶点属性组合算大致在 300 到 900 顶点之间多个对象必须使用同一个材质实例或者使用兼容的材质参数对象的缩放不能为负值有些平台还会限制缩放值的范围。动态合批的代价是CPU 每帧都要把多个网格重新组合成一个大网格并上传。在小游戏这种 CPU 预算本来就紧张的环境里动态合批反而可能成为性能瓶颈。我的建议是别在小游戏场景里靠“开动态合批”来救帧率它更适合对象数量少、顶点数极低、物体移动频繁但不希望重新合批的场合。2.3 GPU Instancing同人同台才能批量复制GPU Instancing 适合大量“同网格、同材质”的物体比如草丛、小碎石、路灯、椅子。它不是把网格合并到一个批次里而是通过一次 Draw Call 提交多个实例的变换矩阵和每实例数据。使用条件也很明确网格必须相同材质必须相同并且 Shader 要开启 instancing 支持每实例数据只能放在 instancing 专用的 buffer 中。Shader 里需要加上类似这样的声明#pragma multi_compile_instancing UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float4, _InstanceColor) UNITY_INSTANCING_BUFFER_END(Props)在 URP 的 Lit Shader 里通常已经内置了 instancing 支持直接勾选材质面板上的Enable GPU Instancing就能用。小游戏场景里我把 Instancing 排在了静态合批前面因为它能省 CPU也不像静态合批那样吃掉大量内存。唯一麻烦的是美术经常会给同一批物件做“随机颜色”或“随机缩放”这些参数如果没有放进 instancing buffer而是用每个材质实例去改Instancing 也会失效。2.4 SRP Batcher不是合 mesh而是“缓存状态”SRP Batcher 是 SRPURP/HDRP里非常关键的机制也是最容易被误解的一个。它不会把网格真的合并在一起而是把材质属性放到 GPU 侧的一块大常量缓冲区里。当渲染管线反复遇到同一个 Shader、同一套 Shader 变体时只要材质属性没变化就能直接复用之前绑定好的状态省掉一长串 CPU 设置。它的核心收益是减少 CPU 为“切换渲染状态”付出的时间。Draw Call 数量可能没怎么降但每一个 Draw Call 的开销变低了。这正好对应我前面说的“DC 不高但很卡”的场景——SRP Batcher 能直接消除大量SetPass和常量上传。在小游戏项目中我通常把 SRP Batcher 当作优先项。Shader 只要符合兼容规则、渲染路径走 URP开启后基本没有副作用不用像静态合批那样惦记内存也不像动态合批那样受顶点数限制。四种方式放在一起对比更清晰合批方式合的是什么主要代价小游戏适用建议静态合批多个网格合并为一个网格内存、包体、加载时间只在确需构建期合并时用动态合批每帧临时合并小网格CPU 重组与上传慎用顶点数极低再看GPU Instancing一次绘制多个实例要求同网格同材质优先用于重复物件SRP Batcher缓存材质属性与渲染状态需要 Shader 兼容默认开启收益最稳定3. 我在看 Shader 时反复确认的 SRP 合批兼容规则3.1 UnityPerMaterial 常量缓冲里别放引擎属性SRP Batcher 兼容的 Shader 有个硬性要求材质属性必须放在名为UnityPerMaterial的CBUFFER里。URP 的 Lit Shader、ShaderGraph 生成的 Shader 默认都是兼容的但手写 Shader 漏写CBUFFER_START就会立刻失效。正确的写法类似这样CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _BaseMap_ST; CBUFFER_END如果属性写在外面float4 _BaseColor; // 没放进 CBUFFERSRP Batcher 不认SRP Batcher 看到这种 Shader会直接把该材质标记为不兼容之后这个材质的 Renderer 就退回普通提交路径。我见过有人把顶点相关的引擎属性也塞进UnityPerMaterial比如_ObjectToWorld这类由引擎维护的内容。这并不正确SRP Batcher 需要的是材质属性引擎属性应该交给 SRP 的固有常量缓冲处理。塞错了不会编译报错但可能增加常量缓冲的体积拉低缓存命中率。3.2 多 Pass 一出现所有批处理都会绕着走SRP Batcher 对 Shader 的一个硬性要求是每个 Pass 都不能有多余的“引擎级状态”冲突而且 Shader 内部尽量不要出现多 Pass 结构。为什么多 Pass 会断批因为管线要在一个对象上执行多个 Pass后一个 Pass 会依赖前一个 Pass 结果渲染顺序被强制锁定邻接的 Renderer 没法做状态归并。项目里最常见的是边缘光效果有些 TA 喜欢用第二个 Pass 做全屏描边或者是老项目里为了兼容不同平台写了两个 Fallback Pass。在小游戏场景里如果这类材质出现在场景中央或 UI 附近往往会导致一大片区域失去合批能力。我看到这种情况时会先问一句这个多 Pass 能不能改成单 Pass 的双面渲染或者用后续处理代替如果不行至少要把多 Pass 材质的数量控制住不要让它零星地穿插在主要 Batch 之间。3.3 MaterialPropertyBlock它不背全部锅但会打乱缓存很多人一提到合批失效就怪 MaterialPropertyBlockMPB说“用了 MPB 一定断批”。这个说法不完全准确。MPB 用于给同一材质的多个 Renderer 设置不同的每物体属性比如草地颜色、树冠偏移。在 GPU Instancing 的 Shader 里MPB 可以作为实例数据传递并不必然导致断批。真正的问题是MPB 会把属性变成“每对象可变”这会让 SRP Batcher 很难继续复用统一的常量缓冲。因为 SRP Batcher 的缓存建立在整个材质“属性整体未变”的基础上一旦存在每对象数据引擎就要针对这个 Renderer 单独更新 buffer 内容命中率就下来了。所以在小游戏项目里我的原则是MPB 能用但不要滥用。如果只是想让 100 个小石头颜色略有差异正确做法是把颜色放进 instancing buffer用 Shader 里的UNITY_ACCESS_INSTANCED_PROP读取而不是给每个石头单独塞一个 MPB。这样既保留了随机差异又不会把渲染路径打回“逐对象设置”的老路。4. 小游戏场景里最容易“断批”的五个现场4.1 装饰小物件材质相同却颜色不同我曾把一个主城场景里的几十个石头摆件认为是“绝对能合批”结果 Frame Debugger 里显示每个石头一个批次。查了半天发现石头用的是同一个网格、同一个材质但美术在 Inspector 里给每个石头赋了一个材质实例并改了_BaseColor。这种情况最坑的是视觉上完全看不出来面板里都是同一套参数实际上引擎持有几十个材质实例。解决方式也很直接把颜色差异从“创建多份材质”改成“一份材质 每实例数据”。在 URP 里使用 ShaderGraph 时把颜色属性设为Instanced属性或者手工 Shader 里写入 instancing buffer就能保留每对象差异同时继续合批。4.2 半透明与排序UI 的 Canvas 一断整堆卡都在等小游戏的 UI 通常走的是 Canvas 合批体系但 UI 里一旦掺入半透明 Mesh、粒子或 RawImage 的特殊 ShaderCanvas 本身的合批结构会被打断。更麻烦的是半透明渲染需要按距离/深度排序一旦排序顺序被打乱相邻元素之间的状态切换就会激增。我在实际项目里见过一个签到面板背景图、按钮、光效粒子都堆在同一个 Canvas 下。光效粒子用了AdditiveShader按钮的些微透明效果又来自不同的材质整个 Canvas 被拆成七八段。看起来 UI Draw Call 只有 20 多但帧率掉得很明显。处理思路是把粒子特效单独提到一个 Canvas 之上且保持他们自己的批次类型不要为了省一个 Canvas 的创建开销把太多不同材质的东西硬塞进同一个界面容器里。4.3 植被与粒子看起来适合 Instancing却都用了 MPB植被是 Instancing 的经典对象但小游戏场景的植被经常被做成“同一个网格、同一个材质然后用脚本随机翻转缩放”。翻转缩放如果是在 Transform 上做问题不大Instancing 本身会处理不同矩阵。真正的问题是很多人用 MPB 去设置风力偏移或颜色变化而且脚本每帧都在改。每帧更新 MPB意味着每帧都要刷新常量数据SRP Batcher 缓存直接失效。小游戏里这种随风摆动的草应该把风力参数放到全局变量里在 instancing 的 Shader 里用_WindStrength这种全局值计算而不是对一个一个草块做 MPB 刷新。4.4 场景静态物体勾完 Static 就以为合批了勾上Static不等于自动合批。静态合批的前提仍然是材质相同、Shader 相同、顶点属性布局相同。很多项目里美术建了 20 栋房子看起来都用了同一个材质球但有一部分房子用了 Atlas 贴图另一部分用了独立贴图结果静态合批只合了其中一个集合。另一个常见问题是把不规则的动态物件也勾上了Static。一旦物件在运行时被移动父节点静态合批的数据就失效了引擎甚至会在帧中断里重建批量网格造成瞬时卡顿。所以我建议在小游戏项目里静态标志只勾固定的关卡物件动态人物、可交互物件一律不勾。4.5 相机与阴影渲染顺序和绘制状态互相拉扯小游戏场景如果开了实时阴影阴影贴图渲染阶段会先执行一遍 ShadowCaster Pass这会增加一批额外提交。部分材质如果不希望参与阴影却没有关闭Cast Shadows就会白白生成一批阴影批次。我见过一个极端例子一个装饰小物件区域物体本身只有 30 个批次阴影渲染却额外产生了 30 个批次还因为每个物体的包围盒大小不同阴影的绘制顺序没法做状态归并CPU 开销直接翻倍。在小游戏项目里如果不是特别需要动态阴影我会把大部分场景物体的Cast Shadows设为Off只保留主要建筑和地面的阴影。这样能做阴影的物体数量下降合批的稳定性会明显提高。5. 我的记忆方法把合批条件压缩成一句可执行的顺口溜5.1 “同材质、同变体、同Mesh、不块、不多Pass”做了几个项目之后我给团队整理了一套非常粗糙但有效的记忆口诀就五个词同材质、同变体、同Mesh、不块、不多Pass。同材质不是“看起来一样”的材质而是同一个材质对象或者同一套属性值同变体Shader 的关键字、Pass、RenderQueue 保持一致差别大的变体会从 SRP Batcher 的兼容列表里掉出去同MeshInstancing 和静态合批都要求网格结构一致顶点属性布局不同也没法合不块不要乱用 MaterialPropertyBlock 逐帧刷新确实需要每对象属性就走 instancing 每实例数据不多Pass多 Pass Shader 是断批大户能改单 Pass 就改单 Pass。这套口诀不是严格的“充分必要条件”但它能在出问题时快速提醒我哪里可疑。每次 Frame Debugger 里看到相邻批次之间出现状态切换我就按这五个词从头查一遍。5.2 对照表查吧断批时的排查顺序我实际排查时会按下面这个顺序走一遍先看 Frame Debugger确认是否开启了 SRP BatcherURP Asset 的SRP Batcher选项是否勾上检查材质面板是否每个 Renderer 都被偷偷换成独立材质实例检查 Shader 是否兼容有没有CBUFFER有没有多 Pass检查脚本有没有每帧调用SetPropertyBlock检查网格有没有超过动态合批顶点数或者网格不是可读状态最后看渲染顺序半透明物体之间是不是被强制排序。这个顺序对我很有效因为大部分问题出在第 2 步和第 4 步而往往我们一开始会怀疑第 5、6 步是元凶。6. 最后分享我这套梳理在小游戏主城场景里的落地结果6.1 排查前看起来都“合批了”实际上卡在 UI 和装饰物我负责的项目是一个小游戏主城场景画面里有一片石头路、路灯、小树、NPC再加上顶部的一排 UI 按钮。排查前我打开 Frame Debugger看到 Draw Call 58觉得还行。但 Profile 显示渲染线程每帧要花 9ms 到 11ms一些旧的 Android 机器直接掉到 25fps。按上面的思路排查后主要问题有三个一堆路灯和小石头被脚本设置了 MPB 颜色导致 SRP Batcher 命中率很低树干和石头混用了同一个材质球的不同实例但只有贴图不同原本可以用 Atlas 和共用材质解决UI 的签到面板里放了一个特效 Mesh把 Canvas 批次切成三段。6.2 改动与测量DrawCall 没有少一半但 P95 帧耗时降了改动方案其实很简单把主城装饰物改成“一个材质球 instancing 属性”颜色差异改用UNITY_INSTANCED_PROP传入把树干和石头的贴图合并到一张 Atlas统一使用同一个 URP Lit 材质把签到面板里的特效 Mesh 移出原 Canvas放进一个独立 Canvas 并设置Sorting Order。改完后 Draw Call 从 58 降到了 41数字降幅不算夸张但渲染线程耗时从 10ms 降到了 5ms 左右。原因就是状态切换大幅度减少很多批量格子之间的SetPass消失了。小游戏的真机帧率也从 25fps 提回了稳定的 40fps 上下。6.3 沉淀下来的小游戏合批规范经过这个项目我把规则固化成了几条很具体的约束装饰类重复物件统一用 Instancing颜色、缩放差异全部设计成 instancing 属性场景物件能不创建独立材质实例就不创建实在要差异就用贴图集或顶点色UI 内部不放 Mesh 渲染粒子特效放到独立 Canvas并控制其和 UI 图层的遮挡关系动态阴影只开在主建筑和角色上装饰物一律关闭Cast Shadows手写 Shader 一律检查CBUFFER_START(UnityPerMaterial)避免多 Pass避免在材质球上逐帧刷新属性。这些规则听起来平平无奇但真正逐条落地之后小游戏场景的渲染性能会稳定很多。SRP 的合批问题说到底不是“背几个函数名”就能解决的关键是要理解每种机制背后的代价然后把它变成项目里的资源规范。我在实际项目中最大的体会是合批优化不能等到 QA 反馈卡顿再做应该在资源制作阶段就控制好材质、网格和 Shader 的使用方式。先把源头管住后面调起来会轻松得多。