ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity项目优化全链路实战:减面、合批、资源管理与紫材质排查

Unity项目优化全链路实战:减面、合批、资源管理与紫材质排查

做Unity项目优化,绕不开四件事:减面、合批、资源管理,还有那个一出现就让所有人血压上升的紫材质。我见过太多团队,把高模直接拖进场景,帧数掉到十几,然后对着AssetBundle和Shader一顿猛猜,最后发现真正的问题根本不是某一个点,而是整条资源链路都没理顺。这篇文章就是把这条链路从头到尾捋一遍:模型面数怎么控、Draw Call怎么压、资源怎么分、紫材质到底怎么根除。适合正在做移动端项目、或者Unity项目开始频繁掉帧、内存告警、材质发紫的同学,直接照着排查就行。

1. 卡顿的根源:先分清是面数还是Draw Call在拖后腿

1.1 从Profiler里看到底谁在咬帧数

很多人一卡就开骂模型面数太高,然后开始无脑减面,减完发现帧数纹丝不动。这种情况我遇到太多次了。真正的优化第一步不是动手改资源,而是先确定瓶颈在CPU还是GPU,在渲染还是逻辑,在面数还是Draw Call。

Unity自带的Profiler能直接看到主线程和渲染线程的耗时,Frame Debugger能逐帧看每一条Draw Call的提交过程。打开Profiler跑到目标场景,如果看到Rendering项里Triangles数量正常,但Batches数量动辄两三百往上,那问题大概率是Draw Call。反过来,如果单个帧的顶点处理时间明显偏高,或者GPU端出现明显的光栅化压力,再看是不是模型太密。

我自己习惯的记录方式是:先跑一轮,把“场景物体数量、三角面总数、Draw Call批次、SetPass Call、纹理内存”这五项全部记录。接下来每做一个优化动作,再跑同一场景同一路径,看这五项数字怎么变。没有这个基线,后面的优化就是瞎猜。

1.2 面数和Draw Call其实是两笔账

面数和Draw Call的成本逻辑完全不一样,理解这笔账才能对症下药。

面数的成本在量:顶点数越多,CPU要做的顶点处理、裁剪、变换计算就越多,GPU要做的光栅化、片元着色工作也越多。这个成本是“每帧都要付的流水账”,场景里只要有一个百万级面数的模型,整个帧率都会被拖下水。

Draw Call的成本在次数:每提交一次绘制命令,CPU都要构造渲染状态、做性能验证、还要通过驱动层传到GPU。移动端这块尤其敏感,本来CPU性能就赶不上桌面平台,几百个Draw Call直接把主线程锁死。

我常用的生活化类比是:面数高就像搬家时东西特别重,每一车都装得很满,跑一趟就要花大力气;Draw Call多则像快递员频繁空车往返,一次次跑,每次都不带什么东西,但油钱、时间都耗在路上了。所以优化的思路也很明确:体重太重就减面,跑得太多次就合批。

2. 模型减面:从源头建模到引擎导入的全链路优化

2.1 建模阶段就在做的“面数预算”

减面最好的时机是在建模阶段,而不是等资源进Unity之后才补救。项目初始化的时候,我习惯跟美术团队做一张面数预算表,把场景里的物体按重要程度分等级:主角和交互物件给最高配额,中景物件次之,远景和背景物体给最低配额。

这样做的好处是,美术在建模时就有了约束,不会兴致勃勃地把一把椅子做到两万面,结果进游戏被镜头一晃带过。面数预算表的分配逻辑很简单:离玩家越近、被看到的时间越长、交互越频繁的物体,分配越多面数;反之尽量压。

具体减面的时候,有几个操作是性价比最高的:删掉看不见的面,比如紧贴墙体的背面、地面以下的半截;清理重叠顶点和零面积三角形;去掉那些平缓曲面上不必要的密集分段。很多新手喜欢用自动减面插件一键处理,但我建议自动减面之后一定要人工检查边缘轮廓,因为自动算法最容易在轮廓、曲率转折处出现三角面拉长和破洞,这类问题在导入Unity后很难修。

2.2 Unity导入面板里的关键参数

模型进入Unity之后,导入面板本身就有一批影响性能和内存的参数,很多人忽略了。

