[JavaScript] VRM to MMD — VMDアニメーションが動くまでのデバッグ記録
はじめに
前回の記事でVRM→PMX変換のバイナリ書き出しまでを実装し、BlenderやPMXEditorでの表示を確認した。しかしVMDモーションを適用すると「万歳ポーズ」になったり足が動かなかったりと、アニメーション互換に問題があった。
この記事では、正常に動作する外部ツール製PMXと自作PMXをバイナリレベルで比較し、問題を1つずつ特定・修正した記録をまとめる。
[JavaScript] VRM to MMD(PMX) 変換ツール — glBパースからPMXバイナリ書き出しまで(β版) // PROTOCOL.LAIN
VRMファイルをMMD用PMX形式に変換するブラウザ完結ツールを開発中。glTF Binaryのパース、PMX 2.0バイナリの生成、座標系変換、Humanoidボーンマッピングの実装記録
lain-lab.com
方針:推測ではなく実データで比較する
最初の数時間は「バインドポーズの問題では」「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つの差異が見つかった:
-
ローカル軸フラグ:正常品は腕にローカル軸(flag
0x0800)が設定されている。VMD/VPDの腕回転はこのローカル軸基準で適用される -
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をバイナリパースして数値を読む」ことで原因が特定できた。推測で直そうとして失敗した時間の方が、パーサーを書いてから修正が完了するまでの時間より長かった。バイナリフォーマットのデバッグでは、まず可視化ツールを作るべき。