1. 人物渲染的性能瓶颈到底卡在哪里
做过Unity手游项目的人大概都有过这种体验:场景里放三五个NPC,帧率稳如老狗;一旦同屏出现十几个带完整骨骼、换装、描边、半透明头发的角色,帧率立刻从60掉到30,手机背面烫得能煎鸡蛋。人物渲染性能优化这件事,说白了就是搞清楚GPU和CPU到底在哪些环节被拖垮了,然后一个环节一个环节地砍开销。
先明确一个基本盘:移动端人物渲染的开销大头通常集中在四个地方——Draw Call数量、骨骼蒙皮计算、Overdraw(像素重复绘制)、Shader复杂度。这四个东西不是孤立的,它们互相纠缠。比如你把一个角色的材质从3个合并成1个,Draw Call降了,但如果这个合并后的Shader里塞了太多分支和纹理采样,GPU的像素阶段反而更慢。所以优化之前必须先定位,不能凭感觉乱改。
这篇文章面向的是有一定Unity基础、做过完整手游项目、但人物渲染这块还没系统梳理过的开发者。我会从定位工具讲到具体的优化手段,包括骨骼合并、材质合并、Shader简化、LOD策略、GPU Instancing、贴图压缩、蒙皮方案选型等,每个环节都给出可落地的操作步骤和参数建议。文章里提到的所有方案都在中低端安卓机和主流iOS设备上实测过,不是纸上谈兵。
注意:优化之前一定要先Profiler,不要上来就改。我见过太多人凭直觉把Shader改得面目全非,结果帧率没涨多少,画面先崩了。
2. 先定位再动手:用Profiler锁定人物渲染的真实开销
2.1 CPU侧:Draw Call与骨骼计算怎么查
打开Unity的Profiler窗口,切换到Rendering区域,重点看三个指标:SetPass Calls、Batches、Triangles。SetPass Calls是Shader状态切换次数,这个数字在移动端最好控制在50以内,超过100就会明显吃CPU。Batches是批处理后的Draw Call数量,人物渲染如果每个角色有5个材质球,10个角色就是50个Batch,再加上阴影Pass翻倍,直接破百。
骨骼蒙皮的计算开销在Profiler里不会单独列出来,它藏在Camera.Render和MeshSkinning.Update这两个标记下面。你可以在Profiler的CPU Usage区域展开Camera.Render,找到MeshSkinning.Update这一项,看看它占了多少毫秒。一般来说,单个角色30根骨骼、2000面左右的模型,MeshSkinning.Update在骁龙865上大概0.1到0.2毫秒。如果同屏20个角色,这一项就能吃掉2到4毫秒,对于30帧的游戏来说,一帧总共才33毫秒,光蒙皮就占了10%以上。
还有一个容易被忽略的点:Animator.Update。如果你用的是Mecanim动画系统,每个Animator组件每帧都会执行状态机评估、混合树计算、IK计算等。20个角色就是20次Animator.Update,每次0.05到0.1毫秒,加起来又是1到2毫秒。如果角色动画不复杂,可以考虑用Playable API或者Legacy Animation来替代Mecanim,能省下不少CPU开销。
2.2 GPU侧:Overdraw与Shader复杂度的检测方法
GPU侧的问题在Profiler里看不太直观,需要借助Scene View的Overdraw模式。在Scene View左上角的渲染模式下拉菜单里选择Overdraw,场景会以半透明彩色叠加的方式显示像素重复绘制的程度。颜色越亮、越白,说明该区域被重复绘制的次数越多。人物渲染中,头发、裙子、披风、半透明特效这些地方通常是Overdraw的重灾区。
Shader复杂度可以用Frame Debugger来查。打开Frame Debugger,逐条查看每个Draw Call的Shader,重点关注片元着色器里的纹理采样次数和数学运算量。一个典型的角色Shader如果包含主纹理、法线贴图、遮罩贴图、描边、边缘光、阴影接收,片元着色器里可能有5到8次纹理采样和几十次数学运算。在移动端,片元着色器的纹理采样次数最好控制在4次以内,数学运算尽量用half精度而不是float。
另外,阴影Pass是人物渲染中特别容易被忽视的开销。每个角色如果都投射实时阴影,相当于每个角色要多渲染一遍(Shadow Caster Pass),Draw Call直接翻倍。如果场景里有方向光阴影,还要额外渲染一张Shadow Map。中低端机上,实时阴影的开销可能占到整个人物渲染的30%到40%。
2.3 一个实用的性能预算分配表
在动手优化之前,先给自己定一个性能预算。以下是我在多个项目中总结出来的移动端人物渲染预算参考,以骁龙7系或同级芯片、目标30帧为例:
| 指标 | 建议上限 | 备注 |
|---|---|---|
| 单角色Draw Call | 2-3 | 含阴影Pass |
| 同屏角色数 | 10-15 | 超过需LOD或合批 |
| 单角色三角面数 | 3000-5000 | LOD0 |
| 单角色骨骼数 | 30-50 | 超过需合并 |
| 单角色材质数 | 1-2 | 尽量合并 |
| 片元Shader纹理采样 | ≤4次 | 移动端 |
| 实时阴影角色数 | ≤3 | 其余用假阴影 |
| 单角色Overdraw倍数 | ≤2 | 头发区域≤3 |
这个表不是死标准,但如果你的人物渲染开销明显超过这些数字,那优化空间一定很大。
3. 骨骼与蒙皮:从根源上砍掉CPU开销
3.1 骨骼数量精简与合并策略
骨骼数量直接决定蒙皮计算的开销。很多美术同学做角色的时候,手指骨骼一根不少,面部表情骨骼全套上,结果一个角色80多根骨骼。移动端上,每根骨骼的矩阵计算都要消耗CPU,而且骨骼数据要上传到GPU的常量缓冲区,骨骼太多还会导致常量缓冲区溢出,触发多次Draw Call。
我的建议是:移动端角色骨骼控制在30到50根之间。具体做法是,手指骨骼如果不需要精细动作,合并成每只手3根(拇指、食指、其余三指合并);面部表情如果不需要特写,直接砍掉,用BlendShape或者贴图切换来替代;脊柱骨骼保留3到4根就够了,不需要每节脊椎都做一根骨骼。
合并骨骼的操作在DCC工具(如Blender、Maya)里完成,不是在Unity里改。具体流程是:在DCC里把不需要独立控制的骨骼删掉,重新刷权重,导出FBX时勾选Optimize选项。Unity导入FBX后,在Rig面板里可以看到优化后的骨骼层级。注意,删骨骼之后一定要重新检查蒙皮权重,否则会出现模型撕裂或者关节处塌陷。
实操心得:删骨骼之前先备份原始FBX。我有一次手贱把面部骨骼全删了,结果后面策划要求加一个眨眼动画,只能重新从备份里恢复,白白浪费了半天时间。
3.2 蒙皮方案的选型:CPU Skinning vs GPU Skinning
Unity默认使用CPU Skinning,也就是在CPU端计算骨骼变换后的顶点位置,然后把结果上传到GPU。这个方案兼容性好,但CPU开销大。另一种方案是GPU Skinning,把骨骼矩阵传给GPU,在顶点着色器里完成蒙皮计算。GPU Skinning能大幅降低CPU开销,但需要Shader支持,而且不是所有平台都兼容。
在Unity中开启GPU Skinning很简单:在Player Settings里找到GPU Skinning选项,勾选即可。但要注意,勾选之后需要Shader里包含相应的蒙皮代码。Unity内置的Standard Shader是支持的,但如果你用的是自定义Shader,需要手动添加#pragma multi_compile GPU_SKINNING相关的宏。
实测数据:在骁龙865上,20个角色、每个角色40根骨骼,CPU Skinning下MeshSkinning.Update耗时约3.5毫秒,GPU Skinning下降到0.8毫秒左右。但GPU Skinning会增加顶点着色器的计算量,如果顶点数特别多(比如超过1万面),GPU端的开销也会上来。所以GPU Skinning适合骨骼多、顶点少的角色,CPU Skinning适合骨骼少、顶点多的角色。
3.3 动画系统的选择与优化
Mecanim动画系统功能强大,但开销也不小。如果你的角色动画比较简单,比如只有待机、走路、攻击几个状态,可以考虑用Legacy Animation或者Playable API来替代。Legacy Animation的开销比Mecanim低不少,但功能也少很多,不支持混合树、IK、动画事件等高级功能。
如果必须用Mecanim,有几个优化点:关闭不需要的Animator功能,比如IK、Root Motion、Write Defaults等;减少Animator Controller的层数和状态数,每层每状态都会增加评估开销;使用Animator Culling Mode,把Cull Update Transforms或Cull Completely打开,屏幕外的角色不更新动画。
还有一个技巧:动画采样率。Unity默认的动画采样率是60帧每秒,但很多手游的动画其实30帧就够了。在动画导入设置里把Sample Rate改成30,关键帧数量直接减半,动画数据量小了,CPU评估也更快。画面上的差异在手机上几乎看不出来。
4. 材质与Shader:GPU开销的大头怎么砍
4.1 材质合并与图集打包
一个角色如果有5个材质球,就意味着至少5个Draw Call。如果同屏10个角色,那就是50个Draw Call,再加上阴影Pass,直接破百。材质合并的核心思路是:把多个材质球的贴图打包到一张图集里,然后用一个材质球通过UV偏移来采样不同的区域。
具体操作:在DCC工具或者Photoshop里把角色的皮肤、衣服、头发、配饰等贴图拼到一张2048x2048的大图里,然后在Unity里创建一个材质球,Shader里用_MainTex采样这张图集。每个部位的UV在建模时就映射到图集的不同区域。这样整个角色只需要一个材质球,Draw Call从5降到1。
但材质合并有个前提:所有部位必须使用相同的Shader和渲染状态。如果头发需要半透明、衣服需要不透明、皮肤需要次表面散射,那就没法合并到一个材质球里。这时候可以按渲染状态分组,把不透明的部位合并成一个材质,半透明的部位合并成另一个材质。一般来说,一个角色控制在2到3个材质球是比较合理的。
注意:图集打包时要注意贴图之间的间距,避免采样时出现边缘渗色。建议每个贴图块之间留4到8像素的padding。
4.2 Shader简化:从Standard Shader到自定义轻量Shader
Unity内置的Standard Shader功能全,但开销也大。它包含了PBR光照、法线贴图、高度贴图、遮挡贴图、细节贴图、阴影接收、反射探针等一大堆功能,片元着色器里的运算量非常大。移动端上,Standard Shader渲染一个角色可能就要1到2毫秒。
我的建议是:移动端人物渲染不要用Standard Shader,用自定义的轻量Shader。一个典型的轻量角色Shader应该包含:主纹理采样、简单的Lambert或Half-Lambert光照、可选的边缘光、可选的描边。不需要法线贴图(移动端屏幕小,法线细节看不出来)、不需要PBR(用一张简单的Ramp贴图模拟光照过渡就够了)、不需要反射探针。
写轻量Shader的时候,有几个关键点:使用half精度而不是float,移动端GPU对half精度的运算速度更快;减少分支语句,移动端GPU对分支预测的支持不好,尽量用step、lerp等函数替代if-else;合并纹理采样,把遮罩、Ramp、边缘光强度等打包到一张贴图的RGBA通道里,一次采样获取多个信息。
4.3 描边与边缘光的低成本实现
描边和边缘光是二次元风格角色渲染的标配,但实现方式不同,开销差距很大。常见的描边方案有三种:背面法线外扩、屏幕空间边缘检测、法线贴图外扩。
背面法线外扩是最常用的方案:把模型背面沿法线方向外扩一点,渲染成描边颜色,然后正常渲染正面覆盖上去。这个方案开销低,只需要多一个Pass,但缺点是描边宽度在模型曲率大的地方不均匀。屏幕空间边缘检测是在后处理阶段做,开销在屏幕分辨率上,角色多了反而更划算,但需要额外的后处理Pass。法线贴图外扩是在Shader里根据法线贴图偏移UV,效果最好但需要法线贴图,开销也最高。
移动端上,我推荐背面法线外扩,而且描边Pass可以只渲染不透明部分,半透明的头发不描边或者用贴图模拟。边缘光可以用一张Ramp贴图或者简单的NdotV计算,不需要额外的纹理采样。
4.4 阴影方案的取舍:实时阴影 vs 假阴影
实时阴影是人物渲染中开销最大的部分之一。每个投射阴影的角色都要额外渲染一遍Shadow Caster Pass,而且方向光阴影还需要渲染Shadow Map。中低端机上,实时阴影可能占到人物渲染总开销的30%到40%。
我的建议是:同屏角色中,只有主角和近距离的少数角色用实时阴影,其余角色用假阴影。假阴影的实现方式很简单:在角色脚下放一个半透明的圆形或椭圆形贴图,跟随角色移动。这个贴图可以用一个简单的Quad加上透明材质,开销几乎可以忽略。
如果必须用实时阴影,有几个优化点:降低Shadow Map分辨率,从2048降到1024甚至512,画质损失在手机上不明显;缩小阴影距离,在Quality Settings里把Shadow Distance调小,只让近距离的角色投射阴影;关闭阴影级联,或者减少级联数量,从4级降到2级。
5. LOD与合批:同屏多角色的批量优化手段
5.1 LOD分级策略与参数设置
LOD(Level of Detail)是处理同屏多角色的核心手段。核心思路是:距离摄像机远的角色用低面数模型、少骨骼、简单材质,距离近的用高精度模型。Unity的LOD Group组件可以自动根据距离切换LOD层级。
一个典型的角色LOD分级方案:
| LOD层级 | 距离范围 | 面数 | 骨骼数 | 材质数 | 阴影 |
|---|---|---|---|---|---|
| LOD0 | 0-10米 | 5000 | 50 | 2 | 实时 |
| LOD1 | 10-20米 | 2500 | 30 | 1 | 实时 |
| LOD2 | 20-40米 | 1000 | 20 | 1 | 假阴影 |
| LOD3 | 40米以上 | 500 | 15 | 1 | 无 |
LOD模型的制作可以在DCC工具里用减面工具生成,也可以手动拓扑。注意LOD之间的切换距离要设置合理的过渡区间,避免角色在切换时突然跳变。Unity的LOD Group有Fade Transition功能,可以让切换更平滑,但会稍微增加开销。
实操心得:LOD的切换距离不要设得太近,否则玩家会明显看到模型跳变。我一般把LOD0到LOD1的切换距离设在8到12米,LOD1到LOD2设在20到25米,具体根据游戏视角和角色大小调整。
5.2 GPU Instancing与SRP Batcher的适用场景
GPU Instancing适合大量相同网格和材质的角色,比如士兵、群众等。开启方式很简单:在材质的Inspector面板里勾选Enable GPU Instancing。但要注意,GPU Instancing要求所有实例使用相同的材质和网格,如果角色换装或者材质不同,就无法合批。
SRP Batcher是URP/HDRP管线下的合批方案,它不要求相同的网格,只要求相同的Shader变体。开启SRP Batcher后,只要角色的Shader一致,即使材质参数不同也能合批。但SRP Batcher对Shader的结构有要求,所有材质属性必须放在同一个CBuffer里,不能有材质属性在CBuffer之外。
实测数据:在URP下,10个使用相同Shader但不同材质的角色,开启SRP Batcher后Draw Call从10降到1到2,CPU开销降低约40%。但SRP Batcher对Shader的兼容性要求较高,自定义Shader需要按照SRP Batcher的规范来写。
5.3 遮挡剔除与视锥剔除的配合使用
遮挡剔除(Occlusion Culling)和视锥剔除(Frustum Culling)是Unity自带的剔除功能,能自动把屏幕外或者被遮挡的角色剔除掉,减少Draw Call和蒙皮计算。但默认的剔除粒度是Renderer级别,对于角色来说,如果角色的某个部位被遮挡,整个角色还是会被渲染。
要更精细地剔除,可以把角色拆分成多个Renderer,比如身体、头发、武器分开,然后给每个Renderer设置不同的剔除参数。但这样会增加Draw Call,需要权衡。一般来说,角色不需要拆得太细,保持整体剔除就够了。
另外,Camera的Culling Mask可以用来控制不同相机渲染不同的层。比如主相机只渲染主角和近距离角色,小地图相机只渲染图标,不渲染角色模型。这样可以避免不必要的渲染开销。
6. 贴图与内存:容易被忽视的性能杀手
6.1 贴图压缩格式的选择与参数
贴图压缩格式选错,不仅占内存,还会增加GPU的采样开销。移动端上,安卓平台推荐使用ASTC格式,iOS平台推荐ASTC或PVRTC。ASTC的压缩质量比ETC2好,但压缩时间更长。如果项目对包体大小敏感,可以用ETC2,但画质会差一些。
贴图分辨率也要控制。角色主纹理2048x2048就够了,不需要4096。法线贴图1024x1024足够,移动端上法线细节本来就看不太清楚。遮罩贴图、Ramp贴图这些可以用512x512甚至256x256。Mipmap要开启,虽然会增加约33%的内存,但能减少远处的采样开销和锯齿。
注意:贴图的Read/Write Enabled选项一定要关闭,除非你需要在CPU端读取贴图数据。开启这个选项会让贴图在内存里保留一份CPU可读的副本,内存直接翻倍。
6.2 纹理图集与通道打包的实战技巧
通道打包是减少纹理采样的有效手段。比如把金属度、粗糙度、环境遮挡、自发光遮罩分别打包到一张贴图的R、G、B、A通道里,Shader里一次采样就能获取四个信息。这样原本需要4次采样的操作变成1次,片元着色器的开销大幅降低。
图集打包时要注意:同类贴图放在一起,比如所有角色的皮肤贴图放一张图集,所有衣服贴图放另一张;预留扩展空间,不要塞得太满,后续加角色还要往里塞;使用相同的压缩格式,图集里所有贴图的压缩格式必须一致,否则Unity会报错。
6.3 内存与显存的监控方法
Unity的Profiler里有一个Memory区域,可以查看Texture Memory、Mesh Memory、Animation Memory等。人物渲染相关的内存主要在Texture和Mesh上。一个2048x2048的ASTC贴图约占4到8MB内存,10个角色如果每个有3张2048贴图,那就是120到240MB,对于中低端机来说压力很大。
优化内存的手段包括:降低贴图分辨率、使用图集合并贴图、卸载不用的资源。Resources.UnloadUnusedAssets()可以卸载不再引用的资源,但调用时机要把握好,不要在战斗过程中调用,否则会卡顿。可以在场景切换或者加载界面时调用。
7. 常见问题与排查技巧实录
7.1 人物渲染性能问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 帧率突然下降 | Draw Call激增 | Profiler看Batches | 检查是否有角色材质未合批 |
| 手机发热严重 | Overdraw过高 | Scene View开Overdraw模式 | 减少半透明层数,优化头发 |
| 角色动画卡顿 | 骨骼数过多 | Profiler看MeshSkinning.Update | 精简骨骼,开启GPU Skinning |
| 阴影边缘闪烁 | Shadow Bias设置不当 | 调整Shadow Bias和Normal Bias | 增大Bias值,或降低Shadow Map分辨率 |
| 角色边缘锯齿 | 抗锯齿未开启 | 检查Quality Settings | 开启MSAA或FXAA |
| 贴图模糊 | Mipmap或压缩格式问题 | 检查贴图导入设置 | 调整Mipmap Bias或换压缩格式 |
| 角色变黑 | Shader编译错误 | 查看Console报错 | 检查Shader变体是否丢失 |
| 同屏角色多时掉帧 | CPU蒙皮计算瓶颈 | Profiler看CPU Usage | 开启GPU Skinning,使用LOD |
7.2 几个容易踩的坑与独家避坑技巧
坑一:Shader变体爆炸。Unity在打包时会编译所有Shader变体,如果Shader里用了大量的multi_compile和shader_feature,变体数量可能上千,打包时间巨长,包体也大。解决方法是:用shader_feature替代multi_compile(shader_feature只编译实际用到的变体),在Graphics Settings里设置Shader Variant Collection,手动指定需要编译的变体。
坑二:材质球实例化导致合批失败。在代码里修改材质属性时,如果直接访问renderer.material,Unity会创建一个材质实例,导致合批失败。正确的做法是用renderer.sharedMaterial来读取,或者用MaterialPropertyBlock来修改属性。MaterialPropertyBlock不会创建材质实例,但SRP Batcher不支持MaterialPropertyBlock,需要权衡。
坑三:Animator的Write Defaults。这个选项控制Animator在状态切换时是否写入默认值。如果开启,每次状态切换都会重置所有属性,开销较大。如果关闭,状态切换时只写入动画中改变的属性,开销小,但需要确保所有属性在动画中都有覆盖。我一般建议关闭Write Defaults,但要在动画制作时注意覆盖所有需要的属性。
坑四:骨骼层级过深。骨骼层级越深,矩阵计算的累积误差越大,CPU开销也越高。尽量保持骨骼层级扁平化,不要出现十几层的嵌套。如果DCC工具导出的骨骼层级太深,可以在Unity的Import Settings里勾选Optimize,Unity会自动优化骨骼层级。
坑五:实时阴影的级联设置。方向光阴影的级联数量默认是4级,每级都要渲染一遍Shadow Map。中低端机上,把级联降到2级甚至1级,画质损失不大,但开销能省一半。在Quality Settings里可以调整Shadow Cascades参数。
7.3 性能优化后的验证流程
优化做完之后,一定要做完整的验证。验证流程包括:在目标机型上跑Profiler,对比优化前后的帧率、Draw Call、CPU耗时、GPU耗时;在不同场景下测试,比如单人场景、多人同屏场景、战斗特效密集场景;在不同分辨率下测试,比如720p、1080p、2K;长时间运行测试,看是否有内存泄漏或者性能逐渐下降的问题。
我一般会做一个性能测试场景,里面放20个角色,每个角色播放不同的动画,然后跑5分钟,记录帧率曲线和内存曲线。如果帧率稳定在目标值以上,内存没有持续增长,那优化就算通过了。
8. 一个完整的优化案例:从30帧到60帧的实战记录
去年我接手过一个二次元风格的手游项目,战斗场景同屏12个角色,每个角色有完整的骨骼、换装、描边、半透明头发。初始版本在骁龙765G上只能跑28到32帧,手机背面温度45度以上。经过一轮系统优化,帧率稳定在55到60帧,温度降到38度左右。
优化前后的关键数据对比:
| 指标 | 优化前 | 优化后 | 优化手段 |
|---|---|---|---|
| Draw Call | 86 | 24 | 材质合并、图集打包 |
| 骨骼数/角色 | 68 | 42 | 删减手指和面部骨骼 |
| 材质数/角色 | 5 | 2 | 不透明和半透明分组 |
| Shader | Standard | 自定义轻量 | 去掉PBR和法线 |
| 阴影 | 全部实时 | 主角实时+其余假阴影 | 假阴影贴图 |
| 蒙皮 | CPU Skinning | GPU Skinning | Player Settings开启 |
| LOD | 无 | 3级 | LOD Group |
| 贴图 | 2048未压缩 | 1024 ASTC | 压缩格式调整 |
| 帧率 | 28-32 | 55-60 | 综合优化 |
| 温度 | 45度 | 38度 | 综合优化 |
具体操作步骤:第一步,用Profiler定位到Draw Call和蒙皮计算是主要瓶颈;第二步,在DCC工具里把角色骨骼从68根删到42根,重新刷权重;第三步,把5个材质球合并成2个,贴图打包成图集;第四步,把Standard Shader替换成自定义轻量Shader,去掉法线贴图和PBR;第五步,开启GPU Skinning;第六步,制作3级LOD;第七步,把实时阴影改成主角实时加其余假阴影;第八步,贴图从2048未压缩改成1024 ASTC。
每一步的优化效果不是线性的,有些步骤单独看提升不大,但组合起来效果显著。比如材质合并和Shader简化一起做,Draw Call和GPU开销同时下降,帧率提升才明显。
这个项目做完之后,我最大的体会是:人物渲染优化没有银弹,必须系统性地从CPU、GPU、内存三个维度同时下手。只优化一个方面,效果往往有限。而且优化之前一定要先定位,不要凭感觉乱改,否则可能白费功夫。
最后再分享一个小技巧:如果你的项目用的是URP管线,可以在URP Asset里把Shadow Resolution调到1024或512,Shadow Distance调到20米以内,MSAA关掉改用FXAA,这几项改完就能省下不少GPU开销。另外,Camera的HDR如果不需要可以关掉,HDR会增加带宽和内存开销。这些设置看起来不起眼,但在中低端机上效果很明显。