[JavaScript] VRM to MMD — 足IK・Aスタンス・指ボーン生成の実装詳細

[JavaScript] VRM to MMD — 足IK・Aスタンス・指ボーン生成の実装詳細

はじめに

これはVRM→PMX変換ツールの3本目の記事になる。

1本目

glBパースからPMXバイナリ書き出しまでの基礎

2本目

VMDが動かない原因を正常品PMXとのバイナリ比較で特定した記録

2本目で「座標系の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命名でも同じ名前なので変更不要。親指だけが例外だった。

[JavaScript] VRM to MMD — (指ボーンのマッピングと指先生成)

指先ボーンの生成

正常品には各指に「〜先」ボーン(左人指先・左中指先など)があった。指先はウェイトを持たない末端マーカーで、指の最終セグメントの向きを定める。各指の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が、ブラウザだけで出力できるようになった。

[JavaScript] VRM to MMD — (指ボーンのマッピングと指先生成) [JavaScript] VRM to MMD — (指ボーンのマッピングと指先生成)

まとめ

VMDが動くPMXを出力するために必要だった実装は、足IKの生成、Aスタンス化+手動スキニング、指ボーンの正しいマッピングと指先生成、そしてthree-vrmのリセット対策としての位置キャッシュ。

特にAスタンス化まわりは「ボーンを動かす」「頂点を追従させる」「その状態を書き出しまで保持する」の3つが揃わないと崩れる。手動スキニングの二重適用、three-vrmのボーン姿勢リセットと、罠が連続したが、どれも正常品PMXをバイナリパースして数値で比較することで原因が確定できた。バイナリフォーマットのデバッグは、可視化・数値化のツールを先に用意することが結局の近道だった。

3日がかりだったが、UnityもBlenderも使わず、ブラウザのJavaScriptだけでVMD対応のVRM→PMX変換が実現できた。