[3D技術史 #04] GPU時代のリアルタイムスキニングと標準フォーマット
この記事は「スキニング&リギング技術史」4部構成の第4部(最終回)です。
- セグメントアニメーション — 関節がバラバラだった時代
- Linear Blend Skinning — メッシュが繋がった革命と「潰れ」の壁
- DQS・PSD — 潰れを騙す数学的トリック
- GPU時代のリアルタイムスキニングと標準フォーマット(本記事)
CPUの限界
第2部で見た通り、LBSの計算コストは「全頂点 × 影響ボーン数」。
10,000頂点 × 4ボーン = 40,000回の行列×ベクトル演算を毎フレームCPUで行う必要がある。
1990年代後半のCPU(Pentium III, 500MHz程度)では、これが限界に近かった。
頂点数を増やすか、影響ボーン数を増やすか、キャラクター数を増やすか — どれか1つしか選べない。
しかし同時期、グラフィックスハードウェアに革命が起きていた。プログラマブルシェーダーの登場。
CPUからGPUへ
固定機能パイプラインの時代
初期のGPU(GeForce 256, 1999年)は「固定機能パイプライン」で、頂点の変換や照明計算をハードウェアで行っていた。
GeForce - Wikipedia
GeForce is a brand of graphics processing units (GPUs) designed by Nvidia and marketed for the performance market. As of the GeForce 50 series, there have been nineteen iterations of the design.[clarification needed] In August 2017, Nvidia stated that there are over 200 million GeForce gamers.
en.wikipedia.orgスキニングはその固定機能の1つとして組み込まれ、「ハードウェアT&L(Transform & Lighting)」の中でボーンブレンディングが実行された。
ただし固定機能はカスタマイズできない。
影響ボーン数は2本固定、ブレンド方式もLBSのみ。
DQSや独自の変形方式を使うことはできなかった。
Vertex Shaderの登場
2001年、DirectX 8.0とともにプログラマブルVertex Shaderが登場。
頂点ごとの処理をプログラマーが自由に書けるようになった。
これがスキニングにとっての転換点。
// Vertex Shaderでのスキニング(HLSL擬似コード)
float4x4 boneMatrices[MAX_BONES]; // ボーン行列の配列
float4 skinnedPos = float4(0, 0, 0, 0);
for (int i = 0; i < 4; i++) {
float weight = weights[i];
int boneIdx = joints[i];
skinnedPos += weight * mul(boneMatrices[boneIdx], position);
}
CPUで1頂点ずつ順番に計算していた処理が、GPUで数千頂点を同時並列に実行できるようになった。
スキニングの計算コストが事実上消滅し、頂点数の制約が大幅に緩和された。
GPUスキニングの圧倒的な並列性
CPUとGPUの根本的な違いは並列性。
CPU: 4〜16コア × 1頂点 = 4〜16頂点を同時処理
GPU: 数千コア × 1頂点 = 数千頂点を同時処理
CPUは1コアあたりの処理能力が高い代わりにコア数が少ない。
GPUは1コアの能力は低いが、数千のコアが同じ処理を異なるデータに対して実行する(SIMD / SIMT)。
スキニングは「全頂点に同じ計算を適用する」処理なので、GPUの並列性と完璧に相性が良い。
14万頂点のVRMモデル(VRM→PMX変換で扱ったlain02.vrm)でも、GPUなら1フレーム1ms未満でスキニングが完了する。
CPUでは手動スキニングに体感で数秒かかった
——— あの処理がGPUなら毎フレーム余裕で走る。
なぜ「1頂点あたり4ボーン」なのか
GPU のアトリビュート制約
ほぼ全ての3Dエンジンとフォーマットが、1頂点あたりの影響ボーン数を4本に制限している。
技術的にもっと多くのボーンを扱えるにもかかわらず、4本が標準な理由はGPUのデータ構造にある。
GPUの頂点アトリビュートは vec4(4成分ベクトル)が基本単位。
ボーンインデックスとウェイトをそれぞれ vec4 で格納すると:
JOINTS_0: vec4 = (boneA, boneB, boneC, boneD) ← 4本のボーンインデックス
WEIGHTS_0: vec4 = (0.5, 0.3, 0.15, 0.05) ← 4本のウェイト
vec4 1つで4ボーン分が収まる。
5本以上にするには JOINTS_1 / WEIGHTS_1 を追加する必要があり、アトリビュートのスロットを消費する。
GPUの頂点アトリビュート数には上限(OpenGL ES 3.0で16個)があり、位置・法線・UV・タンジェント等で既に多くを使っている。
実用上の理由
4ボーンで十分な理由もある。
人体モデルの大半の頂点は、実際には1〜2本のボーンにしか有意なウェイトを持っていない。
関節の境界付近だけが2〜3本、本当に複雑な股関節や肩まわりでやっと4本使う。
5本目以降のウェイトは通常0.01未満で、見た目にほぼ影響しない。
VRM→PMX変換でglTFの skinWeight を読み出した時も、大半の頂点は1〜2本しか有意なウェイトを持っていなかった。
4本の枠のうち残りはウェイト0で埋められている。
8ボーン対応の動き
近年のハイエンドゲーム(Unreal Engine 5等)では8ボーンに対応する動きもある。
glTF仕様でも JOINTS_1 / WEIGHTS_1 を追加することで8ボーンを表現できる。
ただし対応するレンダラーはまだ少なく、互換性を重視するなら4ボーンが安全。
マトリクスパレット — ボーン行列の受け渡し
Uniform配列としてのボーン行列
GPUスキニングでは、全ボーンの変換行列をUniform変数の配列としてシェーダーに渡す。
この配列をマトリクスパレット(Matrix Palette)と呼ぶ。
uniform mat4 boneMatrices[MAX_BONES]; // 最大ボーン数分の行列配列
頂点シェーダーは、頂点アトリビュートの JOINTS_0 からボーンインデックスを読み、boneMatrices[index] で対応する行列を取得し、WEIGHTS_0 のウェイトでブレンドする。
MAX_BONESの制約
Uniform変数にもGPU側の容量制限がある。
mat4(4×4行列)は16個のfloat。100ボーンなら1,600個のfloat。OpenGL ES 3.0のUniform上限(最低256個のvec4 = 1,024 float)では、64ボーンが限界になる計算。
この制約を回避するため、現代のエンジンはテクスチャにボーン行列を格納するテクニックを使う。
テクスチャのRGBAピクセルに行列の各要素を詰め込み、頂点シェーダーからテクスチャフェッチで読み出す。
これでボーン数の上限は事実上なくなる。
Three.jsのSkinnedMeshも、ボーン数が多い場合はデフォルトでこのテクスチャベースの方式(DataTexture にボーン行列を格納)を使用する。
VRM→PMX変換で扱った150本以上のボーンも、Three.jsのレンダリング時にはテクスチャ経由でGPUに渡されていた。
glTF / VRMにおけるスキニングデータ
glTF 2.0のスキニング仕様
現代の3Dフォーマットの標準となったglTF 2.0は、スキニングデータを以下の構造で保持している。
{
"meshes": [{
"primitives": [{
"attributes": {
"POSITION": 0,
"NORMAL": 1,
"JOINTS_0": 2, // ボーンインデックス(uint8×4 or uint16×4)
"WEIGHTS_0": 3 // ボーンウェイト(float×4)
}
}]
}],
"skins": [{
"joints": [0, 1, 2, ...], // ボーンノードのインデックス配列
"inverseBindMatrices": 4 // バインド逆行列の配列(accessor)
}]
}
JOINTS_0 と WEIGHTS_0 が頂点ごとの「どのボーンに、どれだけ」を表す。
skins.inverseBindMatrices が第2部で解説したバインド逆行列の配列。
この構造は、LBSの数式をそのままデータに落とし込んだもの。
数式の各項がフォーマットの各フィールドに1:1対応している:
変形後位置 = Σ (WEIGHTS_0[i] × boneWorldMatrix[JOINTS_0[i]] × inverseBindMatrices[JOINTS_0[i]] × POSITION)
VRMとglTFの関係
VRMはglTFの拡張であり、スキニングデータの構造はglTFそのまま。
VRMが追加するのは以下の拡張JSON:
- Humanoidマッピング — どのジョイントが
leftUpperArmでどれがrightFootかの対応表 - MToonマテリアル — PBRの代わりにトゥーンシェーダーを使うための拡張
- メタデータ — タイトル・作者・ライセンス
つまりVRM→PMX変換の本質は「glTFのスキニングデータを読み、PMXのスキニングデータとして書き直す」作業。
glTFの JOINTS_0 / WEIGHTS_0 をThree.jsの SkinnedMesh.geometry.attributes.skinIndex / skinWeight 経由で読み、PMXの頂点フォーマット(BDEF1/BDEF2/BDEF4)に変換して書き出す。
PMXのスキニングデータ構造
PMX 2.0は頂点ごとにウェイトタイプを持つ:
BDEF1: ボーン1本、ウェイト1.0固定
BDEF2: ボーン2本、ウェイト1つ(もう1つは 1.0 - w)
BDEF4: ボーン4本、ウェイト4つ
SDEF: 球面変形(LBSの代替、肩等に使用)
QDEF: 四元数変形(DQSに相当)
glTFが常に4ボーン×4ウェイトなのに対し、PMXは実際に使うボーン数に応じてデータ量を節約する設計。
VRM→PMX変換では、glTFの4ボーン分のウェイトを調べて有意なもの(ウェイト > 0)の数に応じてBDEF1/BDEF2/BDEF4を使い分ける。
Compute Shaderと次世代スキニング
従来のVertex Shaderスキニングの限界
Vertex Shaderでのスキニングは「描画のたびに計算する」。
同じフレームでシャドウマップ、Gバッファ、フォワードパスと複数回描画すると、毎回同じスキニング計算が走る。
Compute Shaderによる事前計算
Compute Shader(DirectX 11+ / OpenGL 4.3+ / WebGPU)を使えば、スキニング計算を描画パスから分離して1フレームに1回だけ実行できる。
結果をバッファに書き戻し、各描画パスはスキニング済みの頂点を参照するだけ。
従来: シャドウ描画(スキニング) → 本描画(スキニング) → 後処理(スキニング) = 3回
Compute: スキニング(1回) → シャドウ描画 → 本描画 → 後処理 = 1回
WebGPUの普及に伴い、ブラウザでもCompute Shaderによるスキニングが現実的になりつつある。
Three.jsも将来的にWebGPUレンダラーでCompute Skinningに対応する可能性がある。
モーフターゲットとの統合
Compute Shaderスキニングの利点は、スキニングとモーフターゲット(ブレンドシェイプ)の計算を1つのComputeパスで統合できること。
VRMの表情モーフ(50以上のブレンドシェイプを持つモデルもある)とスキニングを同時に効率よく計算できる。
この連載を通して
30年の技術進化
1993年: 剛体パーツ、10パーツ、FK
↓ 関節の隙間問題
1998年: LBS、数千頂点、CPU計算
↓ キャンディラッパー現象
2004年: DQS / PSD で潰れ対策
↓ 計算コストの壁
2001年〜: GPU Vertex Shaderでスキニング
↓ ボーン数・頂点数の制約緩和
2020年〜: Compute Shader / WebGPU
セグメントアニメーションからCompute Shaderまで、約30年。
解決した問題の上に新しい問題が乗り、それを解決する技術がまた新しい問題を生む
——— この繰り返しが3DCGの歴史。
VRM→PMX変換で体験した「全ての層」
VRM→PMX変換ツールの開発は、この30年の技術史を凝縮した体験だった。
- FK階層 —
updateMatrixWorld(true)でボーンの親子行列を伝播 - LBS — 手動スキニングでスキン行列を計算、バインド逆行列の二重適用で崩壊
- 頂点ウェイト —
skinIndex/skinWeightを読み出してPMXのBDEF形式に変換 - マトリクスパレット —
skeleton.bonesとboneInversesの配列操作 - 4ボーン制限 — glTFの
JOINTS_0(vec4) がそのまま4ボーンの枠 - IK — MMDの足IKボーンをPMXバイナリに書き出し
教科書で読む技術史と、自分で実装して崩壊させて直した経験は、理解の深さが全く違う。
渦巻いたメッシュを見た瞬間に「バインド逆行列が二重に掛かっている」と気づけるのは、数式を手で書いたからこそ。
3Dフォーマットの変換ツールを作ることは、3DCGの技術スタックを「使う側」から「中身を理解する側」に変える最短ルートかもしれない。