[JavaScript] VRM to MMD — VMDアニメーションが動くまでのデバッグ記録

[JavaScript] VRM to MMD — VMDアニメーションが動くまでのデバッグ記録

はじめに

前回の記事でVRM→PMX変換のバイナリ書き出しまでを実装し、BlenderやPMXEditorでの表示を確認した。しかしVMDモーションを適用すると「万歳ポーズ」になったり足が動かなかったりと、アニメーション互換に問題があった。

この記事では、正常に動作する外部ツール製PMXと自作PMXをバイナリレベルで比較し、問題を1つずつ特定・修正した記録をまとめる。

[JavaScript] VRM to MMD — VMDアニメーションが動くまでのデバッグ記録

方針:推測ではなく実データで比較する

最初の数時間は「バインドポーズの問題では」「Z反転が原因では」と推測ベースで修正していたが、堂々巡りになった。方針を変えて、正常に動くPMX(外部ツールで変換したもの)と自作PMXをPythonでパースし、数値を突き合わせるアプローチに切り替えた。これが正解だった。

PMX解析スクリプト

PMXバイナリを読んで、各ボーンの名前・位置・親・フラグ・ウェイト頂点数を出力するスクリプトを書いた。

# 出力例(抜粋)
# idx name     parent   flag  rot mov vis wverts  pos
#   0 全ての親   ROOT   0x001f  Y   Y   Y      0  [0.00,0.00,0.00]
#   4 下半身       腰   0x001a  Y   -   Y   2052  [-0.00,12.00,-0.20]
#   5 上半身       腰   0x001b  Y   -   Y    996  [0.00,13.41,-0.24]
#  19 左腕      左肩C   0x081b  Y   -   Y    394  [1.36,15.93,0.32]

このスクリプトが今回のデバッグの主力ツールになった。同じものをVMDにも作り、「VMDが動かそうとしてるボーン名」と「PMXに存在するボーン名」を自動照合できるようにした。

問題1:座標系の反転軸が間違っていた

症状

VMDを適用すると腕が「万歳」になる。腕を下ろすモーションのはずが、逆方向に動く。

比較

正常品と自作の同じ頂点・ボーンの座標を並べた:

             正常品               自作
頂点0      [ 0.23, 17.52, -0.60]  [-0.23, 17.52,  0.60]
左腕       [ 1.36, 15.93,  0.32]  [-1.36, 15.93, -0.32]
左目       [ 0.18, 18.10,  0.03]  [-0.18, 18.10, -0.03]

XとZの符号が両方反転していた。 MMDの左手座標系では、キャラの左手が+X、正面が-Z。自作は左手が-X、正面が+Zで、180度ひっくり返った鏡像状態だった。

原因

VRM(右手系)→ PMX(左手系)の変換で Z軸反転 を実装していたが、正しくは X軸反転 だった。

VRMの右手系ではキャラの左手が-X方向。これをMMDの左手系に変換するには、X座標の符号を反転させて左手を+X方向にする。Z反転だとXが逆のままで、鏡像になる。

修正

// 修正前(Z反転 → 鏡像になる)
if (invertZ) {
  tmpPos.z = -tmpPos.z;
  tmpNorm.z = -tmpNorm.z;
}

// 修正後(X反転 → 正常品と一致)
if (invertZ) {
  tmpPos.x = -tmpPos.x;
  tmpNorm.x = -tmpNorm.x;
}

X反転は鏡像変換なので、三角形の巻き順(ワインディングオーダー)も反転しないと面が裏返る:

if (invertZ) {
  // X反転(鏡像)時は三角形の巻き順を反転して表裏を保つ
  for (let i = start; i < start + count; i += 3) {
    const a = (indexAttr ? indexAttr.getX(i)     : i)     + vertexOffset;
    const b = (indexAttr ? indexAttr.getX(i + 1) : i + 1) + vertexOffset;
    const c = (indexAttr ? indexAttr.getX(i + 2) : i + 2) + vertexOffset;
    allIndices.push(a, c, b); // b,cを入れ替え
  }
}

ボーン位置の反転も同様にZ→Xに変更。これで万歳は直った。

検算

修正後の自作PMXをパースして、正常品と座標が一致することを確認:

