Unity网格合并优化:保持层级结构降低Draw Call的实战方案

1. 项目概述:为什么我们需要批量合并建筑网格

在Unity中制作大型场景,尤其是城市、城堡或复杂的室内环境时,最头疼的问题之一就是Draw Call。每一个独立的网格、每一种不同的材质,都会向GPU发送一次绘制指令。当你的场景里塞满了成百上千个由基础立方体、圆柱体拼成的建筑模块时,性能瓶颈立刻就出现了。游戏帧率下降、编辑器卡顿,这些都是Draw Call爆炸的典型症状。

CombineMeshes,或者说网格合并技术,就是解决这个问题的核心手段。它的原理并不复杂:将多个使用相同材质的静态网格,在运行时或烘焙时合并成一个大的网格。这样一来,原本需要几十次甚至上百次Draw Call来渲染的建筑群,可能只需要几次。这对于移动端、WebGL等性能受限的平台来说,是至关重要的优化步骤。

但很多开发者,尤其是刚接触场景优化的朋友,会直接使用Unity自带的StaticBatching(静态批处理)或者简单地写个脚本遍历子物体调用Mesh.CombineMeshes。这样做往往会导致一个严重的问题:合并后的物体,其层级结构(Hierarchy)完全被打乱,所有子物体都消失了,只剩下一个合并后的大网格物体。这对于后续的场景管理、动态交互(比如只想破坏建筑的一部分)、LOD(多层次细节)设置来说,简直是灾难。

因此,这个项目的核心目标非常明确:实现建筑模块的批量网格合并,同时完美保持它们在Hierarchy中的原始层级结构。这不仅仅是优化,更是为了优化之后不牺牲项目的可维护性和扩展性。下面,我将从一个实战者的角度,拆解其中的技术细节、实操步骤以及那些官方文档里不会告诉你的“坑”。

2. 核心思路与方案选型:手动合并 vs. 运行时合并

在动手写代码之前,我们必须明确两种主要的合并策略及其适用场景,这决定了我们整个方案的架构。

2.1 静态合并(编辑器工具)

这是最常用、也是效果最好的方式。我们编写一个编辑器扩展(Editor Tool),在场景编辑阶段,由开发者手动或半自动地触发合并操作。合并后的新网格和材质会作为资源(Asset)保存到项目中,并生成一个使用该资源的新GameObject来替换原有的复杂结构。

优点:

  • 零运行时开销:合并操作只在编辑时执行一次,游戏运行时直接加载最终的低Draw Call模型,没有任何合并计算消耗。
  • 结果完全可控:可以仔细检查合并后的模型、UV、材质是否正确,并进行手动调整。
  • 便于资源管理:生成的预制体(Prefab)可以像普通模型一样进行版本管理、AB打包。

缺点:

  • 失去模块化编辑能力:合并后,原始的方块单元无法再在场景中直接移动、旋转。如果需要修改,必须回退到未合并的状态,修改后再重新合并。
  • 增加资源体积:生成了新的网格和材质文件。

适用场景:对于确定不会在运行时改变形态的静态建筑、地形装饰、固定场景道具,这是首选方案。我们的教程也将以此为重点。

2.2 运行时动态合并

通过脚本在游戏启动时(如AwakeStart中)动态查找并合并网格。StaticBatching本质上也是Unity在运行时帮你做的一种自动合并。

优点:

  • 保持编辑灵活性:在编辑器中,你依然可以自由摆放各个模块。
  • 适合程序化生成内容:对于运行时由代码生成的建筑,可以在生成后立即合并。

缺点:

  • 增加启动时间或帧时间开销:合并计算本身需要CPU时间,对于大量网格可能引起卡顿。
  • 内存管理更复杂:需要妥善处理合并前后网格的内存分配与释放,避免泄露。
  • 对动态物体无效:如果合并后的物体或其父物体有任何移动、旋转、缩放(非批处理静态标识),批处理会失效。

适用场景:适合中小型、模块化程度高、且可能在运行时通过配置切换组合的场景。或者用于合并程序化生成且之后不再移动的物体。

