做千人同屏的城市场景时,我踩过一次很深的坑:角色数量一到三百,CPU的蒙皮计算就把帧率拖到二十以下。换LOD、调DrawCall都没用,瓶颈根本不在渲染,而在骨骼动画每帧都在做的矩阵运算。后来换成Mesh Animation Baker这类基于VAT(顶点动画贴图)思路的烘焙插件,同样的场景压到满帧,同屏五百人也没再抖过。这篇文章把整套原理拆开讲清楚,包括烘焙流程、Shader端的采样逻辑、参数如何取舍、以及我实际踩过的坑。1. 技术选型思路:为什么人群动画会成为性能瓶颈1.1 CPU蒙皮计算的开销到底从哪来先算一笔账。Unity里一个正常的人形角色,骨骼数量通常在60到100根左右,每个顶点受4到8根骨骼影响。每帧更新动画时,CPU要计算骨骼的局部矩阵、层级变换、最终矩阵,再把这些矩阵传给GPU,GPU还要逐顶点做一次矩阵乘法和权重混合。假设一个角色网格有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端也只是一次纹理查找和一次坐标映射。这就是VAT(Vertex 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×256(65536像素)不够放,至少要512×512(262144像素)才放得下。如果网格是12000顶点、120帧,那就需要512×1024甚至1024×1024。网格越复杂,动画越长,纹理越大,这一点在选择烘焙精度时要想清楚。2.3 关键参数对照:帧率、顶点数、纹理分辨率与显存消耗参数取舍直接影响两方面:画面流畅度和纹理带宽占用。给一张我实测常用的参数表,大家可以直接抄作业:参数项保守方案平衡方案高画质方案影响结果动画采样帧率15fps30fps60fps采样率低则动作顿挫,高则纹理增大单顶点贴图像素12字节(RGBAHalf)12字节(RGBAHalf)16字节(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里定义常量buffer:UNITY_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受限25ms)400人以上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资产量,必须考虑共性复用、同款换色。还有一个隐蔽问题:烘焙是在离线状态按某个固定的网格拓扑做的。如果运行时需要LOD,LOD网格的顶点数量变了,就无法和原烘焙贴图直接对应。解决办法是烘焙一套最高精度的纹理,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统一。理解透它,你拿到手的不只是人群动画,而是一整套“用纹理交换计算”的通用优化思维。这套思路放在任何引擎里都不过时。
做千人同屏的城市场景时,我踩过一次很深的坑:角色数量一到三百,CPU的蒙皮计算就把帧率拖到二十以下。换LOD、调DrawCall都没用,瓶颈根本不在渲染,而在骨骼动画每帧都在做的矩阵运算。后来换成Mesh Animation Baker这类基于VAT(顶点动画贴图)思路的烘焙插件,同样的场景压到满帧,同屏五百人也没再抖过。这篇文章把整套原理拆开讲清楚,包括烘焙流程、Shader端的采样逻辑、参数如何取舍、以及我实际踩过的坑。1. 技术选型思路:为什么人群动画会成为性能瓶颈1.1 CPU蒙皮计算的开销到底从哪来先算一笔账。Unity里一个正常的人形角色,骨骼数量通常在60到100根左右,每个顶点受4到8根骨骼影响。每帧更新动画时,CPU要计算骨骼的局部矩阵、层级变换、最终矩阵,再把这些矩阵传给GPU,GPU还要逐顶点做一次矩阵乘法和权重混合。假设一个角色网格有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端也只是一次纹理查找和一次坐标映射。这就是VAT(Vertex 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×256(65536像素)不够放,至少要512×512(262144像素)才放得下。如果网格是12000顶点、120帧,那就需要512×1024甚至1024×1024。网格越复杂,动画越长,纹理越大,这一点在选择烘焙精度时要想清楚。2.3 关键参数对照:帧率、顶点数、纹理分辨率与显存消耗参数取舍直接影响两方面:画面流畅度和纹理带宽占用。给一张我实测常用的参数表,大家可以直接抄作业:参数项保守方案平衡方案高画质方案影响结果动画采样帧率15fps30fps60fps采样率低则动作顿挫,高则纹理增大单顶点贴图像素12字节(RGBAHalf)12字节(RGBAHalf)16字节(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里定义常量buffer:UNITY_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受限25ms)400人以上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资产量,必须考虑共性复用、同款换色。还有一个隐蔽问题:烘焙是在离线状态按某个固定的网格拓扑做的。如果运行时需要LOD,LOD网格的顶点数量变了,就无法和原烘焙贴图直接对应。解决办法是烘焙一套最高精度的纹理,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统一。理解透它,你拿到手的不只是人群动画,而是一整套“用纹理交换计算”的通用优化思维。这套思路放在任何引擎里都不过时。