[JavaScript] Lain Clock MMDモデルの GrantSolver クラッシュ修正 — PMXボーンインデックスと Three.js ランタイムの不整合

[JavaScript] Lain Clock MMDモデルの GrantSolver クラッシュ修正 — PMXボーンインデックスと Three.js ランタイムの不整合

はじめに

前回の記事「 Lain Clock カレンダー機能の WebGL 3D メッシュ化と描画順序(renderOrder/depthWrite)の最適化」では、カレンダーモジュールの 3D 統合について解説しました。

今回は、特定の PMX モデル(YYB式初音ミク等)を Lain Clock に読み込んだ際に MMDAnimationHelperGrantSolver が毎フレームクラッシュする問題を追跡・修正した記録です。

[JavaScript] Lain Clock MMDモデルの GrantSolver クラッシュ修正 — PMXボーンインデックスと Three.js ランタイムの不整合

別の閲覧&変換ツールでは問題は見つからず、アニメーションでこける。
原因はボーンが多数あり、コード側が未対応だったため。

[JavaScript] Lain Clock MMDモデルの GrantSolver クラッシュ修正 — PMXボーンインデックスと Three.js ランタイムの不整合

時計アプリ側のコードを修正で対応。

[JavaScript] Lain Clock MMDモデルの GrantSolver クラッシュ修正 — PMXボーンインデックスと Three.js ランタイムの不整合

1. 症状

YYBMiku.pmx を IndexedDB 経由でロードし、walk.vmd を適用して歩行させると、以下のエラーが毎フレーム発生してモデルが一切描画されなくなります。

Uncaught TypeError: Cannot read properties of undefined (reading 'quaternion')
    at GrantSolver.updateOne (MMDAnimationHelper.js:1185:45)
    at GrantSolver.update (MMDAnimationHelper.js:1145:9)
    at MMDAnimationHelper._animateMesh (MMDAnimationHelper.js:562:18)
    at MMDAnimationHelper.update (MMDAnimationHelper.js:160:9)

さらに、歩行アニメーションを止めてトーク(VPDポーズ適用)に入ると、別の経路でも同一エラーが発生しました。

at MMDAnimationHelper.pose (MMDAnimationHelper.js:235:36)
at applyPose (mmd.js)
at startRandomTalk (mmd-talk.js)

他の PMX モデル(lain_v1 等)では全く発生しません。モデル固有の問題です。

2. 初手:PMX バイナリチェックツールの開発

「PMX ファイル自体が壊れている」という仮説のもと、PMX バイナリを直接パースしてバリデーションするチェック&修復ツールを開発しました。

チェック項目

  • ヘッダー / マジック / バージョン検証
  • ボーン親インデックスの範囲外・自己参照
  • 付与親(Grant Parent)のインデックス範囲外・自己参照・循環ループ検出
  • IK ターゲット / リンクの範囲外
  • 頂点のボーンインデックス範囲外
  • マテリアルのテクスチャインデックス・面数合計の整合性
  • 剛体・ジョイントの参照先検証

結果:PMX は clean

YYBMiku.pmx(748ボーン)をチェックした結果、全項目パス。付与親インデックスも全て 0〜747 の有効範囲内でした。

━━ BONES ━━
Count: 748
Grant parents: all valid ✓
Bone parents: all valid ✓
Tail bones: all valid ✓
IK references: all valid ✓
━━ SUMMARY ━━
✓ No issues found — PMX is clean

PMX ファイルに問題は無い。原因は別の層にあります。

3. 真の原因:PMX ボーンインデックスと Three.js ランタイムのズレ

GrantSolver の動作原理

PMX フォーマットのボーンには「付与親(Grant Parent)」という仕組みがあります。あるボーンの回転や移動を、別のボーンの回転・移動に連動させる機能です。髪や衣服の物理ボーンで多用されます。

Three.js の MMDAnimationHelper 内部にある GrantSolver は、毎フレーム付与関係を解決します。

// Three.js MMDAnimationHelper 内部(概念)
updateOne(grant) {
  const bone = this.mesh.skeleton.bones[grant.index];
  const parentBone = this.mesh.skeleton.bones[grant.parentIndex];
  // ↑ parentBone が undefined → .quaternion アクセスでクラッシュ
  bone.quaternion.copy(parentBone.quaternion).multiplyScalar(grant.ratio);
}

なぜ PMX は正しいのにクラッシュするのか

MMDLoader が PMX バイナリを読み込んで Three.js の SkinnedMesh を構築する際、内部的に生成される skeleton.bones 配列の順序・数が、元の PMX のボーンインデックスと一致しないケースがあることが原因でした。

YYBMiku のように 748 ボーンを持つ複雑なモデルでは、付与ボーン 28 個のうち 2 個だけがランタイム配列で存在しないインデックスを指しており、そこで undefined.quaternion のクラッシュが発生していました。

[sanitizeGrants] removed 2 invalid grant reference(s) (total was 28)

他のモデルで問題が起きなかったのは、ボーン数が少ないか付与ボーンを使っていないため、インデックスのズレが発生しなかったからです。

4. 修正:sanitizeGrants によるロード時パッチ

① サニタイズ関数の追加 (mmd.js)

MMDAnimationHelper.add() の直後に、GrantSolver の付与配列を走査して不正参照を除去する関数を挿入しました。

