尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity DrawCall优化:Mesh合并、材质合并与图集化实战

Unity DrawCall优化:Mesh合并、材质合并与图集化实战 加载一个室内场景Profiler 里 SetPass Calls 显示 400 多FrameDebugger 一打开满屏的绿色方块每个方块旁边都挂着一次Draw Mesh——那一刻我基本能确定问题不在 Shader也不在光照而是场景里有太多独立的 MeshRenderer每个都拖着自己那一份材质和贴图draw call 就这么被硬生生堆起来了。这个现象在 XR 项目里尤其致命PICO4 这类一体机要同时渲染左右眼帧率目标卡在 72Hz 甚至 90HzCPU 侧稍微多提交一点东西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.GetComponentMeshRenderer(); 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.GetComponentsInChildrenMeshFilter(); var combines new ListCombineInstance(filters.Length); var materials new ListMaterial(); for (int i 0; i filters.Length; i) { var mf filters[i]; var srcMesh mf.sharedMesh; if (srcMesh null) continue; var mr mf.GetComponentMeshRenderer(); 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.AddComponentMeshFilter().sharedMesh mesh; go.AddComponentMeshRenderer().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.5ASTC 6x6 约 0.22。一张 512x512 的 ETC2 大约 128KB64 张就是 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 是 4x4ASTC 按块大小并且每张贴图的 UV 区域也对齐到块边界。另外一个反复提醒自己的点是合并这个操作的收益判断一定要用目标设备的真机数据不要用编辑器里的帧率做决策。编辑器里有各种缓存和优化和真机差距非常大。我在 PICO4 上测的时候编辑器和真机的 draw call 收益比例能差一倍以上。如果你正在做一个道具量大的场景我的建议是先别急着写工具先拿一个子场景手动合并一次用 Profiler 看看实际收益有多大。如果收益明显再投入时间做自动化。合并这件事本身不复杂复杂的是分组策略、UV 重映射和各种边角情况而这些只有在真实场景里跑过一遍才能摸清楚。
返回列表