Unity静态合批深度解析:原理、代价与实战避坑指南
静态合批这个词在 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 Static:
using 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 字节)+ UV(8 字节)+ 切线(16 字节)大约是 48 字节,如果还有顶点颜色再加 4 字节。用这个公式快速估算一下,就能判断合批带来的内存增长是否在可接受范围内。
2.4 什么时候不该开静态合批
静态合批不是万能的,有些场景下开了反而亏。第一种情况是静态物体数量少但每个网格很精细,比如场景里只有十几个高模雕塑,每个几万顶点。这种情况下合批省下的 DrawCall 有限,但内存代价巨大,得不偿失。第二种情况是物体虽然静态但需要频繁切换可见性,比如可开关的静态门或者可破坏的静态墙体,合批后单独控制可见性会变得麻烦。
第三种情况是使用了 GPU Instancing 的场景。如果一批静态物体可以用 GPU Instancing 渲染,那 Instancing 的效率通常比静态合批更高,因为 Instancing 不需要复制顶点数据,内存占用小得多。静态合批和 GPU Instancing 在 Unity 里是有优先级的,如果材质开启了 Instancing,Unity 会优先走 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.FindObjectsOfType<MeshRenderer>(); var sb = new StringBuilder(); sb.AppendLine("Name\tStatic\tMaterial\tVertCount"); foreach (var r in renderers) { var mf = r.GetComponent<MeshFilter>(); 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 Debugger,Profiler 里的 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 访问方式 | 改用 sharedMaterial |
4.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.FindObjectsOfType<MeshRenderer>(); 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 系统的协同
LOD(Level 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 看它单独绘制占了多少开销,再决定是否纳入合批。这种逐个评估的方式虽然慢,但对于关键物件来说很值得,能避免盲目合批带来的隐性代价。