从骨骼动画到顶点动画:GPU Instancing实现千人同屏的人群渲染方案
做项目最痛苦的一件事就是在“人群”上翻车。你满怀信心地把城市NPC、军队士兵、街上的行人做成一套逻辑结果一进游戏同屏超过一两百个CPU的骨骼更新直接红温GPU蒙皮带宽也扛不住帧数哗哗往下掉。你开始砍骨骼、减顶点、削动画采样率折腾一圈回来画面也降级了性能还是不够。所以当我第一次接触 Mesh Animation Baker 这类思路时第一反应是这个方向终于对了。它和传统 SkinnedMeshRenderer 完全是两条路核心做法是提前把骨骼动画“烘焙”成网格顶点数据运行时不跑任何骨骼计算直接让 GPU 按时间轴播放顶点动画。配合 Instanced Indirect 渲染一个 DrawCall 画几百上千个动态角色不再是做梦。这篇内容我会从原理、烘焙流程、Shader 实现、性能取舍到踩坑经验完整拆解这套 GPU Optimized Crowd Animation 的技术方案。适合正在做开放世界、模拟经营、战争题材、城市NPC 同屏优化的技术美术和图形程序员参考有些代码和配置细节可以直接抄走用。1. 为什么要把动画“烤”进网格里很多人第一次听到“顶点动画烘焙”时第一反应是这不等于是退回十年前的做法吗早期的引擎做不了骨骼动画才会用顶点动画。但这里有个关键区别——我们不是放弃骨骼动画而是把骨骼动画的“计算结果”在离线阶段全部算完让运行时不再重复计算。烘焙的意义在于把“计算”和“播放”解耦。传统 SkinnedMeshRenderer 的动画链路里每一帧都要做动画状态机更新 骨骼矩阵计算 蒙皮计算。这个链路在小规模角色下没问题但人一多就完蛋因为 CPU 需要处理成千上万根骨骼的矩阵运算GPU 端每个顶点还要做一次蒙皮权重累加。这套流程本身就是为“少量精细角色”设计的不是为“千人同屏”设计的。而 Mesh Animation Baker 的思路是在编辑器里把所有动画帧的顶点位置、法线、切线全部预先采样写成一张或多张动画纹理。运行时Shader 根据当前动画时间从纹理里读出顶点的新位置直接替换网格原始顶点。骨骼、权重、动画状态机全部不需要了。1.1 骨骼动画的成本到底高在哪先算一笔账你就知道传统管线有多亏。假设你有 200 个角色每个角色 30 根骨骼角色动画 30 FPS 更新。CPU 每帧要完成的骨骼矩阵计算量就是 200 × 30 6000 个矩阵变换。这个数字看着不多但你还要算上 AnimationClip 的采样、状态机切换、事件回调还有每个角色各自的 Transform 层级依赖。一旦角色数量上了千CPU 的负担根本不是线性增长而是拖慢整个游戏主循环。GPU 端的蒙皮也不轻松。传统的 SkinnedMeshRenderer 在每个顶点上都要执行权重矩阵混合虽然 GPU 并行处理很强但蒙皮计算消耗的是 ALU 和带宽。顶点越多、骨骼权重通道越多开销越大。更致命的是每个角色至少一个 DrawCall哪怕你用 GPU Instancing骨骼动画的实例化处理也比顶点动画复杂。在这种背景下“先算好、再播放”的方案就显得非常合理。因为动画本身是确定性的同一个 Clip 在相同时间点顶点位置永远一样。那为什么不在离线时把它烤出来运行时直接查表1.2 用空间换时间烘焙方案的核心逻辑烘焙的本质是典型的空间换时间。原本运行时需要 CPU 算骨骼矩阵、GPU 做蒙皮现在这些计算全部提前完成运行时要做的就是根据时间采样纹理拿到顶点数据渲染。这换来的优势很明显CPU 侧几乎零动画开销不再需要骨骼矩阵更新。GPU 侧不再做蒙皮混合顶点位置直接来自纹理采样。配合 Instanced Rendering同屏角色数量可以提升一个数量级。代价也很直接数据量变大。每个顶点、每个动画帧都要存一份位置和法线数据烘焙的 Clip 越长、模型顶点越多内存和显存压力越大。后面我会详细讲压缩和量化这是整个方案能不能落地的关键。用生活里的例子打个比方传统骨骼动画就像一家每天现做菜的餐厅客人多点几桌就忙不过来。Mesh Animation Baker 相当于把菜提前做成半成品客人来了直接加热上桌。半成品肯定占仓库位置但出餐速度是肉眼可见地快。1.3 GPU Instancing 与 Indirect让海量角色真正可行光有顶点动画还不够。如果烘焙完还是每个角色一个 DrawCall那省了 CPU 骨骼计算却死在 API 调用上。这里必须配合 GPU Instanced Indirect 渲染。传统 GPU Instancing 虽然能合并相同网格的多个实例但实例数量需要在 CPU 侧明确指定。而 Indirect 更进一层——通过一个 ComputeBuffer 当作“绘制命令缓冲区”GPU 自己决定画多少个实例、从哪个实例开始。对整个群体做剔除、排序、填充数据都可以在 ComputeShader 或 C# 侧完成最后只需要一次 Graphics.DrawMeshInstancedIndirect 调用。这一套组合拳下来2500 个角色同屏主线程的渲染调用只有一笔 DrawCallCPU 侧工作量基本是常数。这也是为什么很多大规模战争模拟、城市人口系统都在走这条技术路线。2. Mesh Animation Baker 的核心环节拆解很多人以为 Mesh Animation Baker 就是把动画顶点存到 Texture2D 里然后在 Shader 里读一下其实没那么简单。真正做下来核心环节至少有四个烘焙采样、数据编码、Shader 顶点流替换、动画播放管理。任何一个环节出问题效果都可能直接崩。2.1 动画采样怎么把动画“画”成数据第一步是在编辑器里对每个 AnimationClip 逐帧采样。Unity 有一个非常好用的接口 SkinnedMeshRenderer.BakeMesh它可以在指定时间点把蒙皮后的网格直接烘焙出来。你写个循环从 0 跑到 Clip 时长每隔固定时间采样一帧每一帧都能拿到一套完整的 Mesh.vertices、normals、tangents。采样频率的选择要谨慎。如果你原始动画是 60 FPS烘焙采样也按 60 FPS数据量直接翻倍。实践上大多数游戏动画 30 FPS 播放足够流畅尤其是人群里的小角色30 FPS 采样完全够用。如果角色有快速爆发动作比如挥剑、冲刺建议对这部分单独提高采样率而不是把整段 Clip 都提高。另一个容易忽略的问题是第一帧和最后一帧。如果你的 Clip 是循环动画比如“走路”烘焙时要保证最后一帧采样点和第一帧一致否则循环时会跳变。我的做法是判断 Clip 的 isLooping 属性如果是循环动画最后一帧直接采集第 0 帧的数据不额外多采一帧。2.2 数据编码与压缩显存控制的关键拿到顶点数据后直接存成 Float4 纹理是非常奢侈的。一个 5000 顶点的模型存一个 Float4 位置纹理光位置数据就是 5000 × 4 × 4 字节 80 KB再乘上动画帧数很快就爆显存。所以压缩是必须的。常用的压缩套路有几个位置数据量化到 16 位。把模型的局部坐标范围缩放到 0~1然后映射到 0~65535 的整数范围存储时用 RG16 或者 RGBA16 格式的纹理。Shader 里再反量化回真实坐标。位置精度一般能接受因为大多数角色模型尺寸不超过几米16 位精度足够。法线量化到 8 位。法线本身就是方向向量用八位把 xyz 压缩到 [-1, 1] 范围精度损失肉眼几乎不可见。时间维度增量编码。很多时候相邻帧之间顶点位置变化很小可以存当前帧相对于第一帧的偏移量数值范围缩小后量化误差更小压缩率更高。数据纹理的排布也有讲究。最简单的布局是纹理宽度等于顶点数纹理高度等于动画帧数。但顶点数经常不是 2 的幂直接这么排有点浪费。更常见的做法是把顶点 ID 映射到二维 tile 上然后加上时间轴形成一个小 Atlas。像素坐标的换算公式一定要写清楚uv (float2(vertexID % tileWidth, vertexID / tileWidth) 0.5) / tileSize float2(animTime, 0)。注意那个 0.5 偏移这是像素中心对齐的经典操作不加的话会有半像素偏移导致顶点抖动。2.3 Shader 侧顶点流替换从 Bones 到 Texture终于到了 Shader 部分。常规角色的 Shader 里顶点位置来自 Mesh 的 POSITION 语义法线来自 NORMAL。用了 Mesh Animation Baker 后这些都要“骗”一下你把 Mesh 本身当成一个空壳子它的顶点数量和布局必须和烘焙时一致但真正的位置和法线从动画纹理里读。核心思路是把顶点索引作为关键 ID。在顶点着色器里你可以通过 SV_VertexID 拿到当前顶点的索引但这个索引是 0 到顶点数-1 的连续值和你烘焙时的顶点顺序一一对应。然后用这个索引去计算纹理 UV采样位置、法线、切线最后覆盖输入结构体里的 position 和 normal。Shader 侧要特别注意的是动画纹理的采样必须用 tex2Dlod 或者显式 LOD因为顶点着色器里默认的纹理采样行为可能带 mipmap一旦采样到低 mip顶点位置会被“糊”没模型直接炸开。我习惯把动画纹理设成 Point 滤波、Clamp Wrap不需要任何 mipmap。另外光照计算依赖法线。如果你只烘焙了位置法线还是用原始网格的静态法线那么角色扭动时光照就是错的。所以烘焙阶段必须同时把法线、切线写入纹理Shader 里读出来后替换。切线同理尤其是用了法线贴图的材质切线不对法线贴图会完全错乱。2.4 动画播放管理与多 Clip 切换烘焙只是把静态数据准备出来播放逻辑还得自己实现。最简单的做法是 Shader 里传一个全局时间 _AnimTimeshader 用它去采样纹理。但人群动画的难点在于你不能让所有角色行动完全同步否则看起来会像克隆人军团非常假。每实例数据里最好加一个 _AnimOffset偏移量可以是负数也可以做随机小数。Shader 里实际采样时间 _AnimTime * _AnimSpeed _AnimOffset。这样每个角色在同一个时刻处于动画的不同相位人群自然就“活”了。多 Clip 支持要看你怎么组织纹理。最粗暴的方案是每个 Clip 一张纹理材质球一大堆切换时换 MaterialPropertyBlock更优雅的方案是用 Texture2DArray把不同 Clip 放到不同 layer实例数据里带一个 clipIndexShader 里做动态分支采样。过渡动画可以再加一个 crossfade 时间参数两层采样后做一次插值这个在大量同屏时成本也不高尤其适合远程角色。3. 从 0 到 1 实现一套 GPU 人群动画理论聊完直接上实操。我做了一个简化版 Demo一个 2000 顶点的低模角色三段动画待机、走路、跑步目标是把 500 个实例一次性画出来CPU 主线程开销控制在极小范围。3.1 烘焙工具遍历 Clip 并逐帧 BakeMesh编辑器下的烘焙脚本是整个流程最脏最累的活。核心逻辑长这样// 简化版SkinnedMeshRenderer 动画烘焙工具 [MenuItem(Tools/Bake Animation To Texture)] public static void BakeAllClips() { SkinnedMeshRenderer skin target.GetComponentSkinnedMeshRenderer(); ListAnimationClip clips GetClips(target); int vertexCount target.sharedMesh.vertexCount; float sampleRate 30f; foreach (var clip in clips) { int frameCount Mathf.CeilToInt(clip.length * sampleRate) 1; Vector3[] positions new Vector3[vertexCount * frameCount]; Vector3[] normals new Vector3[vertexCount * frameCount]; for (int f 0; f frameCount; f) { float time Mathf.Min(clip.length, f / sampleRate); Mesh bakedMesh new Mesh(); skin.BakeMesh(bakedMesh); // Unity 会根据当前动画状态采样 // 注意这里需要先设置采样时间 clip.SampleAnimation(target.gameObject, time); Array.Copy(bakedMesh.vertices, 0, positions, vertexCount * f, vertexCount); Array.Copy(bakedMesh.normals, 0, normals, vertexCount * f, vertexCount); } SaveToTexture(clip.name, positions, normals, vertexCount, frameCount); } }有几个关键点要注意。BakeMesh 之前必须先 SampleAnimation否则采样到的是当前 Playable 的状态不是你想要的帧。Clip.SampleAnimation 是编辑器下非常好用的 API它会临时把角色的姿势设到特定时间点。Mesh 的顶点顺序和 sharedMesh 必须一致。有些模型有 Optimization 选项会把顶点重排。烘焙时我建议关掉 Mesh 的 Read/Write 优化并且不要使用 CombineChildren否则后期 Shader 里用 SV_VertexID 索引会对不上。最后的 SaveToTexture 就是把你选好的编码方式落地。我推荐用 Texture2D 的 RGBAHalf 或 RG16 格式按 Atlas 方式排布并关闭 mipmap、改成 Clamp。3.2 渲染端DrawMeshInstancedIndirect 与 ArgsBuffer烘焙完纹理接下来就是运行时渲染。我用 Graphics.DrawMeshInstancedIndirect 来画所有角色但这个 API 需要你提供一个参数 Buffer告诉 GPU 画多少个实例、网格有几条索引。// 初始化间接绘制参数 ComputeBuffer argsBuffer new ComputeBuffer(4, sizeof(uint), ComputeBufferType.IndirectArguments); uint[] args new uint[4]; Mesh mesh bakedSharedMesh; args[0] mesh.GetIndexCount(0); // 索引数量 args[1] (uint)instanceCount; // 实例数量 args[2] mesh.GetIndexStart(0); args[3] 0; argsBuffer.SetData(args);实例数据我用一个单独的 ComputeBuffer 存每个实例包含 position、scale、randomOffset、clipIndex 等。这里有个细节DrawMeshInstancedIndirect 默认不支持每实例数据直接绑定到 MaterialPropertyBlock所以要显式把实例 Buffer 绑定到 Shader 里的 StructuredBuffer。instanceBuffer new ComputeBuffer(instanceCount, instanceSize); instanceBuffer.SetData(instanceData); material.SetBuffer(_InstanceBuffer, instanceBuffer); // 每帧绘制不需要额外 SetPass 调用 Graphics.DrawMeshInstancedIndirect(bakedMesh, 0, material, argsBuffer);这里要注意的是bakedMesh 本身只是“壳”它的 Renderer 不存在也没关系。我们只用它的顶点数量、索引、UV 这些静态信息。经过烘焙后真正的顶点坐标完全是 Shader 说了算。3.3 Shader 实现从动画纹理到最终顶点Shader 部分是最容易翻车的地方我直接给一个核心片段StructuredBufferInstanceData _InstanceBuffer; sampler2D _AnimTex; float4 _AnimTex_TexelSize; int _TileCount; struct InstanceData { float4 position; float4 scaleOffset; // xyz: scale, w: random offset }; float4 GetAnimPos(uint vertexID, float time) { int tileWidth _TileCount; float2 tileUV float2(vertexID % tileWidth, vertexID / tileWidth); float2 uv (tileUV 0.5) * _AnimTex_TexelSize.xy; uv.x _AnimTex_TexelSize.x * time; float4 pos tex2Dlod(_AnimTex, float4(uv, 0, 0)); return pos; } v2g vert (uint vertexID : SV_VertexID, uint instanceID : SV_InstanceID) { InstanceData data _InstanceBuffer[instanceID]; float animTime _Time.y * _AnimSpeed data.scaleOffset.w; float4 pos GetAnimPos(vertexID, animTime); float3 worldPos pos.xyz * data.scaleOffset.xyz data.position.xyz; v2g o; o.worldPos worldPos; o.clipPos UnityWorldToClipPos(worldPos); return o; }注意这段代码里的顶点索引来自 SV_VertexID而不是语义化的 POSITION。使用 DrawMeshInstancedIndirect 时SV_InstanceID 会自动提供实例索引正好用来访问结构化缓冲区。动画时间我用 _Time.y这是 Unity Shader 内置的全剧时间。但要注意如果所有实例用一模一样的时间看起来就会完全同步所以每个实例的随机偏移是必须的。另外如果角色在场景里移动position 字段应该是世界空间的位置我们的动画坐标烘焙时用的是模型局部空间所以要先缩放再平移顺序别搞反。3.4 百人同屏的性能调优实践我的测试场景里放了 500 个角色每个 2000 顶点三段动画烘焙到一张 2048×2048 的 RGBAHalf 纹理里。实测下来DrawCall 是 1Main Thread 的耗时几乎可以忽略不计真正的开销集中在 GPU 带宽上——因为每个顶点都要从纹理里读位置和法线。这种情况下压缩的意义就体现出来了。如果用 Float4 存位置带宽翻倍改用 RGBAHalf 后视觉差异不大但带宽直接减半。我还对远处的实例做了 LOD超过一定距离直接切换成低模进一步降低带宽压力。还有一个非常容易忽视的优化点Culling。DrawMeshInstancedIndirect 不会自动做视锥剔除你必须在 CPU 或 ComputeShader 里做。我的做法是每帧把相机视锥矩阵传到查完 ComputeShader 里让每个实例判断自己在不在视锥内不在就把它的缩放置为 0这样 GPU 虽然还是处理全部实例但大部分顶点都挤到一点上实际渲染开销小很多。4. 踩坑实录与排查技巧如果只看原理你可能会觉得这套方案很美好但真正落地时坑密集度非常高。我把自己踩过的、帮别人排查过的问题整理在这里每条都能省你半天时间。4.1 显存占用计算别让小成本变成大负担烘焙前一定要算一笔账。假设你有一个 8000 顶点的角色素材比较精细动画 Clip 总时长 10 秒采样率 30 FPS位置用 RGBAHalf8 字节/顶点法线用 RGBAHalf8 字节/顶点。那么单个 Clip 的纹理大小大约是8000 × 300 帧 × 16 字节 38.4 MB。如果三段动画就是 100 多 MB直接吃掉巨大显存。所以实际操作里我强烈建议把模型减面到 2000~3000 顶点人群角色根本不需要太精细。采样率 30 FPS 足够除非有慢动作镜头。位置用 RGBAHalf 或量化到 RG16法线用 RGBAHalf别全用 Float。共享纹理 Atlas 时把多个 Clip 拼到同一张纹理里而不是一张 Clip 一张纹理。对循环动画做特殊优化只存一整个循环周期不要为不同循环位移多占空间。这个账不算清楚做出来的方案可能在性能和内存之间两头挨打。4.2 “顶点乱飞”与纹理寻址 Bug我见过太多人做顶点动画纹理第一帧是好的第二帧开始模型像爆炸一样乱飞。这种问题九成出在 UV 计算上。你是不是只在 U 方向加了时间如果你的纹理布局是时间在 U 方向那没问题但很多人把时间放到了 V 方向又用 UV 的 y 作为顶点索引就会乱。还有像素中心问题。GPU 纹理采样时UV 坐标落在像素边缘和像素中心的差别非常大。标准做法是uv (pixelCoord 0.5) / textureSize这个 0.5 必须加。不加的话某些顶点会采到相邻像素表现就是个别顶点周期性跳动。Wrap 模式也必须设成 Clamp。如果设成 Repeat当你采样的时间超过纹理长度时UV 会回绕动画会瞬间跳回开头看起来像瞬移。想循环播放应该靠 Shader 里的取模运算控制时间而不是靠纹理寻址。4.3 阴影、包围盒与剔除问题烘焙顶点动画后Unity 的剔除和阴影系统会有一个通病它不知道你的网格顶点在动画时会跑到哪里。如果你直接用一个静态 Mesh 的 Bounds那么动画幅度大的角色比如跳跃、挥舞武器有可能被视锥剔除错误的裁剪掉或者阴影完全消失。解决办法是在烘焙时根据所有动画帧的顶点位置计算一个“联合包围盒”在运行时覆盖到 Mesh 的 renderer.bounds 上。更稳妥的做法是在 Shader 的顶点阶段输出一个足够大的包围球位置让物体永远不会被错误剔除。但注意这个方法会降低遮挡剔除效率所以只对动画幅度大的角色使用。阴影方面如果角色在烘焙动画中移动位置阴影会根据最终变换矩阵生成一般没问题。但如果烘焙位置是局部空间而实例会移动必须确保传给阴影 Pass 的矩阵和阴影 Pass 里的实例变换逻辑一致否则阴影会留在原地。4.4 动态骨骼、挂点和换装烘焙方案的边界Mesh Animation Baker 不是银弹。它是为“绝对大量、绝对重复、不需要交互的群体”设计的。如果你的角色需要动态 IK、头部跟踪、挂点武器、表情 BlendShape烘焙方案就很别扭。挂点问题尤其典型。角色手上拿武器武器位置是跟着手骨走的。烘焙后没有骨头了武器怎么挂通用的做法是把武器位置也烘焙成动画数据甚至把武器网格合并进角色网格一起烘焙。但如果武器可以随时切换这就很麻烦。所以我自己项目里的策略是远处和中间层的群体 NPC 用全套烘焙动画靠近摄像头、有交互、有特殊挂点的角色保留 SkinnedMeshRenderer。这两个角色可以用同一套模型只是渲染路径不同。为了纹理资源不翻倍远处和近处的 LOD 共享烘焙纹理近距离切回骨骼后仍然用原来的动画 Clip烘焙精度和骨骼动画原始效果保持一致。4.5 常见问题速查表现象可能原因解决方案动画顶点爆炸乱飞纹理 UV 计算错误或采样到 mipmap加 0.5 像素偏移设置 Point 滤波、Clamp、无 mipmap动画循环时瞬移Wrap Mode 设成了 Repeat在 Shader 里对时间取模纹理保持 Clamp角色全部步调一致所有实例共享同一时间每实例加随机偏移用 _Time offset阴影丢失或角色被裁剪静态 Bounds 没覆盖动画范围烘焙时计算联合 AABB或者 Shader 输出足够大的包围球显存占用暴涨高精度 Float 纹理 过多顶点 过高采样率RGBAHalf 量化减面30 FPS多 Clip 共享 Atlas某个实例消失但数据还在间接绘制 rulling 或者缩放置零检查 Buffer 数据绑定、视锥剔除结果用调试模式先关闭剔除法线错乱导致光照错误只替换了位置没替换法线烘焙和采样时把 normal、tangent 一并处理这里还想补充一个真正容易忽略的坑如果用 Animation Clip 里的 Root Motion也就是角色位移不要把它烘焙进顶点动画。因为你在实例化时已经给每个角色设置了独立的世界坐标如果再把 root motion 一次性合入顶点偏移角色会走“双倍距离”。正确做法是把 root motion 的位移单独剥离运行时作为实例位置增量处理。具体可以从 AnimationClip 的 rootCurves 里读出位移检查是否位移曲线有值烘焙时只保存网格本身的局部动画不解算 root motion。5. 后续还能怎么扩展最后再说一点我自己的心得。Mesh Animation Baker 这套能力落地后你还可以把它扩展成更通用的“人群动画管线”比如把粒子系统的人群、动物群、甚至远景的交通工具都做成同一套数据驱动。只要动画是确定性的都能先烘焙再播放。这套思路和程序化生成结合也能做出规模感很强的城市背景层。我个人建议从一个小项目开始试验先解决 200 个 NPC再上量到 2000。等技术手感有了再考虑大规模合批和更激进的压缩方案。如果你在项目中踩到新的坑欢迎回来一起交流。