自作(修正後)  [ 0.23, 17.52, -0.60]
正常品        [ 0.23, 17.52, -0.60]  ← 完全一致

問題2:hips → センター のマッピングが間違っていた

原因

VRMの hips を MMDの センター にマッピングしていた。しかしMMDの構造では:

  • センター = 全身移動用の仮想ボーン(頂点ウェイトを持たない)
  • 下半身 = 腰まわりの頂点が実際に乗るボーン

つまりVMDが「下半身」を回転させても、腰の頂点はhips(=センター扱い)にウェイトされていて動かない。

修正

// 修正前
const BONE_NAME_MAP = {
  hips: 'センター',
  // ...
};

// 修正後
const BONE_NAME_MAP = {
  hips: '下半身',
  // ...
};

仮想ボーン(全ての親・センター・グルーブ・腰)はウェイトを持たない移動用ボーンとして別途生成し、下半身(= VRM hips)がウェイトを持つ実体ボーンとして機能する構造にした。

全ての親(仮想・原点)
└ センター(仮想・移動用)
  └ グルーブ(仮想)
    └ 腰(仮想・hips位置)
      ├ 上半身 ← VRM spine
      └ 下半身 ← VRM hips(腰の頂点ウェイトはここ)
              ├ 左足 ← VRM leftUpperLeg
              └ 右足 ← VRM rightUpperLeg

問題3:足IKボーンが存在しなかった

症状

座標系を修正して万歳は直ったが、足が棒立ちのまま動かない。

VMD解析で原因特定

walk.vmdをパースして、アニメーションが動かそうとしているボーン名と自作PMXのボーンを照合した。

VMDのボーン名          キー数   PMXに存在
──────────────────────────────────────
右足IK                 81    MISSING ← 本命
左足IK                 81    MISSING ← 本命
センター                 65    OK
下半身                   65    OK
首                       33    OK
左腕                     33    OK
右腕                     33    OK

足IKが存在しない。 MMDの歩行モーションは足首の位置をIKで指定し、膝・股関節が自動追従する仕組み。IKボーンが無いと、81キー分の脚モーションが丸ごと無視される。

IKボーンの実装

4本のIKボーンを追加した:

// 左足IK(親=全ての親, ターゲット=左足首, リンク=左ひざ・左足)
writeIK('左足IK', 'leg_IK_L', vrmBonePos('leftFoot'),
  0,                            // parent: 全ての親
  vrmBoneIdx('leftFoot'),       // target: 左足首
  40,                           // iterations
  2.0,                          // angle limit (rad)
  [
    { idx: vrmBoneIdx('leftLowerLeg'), limit: true,
      min: [-Math.PI, 0, 0],    // 膝は後ろに折れない
      max: [-0.0087, 0, 0] },
    { idx: vrmBoneIdx('leftUpperLeg'), limit: false }
  ]
);

PMXのIKボーンはフラグの bit 5(0x0020)を立てると、後続にIKパラメータが必要になる:

[target bone index]    // IKのターゲット
[loop count: int32]    // 反復回数
[angle limit: float32] // 1回の反復の角度制限(ラジアン)
[link count: int32]    // リンク(関節チェーン)の数
  [bone index]         // リンクボーン
  [has limit: uint8]   // 角度制限の有無
    [min xyz: float32×3] // 制限あり時のみ
    [max xyz: float32×3]

膝の角度制限(X軸のみ、-0.0087)が重要で、これがないと膝が前後左右どこにでも折れてIKが暴れる。

結果

足IK追加後、walk.vmdで脚が動くようになった。上半身の腕も動いており、歩行モーションとして成立した。

問題4:ローカル軸とAスタンス

症状

歩行はするが、腕のポーズがおかしい。腕が突っ張ったり変な角度になる。

正常品のボーン解析

腕まわりのボーンを詳細に比較した:

              正常品                     自作
左腕pos     [1.36, 15.93, 0.32]      [-1.36, 15.93, -0.32]
左ひじpos   [3.46, 14.16, 0.32]      [-4.11, 15.93, -0.32]
             → 水平から-40度          → 水平
左腕flag    0x081b (ローカル軸あり)    0x001e (なし)
左腕localX  [0.766, -0.643, 0.000]    -