Mesh Compression建议根据物体用途设置:中等偏高的压缩能明显降低网格数据的内存占用。缺点是顶点位置和法线会有轻微精度损失。对近景交互物品建议用Off或Low,对场景边缘的大面积低模,Medium或High是安全的。Read/Write Enabled默认是勾上的,但它意味着Unity会保留一份网格的CPU可读副本,内存开销翻倍。没有运行时改网格需求的模型,一律关掉。还需要留意Normals和Tangents的设置,如果是纯色或不依赖法线贴图的物体,Tangents可以关闭。

这些导入参数的调整不会改原始建模文件,只是影响Unity内部存储和运行时的表现,所以改起来没有风险,可以放心理顺。

2.3 LOD分级才是真正的动态减面

如果说导入参数是静态减面,那么LOD就是运行时的动态减面。LOD的核心思路是:同一个物体准备好几套不同精度的网格,相机离得远的时候用最粗糙的那套,离得近才上高精度模型。玩家在高强度战斗场景里根本注意不到远处的树干少了几个分段,但帧数会明显好起来。

Unity里用LOD Group组件来管理这套切换。我给移动端项目的常用配置是:LOD0是完整精度模型,Screen Relative Height(占屏比例)约50%以上才显示;LOD1是减面到70%左右的中模,占屏比例25%左右;LOD2是减面到40%的低模,占屏比例12%;LOD3一般直接用极简模型或干脆Cull。

做LOD可以手工减,也可以用专门的减面工具批量生成。生成完之后一定要逐级检查:切换时是否出现模型“闪烁”或“跳变”,烘焙贴图是否还匹配,蒙皮动画模型做LOD时骨骼数量和权重是否正确。LOD切换距离不是越小越好,太激进的切换会让玩家明显看到模型变粗糙,太保守又起不到作用。

3. 合批实战:把Draw Call真正压下去

3.1 Draw Call贵在状态切换

很多人知道Draw Call越少越好,但不知道为什么。CPU每次提交一个Draw Call,中间要经历:校验渲染状态、更新常量缓冲区、切换材质和纹理、下发绘制指令。这个过程非常依赖驱动和硬件,而且每一帧每个物体都要重新来一遍。同一个场景如果有500个不同材质的物体,CPU一帧要做500次全套流程,再强的手机也扛不住。

合批的本质就是让多个物体共用同一次绘制提交,从而省掉大部分状态切换开销。但Unity里的合批方式有好几种,它们适用场景完全不同,用错了反而更糟。

3.2 静态合批:场景里“躺平不动的物体”

静态合批适合场景里完全静止的物体,比如建筑、路障、装饰物。Unity会将这些标记为Static的网格在运行时合并成一个大网格,然后一次Draw Call画出来。这是减少Draw Call最立竿见影的方式,遮罩场景里几百个静态物体合到几个网格之后,批次能降一个数量级。

代价也有:首先需要额外占用内存,因为合并后会生成一份合并网格的副本;其次如果一个网格内部引用了多种材质,合批时会被拆开,所以静态合批对材质统一的要求很高。另外静态合批的网格不能再被独立移动,如果某个物体在玩法里被敌人击飞,静态合批会让它变成“尸体”无法被正确搬动。我的经验是:先确认物体整个生命周期都不会动,再勾Static。

3.3 动态合批:适合小物体但限制很多

动态合批是Unity自动处理的一种方式,理论上每一帧CPU会把多个小网格组合一次再提交。但它对顶点数有限制:单个网格顶多900个顶点(实际上受多个属性影响,数值会更低),材质必须相同,某些Shader特性也会阻止合批。Scale存在负值、Lightmap参数不一致的物体不会参与。

很多人以为开了动态合批选项就万事大吉,实际案例里动态合批经常部分生效:一批物体里只有几个满足条件被合了,其余的依然单独提交。用Frame Debugger可以看到哪些Draw Call被“Batched”了,哪些没有。所以我始终建议:小物体可以开启动态合批,但不要对动态合批产生依赖,关键场景里还是要通过减少材质种类、合并网格来从结构上降低批次。

3.4 GPU Instancing与SRP Batcher:不同世代的合批方案

大量重复的相同物体,比如草地、路灯、碎石堆,用GPU Instancing才是最优解。GPU Instancing允许一次Draw Call绘制多个实例,每个实例通过实例化属性数组区分位置、颜色等差异,CPU提交一次,GPU循环画几百份。

