从骨骼动画到VAT:Unity大规模人群动画性能优化实战
做千人同屏的城市场景时我踩过一次很深的坑角色数量一到三百CPU的蒙皮计算就把帧率拖到二十以下。换LOD、调DrawCall都没用瓶颈根本不在渲染而在骨骼动画每帧都在做的矩阵运算。后来换成Mesh Animation Baker这类基于VAT顶点动画贴图思路的烘焙插件同样的场景压到满帧同屏五百人也没再抖过。这篇文章把整套原理拆开讲清楚包括烘焙流程、Shader端的采样逻辑、参数如何取舍、以及我实际踩过的坑。1. 技术选型思路为什么人群动画会成为性能瓶颈1.1 CPU蒙皮计算的开销到底从哪来先算一笔账。Unity里一个正常的人形角色骨骼数量通常在60到100根左右每个顶点受4到8根骨骼影响。每帧更新动画时CPU要计算骨骼的局部矩阵、层级变换、最终矩阵再把这些矩阵传给GPUGPU还要逐顶点做一次矩阵乘法和权重混合。假设一个角色网格有5000个顶点GPU端每帧要做5000次矩阵运算。如果同屏300个角色那就是150万次矩阵运算这还不包括骨骼层级更新的CPU开销。更麻烦的是Unity的Animator本身有DOTS、多线程等优化但动画状态机、IK、事件回调这些机制在大批量角色时仍然是沉重负担。实测下来一个普通品牌手机GPU的活动骨骼数量超过200个时CPU帧耗时就开始明显上涨。1.2 GPU Instancing解决不了“动画”这一环GPU Instancing能大幅减少DrawCall让几百个相同网格一次DrawCall绘制完成。但它只解决“静态实例”或者“同一帧同一姿态”的重复绘制问题。人群动画里每个角色需要不同的骨骼姿态这就意味着每个实例提交给GPU的骨骼矩阵数组是独立的Instancing的合批优势会被矩阵上传开销抵消大半。而且逐实例上传骨骼矩阵数组要占很大的常量缓冲区空间单个DrawCall能承载的实例数量也有上限。基础参数再叠上阴影深度Pass同一个角色还得再算一次骨骼变换整个带宽压力落到GPU上也吃不消。所以单纯堆Instancing没法在人数上做大规模突破。1.3 VAT方案的核心思路把动画做成一帧帧顶点快照Mesh Animation Baker这类插件走了一条完全不同的路它把骨骼动画的运行期计算变成烘焙期的预处理。在离线或导入阶段把每一帧动画的顶点位置、法线直接计算出来写入一张纹理准确说是一张位置贴图和一张法线贴图。运行时Shader做的事情只剩一件用顶点索引和时间帧去采样贴图把采样结果直接作为顶点位置输出。不再有骨骼矩阵、权重混合、层级变换动画计算从CPU迁走GPU端也只是一次纹理查找和一次坐标映射。这就是VATVertex Animation Texture的核心思想。这套方案有两个直接红利Shadergraph支持的移动GPU都对纹理读取做了专门优化带宽友好顶点动画不依赖骨骼层级几百上千个实例用Instancing一次绘制效果非常可观。2. 动画烘焙的核心机制从骨骼动画到纹理坐标2.1 烘焙的三个步骤采样、写纹理、归一化先说是怎么把骨骼动画“烤”进纹理的。流程并不复杂如果你愿意甚至可以用一个编辑器工具脚本自己实现。第一步是采样。在烘焙窗口期按固定帧率比如每秒30帧或60帧驱动源骨骼动画每一帧记录下网格所有顶点的世界空间位置和法线。这里的“驱动”可以是Unity自带AnimationClip也可以是外部第三方动画资产。每个顶点每个采样帧产生一个三维坐标和一个三维法线。第二步是写纹理。把顶点索引和帧号映射到像素坐标将位置和法线分别编码进两张贴图。常规做法是把贴图横向切分横向坐标对应顶点索引纵向坐标对应帧号。也有的实现把顶点切分到多个tile本质上是一回事。第三步是归一化。纹理能存的最大值范围是0到1或者Half Float的负值到较大正数而角色顶点的坐标值动辄在几米到几十米所以必须用一个包围盒或者AABB来归一化记录中心点和范围把顶点位置映射到 [0,1] 区间运行期再逆推回来。还有一步容易忽略法线也必须归一化并偏离0.5基准因为法线可能是负方向纹理颜色通道默认没有负数概念。有些插件把法线打包进RGBA的A通道或者单独用一张RGBA贴图存法线避免过度压缩导致颜色偏绿、偏紫。2.2 贴图布局为什么顶点索引和帧号配成“一张大图”VAT贴图本质上是一张二维查询表。横向坐标U存顶点ID纵向坐标V存时间帧。渲染时顶点着色器拿到当前渲染的顶点索引可以用SV_VertexID获取也可以从UV预存拿到动画时间可以是全局时间也可以是每个实例的随机偏移然后在贴图上取对应像素。为什么要横向顶点、纵向时间而不是反过来因为GPU纹理采样时横向相邻像素在显存里通常也是连续排布的同一帧内大量连续顶点索引能被缓存友好地顺序读取采样效率更好。反过来做一帧内要跨行跳像素缓存命中率就差了。贴图分辨率选择很关键。例如角色网格3600个顶点、动画60帧总共需要20万多个像素一张256×25665536像素不够放至少要512×512262144像素才放得下。如果网格是12000顶点、120帧那就需要512×1024甚至1024×1024。网格越复杂动画越长纹理越大这一点在选择烘焙精度时要想清楚。2.3 关键参数对照帧率、顶点数、纹理分辨率与显存消耗参数取舍直接影响两方面画面流畅度和纹理带宽占用。给一张我实测常用的参数表大家可以直接抄作业参数项保守方案平衡方案高画质方案影响结果动画采样帧率15fps30fps60fps采样率低则动作顿挫高则纹理增大单顶点贴图像素12字节RGBAHalf12字节RGBAHalf16字节RGBAFloat精度和显存占用位置贴图分辨率256×256512×5121024×1024单角色显存占用法线贴图分辨率256×256512×5121024×1024单角色显存占用动画偏移每个实例随机相位每实例随机8~16相位手动配置战役人群错落感这里有一个我比较推荐的量化公式单角色纹理总像素数约等于“顶点数×动画帧数×1.1”。一家伙50万像素以内的角色1060级显卡随便跑几百个。纹理容量按每个像素4字节RGBAHalf的一半估算一个50万像素的纹理占用约2MB显存。注意这是显卡显存占用不是内存移动端尤其要盯着这个数。法线和位置贴图建议分开存不要贪省空间塞进一张RGBA。位置贴图对精度要求高一旦压缩动作边缘会出现肉眼可见的波浪抖动。法线贴图可以适当用低分辨率毕竟法线变化相对平滑低分辨率法线配合高分辨率位置画面观感仍能维持。2.4 归一化坐标的细节中心点、范围和数据结构位置数据是浮点数动辄10米以上直接存贴图根本没法看。常见做法是用角色骨骼动画期间的AABB做变换float3 Mini -0.5f * _BoundsRange; // 包围盒最小点 float3 Size _BoundsRange; // 包围盒大小 float4 AnimatedPos (pos - Mini) / Size;运行期再做一遍逆运算也就是Shader里的这两行float3 pos tex2Dlod(_PositionTex, float4(uv,0,0)).rgb; pos pos * _BoundsSize _BoundsCenter;要特别注意Unity的纹理坐标和顶点ID映射必须保证同一套否则烘焙出来就是一锅粥。而且烘焙出的顶点位置已经包含模型变换后的世界空间或模型空间烘焙时选取的空间规范两边要约定死运行时Shader也必须用同空间处理。3. Shader实现与渲染管线集成3.1 顶点着色器里的贴图采样核心代码一个完整的VAT顶点Shader并不复杂核心代码我拆成两段。第一段是采样坐标构造。顶点ID可以从输入结构里自带UV的x通道拿实际上插件通常会把顶点索引预烘焙进UV里这样不需要SV_VertexID避免一些顶点流扩展的开销float2 GetAnimUV(float vertexIndex, float timeFrame) { float u (vertexIndex 0.5) / _VertexCount; float v (timeFrame 0.5) / _FrameCount; return float2(u, v); }第二段是顶点位置和法线更新。因为位置贴图存的是归一化之后的坐标必须先逆归一化再参与后续MVP矩阵变换float4 uvPos float4(GetAnimUV(IN.uv.x), 0, 0); float3 worldPos tex2Dlod(_PositionTex, uvPos).rgb; worldPos worldPos * _BoundsSize _BoundsCenter; float3 worldNormal tex2Dlod(_NormalTex, uvPos).rgb; worldNormal worldNormal * 2.0 - 1.0;然后正常Unity变换即可。注意法线要乘的是到世界空间的变换矩阵如果顶点位置烘焙在模型空间还要再叠模型矩阵如果烘焙在世界空间直接输出就行。个人更推荐世界空间烘焙省去矩阵乘法精度损耗。3.2 配合GPU Instancing怎么让上百人各自走不同的时间有了顶点动画材质接下来就是Instancing。每个实例只需要一个属性当前动画时间偏移。比如A实例走0.0秒起步B实例走0.4秒起步C实例走0.8秒起步通过这个错峰人群立刻有“大家各走各的”的层次感。Unity的Instancing在Shader里定义常量bufferUNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _AnimOffset) UNITY_INSTANCING_BUFFER_END(Props)然后在顶点着色器里读取float timeUV frac(_Time.y * _AnimSpeed UNITY_ACCESS_INSTANCED_PROP(Props, _AnimOffset));用frac保证时间一直在0到1循环人群往复走动不用手动管理循环点。更大的规模优化见后文基础Instancing足够支撑数百人场景。关于GetAnimUV里的u坐标要注意一个坑如果网格有UV合并或者一半顶点不被动画影响顶点索引并不连续。有些插件会把顶点索引存成顶点色或第二套UV更稳妥的办法是用剖分后的索引重排网格让烘焙贴图反向映射到正确顶点。这个问题在“问题排查”还会展开。3.3 阴影Pass和深度Pass为什么阴影常“丢人”很多VAT方案做出来之后角色在地面上走着走着突然就没了或者阴影留在原地角色本体飘走了。这多半是ShadowCaster Pass和Depth Pass没有同步做顶点动画。Unity的阴影渲染走独立的Pass默认只用原模型顶点位置它不知道你用贴图偏移了顶点坐标。所以必须把顶点动画的核心Shader逻辑复制到ShadowCaster Pass里甚至还要再复制一份用于LOD过渡、深度的Pass。正确做法是在SubShader内写两个Pass一个ForwardBase做常规渲染一个ShadowCaster做阴影投射。两个Pass用同一个VertexShader变体通过宏开关控制是否输出深度。烘焙时贴图是唯一数据源阴影方向错误的问题基本都能靠Pass统一解决。3.4 动态合批和批量绘制的边界条件GPU Instancing不是一定能合批。VAT实例之间唯一不同的就是_AnimOffset这属于实例化属性合批没有任何问题。但如果材质有改动比如某个角色穿了不同颜色的衣服材质实例数一变合批就会断开。人群项目里建议把外观差异放到贴图上而不是用多材质。还有一种情况是网格很小、实例很多这时候逐个实例的簇状调度开销也很可观GPU绘制的上限不取决于实例数而取决于总顶点数和Shader复杂度。一个角色网格若超过2万顶点大量实例化之后顶点处理压力极大。需要合理控制网格密度用LOD来兜底。后面第6部分会提到LOD和VAT结合起来是一个很成熟的思路。4. 完整实操流程从模型到成片的一点一滴4.1 准备模型与动画资产接入Mesh Animation Baker之前要保证模型本身干净网格不包含额外顶点色需求UV摊位没被其他东西占用传统做法是把顶点索引烘焙到UV.x层级命名别太乱动画片段最好是循环动作而且要确认源动画的root motion处理方式。动画片段建议选择循环动作以便烘焙后做无缝拼接。启停动作、随机动作也可以但要额外烘焙多个动画片段运行时通过动画纹理的偏移切换而不是依赖Unity动画状态机。把模型拖入场景前不勾选“优化游戏对象”也不要加任何高开销组件烘焙只关心骨架和蒙皮信息。如果发现烘焙结果有的顶点跑飞先检查蒙皮权重和骨骼动画的bounding box范围这是最常见的光照剔除问题。4.2 烘焙参数设置与生成流程在相关插件面板或联网脚本里一般有这几个地方选择动画片段2. 设置帧率3. 分辨率自动计算4. 是否生成法线贴图5. 是否应用包围盒归一化6. 输出路径。我给一组实测稳妥的参数角色约3000顶点一段2秒的循环走路动画采样30fps大约60帧。位置贴图用512×256法线贴图用256×256。烘焙完检查贴图位置贴图看起来是一个个颜色渐变的色块淡蓝淡绿都正常别有纯黑纯白斑块那代表顶点跑出包围盒了法线贴图像一张淡淡的彩色模糊图别有大片饱和色。生成完资源和组件后场景里应该能看到一个替换了Shader的MeshRenderer材质 inspector 能展示 Animation Offset、Animation Speed、Loop 属性。此时拖动时间角色应该能看到完整动画。4.3 场景搭建、实例化安排搭建大场景时推荐用遮罩或三角形网格来控制实例分布人群自动填充区域。传统做法是放一排空物体作为中心点运行时用位置随机错开这对性能优化没有好处也不利于遮挡剔除。用GPU Instancing时提交顺序很重要同一个材质、同一个VAT贴图的角色排在一起一次DrawCall绘制几百个。如果不同角色共用同一张纹理但采样起始不同只要材质相同仍然可以合批。因此不要给每个角色复制一个材质实例要用材质PropertyBlock或者Instancing Buffer传_AnimOffset。做完后打开Frame Debugger一个“Draw Mesh Instanced”的条目里能看到几百个实例这就是成功的标志。4.4 性能基线数字参考我在不同硬件上实测过一组典型数据场景行人每个角色3000顶点、60帧动画位置法线共约1.5MB显存硬件级别骨骼动画极限人数VAT方案同级人数帧耗时对比中端手机GPU60~80人CPU受限25ms400人以上CPU耗时从15ms降到2ms桌面入门显卡150人左右800人以上总帧耗时稳定在12ms内高端桌面GPU300人左右2000人以上GPU带宽接近饱和帧率仍稳重点不是跑多少数量而是CPU掉下来之后让出了大量余量给逻辑、刷怪、天气系统。VAT不单是“渲染优化”更像是对整个帧耗时的“结构性释放”。5. 常见问题与排查技巧实录5.1 动画抖动、瞬移、速度失真表现角色动作一顿一顿或者快到飞起。排查顺序先看贴图帧率与场景时间是否匹配。采样帧率30fps意味着每帧时间步长是1/30但运行期Shader可能用真实秒数直接除以时间就会造成速度差另外循环动画末尾的插值如果没处理好也会造成末尾跳变。解决烘焙时把循环动画首尾帧设置成一致烘焙帧率建议是运行期Shader采样频率的整数倍。如果动画要慢放要么让Shader采样时间变慢要么把动画帧数增多。千万不要指望GPU自动插值纹理采样线性过滤只能做相邻帧的简单线性插值遇到动作幅度大的动力学骨骼比如裙摆、头发插值会产生不可接受的伸缩。5.2 半透明阴影、颜色异常、边缘锯齿表现角色边缘有亮边或阴影发黑。这类问题绝大部分是Texture格式和压缩方式引起的。位置贴图建议用RGBAHalf不要用RGB24或者带mipmap压缩的BC格式。位置数据对精度太敏感一旦因为压缩导致某个顶点的坐标偏差几个厘米整个身体就会破洞。法线贴图建议用RGBAHalf或者单独用RGHalf别用BC5直接吃半精度在这个尺度够用。边缘锯齿优先检查模型UV接缝处的相邻顶点是否分别落到了贴图的不同行如果是可以把UV重映射成单行连续数据。5.3 阴影错位、角色被裁剪角色本体显示正常但地面影子位置不对或者影子离角色越来越远。这是阴影Pass未同步顶点动画导致的参考前面3.3节把顶点采样统一放进所有Pass。还有一种情况是引擎的默认投影矩阵以模型包围盒为准做裁剪VAT烘焙后顶点可能超出原模型包围盒需要把SkinnedMeshRenderer的bounds改成一个包含所有动画状态的大包围盒否则角色一动就原地消失。5.4 显存和内存水位异常逐角色烘焙的纹理如果数量多显存一下子就顶满了。排查时优先看Resolution是不是被锚定在1024×1024一种优化是顶点数超过2万才用1024常规角色用512×256足够。配合纹理压缩技术移动端可以用ASTC HDR桌面端用BC7但只能对法线贴图这么做位置贴图请保持不压缩。内存方面烘焙产出的Texture如果作为常规资源常驻内存要考虑卸载。做法是运行时用Graphics.RenderMeshInstanced一次性提交烘焙贴图只留一张采样源不再保留每个角色的MeshRenderer和材质实例。用Texture2DArray还能更进一步把多角色贴图叠进同一张数组纹理Shader根据实例ID索引图层这是大做量时的进阶选项。5.5 排查速查表症状第一嫌疑解决建议动作抖动采样帧率和Shader时间不匹配统一帧率循环帧一致破洞闪烁位置贴图压缩精度不足改RGBAHalf禁用压缩阴影错位ShadowCaster Pass缺VAT逻辑所有Pass共用一个VertexShader角色消失包围盒过小被视锥剔除放大bounds预烘焙AABB显存爆满单角色纹理分辨率过高用512×256级别减半分清位置和法线纹理合批失效材质实例被复制用Instancing Property Block传参数6. 这个方案的扩展玩法与边界6.1 不止人群植被、布料、粒子都能“烤”VAT技术最经典的扩展场景是植被。传统草和树的骨骼动画开销很大而且风的影响对大量植被是全局性的。把叶片摆动、整株摇晃烘焙成几个动画周期贴图运行时通过全局时间加随机相位一片森林的亿万片叶子也能流畅飘动。布料类物体同样适用裙摆、披风、旗帜这类轻量动态动画烘焙在DCC里跑一遍Pelvis模拟然后把布料顶点序列烘焙成贴图运行时让这段布料变成“自带动画的静态网格”。这种方法比运行时布料模拟便宜一个数量级视觉效果还稳定。人群和角色动画也能做多层叠加两层VAT分别存“走路”和“攻击”Shader里用两个时间偏移分别采样并插值。只要烘焙分辨率和帧数受得了Unity运行时可以用一个混合权重控制动画切换效果类似传统的Crossfade。6.2 限制和坑VAT不是万能药VAT最大的限制是“烘焙出来的东西改不了”。你不能运行时临时改变骨骼角度不能做IK物理反馈也基本无解。动画一旦烘焙就只是一套固定回放适合打底的大规模环境角色不适合需要高度交互的NPC。内存和显存换来的是CPU释放但如果角色种类太多每个角色都需要独立一套烘焙纹理资产体积会膨胀。如果项目有100种不同角色每种角色位置法线按2MB算就有200MB资产量必须考虑共性复用、同款换色。还有一个隐蔽问题烘焙是在离线状态按某个固定的网格拓扑做的。如果运行时需要LODLOD网格的顶点数量变了就无法和原烘焙贴图直接对应。解决办法是烘焙一套最高精度的纹理LOD网格在做顶点动画采样时用UV坐标去插值原网格顶点位置但这要求LOD网格的UV布局与原网格接近。实践里更多是用“同骨架同顶点数”的LOD策略或者干脆给LOD单独的烘焙贴图。6.3 结合DOTS与RenderMeshIndirect的进阶方案如果目标轻松千人、万人级别GPU Instancing已经不够。把实例数据放到ComputeBuffer里用Graphics.RenderMeshIndirect提交实例ID从SV_InstanceID读出来再从Buffer里取位置、朝向、动画偏移。注意VAT本身做的是“动画格子内的世界位置”大范围的实例移动还需要Buffer里的实例位置去偏移两者的坐标系要理清。我实际测过一套万人草场景方案把草叶的摆动烘焙成位置贴图实例位置用圆形抽稀算法放在ComputeBuffer一个DrawCall绘制20000个草簇移动端依然满帧。这套路本质上就是“VAT烘焙动画 GPU驱动的大规模实例”。如果Unity项目考虑ECS架构直接把实例位置和动画偏移挂在Entity组件上ComputeShader排队更新能到更高量级。6.4 个人实操体会我在不少项目里用过这套方案有一个很深的感受适合用VAT的场景一定不是“偶尔几十个角色”而是“数量多到角色动画的CPU计算已经占用了其他系统预算”。最常见的是开放世界的路人、集会演出、开场的兽潮、商品展示区的批量动作。做之前先问自己两个问题每个模型必须保留IK吗必须能实时改变局部骨骼吗如果两个答案都是否VAT烘焙基本就是当前最优解。调试时给我帮助最大的还是Texture可视化直接在编辑器里把位置贴图放大看沿着U方向移动采样点如果颜色不是连续渐变的说明烘焙映射有跳边如果某一块突然变成纯黑说明包围盒归一化没覆盖全。这两种视觉排查比刷一堆日志管用得多。最后说一句真心话Mesh Animation Baker这类工具虽然名义上是“插件”本质上教的是一套可以自己实现的GPU动画管线——烘焙、采样、Instancing、阴影Pass统一。理解透它你拿到手的不只是人群动画而是一整套“用纹理交换计算”的通用优化思维。这套思路放在任何引擎里都不过时。