function sanitizeGrants(mesh, helper) {
  if (!mesh || !helper) return;

  try {
    const obj = helper.objects.get(mesh);
    if (!obj?.grantSolver) return;

    const solver = obj.grantSolver;
    const grants = solver._grants ?? solver.grants;
    if (!Array.isArray(grants) || grants.length === 0) return;

    const bones = mesh.skeleton?.bones;
    if (!bones) return;

    let removed = 0;
    for (let i = grants.length - 1; i >= 0; i--) {
      const g = grants[i];
      if (!g) { grants.splice(i, 1); removed++; continue; }

      const parentIdx = g.parentIndex;
      const selfIdx = g.index;

      if (parentIdx == null || parentIdx < 0 || parentIdx >= bones.length || !bones[parentIdx]) {
        grants.splice(i, 1);
        removed++;
      } else if (selfIdx != null && selfIdx === parentIdx) {
        grants.splice(i, 1);
        removed++;
      } else if (selfIdx != null && (selfIdx < 0 || selfIdx >= bones.length || !bones[selfIdx])) {
        grants.splice(i, 1);
        removed++;
      }
    }

    if (removed > 0) {
      console.warn(
        `[sanitizeGrants] removed ${removed} invalid grant reference(s) (total was ${removed + grants.length})`
      );
    }
  } catch (e) {
    console.warn("[sanitizeGrants] error:", e.message);
  }
}

チェック内容は以下の 3 パターンです。

  1. parentIndex が skeleton.bones の範囲外、または参照先が undefined → 除去
  2. 自分自身を付与親として参照(無限ループ) → 除去
  3. 自分自身のボーンインデックスもランタイムに存在しない → 除去

② 呼び出し箇所

MMDAnimationHelper.add() の直後、全てのコードパスで sanitizeGrants を実行します。

// loadActorFromRecord(IndexedDB 読み込み)
if (vmdPath) {
  actorHelper.add(mesh, { animation: vmdAnimation, physics: false });
  sanitizeGrants(mesh, actorHelper);    // ★
  // ...
} else {
  actorHelper.add(mesh, { physics: false });
  sanitizeGrants(mesh, actorHelper);    // ★
}

// loadActor(public 直接読み込み)— 同様

helper.update() の try/catch ガード

sanitizeGrants で除去しきれないケースに備え、毎フレームの helper.update() を try/catch で囲みます。ログは初回のみ出力して無限スパムを防止します。

export function updateMMD(delta) {
  for (const actor of actors) {
    if (actor !== speakingActor) {
      if (actor.vrm) {
        actor.mixer?.update(delta);
        actor.vrm.update(delta);
      } else {
        try {
          actor.helper?.update(delta);
        } catch (e) {
          if (!actor._grantErrorLogged) {
            console.warn(`[updateMMD] helper.update error for "${actor.key}":`, e.message);
            actor._grantErrorLogged = true;
          }
        }
      }
    }
  }
  // ...
}

applyPose() の try/catch ガード

トーク開始時に helper.pose() が呼ばれるルートでも同一の GrantSolver がクラッシュするため、こちらにも try/catch を追加しました。初回エラー時に再度 sanitizeGrants を実行してリトライします。

export function applyPose(name, actor = null, { resetPose = true, ik = true } = {}) {
  const targetActor = actor ?? getPrimaryActor();
  if (!targetActor?.mesh || !targetActor.helper) return;

  const vpd = vpdPoses[name];
  if (!vpd) return;

  try {
    targetActor.helper.pose(targetActor.mesh, vpd, { resetPose, ik });
  } catch (e) {
    if (!targetActor._poseGrantFixed) {
      console.warn(`[applyPose] GrantSolver error, re-sanitizing...`);
      sanitizeGrants(targetActor.mesh, targetActor.helper);
      targetActor._poseGrantFixed = true;
      try {
        targetActor.helper.pose(targetActor.mesh, vpd, { resetPose, ik });
      } catch (e2) {
        console.warn("[applyPose] still failing after re-sanitize:", e2.message);
      }
    }
  }
}

5. 修正後の動作

修正適用後、YYBMiku モデルは歩行アニメーション・トーク(VPD ポーズ + 吹き出し)の両方で正常に動作するようになりました。コンソールには以下のログのみが出力されます。

[sanitizeGrants] removed 2 invalid grant reference(s) (total was 28)

付与関係 28 件中 26 件は有効なまま維持されるため、髪や衣服の連動表現は崩れません。

6. 副産物:PMX Checker ツール

原因調査の過程で開発した PMX バイナリチェック&修復ツールは、Lain Clock とは独立したスタンドアロンのブラウザツールとして公開予定です。

  • PMX 2.0 / 2.1 のフルパース(ヘッダー → 頂点 → 面 → テクスチャ → マテリアル → ボーン → モーフ → 表示枠 → 剛体 → ジョイント)
  • ZIP 内の PMX 自動抽出(Shift_JIS ファイル名対応)
  • 付与親のバイナリパッチ修復 → 修復済み PMX / ZIP のダウンロード

7. まとめ

今回の問題は、PMX ファイル自体には一切のエラーが無いにもかかわらずクラッシュするという、原因特定が難しいケースでした。

  • PMX のボーンインデックスは正しい → PMX チェッカーでは検出不可
  • Three.js の MMDLoader がランタイムで構築する skeleton.bones の並び・数が PMX と一致しない → ここでズレが発生
  • GrantSolver がズレたインデックスで undefined.quaternion を参照 → クラッシュ

対策として、ロード直後にランタイムのボーン配列に対して付与参照を検証・除去する sanitizeGrants を導入し、さらに helper.update()helper.pose() の両方を try/catch で保護する多層防御を実装しました。

ボーン数が少ないモデルでは発生しないため見過ごされやすい問題ですが、YYB 式のような高ボーン数モデルを扱う場合は注意が必要です。