注意:保持层级的关键,在于我们不是粗暴地合并整个根节点下的所有东西。而是有选择地、分组合并。例如,一栋房子,我们可能将“墙体”下的所有方块合并成一个网格,将“屋顶”下的所有瓦片合并成另一个网格,而“门窗”可能因为材质不同或需要独立交互而不合并。这样,在Hierarchy中,“墙体”、“屋顶”、“门窗”这些父节点和其组织结构依然存在,只是它们的子节点(原来的无数个Cube)被替换成了合并后的单个网格物体。

3. 工具选型与准备工作

工欲善其事,必先利其器。除了Unity自带的功能,选择合适的工具能让我们的工作流更顺畅。

3.1 必需组件与知识准备

  1. MeshFilter 和 MeshRenderer:这是网格合并操作直接打交道的两个核心组件。MeshFilter存储网格数据,MeshRenderer负责渲染并持有材质。
  2. 材质与材质球:合并的基本单位是共享同一材质的网格。确保你想要合并的建筑模块使用的是同一个材质球(Material),而不是多个材质实例。如果颜色不同,应该使用材质属性(如_Color)或顶点颜色来区分,而不是创建新材质。
  3. 编辑器脚本基础:我们需要创建Editor文件夹下的C#脚本,并使用UnityEditor命名空间中的类,如EditorWindow,Editor,Selection等。
  4. 预制体工作流:强烈建议将你的建筑模块(如各种形状的墙壁、地板、屋顶预制体)规范化管理。合并操作的目标通常是这些预制体在场景中的实例。

3.2 可选辅助工具

  • Obi Advanced Mesh Combiner / Mesh Baker:这些是Asset Store上非常强大的第三方网格合并插件。它们提供了图形化界面、更完善的合并选项(如处理光照贴图UV、LOD合并等)。如果你的项目预算允许且合并需求非常复杂,使用这些成熟工具可以节省大量时间。但本教程旨在理解原理并实现核心功能,因此我们将从零开始构建。
  • ProBuilder / SabreCSG:如果你是在Unity内部直接进行场景建模,这些工具创建的面片通常已经过优化,但将其转换为常规网格后,同样面临合并需求。

4. 分步实现:编写保持层级的网格合并编辑器工具

接下来是核心实操部分。我们将创建一个名为HierarchyPreservingMeshCombiner的编辑器工具。

4.1 创建编辑器窗口

首先,在项目的Editor文件夹下创建一个C#脚本。

using UnityEngine; using UnityEditor; using System.Collections.Generic; using System.Linq; // 用于便捷的集合操作 public class HierarchyPreservingMeshCombiner : EditorWindow { // 窗口实例获取方法 [MenuItem("Tools/场景优化/层级保持网格合并器")] static void Init() { var window = GetWindow<HierarchyPreservingMeshCombiner>(); window.titleContent = new GUIContent("网格合并器"); window.Show(); } // 工具界面变量 private GameObject targetRoot; // 要合并的根物体 private string combinedMeshName = "Combined_Mesh"; private bool combineInactive = false; private bool createPrefab = true; private string savePath = "Assets/CombinedMeshes/"; // 绘制编辑器窗口界面 void OnGUI() { GUILayout.Label("层级保持网格合并设置", EditorStyles.boldLabel); targetRoot = (GameObject)EditorGUILayout.ObjectField("目标根物体", targetRoot, typeof(GameObject), true); combinedMeshName = EditorGUILayout.TextField("合并网格名称", combinedMeshName); combineInactive = EditorGUILayout.Toggle("包含未激活物体", combineInactive); createPrefab = EditorGUILayout.Toggle("生成预制体", createPrefab); savePath = EditorGUILayout.TextField("保存路径", savePath); EditorGUILayout.Space(); if (GUILayout.Button("开始合并选中物体及其子物体")) { if (targetRoot == null) { EditorUtility.DisplayDialog("错误", "请先指定目标根物体!", "确定"); return; } CombineMeshesUnderRoot(targetRoot); } if (GUILayout.Button("合并场景中所有选中物体")) { if (Selection.gameObjects.Length == 0) { EditorUtility.DisplayDialog("错误", "请在场景中选中至少一个物体!", "确定"); return; } // 这里可以扩展为遍历多个选中的根物体 CombineMeshesUnderRoot(Selection.activeGameObject); } } }

