このノートでは 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) }
}
歩きから走りへの遷移や、上半身が狙いを定めつつ下半身が移動するレイヤードブレンドは、この操作の上に築かれます。ワールド変換とスキニング行列の計算は、必ずブレンドの後に行ってください。順序を逆にしてはいけません。行列は補間できない——本稿の冒頭で述べたとおりです。
次回からレイトレーシングの章に入ります。