独立游戏优化:GPU骨骼蒙皮与VAT顶点动画实现解析
上一篇聊完了我的独立游戏角色从建模、贴图到绑定的过程这一篇把角色“动起来”这个环节单独拿出来聊。很多独立游戏开发者做完绑定后第一反应是拖进 Unity挂上 Animator让 SkinnedMeshRenderer 自动处理蒙皮。小场景、几个角色的时候这套流程确实没有问题但只要你做的不是纯剧情向游戏而是需要同屏刷出几十个怪的玩法CPU 蒙皮很快会成为瓶颈。这里要给出一个明确判断GPU 动画不是替代传统骨骼动画的银弹它是独立游戏在“大量角色同屏”这个具体问题上性价比最高的优化手段之一。它的核心思路是把顶点蒙皮的计算从 CPU 搬到 GPU让蒙皮在顶点着色器里完成从而绕过 SkinnedMeshRenderer 的 CPU 开销也让角色网格更容易进入合批流程。这篇会从原理讲起给出两条可落地的实现路线实时骨骼矩阵 GPU 蒙皮以及更彻底的顶点动画纹理方案也就是 VAT。两条路线都配上完整代码最后再讲常见的坑和工程化建议。如果你正在做割草类、尸潮类、ARPG 刷怪类或者任何需要在屏幕上同时存在大量角色的独立游戏这一篇应该能帮你少走不少弯路。1. 为什么独立游戏角色需要 GPU 动画传统骨骼动画的流程简单说是这样的Animator 根据当前动画状态采样 AnimationClip得到骨骼的旋转、位移、缩放再把这些数据更新到骨骼层级上。SkinnedMeshRenderer 拿到每个骨骼的世界矩阵后逐顶点做矩阵变换把模型空间顶点变成骨骼空间再变回世界空间最后交给 GPU 渲染。这套流程最重的一段发生在 CPU 上就是逐顶点蒙皮。一个角色如果是 1 万顶点、4 根骨骼影响每个顶点要做 4 次矩阵乘向量CPU 每帧要处理几万次浮点运算。场景里放 20 个这样的角色就是几十万次运算。独立游戏又经常在移动设备上跑移动端 CPU 性能比 PC 弱不少角色一多耗电和发热都跟着上来。还要考虑合批问题。SkinnedMeshRenderer 的网格顶点是动态蒙皮后的结果每一帧都在变这导致它很难直接参与 Mesh 合批或者 GPU Instancing。就算多个角色用的是同一个模型同一套动画SkinnedMeshRenderer 也比普通 MeshRenderer 难优化。所以从 CPU 开销、合批潜力两个角度看GPU 动画都值得独立游戏开发者关注。当然GPU 动画也不是零成本。AnimationClip 的采样、骨骼矩阵的更新这些逻辑如果都要做CPU 上还是会有开销。更推荐的做法是把动画采样结果烘焙成贴图或矩阵数组让 Shader 只在渲染时读取。后面讲到的 VAT 思路就是把这个逻辑推到更彻底的程度。2. 先理清概念骨骼、蒙皮、权重和 GPU 蒙皮在写代码之前先把几个术语讲清楚。它们之间有关系但很多初学者会混在一起。骨骼在引擎里就是一个有层级关系的 Transform 节点。Root 骨骼是起点往下是脊椎、腿、手臂一层层挂接。动画数据本质上就是记录每个骨骼在某个时间点的局部旋转和位移。注意这里只控制骨骼不直接控制顶点。蒙皮是把顶点和骨骼关联起来的过程。每个顶点不一定只受一根骨骼影响它可以同时受多根骨骼影响每根骨骼有一个权重。Unity 的 SkinnedMeshRenderer 默认支持每顶点最多 4 根骨骼这也对应顶点数据里通常有四组骨骼索引和四组权重。权重之和要等于 10.7 表示这根骨骼的影响占 70%另 0.3 是另一根骨骼。如果一个顶点的权重加起来大于 1蒙皮结果就会异常角色某个地方可能像被拉伸一样扯出去。蒙皮计算的本质是下面这个公式蒙皮后顶点位置 Σ(第i根骨骼的权重 × 第i根骨骼的当前矩阵 × 第i根骨骼的绑定姿势逆矩阵 × 模型空间顶点)通俗解释就是先把顶点从模型空间的绑定姿势变换到骨骼自己的局部空间再用骨骼当前的动画矩阵把它变换到骨骼所在的世界空间最后按权重做加权平均。绑定姿势逆矩阵是固定数据在模型导入时就能拿到。CPU 蒙皮和 GPU 蒙皮的区别只是这段计算发生在哪里。SkinnedMeshRenderer 在 CPU 上算算完把结果顶点提交给 GPU 渲染。GPU 蒙皮则是在顶点着色器里做同样的矩阵运算GPU 天然适合这种大量并行的小计算而且省去了把蒙皮结果从 CPU 拷贝到 GPU 的过程。对比维度CPU 蒙皮GPU 蒙皮计算位置CPU 主线程/工作线程顶点着色器顶点数据每帧重新计算Shader 内实时计算合批支持较弱更容易配合 Instancing适用规模同屏角色少同屏角色多灵活性支持运行时动态修改骨骼需要提前准备矩阵或贴图物理交互自然支持骨骼变换需要额外处理碰撞体同步这里要强调一个容易误解的点GPU 动画不是把动画状态机搬上 GPU。Animator 的采样、状态切换、动画融合这些依然可以在 CPU 上做只是最后不再走 SkinnedMeshRenderer 的蒙皮循环而是把结果矩阵传到 Shader。更激进的 VAT 方案连矩阵都可以不用直接把顶点位置烘焙到贴图里。3. GPU 动画的两条主流路线实时蒙皮与 VAT实现 GPU 动画独立游戏里最常见的有两条路线。3.1 路线 A实时骨骼矩阵 GPU 蒙皮这条路线仍然使用骨骼动画数据只是把 SkinnedMeshRenderer 换成普通 MeshRenderer然后在自定义 Shader 的顶点着色器里读取骨骼权重和矩阵自己算蒙皮。做法是动画系统先把骨架摆到当前帧姿势然后脚本把每根骨骼的当前世界矩阵、乘以绑定姿势逆矩阵得到一个蒙皮矩阵数组通过 MaterialPropertyBlock 传给 Shader。Shader 根据顶点的骨骼索引和权重在循环里做加权矩阵变换。这条路线的好处是保留了骨骼的灵活性。角色可以换武器、换头部Unity 的骨骼层级改动依然有效。坏处是每一帧仍然要更新骨骼矩阵数组CPU 有开销而且 Shader 里支持的最大骨骼数量受常量缓冲区限制。适合用在玩家角色、重要的 NPC、需要换装的角色上。这些角色数量不会特别多但动画质量要求高骨骼驱动是合理的选择。3.2 路线 BVAT 顶点动画纹理VAT全称 Vertex Animation Texture顶点动画纹理。思路更直接不再依赖骨骼直接把动画每一帧的顶点位置烘焙到一张纹理里。渲染时顶点着色器根据当前时间从纹理里采样出当前帧的顶点位置然后做正常的模型变换和投影。这条路线的好处是 GPU 开销极低而且渲染这些角色时完全不需要 SkinnedMeshRenderer也不需要在 CPU 上更新骨骼矩阵。劣势是需要离线烘焙动画是固定的换装、动态骨骼之类的需求基本没法做。另外顶点数据要写进纹理纹理大小和顶点数、动画帧数成正比。适合用在大量重复的小怪、尸体、草丛、飘带、批量特效等场景。你想要远处站着一排小怪动作种类不多但数量很多VAT 是最划算的。两条路线的选型独立游戏可以参考一个简单标准需要骨骼动态变化的用 A只需要播放固定动画且数量特别多的用 B。下面分别给出完整的实现代码。4. 环境准备与角色资源整理这篇以 Unity 为例渲染管线用内置管线和 URP 都适用代码的 CGPROGRAM 部分做少量调整即可。角色模型从哪里来都可以Blender、Maya、3ds Max 都行关键是导出时要注意下面几点。第一骨骼命名要统一。GPU 蒙皮脚本是靠骨骼数组顺序和 SkinnedMeshRenderer.bones 对应的所以不要在导出后再手动改名。如果项目里有多个角色需要共用同一套 GPU 动画 Shader建议给关键骨骼带上Root、Spine、Head这类统一前缀方便批量处理。第二骨骼权重数量。Unity 导入设置里有一个 Skin Weights 选项可以限制每顶点最多影响骨骼数。建议设为 4 Bones这对应 Shader 里一次完整的 4 组循环。如果某些模型用到 8 根骨骼Shader 循环就要扩到 8性能和代码复杂度都上升。第三动画采样帧率。GPU 动画的数据量跟帧率强相关。影视动画可能用 30 帧每秒游戏里 15 到 24 帧每秒通常就够。烘焙 VAT 时优先用 15 帧能省一半纹理内存动作的流畅度损失在跑动、挥砍这类节奏快的动作上并不明显。第四把角色资源做成 Prefab。GPU 动画可能涉及替换 SkinnedMeshRenderer 为 MeshRenderer这个操作建议在 Prefab 里做而不是直接改场景里的角色。保留一份原始 SkinnedMeshRenderer 版本方便出问题时回滚。最后关于角色位置。路线 A 的演示脚本里我使用了 rootInv 来抵消角色根节点自身的位移和旋转所以角色放在场景任意位置都能工作。路线 B 的烘焙脚本为了简洁要求角色根节点放在世界原点正式项目里你再扩展成支持任意位置即可。先跑通最小示例再考虑复杂变换。5. 路线 A 实现实时骨骼矩阵 GPU 蒙皮先看最终效果的目标角色使用普通 MeshRenderer 渲染每帧由脚本把骨骼蒙皮矩阵传给 Shader顶点在 GPU 上完成蒙皮。这意味着场景里的多个角色可以用同一个 Shader还能配合 GPU Instancing 实现大批量渲染。创建脚本GPUSkinnedMeshBaker.cs挂在角色根节点上。脚本启动时找到同物体或子物体上的 SkinnedMeshRenderer读取骨骼数组和绑定姿势矩阵然后在 LateUpdate 里用 AnimationClip.SampleAnimation 采样动画帧把骨骼矩阵和绑定姿势逆矩阵串起来。// 文件路径Assets/Scripts/GPUSkinnedMeshBaker.cs using UnityEngine; public class GPUSkinnedMeshBaker : MonoBehaviour { [Header(动画资源)] public AnimationClip clip; public float playSpeed 1.0f; private MeshRenderer meshRenderer; private MaterialPropertyBlock propertyBlock; private Transform[] bones; private Matrix4x4[] boneMatrices; private Matrix4x4[] bindPoses; void Start() { meshRenderer GetComponentMeshRenderer(); propertyBlock new MaterialPropertyBlock(); SkinnedMeshRenderer skin GetComponentInChildrenSkinnedMeshRenderer(); if (skin null) { Debug.LogError(请把 SkinnedMeshRenderer 挂在角色根节点或子物体上。); return; } bones skin.bones; bindPoses skin.sharedMesh.bindposes; boneMatrices new Matrix4x4[bones.Length]; // 切换到普通 MeshRenderer 渲染 // 这里默认已经在 Inspector 中把 SkinnedMeshRenderer 替换成了 MeshRenderer // 并将 sharedMesh 设置为该角色的绑定姿势网格 if (clip ! null) { clip.SampleAnimation(gameObject, 0f); } } void LateUpdate() { if (clip ! null) { float time Mathf.Repeat(Time.time * playSpeed, clip.length); clip.SampleAnimation(gameObject, time); } Matrix4x4 rootInv transform.worldToLocalMatrix; for (int i 0; i bones.Length; i) { // 蒙皮矩阵 角色根节点逆矩阵 * 骨骼当前世界矩阵 * 绑定姿势逆矩阵 boneMatrices[i] rootInv * bones[i].localToWorldMatrix * bindPoses[i]; } propertyBlock.SetMatrixArray(_BoneMatrices, boneMatrices); meshRenderer.SetPropertyBlock(propertyBlock); } }这段脚本的关键逻辑是蒙皮矩阵的组装。bones[i].localToWorldMatrix是骨骼在当前帧的世界矩阵bindPoses[i]是模型导入时记录下的绑定姿势逆矩阵。两者相乘等于把顶点从模型空间带到骨骼当前所在的空间。最后再乘rootInv是为了让最终结果回到角色根节点的本地空间这样 Shader 里还能继续沿用 UnityObjectToClipPos 处理模型变换和投影。接下来写 GPU 蒙皮 Shader。核心是在顶点着色器里读取每个顶点的骨骼索引和骨骼权重然后用蒙皮矩阵数组做加权变换。// 文件路径Assets/Shaders/GPUSkin.shader Shader Custom/GPUSkin { Properties { _MainTex (Albedo (RGB), 2D) white {} } SubShader { Tags { RenderTypeOpaque } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 3.0 #include UnityCG.cginc sampler2D _MainTex; // 蒙皮矩阵数组数量根据项目实际骨骼数调整 float4x4 _BoneMatrices[32]; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; // 骨骼索引和权重来自Mesh的皮肤通道 float4 boneIndices : BLENDINDICES; float4 boneWeights : BLENDWEIGHT; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; float3 SkinVertex(float3 positionOS, float4 boneIndices, float4 boneWeights) { float3 position 0; for (int i 0; i 4; i) { int boneIndex (int)boneIndices[i]; float boneWeight boneWeights[i]; float4x4 boneMatrix _BoneMatrices[boneIndex]; position boneWeight * mul(boneMatrix, float4(positionOS, 1.0)).xyz; } return position; } v2f vert (appdata v) { v2f o; float3 skinnedPos SkinVertex(v.vertex.xyz, v.boneIndices, v.boneWeights); o.pos UnityObjectToClipPos(skinnedPos); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv); return col; } ENDCG } } }这里的BLENDINDICES和BLENDWEIGHT是从网格顶点数据里读取骨骼信息的语义。如果你发现角色顶点完全不动或者整体错乱大概率是当前 Mesh 的骨骼通道没有正确传给 Shader。这时可以把骨骼索引和权重自己复制到 UV2、UV3 通道再在 Shader 里对应读取。使用步骤也很简单。先把 SkinnedMeshRenderer 替换成 MeshRenderer 和 MeshFilter把 SkinnedMeshRenderer.sharedMesh 赋给 MeshFilter。挂上 GPUSkinnedMeshBaker拖入动画 Clip把材质替换为 Custom/GPUSkin。运行后角色应该正常播放动画。6. 路线 B 实现VAT 顶点动画纹理如果场景里需要同屏几百个小怪路线 A 的矩阵数组更新还是有点重。此时更推荐用 VAT把动画顶点预烘焙到贴图里运行时 Shader 只需要采样贴图CPU 侧几乎不做事。这里的关键流程是用脚本在启动时逐帧采样动画通过 SkinnedMeshRenderer.BakeMesh 得到当前帧的顶点位置把每个顶点的坐标写入纹理像素。纹理横向是帧数纵向是顶点 ID。运行时顶点着色器根据当前的动画时间按顶点 ID 去采样对应帧的位置。看一下 C# 侧的实现。为了代码简洁这个烘焙脚本要求角色根节点放在世界原点没有旋转和缩放。否则 BakeMesh 出来的顶点坐标和模型本地坐标之间会多一层变换需要在写入贴图前手动转换。// 文件路径Assets/Scripts/GPUVATBaker.cs using UnityEngine; public class GPUVATBaker : MonoBehaviour { [Header(动画资源)] public AnimationClip clip; public float fps 15f; public float timeScale 1f; private SkinnedMeshRenderer skin; private MeshRenderer meshRenderer; private MeshFilter meshFilter; private Mesh bakedMesh; void Start() { skin GetComponentSkinnedMeshRenderer(); if (skin null) { Debug.LogError(需要 SkinnedMeshRenderer 来烘焙动画。); return; } int vertexCount skin.sharedMesh.vertexCount; int frameCount Mathf.CeilToInt(clip.length * fps) 1; // 使用半浮点纹理保存顶点位置支持负数坐标 Texture2D animTex new Texture2D(frameCount, vertexCount, TextureFormat.RGBAHalf, false); animTex.wrapMode TextureWrapMode.Clamp; animTex.filterMode FilterMode.Point; bakedMesh new Mesh(); for (int frame 0; frame frameCount; frame) { float t Mathf.Clamp(frame / fps, 0f, clip.length); clip.SampleAnimation(gameObject, t); skin.BakeMesh(bakedMesh); Vector3[] vertices bakedMesh.vertices; for (int i 0; i vertexCount; i) { Vector3 v vertices[i]; Color data new Color(v.x, v.y, v.z, 1f); animTex.SetPixel(frame, i, data); } } animTex.Apply(); // 替换渲染组件 DestroyImmediate(skin); meshRenderer gameObject.AddComponentMeshRenderer(); meshFilter gameObject.AddComponentMeshFilter(); meshFilter.sharedMesh bakedMesh; Material mat new Material(Shader.Find(Custom/VATSkin)); mat.SetTexture(_AnimTex, animTex); mat.SetFloat(_FrameCount, frameCount); mat.SetFloat(_VertexCount, vertexCount); mat.SetFloat(_TimeScale, timeScale); meshRenderer.sharedMaterial mat; } }烘焙脚本的核心就是两个循环外层按帧数采样动画内层把每个顶点位置写入纹理。纹理格式用 RGBAHalf因为顶点坐标有负数如果使用 RGBA32 会丢失符号信息。点过滤很重要因为我们要读取整数帧不希望纹理线性插值在帧之间自动混合。然后是 VAT 的 Shader。它不再需要骨骼矩阵只需要一个动画时间在顶点着色器里通过SV_VertexID拿到当前顶点的 ID然后和动画帧一起采样贴图。// 文件路径Assets/Shaders/VATSkin.shader Shader Custom/VATSkin { Properties { _MainTex (Albedo (RGB), 2D) white {} _AnimTex (Animation Texture, 2D) black {} _FrameCount (Frame Count, Float) 1 _VertexCount (Vertex Count, Float) 1 _TimeScale (Time Scale, Float) 1 } SubShader { Tags { RenderTypeOpaque } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 4.0 #include UnityCG.cginc sampler2D _MainTex; sampler2D _AnimTex; float _FrameCount; float _VertexCount; float _TimeScale; struct appdata { float2 uv : TEXCOORD0; uint vertexID : SV_VertexID; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; v2f vert (appdata v) { v2f o; float frame floor(frac(_Time.y * _TimeScale) * (_FrameCount - 1)); float u (frame 0.5) / _FrameCount; float v (v.vertexID 0.5) / _VertexCount; float3 animPos tex2Dlod(_AnimTex, float4(u, v, 0, 0)).xyz;

相关新闻

最新新闻

日新闻

周新闻

月新闻