2つの差異が見つかった:

  1. ローカル軸フラグ:正常品は腕にローカル軸(flag 0x0800)が設定されている。VMD/VPDの腕回転はこのローカル軸基準で適用される

  2. Aスタンス vs Tスタンス:正常品の腕は水平から約40度下がった状態(Aスタンス)で焼かれている。自作はTスタンス(腕が真横に水平)

walk.vmdの腕の回転値を見ると、Z軸に-42度の回転が入っている。これは「Aスタンスの腕をさらに下ろす」動きで、Aスタンスのローカル軸前提で作られている。

ローカル軸は実装したが、Aスタンス化はメッシュのスキニング焼きが必要で、Three.jsの applyBoneTransform を使った方法ではメッシュが崩壊した。ボーン位置のみAスタンスで焼き、頂点はTポーズのまま出す方式が正しいアプローチだと分かったが、これは今後の課題。

ローカル軸の実装

ボーン方向をローカルX軸、それに直交する前方ベクトルをローカルZ軸として設定:

const LOCAL_AXIS_MAP = {
  leftShoulder:  ['leftShoulder', 'leftUpperArm'],
  leftUpperArm:  ['leftUpperArm', 'leftLowerArm'],
  leftLowerArm:  ['leftLowerArm', 'leftHand'],
  // ...
};

const computeDir = (fromHuman, toHuman) => {
  const a = humanoidMap.get(fromHuman);
  const b = humanoidMap.get(toHuman);
  const pa = new THREE.Vector3(), pb = new THREE.Vector3();
  a.getWorldPosition(pa); b.getWorldPosition(pb);
  return pb.sub(pa).normalize();
};

フラグに 0x0800 を立てると、ローカル軸データ(localX: float32×3, localZ: float32×3)が後続する。書き忘れるとボーンフラグ問題と同じくオフセットがずれてファイルが壊れる。

今回の教訓

推測ベースのデバッグは遠回り

「たぶんバインドポーズが原因」「Z反転のせいでは」と仮説を立てて修正する方法は、バイナリフォーマットの問題には効率が悪い。正常品のバイナリをパースして数値を比較する方が圧倒的に速い。座標系の問題は、パースした瞬間に符号の不一致が見えて原因が確定した。

VMD照合スクリプトは必須

「VMDが動かそうとしているボーン名」と「PMXに存在するボーン名」を突き合わせるスクリプトは、足IK問題を一瞬で特定した。VMDファイル自体は非常にシンプルな構造(ボーン名15バイト + フレーム番号 + 位置 + クォータニオン + 補間パラメータ)なので、パーサーは短く書ける。

PMXのボーンフラグは地雷原

フラグの各ビットが後続データの有無を制御するため、フラグと実際に書いたデータが不整合を起こすと、それ以降の全データが壊れる。今回だけでも:

  • 0x0001(接続先): 0=12バイト(offset), 1=インデックス
  • 0x0020(IK): 立てたらIKパラメータ必須
  • 0x0800(ローカル軸): 立てたら24バイト(localX+Z)必須
  • 0x0100/0x0200(付与): 立てたらインデックス+float必須

1つでもずれるとファイル全体が読めなくなる。

現在の状態

動作確認済み

  • VMDモーション(walk.vmd等)で歩行アニメーションが動作
  • VPDポーズの読み込み・適用
  • 足IK(左右足IK + つま先IK)
  • 仮想ボーン階層(全ての親 → センター → グルーブ → 腰)
  • 腕系ボーンのローカル軸フラグ
  • X軸反転による正しい座標系変換

未解決

  • Aスタンス化(腕のポーズ精度向上に必要)
  • 腕捩り・手捩りボーン
  • 肩P/Cの補助ボーン
  • 足D・足先EX
  • テクスチャが黒い問題(MToonマテリアルのテクスチャ取得)
  • モーフ・表情
  • 剛体・ジョイント(物理演算)

ツール

まとめ

VMDアニメーションが正常に動くまでに必要だった修正は4つ:座標系のX反転、hips→下半身のマッピング修正、足IKボーンの実装、ローカル軸フラグの追加。どれも「正常品PMXをバイナリパースして数値を読む」ことで原因が特定できた。推測で直そうとして失敗した時間の方が、パーサーを書いてから修正が完了するまでの時間より長かった。バイナリフォーマットのデバッグでは、まず可視化ツールを作るべき。