大多数人对人物渲染优化的第一反应是“换更牛的Shader、开更多特效”,但角色密度高的项目里,真正让帧率崩掉的往往是那些看不见的基础开销。前阵子我接了一个三渲二风格手游Demo的调优需求,战斗场景同一屏最多同时出现六个角色,加上技能特效,帧率从60一路掉到40出头。用Profiler抓帧后才发现,CPU侧MeshSkinning和Rendering轮流占着前三名,特效反而只是小头。这篇我按实际排查顺序,从网格蒙皮、材质球、Shader变体、动画Culling、阴影参数到渲染顺序,把Unity人物渲染性能优化的思路和实测数据完整拆一遍。做二次元、三渲二、MMO刷怪场景的朋友,遇到同类问题可以直接照着走一遍流程。
1. 角色开销偏高的源头:从骨骼蒙皮到变体膨胀
先给一个反直觉的结论:人物角色渲染贵,贵在“每帧都要动”。静态场景可以烘焙、可以预计算、可以离线光照,但角色必须每帧让CPU去更新骨骼、让GPU去重新绘制阴影、重新计算顶点变换。这一整套链路里,任何一个环节多出来一点点开销,放在六七个角色身上都会被放大数倍。
1.1 SkinnedMeshRenderer的每帧账单
抛开第三方简化方案,Unity里普通角色走的是三件套:Animator更新骨骼层级关系,SkinnedMeshRenderer把骨骼变换矩阵应用到所有顶点,然后Renderer把Mesh、材质和渲染状态提交给GPU。
很多人只盯着DrawCall,却忘了前两步是在CPU上实打实算的。一个20K三角面、4个骨骼权重、96根骨骼的角色,每帧要做的矩阵混合接近顶点数乘以权重数,也就是八万次左右的矩阵乘加,再算上96根骨骼从Animator拿到结果后的矩阵更新,这些统统跑在CPU上。如果场景里有五六个角色,就是几十万次矩阵运算,任何一个中低端移动设备的CPU都会被压出可见的掉帧。
所以在优化清单里,网格顶点数和骨骼权重上限通常放在最前面,原因就在这:它不是GPU负担,是CPU负担。GPU端大家反而容易陷入三角形数量焦虑,但实际上移动端GPU跑20K三角形的能力绰绰有余,瓶颈往往在驱动提交和顶点变换的CPU侧。
1.2 材质球数量与Shader变体是隐形炸弹
美术同学为了调参方便,会把角色拆成皮肤、头发、眼睛、裙子、金属饰件、发光部件等多个材质球。这个“方便”在运行时是要还的:每个不同材质的 SkinnedMeshRenderer 不参与合并,每个材质球都是一个独立DrawCall起步,如果Shader里还带描边Pass,那更是成倍往上翻。
更隐蔽的是Shader变体。同样一套Shader,只要带几个Keyword开关,Unity会为每个组合编译出独立变体。比如_NORMALMAP开/关、_ALPHATEST_ON开/关、_ADDITIONAL_LIGHTS的三档强度、_MAIN_LIGHT_SHADOWS_CASCADE的三档级联,这一乘就是 2×2×3×3 = 36 个变体。如果再掺几个描边或UV动画的开关,轻松上百。这些变体在打包时不会自己消失,全进发布包,运行时Shader加载时间和显存占用也跟着涨。变体膨胀不是理论问题,是实打实的包体与加载问题。
1.3 动态顶点带来的连带副作用
角色顶点每帧在动,这会让一个容易被忽视的系统跟着遭殃:动态包围盒。SkinnedMeshRenderer需要每帧根据顶点实际位置重新计算包围盒,否则视锥剔除和遮挡剔除都会出错。包围盒一旦计算得偏大,本该被裁剪掉的角色反而继续参与渲染。
同时,实时阴影也有连带开销。只要角色动了,只要方向光还开着,阴影贴图就要重新渲染。这还不是简单重绘一次,每个角色都会被画进ShadowMap,阴影的绘制成本和正常视角渲染几乎相当。动态角色越多,这个连带成本越明显。所以人物渲染优化,看的不只是一个Renderer的绘制开销,而是整条由“动”引起的链路。
2. 动手前先“体检”:Profiler与Frame Debugger怎么配合用
我见过太多次“拿到项目就开始改Shader、开GPU Instancing”的情况,结果改了一周帧率该卡的还是卡。人物渲染优化的第一步永远是摸清瓶颈在CPU还是GPU,在网格还是Shader,在阴影还是动画。这一步不花时间做好,后面全是盲人摸象。
2.1 第一步:分清瓶颈在CPU还是GPU
打开Profiler,切到CPU Usage模块,在Hierarchy里按耗时排序,一帧一帧看。关注这几个关键模块的名字:
| Profiler中看到的模块 | 大概率说明的情况 | 优先优化方向 |
|---|---|---|
| MeshSkinning 耗时高 | 蒙皮顶点太多、权重数量太高 | 模型减面、限制权重、合并网格、开GPU Skinning |
| Animator.Update 耗时高 | 骨骼层级复杂、动画曲线太多太密 | 压缩动画、降低采样率、Animator Culling |
| Renderer.SetPassCall / RenderLoop 耗时高 | 状态切换太频繁、设置命令太多 | 合并材质球、裁剪Shader变体、开SRP Batcher |
| RenderForwardOpaque 在GPU耗时高 | 像素着色阶段过重、Overdraw严重 | 减少半透明层、减少动态多光源、调整阴影参数 |
CPU这块多花几眼不会错。GPU侧则需要看GPU Profiler,Editor环境下可以开Frame Debugger辅助,真机则建议连RenderDoc或者厂商工具拉GPU数据。Android低端机上还可以用Perfetto抓一下RenderThread的驱动开销,这经常能暴露Shader复杂度问题。
2.2 第二步:Frame Debugger逐个SetPassCall查状态
Frame Debugger是排查DrawCall和状态切换的利器。打开Window > Analysis > Frame Debugger,选中要看的帧,然后一帧一帧往下翻。我通常关注三件事:
- Mesh为什么提交了这么多次:同屏多个同Mesh角色,为什么没被合批
- Material切换为什么这么频繁:每切一个材质,就是一次状态变更
- Shader Pass跳了几次:多Pass的描边、阴影Pass、透明Pass,每一个都意味着额外的提交
在URP里,如果你开了SRP Batcher,Frame Debugger会明确显示是否命中SRP Batcher。命中说明这些材质用的是兼容Shader和兼容CBUFFER布局,没有被计入SetPassCall。没命中,就去检查Shader是不是个自定义写的,没有按URP的CBUFFER规范来。这一条排查对大多数项目来说都是立竿见影的:同一个场景,SRP Batcher把SetPassCall从七八十降到十几个,是很常见的情况。
2.3 第三步:一次只改一个变量
这个习惯帮我避了很多坑。改性能优化的时候,最忌讳同时开十几个开关,因为一旦帧率变差了,你根本不知道是哪个开关出了问题。正确的做法是:每次只改一项,改完上真机跑同一段战斗流程,记录帧率和Profiler关键指标。
真机测还要注意统一环境:同一个机型、同一个场景、同一个技能释放路径。如果可能,锁死热降频后的指标,别在设备凉的时候测一次、热的时候又测一次,数据会出现巨大波动。我一般会先用负载场景让手机跑热,再开始记录数据,这样得到的结果更接近真实用户会遇到的状况。
3. 网格与蒙皮层:把每帧的CPU账单降下来
人物渲染的“物理基础”是网格数据和骨骼数据,这一层不优化,后面Shader调得再好也救不回来。而且这一层的问题是美术规范问题,做对了之后每个角色都受益。
3.1 模型验证:三角面、顶点数、骨骼权重上限
移动端项目我给的角色标准一般是:单个常规角色15K到30K三角形;骨骼数量控制在60到100根;蒙皮权重最高4个,除非万不得已不要上6或8。高精端游写实项目可以放宽,但三渲二、二游UI风格项目完全可以按这个基准来。
关键在于“权重算什么账”:每个顶点的骨骼权重数量直接影响蒙皮矩阵混合次数。4权重已经是移动端主流,8权重意味着每个顶点要做的矩阵乘加翻倍。很多模型导出时默认带着8权重,导入Unity后如果在Model Importer里没改,BlendWeights选项默认可能还是4 Bones,这一点美术和开发一定要对齐。
Model Importer里还有两个容易被忽略的开关:Read/Write Enabled一旦开启,网格数据就会在CPU侧和GPU侧各保留一份。对完全不需要在运行时读取顶点数据的角色网格,直接关掉,能省不少内存。另外Mesh Compression可以适度开到Medium或High,三角形数量不是越多越精细,顶点数据的浮点精度压缩到8bit/16bit后,视觉差异通常很小,内存和带宽却实打实降下来了。
3.2 动画数据:压缩、采样率、Culling
蒙皮计算花钱,动画数据本身的解压和插值也花钱。三渲二项目里美术会导入大量动画曲线,默认30FPS采样,一段60秒的动画光曲线数据就够CPU喝一壶。我这边项目优化时做了两件事:
第一,把Animation Compression从Optimal改成Keyframe Reduction,并适当降低Animation Sample Rate到15Hz或20Hz。Optimal模式虽然在包体上最省,但CPU解压时反而要花额外时间恢复关键帧;Keyframe Reduction配合可接受的视觉损失,CPU开销更稳定。实测在角色跑步、攻击动作上,压到15Hz基本看不出来,除非有非常高速的镜头特写动作。
第二,利用Animator的Culling Mode。默认是Always Animate,角色哪怕在屏幕外也在更新动画和骨骼。Cull Update Transforms适合那种还在屏幕外但需要IK准确的角色,Cull Completely则适合路人、小怪、离屏角色。项目里我们给所有非战斗NPC开了Cull Completely,CPU侧的Animator.Update降了大概30%。副作用是角色离屏期间动画不推进,回屏瞬间会跳一下,对怪群和路人是完全能接受的。
3.3 GPU Skinning与合并SkinnedMesh的取舍
GPU Skinning在Player Settings的Other Settings里可以找到,iOS和Android都支持。开启后,蒙皮计算从CPU移到GPU,这对于CPU紧张的移动端很友好。但它不是零成本:GPU侧顶点处理变重,老款Mali或部分Adreno驱动上可能不升反降。
建议是分机型开关。同一台测试机数据好不意味着所有真机都好,我习惯在立项初期就做一张主流机型白名单,列清楚哪些机型开GPU Skinning、哪些不开。至少在我做过的一个项目里,骁龙865上开启后MeshSkinning立刻从2ms降到0.3ms,但同一台设备如果同时开了阴影级联和复杂后处理,GPU侧的额外开销会吃掉这部分收益,所以别只看一个模块的数据。
衔接网格合并的问题:多个SkinnedMeshRenderer如果能合并成一个,提交次数和合批效率都会改善。但前提是它们的材质、贴图、骨骼结构一致。如果只是把身体、头、四肢合并Mesh,但材质球还是三四个,那合了也白合,渲染时还是按材质切换拆开。所以做合并之前,先跟美术确认贴图是同一个图集,材质是同一个Shader,否则收益会被材质切换抵消。
4. 材质与Shader层:变体剪枝与“减法渲染”
网格层做完后,CPU的刚性开销降下来了,接下来是Shader和材质这层。这层是整场优化里变数最大、坑最多的地方,尤其对三渲二或二次元风格项目来说,美术表现和性能的拉锯战基本都在这里展开。
4.1 材质球合并与SRP Batcher
材质球合并的核心原则很简单:同Shader、同贴图、同参数的材质才能合并。三渲二角色的身体、脸、衣服如果都吃同一套图集,尽量合成一个材质。个别角色想换发色、换瞳孔颜色,不要新建材质球,用MaterialPropertyBlock去覆盖颜色变量。这样同一套网格和同一套Shader还在,DrawCall不会增加,表现差异照样有。MaterialPropertyBlock对批处理有一些额外约束,但比起几百个重复材质球带来的提交灾难,这个代价完全值得。
如果你是URP项目,开SRP Batcher是性价比最高的操作。在URP Asset里勾选SRP Batcher后,材质只要Shader兼容SRP,Unity就能把多个材质提交合并成一次。要注意的是自定义Shader必须按SRP Batcher规范定义CBUFFER,像CBUFFER_START(UnityPerMaterial)这样的结构,否则状态切换还是一个个来。这个细节在官方文档里其实写得不显眼,但排查起来特别费时间。
4.2 Shader变体的修剪与预编译
变体的坑我前面已经埋了一句,这里展开说操作。Unity中有两套常用的变体机制:shader_feature和multi_compile。multi_compile的关键字如果没有被裁剪,打包时一定全量带上;shader_feature则会根据是否被使用来自动排除,但前提是Shader被正确引用到场景或者AssetBundle里。做变体剪枝时,先检查Shader里哪些Keyword加了multi_compile,能换shader_feature的就换。
然后打开Graphics Settings,看Always Included Shaders,把用不到的Shader删掉。再用Shader Variants Collection配置运行时要加载的变体白名单。以我优化过的三渲二Shader为例:原本4个Keyword组产生了96个变体,剪掉动态多光源、去掉不必要的Cascade档位后,压到18个,打包体积和Shader加载时间都肉眼可见地下降。变体数量少,真机上的SetPassCall也稳定下来,因为你不再需要为了覆盖所有Keyword组合而让驱动去做额外状态准备。
如果你用Addressables,记得把Shader变体拆成单独的Build Target按需加载。很多团队抱怨“优化完包体又涨回去了”,大概率是变体在AssetBundle里被隐式打包进去了。单独控制加载时机后,我这边发布包缩水大约50MB。
4.3 卡通渲染/NPR里的性能陷阱
二次元、三渲二项目的角色Shader往往是性能大头。表面上看NPR效果比PBR简单——没有复杂的微表面BRDF——但实际做出来以后,为了追求“好看的描边”和“干净的脸”,很多团队会不知不觉加一堆光源、一堆Pass、一堆后处理,性能比PBR还糟。
我见过最常见的问题有三个:
第一,描边Pass。最常见做法是模型沿法线外扩再画一遍背面,这等于把角色多画一次。移动端上6个角色就意味着多6个描边DrawCall,而且描边层还不参与合批。换个思路,用屏幕空间的深度法线描边,让描边作为全屏后处理执行,角色本身仍然是一次绘制,场景里的角色数量再多也不怕。
第二,动态多光源。三渲二Shader为了保留日系质感,会写多个光源循环,每多一个实时方向光或点光,角色就要多跑一遍光照pass。对游戏来说,绝大多数时候只需要主光照 + 一档补光。多余的光源用Light Probe或者烘焙后处理去模拟,不要塞进Shader里逐像素算。
第三,为了表现细节把材质拆得太碎。卡通角色的眼睛、脸可以是单独的Mesh和材质,但“脸”这种迎光部件很适合塞进主材质里用UV划分处理,或者用MaterialPropertyBlock控制。材质球一旦多到按角色数量乘,DrawCall就会成倍回升。
5. 阴影、深度与渲染顺序:角色群像场景的三大坑
人物渲染的最后一个大头经常是阴影和深度相关配置。很多项目角色数量一多,阴影和排序问题就全出来了:地上的阴影在闪、角色之间排序乱、半透明头发穿模闪烁。这些问题的根源往往不是美术资源,而是渲染管线的全局配置。
5.1 Shadow Distance、Cascade、软阴影的取舍
实时阴影是按距离和级联算的。级联越多,阴影精度越高,Shader里要采样的ShadowMap层数也越多。普通玩家视角下,角色身上的阴影在中远距离根本看不清细节,所以完全没必要为所有人开最高级联。
移动端建议:Shadow Distance控制在20到30米,Shadow Cascade设成2 Cascade,阴影类型如果项目风格能接受就选Hard Shadows,要柔化就只保留一层PCF滤波。我实测6个角色同屏时,Cascade从4档降到2档,角色Shader的GPU耗时能降15%左右,而画面观感几乎不受影响,因为远处角色的阴影本来就被Shadow Distance裁剪掉了。
同时要给“远处角色”单独安排假阴影。做法很简单:一个平铺的角色脚下贴花,用圆形的软阴影贴图,按Alpha叠在地面上。不要用Projector的实时投影,Projector本身是一笔额外渲染。用UV朝向始终向上的贴花面片,成本只有贴图采样。我们的小怪场景里,所有中距离小怪都换成假阴影后,ShadowMap渲染压力下降非常明显,帧率回升了大概4到5毫秒。
5.2 透明头发与不透明身体的排序问题
三渲二角色十有八九有透明头发或蕾丝配件。透明物体的排序规则是从后往前绘制,可角色身体的Opaque部分会写入深度,头发在角色自遮挡时就会产生穿模或闪烁。常见解法是把头发单独放到Transparent队列,并在Shader里写入深度,或者给头发单独做一层深度偏移。要警惕的是这会在GPU上增加Sorting和Overdraw成本,如果角色数量多,会成倍放大。
我项目里最后是把头发的AlphaTest改成AlphaBlend但严格控制贴图对比度,让透明部分的覆盖近乎不透明,再把队列放回Geometry或AlphaTest队列,这样既保住了排序,又不会产生严重的半透明混合开销。对移动端比较稳的另一个做法是给头发用_ALPHATEST_ON裁切而不是AlphaBlend,虽然会出现锯齿,但加一层基于屏幕空间的抗锯齿后能接受。
5.3 深度Prepass与Overdraw的权衡
URP里有个Depth Prepass选项,本质是先不写颜色只写深度,让后面的不透明Pass可以更高效地通过早期深度测试。对PC或主机端,这一招在复杂场景里非常有用。但移动端是Tiled GPU架构,多一次DepthPass等于多一些带宽消耗和顶点处理,不一定划算。简单场景里开Depth Prepass甚至会拖慢帧率。
要判断该不该开,还是回到Profiler:如果Overdraw特别严重,比如大量半透明部件叠在一起,开Prepass收益就大;如果场景本来就简洁,角色数量也不多,预渲染深度纯属浪费。它可以作为一个开关放开,让不同画质档位去动态选择。但注意千万不要每帧切换,会引起渲染状态抖动。
6. 一次真实调优案例:六个角色同屏的帧率回归
把上面的原则串起来,我用一个最近做的实际案例收尾。项目是Unity 2021.3 LTS URP,三渲二风格,测试机是某骁龙865机型。战斗场景里有1个主角加5个大世界小怪同屏,每只小怪身体Mesh约22K三角形,96根骨骼,材质球有4个:皮肤、头发(透明)、裙子、武器发光件。
6.1 起始数据
没优化前,一帧的DrawCall是186,SetPassCall是72,角色相关的RenderThread耗时约9.2ms,整体帧率在41到48FPS之间波动。Profiler里CPU侧的MeshSkinning占了约2.1ms,Animator.Update占了1.4ms,Renderer.SetPassCall和RenderLoop各占一部分,GPU侧则被角色Overdraw和阴影级联吃掉了不少。
6.2 按优先级执行的操作清单
整个优化过程按下面这个顺序做,每步都用真机复测同一段10秒战斗流程:
- 合并网格:把身体、头、四肢合并为一个SkinnedMeshRenderer,权重上限设为4,关闭Read/Write Enabled,Mesh Compression开到Medium。这一步后MeshSkinning从2.1ms降到1.2ms。
- 合并材质:皮肤、裙子、头发合并到一个图集和一个材质球,武器发光单独留一个。材质球从4个降到2个,Frame Debugger里SetPassCall开始松动。
- 开启SRP Batcher:重写了一个自定义Shader的CBUFFER结构,URP Batch命中率明显提高,SetPassCall从72掉到19。
- 清理Shader变体:把96变体剪到18,Shader加载时间和内存占用下降,多余的全进Addressables按需加载。
- 调整阴影:Shadow Distance从50降到25,Cascade从4降到2,小怪的实时阴影全部替换成假阴影贴花,主角保留实时阴影。
- 动画压缩:Animation Sample Rate压到15Hz,小怪的Animator Culling Mode设成Cull Completely。
- GPU Skinning:在测试机上开启GPU Skinning,MeshSkinning降到0.3ms,但这个开关在部分老机型上要关掉,对新设备保留。
6.3 优化后数据和几个让人意外的点
优化后DrawCall降到58,SetPassCall降到14,角色相关RenderThread耗时约3.4ms,帧率稳定在58到60FPS。CPU侧的Animator.Update几乎不占时间,阴影渲染的开销也降了一大截。
有两个点值得单独说,因为它们和我最初预想的完全相反。一个是GPU Skinning并不是灵丹妙药,在老机型上反而更慢,最后是靠机型白名单开关解决的。另一个是透明头发造成的Overdraw问题,比我以为的要严重得多,它不只是排序闪烁,而是GPU像素填充的大量浪费。把头发从AlphaBlend改成AlphaTest后,角色头部和脸部的高频闪烁消失,GPU耗时也下来了。
这次优化下来,我最大的体会
人物渲染优化真的不是某个设置打勾就完事的,它是一条从模型资产、动画数据、材质Shader、变体管理到全局渲染顺序的链路,每厘清一环都能挤出一点性能。以后再遇到“人物渲染卡”的问题,我会先花一小时把Profiler数据看透,再决定从哪一层下手。很多朋友一听说掉帧就急着开GPU Instancing、开遮挡剔除,结果没有数据支撑,越调越玄。对移动端来说,实打实地管好网格、材质、变体和阴影这几个变量,帧率提升往往比任何“高级功能开关”都来得快。另外一个小技巧:如果用了Addressables,Shader变体一定要单独处理,发布包里那堆用不到的变体,才是最大的隐形元凶。