
静态合批这个词在 Unity 圈子里被提得太多了多到很多人把它当成一个“勾一下就行”的选项结果项目做完发现 DrawCall 没降多少内存反而涨了一截甚至场景里某些物件莫名其妙不显示了。我自己第一次接触 Static Batching 是在一个场景物件特别碎的项目里当时美术把整个场景拆成了上千个小网格帧率惨不忍睹我心想这不就是静态合批的典型场景吗勾上之后确实降了一些但随之而来的问题让我花了整整两天去排查。所以这篇内容我想把静态合批这件事从头到尾讲透它到底是什么、为什么能省性能、什么情况下用了反而亏、以及实际项目中怎么判断该不该用。不管你是刚接触 Unity 性能优化不久还是已经用过但踩过坑应该都能从下面这些内容里找到对你有用的部分。1. 静态合批到底在做什么1.1 从一次 DrawCall 的代价说起要理解静态合批得先搞清楚它省掉的到底是什么。GPU 渲染一个物体CPU 需要做一系列准备工作设置材质、绑定纹理、上传变换矩阵、提交绘制命令这一整套流程走完才算一次 DrawCall。单次 DrawCall 的开销在桌面端可能只有几十微秒听起来不多但场景里如果有两千个物体累加起来就是几十毫秒的 CPU 时间帧率直接被拖垮。更麻烦的是这些开销发生在主线程上会跟游戏逻辑抢时间。静态合批的核心思路很直接既然每个物体单独提交一次绘制命令太浪费那能不能把多个使用相同材质的静态物体合并成一个大网格一次性提交答案是能。Unity 在构建时或者运行时取决于平台和设置会把标记为 Static 的物体网格数据读出来按照材质分组把顶点数据变换到世界空间后拼接到一起生成一个合并后的大网格。渲染时只需要对这个大网格发一次绘制命令DrawCall 数量就降下来了。这里有个关键点很多人没注意到静态合批合并的是网格数据本身不是渲染状态。也就是说它要求参与合批的物体共享同一个材质。如果两个物体材质不同哪怕都是静态的也不会被合到一批里。这一点跟动态合批Dynamic Batching的逻辑类似但动态合批是在运行时逐帧判断和合并开销在 CPU 上而静态合批的合并工作大部分在构建阶段就完成了运行时几乎没有额外开销这也是它被称为“免费午餐”的原因。1.2 合并后的网格长什么样我拿一个具体例子来说明。假设场景里有一百棵静态的树每棵树用的是同一个材质但位置、旋转、缩放各不相同。开启静态合批后Unity 会把这一百棵树的网格顶点全部变换到世界空间然后拼成一个包含一百棵树所有顶点的大网格。这个合并后的网格顶点数是一百棵树顶点数之和索引缓冲区也是拼接起来的。渲染的时候GPU 拿到的是一个大顶点缓冲区和一个大索引缓冲区一次绘制命令画完所有树。从 GPU 的角度看它根本不知道这里有一百棵树它只知道“画这一堆三角形”。这就是静态合批的本质用空间换时间用更大的网格数据换取更少的绘制命令。但这里埋着一个容易被忽略的代价合并后的网格会常驻内存。原来一百棵树可能共享同一个网格资源内存里只有一份顶点数据合批之后变成了一百份变换后的顶点数据内存占用直接翻了很多倍。这是静态合批最典型的副作用也是很多人在移动端项目里用了之后发现内存暴涨的原因。1.3 跟动态合批的本质区别很多人把静态合批和动态合批混为一谈觉得都是“合批”应该差不多。实际上两者的机制和适用场景差别很大。动态合批是在运行时每帧判断哪些物体可以合批把顶点数较少的网格在 CPU 端变换后合并然后提交绘制。它的优点是灵活物体可以移动缺点是 CPU 开销大而且有顶点数上限默认情况下一个合批最多 300 个顶点超过就不合了。静态合批则完全相反物体不能移动但合批工作在构建期完成运行时零开销而且没有顶点数上限理论上可以把整个场景的静态物体合成一个巨大的网格。代价是内存占用高而且合批后的物体失去了独立的变换能力你没法在运行时单独移动其中一棵树。我一般是这样判断的场景里那些永远不会动的物件比如建筑、地形装饰、静态道具优先考虑静态合批会动的物件比如角色、载具、可拾取物品走动态合批或者 GPU Instancing 的路子。两者不是替代关系而是互补关系。2. 开启静态合批的正确姿势与隐藏代价2.1 标记 Static 的几种方式与区别在 Unity 里开启静态合批最直接的方式是在 Inspector 面板上勾选物体右上角的 Static 复选框。但很多人不知道的是这个 Static 标记其实是一组标志位的集合包括 Contribute GI、Occluder Static、Occludee Static、Batching Static、Navigation Static 等等。勾选整个 Static 会把所有标志位都打开但你可能只想开启 Batching Static 而不想让它参与 GI 计算或者遮挡剔除。我踩过的坑就在这里早期项目里我图省事直接勾了整个 Static结果发现场景烘焙光照的时间变长了因为那些本来不需要参与 GI 的物件也被纳入了光照计算。后来我改成只勾 Batching Static烘焙时间明显缩短。所以正确的做法是在 Inspector 面板上点击 Static 旁边的小箭头展开下拉菜单只勾选你真正需要的标志位。如果你是通过代码或者批量工具来设置可以用GameObjectUtility.SetStaticEditorFlags方法来精确控制。比如只开启 Batching Staticusing UnityEditor; public class StaticFlagTool { [MenuItem(Tools/Set Batching Static Only)] static void SetBatchingStaticOnly() { foreach (var go in Selection.gameObjects) { GameObjectUtility.SetStaticEditorFlags(go, StaticEditorFlags.BatchingStatic); } } }这段代码可以批量把选中物体的静态标志位设置为仅 Batching Static避免误开其他标志位带来的额外开销。在实际项目里这种小工具很实用尤其是场景物件多的时候手动一个个点太费时间。2.2 合批的材质分组逻辑前面提到静态合批要求共享材质但这里的“共享”有更细的规则。Unity 在合批时会按照材质实例来分组注意是材质实例而不是材质资源。如果两个物体用的是同一个材质资源但其中一个在运行时修改了材质属性比如改了颜色Unity 会为它创建一个材质实例这时候两个物体就不再共享同一个材质实例了合批也就断了。这个机制在项目里经常引发问题。比如美术给一批静态道具用了同一个材质但某个道具需要单独调一下颜色于是在材质面板上改了颜色Unity 自动创建了一个材质实例。结果就是这个道具脱离了合批组DrawCall 多了一次。更隐蔽的情况是某些脚本在运行时通过renderer.material访问材质这也会触发材质实例化悄悄破坏合批。我的经验是对于静态合批的物体尽量用renderer.sharedMaterial来访问材质避免意外创建实例。如果确实需要不同颜色考虑用顶点颜色或者把颜色差异烘焙到纹理里而不是给每个物体单独创建材质实例。另外可以在项目里加一个检查工具扫描场景中所有静态物体看看有没有因为材质实例化导致合批断裂的情况。2.3 内存代价的量化估算静态合批的内存代价到底有多大很多人没有概念。我拿一个实际项目的数据来说明场景里有两百个静态道具每个道具的网格平均有 500 个顶点顶点格式包含位置、法线、UV、切线每个顶点大约占 48 字节。不开启合批时如果这些道具共享几个网格资源内存里可能只有几份网格数据总共几十 KB。开启合批后每个道具的顶点数据都被复制并变换到世界空间两百个道具就是两百份数据总顶点数十万个乘以 48 字节大约 4.8 MB。单个场景 4.8 MB 听起来还能接受但如果项目有几十个场景或者场景里的静态物件更多、网格更精细这个数字会迅速膨胀。我见过一个场景静态合批后内存多占了将近 50 MB 的情况在移动端这就是很严重的问题了。所以开启静态合批之前一定要评估场景的静态物件数量和网格复杂度心里有个大概的账。这里有个估算公式可以参考合批后额外内存 ≈ 静态物体数量 × 单个物体平均顶点数 × 每顶点字节数。每顶点字节数取决于顶点属性位置12 字节 法线12 字节 UV8 字节 切线16 字节大约是 48 字节如果还有顶点颜色再加 4 字节。用这个公式快速估算一下就能判断合批带来的内存增长是否在可接受范围内。2.4 什么时候不该开静态合批静态合批不是万能的有些场景下开了反而亏。第一种情况是静态物体数量少但每个网格很精细比如场景里只有十几个高模雕塑每个几万顶点。这种情况下合批省下的 DrawCall 有限但内存代价巨大得不偿失。第二种情况是物体虽然静态但需要频繁切换可见性比如可开关的静态门或者可破坏的静态墙体合批后单独控制可见性会变得麻烦。第三种情况是使用了 GPU Instancing 的场景。如果一批静态物体可以用 GPU Instancing 渲染那 Instancing 的效率通常比静态合批更高因为 Instancing 不需要复制顶点数据内存占用小得多。静态合批和 GPU Instancing 在 Unity 里是有优先级的如果材质开启了 InstancingUnity 会优先走 Instancing 路径静态合批可能就不生效了。所以如果你的项目已经在用 Instancing先确认一下静态合批是否还有必要。3. 实操流程从标记到验证的完整链路3.1 场景静态物件的识别与分类动手开合批之前先要把场景里的物件分清楚。我的做法是先把场景里的所有物件按“是否会移动”分成两类静态的放一组动态的放另一组。然后静态组里再按材质分组看看哪些物件共享材质、哪些是独立材质。这个分类工作看起来繁琐但它是后续判断合批效果的基础。具体操作上我一般会写一个简单的编辑器脚本遍历场景里所有带 Renderer 的物体输出它们的静态标志位状态、材质引用、网格顶点数。这样一张表拉出来哪些该合批、哪些不该合批一目了然。下面是一个简化的扫描脚本using UnityEngine; using UnityEditor; using System.Text; public class StaticBatchScanner : EditorWindow { [MenuItem(Tools/Static Batch Scanner)] static void Scan() { var renderers Object.FindObjectsOfTypeMeshRenderer(); var sb new StringBuilder(); sb.AppendLine(Name\tStatic\tMaterial\tVertCount); foreach (var r in renderers) { var mf r.GetComponentMeshFilter(); int vertCount mf ! null mf.sharedMesh ! null ? mf.sharedMesh.vertexCount : 0; bool isStatic GameObjectUtility.GetStaticEditorFlags(r.gameObject) .HasFlag(StaticEditorFlags.BatchingStatic); string matName r.sharedMaterial ! null ? r.sharedMaterial.name : None; sb.AppendLine(${r.name}\t{isStatic}\t{matName}\t{vertCount}); } Debug.Log(sb.ToString()); } }跑一遍这个脚本把结果导出成表格你就能清楚看到场景里有多少静态物件、它们用了多少种材质、总顶点数大概多少。基于这些数据再决定合批策略比盲目勾选靠谱得多。3.2 材质合并与图集准备静态合批的效果很大程度上取决于材质的分组情况。如果场景里每个静态物件都用了不同的材质那合批基本没效果因为每个材质还是得单独提交。所以合批之前往往需要先做材质合并把能共用材质的物件归到一起。材质合并的常见手段是打图集。把多个小纹理拼成一张大图然后让多个物件共用同一个使用大图的材质这样它们就能合到一批里。Unity 里可以用 Sprite Atlas 或者第三方图集工具来做具体选哪个取决于项目类型。3D 项目里我一般用 Texture Packer 类的工具或者自己写脚本合并2D 项目直接用 Sprite Atlas 就行。这里有个细节要注意打图集之后材质的 Shader 也要统一。如果两个物件用的是不同 Shader 的材质哪怕纹理打进了同一张图集它们也不会合批。所以材质合并的前提是 Shader 统一这一点在项目初期规划材质体系的时候就要考虑进去。3.3 运行时合批与构建期合批的差异Unity 的静态合批在不同平台和不同构建配置下行为不完全一样。在编辑器里静态合批默认是运行时进行的也就是说你进入 Play 模式后 Unity 才会去合并网格。但在构建发布版本时Unity 会把合批工作提前到构建阶段完成生成合并后的网格数据打包进资源里。这个差异导致编辑器里的表现和真机上的表现可能不一致尤其是内存占用方面。我在项目里遇到过编辑器里看着内存正常、打包到真机上内存暴涨的情况排查后发现就是构建期合批把大量网格数据烘进了包体。所以验证静态合批效果一定要在真机或者至少是构建后的版本上测不能只看编辑器里的 Profiler 数据。另外不同平台的合批策略也有差异移动端和桌面端的内存预算不同同一个场景在两端的效果可能差很多。3.4 用 Frame Debugger 验证合批是否生效开了静态合批之后怎么确认它真的生效了最直接的工具是 Frame Debugger。打开 Frame Debugger 抓一帧看渲染事件列表里 DrawCall 的数量和分组情况。如果合批生效了你会看到原本多个物件的绘制被合并成了一个大的 DrawCall事件名里通常会带有 “Static Batch” 之类的标识。我一般会对比开启前后的 Frame Debugger 数据看 DrawCall 数量降了多少、每个 DrawCall 的顶点数是多少。如果发现某些本该合批的物件还是单独绘制就要去查原因是不是材质实例化了、是不是静态标志位没设对、是不是 Shader 不兼容合批。Frame Debugger 里还能看到每个 DrawCall 对应的网格信息对着排查很直观。除了 Frame DebuggerProfiler 里的 Rendering 模块也能看 DrawCall 和批次数量。我习惯在真机上跑 Profiler看实际运行时的批次数据因为编辑器里的数据参考价值有限。真机数据才是最终判断依据。4. 踩坑实录与排查速查表4.1 合批后物件消失或不显示这是静态合批最吓人的问题之一开启合批后某些物件在场景里看不见了。我遇到过两次第一次是因为物件的包围盒计算有问题合批后包围盒没有正确更新导致被视锥剔除掉了。第二次是因为物件的缩放是负数镜像翻转合批时顶点变换出了错网格跑到奇怪的位置去了。排查这类问题的思路是先关掉合批看物件是否正常确认问题确实由合批引起然后检查物件的 Transform 是否有异常缩放或旋转再检查网格的包围盒是否合理。如果是个别物件出问题可以尝试把它从合批组里排除单独处理。Unity 里可以通过给物件设置不同的材质或者取消静态标志来把它排除出合批。4.2 DrawCall 没降反升的怪现象听起来反直觉但确实会发生。原因通常是合批把原本可以动态合批的小物件强行合并成了一个大网格而这个大网格因为某些原因比如超过了索引缓冲区格式的限制被拆成了多次绘制结果 DrawCall 反而多了。另一种情况是合批后材质实例化问题导致分组混乱原本能合在一起的物件被拆散了。遇到这种情况我的排查顺序是先用 Frame Debugger 看实际的绘制分组找出哪些物件没有被合到一起然后检查这些物件的材质是否共享、静态标志是否一致最后看合并后的网格是否超过了单个绘制调用的限制。如果是网格太大的问题可以考虑把场景分成几个合批组而不是全部合成一个。4.3 移动端内存告急的应对移动端项目对内存敏感静态合批带来的内存增长很容易成为压垮骆驼的最后一根稻草。我在一个移动端项目里就遇到过合批后内存超预算的情况当时的解决办法是分批合批把场景按区域划分只对玩家附近可能同时可见的区域开启合批远处区域用 LOD 或者不开启合批。另一个思路是用 GPU Instancing 替代静态合批。对于重复度高的静态物件比如大量相同的树、石头Instancing 的内存效率比静态合批高得多因为不需要复制顶点数据。Unity 里给材质开启 Enable GPU Instancing 就行配合静态标志使用Unity 会自动选择更优的路径。下面这张表是我整理的常见问题速查遇到合批相关问题时可以对照排查现象可能原因排查手段解决方向物件消失包围盒错误或负缩放关闭合批对比、检查 Transform修正包围盒、避免负缩放DrawCall 未降材质未共享或 Shader 不统一Frame Debugger 看分组统一材质与 Shader内存暴涨合批网格数据过大Profiler 看内存曲线分批合批或改用 Instancing合批不生效静态标志位缺失检查 StaticEditorFlags补设 BatchingStatic部分物件不合批材质实例化检查 material 访问方式改用 sharedMaterial4.4 静态标志位被意外清除的隐蔽问题这个问题很隐蔽场景里的物件明明勾了静态但合批就是不生效。排查半天发现是某个脚本在运行时或者编辑时清除了静态标志位。常见的情况是某些导入工具或者场景加载逻辑会重置物件的标志位导致静态标记丢失。我的应对办法是在构建前加一个检查步骤扫描所有应该静态的物件确认它们的 BatchingStatic 标志位还在。如果发现丢失就在构建流程里自动补上。这个检查可以写成构建回调挂在IPreprocessBuildWithReport接口上构建前自动跑一遍避免带着问题打包。using UnityEditor.Build; using UnityEditor.Build.Reporting; using UnityEngine; public class StaticFlagBuildCheck : IPreprocessBuildWithReport { public int callbackOrder 0; public void OnPreprocessBuild(BuildReport report) { var renderers Object.FindObjectsOfTypeMeshRenderer(); int missing 0; foreach (var r in renderers) { if (r.gameObject.isStatic !GameObjectUtility.GetStaticEditorFlags(r.gameObject) .HasFlag(StaticEditorFlags.BatchingStatic)) { missing; Debug.LogWarning($静态物件缺少 BatchingStatic: {r.name}, r); } } if (missing 0) { Debug.LogWarning($共 {missing} 个静态物件缺少 BatchingStatic 标志); } } }这段代码在每次构建前自动检查把缺少标志位的物件列出来避免因为标志位丢失导致合批失效却浑然不知。5. 静态合批与其他优化手段的配合5.1 和 LOD 系统的协同LODLevel of Detail和静态合批可以配合使用但要注意顺序。LOD 的每个层级如果都是静态的可以分别参与合批但 LOD 切换本身是运行时行为合批后的网格不能动态切换。所以通常的做法是让 LOD0最高精度参与静态合批低层级用简化网格单独处理或者低层级也用合批但接受切换时的额外开销。我在项目里的经验是远景物件用 LOD 加静态合批效果不错因为远景物件通常不会频繁切换 LOD 层级合批带来的 DrawCall 节省很可观。近景物件如果 LOD 切换频繁合批反而可能因为切换时的网格重建带来卡顿这种情况要谨慎。5.2 和遮挡剔除的搭配遮挡剔除Occlusion Culling和静态合批都是静态场景优化的常用手段两者可以同时用。遮挡剔除负责把被挡住的物件剔除掉不渲染静态合批负责把可见的物件合并绘制。配合得当的话效果是叠加的先剔除掉大量不可见物件剩下的可见物件再合批绘制DrawCall 和渲染负载都能降下来。但这里有个细节静态合批后的网格是一个整体遮挡剔除对合批后的网格是按整体判断可见性的。如果合批网格的一部分被挡住、另一部分可见整个合批网格还是会被渲染。所以合批的粒度要跟遮挡剔除的粒度匹配不要把空间上相距很远、遮挡关系差异大的物件合到一批里否则遮挡剔除的效果会打折扣。5.3 和 GPU Instancing 的取舍前面提过 Instancing 和静态合批的关系这里再展开说一下取舍逻辑。Instancing 适合大量重复的相同网格内存效率高但要求网格和材质完全一致。静态合批适合材质相同但网格各异的物件灵活性稍高但内存代价大。实际项目里两者往往混用重复度高的物件植被、石块走 Instancing独特造型的静态物件建筑、雕塑走静态合批。判断标准很简单如果一批物件的网格是同一个资源优先 Instancing如果网格各不相同但材质相同考虑静态合批。这个判断在项目初期做资源规划时就要定下来后期再改成本很高。6. 一些实战中的判断经验静态合批这件事说到底是一个权衡问题用内存换 DrawCall用构建期的预处理换运行时的效率。它不是什么场景都适用的银弹但在合适的场景下确实能带来明显的性能收益。我在多个项目里用下来总结出几条判断经验供参考。第一先看 DrawCall 瓶颈是否真的存在。如果项目帧率稳定、Profiler 里渲染线程不是瓶颈那静态合批的收益有限没必要为了优化而优化。第二先做材质合并再考虑合批。材质不统一的情况下开合批效果大打折扣先把材质体系理顺更重要。第三移动端项目要严格控制合批规模宁可分批合批也不要一次性全合内存预算要留足余量。第四验证一定要在真机或构建版本上做编辑器数据只能参考。还有一个容易被忽略的点静态合批的效果会随着场景物件的增减而变化。项目迭代过程中如果不断往场景里加静态物件合批后的网格会越来越大内存占用持续增长但这个问题往往到后期才暴露。所以建议在项目里定期跑一次合批内存评估看看增长趋势是否在可控范围内别等到内存告急才回头排查。最后分享一个小技巧如果某个静态物件你拿不准该不该合批可以先不合用 Frame Debugger 看它单独绘制占了多少开销再决定是否纳入合批。这种逐个评估的方式虽然慢但对于关键物件来说很值得能避免盲目合批带来的隐性代价。