写独立游戏角色动画,越到后期越会发现,性能往往不是死在特效,而是死在一堆角色同时播放骨骼动画这件事上。CPU 要算蒙皮矩阵、逐顶点变换,还要维护动画状态机;Draw Call 一多,帧率立刻掉得很难看。做单机 Demo 时感觉不明显,一旦场景里出现几十上百个角色,动画系统就成了最大的瓶颈。
这篇文章是“我的独立游戏角色是怎么制作的”系列的下篇。上篇我们聊过角色建模、UV 展开和材质规范,这一篇专门拆解角色动画的性能优化方案:什么是 GPU 动画、常见的 GPU 动画实现思路有哪些、在 Unity 里怎么把角色骨骼动画烘焙成顶点动画纹理,以及接入 Shader 之后的完整效果验证。
文章适合正在做独立游戏、Demo 里角色数量多到帧率吃紧的开发者。学完之后,你至少能掌握一套可落地的 GPU 动画接入流程,以及排查顶点抖动、动画不同步、阴影错乱等高频问题的思路。
1. 角色动画的性能瓶颈在哪里
1.1 骨骼动画的计算链路
先看一下传统骨骼动画在 GPU 上的工作流程。角色模型里有骨骼层级,顶点绑定到若干根骨骼,每根骨骼带权重。每一帧播放动画时,CPU 要做的事情包括:
- 根据 AnimationClip 的曲线采样每个骨骼的局部旋转、位移和缩放。
- 从骨骼层级关系出发,逐级做矩阵乘法,算出每根骨骼的模型空间矩阵。
- 把骨骼矩阵和绑定姿势(Bind Pose)的逆矩阵相乘,得到蒙皮矩阵。
- 每个顶点根据骨骼权重,把多个蒙皮矩阵的结果做加权混合,得到最终的顶点位置。
也就是说,一个网格里有 5000 个顶点,每个顶点最多混合 4 根骨骼,那么光是顶点变换就要做 5000 次矩阵乘加。角色越多,CPU 的负担越重。更麻烦的是,不同动画之间还要做混合、过渡和状态切换,这些逻辑同样吃 CPU。
这就是很多独立游戏在“多角色同屏”场景下卡顿的根本原因:不是渲染慢,而是角色动画的骨骼计算全部压在 CPU 上。
1.2 为什么选择 GPU 动画
GPU 动画的本质是“把动画计算从 CPU 搬到 GPU”。
GPU 是高度并行的处理器,几千个顶点同时做矩阵运算对它来说非常轻松。我们把原本在 CPU 上执行的蒙皮计算,改成两种方式之一:
- 把骨骼矩阵传到 GPU,用 Shader 或 Compute Shader 做蒙皮。
- 提前把每一帧的顶点位置烘焙到纹理里,渲染时直接采样纹理得到顶点坐标。
第一种方案你仍然需要传骨骼矩阵,CPU 要负责采样动画曲线并上传矩阵,但逐顶点的计算量被 GPU 分担了。第二种方案更彻底,CPU 只需要设置一个时间参数,完全不需要管骨骼,因为“动画结果”已经预处理好了。
对于大量同类角色、大量简单动画、希望减少 CPU 动画开销的项目,GPU 动画是性价比很高的优化手段。
1.3 GPU 动画的典型应用场景
不是所有角色动画都适合 GPU 动画。它的优势在下面几类场景里最明显:
- 割草游戏:同屏几十上百个小怪,怪物动画比较简单,走、跑、攻击、死亡。
- 城市人群:街头大量 NPC,动画循环短,重复度高。
- 放置类和资源类:建筑、树木、旗帜、水体波纹等环境动画。
- 移动端项目:CPU 本身紧张,希望把动画开销完全转移到 GPU。
- 群组模拟:鸟群、鱼群、蚂蚁群,个体数量大但单个角色面数低。
如果你的角色数量不多、动画状态复杂且需要频繁混合,传统的骨骼动画反而更灵活,不要盲目替换成 GPU 动画。
2. 角色制作与动画导出的完整流程
独立游戏角色的制作流程,无论最后使用哪种动画方案,前期资产准备工作都差不多。先把基础打好,后面接 GPU 动画才不会返工。
2.1 模型与 UV 规范
建模阶段重点关注三点:
- 三角面数要克制。独立游戏角色,面数建议控制在 3000 到 8000 三角面之间,具体看平台。面数越低,后期烘焙顶点纹理时的数据量越小。
- UV 展开要合理。虽然 GPU 动画的顶点纹理不是常规贴图,但 UV 第一套通道往往会被复用,所以尽量让 UV 铺满 0 到 1 的区间,没有重叠。
- 拓扑尽量均匀。均匀的顶点密度会让动画变形过渡更自然,烘焙出来的顶点动画纹理也更稳定。
2.2 绑定与蒙皮规范
骨骼数量和权重直接决定动画质量,也影响后续烘焙的平滑程度。
建议:
- 每个顶点最多 4 根骨骼权重,移动端可以压到 2 根。
- 骨骼层级不要过深,独立游戏角色 15 到 30 根骨骼足够。
- 权重刷完之后,逐个动画检查关节处的变形,避免穿模和异常拉伸。
- 角色最好摆一个 T-Pose 或 A-Pose,方便后期动画统一。
如果模型已经刷过权重,制作动画时也要保持骨骼命名规范,不然换引擎或换烘焙工具时很容易丢绑定关系。
2.3 动画制作与采样设置
动画文件的参数会影响烘焙数据量:
- 动画帧率建议用 15 到 30 FPS。30 FPS 已经能覆盖大部分低模角色的流畅度,再高对视觉提升有限,数据量却成倍增加。
- 动画片段要循环,起点和终点的骨骼姿势保持一致,循环播放时才不会跳帧。
- 导出时把动画压缩成关键帧插值形式,烘焙工具会自己读取关键帧,不需要每个帧都手 K。
举个例子,一个行走动画 1 秒,30 FPS,就是一个 30 帧的循环动画。如果用 60 FPS 烘焙,纹理高度会翻倍,但画面感知差异很小。
3. GPU 动画方案对比:顶点动画纹理 vs GPU 蒙皮
实现 GPU 动画,主流的两种方案各有优劣。为了帮助你理解,这里做一个比较完整的对比。
3.1 顶点动画纹理
顶点动画纹理(Vertex Animation Texture,简称 VAT)的核心思想:在制作阶段把每一帧每个顶点的位置烘焙到一张纹理中。纹理的 X 方向是顶点索引,Y 方向是时间帧。渲染时,顶点着色器根据顶点 ID 和当前动画时间,从纹理里采样出对应的位置,然后直接当作顶点坐标输出。
这种方法的好处是:
- CPU 完全不需要知道骨骼状态。
- 一个 Draw Call 就能渲染大量角色,配合 GPU Instancing 效果更好。
- 动画结果在制作阶段就能预览,不会出现运行时骨骼计算差异。
缺点是:
- 纹理数据量大,角色顶点多、动画长的时候,纹理内存开销明显。
- 动画一旦烘焙,就无法在运行时做动态骨骼混合和动画过渡。
- 阴影、深度、遮挡剔除都需要额外处理。
3.2 GPU 蒙皮
GPU 蒙皮是另一种思路:仍然保留骨骼动画,但把蒙皮矩阵的计算移到 GPU 上。
具体做法是:CPU 仍然采样动画曲线,把每根骨骼的最终矩阵上传到材质或者 Compute Shader 的缓冲区里,然后在顶点着色器里做“逐顶点蒙皮矩阵混合”。这样 CPU 只负责更新几十个骨骼矩阵,不用为几千个顶点逐个计算。
这种方式比 VAT 更灵活,动画状态机、动画混合仍然可以保留。但 CPU 的上传成本和 Shader 里的矩阵计算量也不容忽视。
| 对比维度 | 顶点动画纹理(VAT) | GPU 蒙皮(Compute Shader) |
|---|---|---|
| CPU 负担 | 极低,只需更新时间参数 | 较低,需更新骨骼矩阵 |
| 内存占用 | 纹理体积大,和顶点数×帧数成正比 | 依赖骨骼数,占用较小 |
| 动画灵活性 | 低,烘焙后不可动态混合 | 高,可保留动画状态机 |
| 实现难度 | 中等,需要烘焙工具 | 偏高,涉及 Compute Shader |
| 适用场景 | 大量重复角色、环境动画 | 角色数较多的写实项目 |
4. 顶点动画纹理烘焙:从 Blender 到引擎
下面进入实战部分。我们以 Blender 制作角色动画、Unity 作为游戏引擎为例,演示一套完整的 VAT 烘焙与接入流程。
版本说明:不同 Blender 和 Unity 版本 API 略有差异,本文示例代码重点演示思路,你在使用时需要根据自己的版本微调。
4.1 创建项目结构
建议目录结构如下:
Assets/ Models/ Character.fbx Animations/ Idle.fbx Walk.fbx VAT/ Scripts/ VATBaker.cs VATExtractor.cs Shaders/ VATShader.shader Textures/ Char_Walk_VAT.png在 Blender 里完成模型绑定和动画后,导出为 FBX 时尽量只导出网格,骨骼和动画可以保留,但最终接入 VAT 的角色网格里不再需要骨骼组件。
4.2 用 Python 在 Blender 里烘焙顶点位置
我们可以在 Blender 的 Scripting 面板里运行一段 Python 脚本,读取动画每一帧的网格顶点坐标,并导出为图像。
下面是核心脚本思路:
import bpy import numpy as np from PIL import Image # 获取当前选中的模型 obj = bpy.context.active_object mesh = obj.data # 动画总帧数和顶点数 total_frames = bpy.context.scene.frame_end - bpy.context.scene.frame_start + 1 vertex_count = len(mesh.vertices) # 准备一个二维数组存储所有帧的顶点坐标 # 格式: [帧][顶点索引] -> (x, y, z) vertex_data = np.zeros((total_frames, vertex_count, 3), dtype=np.float32) for frame in range(bpy.context.scene.frame_start, bpy.context.scene.frame_end + 1): bpy.context.scene.frame_set(frame) # 注意:这里需要确保 mesh 的坐标是模型空间坐标 # 如果模型有父级,需要先用 obj.matrix_world 转换 for i, vert in enumerate(mesh.vertices): vertex_data[frame - bpy.context.scene.frame_start][i] = vert.co[:] # 把顶点数据映射成图像像素 # 纹理宽 = 顶点数,纹理高 = 总帧数 width = vertex_count height = total_frames # 将位置数据归一化到 0~1 范围 # 实际生产可以按模型包围盒统一归一化,或者用浮点纹理保存原始值 # 这里为简化演示,只做最小最大归一化 min_pos = vertex_data.min() max_pos = vertex_data.max() normalized = (vertex_data - min_pos) / (max_pos - min_pos) # 创建 RGB 图像,R/G/B 分别对应 X/Y/Z img = Image.new("RGB", (width, height)) pixels = img.load() for y in range(height - 1, -1, -1): frame_data = normalized[y] for x in range(width): n = pixels if False else None r = int(frame_data[x][0] * 255) g = int(frame_data[x][1] * 255) b = int(frame_data[x][2] * 255) img.putpixel((x, height - 1 - y), (r, g, b)) img.save("//Char_Walk_VAT.png")这段脚本的核心逻辑是:遍历动画帧,取每一帧网格顶点的模型空间坐标,然后把坐标归一化写入一张图片的像素。
这里有几个细节需要说明:
- 归一会丢失精度。如果角色模型很小,归一化到 0 到 255 的 8 位图会导致严重的顶点抖动。
- 更稳妥的做法是导出为浮点纹理(EXR、HDR 或者 Unity 的 Texture2D 手动填充 RawTextureData),或者用中心点加缩放量偏移的方式,把位置还原公式写进 Shader。
frame_set会移动帧指针,脚本运行前需要确认动画所在的帧范围。
我在实际项目中,更推荐在引擎里离线烘焙,下面演示 Unity 的 C# 版本。
4.3 在 Unity 中离线烘焙顶点位置
如果你不愿意在 Blender 里做复杂度较高的图像导出,也可以在 Unity 编辑器里写一个编辑器脚本。这种方法的好处是:模型和动画已经在 Unity 中配置好,你可以直接读取SkinnedMeshRenderer的BakeMesh,然后逐帧采样,输出的纹理直接保存到 Assets 目录。
using System.IO; using UnityEngine; using UnityEditor; public static class VATBaker { [MenuItem("Tools/VAT/Bake Animation To Texture")] public static void Bake() { SkinnedMeshRenderer skin = Selection.activeGameObject.GetComponent<SkinnedMeshRenderer>(); AnimationClip clip = Selection.activeGameObject.GetComponent<Animation>().clip; int vertexCount = skin.sharedMesh.vertexCount; float frameRate = 30f; int totalFrames = Mathf.CeilToInt(clip.length * frameRate); // 用于接收每帧烘焙出的网格 Mesh bakedMesh = new Mesh(); // 颜色数组用来存储顶点位置信息 Color[] vertexColors = new Color[vertexCount * totalFrames]; for (int frame = 0; frame < totalFrames; frame++) { float time = frame / frameRate; clip.SampleAnimation(Selection.activeGameObject, time); skin.BakeMesh(bakedMesh); for (int v = 0; v < vertexCount; v++) { Vector3 pos = bakedMesh.vertices[v]; // 这里把位置值先存到 Color 里,后续再转成纹理 vertexColors[v + frame * vertexCount] = new Color(pos.x, pos.y, pos.z, 1.0f); } } // 创建 Texture2D Texture2D tex = new Texture2D(vertexCount, totalFrames, TextureFormat.RGBAFloat, false); tex.SetPixels(vertexColors); tex.wrapMode = TextureWrapMode.Clamp; tex.filterMode = FilterMode.Bilinear; tex.Apply(); // 保存为 Asset byte[] bytes = tex.EncodeToEXR(); string path = "Assets/VAT/Textures/" + clip.name + "_VAT.exr"; File.WriteAllBytes(path, bytes); AssetDatabase.Refresh(); // 记录模型包围盒信息 Debug.Log("VAT 纹理生成完成:" + path); } }这段脚本的思路是:
- 通过
Selection.activeGameObject获取当前选中的角色。 - 用
clip.SampleAnimation把动画采样到指定时间点。 - 调用
BakeMesh让 SkinnedMeshRenderer 在 CPU 上把蒙皮结果计算出来。 - 把每个顶点的位置写入一个
Color数组。 - 最后生成一张
Texture2D,格式是RGBAFloat,这样才能保留原始浮点精度。
RGBAFloat纹理的内存占用较大,适合开发和验证阶段。正式发布时,可以根据平台选择RGBAHalf或压缩格式。
4.4 编写 VAT Shader
接下来是 GPU 动画最核心的部分:顶点着色器从纹理中采样位置。
这是一个可以在 Unity 中运行的核心 Shader 示例:
Shader "Custom/VATShader" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _PositionTex ("Position Texture", 2D) = "black" {} _FrameCount ("Frame Count", Float) = 30 _FrameRate ("Frame Rate", Float) = 30 _TimeOffset ("Time Offset", Float) = 0 } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.0 sampler2D _MainTex; sampler2D _PositionTex; float _FrameCount; float _FrameRate; float _TimeOffset; struct Input { float2 uv_MainTex; }; // 顶点动画函数 void vert(inout appdata_full v) { // 通过顶点 ID 计算这个顶点在纹理中的横坐标 // appdata_full 在多数内置管线中不直接提供 vertexID,需要额外声明 // 这里先给出伪代码思路: /* float vertexID = v.vertex.x * 0.0; // 实际应从输入语义传入 float frameIndex = floor((_Time.y + _TimeOffset) * _FrameRate) % _FrameCount; float vCoord = frameIndex / max(1.0, _FrameCount - 1.0); float4 pos = tex2Dlod(_PositionTex, float4(vertexID / _PositionTex_TexelSize.z, vCoord, 0, 0)); v.vertex.xyz = pos.xyz; v.normal = float3(0, 1, 0); */ } void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Metallic = 0; o.Smoothness = 0; } ENDCG } FallBack "Diffuse" }上面代码里的vert函数展示了核心思路,但appdata_full在 Unity 内置管线中拿不到无符号顶点 ID。实际项目中,我们更推荐使用自定义顶点数据。最稳定的做法是用顶点 UV 的一个通道提前记录顶点索引:
// 在建模或编辑器脚本中把每个顶点的索引写入 uv3.x // Shader 里采样时就拿 uv3.x 作为横坐标更具体的采样代码如下:
void vert(inout appdata_custom v) { float vertexID = v.uv3.x; // 这个值在模型准备时就写好了,范围是 0~1 float time = _Time.y + _TimeOffset; // 计算当前应该播放到第几帧 float frameIndex = fmod(floor(time * _FrameRate), _FrameCount); float vCoord = frameIndex / max(1, _FrameCount - 1); // 从位置纹理采样 float4 pos = tex2Dlod(_PositionTex, float4(vertexID, vCoord, 0, 0)); // 覆盖顶点位置 v.vertex.xyz = pos.xyz; }这里有几个关键点:
_Time.y是 Unity 内置的时间变量,单位是秒。fmod(floor(time * _FrameRate), _FrameCount)可以保证动画循环播放。vertexID不是真正的整数索引,而是归一化后的 0 到 1 坐标,对应纹理的横坐标。- 采样
_PositionTex时,横坐标和纵坐标都在 0 到 1 范围内,纹理过滤模式需要设置成Bilinear,否则帧切换时顶点位置会跳变。
4.5 编写顶点索引写入脚本
既然 Shader 需要uv3.x来存放顶点索引,我们还需要一个编辑器脚本来把顶点索引写入网格的 UV 通道。
using UnityEngine; using UnityEditor; public static class VertexIndexWriter { [MenuItem("Tools/VAT/Write Vertex Index To UV3")] public static void WriteVertexIndex() { Mesh mesh = Selection.activeGameObject.GetComponent<MeshFilter>().sharedMesh; Vector3[] uv3 = new Vector3[mesh.vertexCount]; for (int i = 0; i < mesh.vertexCount; i++) { // 归一化到 0~1,后续采样纹理时需要精确匹配 uv3[i] = new Vector3((float)i / (mesh.vertexCount - 1), 0, 0); } mesh.SetUVs(3, uv3); EditorUtility.SetDirty(mesh); AssetDatabase.SaveAssets(); Debug.Log("顶点索引已写入 uv3"); } }运行这个菜单命令之后,模型的uv3.x就记录了每个顶点的归一化 ID,Shader 里的采样横坐标就能精确对应到烘焙纹理的列。
注意:如果网格顶点数量在不同导入设置下发生变化,比如 Unity 自动拆分了平滑组或者添加了额外顶点,这个索引可能对不上。所以更严谨的做法是把 UV3 的写入放到模型导入管线的后处理步骤中,或者确保烘焙 Mesh 和渲染 Mesh 的顶点顺序完全一致。
4.6 使用 SkinnedMeshRenderer 的优化操作
烘焙完成后,原始角色节点上的SkinnedMeshRenderer就不再需要了。你可以把它替换成普通的MeshRenderer+MeshFilter,并把 SkinnedMeshRenderer 烘焙出来的第一个 Mesh 作为静态网格。
这样做的原因是:MeshRenderer的性能开销远低于SkinnedMeshRenderer,它不需要每帧更新骨骼矩阵,也更容易做 GPU Instancing。
替换后的组件结构如下:
Character (GameObject) ├─ MeshFilter ├─ MeshRenderer └─ Material (使用 VATShader)材质球上需要设置两张纹理:
_PositionTex:烘焙好的顶点位置纹理。_MainTex:角色原本的贴图。
更新材质参数时,把时间周期和帧率设置好:
Material mat = GetComponent<MeshRenderer>().sharedMaterial; mat.SetFloat("_FrameCount", frameCount); mat.SetFloat("_FrameRate", 30f); mat.SetFloat("_TimeOffset", Time.time);4.7 运行验证
把具有 VAT Shader 的角色放入场景,进入 Play Mode,你会看到角色播放烘焙好的动画。
验证内容:
- 动画循环播放是否正常。
- 顶点是否有抖动或撕裂。
- 阴影是否正确。
- 多个角色实例是否共用同一套 Material。
为了验证大量角色的性能,可以写一个简单的 Spawner:
using UnityEngine; public class VATSpawner : MonoBehaviour { public GameObject characterPrefab; public int count = 100; public float radius = 20f; void Start() { for (int i = 0; i < count; i++) { Vector3 pos = transform.position + Random.insideUnitSphere * radius; pos.y = 0; Instantiate(characterPrefab, pos, Quaternion.identity); } } }创建 100 个角色后,打开 Profiler 观察 CPU 耗时。正常情况下,动画采样逻辑已经不存在了,你会看到 CPU 侧 Animation 和 Skinning 的开销几乎为零。
5. GPU 蒙皮方案:Compute Shader 的接续思路
如果你希望保留动画混合的能力,又不愿意放弃 GPU 并行计算的优势,可以考虑 GPU 蒙皮方案。
它的思路是:CPU 只负责把每根骨骼的蒙皮矩阵传到 Compute Shader,Compute Shader 读取网格的顶点数据、骨骼索引和权重,逐顶点并行计算最终位置。
核心 C# 代码如下:
using UnityEngine; public class GPUSkinner : MonoBehaviour { public ComputeShader computeShader; public Mesh sourceMesh; public Material targetMaterial; private ComputeBuffer boneMatrixBuffer; private ComputeBuffer vertexBuffer; private ComputeBuffer outputVertexBuffer; private int kernelIndex; void Start() { kernelIndex = computeShader.FindKernel("CSMain"); // 初始化各缓冲区 // 每帧更新 boneMatrixBuffer 为当前骨骼矩阵 } void Update() { // 从 Animator 获取当前骨骼矩阵数组 Matrix4x4[] boneMatrices = new Matrix4x4[30]; // 通过 animator.GetBoneTransform() 等接口填充 boneMatrixBuffer.SetData(boneMatrices); computeShader.SetBuffer(kernelIndex, "BoneMatrices", boneMatrixBuffer); computeShader.Dispatch(kernelIndex, vertexCount / 64, 1, 1); } }这种方案在移动端和桌面端表现不同,需要根据目标平台做针对性优化。Compute Shader 对 GPU 架构有一定要求,老设备上可能不支持,或者占用过多的 GPU 寄存器。
我的建议是:如果你的项目里角色动画数量大、状态复杂,优先考虑 GPU 蒙皮;如果角色动画固定、数量爆炸,比如群集、植被、门面招牌,优先考虑 VAT。
6. 常见问题与排查思路
6.1 顶点抖动、错位、闪烁
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 顶点位置出现抖动 | 位置纹理精度不足,8 位纹理无法保留小数精度 | 改用 RGBAHalf 或 RGBAFloat 纹理 |
| 顶点错位、飞到远处 | 顶点索引写入的 uv3 与烘焙时的顶点顺序不一致 | 检查 uv3 是否在导入管线中被重排 |
| 帧切换瞬间闪跳 | 纹理 FilterMode 使用 Point、时间计算不连续 | 使用 Bilinear 过滤,确认帧索引的 floor 取整没有溢出 |
| 采样坐标翻转导致上下颠倒 | 纹理坐标方向不一致 | 确认 vCoord 从底部或顶部采样时是否符合预期,必要时翻转 Y 轴 |
6.2 阴影错乱
VAT Shader 的阴影需要重新计算。当你用tex2Dlod修改了顶点位置,阴影 Pass 中的顶点着色器也要执行同样的逻辑。
解决办法:把顶点变换逻辑抽象成一个公共函数,在 ShadowCaster Pass 和 Forward Pass 里都调用它。
// 注意:不同的内置渲染管线写法不同,这里提供思路 void vertShadow(inout appdata_custom v) { // 同样从 _PositionTex 采样位置 v.vertex.xyz = SampleVATPosition(v.uv3.x, _Time.y); }6.3 多个角色动画不同步
如果多个角色实例使用同一个材质球,它们的_Time全局值是相同的,所以动画会同步。如果你希望角色之间有相位差,可以给每个实例设置不同的_TimeOffset。
mat.SetFloat("_TimeOffset", Random.Range(0f, 1f));注意,如果材质球是共享材质,这样设置会影响所有使用该材质的角色。你需要使用MaterialPropertyBlock或者为每个角色实例化材质。推荐MaterialPropertyBlock,性能更好。
MaterialPropertyBlock block = new MaterialPropertyBlock(); block.SetFloat("_TimeOffset", Random.Range(0f, 1f)); GetComponent<MeshRenderer>().SetPropertyBlock(block);6.4 包围盒不正确导致角色被裁剪
因为 Shader 里修改了顶点位置,Unity 的视锥剔除仍然基于原始 Mesh 的包围盒。当动画导致顶点超出包围盒范围时,角色可能会被错误剔除。
解决办法:
- 在导入设置中手动扩大
Mesh.bounds的范围。 - 或者在顶点着色器进入裁剪之前,用
unity_ObjectToWorld换算大包围盒。
最简单的方式是在 Start 里写入较大包围盒:
Mesh mesh = GetComponent<MeshFilter>().mesh; mesh.bounds = new Bounds(Vector3.zero, Vector3.one * 10f);6.5 移动端纹理带宽过大
RGBAFloat 纹理在移动端是一笔不小的带宽开销。如果移动端出现发热或掉帧,优先做以下优化:
- 改为 RGBAHalf,精度对大多数角色足够。
- 降低烘焙帧率,从 30 降到 20,视觉差距不大。
- 分拆动画纹理,只保留项目必用的动画。
- 使用 ASTC 等压缩格式时,需要验证压缩后顶点数据的误差。
6.6 内存占用粗估
一张 VAT 纹理的内存估算如下:
- 假设顶点数 5000,动画总帧数 60。
RGBAFloat每像素 16 字节:5000 × 60 × 16 = 4,800,000 字节 ≈ 4.6 MB。- 如果只存位置,可以拆成 RGB,用
RGBAHalf(每像素 8 字节),数据量直接减半。
如果项目有几十个动画,内存压力就会变得明显。所以实际项目中,通常只对最高频的循环动画做 VAT,其他动画仍然走骨骼动画。
6.7 动画循环接缝闪烁
动画首尾帧如果没有完全对齐,循环播放时会出现“跳变”的闪烁感。解决方法是保证烘焙时的动画 Clip 首尾姿势一致,或者将纹理采样结果在最后一帧和第一帧之间做插值。
Shader 侧可以做一个简单的帧间混合:
float frameA = floor(frameIndex); float frameB = fmod(frameA + 1, _FrameCount); float frac = frameIndex - frameA; float4 posA = tex2Dlod(_PositionTex, float4(vertexID, frameA / max(1, _FrameCount - 1), 0, 0)); float4 posB = tex2Dlod(_PositionTex, float4(vertexID, frameB / max(1, _FrameCount - 1), 0, 0)); v.vertex.xyz = lerp(posA.xyz, posB.xyz, frac);这样不仅能解决循环接缝,还能让动画更平滑。
7. GPU 动画的工程实践建议
7.1 用数据决定方案
不要因为“炫酷”就用 GPU 动画。先在 Profiler 里跑一下实际项目,确认 CPU 的开销主要来自 Animation、SkinnedMeshRenderer 还是 Render。如果你的角色数量不到 10 个,传统骨骼动画完全没问题。
真正适合 GPU 动画的量化标准是:
- 同屏角色数量超过 30。
- 角色动画长度较短,循环录制。
- 角色面数在 3000 到 8000 之间。
- CPU 的 Animation 和 Skinning 耗时占比超过 10%。
7.2 动画纹理的规范命名
烘焙生成的 VAT 纹理建议按照“角色名_动画名_VAT”的规则命名。例如:
Goblin_Walk_VAT.exrGoblin_Attack_VAT.exrGuard_Idle_VAT.exr
材质球上挂载的动画纹理建议放进一个专门的文件夹,方便批量调整压缩格式和导入设置。
7.3 引入 LOD 和 GPU Instancing
GPU 动画最适合配合 GPU Instancing 使用。把 100 个相同角色放到同一个渲染批次里,Draw Call 会降到很低。
材质属性必须通过MaterialPropertyBlock或者Graphics.DrawMeshInstanced传入,避免实例之间互相覆盖。
如果角色距离摄像机较远,可以生成低面数 LOD,同时把 VAT 纹理的分辨率降下来。远距离角色对动画精度要求不高,低精度纹理完全足够。
7.4 阴影策略
VAT 角色数量大时,实时阴影压力会转移到 Shadow Pass 上。尽量让 VAT Shader 的 ShadowCaster Pass 保持简单,不要采样过多纹理。
大量角色推荐使用:
- 烘焙光贴图替代实时阴影。
- 使用 Shadow Map 但降低阴影分辨率。
- 只对近距离 VAT 角色开启实时阴影。
7.5 避免材质实例爆炸
自定义 Shader 中暴露了_TimeOffset,如果每个角色都实例化一个材质,内存和切换代价都会变大。尽量用MaterialPropertyBlock共享材质,这是 VAT 渲染的核心习惯。
7.6 保留原始骨骼动画资产
烘焙成 VAT 之后,原始 FBX、骨骼动画和蒙皮数据不要删。后续如果角色动作需要修改、或者需要混合动画,原始资产是唯一的修改入口。VAT 是“结果”,不是“源”。
8. 收尾:关于角色制作的下一步
GPU 动画只是角色动画优化链条中的一环。如果你已经成功把 100 个角色跑起来,接下来最值得做的事情是把三件事打通:一是把 VAT 接入项目的对象池和循环复用系统,二是给远距离角色动态切换低精度 VAT 纹理,三是建立一套动画烘焙的自动化流程,避免每次修改动画后手工重导。
做独立游戏,性能优化不是把某个技术点做到极致,而是围绕自己的场景找到性价比最高的组合。骨骼动画、顶点动画纹理、GPU 蒙皮,这些方案会长期共存。当你理解了它们各自的代价和边界,你就能在每个项目里做出更准确的选择。
希望这篇笔记对正在优化独立游戏角色的你有所帮助。如果你在接入时踩到别的坑,欢迎带着报错信息来交流,我看到了会继续补充进去。