操作上很简单:材质面板勾选Enable GPU Instancing,然后在代码里通过MaterialPropertyBlock为不同实例设置差异化的颜色、缩放、偏移。需要注意:这类物体必须是同一个Mesh和同一套材质,而且Instancing对Shader有要求,URP/HDRP管线下还要求Shader支持Instancing变体。

如果项目用的Unity 2019以后版本并且开启了SRP,那么SRP Batcher会在URP/HDRP下自动减少材质状态切换。它和静态合批、动态合批的思路不同,不是合并网格,而是复用材质属性绑定。简单理解就是:CPU不再反复为同一个Shader重新设置一堆材质参数,而是缓存起来直接用。用SRP管线的项目,把支持SRP Batcher的Shader换成标准写法,再配合材质尽量统一,批次数字会好看很多。

三种合批方式的选择可以参考这个基础逻辑:

  • 静态不动的建筑群、地形周边:静态合批
  • 大量运动的重复小物体:GPU Instancing
  • 少量不满足Instancing条件的分散物体:动态合批兜底

4. 资源管理:模型优化不只是“减面”和“合批”

4.1 纹理压缩和纹理集:把内存大户管住

模型减得再干净,一张4K纹理就能把内存吃回去。游戏项目里纹理永远是内存大户,很多卡顿根本不是模型引起的,而是纹理量太大导致加载后的内存压力、纹理流送瓶颈。

移动端纹理建议无脑走ASTC或ETC2压缩。Texture Import Settings里,Compression选项不能一直用Default,要根据平台选对。比如安卓平台很多设备支持ASTC,就优先ASTC 6x6或8x8,iOS则看设备能力。RGBA32这种未压缩格式尽量只在UI或特别重要的贴图上用。还要注意Generate Mipmap:3D场景物体建议开,因为远距离不采样全分辨率纹理能明显省性能;但UI图集一般不开,不然会浪费内存。

纹理集的意义不光在省Draw Call,还省切换开销。同一批次渲染的物体如果分别引用了10张不同纹理,GPU会频繁切换纹理采样器;把相关贴图合并成Atlas后,一次切换就覆盖整个批次。做Atlas时记得预留边距和Padding,避免线性过滤时出现边缘混色。

4.2 AssetBundle分包怎么做才不会依赖地狱

AssetBundle的设计目标很简单:让资源能在需要时才下载和加载。但分包策略一旦设计错,就会出现各种循环依赖、重复下载、Shader丢失,甚至代码都跑起来了画面全是紫的。

我建议按“功能模块+公共依赖”来分:所有模型、材质、纹理、Shader必须拆成有明确归属的包;公共资源(人物、UI、共享Shader)单独打一个稳定版本的基础包;场景内资源按场景分组。打包之前用依赖分析工具把所有资源的依赖树拉一遍,确认一个模型包引用的贴图、材质、Shader都在它所依赖的包里,或者被公共包覆盖。

这个检查非常重要,很多紫材质问题就是在AssetBundle阶段埋下的:模型在资源包里,但模型用的Shader没被打进任何包,或者被打进了更新频率更高的包,运行时Shader还没加载完,材质就直接回退成紫色。

4.3 加载卸载的节奏:不能只加载不释放

资源管理不只是“如何加载”,更要考虑“何时卸载”。Unity的Resources.Load和AssetBundle加载都是带引用计数的,加载了不卸载,场景切换几次后内存就涨上去了。项目后期常见的“越玩越卡”十有八九是资源泄漏:旧场景的模型和纹理还占着内存,新场景又继续加载新的。

我的做法是给每个业务场景定义一套明确的加载清单和卸载时机:进入Loading界面时异步预加载下一场景必需资源;场景切换完成且新的根节点激活后,再卸载上一个场景的资源包。如果使用的是原生AssetBundle,要在加载时记录Bundle对象的引用,卸载时严格对应;如果项目有权限升级到Addressables,可以省掉很多手写的引用计数逻辑,但包体规划逻辑还是躲不开。

5. 紫材质排查:不要一紫就慌,按步骤来

5.1 紫材质是怎么来的

在Unity里材质显示成紫色,是引擎在“材质完全不可用时”显示的一种占位颜色。触发原因归纳起来就是:运行时找不到Shader,或者当前渲染环境不兼容这个Shader。

