
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 运行时动态合并通过脚本在游戏启动时如Awake或Start中动态查找并合并网格。StaticBatching本质上也是Unity在运行时帮你做的一种自动合并。优点保持编辑灵活性在编辑器中你依然可以自由摆放各个模块。适合程序化生成内容对于运行时由代码生成的建筑可以在生成后立即合并。缺点增加启动时间或帧时间开销合并计算本身需要CPU时间对于大量网格可能引起卡顿。内存管理更复杂需要妥善处理合并前后网格的内存分配与释放避免泄露。对动态物体无效如果合并后的物体或其父物体有任何移动、旋转、缩放非批处理静态标识批处理会失效。适用场景适合中小型、模块化程度高、且可能在运行时通过配置切换组合的场景。或者用于合并程序化生成且之后不再移动的物体。注意保持层级的关键在于我们不是粗暴地合并整个根节点下的所有东西。而是有选择地、分组合并。例如一栋房子我们可能将“墙体”下的所有方块合并成一个网格将“屋顶”下的所有瓦片合并成另一个网格而“门窗”可能因为材质不同或需要独立交互而不合并。这样在Hierarchy中“墙体”、“屋顶”、“门窗”这些父节点和其组织结构依然存在只是它们的子节点原来的无数个Cube被替换成了合并后的单个网格物体。3. 工具选型与准备工作工欲善其事必先利其器。除了Unity自带的功能选择合适的工具能让我们的工作流更顺畅。3.1 必需组件与知识准备MeshFilter 和 MeshRenderer这是网格合并操作直接打交道的两个核心组件。MeshFilter存储网格数据MeshRenderer负责渲染并持有材质。材质与材质球合并的基本单位是共享同一材质的网格。确保你想要合并的建筑模块使用的是同一个材质球Material而不是多个材质实例。如果颜色不同应该使用材质属性如_Color或顶点颜色来区分而不是创建新材质。编辑器脚本基础我们需要创建Editor文件夹下的C#脚本并使用UnityEditor命名空间中的类如EditorWindow,Editor,Selection等。预制体工作流强烈建议将你的建筑模块如各种形状的墙壁、地板、屋顶预制体规范化管理。合并操作的目标通常是这些预制体在场景中的实例。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 GetWindowHierarchyPreservingMeshCombiner(); 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 ListMeshRenderer allRenderers new ListMeshRenderer(); CollectRenderers(root.transform, allRenderers); if (allRenderers.Count 0) { Debug.LogWarning($在 {root.name} 下未找到任何MeshRenderer。); return; } // 2. 按材质进行分组 DictionaryMaterial, ListMeshRenderer materialToRenderers new DictionaryMaterial, ListMeshRenderer(); foreach (var renderer in allRenderers) { // 跳过没有材质或MeshFilter的渲染器 if (renderer.sharedMaterials.Length 0 || renderer.GetComponentMeshFilter() null) continue; // 注意一个MeshRenderer可能有多个子网格SubMesh对应多个材质。 // 这里简化处理只取第一个材质作为分组键。对于复杂模型需要循环所有材质。 Material keyMat renderer.sharedMaterial; if (keyMat null) continue; if (!materialToRenderers.ContainsKey(keyMat)) { materialToRenderers[keyMat] new ListMeshRenderer(); } materialToRenderers[keyMat].Add(renderer); } // 3. 为每个材质组创建合并后的父物体 DictionaryMaterial, GameObject combinedParents new DictionaryMaterial, GameObject(); foreach (var kvp in materialToRenderers) { Material mat kvp.Key; ListMeshRenderer 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数组 ListCombineInstance combineInstances new ListCombineInstance(); ListTransform transformsToDeactivate new ListTransform(); foreach (var renderer in renderers) { MeshFilter mf renderer.GetComponentMeshFilter(); 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.AddComponentMeshFilter(); newMF.sharedMesh combinedMesh; MeshRenderer newMR newParent.AddComponentMeshRenderer(); 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, ListMeshRenderer outputList) { if (!combineInactive !root.gameObject.activeInHierarchy) return; MeshRenderer mr root.GetComponentMeshRenderer(); 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 关键参数与原理详解CombineInstance.transform这是保持模型位置、旋转、缩放正确的核心。每个子网格在合并时都需要提供其从本地坐标系到世界坐标系的变换矩阵localToWorldMatrix。这样合并后的大网格才能正确还原所有小模块在世界中的位置。Mesh.CombineMeshes(combineInstances, mergeSubMeshes)mergeSubMeshes参数设为true时会将所有输入的网格合并成一个子网格SubMesh这意味着最终只能使用一个材质。这正是我们按材质分组后合并的目的。如果设为false则每个输入网格会保留为自己的子网格合并后的MeshRenderer需要使用一个材质数组。这适用于合并多个不同材质但想保持在一个Draw Call内的情况需要支持多子网格的Shader。禁用 vs. 销毁原始物体代码中选择了SetActive(false)。这是一个安全的选择让你有机会检查合并结果。确认无误后可以运行一个清理脚本或手动删除这些被禁用的物体。直接DestroyImmediate在编辑器脚本中虽然可以但一旦出错无法撤销。网格索引格式 (indexFormat):当合并的网格顶点数可能超过6553516位索引的最大值时必须将indexFormat设置为UInt32否则会导致渲染错误或崩溃。5. 高级议题与常见问题排查即使核心流程走通在实际项目中你还会遇到各种边界情况和难题。5.1 光照贴图UV的处理如果你的场景使用了烘焙光照Lightmapping那么合并网格后新的网格将没有光照贴图UV。这会导致烘焙的光照信息无法正确应用。解决方案合并前生成UV2在合并前确保每个原始网格已经生成了第二套UVUV2用于光照贴图。可以在导入设置中勾选“Generate Lightmap UVs”或使用代码Mesh.GenerateSecondaryUVSet()。合并后重新烘焙网格合并完成后你需要将新的合并物体标记为Static包含Lightmap Static然后在Lighting窗口中重新烘焙光照。因为网格的几何信息已经完全改变旧的光照贴图数据不再适用。5.2 碰撞体与导航网格的同步合并操作只处理了MeshFilter和MeshRenderer。如果你的原始建筑模块带有MeshCollider合并后它们会随着原物体被禁用而失效。解决方案为合并物体添加MeshCollider在创建合并父物体后可以为其添加一个MeshCollider并赋予合并后的网格。但要注意一个复杂的大网格碰撞体性能可能很差。使用简化碰撞体或复合碰撞体更优的做法是不为合并后的视觉模型添加碰撞体而是专门创建一个简化的碰撞体层级比如用一些BoxCollider或凸包MeshCollider来近似整个建筑的体积。导航网格NavMesh也是基于场景中的碰撞体或特定标记来烘焙的因此需要同步更新。5.3 材质球实例化问题代码中我们使用renderer.sharedMaterial进行分组。这要求所有要合并的物体必须引用同一个材质球资产。如果它们在编辑器中通过修改材质属性产生了多个材质实例即使看起来一样sharedMaterial也会指向不同的实例导致无法合并。排查与解决在Project视图中搜索“(Instance)”来查找材质实例。编写预处理脚本将相同参数的材质实例合并回同一个材质球。在设计模块化资产时就规定使用材质属性如通过脚本设置MaterialPropertyBlock来改变颜色等而非创建新实例。5.4 合并后模型接缝或光照断裂有时合并后在原本模块衔接的地方会出现细微的缝隙或光照不连续。原因与解决顶点位置不精确确保你的基础模块如Cube在建模时顶点坐标是精确对齐的没有微小的浮点误差。在Unity中检查Transform的Position值是否为整数或规整的小数。法线问题合并时顶点法线是直接取自原网格。在模块边缘两个相邻面的法线方向是硬边。如果希望得到平滑的外观需要在合并前或合并后对内部接缝处的顶点法线进行平滑处理但这通常不是建筑模块的需求建筑需要硬朗的边角。光照贴图接缝这是重新烘焙光照后最常见的问题。需要在UV2展开时确保有足够的padding并在Unity的Model Import设置或第三方建模软件中处理好UV岛之间的间距。6. 实操心得与性能考量经过多个项目的实践我总结出以下几点心得合并的粒度是关键。不要试图将整个场景合并成一个网格。应根据视觉区块、材质和功能来划分。例如将一栋建筑的外墙合并内部家具合并植物合并成另一组。这样即使在合并后你仍然可以通过禁用/启用整个合并体来控制大范围的显示/隐藏。善用预制体变体。将合并后的结果保存为预制体。你可以创建多个变体比如“建筑_合并版”和“建筑_未合并版”分别用于发布版本和开发阶段编辑。在性能与内存间权衡。合并减少了Draw Call但增加了单个网格的复杂度顶点和三角形数。对于GPU处理一个包含10万个三角形的大网格和处理100个各1000个三角形的小网格前者通常更高效。但极端情况下一个过于巨大的网格可能超出某些低端设备的处理能力。同时合并后的网格无法享受视锥体剔除Frustum Culling对单个小物体的优化。如果一栋大楼只有一角在屏幕内整个大楼的网格都需要被提交给GPU。因此合理的分组合并尤为重要。自动化与手动检查结合。可以编写更智能的工具自动按材质、按层级深度、按空间位置进行分组合并。但在关键场景合并后一定要手动检查模型、光照和碰撞。自动化是提高效率的手段但最终质量需要人工把关。版本控制注意事项。合并生成的.mesh和.mat文件是二进制资源在版本控制如Git下的差异对比和合并比较困难。确保团队有清晰的规范知道这些资源是如何生成的并且在需要修改时是回退到原始模块重新合并还是直接编辑合并后的资源。最后记住网格合并是优化手段不是目的。它的目标是提升运行时性能。在着手合并之前先用Unity的Profiler和Frame Debugger工具分析一下确认Draw Call确实是当前场景的性能瓶颈。有时候通过合理的材质合批Texture Atlas、简化模型面数、使用GPU Instancing等技术可能以更小的代价获得可观的性能提升。将层级保持网格合并作为你场景优化工具箱中的一件利器在正确的时机使用它才能打造出既流畅又易于维护的高质量项目。