ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Unity人物渲染性能优化实战:从骨骼到Shader的全链路踩坑指南

Unity人物渲染性能优化实战:从骨骼到Shader的全链路踩坑指南 我最早入行那会儿总觉得 Unity 角色渲染只要“能跑起来就行”直到第一次被 Android 真机帧率按在地上摩擦场景里就两个主角模型愣是把骁龙中端平台拖到 20 帧出头。那时候才意识到Unity 人物渲染性能优化根本不是“调低分辨率、关个阴影”这么简单它背后牵扯到骨骼动画、材质变体、合批机制、阴影通道、内存带宽一整套链路。这篇文章就当是我这几年的踩坑笔记围绕 Unity、人物渲染、性能优化三个关键词把场景怎么搭、Shader 怎么砍、真机怎么测、问题怎么查完整过一遍。适合正在做移动端 ARPG、MMO 或者次世代卡通渲染项目的朋友尤其是那种“角色一多就卡、一开阴影就烫”的阶段。1. 先定思路角色渲染优化的“三件套”1.1 为什么人物渲染要单独拎出来做很多人一开始喜欢用全局渲染设置做优化比如把整体阴影质量调低、把全局的贴图分辨率压小。这招对静态场景挺管用但一遇到角色就失灵因为人物渲染和场景渲染本质上不是一回事。场景里的墙、地面、石头基本是静态的网格不变化、矩阵不更新可以走静态合批、GPU Instancing、遮挡剔除。角色不一样Skinned Mesh Renderer 每一帧都要做骨骼矩阵计算、蒙皮顶点变换还要根据动画状态切换材质参数。再加上角色通常是镜头焦点任何画质劣化观众一眼就能看出来你不可能为了省性能把主角的贴图改成 256×256也不能粗暴关掉所有阴影否则角色像贴纸一样浮在地面上观感直接崩。这就意味着角色必须当成一套独立的渲染管线来优化场景优化和角色优化要分开做。我在项目里习惯先把“角色通道”从全局渲染里摘出来用单独的相机层、单独的 Shadow Only 灯光、单独的 LOD 规则去控制然后再去看全局设置。这样做的好处是排查问题的时候边界清晰帧率掉了先看角色通道占了多少毫秒再看场景占了多久不会混在一起瞎猜。1.2 优化目标与衡量标准定优化方案之前先定目标不然优化就是无底洞。我一般按三个档位定标准平台目标帧率单帧预算角色渲染参考耗时中端 Android30 FPS33.3 ms≤ 8 ms高端 Android / iOS60 FPS16.6 ms≤ 5 msPC / 开发机60 FPS16.6 ms≤ 6 ms注意这里说的是“参考”因为角色大小、数量、同屏场景复杂度都会影响数值。但无论如何Profiler 里的帧耗时是硬指标不是“感觉还行”就行。每个版本优化完我都要用同一台测试机、同一个场景、同一段镜头动作回放三遍取中位数帧耗时记录下来形成基线。有了基线之后才能谈“优化了多少”。没有基线就去改参数那就是摸着石头过河改完也不知道是变好还是变坏。2. 拆解开销人物渲染把性能花在了哪里2.1 蒙皮与骨骼更新的开销先聊最容易忽略的一项蒙皮计算。很多人以为角色性能瓶颈都在 Pixel Shader其实在顶点数偏多、骨骼数偏多的情况下蒙皮阶段能把 CPU 和 GPU 同时卡住。一个 10000 顶点的角色模型假设每顶点绑定 4 根骨骼权重那么蒙皮阶段就要执行约 40000 次骨骼加权变换。CPU 侧要先把所有骨骼的矩阵算好传进 GPUGPU 侧顶点的位置、法线、切线都要乘骨骼矩阵累加权重。顶点数翻倍蒙皮开销近似翻倍而且这个开销不受 LOD 距离之外的对象数量影响不大前提是每个角色都在更新硬件蒙皮。更隐蔽的问题是动画更新本身。Unity 里 SkinnedMeshRenderer 即使没有被相机看到默认也可能持续更新骨骼和蒙皮。场景里有十几个 NPC 角色每个都在后台算骨骼矩阵哪怕它们根本没上屏帧率照样被拖下去。这时候必须把 Animation 组件或者 Animator 的 Culling Mode 改掉让它离屏就停止更新。2.2 Draw Call 与 Shader 变体的叠加角色渲染的杀手锏永远绕不开 Draw Call。一个人物模型通常被拆成头发、脸部、身体、武器、披风好几个材质。每个材质一个 Draw Call几个材质就是几个 Draw Call。如果再给角色加上描边、流光、受击闪白这些效果每个效果就可能产生额外的 Pass 或者额外的材质变体。这里最坑的是材质变体。同一个 Shader因为关键字不同、渲染管线不同编译出来的变体可能多达几十个。Unity Build Report 里你能看到 Shader 变体数量膨胀到几千很多根本没用上但打包的时候全部被塞进去。运行时每切换一组材质关键词GPU 就要重新切换渲染状态SetPass Call 数量暴涨对移动端尤其致命。角色又是最容易产生“独占材质”的对象。玩家换装系统给每个角色动态生成一份材质实例材质参数各不相同那这些材质全部无法合批Draw Call 指数上升。这也是 Unity 马赛克、换装、捏脸系统之后性能骤降的根本原因之一。2.3 阴影、后处理和“看不见”的浪费很多项目角色性能优化卡在最容易忽视的环节阴影绘画。实时阴影的本质是把摄像机放到光源位置再渲染一次深度图。一个角色如果 Cast Shadows 和 Receive Shadows 同时打开且场景里有主光加两三个补光都开着实时阴影等于角色要被多画三四遍。移动端这是在自找难受。我在项目里调试时还发现一个典型问题为了做角色边缘光效果美术在 Shader 里直接加了 GrabPass 或者屏幕后处理依赖结果整个屏幕内容要被重新采样一遍。角色的轮廓、描边、辉光能做得好看不假但每多一个屏幕级采样GPU 的带宽和填充率就多一分压力。对移动端来说Overdraw 是比多边形数量更可怕的敌人一个小角色在屏幕上不足 200 像素但如果半透明层叠了七八层填充开销照样爆表。3. 实操复盘一套可以直接照抄的角色渲染优化流程3.1 模型与贴图进引擎前先处理进 Unity 之前模型侧能做的事其实比引擎内更多。我每次拿到新角色资产会先在 Blender 或者 Maya 里做三件事。第一降低骨骼数量。不是所有骨头都要给完整骨骼链手指的一节、衣摆的飘带很多地方用 1 到 2 根骨骼占位就够了。骨骼直接决定了蒙皮矩阵的运算量无意义的骨骼越多性能越差。第二检查顶点权重数量。Unity 默认支持每顶点 4 根骨骼权重多于 4 根的会在导入时被截断可能造成蒙皮异常。反过来如果角色全身大部分顶点只需要 2 根骨骼权重那尽量把权重数目压低这能明显加快蒙皮计算。很多引擎内做不了这一步所以源文件就处理掉是最划算的。第三贴图合并成图集。人物常见的拆分方式是头、上半身、下半身、武器各一张贴图这会导致至少 4 个材质。与其这样不如把 UV 布局重新整理合成一张 2048 或者 1024 的 Atlas材质数量直接降到一个。少一个材质就是少一个 Draw Call这一步的性价比极高。顺便说一句模型顶点数。移动端角色建议单模型在 15000 到 30000 顶点之间PC 端可以放宽到 50000。不要追求“面数越多越精致”贴图细节和法线贴图带来的观感提升通常比多边形更划算。3.2 Unity 场景内的逐项设置模型没问题之后进 Unity 场景里逐项核对。我列一个经常检查的清单每一项都踩过坑。灯光设置全场景只保留一盏实时平行光作为主光源并且开启阴影。其余点光源、聚光灯要么烘焙进 Lightmap要么用 Light Probe 模拟。移动端上绝对不要让多盏实时灯光同时影响角色否则每个像素要算多组光照模型费电还发热。相机设置确认相机的 HDR 和 MSAA 是否真的需要。很多项目默认把 HDR 打开渲染范围从 [0,1] 变成高动态范围带宽增加不少如果画面没有明显的 Bloom 需求关掉能省一截。MSAA 在高端机可以开 2x中低端建议用 FXAA 或者 TAA 代替节省的像素填充很可观。LOD 与遮挡剔除至少给角色做三档 LODLOD0 是完整模型LOD1 去掉头发面片、简化布料LOD2 直接换低模或者 Billboard。遮挡剔除记得检查 Occlusion Culling 窗口里的 Areas我见过不少项目打勾了遮挡剔除却忘了生成遮挡数据等于没开。动画剔除Animator 的 Culling Mode 从 Always Animate 改成 Based On Renderers。离屏的 NPC 不该继续算骨骼实测一个 20 个 NPC 的场景仅这一项就能省掉 3 到 4 ms 的 CPU 耗时。3.3 Shader 减负与 SRP BatcherShader 是角色渲染性能优化里弹性最大的环节。一套好的角色 Shader 不应该堆功能和特效而应该“按需组装”。我的建议是移动端直接用 URP换掉 Built-in Render Pipeline。URP 在光照计算、SRP Batcher、Shader 变体剥离上都有明显优势。角色 Shader 尽量用 URP/Unlit 或者 URP/Lit 的轻量版本改不要直接拿内置 Standard Shader 硬扛。Standard 里包含一堆全局光照、反射探针、实时阴影的关键字对移动端角色来说属于严重超配。然后打开 SRP Batcher。这个功能对角色渲染特别友好因为角色只有一个网格、一个材质、一个 TransformSRP Batcher 能把这些对象的 CPU 数据打包成统一的内存布局显著减少 SetPass 调用的 CPU 开销。注意 SRP Batcher 要求 Shader 兼容 SRP Batch用了自定义 Shader 得在材质面板上确认兼容性标识。对于描边、受击闪白、技能发光这类效果不要做在主 Shader 里做成独立的 Overlay Pass 或者独立的材质层在需要的时候才切换 Pass。效果层叠虽然好看但每个额外 Pass 都是真金白银的性能开销。3.4 批量角色的实例化如果场景里要放几十个重复 NPC比如广场上的卫兵、村庄里的行人那就得上实例化。这里的核心限制是 Skinned Mesh 不能直接像静态 Mesh 那样合并合批因为每一帧的顶点位置都随骨骼变化。我常用的方案是“远近分离”近处的几个 NPC 保留 SkinnedMeshRenderer做完整骨骼动画中远距离的 NPC 烘焙成顶点动画纹理用 MeshRenderer GPU Instancing 播放更远的直接切换成 Billboard 或者带透明贴图的十字片。这个方案在手游大世界里非常常见远处的人流、鸟群、鱼群基本都是这个套路。顶点动画纹理的实现思路是在 DCC 工具里把一段循环动画烘焙到纹理每个顶点的位置变化存成 RGB运行时 Shader 采样纹理来偏移顶点位置。这样一张纹理就能驱动成百上千个角色而且完全避开了骨骼蒙皮计算。代价是纹理内存会增加一些但对于中远景批量角色来说换来的性能收益是值得的。4. 移动端特有的大坑和排雷技巧4.1 真机压测才是真编辑器不是这句话我强调再多遍都不过分Unity Editor 里的 Profiler 数据只能看逻辑框架不能当真机性能参考。编辑器渲染走的是桌面 GPU瓶颈习惯性集中在 CPU 脚本上而移动端 GPU 受限时瓶颈在 Fillrate、带宽、指令吞吐完全不是一码事。我曾经在编辑器里把角色渲染优化到 2 ms美滋滋上了真机结果帧率比之前还难看原因是编辑器显卡根本不在同一个压力等级。后来我干脆养成习惯优化完只信真机数据编辑器只用来查脚本耗时和 Draw Call 组织。Android 用 PerfDog 或者 Snapdragon ProfileriOS 用 Xcode 自带的 GPU Frame Capture两个平台都要测因为 Adreno 和 Mali 对 Shader 的容忍度差别很大。Mali 上很贵的半透明 OverdrawAdreno 可能还好Adreno 上复杂的浮点指令Mali 可能直接瓶颈。4.2 纹理压缩、分辨率与内存上限移动端角色优化的另一大板块是纹理内存和带宽。同样一张 2048×2048 的 RGBA32 贴图未压缩要占 16 MB 显存换成 ASTC 4×4 压缩后大约 4 MB带宽压力也成倍下降。Android 上首选 ASTCiOS 上 ASTC 也是标配实在兼容性有要求才用 ETC2。这里有一个常见误区很多人只压 Diffuse漫反射贴图忽略了法线贴图、自发光贴图、Mask 贴图。这些贴图如果不压缩照样占用大量带宽。法线贴图压缩后精度会略降但移动端可以接受除非是特写大脸镜头否则压缩收益远大于精度损失。还有 Mip Maps。角色贴图必须开 Mip Maps不然模型一旦缩小采样会出现严重闪点和锯齿但 UI、特效、序列帧贴图建议关掉 Mip Maps省内存也省带宽。纹理大小上限我一般控制在 2048个别主角可以上 4096但 Boss 和 NPC 千万别跟着上。4.3 新手容易踩的几个隐藏陷阱有些坑不是文档里写明白的得实际撞过才知道。第一个坑材质动态实例化后无法合批。项目代码里只要对某个材质赋值了material.color或material.SetFloatUnity 就会自动克隆一份材质。场景里五个角色共用一个材质只要有一处代码做了material.SetXxx立刻变成五个独享材质所有合批优势全部瓦解。正确做法是使用MaterialPropertyBlock它不会触发材质克隆又能让每个角色持有独立颜色或浮点参数。第二个坑阴影配置过于激进。角色在特写镜头里确实需要投影但没必要让所有角色都 Cast Shadows 和 Receive Shadows。NPC 和远景角色设置 Cast Shadows OffReceive Shadows Off只保留主角和一个关键 Boss 的阴影画面观感不会差太多帧率能明显回升。第三个坑布料和头发系统无脑堆物理。Unity 的布料模拟是 CPU 密集计算每个碰撞体都要参与解算。披风、长发、裙子如果全部启用实时布料移动端动不动就掉十几帧而且解算不稳定还会抖动。我的折中方案是只对主角披风开启布料模拟NPC 和杂兵直接用骨骼动画模拟飘动。第四个坑Shader 里写了#pragma multi_compile但没控制变体数量。每多一个 multi_compile 关键字变体数量是几何级增长。能用shader_feature_local就尽量用编译时把用不到的变体剥离掉减小包体也缩短运行时状态切换时间。5. 问题速查与后续优化方向5.1 一条实战排查链路真机帧率上不去的时候我推荐按这个顺序排查能省很多时间。第一步Unity Profiler 里先看 CPU 还是 GPU 是瓶颈。如果 CPU PlayerLoop 里的Render或者Animation很高说明骨骼计算、Draw Call、脚本逻辑有问题如果 CPU 不高但 GPU 帧率低那大概率是 Overdraw、纹理带宽、Shader 指令的问题。第二步看 Draw Call 和 SetPass Call。Draw Call 数量超过 200或者 SetPass 超过 100基本可以确定渲染状态切换过多先合批、再检查材质数量、再砍变体。第三步再看骨骼数、顶点数、动画更新范围。用 Profiler 里新增的 Frame Debugger把渲染列表一条条拉出来看哪些 Draw Call 是角色贡献的哪些是场景贡献的。下面是我整理的一份速查表直接照着对号入座症状瓶颈定位解决方案CPU Render 耗时高Draw Call 爆炸合并材质、开 SRP Batcher、LODCPU Animation 耗时高骨骼动画全量更新Animator Culling、离屏停止、骨骼精简GPU 帧耗高Overdraw / 纹理带宽减少半透明层、纹理压缩、关 HDR角色一多就卡实时阴影 每角色材质实例阴影分级、MaterialPropertyBlock帧率波动大顶点动画 / 蒙皮峰值GPU Skinning、顶点动画纹理、LOD5.2 后续还能继续深挖的方向到这里聊的是大多数项目能直接落地的优化手段但角色渲染的上限还远不止这些。如果是做超多同屏角色的项目下一步可以研究 GPU Skinning 和 DOTS Animation。Unity 的 SkinnedMeshRenderer 自带 GPU Skinning 选项把蒙皮计算挪到 GPUCPU 的骨骼矩阵上传压力会小很多。DOTS 加 Burst 则是把整个动画系统搬到 ECS 架构上适合大规模 NPC 群体但学习成本和重构成本都相当高不建议中小项目为了炫技硬上。如果只是想继续压榨现有管线我建议把精力放在 Shader 变体剥离和 SRP Batcher 兼容性上。多做几版基准测试你会发现角色渲染在移动端的优化空间永远比想象中大但前提是每一步都能量化。我个人这几年的体会是角色渲染性能优化没有一招见效的银弹真正稳的方式是每个版本只动一个变量测出变化再动下一个。今天砍 0.5 ms明天削 0.2 ms攒下来就能把角色渲染从“能看”推到“能打”。这个过程很枯燥但真机帧率涨上来的那一刻你会觉得前面所有折腾都没白费。
RELATED READING

延伸阅读

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