T.TAO
ブログへ戻る
/9 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)。

鍵となるのがクォータニオンです。オイラー角はジンバルロックを起こし、補間経路が回転順序に依存します。回転行列はそもそも補間できません。クォータニオンは回転を 4 つの数で表し、球面上の最短弧に沿って補間します。これがまさに求めていた「もっとも自然な回転の遷移」です。

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
}

このコメントは重要です。ボーンをトポロジカル順に格納すれば、この計算は再帰のない一回の線形走査になり、並列化も可能になります。インポート時にこのソートを行いましょう。

スキニング行列が最終的にシェーダーへ渡るものです。

Plain TextskinMatrix[i] = worldTransform[i] * inverseBindPose[i]

inverseBindPose はバインドポーズにおけるボーンのワールド変換の逆行列です。その役割は、頂点をモデル空間からいったんボーンのローカル空間へ持ち込み、現在のボーンのワールド変換が正しく配置できるようにすることです。これを省くと、頂点は変換を二重に受けます。

GPU スキニング

各頂点は最大 4 本のボーンのインデックスとウェイトを持ちます。

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 に正規化されている必要があります。さもないとアニメーション中にモデルが膨らんだり縮んだりします。エクスポート時に検査するほうが、シェーダーで補正するより安上がりです。

LBS にはよく知られた欠陥があります。キャンディラッパー現象です。あるボーンが別のボーンに対して 180° 近く回転すると——手首や肘の極端なひねり——二つの変換の線形ブレンドが中間頂点を軸へ向かって潰し、ねじれた飴の包み紙のように見えます。対処はデュアルクォータニオンスキニング(DQS、行列ではなく回転を補間する)か、より実用的には中間のツイストボーンを追加して回転を分散させることです。

頂点関数かコンピュートか

上の実装は頂点関数内でスキニングするため、パスごとに再計算されます。キャラクターを三度描画すれば——メインパス、シャドウパス、深度プリパス——スキニングも三度行われます。

もう一つの方法は、コンピュートシェーダーで一度だけスキニングし、結果を出力バッファに書き、以後のパスはスキニング済みの頂点を使うというものです。

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;
}

どちらを選ぶか。

  • キャラクターの描画が 1〜2 回 → 頂点関数のほうが単純で、中間バッファのメモリと帯域も不要。
  • 描画が 3 回以上、あるいはスキニング後の頂点を別の目的で使う(衝突、クロス、レイトレーシングの BLAS 更新)→ コンピュートスキニング。

レイトレーシングは決定的な理由になります。加速構造はスキニング済みのワールド空間頂点を必要とし、それは実際にバッファ上に存在していなければなりません。頂点関数の一時的な結果は他から参照できません。

キーフレームのサンプリングとブレンド

キーフレームは等間隔とは限らないので、現在時刻を含む区間を二分探索で求めます。

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)
}

前フレームのインデックスをキャッシュし、次のフレームはそこから線形に探索してください。アニメーション時間は単調に進むため、たいてい 1〜2 歩で命中し、二分探索よりはるかに安く済みます。

アニメーションブレンドは二つのポーズをウェイトで補間します。ポーズを行列の配列ではなく 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 [付録] Compute Shader
  22. 22Metal #22 [付録] SwiftUI における Metal