1. 项目概述:为什么我们需要在运行时启动静态合批?
在Unity项目里摸爬滚打久了,尤其是做移动端或者大型开放世界项目,性能优化永远是悬在头顶的一把剑。其中,“合批”是优化Draw Call、提升渲染效率最核心的手段之一。我们通常把合批分为静态合批和动态合批。静态合批,顾名思义,是针对那些在游戏运行过程中位置、形态、材质都不会改变的静态物体。它的原理是在构建(Build)时,将多个符合条件的小网格合并成一个或几个大网格,从而让CPU一次提交就能渲染大量物体,极大地减少了Draw Call。
但是,传统的静态合批有个明显的限制:它发生在构建阶段。这意味着,所有需要合批的物体必须在编辑器中标记为Static,并且合批的结果是“写死”在构建出的游戏数据里的。这带来了几个棘手的问题:第一,如果你的游戏有大量通过资源加载(如AssetBundle、Addressables)动态生成的静态场景物件,它们无法参与构建时的合批。第二,有些物体虽然在逻辑上是静态的,但其生成时机可能在运行时(比如根据玩家进度解锁的区域),传统方式无能为力。第三,构建时的合批会显著增加包体大小和内存占用,因为合并后的大网格数据会被直接包含在构建中。
于是,“在游戏运行时使用代码启动静态合批”这个需求就变得非常迫切。它允许我们将合批的时机从构建时推迟到运行时,针对动态加载的、或运行时才确定需要静态化的物体进行合批,从而兼具了灵活性与性能。这不仅仅是调用一个API那么简单,它涉及到对合批原理的深刻理解、对内存与性能的精细权衡,以及一系列容易踩坑的注意事项。接下来,我就结合自己趟过的坑,把静态合批从原理到代码启动的完整步骤和所有注意事项,掰开揉碎了讲清楚。
2. 静态合批的核心原理与前置条件
在动手写代码之前,我们必须彻底搞明白Unity静态合批到底在做什么,以及它需要什么条件。这能帮你避免很多“为什么合批没生效”的困惑。
2.1 合批的本质:减少Draw Call
渲染一个物体,CPU需要准备并提交一系列数据(顶点、索引、材质、变换矩阵等)给GPU,这个提交过程就是一次Draw Call。Draw Call过多会严重消耗CPU时间,成为性能瓶颈。合批的目标就是将多个物体的渲染数据合并,让CPU一次提交就能渲染多个物体。
静态合批通过合并网格来实现。假设你有1000个相同的石头模型,散落在场景中。如果不合批,每个石头都是一个独立的网格,至少产生1000个Draw Call(假设使用相同材质)。静态合批会把这1000个石头的网格数据,根据它们各自的变换矩阵(位置、旋转、缩放)计算好最终的顶点位置,然后拼接到一个大的顶点/索引缓冲区中。最终,这1000个石头在渲染时,可能只需要1个或几个Draw Call(取决于顶点数是否超过上限)。
2.2 静态合批的硬性条件
不是随便几个物体就能静态合批的。以下是必须满足的条件,无论是构建时还是运行时合批,这些规则都适用:
- 网格(Mesh)必须相同:这是指网格的源数据(顶点、三角形)来自同一个Mesh资产。通过
Instantiate复制的物体都满足这一条件。但注意,如果网格在运行时被修改(如通过Mesh.vertices),修改后的网格实例会被视为不同的网格,可能破坏合批。 - 材质(Material)必须相同:这里的“相同”指的是指向同一个材质球(Material)实例。即使两个材质球的设置(Shader、属性)完全一样,但如果是两个不同的实例,也无法合批。这是新手最容易踩的坑。
- 物体必须标记为Static:物体的
Static标志需要被勾选。这个标志是一个位掩码,我们通常关心的是其中的StaticBatchingStatic子项。在运行时通过代码合批,我们也需要确保物体被正确标记。 - 使用相同的渲染队列(Render Queue):材质所使用的Shader决定的渲染队列必须相同。
- 受相同的投影影响:例如,同时被同一个投影(Projector)影响或同时不被影响。
- 其他渲染状态相同:如同一个Lightmap索引、同一种Reflection Probe等。
注意:很多人会混淆“材质相同”和“材质属性相同”。请记住,合批看的是材质实例的引用是否相同。如果你在代码里
new Material(existingMaterial)创建了一个新实例,即使属性没变,它们也无法与原始材质合批。最佳实践是,对于需要合批的大量物体,共享同一个材质实例。
2.3 构建时合批 vs. 运行时代码合批
理解两者的区别,能更好地把握运行时合批的应用场景:
| 特性 | 构建时静态合批 | 运行时代码静态合批 |
|---|---|---|
| 时机 | 项目构建(Build)过程中 | 游戏运行时的任意时刻(如场景加载后) |
| 对象 | 编辑器中标记为Static的物体 | 任何在运行时存在的GameObject(通常由代码动态生成或加载) |
| 灵活性 | 低,合批方案固定 | 高,可根据游戏逻辑动态决定合批对象和时机 |
| 内存影响 | 增加构建后包体和运行时的内存占用(存储合并后的大网格) | 增加运行时的内存占用(在运行时生成合并网格) |
| CPU开销 | 无运行时开销 | 有一次性CPU开销(计算合并网格),需选择合适的时机进行 |
运行时合批的核心价值在于动态性。例如:
- 动态加载的场景区块:你的开放世界地图分块加载,每加载一块,就将这块内的所有静态装饰物(花草、碎石)进行合批。
- 程序化生成内容:随机生成的地牢,在生成完毕后,立即将墙、地板等静态元素合批。
- 优化UI:虽然UI通常用动态合批,但对于复杂的、全屏静态的背景UI元素,也可以考虑运行时静态合批来确保性能。
3. 运行时代码启动静态合批的完整步骤
理论铺垫完毕,现在进入实战环节。我们将通过一个完整的示例,演示如何在运行时,将一堆动态生成的相同模型进行静态合批。
3.1 步骤一:准备环境与创建测试用例
首先,我们创建一个简单的测试场景和脚本。
- 创建合批管理器脚本:在项目中创建一个C#脚本,命名为
RuntimeStaticBatching.cs。 - 准备一个预制体:在场景中创建一个简单的立方体(Cube),为其创建一个材质(例如,
Default-Material)。将这个立方体拖入Project窗口,做成一个预制体(Prefab),命名为StaticBatchPrefab。确保预制体根节点上的Static复选框未被勾选,因为我们要在运行时控制它。 - 创建测试脚本:我们将编写代码,动态生成大量该预制体,然后对它们进行合批。
3.2 步骤二:编写核心合批代码
RuntimeStaticBatching.cs脚本的内容如下。我将代码分成几个部分,并附上详细注释。
using UnityEngine; using System.Collections.Generic; public class RuntimeStaticBatching : MonoBehaviour { public GameObject staticPrefab; // 拖入我们创建的StaticBatchPrefab public int gridSize = 10; // 生成10x10的网格 public float spacing = 2.0f; // 物体之间的间隔 private List<GameObject> m_BatchableObjects = new List<GameObject>(); void Start() { GenerateObjects(); PerformStaticBatching(); } // 方法1:动态生成物体 void GenerateObjects() { if (staticPrefab == null) { Debug.LogError("请指定静态合批预制体!"); return; } // 获取预制体上的材质实例。这是关键一步! // 我们直接使用预制体上Renderer的sharedMaterial。 // 注意:所有物体将共享这个材质实例,这是合批的前提。 Material sharedMaterial = staticPrefab.GetComponent<Renderer>()?.sharedMaterial; if (sharedMaterial == null) { Debug.LogError("预制体上没有找到材质!"); return; } for (int x = 0; x < gridSize; x++) { for (int z = 0; z < gridSize; z++) { Vector3 position = new Vector3(x * spacing, 0, z * spacing); GameObject obj = Instantiate(staticPrefab, position, Quaternion.identity, this.transform); // 重要:将物体标记为“StaticBatchingStatic”。 // GameObjectUtility.SetStaticEditorFlags是编辑器API,运行时不可用。 // 我们需要直接设置GameObject的isStatic属性,并确保其包含StaticBatchingStatic标志。 // 更准确的做法是,我们只需要设置isStatic为true,Unity内部会处理标志位。 // 但对于运行时合批,我们通常使用StaticBatchingUtility,它内部会处理标记。 // 这里我们先创建好物体列表。 m_BatchableObjects.Add(obj); } } Debug.Log($"生成了 {m_BatchableObjects.Count} 个物体用于合批。"); } // 方法2:执行静态合批 void PerformStaticBatching() { if (m_BatchableObjects.Count == 0) { Debug.LogWarning("没有可合批的物体。"); return; } // 核心API:StaticBatchingUtility.Combine // 第一个参数:根GameObject。所有待合批的物体必须是这个根节点的子物体。 // 第二个参数:待合批的GameObject数组。 // 这个函数会做以下几件事: // 1. 检查这些物体是否符合静态合批条件(同网格、同材质等)。 // 2. 将符合条件的物体的网格合并。 // 3. 自动将这些物体的isStatic标志设为true,并设置StaticBatchingStatic子标志。 // 4. 创建一个新的合并网格资源,并赋值给相关渲染器。 // 注意:合批后,原始物体的MeshFilter组件可能被禁用或修改,其网格引用可能指向合并后的大网格。 StaticBatchingUtility.Combine(m_BatchableObjects.ToArray(), this.gameObject); Debug.Log("运行时静态合批完成!"); // 合批后,可以通过Frame Debugger或Stats窗口查看Draw Call数量的变化。 } }代码关键点解析:
StaticBatchingUtility.Combine:这是运行时静态合批的唯一核心API。它接受一个GameObject数组和一个根节点。所有待合批的物体最好是这个根节点的子物体,这样管理起来最清晰,但API本身并不强制要求父子关系。不过,合批后,这些物体的变换关系会以根节点为参考进行计算,所以将它们放在同一个根节点下是最佳实践。- 材质实例共享:在
GenerateObjects中,我们通过sharedMaterial获取材质。Instantiate预制体时,新物体会继承预制体上的材质引用。因此,所有生成物体都共享同一个材质实例,满足了合批的关键条件。如果你需要在运行时修改材质属性(如颜色),必须非常小心。直接修改renderer.material属性会创建一个新的材质实例(renderer.material是getter,调用时会复制材质),这将立即破坏合批。正确的做法是,如果需要修改,在合批前修改预制体的共享材质,或者合批后通过修改材质的全局属性(使用MaterialPropertyBlock)来避免实例化新材质。 - 静态标记:我们不需要手动设置
obj.isStatic = true。StaticBatchingUtility.Combine方法内部会自动处理这件事,它将为所有成功合批的物体设置正确的静态标志。
3.3 步骤三:测试与验证
- 将脚本挂载到场景中的一个空GameObject上,比如命名为“BatchManager”。
- 在Inspector窗口中,将
StaticBatchPrefab预制体拖拽到脚本的staticPrefab字段。 - 运行游戏。在
Start方法中,会先生成100个(10x10)立方体,然后立即对它们进行合批。 - 验证合批效果:
- 方法一:使用Stats窗口。在Game视图左上角点击Stats按钮。查看“Batches”或“Saved by batching”项。合批前,Batches数量可能接近100;合批成功后,这个数字会大幅下降(理想情况下可能降到个位数)。
- 方法二:使用Frame Debugger。Window -> Analysis -> Frame Debugger。打开Frame Debugger,在游戏运行时点击“Enable”。然后逐帧查看Draw Call列表。合批成功后,你会看到大量的立方体被合并到一个或少数几个“Draw Mesh”调用中,而不是每个立方体单独一个Draw Call。
4. 静态合批的所有注意事项与深度解析
如果你只是照搬上面的代码,在简单场景下可能没问题。但一旦项目复杂起来,各种坑就会接踵而至。下面是我总结的、在真实项目中必须注意的所有事项。
4.1 材质与Shader的陷阱
这是合批失败的头号原因。
陷阱一:无意中创建了新的材质实例。
// 错误做法:这会在运行时创建一个新的材质实例,破坏合批 void ChangeColor(GameObject obj) { Renderer renderer = obj.GetComponent<Renderer>(); renderer.material.color = Color.red; // .material 调用会复制材质! } // 正确做法一:在合批前修改共享材质(影响所有使用该材质的物体) void ChangeColorBeforeBatching() { Material sharedMat = staticPrefab.GetComponent<Renderer>().sharedMaterial; sharedMat.color = Color.red; } // 正确做法二:合批后使用MaterialPropertyBlock进行差异化渲染(不破坏合批) void ChangeColorAfterBatching(GameObject obj) { Renderer renderer = obj.GetComponent<Renderer>(); MaterialPropertyBlock props = new MaterialPropertyBlock(); renderer.GetPropertyBlock(props); // 获取现有的属性块(如果有) props.SetColor("_Color", Color.red); renderer.SetPropertyBlock(props); }MaterialPropertyBlock是解决“同材质、不同属性”需求的利器。它允许你为每个渲染器设置覆盖的材质属性,而这些覆盖是在GPU端通过常量缓冲区(CBuffer)实现的,不涉及创建新的材质实例,因此不会破坏合批。但是,使用MaterialPropertyBlock后,这些物体将无法与未使用PropertyBlock的、但材质相同的物体进行合批。通常,所有使用相同材质和相同PropertyBlock配置的物体仍然可以相互合批。陷阱二:Shader中使用了影响合批的特性。 某些Shader特性会强制物体无法合批,例如:
- GPU Instancing:如果材质启用了GPU Instancing,静态合批可能不会生效。Unity会优先尝试使用GPU Instancing来合批。这两者是不同的优化路径,通常不需要同时使用。
- Shader LOD:不同的LOD级别可能导致无法合批。
- RenderType Tags:不匹配的RenderType标签可能产生影响。
- 自定义的Shader变体:如果物体因为不同的全局关键字(如
#pragma multi_compile)而使用了不同的Shader变体,它们无法合批。确保需要合批的物体所处的渲染环境(如光照、阴影)一致,以触发相同的Shader变体。
4.2 网格与变换的考量
- 网格读写权限:用于合批的网格,其“Read/Write Enabled”设置会有影响。对于构建时合批,通常建议关闭此选项以节省内存。对于运行时合批,如果网格是通过代码创建的,或者你需要访问顶点数据,则需要开启。但要注意,开启后会增加内存占用。对于从AssetBundle加载的预制体中的网格,其设置是预定义的。
- 非统一缩放:物体的非统一缩放(Scale的x, y, z值不同)在合批时会被“烘焙”进合并后的顶点数据中。这本身不会阻止合批,但会使得合批后的网格在顶点级别失去非统一缩放的变换灵活性。合批后,你再修改该物体的缩放将不会影响其渲染形态(因为顶点位置已经按当时的缩放计算好了)。这是一个重要的行为变化,需要在设计时考虑。
- 合批的粒度:
StaticBatchingUtility.Combine是一次性操作。如果你有1万个物体,一次性合批它们可能会产生一个顶点数超限的巨型网格(Unity有每网格65535个顶点的限制,但新版支持更多)。StaticBatchingUtility内部会处理这个限制,自动将物体分组到多个合并网格中。但分组逻辑是黑盒。如果你需要对合批有更精细的控制(例如,按区域分组),可能需要自己实现分组合批的逻辑,即多次调用Combine方法,每次传入一个物体子集。
4.3 内存与性能的权衡
运行时合批不是免费的午餐,它用CPU时间和内存换取了渲染时的性能。
- CPU开销:
Combine方法调用时,Unity需要在主线程计算所有物体的变换矩阵,合并顶点和索引数据。对于成千上万的物体,这一帧可能会造成卡顿。务必在加载场景、切换关卡等可以接受卡顿的时机进行,避免在游戏流畅运行时进行。 - 内存开销:合批会创建新的网格资源来存储合并后的数据。这意味着,你既保留了原始网格的内存(如果还被其他地方引用),又增加了合并网格的内存。如果原始物体在合批后不再需要单独存在,可以考虑销毁它们的原始MeshFilter组件或将其网格引用置空,但操作需谨慎,避免影响其他引用。
- 合批的不可逆性:一旦物体被静态合批,其渲染状态就被“锁定”了。你不能再动态地修改这些物体的网格(如变形、破坏),也不能再改变它们的材质(除了用MaterialPropertyBlock覆盖部分属性)。任何修改都可能使合批失效或导致渲染错误。静态合批的对象,必须是真正的、在整个合批生命周期内都不会改变的“静态”物体。
4.4 光照、阴影与探针
- 光照贴图(Lightmapping):静态合批的物体可以参与光照贴图烘焙,但必须在合批之前就标记为Static并完成光照贴图烘焙。运行时合批的物体,由于在编辑时不存在,无法预先烘焙光照贴图。对于它们,你只能使用实时光照或光照探针(Light Probes)。
- 光照探针:运行时合批的物体可以很好地与光照探针系统协作。只要物体使用了相同的光照探针设置,并且合批后其渲染器正确引用了光照探针数据,合批就不会被破坏。
- 反射探针(Reflection Probes):同样,需要确保合批的物体使用相同的反射探针设置(如Blend Probes或Off)。
- 阴影:静态合批的物体投射和接收阴影的行为与普通物体一致。合批本身不会影响阴影计算。但注意,巨大的合批网格可能会产生巨大的阴影投射体,影响阴影裁剪效率。
5. 高级技巧与实战中的常见问题排查
掌握了基础,我们再看一些进阶玩法和如何解决那些令人头疼的“合批失效”问题。
5.1 分块与延迟合批策略
对于超大型场景,不要试图一次性合批所有物体。可以采用分块策略:
public class ChunkedStaticBatching : MonoBehaviour { public GameObject prefab; public int worldSize = 100; public int chunkSize = 10; public float spawnRadius = 50f; private Dictionary<Vector2Int, List<GameObject>> m_Chunks = new Dictionary<Vector2Int, List<GameObject>>(); void Start() { GenerateWorldByChunks(); } void GenerateWorldByChunks() { // 假设根据玩家位置生成区块 Vector3 playerPos = Vector3.zero; // 获取玩家位置 Vector2Int playerChunk = GetChunkCoord(playerPos); for (int x = -1; x <= 1; x++) { for (int z = -1; z <= 1; z++) { Vector2Int chunkCoord = new Vector2Int(playerChunk.x + x, playerChunk.y + z); if (!m_Chunks.ContainsKey(chunkCoord)) { GenerateChunk(chunkCoord); // 可以在生成后立即合批该区块,也可以等几帧分散压力 StartCoroutine(BatchChunkDelayed(chunkCoord, 1)); } } } } IEnumerator BatchChunkDelayed(Vector2Int coord, int framesToWait) { for (int i = 0; i < framesToWait; i++) { yield return null; // 等待指定帧数,分散CPU压力 } if (m_Chunks.TryGetValue(coord, out List<GameObject> objects)) { GameObject chunkRoot = new GameObject($"Chunk_{coord.x}_{coord.y}"); chunkRoot.transform.parent = this.transform; foreach (var obj in objects) { obj.transform.parent = chunkRoot.transform; } StaticBatchingUtility.Combine(objects.ToArray(), chunkRoot); Debug.Log($"区块 {coord} 合批完成。"); } } // ... 其他方法:GetChunkCoord, GenerateChunk }这个策略将世界划分为区块,只在玩家周围的区块进行生成和合批。并且使用协程延迟合批,避免在同一帧造成巨大的CPU峰值。
5.2 合批失效问题排查清单
当你在Frame Debugger里发现合批没有生效时,请按照以下清单逐一排查:
- 检查材质实例:这是最常见的原因。在Frame Debugger中,点击每个Draw Call,查看使用的材质。确认多个物体使用的是否是完全相同的材质实例(Instance ID相同)。如果不同,回溯代码,查找哪里创建了新的材质实例(检查所有
renderer.material = xxx或new Material(...)的调用)。 - 检查网格实例:同样在Frame Debugger中,确认网格是否相同。动态创建的网格(
new Mesh())每个都是独立实例,无法合批,除非你显式地共享同一个网格实例。 - 检查静态标志:合批后,物体的
isStatic应该为True。可以在运行时通过代码检查,或在Scene视图的右上角下拉菜单中开启“Static”筛选查看。 - 检查渲染状态:
- 渲染队列:确保所有材质的渲染队列(通过Shader的
Queue标签设置)一致。 - Shader变体:在复杂光照环境下,物体可能因为接受阴影、处于不同光照区域等原因,编译出不同的Shader变体。使用Frame Debugger查看Draw Call的Shader Pass名称,如果不一致,则无法合批。可以尝试在Quality Settings中简化光照或使用更统一的Shader。
- 光照探针/反射探针:确保相关设置一致。
- 渲染队列:确保所有材质的渲染队列(通过Shader的
- 检查缩放:虽然非统一缩放不会阻止合批,但极端的缩放值有时会带来意想不到的问题。确保缩放值合理。
- 检查API调用时机:确保在调用
StaticBatchingUtility.Combine时,所有待合批的物体都已经完成了初始化和材质赋值。不要在物体还在异步加载过程中就尝试合批。 - 使用
StaticBatchingUtility内部状态:Unity没有直接提供查询合批状态的API。但你可以通过合批前后Draw Call数量的变化来间接验证。也可以尝试在调用Combine后,检查物体的MeshFilter.sharedMesh是否指向了一个名称包含“Combined Mesh”的新网格。
5.3 与动态合批、GPU Instancing的对比与选择
了解其他合批技术,有助于做出正确选择:
| 技术 | 原理 | 条件限制 | CPU开销 | GPU开销 | 适用场景 |
|---|---|---|---|---|---|
| 静态合批 | 构建/运行时合并网格顶点数据,减少Draw Call。 | 网格、材质实例完全相同;物体静态。 | 高(一次性合并开销) | 低 | 场景中大量完全静止、不变化的物体(建筑、地形装饰物)。 |
| 动态合批 | 每帧CPU动态合并小型网格的顶点数据。 | 顶点数很少(<300);缩放一致;使用相同材质实例等。限制极多。 | 每帧中低 | 低 | 小型、简单的动态物体(UI元素、子弹、粒子)。Unity自动进行,可控性低。 |
| GPU Instancing | CPU一次提交一个网格和材质,GPU根据提供的变换矩阵数组实例化渲染多个。 | 相同网格和材质;Shader支持Instancing;变换数据每帧更新。 | 每帧低(仅提交矩阵数组) | 极低 | 大量相同模型,但需要独立运动或变化(人群、植被、同型号敌人)。 |
如何选择?
- 如果你的物体完全静止,且数量巨大,静态合批是最佳选择,它能最大程度减少Draw Call。
- 如果你的物体需要移动或变化,但模型相同,优先考虑GPU Instancing。它在现代硬件上效率极高。
- 动态合批限制太多,通常作为前两者的补充,用于处理一些简单的动态小物件。不要过度依赖它。
在运行时,你甚至可以混合使用这些策略。例如,对于一片森林,树干使用静态合批,而随风摇摆的树叶使用GPU Instancing的Shader来实现动画。
最后,再分享一个我自己的深刻体会:性能优化永远是权衡的艺术。运行时静态合批给了我们动态管理合批的灵活性,但这份灵活性是以CPU开销和内存为代价的。在项目中实施前,一定要用Profiler和Frame Debugger进行量化分析。确认合批带来的Draw Call减少收益,确实大于其带来的内存增长和那一次性的CPU卡顿。对于移动平台,内存尤其珍贵,有时候,为了省下几MB内存,忍受多几十个Draw Call也是值得的。没有银弹,只有最适合你当前项目瓶颈的那把钥匙。