[3D技術史 #04] GPU時代のリアルタイムスキニングと標準フォーマット

[3D技術史 #04] GPU時代のリアルタイムスキニングと標準フォーマット

この記事は「スキニング&リギング技術史」4部構成の第4部(最終回)です。

  1. セグメントアニメーション — 関節がバラバラだった時代
  2. Linear Blend Skinning — メッシュが繋がった革命と「潰れ」の壁
  3. DQS・PSD — 潰れを騙す数学的トリック
  4. GPU時代のリアルタイムスキニングと標準フォーマット(本記事)

CPUの限界

第2部で見た通り、LBSの計算コストは「全頂点 × 影響ボーン数」。

10,000頂点 × 4ボーン = 40,000回の行列×ベクトル演算を毎フレームCPUで行う必要がある。

1990年代後半のCPU(Pentium III, 500MHz程度)では、これが限界に近かった。

頂点数を増やすか、影響ボーン数を増やすか、キャラクター数を増やすか — どれか1つしか選べない。

しかし同時期、グラフィックスハードウェアに革命が起きていた。プログラマブルシェーダーの登場。

CPUからGPUへ

固定機能パイプラインの時代

初期のGPU(GeForce 256, 1999年)は「固定機能パイプライン」で、頂点の変換や照明計算をハードウェアで行っていた。

スキニングはその固定機能の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_0WEIGHTS_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.bonesboneInverses の配列操作
  • 4ボーン制限 — glTFの JOINTS_0 (vec4) がそのまま4ボーンの枠
  • IK — MMDの足IKボーンをPMXバイナリに書き出し

教科書で読む技術史と、自分で実装して崩壊させて直した経験は、理解の深さが全く違う。

渦巻いたメッシュを見た瞬間に「バインド逆行列が二重に掛かっている」と気づけるのは、数式を手で書いたからこそ。

3Dフォーマットの変換ツールを作ることは、3DCGの技術スタックを「使う側」から「中身を理解する側」に変える最短ルートかもしれない。