Unity 中使用 GPU 播放简单的顶点动画
Unity中使用 GPU 播放简单的顶点动画
偶然间在知乎上看到了《戴森球计划》制作人关于游戏实现技术细节的回答,在GPU上播放顶点动画这一技术很有意思,《ABZU》制作人也曾分享这一技术在鱼群中的使用。个人在面试时也曾被问过这样的问题:“场景中存在大量使用相同动画的同类角色,可以怎样优化?” 这类技术就是一个很好的解决方案,只是当时的我仅仅听说过,不曾了解其实现细节。 (那时没怎么学过shader
这次就尝试实现下基本功能部分,以下是个人实现,仅供参考,完整项目戳这里。
使用 GPU 播放顶点动画的流程
考虑到有未了解过这项技术的同学,在这里就做个简单的介绍。
在Unity中播放动画,通常我们都会使用 Animator 组件,它的开销大部分是在CPU上(自身状态机逻辑、骨骼矩阵计算等)。当场景中存在大量播放动画的物体时,这个开销就会很大,而CPU还需要运行更重要的游戏逻辑,于是就有了将动画播放的开销挪到GPU上的想法。
动画可以看作模型在一系列帧中每一个顶点在不同时间的位置与法线偏移。常见的骨骼动画本质就是骨骼矩阵驱动顶点位置偏移,每一帧最终输出的结果就是一组变形后的顶点与其法线。
如此说来,我们完全可以提前计算好动画,把每一帧的所有最终顶点数据(位置与法线)保存下来,游戏运行时直接读取数据并赋值给模型的对应顶点,这样就能直接呈现动画效果,省去了中间的计算过程。
而 GPU 天然能够获取模型的顶点数据且能并行计算,所以这个“所有顶点的赋值”的过程是相当迅速的,这就是 GPU 顶点动画的核心思路。
显然,这一做法的代价是需要额外存储动画的顶点数据,并且由于没使用 Animator ,像IK、动画混合等功能都需要亲力亲为。因此它更适合那些没用复杂物理逻辑的批量物体,像海中的鱼群、观众席拥挤的人群等。 (气氛组这一块
GPU 顶点动画的完整工作流很简洁,可以分成两大阶段:
1. 离线烘焙阶段(编辑器里预先完成)
Unity中的 AnimationClip 对象的 「SampleAnimation」 函数可以让动画播放至指定时间点,这是完成烘焙的关键函数。
可以让动画播放一帧后,就用 SkinnedMeshRenderer 对象的「BakeMesh」函数烘焙一次网格,将当前物体网格变为动画所示的样子,然后记录网格中顶点的相关信息。由此反复,就能得到完整动画的顶点数据了。
2. 运行时播放阶段(游戏内由 Shader 完成)
运行前还需要先读取离线烘焙的顶点数据,将所有动画帧数据存入 ComputeBuffer 并上传至 GPU。
GPU 播放动画需要 Shader 的顶点着色器能根据当前时间与实例参数,自动计算目标帧,对相邻帧的顶点、法线数据做线性插值,生成平滑变形效果,配合 GPU Instancing 实现海量实例合批渲染。
接下来就尝试下具体实现吧。
代码实现
烘焙动画顶点数据
考虑到顶点数据要上传到 GPU,我们选择 float 类型数组来存储顶点数据。一个顶点的位置与法线向量就是两个 Vector3 数据,也就是6个 float 数据。可以肯定,我们的数组大小一定是 \(n * 6\) 的长度……吗?
理论上的确如此,但有个小优化可以让法线用 Vector2 存储:八面体法线编码,这里只简单说下它的编码思想,想进一步了解的可以阅读该博客。
法线的模长为1,一个单位球(半径为1的球)其圆心到球面的向量就可以表示为一个法线,可想而知,这个球可以看作是所有法线集合。
现在假设它内部有一个正八面体,就像这样:
那么任意一条法线,都会穿过这个八面体,并在其表面也留下一个交点(求这个交点其实只需要对法线进行缩放就可以了),不同法线留下的交点位置各不相同,所以我们也可以用八面体上的点来记录法线:
然后我们将八面体在Z轴负方向的那个点所连接的4条边都剪开,八面体就会平摊成一个大正方形:
这样一来,只要我们知道正方形点与八面体点间的对应转化、八面体点与球面点间的对应转化,我们就能用这个正方形上的二维点来记录法线啦!
用这个技巧,可以少记录法线的z分量,数组大小就减少为了 \(n * 5\) 的长度,既可以减少空间占用,也可以减少运行时的显存占用。
\(n\) 的具体数值取决于网格顶点数以及采样的动画帧数。动画基本不会在播放时改变顶点数量,前者可以视为一个固定的数值;后者则需要考虑采样帧率。
如果打算以60帧采样一个2秒的动画,这个动画所属模型顶点数为1000,那么存储这个动画的顶点数据就需要:\(2 * 60 * 1000 * 5\) 的长度的 float 数组。如果是30帧采样的话,这个长度就会减半。
也许你也注意到了,这么算下来,最终存储数据所需的空间还是较大的。30帧采样频率较合适,此外还有一个比较简单的减少空间的办法:用16位精度存储。用 Unity 的 Mathf.FloatToHalf 可以将32位的 float 转为16位的 ushort 类型变量,在需要使用时再用 Mathf.HalfToFloat 转回 float 变量进行运算。
这会损失些许精度,但基本对动画的表现效果没有影响。不过它只能减少存储所用的空间,运行时由于会转回float,所以显存上没有变化。
一类模型可能要播放不止一种动画,例如比赛观众席路人们的呐喊助威动作可能有好几个。将同一模型的一组动画顶点数据存在同一文件中,可以方便播放多个动画的实例进行合批。但显然,在存储时要加些额外数据来辅助定位到指定动画的顶点数据,可以按这种方式存储:
模型顶点数
总动画个数
采样帧数
[对于每个动画,则存以下内容]:
- 动画索引
- 动画总帧数
- 动画顶点数据数组
至于要如何读取,我们交给后续的文件读取相关类来处理,现在看看烘焙的整体代码:
using UnityEngine;
using UnityEditor;
using System.IO;
public class AnimationVertexDataBaker: MonoBehaviour
{
public AnimationClip[] clips; // 需要烘焙的动画
public int sampleFps = 30; // 采样帧率
[ContextMenu("保存动画顶点数据")]
private void BakeSkinnedMeshAnimToVerta()
{
GameObject target = gameObject;
// 寻找实际包含 SkinnedMeshRenderer 的子物体
SkinnedMeshRenderer smr = target.GetComponentInChildren<SkinnedMeshRenderer>();
if (smr == null)
{
Debug.LogError("选中的物体及其子物体中未找到 SkinnedMeshRenderer");
return;
}
if (clips == null || clips.Length == 0)
{
Debug.LogError("未指定任何 AnimationClip");
return;
}
// 获取原始顶点数量
int vertexCount = smr.sharedMesh.vertexCount;
int totalAnimationCount = clips.Length;
// 临时Mesh用于每一帧烘焙结果
var bakedMesh = new Mesh();
// 写入.bytes文件
string path = EditorUtility.SaveFilePanel("已保存动画顶点数据", "", target.name + ".bytes", "bytes");
if (!string.IsNullOrEmpty(path))
{
using (var fs = new FileStream(path, FileMode.Create))
using (var bw = new BinaryWriter(fs))
{
bw.Write(vertexCount); // 固定顶点数
bw.Write(totalAnimationCount); // 总动画个数
bw.Write(sampleFps); // 采样帧数
for (int animIndex = 0; animIndex < clips.Length; animIndex++)
{
AnimationClip clip = clips[animIndex];
int totalFrames = Mathf.CeilToInt(clip.length * sampleFps);
var allFrameData = new ushort[totalFrames * vertexCount * 5];
for (int f = 0, idx = 0; f < totalFrames; f++)
{
// 绝对时间(秒)
float t = f / (float)sampleFps;
// 将动画采样到该时刻
clip.SampleAnimation(target, t);
// 强制更新蒙皮网格
smr.BakeMesh(bakedMesh);
// 每帧刷新最新顶点法线
Vector3[] verts = bakedMesh.vertices;
Vector3[] norms = bakedMesh.normals;
for (int v = 0; v < vertexCount; v++)
{
// 位置永远3个half不变
allFrameData[idx++] = Mathf.FloatToHalf(verts[v].x);
allFrameData[idx++] = Mathf.FloatToHalf(verts[v].y);
allFrameData[idx++] = Mathf.FloatToHalf(verts[v].z);
// 八面体编码,用2个float存储法线
Vector2 octPack = OctEncode(norms[v]);
allFrameData[idx++] = Mathf.FloatToHalf(octPack.x);
allFrameData[idx++] = Mathf.FloatToHalf(octPack.y);
}
}
// 记录单组动画顶点数据
bw.Write(animIndex); // 动画索引
bw.Write(totalFrames); // 总帧数
foreach (ushort val in allFrameData)
{
bw.Write(val);
}
Debug.Log($"动画 {animIndex} 写入完成:{clip.name} 帧数:{totalFrames}");
}
}
DestroyImmediate(bakedMesh);
Debug.Log($"全部导出完成!总动画数:{totalAnimationCount} 顶点数:{vertexCount}");
}
}
/// <summary>
/// 八面体法线编码:float3 normal → float2 uv
/// </summary>
private Vector2 OctEncode(Vector3 n)
{
n.Normalize();
float m = Mathf.Abs(n.x) + Mathf.Abs(n.y) + Mathf.Abs(n.z);
Vector2 res = (Vector2)n / m;
if (n.z < 0)
{
res = (Vector2.one - new Vector2(Mathf.Abs(res.y), Mathf.Abs(res.x))) *
new Vector2(Mathf.Sign(res.x), Mathf.Sign(res.y));
}
return res;
}
}
读取动画顶点数据
与存储时相对应的,读取时也要划分好每个动画的数据,先用一个类来装每个动画除了顶点数据外所需辅助信息:
public class AnimationClipData
{
public int animIndex; // 动画索引
public int totalFrames; // 动画总帧数
public float duration; // 动画时长
public int startOffset; // 用于快速定位动画数据位置
}
如前所述,所有动画都会被存在一个 ComputeBuffer 中(你可以理解为数组中),因此每个动画都有个 startOffset 来快速定位自身数据的起始位置索引:
读取数据的类继承 ScriptableObject,这样可以共享一份 ComputeBuffer,减少占用。还需要在初始化时动态创建好使用上这个 ComputeBuffer 的材质,因此我们也还需要为它指定会播放顶点动画的 Shader:
using UnityEngine;
using System.IO;
using System;
using System.Collections.Generic;
[CreateAssetMenu(fileName = "VertexDataProvider", menuName = "Vertex Animation/Data Provider")]
public class VertexDataProvider_SO : ScriptableObject
{
[Header("顶点数据文件")]
public TextAsset animVertexData;
[Header("顶点动画Shader")]
public Shader vertaAnimShader;
[NonSerialized] public Material animMaterial;
private AnimationClipData[] animations; // 所有动画数据数组
private ComputeBuffer globalVertexBuffer; // 全局唯一缓冲区(所有动画连续存放)
public void Initialize()
{
if (globalVertexBuffer == null)
{
if (animVertexData == null)
{
Debug.LogError("顶点数据文件不存在");
return;
}
using (var ms = new MemoryStream(animVertexData.bytes))
using (var reader = new BinaryReader(ms))
{
// 读取文件头
int vertexCount = reader.ReadInt32(); // 顶点数
int totalAnimationCount = reader.ReadInt32(); // 总动画个数
int bakeFPS = reader.ReadInt32(); // 帧率
// 临时存储所有动画数据
var allVertexData = new List<float>();
animations = new AnimationClipData[totalAnimationCount];
// 依次读取所有动画
for (int i = 0; i < totalAnimationCount; ++i)
{
int animIndex = reader.ReadInt32();
int totalFrames = reader.ReadInt32();
animations[i] = new AnimationClipData
{
animIndex = animIndex,
totalFrames = totalFrames,
duration = totalFrames / (float)bakeFPS,
startOffset = allVertexData.Count // 上次动画数据尾部作为这次动画的起始偏移
};
int dataCount = totalFrames * vertexCount * 5;
for (int d = 0; d < dataCount; ++d)
{
allVertexData.Add(Mathf.HalfToFloat(reader.ReadUInt16()));
}
}
// 创建用于GPU动画的材质
globalVertexBuffer = new ComputeBuffer(allVertexData.Count, sizeof(float));
globalVertexBuffer.SetData(allVertexData.ToArray());
animMaterial = new Material(vertaAnimShader);
animMaterial.SetBuffer("_VertaBuffer", globalVertexBuffer);
animMaterial.SetInt("_VertexCount", vertexCount);
animMaterial.enableInstancing = true;
}
}
}
/// <summary>
/// 获取指定索引的动画
/// </summary>
public AnimationClipData GetAnimation(int index)
{
return animations[index];
}
public void Release()
{
globalVertexBuffer?.Release();
globalVertexBuffer = null;
}
}
播放顶点动画的Shader
先来看下整体代码,核心在顶点着色器 vert 里,就是根据当前时间找到该时间所属的两帧,然后做线性插值,得到这一瞬间每个顶点应该在的位置和法线。后面会说明其中一些比较重要的部分:
Shader "Custom/GPUVertexAnimation"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_SpecularPower ("Specular Power", Range(1, 100)) = 32.0
_SpecularIntensity ("Specular Intensity", Range(0, 2)) = 0.5
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" }
LOD 200
Pass
{
CGPROGRAM
#pragma multi_compile_instancing // 使 shader 支持instance
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc" // 引入光照相关函数
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
uint vertexId : SV_VertexID;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
sampler2D _MainTex;
float4 _MainTex_ST;
float _SpecularPower;
float _SpecularIntensity;
// 动画数据
StructuredBuffer<float> _VertaBuffer;
int _VertexCount;
UNITY_INSTANCING_BUFFER_START(Props)
// 每实例独立的动画偏移,可不那么整齐的播放动画
UNITY_DEFINE_INSTANCED_PROP(float, _AnimOffset)
UNITY_DEFINE_INSTANCED_PROP(int, _AnimStartOffset)
UNITY_DEFINE_INSTANCED_PROP(int, _TotalFrames)
UNITY_DEFINE_INSTANCED_PROP(float, _AnimDuration)
UNITY_INSTANCING_BUFFER_END(Props)
// 八面体法线解码(无分支写法)
float3 OctDecode(float2 enc)
{
float3 n = float3(enc.x, enc.y, 1.0 - abs(enc.x) - abs(enc.y));
float t = max(0.0, -n.z);
n.xy += sign(n.xy) * t;
n.z = abs(n.z);
return normalize(n);
}
// 从预处理的动画数据中获取顶点位置与法线
void GetVertexData(int frame, int vertexId, out float3 position, out float3 normal)
{
int instStart = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimStartOffset);
int baseIdx = instStart + (frame * _VertexCount + vertexId) * 5;
position = float3(_VertaBuffer[baseIdx], _VertaBuffer[baseIdx+1], _VertaBuffer[baseIdx+2]);
// 读取2个压缩值,解码还原法线
float2 octPack = float2(_VertaBuffer[baseIdx+3], _VertaBuffer[baseIdx+4]);
normal = OctDecode(octPack);
}
v2f vert (appdata v)
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, o);
// 动画插值计算
float curTime = _Time.y + UNITY_ACCESS_INSTANCED_PROP(Props, _AnimOffset);
float duration = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimDuration);
float animTime = fmod(curTime, duration) / duration;
float totalFrames = UNITY_ACCESS_INSTANCED_PROP(Props, _TotalFrames);
float frameFloat = animTime * (totalFrames - 1);
int frame0 = floor(frameFloat);
int frame1 = min(frame0 + 1, totalFrames - 1);
float frac = frameFloat - frame0;
float3 pos0, pos1, norm0, norm1;
GetVertexData(frame0, v.vertexId, pos0, norm0);
GetVertexData(frame1, v.vertexId, pos1, norm1);
float3 finalPos = lerp(pos0, pos1, frac);
float3 finalNorm = lerp(norm0, norm1, frac);
// 输出裁剪空间坐标
o.vertex = UnityObjectToClipPos(finalPos);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// 光照计算需要:世界空间法线 + 世界空间坐标
o.worldNormal = UnityObjectToWorldNormal(finalNorm);
o.worldPos = mul(unity_ObjectToWorld, finalPos).xyz;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
fixed4 tex = tex2D(_MainTex, i.uv);
// 调用 Unity 官方光照
fixed3 normal = normalize(i.worldNormal);
fixed3 lightDir = _WorldSpaceLightPos0.xyz;
fixed diff = saturate(dot(normal, lightDir));
fixed3 col = tex.rgb * (diff * _LightColor0.rgb + ShadeSH9(float4(normal,1)));
return fixed4(col, tex.a);
}
ENDCG
}
}
FallBack "Diffuse"
}
如果场景里有 100 只狗,它们可能需要同时播放动画,但我们可能并不希望动画完全同步,因此可以将开始的时间可以错开。所以 100 只狗可以有 100 个不同的 _AnimOffset。
即便如此,我们也希望绘制它们只需要一次 DrawCall,这就需要 Shader 支持 GPU Instancing。
UNITY_INSTANCING_BUFFER_START(Props)
UNITY_DEFINE_INSTANCED_PROP(float, _AnimOffset) // 时间偏移(错开动画相位)
UNITY_DEFINE_INSTANCED_PROP(int, _AnimStartOffset) // 在合并 Buffer 中的起始位置
UNITY_DEFINE_INSTANCED_PROP(int, _TotalFrames) // 这个动画总共有多少帧
UNITY_DEFINE_INSTANCED_PROP(float, _AnimDuration) // 动画时长(秒)
UNITY_INSTANCING_BUFFER_END(Props)
这些 UNITY_DEFINE_INSTANCED_PROP 声明的变量,在渲染每个实例时都会从中取自己的那份值。
在顶点着色器 vert 里,_Time.y 是 Unity 内置的“游戏开始后经过的秒数”,加上 _AnimOffset 就能让不同实例错开播放动画:
float curTime = _Time.y + UNITY_ACCESS_INSTANCED_PROP(Props, _AnimOffset);
用 fmod 取余让动画循环播放。
float duration = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimDuration);
float animTime = fmod(curTime, duration) / duration; // 0..1 循环
在得到了归一化的时间 animTime 后,将它乘上 (动画总帧数 - 1) 可以得到,为什么减1,因为动画第1帧是第0秒:
算出浮点帧号后,拆成前后两帧的整数索引和插值权重 frac。这样我们就知道了当前时刻应该从 Buffer 里读取哪两帧的数据。
float totalFrames = UNITY_ACCESS_INSTANCED_PROP(Props, _TotalFrames);
float frameFloat = animTime * (totalFrames - 1);
int frame0 = floor(frameFloat);
int frame1 = min(frame0 + 1, totalFrames - 1);
float frac = frameFloat - frame0;
GetVertexData 函数从 Buffer 中读取顶点所需数据。从这里也能看到之前 VertexDataProvider_SO 记录的 startOffset 的作用(在这个函数里就是 instStart):
void GetVertexData(int frame, int vertexId, out float3 position, out float3 normal)
{
int instStart = UNITY_ACCESS_INSTANCED_PROP(Props, _AnimStartOffset);
int baseIdx = instStart + (frame * _VertexCount + vertexId) * 5;
position = float3(_VertaBuffer[baseIdx], _VertaBuffer[baseIdx+1], _VertaBuffer[baseIdx+2]);
// 读取2个压缩值,解码还原法线
float2 octPack = float2(_VertaBuffer[baseIdx+3], _VertaBuffer[baseIdx+4]);
normal = OctDecode(octPack);
}
八面体法线解码的具体数学原理就不展开了,值得注意的时,这里采用了无 if 分支的写法,算是一个小小的优化。
float3 OctDecode(float2 enc)
{
float3 n = float3(enc.x, enc.y, 1.0 - abs(enc.x) - abs(enc.y));
float t = max(0.0, -n.z);
n.xy += sign(n.xy) * t;
n.z = abs(n.z);
return normalize(n);
}
初始化绑定数据
最后就是负责绑定动画资源与相关参数的类了,它的内容很简单,将读取的数据与shader中对应的参数名绑定即可。其实并不刚需继承 MonoBehaviour,因为它没有需要持续更新的逻辑。
可以注意到,烘焙动画数据时使用的是 SkinnedMeshRenderer,但播放动画的实例却是用 MeshRenderer。这是因为烘焙阶段需要骨骼蒙皮计算来采样动画,运行时就不需要了,可以换用开销更小的 MeshRenderer,并且 MeshRenderer 对 GPU Instancing 合批兼容更好。
using UnityEngine;
[RequireComponent(typeof(MeshRenderer))]
public class GPUAnimLoader : MonoBehaviour
{
[Header("顶点动画数据提供")]
public VertexDataProvider_SO dataProvider;
[Header("要播放的动画下标")]
public int animIndex = 0;
private MeshRenderer meshRenderer;
private MaterialPropertyBlock propBlock;
private void Start()
{
if (dataProvider == null)
{
Debug.LogError("未指定 VertexAnimationDataProviderSO 资产!");
return;
}
// 初始化多动画数据
dataProvider.Initialize();
meshRenderer = GetComponent<MeshRenderer>();
meshRenderer.material = dataProvider.animMaterial;
// 获取当前索引对应的动画
var clipData = dataProvider.GetAnimation(animIndex);
if (clipData == null)
{
Debug.LogError($"动画索引 {animIndex} 不存在!");
return;
}
// 随机偏移(错开动画)
float randomOffset = Random.Range(0, clipData.duration);
// 设置实例属性
propBlock = new MaterialPropertyBlock();
meshRenderer.GetPropertyBlock(propBlock);
propBlock.SetInt("_AnimStartOffset", clipData.startOffset);
propBlock.SetInt("_TotalFrames", clipData.totalFrames);
propBlock.SetFloat("_AnimDuration", clipData.duration);
propBlock.SetFloat("_AnimOffset", randomOffset);
meshRenderer.SetPropertyBlock(propBlock);
}
public void PlayAnimation(int newAnimIndex)
{
animIndex = newAnimIndex;
var clipData = dataProvider.GetAnimation(animIndex);
if (clipData != null)
{
propBlock = new MaterialPropertyBlock();
meshRenderer.GetPropertyBlock(propBlock);
propBlock.SetInt("_AnimStartOffset", clipData.startOffset);
propBlock.SetInt("_TotalFrames", clipData.totalFrames);
propBlock.SetFloat("_AnimDuration", clipData.duration);
meshRenderer.SetPropertyBlock(propBlock);
}
}
private void OnDestroy()
{
dataProvider.Release();
}
}
示例应用
示例项目使用了 styloo的动物资产 中的狗子,里面有提供6个狗子的动画,项目中已经创建好了一堆可用GPU动画的狗子,直接播放就可以看到效果。
这里简单讲下从零开始的使用步骤:
将模型拖入场景中,在 根节点 dog 上添加 AnimationVertexDataBaker 脚本,添加想要播放的动画(要记住顺序哦,后面播放时要填写动画对应的索引)并填写采样帧率,然后调用烘焙:
然后创建读取文件的脚本化对象,并设置好参数:
接着在场景中创建一个空物体,添加 Mesh Filter 组件并赋予模型网格,接着再添加 GPUAnimLoader 脚本并填好参数:
批量复制,运行游戏,就能看到 “万狗奔腾” 的场景了(可以看到 GPU Instancing 发力了)

浙公网安备 33010602011771号