加载一个室内场景,Profiler 里 SetPass Calls 显示 400 多,FrameDebugger 一打开满屏的绿色方块,每个方块旁边都挂着一次Draw Mesh——那一刻我基本能确定,问题不在 Shader,也不在光照,而是场景里有太多独立的 MeshRenderer,每个都拖着自己那一份材质和贴图,draw call 就这么被硬生生堆起来了。这个现象在 XR 项目里尤其致命,PICO4 这类一体机要同时渲染左右眼,帧率目标卡在 72Hz 甚至 90Hz,CPU 侧稍微多提交一点东西,GPU 还没吭声,CPU 就已经先跪了。
Mesh 合并、Material 合并、贴图合并这三件事,本质上是同一条优化链条上的三个环节:把多个渲染单元压成一个,减少 CPU 到 GPU 之间的提交次数。但很多人只做了第一步——把 Mesh 拼起来,结果发现 draw call 一点没降,因为材质还是各管各的,贴图还是各用各的。这篇就把整条链路拆开讲清楚,包括CombineMeshes的使用细节、材质数组的对齐规则、图集化时 UV 重映射的数学、以及那几个我先后踩过至少两次的坑。如果你手头正有一个道具多、建筑碎块多、或者程序化生成网格多的场景,这篇基本可以直接抄。
1. 一次DrawCall爆炸现场:Mesh合并究竟在解决什么问题
1.1 渲染瓶颈的真实位置:从CPU提交到GPU吞吐
先说清楚一件事:draw call 多为什么会卡。GPU 本身对绘制指令的吞吐量其实相当可观,桌面端几千次 draw call 也能吃下,问题出在 CPU 侧。每一次绘制提交,引擎都要做一系列准备工作——绑定 Shader 变体、上传材质属性到常量缓冲区、切换顶点缓冲和索引缓冲、设置渲染状态。这些操作单独看都不贵,但乘上几百上千次,就变成了 CPU 每帧的主要开销来源。
在 Profiler 里,这部分开销对应的是Camera.Render下面的Drawing和RenderLoop节点。你会看到一个很有意思的现象:把 Scene 视图缩到最小,让所有物体都被视锥剔除,帧率立刻回到满值;一旦视野铺满,立刻掉帧。这就是典型的 CPU 提交瓶颈,而不是 GPU 填充率问题。判断方法也很简单,用 FrameDebugger 看每一帧的绘制次数,如果 SetPass Calls 明显高于 Batches 的三分之一以上,说明合批基本没生效。
还有一个容易被忽略的点:在 XR 场景里,左右眼是分别提交的,很多统计数字在 Profiler 里看着还不错,实际上乘以二以后就爆了。所以我习惯在 PICO4 上真机跑的时候,把 "Multiview / Single Pass Instanced" 打开,再用 FrameDebugger 核对,这样看到的数字才是真实的。
1.2 静态合批、动态合批、SRP Batcher各自的能力边界
很多人第一反应是"引擎不是自带合批吗",确实自带,但每种合批都有自己的门槛,踩不到门槛就等于没有。
静态合批要求物体在 Inspector 里勾上 Static,编辑器在构建时会把这些物体的顶点数据复制进一个公共的顶点缓冲,运行时一次性提交。代价是内存暴涨——顶点数据被复制了一份甚至多份,场景越大越明显。而且它对移动物体完全无能为力。
动态合批的名字听着美好,实际约束极其苛刻:顶点数上限很低(不同版本数值不同,但基本在几百这个量级),顶点属性数量也有要求,一旦模型带 UV2、切线、顶点色,很容易就超了。而且它是在 CPU 端把顶点变换到世界空间再合并的,本身就有开销,对移动端不友好。
SRP Batcher 是 URP/HDRP 下的机制,它并不是真的合并 draw call,而是把同一 Shader 变体下的材质属性集中到常驻缓冲区,减少每次绘制时的 SetPass 开销。它的好处是即使材质实例不同,只要 Shader 变体一致就能享受到红利,但它不减少 Batches 数量,对 CPU 侧提交次数的帮助有限。
所以当场景里有大量不同 Mesh、不同材质、且需要保持静态的道具时,手工合并 Mesh 依然是收益最直接的手段。
1.3 合并链条上的三个层级:Mesh、Material、Texture
这三层是有依赖关系的,不能跳着做。
- Mesh 层:把多个 MeshFilter 的几何数据合并成一个 Mesh,减少顶点缓冲切换。
- Material 层:让合并后的多个子网格共用同一个材质实例,减少材质切换。
- Texture 层:把原本分散的多张贴图打进一张图集,让"共用材质"这件事真正成立。
关键在于第二层和第三层是绑死的。Unity 里一个材质对应一个主贴图,你没法让一个材质同时引用五张不同的贴图并各自显示在不同子网格上(除非用贴图数组或者 Shader 里做索引,那是另一套方案)。所以想让材质合并,贴图就得先合并成图集,然后把每个子网格的 UV 重映射到图集对应的区域。这个顺序反了,做出来的东西要么花屏,要么根本没降 draw call。
1.4 什么样的项目才真的需要它
不是所有项目都值得折腾这套流程。如果场景里本来就只有几十个物体,或者物体是动态生成的、每帧都在动,那手工合并的收益很有限,甚至可能因为合并后无法做视锥剔除和 LOD,反而更慢。
真正值得上这套方案的是这几种情况:建筑场景里成百上千的重复碎块、程序化生成的关卡道具、UI 里用 Mesh 拼出来的装饰元素、VR 场景里的室内陈设。共同特点是——静态、数量多、Mesh 小、材质种类有限。这类场景合并之后,draw call 从几百降到个位数是很常见的。
反过来,角色、怪物、可交互道具这些会动的,通常更适合用 GPU Instancing 或者对象池,不要硬塞进静态合并。
2. 把零散Mesh拼成一整块:CombineMeshes的实操与陷阱
2.1 CombineInstance的三个字段到底填什么
Mesh.CombineMeshes的入参是一个CombineInstance[]数组,每个元素代表一份要参与合并的网格数据。这个结构体看起来只有三个字段,但每个都有讲究。
mesh字段填的是源网格,这里一定要用sharedMesh而不是mesh。用mesh会在访问时克隆一份出来,在编辑器里会留下大量内存垃圾,运行时也会拖慢速度。
transform字段填的是把源网格从自身空间变换到目标空间的矩阵。这个是最容易出错的字段,后面单独讲。
subMeshIndex字段指定使用源网格的第几个子网格。默认是 0,如果你的模型有多个材质槽,只填 0 就等于只合并了第一个子网格,其余部分会凭空消失。这个坑我在第一次写合并工具时踩得结结实实,合并完发现模型少了一半,排查了半天以为是顶点数超限。
2.2 变换矩阵:合并后模型位置错乱的根因
最常见的错误写法是直接传filters[i].transform.localToWorldMatrix。这么写逻辑上没错,它把每个子网格都变换到了世界空间。但问题在于,合并出来的 Mesh 你是要赋给某个物体上的,而那个物体自身还带着一个 Transform。
假设根节点在(10, 0, 0),子节点在根节点下方(1, 0, 0)。用localToWorldMatrix合并后,网格顶点在世界空间(11, 0, 0)位置。然后把这份网格赋给根节点,根节点的 Transform 又把顶点平移了 10 个单位,最终渲染在(21, 0, 0)。位置直接错了一倍。
正确的做法是先把每个子网格变换到根节点所在的局部空间:
Matrix4x4 toRoot = root.worldToLocalMatrix * mf.transform.localToWorldMatrix;先乘worldToLocalMatrix再乘子节点自己的localToWorldMatrix,得到的是"从子节点局部空间直接到根节点局部空间"的矩阵。这样合并出来的网格放在根节点下,位置就完全对得上了。
还有个变体情况:如果源网格和子节点的 Transform 本身有缩放,缩放会被烘进顶点数据里。这在大多数情况下没问题,但如果你的 Shader 依赖对象空间坐标做效果(比如三平面映射、顶点动画),烘进缩放之后效果就会变。这种场景下建议合并前先把缩放统一成 1,或者干脆用"只烘旋转和平移"的矩阵。
2.3 mergeSubMeshes参数与材质数组的对应
CombineMeshes的第二个参数mergeSubMeshes决定了合并策略,这个参数和材质数组是一一对应的,必须配套理解。
传true时,所有参与合并的网格会被压成一个子网格,最终 Mesh 只有一个材质槽。这要求所有源网格用的是同一个材质,否则你只能在结果上挂一个材质,其他部分的贴图就全错了。
传false时,每个源网格的每个子网格在结果里都保留为独立子网格,最终 Mesh 的subMeshCount等于所有源子网格数量之和。这时候你必须给对应的 Renderer 挂一个长度完全匹配的材质数组,顺序也要和合并顺序一致。
// 展开所有子网格,保留结构 for (int i = 0; i < filters.Length; i++) { var mf = filters[i]; if (mf.sharedMesh == null) continue; var mr = mf.GetComponent<MeshRenderer>(); if (mr == null || !mr.enabled) continue; Matrix4x4 toRoot = root.worldToLocalMatrix * mf.transform.localToWorldMatrix; var srcMesh = mf.sharedMesh; var srcMats = mr.sharedMaterials; for (int s = 0; s < srcMesh.subMeshCount; s++) { combines.Add(new CombineInstance { mesh = srcMesh, subMeshIndex = s, transform = toRoot }); materials.Add(s < srcMats.Length ? srcMats[s] : srcMats[srcMats.Length - 1]); } }注意最后那句防御性写法:有些美术出的模型,材质槽数量和 subMeshCount 对不上,直接索引会越界。我在一个外包给的模型上就遇到过,subMeshCount 是 3,材质只有一个,索引的时候就崩了。
2.4 IndexFormat.UInt32与顶点上限
默认情况下,Mesh 的索引格式是UInt16,单个网格最多支持 65535 个顶点。场景合并很容易超过这个数字——哪怕只是几十个中等复杂度的道具叠加起来,顶点数破十万轻轻松松。
超过限制之后,Unity 不会报错,而是直接把超出的部分截断,或者渲染出乱七八糟的三角形。这种问题特别难查,因为编辑器里预览可能正常,真机上就崩了。
Mesh combined = new Mesh(); combined.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;设成UInt32之后上限提到四十多亿,基本用不完。代价是索引数据体积翻倍,一个百万顶点的网格大约多占 4MB 左右,这个代价在绝大多数场景下完全值得。
不过这里有个更深层的问题:合并成一个巨大的网格之后,这个网格就变成了一个不可分割的渲染单元。视锥剔除只能整体剔除,任何一个角落出现在画面里,整个网格的顶点都要进管线。如果场景跨度很大,这个代价可能反而超过 draw call 的节省。解决办法是按空间区域分块合并,把大场景切成若干块,每块单独合并、单独剔除。
2.5 完整可跑的合并脚本
把上面这些拼起来,一个能直接用的静态合并脚本大概是这样:
using System.Collections.Generic; using UnityEngine; public static class StaticMeshCombiner { public static GameObject Combine(Transform root, bool keepSubMeshes) { var filters = root.GetComponentsInChildren<MeshFilter>(); var combines = new List<CombineInstance>(filters.Length); var materials = new List<Material>(); for (int i = 0; i < filters.Length; i++) { var mf = filters[i]; var srcMesh = mf.sharedMesh; if (srcMesh == null) continue; var mr = mf.GetComponent<MeshRenderer>(); if (mr == null || !mr.enabled) continue; Matrix4x4 toRoot = root.worldToLocalMatrix * mf.transform.localToWorldMatrix; var srcMats = mr.sharedMaterials; int subCount = keepSubMeshes ? srcMesh.subMeshCount : 1; for (int s = 0; s < subCount; s++) { combines.Add(new CombineInstance { mesh = srcMesh, subMeshIndex = s, transform = toRoot }); if (keepSubMeshes) { materials.Add(s < srcMats.Length ? srcMats[s] : srcMats[srcMats.Length - 1]); } else { materials.Add(srcMats[0]); } } } if (combines.Count == 0) return null; var mesh = new Mesh(); mesh.name = "Combined_" + root.name; mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32; mesh.CombineMeshes(combines.ToArray(), !keepSubMeshes, true); mesh.RecalculateBounds(); mesh.UploadMeshData(false); var go = new GameObject("CombinedMesh"); go.transform.SetParent(root, false); go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; go.transform.localScale = Vector3.one; go.AddComponent<MeshFilter>().sharedMesh = mesh; go.AddComponent<MeshRenderer>().sharedMaterials = materials.ToArray(); return go; } }UploadMeshData(false)表示把网格上传到 GPU 的同时保留一份 CPU 可读副本。传true会释放 CPU 侧的副本,省内存,但之后就没法再做RecalculateBounds、读顶点这些操作了。如果你的网格不会再被改动,传true更划算。
3. Material合并:让多个Renderer吃同一份材质
3.1 材质不同为什么直接断批
Unity 的合批逻辑有一个硬性前提:两个渲染单元如果材质实例不同,就无法被合批处理。注意这里说的是材质实例,不是材质资源。哪怕两个 Renderer 引用的是同一个.mat资源文件,只要它们的renderer.material被访问过一次,Unity 就会为每个 Renderer 克隆出一份独立的材质实例,合批立即失效。
这个隐藏克隆是新手最容易中招的地方。写代码时随手一句renderer.material.color = Color.red,看起来只是改了个颜色,实际上背后创建了一个新的材质实例,还带走了一份 Shader 和贴图的引用开销。正确写法是renderer.sharedMaterial.color = ...,但要小心这会改到所有引用同一个材质的物体。
所以材质合并的第一原则是:尽量让所有需要合并的 Renderer 共享同一份材质资源,不要做任何触发克隆的操作。
3.2 共享材质、材质数组、MaterialPropertyBlock三种路线的取舍
把多个对象的材质统一起来,实际有三条路可以走,各有适用场景。
共享同一份材质实例是最彻底的做法。所有子网格的贴图都打进同一张图集,UV 重映射之后,整个合并网格只需要一个材质槽。这种情况下联合并成一个子网格都行,draw call 能压到最低。缺点是灵活性差,任何单独的材质参数调整都会影响全部。
使用材质数组是折中方案。合并后保留多个子网格,每个子网格对应数组里的一个材质。这种方式适合"贴图不方便合并"的情况,比如不同物件用了完全不同的 Shader 或者不同的渲染队列。好处是保留了灵活性,坏处是材质槽数量决定了下限,draw call 还是按子网格分开算。
MaterialPropertyBlock是给单个 Renderer 传参数的机制,它不创建材质实例,所以不会像.material那样破坏合批。但在 Built-in 管线下,使用 PropertyBlock 的渲染器往往会被排除在动态合批之外;在 URP/HDRP 下,它和 SRP Batcher 的关系需要实测确认,因为不同版本行为有差异。我的做法是只在需要给特定物体传少量参数(比如颜色渐变因子)时用它,而且一定在真机上用 FrameDebugger 验证合批有没有断。
下面这张表可以帮助快速判断:
| 方案 | 是否创建材质实例 | 对合批影响 | 适用场景 |
|---|---|---|---|
| sharedMaterial | 否 | 无 | 完全统一的材质参数 |
| material | 是 | 断批 | 单物体独立调整,慎用 |
| 材质数组 | 否 | 按子网格分批 | 贴图不便合并、Shader 不同 |
| MaterialPropertyBlock | 否 | 视管线而定,需实测 | 少量逐物体参数 |
3.3 subMesh数与材质数组长度必须对齐
合并之后,Mesh 的subMeshCount和 Renderer 的sharedMaterials.Length必须严格对应,多一个少一个都会出问题。
多的时候 Unity 会报警告,渲染结果可能正常但会有性能浪费;少的时候,多出来的子网格会直接用第一个材质渲染,看起来是"贴图错乱",实际上是索引越界后的兜底行为。我在一个项目里就遇到过美术手动改了模型,subMeshCount 从 2 变成 3,材质槽还是 2 个,结果第三个子网格用第一个材质渲染,看起来像"部分零件变白",查了一下午才发现是数量对不上。
写工具的时候,我习惯在合并完成后加一段校验:
int subCount = mesh.subMeshCount; int matCount = renderer.sharedMaterials.Length; if (subCount != matCount) { Debug.LogWarning($"[MeshCombine] subMesh({subCount}) 与 material({matCount}) 数量不匹配: {mesh.name}"); }这段校验在上百个物体批量合并时救过我好几次。
3.4 哪些材质绝对不能合并
不是所有材质都适合合并,有几类必须排除在外。
不同渲染队列的材质,比如一个用Geometry队列,另一个用Transparent,合并之后渲染顺序会乱,透明物可能被不透明物遮挡,或者出现 Z-Fighting。
依赖屏幕空间坐标的 Shader,比如水面、玻璃这类带折射或者屏幕 UV 采样的效果,合并后对象空间信息丢失,效果会明显变形。
需要逐物体裁剪的材质,比如带_Clip或者自定义剔除逻辑的,合并后所有子网格共享一套裁剪参数,原本分开裁剪的物体会有部分不消失。
双面/单面渲染设置不同的材质,合并后只能取一种,另一边会出现穿帮。
遇到这些情况,宁可保留独立渲染,也不要为了省几个 draw call 把画面搞坏。我的经验是,先把明显能合并的筛掉,剩下的单独处理,不要追求一次性全并。
4. 贴图合并:图集化才是材质合并的前置条件
4.1 图集带来的收益要算清楚
贴图合并成图集,最直接的收益是让材质合并成为可能。但除此之外还有几个附带好处:GPU 纹理切换次数减少、纹理缓存命中率提升、批次更加稳定。
不过收益是要算的。假设原本有 64 张 512x512 的贴图,各自独立加载,显存占用按压缩后算。合并成一张 2048x2048 的图集之后,如果排列紧凑,占用会低于 64 张独立贴图的总和——因为每张独立贴图都有对齐填充和 mipmap 链的额外开销,而图集共享一套 mipmap。
但如果排列不紧凑,比如 64 张 512x512 塞进 4096x4096,浪费率就高了。这里有个粗略的估算方式:
单张贴图压缩后的字节数 ≈ 宽 × 高 × 每像素字节数。ETC2 RGB 是 0.5 字节/像素,ASTC 4x4 也是 0.5,ASTC 6x6 约 0.22。一张 512x512 的 ETC2 大约 128KB,64 张就是 8MB。一张 2048x2048 的 ETC2 是 2MB,如果能塞下 64 张 512x512(理论上 4x4 网格正好),那就省了 6MB。这个账算下来,收益是实打实的。
4.2 UV重映射的数学过程
图集化的核心操作是 UV 重映射。原来一张贴图的 UV 范围是[0,1],现在它只占图集里的一小块矩形区域,所有 UV 都要按比例映射过去。
假设图集的某一块区域用归一化坐标表示是Rect(x, y, w, h),原来某个顶点的 UV 是(u, v),映射后的新 UV 就是:
newUV.x = x + u * w; newUV.y = y + v * h;代码写起来很简单,但有两个细节必须处理。
一是UV 内缩。图集里相邻的两张贴图如果直接挨着,采样时因为双线性插值和 mipmap,边界像素会互相渗透,出现明显的接缝或者颜色溢出。解决办法是把每张贴图的 UV 区域向内收缩半个到一个像素:
float insetX = 1.0f / atlasWidth; float insetY = 1.0f / atlasHeight; Rect safeRect = new Rect( rect.x + insetX, rect.y + insetY, rect.width - insetX * 2, rect.height - insetY * 2 );二是mipmap 下的内缩量要加倍。mipmap 层级越高,采样覆盖的范围越大,只有一级内缩在高 mip 下还是会有渗色。如果场景里物体会远距离观看,内缩量按两到三个像素算比较稳妥。
4.3 自研图集打包器 vs Unity内置方案 vs 第三方工具
图集怎么生成,有三条路。
自研打包器。用Texture2D.SetPixels或者Graphics.CopyTexture把多张贴图写进一张大图,配合一个简单的矩形装箱算法(比如 Skyline 或者 MaxRects)。好处是可控,能跟项目的资源管线深度绑定。坏处是要处理压缩格式、mipmap 生成、平台差异,工作量不小。
Unity 内置的 SpriteAtlas。这个东西设计上是给 Sprite 用的,但生成的图集本身是一张普通 Texture2D,可以通过SpriteAtlas.GetSprite拿到对应的 Sprite 和 UV 信息。如果你场景里的贴图本来就在 Sprite 体系里,这条路最省事。缺点是可控参数少,压缩格式和 padding 的设置有限。
第三方工具。Mesh Baker 是这类需求里最常见的选择,它把 Mesh 合并、材质合并、图集打包三件事打包在一起,编辑器界面可以直接操作。用它做原型验证很快,但接入正式管线时要考虑许可证和版本兼容。
我的选择是:原型阶段用现成工具验证收益,确认值得做之后,再根据项目实际情况决定自研还是继续用。自研的临界点大概在"你有超过三种资源类型需要打包,且内置方案覆盖不了"的时候。
4.4 图集参数:Padding、压缩格式、mipmap、Read/Write
图集的参数配置直接决定了最终效果和性能,这里逐项说。
Padding是每张贴图之间的间隔像素。设置太小会有渗色,太大浪费空间。经验值是 2 到 4 像素,配合 UV 内缩使用。如果贴图存在重复边缘(比如可平铺的墙砖),padding 还要再大一些。
压缩格式要按平台选。移动端优先 ASTC,能同时兼顾质量和体积;ETC2 兼容性更好但颜色精度差一些。PC 端可以用 DXT5 或者 BC7。注意图集一旦确定压缩格式,里面所有贴图都会被统一处理,原本用不同格式的贴图会被拉平,质量损失要提前评估。
mipmap建议开启,尤其是场景跨度大的情况。关掉 mipmap 虽然省 33% 显存,但远处物体会出现严重的闪烁和锯齿。
Read/Write Enabled这个选项,图集在打包阶段需要打开,因为要往里面写像素。打包完成、作为运行时资源之后,一定要关掉,否则 CPU 侧会保留一份完整副本,显存占用翻倍。这个坑我在一个项目里踩过——图集资源忘了关 Read/Write,包体大了几十兆,运行时内存也高得离谱。
5. 踩坑清单:渗色、光照贴图错乱、法线丢失与内存反噬
5.1 图集边缘渗色与UV内缩半像素
渗色是最普遍的图集问题,表现是物体边缘出现一圈不该有的颜色,或者相邻物件的颜色互相污染。
成因有两个。一是双线性插值:采样点落在图集边界时,会取到相邻贴图的像素。二是 mipmap 生成:低层级 mipmap 是把相邻像素平均得到的,如果图集里两张贴图紧挨着,平均之后边界就混了。
除了前面说的 UV 内缩,还有个辅助手段是给每张贴图边缘做"扩展填充"(edge padding / dilation),把这贴图最外圈的像素向外复制几个像素,这样即使采样越界,取到的也是自己的颜色。这个操作在生成图集的阶段顺便做掉,成本很低。
实测下来,UV 内缩一个像素 + 边缘扩展两个像素的组合,能解决 95% 以上的渗色问题。剩下的 5% 通常是贴图本身带透明通道,需要在 Shader 里配合_AlphaClip或者预乘 alpha 处理。
5.2 光照贴图UV2与合并的冲突
这是合并流程里最容易被忽略、后果也最严重的一个坑。
Unity 的烘焙光照依赖每个 Mesh 的 UV2 通道和 Lightmap 索引。静态物体合并之后,原本各自对应不同 Lightmap 的物体变成了一个 Mesh,但 Lightmap 索引只能有一个。结果就是光照全部错位,原本亮的区域变暗,阴影位置完全对不上。
正确的处理方式有三种。一是在合并时同步合并光照数据,把每个子网格对应的 Lightmap 区域重新映射到一张新的 Lightmap 上,这是最完整的做法,但需要自己实现。二是合并后重新烘焙,让 Unity 为新的合并 Mesh 生成新的 Lightmap,代价是烘焙时间要重来。三是把合并后的物体排除在烘焙之外,改用实时 GI 或者顶点光照。
我在项目里一般选第二种,因为实现成本最低。合并工具负责生成新的 Mesh 并替换原物体,然后重新跑一次烘焙。缺点是每次改场景布局都要重烘,所以在流程上要把合并放在美术定稿之后。
顺便说一句,CombineMeshes在早期版本有hasLightmapData参数用于处理光照贴图索引,后来这个参数被移除了,现在需要自己处理。如果你在网上看到带这个参数的示例代码,那是老版本的写法。
5.3 法线、切线、顶点色、UV2在合并中的保留
CombineMeshes会保留源网格的哪些顶点属性,取决于所有源网格的属性集合。如果参与合并的网格属性不一致——比如一部分有切线,一部分没有——合并结果可能出现属性缺失或者对齐错误。
常见的表现是合并后模型变得"死黑",或者光照呈现出奇怪的平面感。这通常是因为切线丢失,导致法线贴图无法正确解码。
保险做法是合并前统一属性集合。写一个预处理步骤,遍历所有源网格,检查是否有tangents、colors、uv2,缺的就补上默认值(切线用Vector4(1,0,0,-1),顶点色用白色,UV2 用零)。
public static void EnsureAttributes(Mesh mesh) { if (mesh.tangents == null || mesh.tangents.Length == 0) { int count = mesh.vertexCount; var tan = new Vector4[count]; for (int i = 0; i < count; i++) tan[i] = new Vector4(1, 0, 0, -1); mesh.tangents = tan; } if (mesh.colors == null || mesh.colors.Length == 0) { int count = mesh.vertexCount; var cols = new Color[count]; for (int i = 0; i < count; i++) cols[i] = Color.white; mesh.colors = cols; } }另外要注意,属性一旦补齐,网格的内存占用会上升,因为多存了这些数据。如果场景里根本不用顶点色,那就别补,让它保持缺失状态反而更省。
5.4 内存与加载时间的反噬
合并的收益是运行时的 draw call,代价是加载时的一次性开销和常驻内存的增加。
一次性开销来自合并本身——要把所有源网格读进来、变换、写入新网格。如果有几万个物体,这个过程在低端设备上可能要几秒甚至十几秒。所以合并一定要放在编辑器里离线做,把结果保存成资产,运行时直接加载,绝对不要在运行时动态合并。
常驻内存的增加来自索引格式升级和顶点属性补齐。UInt32索引让每个索引多占 2 字节,百万顶点的网格索引部分大约 4MB。如果合并前本来是分开的十几个小组,合并后总内存可能反而上升。这个账要算清楚,我的做法是在编辑器里跑一遍合并,把合并前后的 Profiler 数据对比一下,确认净收益再决定是否保留。
还有一个容易被忽视的点:合并后的大网格无法做细粒度剔除。原本分散的物体可以被视锥剔除掉大部分,合并后只能整体剔除。如果场景开阔,摄像机只能看到一小部分,合并反而可能让实际渲染的顶点数增加。这个问题在室内场景里不明显,但室外大世界场景里非常突出。
6. 一套可复用的合并工具:从分组策略到验证闭环
6.1 目录组织与资产化输出
工具的第一步是明确输出到哪里。我习惯在项目里建一个目录结构:
Assets/ SceneAssets/ {SceneName}/ CombinedMeshes/ // 合并后的 Mesh 资产 Atlases/ // 生成的图集贴图 Materials/ // 合并后的材质按场景划分,好处是场景卸载时可以整目录一起释放,不会和其他场景的资产混在一起。合并后的 Mesh 名字里带上原始分组信息,方便排查问题,比如Combined_Props_Group01。
生成资产要在编辑器代码里调用AssetDatabase.CreateAsset,不要用运行时创建的临时 Mesh,否则重启编辑器就丢了。
6.2 分组策略:按材质、按贴图、按空间距离
分组是决定合并成败的关键步骤,分得太粗会丢失剔除粒度,分得太细又降不下来 draw call。
我的默认策略是三层过滤:
第一层按材质分组。拥有相同sharedMaterial的物体分到一组,这是最基础的收益来源。
第二层按贴图分组。如果材质不同但贴图可以打进同一张图集,把它们的物体合并到同一组,然后统一做图集化处理。
第三层按空间距离切分。对每一组内部,用简单的网格分块或者 K-means 聚类把空间上聚集的物体分到一起。分块的粒度控制在"单个块的包围盒不超过场景尺寸的十分之一"这个量级。
// 简单的空间分块示例 int CellSize = 20; // 每块 20 米 string GetGroupKey(Vector3 worldPos) { int cx = Mathf.FloorToInt(worldPos.x / CellSize); int cz = Mathf.FloorToInt(worldPos.z / CellSize); return $"{cx}_{cz}"; }实际用下来,这个方案在室内场景里效果很稳定。室外场景可能需要按视距分层,把远处的物体合并得更激进一些。
6.3 运行时加载与卸载
合并后的资产不要挂到场景里直接引用,那样会被场景一起加载,失去按需加载的意义。推荐用 Addressables 或者 AssetBundle 管理,按需加载。
加载时机上,我一般是跟随场景的加载流程——场景加载完成后,异步加载对应的合并资产,加载完成再移除原始的分散物体。这样用户在加载界面看到的是完整场景,加载完成后才切换到合并版本,视觉上不会有明显的跳变。
卸载时要记得把合并后的 Mesh 显式Resources.UnloadUnusedAssets或者通过 Addressables 的引用计数释放。大网格的 GC 时间比较长,最好在场景切换的过场时间做,不要在游戏中途释放。
6.4 用FrameDebugger核对合批结果
做完合并,一定要验证。最直接的工具是 FrameDebugger,打开之后看每一帧的绘制列表。
验证几个关键点:
- 总 Batches 数量是否明显下降。如果从 400 降到 20 以内,说明合并生效了。
- 单次
Draw Mesh的顶点数。如果某次绘制的顶点数巨大(十几万),说明这个格子合并得太激进,需要考虑再切分。 - SetPass Calls 是否跟着下降。如果 Batches 降了但 SetPass 没降,说明材质还没真正统一。
- 有没有出现
Draw Mesh (Dynamic)之类的回退。如果有,说明有部分物体没被合并进去。
在真机上验证时,记得把 Multiview 打开再看一遍,因为 XR 下左右眼的提交是合并的,统计方式和平板渲染不同。
另外,我习惯在合并后的 GameObject 上挂一个简单的脚本,把合并前的物体数量、合并后的 Batches 数量、合并前后的顶点总数打印到 Console 里。这样每次改场景,日志里就能看到收益变化,不至于合并后反而变慢却没人发现。
7. 最后聊几句我踩过的实际问题
第一次做完这套流程的时候,我以为合并完就万事大吉了,结果真机上跑起来发现帧率只涨了一点点。查了半天,原因是合并后的大网格把整个场景包进去了,视锥剔除完全失效,每帧都在渲染全部顶点。后来改成按 20 米分块合并,帧率才真正起来。
还有一次是图集的问题。我在编辑器里看效果完全正常,打包到安卓上一跑,模型边缘全是黑边。原因是编辑器默认用的是未压缩格式,移动端会走 ETC2 压缩,压缩块的边界和贴图边界对不齐,导致采样错位。解决办法是把图集尺寸对齐到压缩块大小(ETC2 是 4x4,ASTC 按块大小),并且每张贴图的 UV 区域也对齐到块边界。
另外一个反复提醒自己的点是:合并这个操作的收益判断,一定要用目标设备的真机数据,不要用编辑器里的帧率做决策。编辑器里有各种缓存和优化,和真机差距非常大。我在 PICO4 上测的时候,编辑器和真机的 draw call 收益比例能差一倍以上。
如果你正在做一个道具量大的场景,我的建议是先别急着写工具,先拿一个子场景手动合并一次,用 Profiler 看看实际收益有多大。如果收益明显,再投入时间做自动化。合并这件事本身不复杂,复杂的是分组策略、UV 重映射和各种边角情况,而这些只有在真实场景里跑过一遍才能摸清楚。