这篇笔记主要写一下骨骼动画在 Metal 里的实现:从关键帧插值到 GPU 蒙皮。
为什么变换要拆成三部分
一个变换矩阵可以插值,但直接对矩阵做线性插值是错的。两个旋转矩阵的中间值不再是旋转矩阵——它会引入缩放和剪切,表现为模型在动画中间帧诡异地塌陷。
所以动画数据总是把变换拆成三部分分别存储和插值:
Swiftstruct Transform {
var translation: SIMD3<Float>
var rotation: simd_quatf // 四元数
var scale: SIMD3<Float>
}
- 平移和缩放用线性插值。
- 旋转用四元数的球面线性插值(slerp)。
四元数是这里的关键。欧拉角会有万向锁,而且插值路径依赖于旋转顺序;旋转矩阵不能直接插值。四元数用四个数表示旋转,插值路径是球面上的最短弧,这正是我们想要的"最自然的旋转过渡"。
Swiftfunc lerp(_ a: Transform, _ b: Transform, _ t: Float) -> Transform {
Transform(
translation: mix(a.translation, b.translation, t: t),
rotation: simd_slerp(a.rotation, b.rotation, t),
scale: mix(a.scale, b.scale, t: t)
)
}
simd_slerp 会自动处理双覆盖问题:q 和 -q 表示同一个旋转,但插值路径完全不同(一个走短弧、一个走长弧)。如果自己实现,记得先检查点积符号并在必要时取反。
骨骼与蒙皮
骨骼是一棵变换的树。每根骨头有一个父节点,它的世界变换是父节点的世界变换乘上自己的局部变换:
Swiftfunc computeWorldTransforms(_ skeleton: Skeleton, _ pose: [Transform]) -> [float4x4] {
var world = [float4x4](repeating: .identity, count: pose.count)
for i in 0..<pose.count {
let local = float4x4(pose[i])
let parent = skeleton.parentIndex[i]
world[i] = parent < 0 ? local : world[parent] * local
// 前提:骨头按拓扑序存储,父节点索引总是小于子节点
}
return world
}
那条注释很重要。按拓扑序存储骨骼让这个计算变成一次简单的线性遍历,不需要递归,也能被并行化。导入模型时应该做这个排序。
蒙皮矩阵是最终送给 shader 的东西:
Plain TextskinMatrix[i] = worldTransform[i] * inverseBindPose[i]
inverseBindPose 是骨头在绑定姿势下世界变换的逆。它的作用是先把顶点从模型空间变换到骨头的局部空间,再用当前的骨头世界变换把它送回去。没有这一步,顶点会被叠加两次变换。
GPU 蒙皮
每个顶点带上最多四根骨头的索引和权重:
Ctypedef struct {
vector_float3 position;
vector_float3 normal;
vector_float2 uv;
vector_ushort4 jointIndices; // ushort 足够,省一半空间
vector_float4 jointWeights;
} SkinnedVertex;
顶点函数里做线性混合蒙皮(LBS):
MSLvertex VertexOut skinned_vertex(SkinnedVertex in [[stage_in]],
constant float4x4 *skinMatrices [[buffer(12)]],
constant Uniforms &u [[buffer(11)]])
{
float4x4 skin =
skinMatrices[in.jointIndices.x] * in.jointWeights.x +
skinMatrices[in.jointIndices.y] * in.jointWeights.y +
skinMatrices[in.jointIndices.z] * in.jointWeights.z +
skinMatrices[in.jointIndices.w] * in.jointWeights.w;
float4 posOS = skin * float4(in.position, 1.0);
float3 nrmOS = (skin * float4(in.normal, 0.0)).xyz; // w = 0:不受平移影响
VertexOut out;
out.position = u.modelViewProjection * posOS;
out.normalWS = normalize(u.normalMatrix * nrmOS);
return out;
}
权重必须归一化(和为 1),否则模型会在动画中膨胀或收缩。导出时检查这一点比在 shader 里补救便宜。
LBS 有一个众所周知的缺陷:糖果包装纸效应。当一根骨头相对另一根旋转接近 180°(手腕、肘部的极限扭转),两个变换的线性混合会让中间的顶点向轴心塌陷,看起来像拧过的糖纸。解法有对偶四元数蒙皮(DQS),它插值的是旋转本身而不是矩阵;或者更实用的做法——加中间的辅助骨骼把扭转分摊开。
顶点函数还是 compute?
上面那个写法把蒙皮放在顶点函数里,每个 pass 都要重算一遍。如果一个角色要渲染三次(主 pass、阴影 pass、深度预通道),蒙皮就算了三次。
另一种做法是用 compute shader 预先蒙皮一次,把结果写进一个输出 buffer,后面所有 pass 都用这个已经蒙皮好的顶点数据:
MSLkernel void skin_vertices(device const SkinnedVertex *in [[buffer(0)]],
device Vertex *out [[buffer(1)]],
constant float4x4 *skinMatrices [[buffer(2)]],
uint id [[thread_position_in_grid]])
{
float4x4 skin = blendMatrices(in[id], skinMatrices);
out[id].position = (skin * float4(in[id].position, 1)).xyz;
out[id].normal = (skin * float4(in[id].normal, 0)).xyz;
out[id].uv = in[id].uv;
}
什么时候用哪个?
- 角色只渲染一两遍 → 顶点函数更简单,也省掉一个中间 buffer 的内存和带宽。
- 角色要渲染三遍以上,或者需要在蒙皮后的顶点上做别的事(碰撞、布料、光追的 BLAS 更新)→ compute 蒙皮。
光线追踪是一个决定性的理由:加速结构需要蒙皮后的世界空间顶点,这些数据必须真实存在于一个 buffer 里,顶点函数的临时结果拿不到。
关键帧采样与动画混合
关键帧通常不是均匀分布的,采样时需要二分查找找到当前时间所在的区间:
Swiftfunc sample(_ channel: AnimationChannel, at time: Float) -> Transform {
var lo = 0, hi = channel.times.count - 1
while lo + 1 < hi {
let mid = (lo + hi) / 2
if channel.times[mid] <= time { lo = mid } else { hi = mid }
}
let t = (time - channel.times[lo]) / (channel.times[hi] - channel.times[lo])
return lerp(channel.values[lo], channel.values[hi], t)
}
把上一帧的索引缓存下来,下一帧从它开始线性搜索,通常一两步就命中——这比每帧二分查找快得多,因为动画时间是单调推进的。
动画混合是把两个姿势按权重插值,这就是为什么姿势要以 Transform 数组而不是矩阵数组的形式保存:
Swiftfunc blend(_ a: [Transform], _ b: [Transform], _ t: Float) -> [Transform] {
zip(a, b).map { lerp($0, $1, t) }
}
走路到跑步的过渡、上半身瞄准与下半身移动的分层混合,都建立在这个操作上。混合完成之后再计算世界变换和蒙皮矩阵——顺序不能反,因为矩阵不能插值,这正是本文开头那一点。
下一篇开始讲光线追踪。