这段代码创建了一个简单的编辑器窗口,包含一些基本设置选项。核心逻辑在CombineMeshesUnderRoot方法中。

4.2 实现核心合并逻辑(保持层级)

这是整个工具的灵魂。思路是:递归遍历目标根节点下的所有子物体,但以直接使用相同材质的叶子节点为分组依据进行合并。

private void CombineMeshesUnderRoot(GameObject root) { if (root == null) return; // 1. 收集所有需要处理的MeshRenderer List<MeshRenderer> allRenderers = new List<MeshRenderer>(); CollectRenderers(root.transform, allRenderers); if (allRenderers.Count == 0) { Debug.LogWarning($"在 {root.name} 下未找到任何MeshRenderer。"); return; } // 2. 按材质进行分组 Dictionary<Material, List<MeshRenderer>> materialToRenderers = new Dictionary<Material, List<MeshRenderer>>(); foreach (var renderer in allRenderers) { // 跳过没有材质或MeshFilter的渲染器 if (renderer.sharedMaterials.Length == 0 || renderer.GetComponent<MeshFilter>() == null) continue; // 注意:一个MeshRenderer可能有多个子网格(SubMesh),对应多个材质。 // 这里简化处理,只取第一个材质作为分组键。对于复杂模型,需要循环所有材质。 Material keyMat = renderer.sharedMaterial; if (keyMat == null) continue; if (!materialToRenderers.ContainsKey(keyMat)) { materialToRenderers[keyMat] = new List<MeshRenderer>(); } materialToRenderers[keyMat].Add(renderer); } // 3. 为每个材质组创建合并后的父物体 Dictionary<Material, GameObject> combinedParents = new Dictionary<Material, GameObject>(); foreach (var kvp in materialToRenderers) { Material mat = kvp.Key; List<MeshRenderer> renderers = kvp.Value; if (renderers.Count < 2) { // 如果只有一个渲染器,合并意义不大,跳过或直接保留原物体 Debug.Log($"材质 {mat.name} 对应的渲染器少于2个,跳过合并。"); continue; } // 创建新的父物体,命名并放置在root下 string parentName = $"{combinedMeshName}_{mat.name}"; GameObject newParent = new GameObject(parentName); newParent.transform.SetParent(root.transform, false); // 保持与世界坐标一致 combinedParents[mat] = newParent; // 4. 准备CombineInstance数组 List<CombineInstance> combineInstances = new List<CombineInstance>(); List<Transform> transformsToDeactivate = new List<Transform>(); foreach (var renderer in renderers) { MeshFilter mf = renderer.GetComponent<MeshFilter>(); if (mf == null || mf.sharedMesh == null) continue; // 创建CombineInstance CombineInstance ci = new CombineInstance(); ci.mesh = mf.sharedMesh; ci.transform = renderer.transform.localToWorldMatrix; // 关键:转换矩阵 combineInstances.Add(ci); // 记录原物体,后续禁用 transformsToDeactivate.Add(renderer.transform); } // 5. 执行合并 Mesh combinedMesh = new Mesh(); combinedMesh.name = parentName; // 对于大量网格,可能需要使用indexFormat为32位 combinedMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32; combinedMesh.CombineMeshes(combineInstances.ToArray(), true); // 第二个参数为true,表示合并子网格 // 6. 为合并后的父物体添加组件 MeshFilter newMF = newParent.AddComponent<MeshFilter>(); newMF.sharedMesh = combinedMesh; MeshRenderer newMR = newParent.AddComponent<MeshRenderer>(); newMR.sharedMaterial = mat; // 使用分组材质 // 7. 禁用原始渲染物体(可选:也可以直接Destroy) foreach (var trans in transformsToDeactivate) { trans.gameObject.SetActive(false); // 如果你确定不再需要,可以DestroyImmediate(trans.gameObject); // 但建议先禁用,检查合并结果无误后再手动清理。 } // 8. 保存网格资源到项目 SaveMeshAsset(combinedMesh, parentName); } Debug.Log($"合并完成!在 {root.name} 下生成了 {combinedParents.Count} 个合并物体。"); // 可选:选中所有新生成的合并物体 Selection.objects = combinedParents.Values.Select(go => (Object)go).ToArray(); } // 递归收集渲染器 private void CollectRenderers(Transform root, List<MeshRenderer> outputList) { if (!combineInactive && !root.gameObject.activeInHierarchy) return; MeshRenderer mr = root.GetComponent<MeshRenderer>(); if (mr != null) { outputList.Add(mr); } foreach (Transform child in root) { CollectRenderers(child, outputList); } } // 保存Mesh为Asset文件 private void SaveMeshAsset(Mesh mesh, string name) { if (!System.IO.Directory.Exists(savePath)) { System.IO.Directory.CreateDirectory(savePath); } string assetPath = System.IO.Path.Combine(savePath, $"{name}.asset"); // 防止覆盖已有文件 assetPath = AssetDatabase.GenerateUniqueAssetPath(assetPath); AssetDatabase.CreateAsset(mesh, assetPath); AssetDatabase.SaveAssets(); Debug.Log($"网格已保存: {assetPath}"); }

4.3 关键参数与原理详解

  1. CombineInstance.transform这是保持模型位置、旋转、缩放正确的核心。每个子网格在合并时,都需要提供其从本地坐标系到世界坐标系的变换矩阵(localToWorldMatrix)。这样,合并后的大网格才能正确还原所有小模块在世界中的位置。
  2. Mesh.CombineMeshes(combineInstances, mergeSubMeshes)
    • mergeSubMeshes参数设为true时,会将所有输入的网格合并成一个子网格(SubMesh),这意味着最终只能使用一个材质。这正是我们按材质分组后合并的目的。
    • 如果设为false,则每个输入网格会保留为自己的子网格,合并后的MeshRenderer需要使用一个材质数组。这适用于合并多个不同材质但想保持在一个Draw Call内的情况(需要支持多子网格的Shader)。
  3. 禁用 vs. 销毁原始物体:代码中选择了SetActive(false)。这是一个安全的选择,让你有机会检查合并结果。确认无误后,可以运行一个清理脚本或手动删除这些被禁用的物体。直接DestroyImmediate在编辑器脚本中虽然可以,但一旦出错无法撤销。
  4. 网格索引格式 (indexFormat):当合并的网格顶点数可能超过65535(16位索引的最大值)时,必须将indexFormat设置为UInt32,否则会导致渲染错误或崩溃。

5. 高级议题与常见问题排查

即使核心流程走通,在实际项目中你还会遇到各种边界情况和难题。

5.1 光照贴图UV的处理

如果你的场景使用了烘焙光照(Lightmapping),那么合并网格后,新的网格将没有光照贴图UV。这会导致烘焙的光照信息无法正确应用。

解决方案:

  1. 合并前生成UV2:在合并前,确保每个原始网格已经生成了第二套UV(UV2),用于光照贴图。可以在导入设置中勾选“Generate Lightmap UVs”,或使用代码Mesh.GenerateSecondaryUVSet()
  2. 合并后重新烘焙:网格合并完成后,你需要将新的合并物体标记为Static(包含Lightmap Static),然后在Lighting窗口中重新烘焙光照。因为网格的几何信息已经完全改变,旧的光照贴图数据不再适用。

5.2 碰撞体与导航网格的同步

合并操作只处理了MeshFilterMeshRenderer。如果你的原始建筑模块带有MeshCollider,合并后它们会随着原物体被禁用而失效。

解决方案:

  1. 为合并物体添加MeshCollider:在创建合并父物体后,可以为其添加一个MeshCollider,并赋予合并后的网格。但要注意,一个复杂的大网格碰撞体性能可能很差。
  2. 使用简化碰撞体或复合碰撞体:更优的做法是,不为合并后的视觉模型添加碰撞体,而是专门创建一个简化的碰撞体层级(比如用一些BoxCollider或凸包MeshCollider来近似整个建筑的体积)。导航网格(NavMesh)也是基于场景中的碰撞体或特定标记来烘焙的,因此需要同步更新。

5.3 材质球实例化问题

代码中我们使用renderer.sharedMaterial进行分组。这要求所有要合并的物体必须引用同一个材质球资产。如果它们在编辑器中通过修改材质属性产生了多个材质实例,即使看起来一样,sharedMaterial也会指向不同的实例,导致无法合并。

排查与解决:

  • 在Project视图中搜索“(Instance)”来查找材质实例。
  • 编写预处理脚本,将相同参数的材质实例合并回同一个材质球。
  • 在设计模块化资产时,就规定使用材质属性(如通过脚本设置MaterialPropertyBlock)来改变颜色等,而非创建新实例。

5.4 合并后模型接缝或光照断裂

有时合并后,在原本模块衔接的地方会出现细微的缝隙或光照不连续。

原因与解决:

  • 顶点位置不精确:确保你的基础模块(如Cube)在建模时,顶点坐标是精确对齐的,没有微小的浮点误差。在Unity中,检查Transform的Position值是否为整数或规整的小数。
  • 法线问题:合并时,顶点法线是直接取自原网格。在模块边缘,两个相邻面的法线方向是硬边。如果希望得到平滑的外观,需要在合并前或合并后对内部接缝处的顶点法线进行平滑处理,但这通常不是建筑模块的需求(建筑需要硬朗的边角)。
  • 光照贴图接缝:这是重新烘焙光照后最常见的问题。需要在UV2展开时确保有足够的padding,并在Unity的Model Import设置或第三方建模软件中处理好UV岛之间的间距。

6. 实操心得与性能考量

经过多个项目的实践,我总结出以下几点心得:

  1. 合并的粒度是关键。不要试图将整个场景合并成一个网格。应根据视觉区块、材质和功能来划分。例如,将一栋建筑的外墙合并,内部家具合并,植物合并成另一组。这样即使在合并后,你仍然可以通过禁用/启用整个合并体来控制大范围的显示/隐藏。
  2. 善用预制体变体。将合并后的结果保存为预制体。你可以创建多个变体,比如“建筑_合并版”和“建筑_未合并版”,分别用于发布版本和开发阶段编辑。
  3. 在性能与内存间权衡。合并减少了Draw Call,但增加了单个网格的复杂度(顶点和三角形数)。对于GPU,处理一个包含10万个三角形的大网格和处理100个各1000个三角形的小网格,前者通常更高效。但极端情况下,一个过于巨大的网格可能超出某些低端设备的处理能力。同时,合并后的网格无法享受视锥体剔除(Frustum Culling)对单个小物体的优化。如果一栋大楼只有一角在屏幕内,整个大楼的网格都需要被提交给GPU。因此,合理的分组合并尤为重要。
  4. 自动化与手动检查结合。可以编写更智能的工具,自动按材质、按层级深度、按空间位置进行分组合并。但在关键场景,合并后一定要手动检查模型、光照和碰撞。自动化是提高效率的手段,但最终质量需要人工把关。
  5. 版本控制注意事项。合并生成的.mesh.mat文件是二进制资源,在版本控制(如Git)下的差异对比和合并比较困难。确保团队有清晰的规范,知道这些资源是如何生成的,并且在需要修改时,是回退到原始模块重新合并,还是直接编辑合并后的资源。

最后,记住网格合并是优化手段,不是目的。它的目标是提升运行时性能。在着手合并之前,先用Unity的Profiler和Frame Debugger工具分析一下,确认Draw Call确实是当前场景的性能瓶颈。有时候,通过合理的材质合批(Texture Atlas)、简化模型面数、使用GPU Instancing等技术,可能以更小的代价获得可观的性能提升。将层级保持网格合并作为你场景优化工具箱中的一件利器,在正确的时机使用它,才能打造出既流畅又易于维护的高质量项目。