ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity DrawCall优化:Mesh、材质、贴图合并与UV重映射

Unity DrawCall优化:Mesh、材质、贴图合并与UV重映射 上周帮一个做数字孪生的团队看现场他们厂区场景里塞了 1400 多个零件模型明明显卡不差帧率却死活上不去Profiler 里 Batches 常年一千二三百。问了才知道之前有人做过一轮 Mesh 合并把同一个小区域里的模型合成一个 Mesh 了跑起来一看——Batches 只掉了 40 来个。原因特别典型Mesh 合并了material 没合并八个材质球还是八个 submeshUnity 照样按材质切八次状态。这也是我在 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 命中率对透明物体做后到前排序来保证混合正确。一旦你把一堆半透明物件合进同一个 MeshUnity 只能整体按这个 Mesh 的中心点排序Mesh 内部的先后顺序变成了 submesh 的索引顺序于是玻璃、水面、烟雾就会开始互相穿帮。我的处理原则是不透明物体放心合Alpha TestCutout的可以合但要保证它和你的图集边缘 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 Dictionarystring, ListMeshFilter Group( IEnumerableRenderer renderers, bool skipTransparent true) { var map new Dictionarystring, ListMeshFilter(); foreach (var r in renderers) { if (r is SkinnedMeshRenderer) continue; // 蒙皮网格不参与 if (r.GetComponentAnimator() ! 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 ListMeshFilter(); map[key] list; } var mf r.GetComponentMeshFilter(); 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质量最好或者 DXT5Android 端用 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. 完整实操把一个散件场景合成单个 MeshRenderer4.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(ListMeshFilter filters, Material sharedMat, bool useLightmap, out Bounds bounds) { var instances new ListCombineInstance(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.GetComponentMeshRenderer(); 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 的网格数据。下面是我最近一个数字孪生项目的实测对比供参考指标优化前优化后变化物件数143622 个合并体 37 个动态物件-95%Batches1274186-85%SetPass Calls38962-84%材质实例数41245-89%贴图数量26831-88%Texture 内存186 MB121 MB-35%帧率PICO4 单眼41 fps68 fps66%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% 的效果剩下的长尾收益很低投入产出比不划算。工具脚本也建议建好版本管理场景资产一改就可能要重跑脚本能跑通比脚本写得多漂亮重要得多。
返回列表