具体分常见三种:一种是在Project窗口里材质引用的Shader被删除了,或资源路径变化,导致Unity找不到;另一种是项目从内置管线切换到URP/HDRP,旧材质里的Standard Shader没有自动升级,整个场景大面积发紫;还有一种是AssetBundle运行时加载的Shader没有随包加载,材质在运行中被识别成“无Shader状态”。标题里说“紫材质”是优化过程中的一个坑,其实它还是最好的报警器:一旦某个材质变紫,说明资源和代码之间一定有一环断了。

5.2 四步排查法

遇到紫材质我建议按以下顺序排查,不要上来就重做材质。

  1. 打开Console窗口,找材质相关报错。如果提示某个Shader “not found”或者“might be missing”,那就锁定了缺谁。
  2. 在Project窗口选中发紫的材质,看Inspector里的Shader下拉框,如果显示的是Missing,说明引用已失效。这时候再检查Project里的Shader是否还存在。
  3. 在场景里找到那个紫色物体,确认它所属的AssetBundle都加载完成,且着色器Shader所在的Bundle先于材质Bundle加载。
  4. 如果一切看起来都对,仍然紫,多半是Shader变体在打包时被裁剪了。打开Build Report或者用AssetBundle Inspector确认Shader变体和引用是否完整。

5.3 打包阶段根除:Always Included Shaders与变体收集

排查到最后,你会发现紫材质的真正源头往往在打包配置里。Unity打包时只会把场景、代码引用的材质和Shader变体收集进包,如果某个Shader只被AssetBundle间接引用,或者只通过Shader.Find动态加载,打包器很可能认为它没有被实际使用而没有包含。

提前做三件事:第一,Project Settings里的Graphics页签有Always Included Shaders列表,把项目里一定会用的Shader加进去,避免被裁剪;第二,Shader变体是很细的问题,Shader里的多重开关会产生几千种变体,只保留项目实际用到的;第三,如果代码动态加载材质和Shader,要在构建时把相关资源设置进Bundle,并确保加载顺序正确。

还有一个很多人忽略的点:编辑器里表现正常,打包后发紫,往往是URP/HDRP管线的Shader在构建时没有生成对应平台的变体。记住Shader的编译目标是分平台的,Android和iOS的变体编译要同时验证,不是编辑器里看到没问题就等于打出来的包没问题。模拟项目X就遇到过类似问题,开发机上全部正常,真机一跑UI全是紫块,最后定位到是Build Target切到Android后,Shader缺了Mobile变体。

5.4 紫材质排查场景速查表

  • 整个场景大面积紫色:大概率是管线切换或Standard Shader不兼容,用管线的Upgrade功能批量升级材质
  • 只有某个模型紫:该模型的材质引用丢失,或者Shder所在Bundle未加载
  • 打包后紫、编辑器不紫:Shader被裁剪,去Graphics的Always Included Shaders里加
  • 安卓紫、iOS不紫:平台Shader变体缺失,切到对应Build Target重新构建并检查变体集合
  • 更新AssetBundle后紫:新包没包含Shader依赖,重新验证依赖树

6. 我自己的优化顺序和几条心得

最后分享几个实战经验,不一定每条都适用于所有项目,但绝大多数情况下按这个顺序做都不会跑偏。

先把优化顺序固化下来:先做Profiler基线,再优化Draw Call,然后压面数和纹理内存,最后处理AssetBundle和Shader。不要反过来,很多团队一上来就疯狂减面,结果Draw Call一点没少,帧数该掉的还是掉。顺序本身就是经验:先解决最抢CPU的绘制开销,再考虑模型密度和内存量,最后管加载释放节奏。

紫材质排查中还有一个小技巧:把Always Included Shaders列表和AssetBundle依赖树放在每次构建的检查清单里。每次发版前,跑一次全场景扫描,把场景和动态加载的Shader清单拉出来和构建报告比对,能省去大量线上冒紫的返工时间。别问我怎么知道的,被真机上的紫屏教训过几次就记住了。

模型优化这个领域,没有一劳永逸的方案。每个项目的手感、预算和设备目标都不一样,Profile一帧帧看,资源一项项理,性能就是这么抠出来的。希望这套链路能让你少走点弯路,真机跑顺了再回来评论区聊聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进