[JavaScript] VRM to MMD — 足IK・Aスタンス・指ボーン生成の実装詳細
はじめに
これはVRM→PMX変換ツールの3本目の記事になる。
1本目
glBパースからPMXバイナリ書き出しまでの基礎
[JavaScript] VRM to MMD(PMX) 変換ツール — glBパースからPMXバイナリ書き出しまで(β版) // PROTOCOL.LAIN
VRMファイルをMMD用PMX形式に変換するブラウザ完結ツールを開発中。glTF Binaryのパース、PMX 2.0バイナリの生成、座標系変換、Humanoidボーンマッピングの実装記録
lain-lab.com2本目
VMDが動かない原因を正常品PMXとのバイナリ比較で特定した記録
[JavaScript] VRM to MMD — VMDアニメーションが動くまでのデバッグ記録 // PROTOCOL.LAIN
VRM→PMX変換ツールのVMDモーション互換を実現するまでの記録。座標系のX反転、足IKボーンの実装、ボーン名マッピングの修正、ローカル軸フラグの追加を、正常品PMXとのバイナリ比較で1つずつ潰した
lain-lab.com2本目で「座標系のX反転」「hips→下半身のマッピング」「足IKの必要性」まで原因を突き止めた。この記事では、実際にVMDモーションが破綻なく動くPMXを出力するための実装の詳細をまとめる。具体的には、足IKボーンの生成、Aスタンス化と手動スキニング、指ボーンの正しいマッピングと指先生成、そして地味に一番ハマった「three-vrmのボーン姿勢リセット」への対策。
足IKボーンの生成
MMDの歩行モーションは足首の位置を足IKで駆動する。足IKボーンが無いとVMDの脚モーション(walk.vmdでは足IKに各81キー)が丸ごと無視され、脚が棒立ちになる。
足IKは4本生成する。左右の足IKと、そのつま先IK。
// 左足IK(親=全ての親, ターゲット=左足首, リンク=左ひざ・左足)
writeIK('左足IK', 'leg_IK_L', vrmBonePos('leftFoot'),
0, // parent: 全ての親
vrmBoneIdx('leftFoot'), // IKターゲット: 左足首
40, 2.0, // 反復回数, 角度制限(rad)
[
{ idx: vrmBoneIdx('leftLowerLeg'), limit: true,
min: [-Math.PI, 0, 0], // 膝はX軸周りに、後ろへは折れない
max: [-0.0087, 0, 0] },
{ idx: vrmBoneIdx('leftUpperLeg'), limit: false }
]
);
// つま先IK(親=足IK, ターゲット=つま先, リンク=足首)
writeIK('左つま先IK', 'toe_IK_L', vrmBonePos('leftToes'),
idxFootIKL, vrmBoneIdx('leftToes'), 3, 4.0,
[ { idx: vrmBoneIdx('leftFoot'), limit: false } ]);
膝の角度制限が肝で、min/max をX軸周りの負の範囲に限定することで、膝が後ろや横に折れずに正しい方向にだけ曲がる。この制限が無いとIKが暴れて脚がねじれる。
IKボーンはフラグの bit 5(0x0020)を立てると、後続にIKパラメータ(ターゲット・反復回数・角度制限・リンク配列)が必要になる。書き忘れるとパーサーがオフセットを見失ってファイルが壊れる。
Aスタンス化 — MMDモーションはAスタンス前提
なぜ必要か
VRMのTポーズ(腕が水平)のまま変換すると、腕のモーションが破綻する。正常品PMXをバイナリ解析すると、腕が水平から40度下がったAスタンスで焼かれていた。
正常品 左腕→左ひじ: 水平から-40度(Aスタンス)
正常品 左腕 localX = [0.766, -0.643, 0] ← cos40°=0.766
MMDの歩行モーションは、この「40度下がった腕」を基準に作られている。walk.vmdの左腕キーはZ軸に-42度の回転で、これは「Aスタンスの腕をさらに下ろす」動き。Tスタンスのモデルに適用すると腕が水平のまま突っ張る。
実装 — ボーンを回転させて手動スキニング
エクスポート時に上腕ボーンを40度回転させ、その姿勢で頂点を焼く。
const bendArm = (humanName, deg) => {
const bone = humanoidMap.get(humanName);
if (!bone) return;
bone.updateWorldMatrix(true, false);
const parentQ = new THREE.Quaternion();
if (bone.parent) bone.parent.getWorldQuaternion(parentQ);
const curWorldQ = new THREE.Quaternion();
bone.getWorldQuaternion(curWorldQ);
// ワールドZ軸周りの回転を、親空間のローカル回転に変換して適用
const worldRot = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(deg));
const targetWorldQ = worldRot.multiply(curWorldQ);
const localQ = parentQ.clone().invert().multiply(targetWorldQ);
bone.quaternion.copy(localQ);
bone.updateMatrixWorld(true);
};
bendArm('leftUpperArm', 40);
bendArm('rightUpperArm', -40);
ここで重要なのが、ボーンを回転させただけでは頂点は追従しないという点。メッシュのworldMatrixで頂点を焼くだけだと、ボーン姿勢が反映されずTポーズのまま出力される。ボーン姿勢を頂点に反映するには、スキニングを適用する必要がある。
手動スキニング — applyBoneTransformの罠
最初はThree.jsの applyBoneTransform を使ったが、メッシュが渦巻き状に崩壊した。
原因は座標変換の二重適用。applyBoneTransform は内部で bindMatrixInverse を掛けてメッシュローカル空間に戻す。その結果にさらに matrixWorld を掛けると、VRMのように bindMatrix ≠ matrixWorld の場合に変換が二重になって崩れる。
解決策は手動スキニング。bindMatrixInverse を経由せず、ワールド空間で直接計算する。
// 各ボーンのスキン行列を事前計算(ワールド空間の変形)
const skinMats = [];
for (let bi = 0; bi < child.skeleton.bones.length; bi++) {
const m = new THREE.Matrix4().multiplyMatrices(
child.skeleton.bones[bi].matrixWorld,
child.skeleton.boneInverses[bi]);
skinMats.push(m);
}
// 頂点ごとにウェイトブレンド
_base.fromBufferAttribute(posAttr, i).applyMatrix4(bindMat); // バインドワールドへ
_acc.set(0, 0, 0);
for (let j = 0; j < 4; j++) {
const w = skinWtAttr.getComponent(i, j);
if (w === 0) continue;
const li = skinIdxAttr.getComponent(i, j);
_t.copy(_base).applyMatrix4(skinMats[li]).multiplyScalar(w);
_acc.add(_t); // ワールド空間の変形後位置
}
skinMat = boneMatrixWorld × boneInverse はワールド空間のスキン変形を表す。これをバインドワールドの頂点に適用すれば、結果はそのままワールド空間になる。bindMatrixInverse を掛けないので二重適用が起きない。
この方式は、ボーンを回転させなければ skinMat が単位行列になり従来と同じ結果になる。つまりAスタンス化しても最悪でメッシュ崩壊はしない、という安全性がある。
three-vrmのボーン姿勢リセット問題
Aスタンス化を実装したら、今度はメッシュとボーンの位置がズレて手がミギー(寄生獣)のように放射状に崩れた。
原因の特定にはまた正常品とのバイナリ比較を使った。自作PMXの腕チェーンの座標を見ると:
NG 左腕 Y=15.93
NG 左ひじ Y=15.93 ← 下がってない
NG 左手首 Y=15.93 ← 下がってない
NG 左人指 Y=16.02
OK 左腕 Y=15.93
OK 左ひじ Y=14.16 ← 階段状に下がる
OK 左手首 Y=12.44
OK 左人指 Y=11.98
メッシュ(手動スキニング)はAスタンスで下がっているのに、ボーン位置はTスタンスのままだった。頂点は下がった位置、ボーンは水平位置で、その間でメッシュが引き裂かれてミギー化していた。
原因はthree-vrmの挙動。bendArm で回転させた後、ボーン位置を書き出す(getWorldPositionを呼ぶ)段階までの間に、three-vrmが内部でボーン姿勢をTスタンスに戻していた。手動スキニングは焼いた瞬間の行列を使うので下がっているが、ボーン書き出しは後からgetWorldPositionを呼ぶのでリセット後のTスタンス値を拾っていた。
対策 — 位置をキャッシュする
Aスタンス適用直後に全ボーンのワールド位置をキャッシュし、以降の書き出しは全てそのキャッシュを参照する。後からgetWorldPositionを呼び直さない。
// Aスタンス適用直後に全ボーンのワールド位置をキャッシュ
const boneWorldPosCache = new Map();
currentModel.traverse(child => {
if (child.isBone) {
const wp = new THREE.Vector3();
child.getWorldPosition(wp);
boneWorldPosCache.set(child, wp);
}
});
const getCachedWorldPos = (bone, target) => {
const c = boneWorldPosCache.get(bone);
if (c) { target.copy(c); return target; }
return bone.getWorldPosition(target); // フォールバック
};
通常ボーン・足IK・指先・ローカル軸・仮想ボーンの全ての位置取得をこのキャッシュ参照に置き換えることで、メッシュとボーンが必ず同じAスタンスで一致するようになった。
指ボーンのマッピングと指先生成
親指の歯抜け問題
変換した手の指が崩れていた。原因は親指のマッピング。@pixiv/three-vrm はVRM 0.xもVRM 1.0命名に正規化する。VRM 1.0の親指は Metacarpal → Proximal → Distal の3関節で、Intermediate が存在しない。
// 誤り(Intermediateはthree-vrmに存在せず親指1が生成されない)
leftThumbProximal: '左親指0', leftThumbIntermediate: '左親指1', leftThumbDistal: '左親指2',
// 正しい
leftThumbMetacarpal: '左親指0', leftThumbProximal: '左親指1', leftThumbDistal: '左親指2',
他の指は Proximal → Intermediate → Distal で、これはVRM 1.0命名でも同じ名前なので変更不要。親指だけが例外だった。
指先ボーンの生成
正常品には各指に「〜先」ボーン(左人指先・左中指先など)があった。指先はウェイトを持たない末端マーカーで、指の最終セグメントの向きを定める。各指のDistalから延長線上に配置する。
for (const [name, en, distalHuman, prevHuman] of activeFingerTips) {
const distalBone = humanoidMap.get(distalHuman);
const dp = new THREE.Vector3();
getCachedWorldPos(distalBone, dp);
const tip = dp.clone();
const prevBone = humanoidMap.get(prevHuman);
if (prevBone) {
const pp = new THREE.Vector3();
getCachedWorldPos(prevBone, pp);
// prev→distal方向に0.8倍延長した位置を指先とする
tip.add(dp.clone().sub(pp).multiplyScalar(0.8));
}
// ...書き出し(parent=対応する指Distal, flag=0x0008 表示のみ)
}
位置取得は先述のキャッシュ経由。これで指先もAスタンスに追従する。
ローカル軸フラグ
腕系ボーン(肩・腕・ひじ・手首)にはローカル軸フラグ(0x0800)を設定する。VMD/VPDの腕回転はボーンのローカル軸基準で解釈されるため。ローカルX軸はボーンの向き(腕→ひじ方向など)、ローカルZ軸はそれに直交する前方ベクトルを使う。
const LOCAL_AXIS_MAP = {
leftUpperArm: ['leftUpperArm', 'leftLowerArm'], // X軸 = 腕→ひじ
leftLowerArm: ['leftLowerArm', 'leftHand'], // X軸 = ひじ→手首
// ...
};
Aスタンス化でボーンが40度下がっているので、キャッシュした位置から計算されるローカルX軸も自動的に40度下方向になり、正常品と一致する。
完成した出力
最終的なボーン構成は正常品PMXとほぼ同等になった。
- 仮想ボーン(全ての親・センター・グルーブ・腰)
- Aスタンスの上半身・腕チェーン+ローカル軸
- 足IK・つま先IK(左右)
- 親指3関節+各指3関節+指先10本
- 髪・スカートなどの非ヒューマノイドボーン
これでVMDモーション(walk.vmd等)とVPDポーズが破綻なく再生できるPMXが、ブラウザだけで出力できるようになった。
まとめ
VMDが動くPMXを出力するために必要だった実装は、足IKの生成、Aスタンス化+手動スキニング、指ボーンの正しいマッピングと指先生成、そしてthree-vrmのリセット対策としての位置キャッシュ。
特にAスタンス化まわりは「ボーンを動かす」「頂点を追従させる」「その状態を書き出しまで保持する」の3つが揃わないと崩れる。手動スキニングの二重適用、three-vrmのボーン姿勢リセットと、罠が連続したが、どれも正常品PMXをバイナリパースして数値で比較することで原因が確定できた。バイナリフォーマットのデバッグは、可視化・数値化のツールを先に用意することが結局の近道だった。
3日がかりだったが、UnityもBlenderも使わず、ブラウザのJavaScriptだけでVMD対応のVRM→PMX変換が実現できた。