T.TAO
返回博客
/8 min read/Graphics Engine

Metal #16 动画

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下骨骼动画在 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) }
}

走路到跑步的过渡、上半身瞄准与下半身移动的分层混合,都建立在这个操作上。混合完成之后再计算世界变换和蒙皮矩阵——顺序不能反,因为矩阵不能插值,这正是本文开头那一点。

下一篇开始讲光线追踪。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal