[JavaScript] VRM to MMD(PMX) 変換ツール — glBパースからPMXバイナリ書き出しまで(β版)
はじめに
VRMファイルをMMD用のPMX形式に変換するブラウザ完結ツールを作っている。現時点ではβ版で、BlenderとPMXEditorでの読み込みを確認済み。Three.jsのMMDLoaderでの読み込みにはまだ課題がある。
従来、VRM→PMXの変換にはUnityやVroid2Pmxといったデスクトップアプリが必要だった。このツールはブラウザ内のJavaScriptだけで変換を完結させる。
VRMとPMXの違い
VRMはglTF Binary(.glb)ベースで、PMXはMikuMikuDance独自のバイナリフォーマット。変換にあたって対処が必要な差異がいくつかある。
座標系
VRMは右手座標系(Y-up、-Zが前方)、MMD/PMXは左手座標系(Y-up、+Zが前方)。変換時にZ軸を反転する必要がある。
if (invertZ) {
tmpPos.z = -tmpPos.z;
tmpNorm.z = -tmpNorm.z;
}
単位
VRMはメートル単位で、身長1.5前後の値になる。PMXの単位はMMD独自だが、実質的には同等のスケールで問題ない。変換時のスケール係数は不要だった。
ボーン名
VRMのHumanoidボーン名は英語(leftUpperArm, rightLowerLeg など)で、MMDは日本語(左腕, 右ひざ など)。マッピングテーブルで変換する。
const BONE_NAME_MAP = {
hips: 'センター',
spine: '上半身',
chest: '上半身2',
neck: '首',
head: '頭',
leftUpperArm: '左腕',
leftLowerArm: '左ひじ',
leftHand: '左手首',
// ... 全55ボーン
};
マテリアル
VRMはPBR(物理ベースレンダリング)またはMToonシェーダーを使うが、PMXはToonシェーディング+スフィアマップ。完全な変換は不可能なので、Diffuse/Ambient/Specularに近似値を設定してToonテクスチャを割り当てている。
PMXバイナリの構造
PMX 2.0のバイナリは以下の順序でセクションが並ぶ。
- ヘッダ(
PMX+ バージョン) - グローバル設定(エンコーディング、インデックスサイズ等)
- モデル情報(名前・コメント)
- 頂点データ
- 面インデックス
- テクスチャテーブル
- マテリアル
- ボーン
- モーフ
- 表示枠
- 剛体
- ジョイント
各セクションは「個数(int32)→ データの繰り返し」という構造。インデックスのバイト数はグローバル設定で指定したサイズに従う。
BinaryWriter
動的に拡張するArrayBufferラッパーを使ってバイナリを書き出す。
class BinaryWriter {
constructor(initialSize = 1024 * 1024 * 5) {
this.buffer = new ArrayBuffer(initialSize);
this.view = new DataView(this.buffer);
this.offset = 0;
}
ensureCapacity(size) {
if (this.offset + size > this.buffer.byteLength) {
const newBuffer = new ArrayBuffer(this.buffer.byteLength * 2);
new Uint8Array(newBuffer).set(new Uint8Array(this.buffer));
this.buffer = newBuffer;
this.view = new DataView(this.buffer);
}
}
writeFloat32(val) {
this.ensureCapacity(4);
this.view.setFloat32(this.offset, val, true);
this.offset += 4;
}
// ...
}
文字列エンコーディング
PMX 2.0はUTF-16LE(encoding=0)とUTF-8(encoding=1)の両方をサポートしている。しかし実際には、BlenderのMMD ToolsやThree.jsのMMDParserはUTF-16LEを前提としている場合がある。互換性のためにUTF-16LEで書き出すのが安全。
writeStringTextBuf(str) {
const codeUnits = [];
for (let i = 0; i < str.length; i++) {
codeUnits.push(str.charCodeAt(i));
}
const byteLen = codeUnits.length * 2;
this.writeUint32(byteLen);
for (const code of codeUnits) {
this.view.setUint16(this.offset, code, true);
this.offset += 2;
}
}
最初はUTF-8で実装したが、Three.jsのMMDLoaderでパースエラーが発生したためUTF-16LEに変更した。
頂点データの抽出
Three.jsのシーンにロード済みのVRMから、SkinnedMeshのジオメトリデータを取り出す。
currentModel.traverse(child => {
if (!child.isSkinnedMesh && !child.isMesh) return;
const geo = child.geometry;
const posAttr = geo.attributes.position;
const normAttr = geo.attributes.normal;
const uvAttr = geo.attributes.uv;
const skinIdxAttr = geo.attributes.skinIndex;
const skinWtAttr = geo.attributes.skinWeight;
});
SkinnedMeshの場合、skinIndex はローカルのスケルトン内でのインデックスなので、グローバルなボーンリストへのマッピングが必要。
const localToGlobal = [];
if (child.skeleton) {
for (const bone of child.skeleton.bones) {
const gIdx = boneMap.get(bone);
localToGlobal.push(gIdx !== undefined ? gIdx : 0);
}
}
ウェイトタイプ
PMXの頂点ウェイトには複数のタイプがある。
0: BDEF1(1ボーン)1: BDEF2(2ボーン)2: BDEF4(4ボーン)3: SDEF(球面変形)
VRMは4ボーンウェイトを持つので、BDEF4(タイプ 2)を使う。最初に 3(SDEF)と書いてしまい、SDEFは追加パラメータ(C, R0, R1のVector3)が必要なため、パーサーがオフセットを見失ってファイル全体が壊れた。たった1バイトの違いで全データが読めなくなる。バイナリフォーマットの怖さ。
ボーンフラグ
PMXのボーンにはビットフラグがあり、その値によって後続データの構造が変わる。
- bit 0(0x0001): 接続先の表示方法。
0= オフセットVector3,1= ボーンインデックス - bit 5(0x0020): IKボーン。立っている場合はIKパラメータが後続する
フラグに 0x0001 を立てて「ボーンインデックスで接続」としたのに、実際にはオフセットVector3(12バイト)を書いてしまうと、パーサーが数バイト分ずれて以降のデータが全て壊れる。同様に、IKフラグ 0x0020 を立てたのにIKパラメータを書かないと、同じ問題が起きる。
// 正しい: オフセットモード(bit0 = 0)
writer.writeUint16(0x0008 | 0x0002 | 0x0004); // visible + rotate + translate
writer.writeFloat32(0); // offset X
writer.writeFloat32(0.3); // offset Y
writer.writeFloat32(0); // offset Z
現在の状態
動作確認済み
- Blender(MMD Tools経由)で読み込み・表示可能
- PMXEditorで読み込み・編集可能
- テクスチャの抽出・ZIP同梱は正常動作
- Humanoidボーンのマッピングは動作
未解決
- Three.js MMDLoaderでの読み込み(ヘッダーパースでエラー)
- テクスチャとマテリアルの紐付けが一部ずれる
- ボーンのオフセット方向が一律固定(子ボーン方向に向けるべき)
- モーフ(BlendShape)未対応
- 剛体・ジョイント(物理演算)未対応
- IKデータ未対応
今後
Three.js互換の問題は、MMDParserが期待するバイナリ構造と出力が微妙に異なる部分があるはずで、既存のPMXファイルとバイナリ比較すれば特定できる。優先度としてはモーフ対応やテクスチャ修正の方が高い。
まとめ
PMXバイナリの手書きは、仕様書通りに書いても1バイトのタイプ値ミスやフラグの解釈違いでファイル全体が壊れる繊細な作業だった。デバッグは「ヘッダーからバイトを追って、どこでオフセットがずれたか」を探す地道な作業になる。
ブラウザだけでVRM→PMX変換ができることは確認できた。β版として公開し、フィードバックを得ながら改善していく。