[Babylon.js #02] 自作MMDモデルをWebGPUでアニメーション (pmx / vmd converter)

はじめに

過去記事でも何度か実装しているPMMの表示およびアニメーションを「Babylon.js」でやってみたので、その備忘録メモです。

表示自体はネット上のサンプル通りのコードでうまくいくのですが、vmdアニメーション処理が思うようにいかず、以下のgithubで公開されている「vmd converter」を利用させてもらってます。

・pmx converter
・vmd converter

ただ、それでもアニメーションが停止するバグにかなり手間取ったので、その辺りのトラブルTipsが主な記事内容になります。

前回の記事:

1. PMX読み込みの壁:WebGPUは甘くない

前回の記事で、環境構築及び、球体表示する「Hello World」実装まで終了。

次は意気揚々と自作のMMDモデル(.pmx)を読み込もうとしたのですが、ブラウザがクラッシュ。

WebGLでは多少のテクスチャ不備や仕様違反があっても「なんとなく」表示してくれることが多かったのですが、WebGPUは非常に厳格です。

  • シェーダーエラー: テクスチャが不足しているマテリアルがあると、パイプラインの生成自体に失敗する。
  • 非同期の罠: 大容量のPMX解析中に処理が詰まる。

解決策:最適化フォーマット .bpmx への転換

「生のPMX」をWebGPUで扱うのは茨の道だと悟り、Babylon.js用に最適化されたフォーマット .bpmx を採用することにしました。 これは babylon-mmd ライブラリがサポートしている形式で、ロード時間が爆速になり、WebGPU上での安定性も格段に向上します。

// main.ts でのインポート
import "babylon-mmd/esm/Loader/Optimized/bpmxLoader";

// 読み込み部分
const mmdImportResult = await SceneLoader.ImportMeshAsync(
    "",
    "./lain/",
    "lain.bpmx",
    scene
);

2. 「動かない」トラブルシューティング

モデルの読み込みには成功し、画面に lain が表示されました。しかし……動きません。 Tポーズ(初期姿勢)のまま、微動だにしないのです。アニメーションファイル(.bvmd)は読み込んでいるはずなのに、なぜ?

罠:MMDランタイムは「登録」しないと動かない

ここが Babylon.js 初心者(そして私)が必ずハマる落とし穴です。 MmdRuntime を作成しただけでは、Babylon.js の描画ループ(Render Loop)と連携してくれません。物理演算やボーンの更新を行うには、シーンに対して明示的に「登録」を行う必要がありました。

// ランタイムを作成
const mmdRuntime = new MmdRuntime(scene);

// 【最重要】これを忘れると動きません!
// シーンの描画ループにMMDの更新処理を割り込ませる
mmdRuntime.register(scene);

// その後にモデルを作成
const mmdModel = mmdRuntime.createMmdModel(mmdMesh);

この register(scene) というたった1行を追加した瞬間、lain が呼吸を始め、物理演算でスカートがふわりと揺れました。 「動かない時は register を疑え」。これが WebGPU MMD の鉄則です。


3. 15秒の壁(無限ループ問題)

Tポーズを脱出し、軽快に踊り始めた lain。しかし、その喜びも束の間でした。 ダンスが始まって約15秒後(正確にはモーションの尺が終わった瞬間)、唐突にアニメーションが停止してしまったのです。

カメラは動かせるので、フリーズしたわけではありません。まるで「踊り疲れた」かのように、最後のポーズで固まってしまいます。

原因:エンジンの「待ちぼうけ」

デバッグログを仕込んで調査したところ、原因はモーションデータ(.bvmd)の メタデータ不足 にありました。 Babylon.js のエンジン側が、「アニメーションの正しい終了時間」を把握できておらず、データが終わっているのに「まだ続きがあるはずだ」と虚無の時間を再生し続けていたのです。

解決策:全フレームをスキャンして「真の終わり」を教える

データに長さが書いていないなら、自分で数えればいい。 力技ですが、モーションデータを解析して「最後のキーフレーム」を探し出し、正しい時間を上書きするロジックを実装しました。

// BVMDローダーで読み込み
const bvmd = await bvmdLoader.loadAsync("motion", "dance.bvmd");
const anyBvmd = bvmd as any;

// --- フレーム数スキャン ---
let maxFrame = 0;

// ボーンアニメーションの最大フレームを探す
if (Array.isArray(anyBvmd.boneTracks)) {
    for (const track of anyBvmd.boneTracks) {
        if (track.frameNumbers.length > 0) {
            const lastFrame = track.frameNumbers[track.frameNumbers.length - 1];
            if (lastFrame > maxFrame) maxFrame = lastFrame;
        }
    }
}

// モーフ(表情)アニメーションも同様にチェック...
// (省略)

// --- 解決! ---
// スキャンした「本当の長さ」を強制的に書き込む
anyBvmd.frameCount = maxFrame;
anyBvmd.duration = maxFrame;

// これでエンジンが正しくループ位置を理解できる
modelAny.setAnimation("motion", true);

3. 15秒の壁(無限ループ問題)

「動いた!」と喜んだのも束の間。 ダンスが始まって約15秒後、唐突にlainが動きを止めてしまいました。 ブラウザがフリーズしたわけではなく、カメラは動くのに、キャラクターだけが静止するのです。

原因:Duration(長さ)の取得失敗と「待ちぼうけ」

デバッグログを見て判明したのは、モーションファイル(.bvmd)の長さ情報が正しく読み取れていないことでした。 エンジン側が「アニメーションの長さは不明(または0秒)」と誤認したり、逆に「もっと長い」と勘違いして、データが存在しない虚無の時間で「終了待ち」をしていたのです。

解決策:フレーム数をスキャンして補完する

データ任せにするのをやめ、コード側で「本当の終わり」を探しに行くロジックを実装しました。

// モーションデータをロード
const bvmd = await bvmdLoader.loadAsync("motion", "dance.bvmd");
const anyBvmd = bvmd as any;

// ボーンとモーフの全トラックを走査して、最大のフレーム番号を探す
let maxFrame = 0;
if (Array.isArray(anyBvmd.boneTracks)) {
    for (const track of anyBvmd.boneTracks) {
        const lastFrame = track.frameNumbers[track.frameNumbers.length - 1];
        if (lastFrame > maxFrame) maxFrame = lastFrame;
    }
}

// 正しい長さを強制的に書き込む
anyBvmd.frameCount = maxFrame;
anyBvmd.duration = maxFrame;

この「手動スキャン」により、エンジンが正確なループ地点を理解できるようになり、途切れることのない永遠のループが実現しました。

ありがとうございます。セクション3の「無限ループ」の解決、非常に分かりやすくまとまっていますね。コードの抜粋も完璧です。

それでは、最後の仕上げとなる セクション4(ビジュアル) と セクション5(まとめ) を作成しました。 これで記事の技術的な部分と、感動のフィナーレが繋がります。


4. ビジュアルの仕上げ:WebGPUの輝き

動きは完璧になりましたが、デフォルトの見た目ではまだ少し「平面的」です。 せっかく WebGPU を使っているのですから、リッチな表現を追加して lain を魅力的に引き立てましょう。

影(Shadow):透明度を味方につける

MMDモデル、特にアニメ調のキャラクターは、髪の毛や服の裾に「透過テクスチャ」を多用します。 通常の影設定だと、透明な部分も「板」として認識されてしまい、地面に四角い影が落ちてしまいます。これでは興醒めです。

そこで、Babylon.js の ShadowGenerator にある 透明度対応オプション を有効化します。

const shadowGenerator = new ShadowGenerator(1024, dirLight);

// WebGPUならこれを使わない手はない!
// 透明テクスチャを考慮した、柔らかい影を落とす
shadowGenerator.transparencyShadow = true;
shadowGenerator.enableSoftTransparentShadow = true;

// モデル側も影を受け取る設定に
mesh.receiveShadows = true;
shadowGenerator.addShadowCaster(mesh);

これで、髪の毛の細かい束も、スカートのレースも、地面に美しく投影されるようになりました。

発光(Bloom):空間を演出する

仕上げに、ポストプロセス(後処理)エフェクトの Bloom(発光) を追加します。 WebGPU のパワーを使えば、負荷を気にせずリッチな光の表現が可能です。

// レンダリングパイプラインを作成
const pipeline = new DefaultRenderingPipeline("default", true, scene, [camera]);

// Bloomを有効化
pipeline.bloomEnabled = true;
pipeline.bloomThreshold = 0.8; // 輝度0.8以上の明るい部分だけを光らせる
pipeline.bloomWeight = 0.3;    // 光の強さ
pipeline.bloomKernel = 64;     // 光の拡散範囲

これを適用すると、白いエプロンや肌のハイライトがほんのりと輝き、キャラクターの実在感が一気に増します。


5. 完成

数々のエラー、Tポーズでの硬直、そして15秒の壁……。 多くの障害を乗り越え、ついに WebGPU 上で自作モデル「lain」が踊り続けられるようになりました。

  • BPMX: 最適化フォーマットで高速ロード
  • Register: 更新ループへの同期
  • Manual Scan: アニメーション全長の補完

これらが揃って初めて、2026年のブラウザ3D表現は完成します。 実際の動作の様子は、以下の動画でご覧ください。