上周帮一个做数字孪生的团队看现场,他们厂区场景里塞了 1400 多个零件模型,明明显卡不差,帧率却死活上不去,Profiler 里 Batches 常年一千二三百。问了才知道,之前有人做过一轮 Mesh 合并,把同一个小区域里的模型合成一个 Mesh 了,跑起来一看——Batches 只掉了 40 来个。原因特别典型:Mesh 合并了,material 没合并,八个材质球还是八个 submesh,Unity 照样按材质切八次状态。这也是我在 Unity 里做 Mesh 合并、材质合并、贴图合并这一整套优化时最常见的误区。Mesh 合并不是终点,它只是把顶点数据打包到了一起;真正决定批次数量的是材质和贴图。这篇内容就是把我这几年在工业可视化、VR 应用、小游戏项目里反复用到的这套合并流程完整拆开讲:材质合并怎么归并属性差异,贴图合并的 UV 重映射怎么算,Mesh 合并的 submesh 怎么规划,以及合并之后为什么模型会变紫、会被相机误剔除、内存反而涨了。不论你是刚开始接触 Unity 优化的新手,还是已经在做 PICO4 这类一体机项目的老手,只要场景里存在大量重复或近似材质的小物件,这套东西都用得上。
1. 先把问题看清楚:DrawCall 到底是被谁"卡"住的
1.1 一个 DrawCall 的构成,以及我踩过的那个坑
要合并,先得知道自己在合并什么。Unity 里一个不透明的 MeshRenderer,如果它的 Mesh 有 N 个 submesh、Renderer 上挂了 N 个材质,那这一帧它就会贡献 N 个 DrawCall,每个 DrawCall 前后还伴随着一次状态切换,也就是 Frame Debugger 里能看到的 SetPass Call。DrawCall 的数量取决于"这个网格要被几套渲染状态画几遍",而渲染状态里最贵的东西就是 Shader 和它的纹理绑定。
所以当你的 Mesh 合并脚本只做了Mesh.CombineMeshes(instances, false, true)——注意第二个参数传了 false——那合并后的 Mesh 会保留每一个原始子网格原本的 submesh 编号,材质数组也跟着变长。合并前 100 个物件如果用了 12 个材质,合并后就是 1 个 MeshRenderer、1 个 Mesh、12 个 submesh、12 个 DrawCall。你省掉了 99 次 Transform 层级计算和剔除开销,但 DrawCall 只从 100 降到 12。这就是我开头说的那个团队遇到的情况,他们看到"合并成功"就以为完事了。
再往下拆一层:状态切换贵在哪儿?GPU 每次都要重新绑定 Shader Program、上传材质常量缓冲区、绑定纹理。现代硬件对常量缓冲的切换已经很快了,真正扎手的是纹理绑定和 Shader 变体切换——如果两个材质用的是同一个 Shader 但 keyword 组合不同,实际用的是两个不同的 Shader Program,切换代价直接翻倍。这解释了为什么"材质合并"和"贴图合并"必须绑在一起做:只统一材质参数,不统一贴图,纹理绑定次数一点没少;只统一贴图,不做材质归并,SetPass 照样切不停。
1.2 Mesh 合并、材质合并、贴图合并三者的关系与优先级
我习惯把这三件事理解成一条流水线上的三道工序,顺序不能乱。
材质合并解决的是"状态切换次数"。它把原本十几个甚至上百个材质球,归并成尽量少的几个,前提是这些材质的 Shader、关键字组合、渲染队列、混合模式必须完全兼容。材质合并本身不需要动网格数据,它改的是渲染器上的材质引用。
贴图合并解决的是"材质能不能合并"这个前置条件。两个材质参数完全一样,只是主贴图不同,你没法把它们算作同一个材质——除非把两张贴图合到一张图集里,再把 UV 重映射到对应的子区域。贴图合并不直接减少 DrawCall,但它让材质合并从"只能合并完全相同的材质"变成"可以合并只有贴图不同的材质",适用范围一下子扩大十几倍。
Mesh 合并解决的是"提交和剔除的开销"。把一堆小网格拼成一个大网格,Unity 只需要做一次视锥体剔除、一次 Transform 计算、一次顶点缓冲绑定。但它对 DrawCall 的贡献完全取决于前面两步做得怎么样。
我的实际做法永远是:先按材质分组 → 组内做贴图图集化 → 最后按组合并网格。如果反过来先合并网格,你会发现合并出来的大网格里混着几十种材质,后面想拆都拆不动,只能推倒重来。这个顺序在 PICO4 那种一体机项目里尤其重要,因为一体机的填充率和带宽都很紧张,返工的成本比 PC 上高得多。
1.3 合并前的三个前置判断,不做完别动手
动手之前有三件事必须先确认,否则合并出来的东西要么不能用,要么性能反而更差。
第一是动态还是静态。参与合并的物件,位置、旋转、缩放必须是相对固定的。合并之后它们共享一个 Transform,任何一个单独动起来都会带着整块网格一起动。如果你的场景里有会开关的阀门、会旋转的风机、会升降的货架,这些必须排除在合并范围之外。我一般会在脚本里检查物体身上有没有 Animator、有没有挂任何会改 Transform 的组件,有就直接跳过。
第二是透明还是不透明。这是最容易被忽略的一条。Unity 对不透明物体做前到后排序来提升 Early-Z 命中率,对透明物体做后到前排序来保证混合正确。一旦你把一堆半透明物件合进同一个 Mesh,Unity 只能整体按这个 Mesh 的中心点排序,Mesh 内部的先后顺序变成了 submesh 的索引顺序,于是玻璃、水面、烟雾就会开始互相穿帮。我的处理原则是:不透明物体放心合,Alpha Test(Cutout)的可以合但要保证它和你的图集边缘 Padding 配合好,Alpha Blend 的坚决不做跨物体合并。
第三是光照。用了烘焙光照贴图的场景,合并后的 Mesh 只能对应一张光照贴图的索引,所以参与合并的所有 Renderer 的lightmapIndex必须相同,lightmapScaleOffset也要妥善处理。用了 Realtime GI 的场景,合并会破坏 UV2 的连续性,间接光会明显变脏。实时光照下合并,多个小物件共用一个巨大的包围盒,阴影投射和接收的范围都会变,阴影可能会突然糊掉或者出现条纹,这一点在特性热词里提到的"unity 阴影问题"里非常常见。
2. 材质合并:把 N 个材质球归并成 1 个
2.1 材质球为什么是 DrawCall 的直接元凶
很多人对材质的理解停留在"它决定物体长什么样",其实在渲染管线里,材质球就是一份渲染状态说明书。它包含 Shader 引用、Shader 的关键字开关集合、贴图槽位绑定、颜色和数值参数、渲染队列、混合模式、深度测试开关等等。GPU 在准备画一批几何之前,会依次检查这些状态和上一批是否一致,不一致就要重新配置。
这里有个细节值得单独说:Unity 里Material实例化和sharedMaterial的区别。你写renderer.material.color = Color.red这种代码,Unity 会为这个渲染器克隆一份材质出来,场景里同一个材质球用在一百个物件上就变成一百零一份材质。在编辑器里点开 Profiler 看 Material 数量经常能看到几百上千,一大半是这么来的。所以做合并之前的第一件事,是全局扫一遍有没有不该出现的材质实例,把读操作统一改成sharedMaterial,写操作要么改资产要么用MaterialPropertyBlock。光是这一步,有些项目就能把 SetPass Call 砍掉三成。
2.2 三条合并路线:复用法、图集法、顶点色法
材质合并不是只有一条路,我按适用场景分成三种,实际项目里往往是组合使用。
| 方案 | 适用条件 | 优点 | 局限 |
|---|---|---|---|
| 复用同一材质实例 | 物件本来就该长一样,比如地板砖、栏杆、螺丝 | 零成本,最稳 | 只对完全相同的物件有效 |
| 图集法统一材质 | 贴图不同,但 Shader、参数、渲染队列一致 | 覆盖面最广,收益最大 | 需要图集工具链和 UV 重写 |
| 顶点色烘焙差异 | 只有颜色或亮度不同,贴图可以共用一张灰度图 | 省贴图,图集可以做得更小 | 只能表达低频颜色差异,受顶点密度限制 |
复用法是入门首选,也是性价比最高的。我在一个工业场景里做过统计,同一个厂区里 60% 以上的物件其实共用的是同一种"金属灰"材质,但因为建模时是从不同资产库导入的,每个人各自带了一套材质,Shader 一样、参数一样,只是实例不同。写个脚本按 Shader 名加参数哈希做一次归并,DrawCall 当场掉一半。
图集法是主力方案,也是这篇内容的重点。它把原本分散的多张贴图合成一张,所有物件共用一个材质,DrawCall 直接压到 1。代价是你必须有一套能打包并重写 UV 的工具,还要处理压缩格式、图集尺寸、边缘溢出这些工程细节。
顶点色法适合"贴图相同、只是染色不同"的情况,比如同一批不同颜色的零件。做法是保留一张共用的灰度或细节贴图,把每个物件的颜色烘进顶点色,Shader 里用顶点色去乘采样结果。它的隐患是顶点密度低的时候颜色过渡会形成明显的插值色块,所以只适合硬边、单色的物件。
2.3 材质属性差异的归并策略
真正难的不是"能不能合",而是"差异怎么办"。我把常见属性和处理方式整理成下面这张表,这套判断逻辑我在好几个项目里都用过,基本能覆盖绝大多数情况。
| 属性 | 差异处理方式 | 备注 |
|---|---|---|
_MainTex | 合并进图集,重写 UV | 只有 Tiling=(1,1)、Offset=(0,0) 才安全 |
_Color/ 顶点色 | 烘进顶点色或烘进贴图 | 颜色差异小优先顶点色 |
_MainTex_ST平铺偏移 | 预烘焙到贴图,或放弃合并 | 平铺系数大于 1 的物件单独一组 |
_Metallic/_Glossiness | 用遮罩图或统一取平均值 | 视觉差异不明显时可接受 |
_BumpMap法线图 | 合并进第二张图集 | 增加一张图集,DrawCall 仍是 1 |
关键字开关(如_NORMALMAP) | 绝不混合,必须分组 | 混用会导致 Shader 变体翻倍 |
| 渲染队列 / 混合模式 | 绝不混合,必须分组 | 透明与不透明混排必定穿帮 |
| Shader 本身 | 绝不混合,必须分组 | 不同 Shader 不可能合成一个材质 |
这张表里最需要反复强调的是"绝不混合"那三行。我见过有人为了追求极致的合并率,把 Cutout 和不透明材质强行统一成一个材质的,结果边框全变成了黑边;也见过把_EMISSION关键字丢掉之后,本来会亮的指示灯全灭了。关键字集合的处理有个技巧:不是取并集,也不是取交集,而是按关键字组合精确分组。因为取并集会给所有物件都开上_NORMALMAP,白白多出采样开销和变体数量;取交集则会让本来有法线的那部分丢掉法线效果。
2.4 用脚本把材质分组这件事自动化
手工分组在几十个物件的场景里还行,上千个就必须写脚本。下面这个分组器的核心思路是:用 Shader 名、关键字集合、渲染队列、混合模式拼成一个字符串当 key,同 key 的渲染器归到一组。
using System.Collections.Generic; using System.Text; using UnityEngine; public static class MaterialGrouper { // 拼一个能唯一代表“渲染状态”的 key static string BuildKey(Renderer r) { var mat = r.sharedMaterial; if (mat == null || mat.shader == null) return null; var sb = new StringBuilder(64); sb.Append(mat.shader.name).Append('#'); // 关键字排序后拼接,保证顺序无关 var kws = mat.shaderKeywords; System.Array.Sort(kws); foreach (var k in kws) sb.Append(k).Append(','); sb.Append('#').Append(mat.renderQueue); sb.Append('#').Append((int)mat.GetFloat("_SrcBlend")); sb.Append('#').Append((int)mat.GetFloat("_DstBlend")); sb.Append('#').Append(mat.GetTag("Queue", false)); // 主贴图尺寸也作为参考,方便后面判断图集能不能装下 var tex = mat.mainTexture; if (tex != null) sb.Append('#').Append(tex.width).Append('x').Append(tex.height); return sb.ToString(); } public static Dictionary<string, List<MeshFilter>> Group( IEnumerable<Renderer> renderers, bool skipTransparent = true) { var map = new Dictionary<string, List<MeshFilter>>(); foreach (var r in renderers) { if (r is SkinnedMeshRenderer) continue; // 蒙皮网格不参与 if (r.GetComponent<Animator>() != null) continue; // 有动画的跳过 var mat = r.sharedMaterial; if (mat == null) continue; if (skipTransparent && mat.renderQueue >= 3000) continue; // 透明组单独处理 var key = BuildKey(r); if (key == null) continue; if (!map.TryGetValue(key, out var list)) { list = new List<MeshFilter>(); map[key] = list; } var mf = r.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) list.Add(mf); } return map; } }这段代码有几个地方是我踩过坑之后加上去的。一是关键字必须排序后再拼,否则同一个材质因为shaderKeywords数组顺序不同会被分到两组。二是跳过SkinnedMeshRenderer,蒙皮网格的顶点每帧由骨骼驱动,硬合并没有意义,除非你改用Mesh.BakeMesh做快照,那是另一个话题。三是把渲染队列大于等于 3000 的直接排除,透明物体单独走一条流程。
拿到分组结果之后,先别急着合。花两分钟看看分组统计:如果某个组只有两三个物件,合它意义不大,反而增加管理复杂度;如果一个组有几百个物件,那这一组就是你的主战场,值得花时间做图集。我在一个项目里做过这样的取舍——原本 1800 个物件分成了 60 多组,最终只对人数最多的 8 个组做了完整合并,DrawCall 就从 1400 降到了 190,投入产出比最高。
3. 贴图合并:UV 重映射的数学与图集打包实操
3.1 合图带来的收益不只是批次
先纠正一个常见的认知偏差:很多人以为合并贴图就等于减少 DrawCall。严格来说不是,合图减少的是"纹理切换",而纹理切换是 SetPass Call 的一部分。它真正的价值在三个地方。
第一,它让材质合并成为可能。原本 50 个物件 50 张不同的贴图,你没法把它们算成一个材质;合图之后它们共用一张大图,材质参数一致,就变成了一个材质。
第二,它显著改善纹理采样的缓存命中率。GPU 采样纹理时是按块读取的,如果同一批渲染反复在几十张小图之间跳,纹理缓存会不断失效。合成一张大图之后,相邻物件采样的 UV 落在同一个纹理块里,缓存命中率会明显提升。这个收益在移动端一体机上特别明显,PICO4 这类设备虽然单眼分辨率高,但显存带宽有限,渲染时频繁切纹理很容易导致掉帧。
第三,它降低了合批失败的概率。Unity 的动态合批和 SRP Batcher 都有严格的兼容条件,材质实例数量越少、纹理绑定越稳定,能成功合批的可能性就越高。
3.2 UV 重映射的计算过程,手推一遍
图集的本质是把多张图的像素搬到一张大图的某个矩形区域里,然后把每个顶点原本指向子图的 UV,换算成指向大图的 UV。这个换算不复杂,但容易在几个细节上翻车。
假设我把一张 256×256 的子图放进一张 1024×1024 图集的左下角偏移 (256, 512) 的位置。注意 Unity 里PackTextures返回的 Rect 是以左下角为原点的归一化坐标,所以这个子区域对应的 Rect 是:
rect.x = 256 / 1024 = 0.25 rect.y = 512 / 1024 = 0.5 rect.width = 256 / 1024 = 0.25 rect.height = 256 / 1024 = 0.25子图上一个原本的 UV 坐标 (0.5, 0.5)(子图正中心),映射到图集后:
newU = rect.x + u * rect.width = 0.25 + 0.5 * 0.25 = 0.375 newV = rect.y + v * rect.height = 0.5 + 0.5 * 0.25 = 0.625验证一下:图集里这个子区域横跨 U 从 0.25 到 0.5,中心就是 0.375,对得上。这就是重映射的全部数学,写成函数只有一行:
static Vector2 RemapUV(Vector2 uv, Rect rect) { return new Vector2(rect.x + uv.x * rect.width, rect.y + uv.y * rect.height); }但请注意,这个公式成立有一个硬前提:材质本身不能有平铺和偏移。如果材质的_MainTex_ST是 (2, 2, 0, 0),意味着顶点 UV 在 0 到 1 之间会被采样两次,也就是贴图重复铺了两遍。这种情况下 UV 会超出子图范围,直接映射到图集上就会采到隔壁子图的像素,出现花屏。所以我在工具里加了这样一道检查:
static bool CanPack(Material mat) { if (mat == null || mat.mainTexture == null) return false; var st = mat.mainTextureScale; var off = mat.mainTextureOffset; // 允许万分之一的误差,浮点比较别用 == return Mathf.Abs(st.x - 1f) < 1e-4f && Mathf.Abs(st.y - 1f) < 1e-4f && Mathf.Abs(off.x) < 1e-4f && Mathf.Abs(off.y) < 1e-4f; }不满足条件的物件怎么办?两条路:一是把它单独分一组,不参与图集;二是提前把平铺"烘死"——在建模阶段就把 UV 展开成实际需要的重复次数,让平铺系数回到 1,再进图集。前者省事,后者收益更高,看你项目里这类物件多不多。
还有一个非常隐蔽的问题:UV 的 V 轴方向。Unity 的纹理坐标原点在左下角,但有些第三方建模工具导出的 UV 是左上角原点,导入时靠 FBX 的导入设置做翻转。如果你的资产混用了不同来源,重映射之后会出现上下颠倒的贴图。我的做法是工具跑完之后随机抽查几张,看一眼就能发现,不要等到打包出包才查。
3.3 图集打包的三种实现方案对比
| 方案 | 实现成本 | 适用阶段 | 我的使用建议 |
|---|---|---|---|
| Unity 内置 SpriteAtlas | 低,官方支持 | 2D 精灵为主 | 只适合 SpriteRenderer,对 Mesh 无用 |
运行时Texture2D.PackTextures | 中,几十行代码 | 运行时动态加载的资产 | 加载耗时和内存峰值要评估 |
| 编辑器离线打包工具 | 高,但一劳永逸 | 固定的场景资产 | 我的首选,包体可控、可反复迭代 |
| 第三方图集工具 | 低 | 已有工具链的团队 | 注意版本兼容和授权 |
我的首选是编辑器离线打包。理由是:运行时打包虽然灵活,但每次启动都要做一遍读取像素、拼图、上传 GPU 的操作,几百张图跑下来几百毫秒都很正常,VR 应用里这个启动卡顿非常影响体验。而离线打包是在构建期完成的,运行期只是加载一张已经压好的图,成本几乎为零。
如果你确实需要运行时打包,下面这个函数是我常用的版本,里面处理了"贴图不可读"这个最常见的报错来源:
using UnityEngine; public static class AtlasBuilder { // 把不可读的贴图拷贝成可读的临时贴图 static Texture2D MakeReadable(Texture2D src) { if (src.isReadable) return src; var rt = RenderTexture.GetTemporary(src.width, src.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); Graphics.Blit(src, rt); var prev = RenderTexture.active; RenderTexture.active = rt; var copy = new Texture2D(src.width, src.height, TextureFormat.RGBA32, false); copy.ReadPixels(new Rect(0, 0, src.width, src.height), 0, 0); copy.Apply(); RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt); return copy; } public static Texture2D Build(Texture2D[] sources, int padding, int maxSize, out Rect[] uvRects) { var readable = new Texture2D[sources.Length]; for (int i = 0; i < sources.Length; i++) readable[i] = MakeReadable(sources[i]); // 起始贴图必须是 2 的幂,否则 PackTextures 会自行扩张,浪费显存 var atlas = new Texture2D(2, 2, TextureFormat.RGBA32, false); uvRects = atlas.PackTextures(readable, padding, maxSize, false); // 用完的临时拷贝及时释放,不然内存会翻倍 for (int i = 0; i < readable.Length; i++) if (readable[i] != sources[i]) Object.Destroy(readable[i]); return atlas; } }这里有两个必须注意的点。一是PackTextures要求所有输入贴图可读,而项目设置里为了省内存通常把贴图设成不可读,所以必须走一遍Graphics.Blit拷贝。二是这些临时拷贝用完一定要Destroy,否则图集做完了内存里还挂着两份数据,在移动端很容易触发内存告警。
3.4 图集参数怎么定:尺寸、Padding、压缩、Mipmap
参数定不好,图集反而会成为性能负担。我把关键参数和取值经验列一下。
尺寸。常见的取值是 1024、2048、4096。判断依据是"这张图集要装下多少张子图的总像素量"。我一般按总像素开根号再上取到 2 的幂。比如 20 张 256×256 的子图,总像素 131 万,开根号约 1145,那就用 2048×2048。注意移动端不要轻易上 4096,虽然现在设备支持,但一张 RGBA32 的 4096 就是 64MB,加上 Mipmap 还要多三分之一,一不小心就把内存打爆。
Padding。也就是子图之间留的缝。留缝的原因有两个:一是防止 GPU 在双线性插值时采到隔壁子图的像素,二是 Mipmap 生成时会做下采样,层级越高需要的边缘缓冲越大。我的经验值是:不开 Mipmap 用 2 到 4 像素,开 Mipmap 用 8 像素起,子图本身越小,Padding 相对要越大。
压缩格式。这是一个必须按平台分别设置的参数,用 Unity 的平台覆盖功能来做。PC 端我用 BC7(质量最好)或者 DXT5;Android 端用 ASTC,块大小按贴图类型选——颜色图用 6×6,法线图用 4×4 或 5×5,遮罩图可以用 8×8。iOS 用 ASTC 或 PVRTC。千万别让图集走 RGBA32 无压缩,那是显存杀手。这里有个小技巧:不同用途的子图(比如颜色图和遮罩图)不要混在同一张图集里,因为它们的压缩需求完全不同,混在一起就只能取折中方案。
Mipmap。这是个需要权衡的开关。开了 Mipmap,远处的物件采样高频层,能显著减少闪烁和摩尔纹,代价是显存多 33%。对于场景里距离变化大的物件(比如厂区里从近处一直延伸到远处的管线),我建议开;对于永远在固定距离显示的小物件,可以关掉省内存。
Read/Write。图集在编辑器里打包完之后,一定要把Read/Write Enabled关掉。开着的话 Unity 会在内存里保留一份 CPU 侧的像素拷贝,一张 2048 的图就是 16MB 白扔。这是我在好几个项目里查到过的隐性问题。
4. 完整实操:把一个散件场景合成单个 MeshRenderer
4.1 步骤一:候选筛选与数据盘点
前面讲的原理落到具体流程,第一步永远是把场景扫一遍,出一份可读的清单。我的做法是选中一个根节点,递归收集所有子物体上的MeshRenderer,然后按材质分组统计,输出一张表:组名、物件数、总顶点数、总三角形数、贴图数量、最大贴图尺寸、预估合并后顶点数。
筛选条件我固定用这几条:排除SkinnedMeshRenderer、排除带Animator的、排除带MeshCollider且需要在合并后单独碰撞的(合并后碰撞体形状会变)、排除static标记不一致的(静态标记不一致会影响静态合批和遮挡剔除)、排除材质为 null 或用的是内置Default-Material的(这类通常是模型导入问题,先修数据再谈合并)。
顶点数要特别留意。Unity 的 Mesh 索引格式分 UInt16 和 UInt32,前者最多支持 65535 个顶点。如果一组物件的顶点总数超过这个值,必须把indexFormat设成IndexFormat.UInt32,否则会看到模型的一部分消失或者索引错乱。这是个非常典型的"合并成功但画面不对"的原因。
4.2 步骤二:生成图集并重写 UV
分组完成后,对每个需要合并的组做图集。流程是:收集组内所有材质的主贴图,去重(很多物件共用同一张贴图,去重之后图集能小一大截),打包成图集,然后遍历组内每个 Mesh 的 UV 通道,按它对应材质的贴图在uvRects里的索引做重映射。
static Mesh RemapUV(Mesh source, Rect uvRect) { var mesh = Object.Instantiate(source); var uv = mesh.uv; for (int i = 0; i < uv.Length; i++) { uv[i] = new Vector2( uvRect.x + uv[i].x * uvRect.width, uvRect.y + uv[i].y * uvRect.height); } mesh.uv = uv; return mesh; }如果组里有法线贴图,第二套 UV 也要做同样的处理,而且法线贴图要打包成对应的第二张图集。这里要提醒一句:mesh.uv2、mesh.uv3这些通道如果在原始资产里不存在,访问会返回空数组,别直接索引。
UV 重写完之后,用一个统一的新材质替换掉组内所有渲染器的材质。新材质的 Shader、关键字组合、渲染队列从组内材质继承,主贴图指向图集。颜色、金属度、光滑度这些属性如果组内有差异,就在这一步按前面表格里的策略处理——差异小的取平均值,差异大的烘进顶点色。
4.3 步骤三:合并 Mesh 与 submesh 规划
到了这一步才能动网格。核心 API 是Mesh.CombineMeshes,它的四个参数决定了合并行为,我逐个说明我的用法。
第一个参数是CombineInstance数组。每个元素包含源网格、要取的 submesh 索引、变换矩阵、光照贴图的缩放偏移。变换矩阵我用transform.localToWorldMatrix,这样合并出来的网格在世界空间,之后把新物体放在世界原点即可。如果组内所有物件在同一个父节点下,用相对父节点的矩阵会更方便后续整体移动。
第二个参数mergeSubMeshes是整个流程里最关键的一个开关。传true表示把所有输入网格的子网格合并成一个,但前提是它们用的是同一个材质。这正是我们前面花大力气做材质合并和贴图合并的原因——只有做到这一步,这里才能传true,合并结果才是一个 submesh、一个 DrawCall。传false会保留原来的 submesh 结构,材质数组也要跟着传,DrawCall 等于 submesh 数量。
第三个参数useMatrices传true,表示使用每个CombineInstance里的变换矩阵。传false就是直接把顶点拼在一起,所有物件都会堆在原点。
第四个参数hasLightmapData,只有在确实要用光照贴图时才传true,并且要保证组内所有渲染器的lightmapIndex一致、lightmapScaleOffset正确传进CombineInstance。如果场景用的是纯实时光照,传false就行。
public static Mesh CombineGroup(List<MeshFilter> filters, Material sharedMat, bool useLightmap, out Bounds bounds) { var instances = new List<CombineInstance>(filters.Count); int totalVerts = 0; int lightmapIndex = -1; var root = filters[0].transform.root; foreach (var mf in filters) { var src = mf.sharedMesh; if (src == null) continue; totalVerts += src.vertexCount; var mr = mf.GetComponent<MeshRenderer>(); if (useLightmap) { if (lightmapIndex < 0) lightmapIndex = mr.lightmapIndex; // 光照贴图索引不一致就直接放弃烘焙数据 else if (lightmapIndex != mr.lightmapIndex) useLightmap = false; } instances.Add(new CombineInstance { mesh = src, subMeshIndex = 0, // 单材质,只取第 0 套 transform = root.worldToLocalMatrix * mf.transform.localToWorldMatrix, lightmapScaleOffset = mr.lightmapScaleOffset }); } var result = new Mesh { name = "Combined_" + sharedMat.name }; result.indexFormat = totalVerts > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16; result.CombineMeshes(instances.ToArray(), true, true, useLightmap); result.RecalculateBounds(); result.RecalculateNormals(); // 视情况,法线本来就有的话可以跳过 bounds = result.bounds; return result; }这里有个取舍要说清楚:RecalculateNormals要不要调。如果原始网格自带法线且没有做过非均匀缩放,直接用原来的就行,重算反而可能把硬边法线算成平滑法线,视觉效果会变。只有在合并过程中做了缩放,或者原网格是程序生成的没法线,才需要重算。
合并完成后创建一个新的 GameObject,挂上MeshFilter和MeshRenderer,把结果 Mesh 和统一材质赋上去,然后禁用或销毁原来的物件。我一般先禁用、验证没问题之后再删,给自己留个后悔药。
4.4 步骤四:验证、落盘与性能对比
验证这一步千万别省。我固定看四个地方。
Frame Debugger。打开之后找到合并后的那批渲染,看它是不是只占了一次 DrawCall,材质是不是只有一个,贴图绑定是不是只有一张图集。如果显示的还是多次,往上翻找原因,通常是材质数组没清干净或者 submesh 没合上。
Game 视图的 Stats 面板。看 Batches 和 SetPass Calls 的变化。Batches 降到接近 1 说明成功,如果 Batches 是 1 但 SetPass Calls 还是好几次,检查一下有没有别的组件(比如阴影、后处理)在额外提交。
Profiler。重点看 CPU 侧的Camera.Render、Drawing和BatchRendererGroup项,还要看 GPU 侧。DrawCall 降下来了但帧率没提升,说明瓶颈在别处,可能是填充率、可能是 Overdraw,别把功劳算错。
包体和内存。对比优化前后 Build 出来的包体大小和运行时的 Texture 内存占用。这个我下面会专门讲,因为合并之后内存涨的情况真的不少。
编辑器里我还习惯把合并结果存成资产,方便下次直接加载,也方便美术同学比对:
#if UNITY_EDITOR using UnityEditor; public static void SaveMeshAsset(Mesh mesh, string folder, string name) { if (!AssetDatabase.IsValidFolder(folder)) AssetDatabase.CreateFolder("Assets", System.IO.Path.GetFileName(folder)); var path = $"{folder}/{name}.asset"; AssetDatabase.CreateAsset(mesh, path); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } #endif存资产的时候记得把原始网格的Read/Write Enabled关掉,只保留合并结果的读权限到调试结束,正式出包前也关掉。我在一个项目里因为忘了关,构建出来的资源包里多带了 200 多 MB 的网格数据。
下面是我最近一个数字孪生项目的实测对比,供参考:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 物件数 | 1436 | 22 个合并体 + 37 个动态物件 | -95% |
| Batches | 1274 | 186 | -85% |
| SetPass Calls | 389 | 62 | -84% |
| 材质实例数 | 412 | 45 | -89% |
| 贴图数量 | 268 | 31 | -88% |
| Texture 内存 | 186 MB | 121 MB | -35% |
| 帧率(PICO4 单眼) | 41 fps | 68 fps | +66% |
5. 常见问题与排查技巧实录
5.1 合并后模型变紫、变黑、贴图错位
变紫是 Unity 里最经典的报错形态,它意味着材质用的 Shader 在当前平台编译失败或者压根没找到。合并之后变紫,八成是新材质的 Shader 引用丢了,或者你创建材质时用了Shader.Find而那个 Shader 没有被打进包。正确的做法是从组内原有的材质上直接读取 Shader 引用:newMat.shader = srcMat.shader,不要用字符串去找。如果是构建之后才变紫,去检查 Graphics Settings 里的 Shader 变体收集和 Always Included Shaders 列表。
变黑通常是三种原因。一是贴图的 sRGB 标记不对,颜色贴图应该是 sRGB,法线图和遮罩图应该是 Linear,标记错了整体亮度会明显偏差。二是新材质的_MainTex没赋值或者赋成了空,Shader 采样到一个默认的黑图。三是关键字丢失,比如原来材质开了_EMISSION,新材质没开,发光部分就黑掉了。关键字同步最稳的办法是创建新材质后跑一遍:
newMat.shaderKeywords = srcMat.shaderKeywords;贴图错位分两种情况。如果整张图错位到另一个子图的位置,那是 UV 重映射的 Rect 索引对错了,检查一下你的贴图到 Rect 的映射表是不是用了字典但字典被覆盖了。如果是纹理内部的采样偏移了半个像素,那多半是 Padding 不够,或者图集在导入时被 Unity 强制二次缩放(比如图集原始尺寸不是 2 的幂但 Max Size 设成了 2048),导致归一化的 Rect 和实际像素不再对应。解决办法是把图集尺寸强制做成 2 的幂,并且导入设置里的 Max Size 不要小于图集实际尺寸。
5.2 包围盒异常导致被剔除或剔除失效
包围盒这个问题在 VR 项目里特别突出。合并之后所有物件共用一个包围盒,这个包围盒会变得非常大。后果有两个方向:如果包围盒算得太小,物体会在你还能看到它的时候被视锥剔除掉,画面里出现突然消失的模型;如果包围盒算得过大,剔除几乎永远命中,等于白做了剔除优化。
包围盒异常最常见的原因是合并时用了错误的变换矩阵。比如你把世界空间的顶点拼进来,却把结果物体的 Transform 放在了某个非原点的位置,包围盒就整个飘走了。所以我在合并之后一定会做两件事:一是调RecalculateBounds,二是把新生成的对象放在世界原点(或父节点原点),零旋转零缩放。
还有一种情况是外包盒被手动覆盖了。Unity 里MeshRenderer.localBounds是可以手动设置的,有些人为了修剔除问题会手动指定一个值,结果换了个场景就失效了。如果你确实需要手动控制,记住localBounds是局部空间,世界空间的剔除盒是它经过 Transform 变换的结果,两者要一起考虑。
另一个跟包围盒相关的坑是:合并之后的巨大物体在任何相机角度下都"在视野内",导致它永远参与渲染。这在单眼渲染里问题不大,但在 VR 的双眼渲染里,两个相机各自做一次剔除,巨大的包围盒会让剔除判断完全失去意义。这是为什么我的合并粒度一般都是按区域或者按房间来分,而不是把整个场景合成一个 Mesh。分区之后,走到 A 房间时 B、C 房间的合并体可以被正常剔除掉,收益反而比合成一个更大。
5.3 内存和包体反而变大了
这是最反直觉的一类问题,合并明明是为了优化,怎么占的资源更多了?我见过三种典型情况。
第一种是图集尺寸失控。把几十张各不相同的小图硬塞进一张大图,图集被迫做到 4096,压缩之前的原始数据就是 64MB。而原本那几十张小图加起来可能只有 8MB。解决办法是控制图集的填充率,一般来说填充率低于 60% 就说明图集开太大了,应该拆成两张小的。另外一定要确保打包完的图集走的是压缩格式,不是 RGBA32。
第二种是 Read/Write 没关。图集可读意味着 CPU 侧保留一份拷贝,2048 的 RGBA32 就是 16MB,一个场景几张图集下去就是几十兆。合并完第一时间去导入设置里把 Read/Write Enabled 关掉。
第三种是网格数据膨胀。如果原始物件有很多是重复实例(比如 100 个一模一样的螺栓),合并之后这 100 份顶点数据全都实实在在地存下来了,原本靠 GPU Instancing 只需要存一份。这种情况就不该合并——判断标准是:如果一组物件的网格资源是同一个sharedMesh引用且数量很多,优先考虑 GPU Instancing 而不是合并。Instancing 只需要一次 DrawCall 且内存只有一份,比合并更划算。我在项目里的判断规则是:同网格实例数超过 20 的,走 Instancing;同网格实例数少但材质需要归并的,走合并。
5.4 常见问题速查表
| 现象 | 最可能的原因 | 排查方向 |
|---|---|---|
| 合并后 DrawCall 没降 | mergeSubMeshes传了 false | 检查合并后 Mesh 的 subMeshCount 和材质数组长度 |
| 模型消失一部分 | 顶点数超 65535 但索引格式是 UInt16 | 检查indexFormat是否设为 UInt32 |
| 材质变紫 | Shader 引用丢失或未打进包 | 从原材质复制 shader,检查 Always Included Shaders |
| 整体变暗/变亮 | 贴图 sRGB 标记错误 | 颜色图开 sRGB,法线/遮罩图关 sRGB |
| 贴图上下颠倒 | UV 原点方向不一致 | 检查 FBX 导入的 UV 翻转设置 |
| 半透明互相穿帮 | 透明物体被合并 | 把渲染队列 ≥3000 的排除出合并范围 |
| 物体提前被剔除 | 合并矩阵错误导致包围盒偏移 | 检查CombineInstance.transform的空间是否统一 |
| 内存暴涨 | 图集过大或 Read/Write 未关 | 查图集实际尺寸、填充率和导入设置 |
| 法线看起来发扁 | 重算法线破坏了硬边 | 原网格有法线时跳过RecalculateNormals |
| 光照贴图错乱 | lightmapIndex不一致 | 按 lightmapIndex 二次分组后再合并 |
我在实际项目里踩过的最大一个坑,是花了整整两天排查"合并后画面正常但帧率反而下降 15%"的问题。最后的结论是:一个本来靠 GPU Instancing 渲染的护栏阵列(同网格 240 个实例)被我合进了一个巨型 Mesh,顶点数据从一份变成 240 份,显存带宽被拖垮,同时那个巨大的包围盒让整片护栏在任何角度都要参与渲染。把这一组从合并列表里摘出来、改回 Instancing 之后,帧率比优化前还高了 20%。这件事之后我给自己定了一条规矩:动手合并之前先问一句"这组物件用 Instancing 是不是更合适",问完再动手,能省掉很多返工。
另外一个经验是关于迭代节奏的。合并这件事不要一次全做完,按组做、按区域做,每做完一组就用 Profiler 跑一次同一条相机路径,记录 Batches 和帧率。因为合并的收益不是你想象中那样线性叠加的——有些组合完是正收益,有些组合完是负收益,只有一组一组地测,才能找到那个真正的最优解。我现在的习惯是先做收益最大的那几组(物件多、材质相近、贴图小的),通常做完三到四组就能拿到整个优化 70% 的效果,剩下的长尾收益很低,投入产出比不划算。工具脚本也建议建好版本管理,场景资产一改就可能要重跑,脚本能跑通比脚本写得多漂亮重要得多。