メンバーの趣味 Nutmeg advent calendar 2024

Babylon.jsでMMDに挑戦してみた。

Babylon.jsでMMDに挑戦してみた。

はじめに

こんにちは!NUTMEGの「やま」です。アドベントカレンダー24日目を担当します。

JavaScriptのWebGLライブラリといえばThree.jsが有名ですが、Microsoft社が開発しているBabylon.jsも活発に開発されています。

3D制作やゲーム開発にはUnityが有名どころですが、Web開発であるとThree.jsやBabylon.jsは開発環境の構築やデプロイが手軽なところが良いですね。

久しぶりにMMDを触っていたこともあり、追加のソフトウェアなしで実現できるWebアプリケーションとしてのMMDビューワーの実装に取り組んでみました。Babylon.jsでMMDモデルやモーションを動作させることについてはbabylon-mmdというライブラリが存在しており、ドキュメントも整備されているため比較的容易に実装することができました。

ドキュメントやサンプル例を参考にしながら作成した、初学者の記録として見ていただければ幸いです。MMDモデルが表示され、音楽に合わせて踊らせることができるところまでの過程を紹介していこうと思います。

今回作成したリポジトリは以下からアクセスできます。 test-babylon-mmd

1. 環境構築

今回使用した主要な技術は以下の通りです。

  • Vite: 高速なビルドツールと開発サーバー。
  • TypeScript: 型安全な JavaScript
  • Biome: コードのフォーマットとリンティングのためのツール。
  • Babylon.js: 強力な 3D レンダリングエンジン。
  • babylon-mmd: Babylon.js で MMD モデルを読み込み、表示するためのライブラリ。

プロジェクトのセットアップ

Bunを使用していますがnpmなどお好みのランタイムで大丈夫です。

テンプレートの作成

  1. Bun + Viteのテンプレートを作成します。

    bun create vite [your-project-name]
    

    実行結果

    ✔ Select a framework: › Vanilla
    ✔ Select a variant: › TypeScript
    
    Scaffolding project in /home/your-project-name
    
    Done. Now run:
    
      cd your-project-name
      bun install
      bun run dev
    
  2. 続けて指示通りプロジェクトのあるディレクトリに移動してコマンドを実行します。

      cd your-project-name
      bun install
    

Biomeのインストール

  1. Biomeのセットアップを行います。

    bun add --dev --exact @biomejs/biome
    bunx biome init
    

    biome.jsonはお好みで設定してください。

    設定例

    {
        "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
        "vcs": {
            "enabled": false,
            "clientKind": "git",
            "useIgnoreFile": false
        },
        "files": {
            "ignoreUnknown": false,
            "ignore": []
        },
        "formatter": {
            "enabled": true,
            "indentStyle": "tab"
        },
        "organizeImports": {
            "enabled": true
        },
        "linter": {
            "enabled": true,
            "rules": {
                "recommended": true
            }
        },
        "javascript": {
            "formatter": {
                "quoteStyle": "double"
            }
        }
    }
    

Babylon.jsなどの依存関係をインストール

  1. Babylon.jsとbabylon-mmdに必要なライブラリを追加します。

    bun add @babylonjs/core @babylonjs/havok @babylonjs/inspector @babylonjs/materials babylon-mmd
    

    実行結果

     bun add @babylonjs/core @babylonjs/havok @babylonjs/inspector @babylonjs/materials babylon-mmd
    bun add v1.1.0 (5903a614)
    
     installed @babylonjs/[email protected]
     installed @babylonjs/[email protected]
     installed @babylonjs/[email protected]
     installed @babylonjs/[email protected]
     installed [email protected]
    
     18 packages installed [10.18s]
    

Viteの設定

  1. vite.config.tsを作成し、MMDのロードをサポートするための設定を記載します。

    import { defineConfig } from 'vite';
    
    export default defineConfig({
      server: {
        headers: {
          "*.wasm": {
            "Content-Type": "application/wasm"
          }
        }
      },
      optimizeDeps: {
        exclude: ['babylon-mmd']
      }
    });
    

2. MMD関連のアセットの準備

  1. モデルデータの準備 MMDではPMXファイルを用いてMMDモデルをインポートしますが、babylon-mmdではPMXを更に最適化したBPMXファイルを使用することができます。 PMXファイルの変換についてはbabylon-mmdの作者様が変換ページを公開していますので以下のリンクから変換してください。

    PMX to BPMX Converter

  2. モーションデータの準備 同様に、babylon-mmdではVMDを更に最適化したBVMDファイルを使用できます。 babylon-mmdの作者様が変換ページを公開していますので以下のリンクから変換してください。

    VMD to BVMD Converter

babylon-mmdでは通常のPMX・VMDファイルもサポートしています。

  1. 音声ファイルの準備 お好みの楽曲等の音源を用意してください。

Babylon.jsでサポートされているサウンド形式は、.mp3、.ogg、.wav、.m4a、.mp4です。

3. Babylon.jsの基本的な設定

まずは、Babylon.jsで基本的なシーンを構築します。

  1. エンジンの初期化 src/main.tsを作成し編集します。

    import { Engine } from "@babylonjs/core/Engines/engine";
    import { createBaseRuntime } from "./baseRuntime";
    import { buildScene } from "./sceneBuilder";
    
    async function initializeEngine() {
      const canvas = document.createElement("canvas");
      canvas.id = "renderCanvas";
      document.body.appendChild(canvas);
    
      const engine = new Engine(canvas, true, {
        preserveDrawingBuffer: false,
        stencil: false,
        antialias: true,
        alpha: false,
        powerPreference: "high-performance",
      });
    
      const runtime = await createBaseRuntime({
        canvas,
        engine,
        sceneBuilder: { build: buildScene },
      });
    
      runtime.run();
    
      window.addEventListener("resize", () => {
        engine.resize();
      });
    }
    
    window.addEventListener("DOMContentLoaded", () => {
      initializeEngine().catch(console.error);
    });
    
  2. ランタイムの作成 src/baseRuntime.tsを作成し編集します。

    import type { AbstractEngine } from "@babylonjs/core/Engines/abstractEngine";
    import type { Scene } from "@babylonjs/core/scene";
    
    export interface ISceneBuilder {
      build(
        canvas: HTMLCanvasElement,
        engine: AbstractEngine,
      ): Scene | Promise<Scene>;
    }
    
    export interface BaseRuntimeInitParams {
      canvas: HTMLCanvasElement;
      engine: AbstractEngine;
      sceneBuilder: ISceneBuilder;
    }
    
    interface BaseRuntime {
      run(): void;
      dispose(): void;
    }
    
    export async function createBaseRuntime(
      params: BaseRuntimeInitParams,
    ): Promise<BaseRuntime> {
      const { canvas, engine, sceneBuilder } = params;
      const scene = await sceneBuilder.build(canvas, engine);
    
      const onResize = (): void => {
        engine.resize();
      };
    
      const onTick = (): void => {
        if (scene) {
          scene.render();
        }
      };
    
      const run = (): void => {
        window.addEventListener("resize", onResize);
        engine.runRenderLoop(onTick);
      };
    
      const dispose = (): void => {
        window.removeEventListener("resize", onResize);
        engine.dispose();
      };
    
      return {
        run,
        dispose,
      };
    }
    

4. シーンの構築

4-1. エンジンの初期設定

src/sceneBuilder.tsを作成し、エンジンの初期設定を行います。ここでは、MMDモデルのスキニング変形を正しく処理するためのSDEFインジェクタの設定と、テクスチャローダーの登録を行います。

import { AbstractEngine } from "@babylonjs/core";
import { SdefInjector, registerDxBmpTextureLoader } from "babylon-mmd";

// エンジンの初期設定
const initializeEngine = (engine: AbstractEngine): void => {
    SdefInjector.OverrideEngineCreateEffect(engine);
    registerDxBmpTextureLoader();
};

initializeEngine 関数では、SdefInjectorでスキニング変形を処理し、registerDxBmpTextureLoaderでテクスチャローダーを登録します。

4-2. シーンの基本設定

次に、シーンの背景色とアンビエントカラーを設定します。

import { Color3, Color4, Scene } from "@babylonjs/core";

// シーンの基本設定を行う
const setupScene = (scene: Scene): void => {
    scene.clearColor = new Color4(0.95, 0.95, 0.95, 1.0);
    scene.ambientColor = new Color3(0.5, 0.5, 0.5);
};

setupScene 関数では、scene.clearColor で背景色を、scene.ambientColor でアンビエントカラーを定義します。

4-3. MMDルートノードの作成

MMDモデルとカメラを整理するために、ルートとなるトランスフォームノードを作成します。

import { Scene, TransformNode } from "@babylonjs/core";

// MMDのルートノードを作成する
const createMmdRoot = (scene: Scene): TransformNode => {
    const mmdRoot = new TransformNode("mmdRoot", scene);
    mmdRoot.position.z = 20;
    return mmdRoot;
};

createMmdRoot 関数でmmdRoot という名前の TransformNode を作成し、MMDモデルとカメラの親ノードとします。

4-4. MMDカメラの作成

MMDモデルを映すためのカメラを作成し、基本的な設定を行います。

import { Scene, TransformNode, Vector3, MmdCamera } from "@babylonjs/core";

// MMDのカメラを作成する
const createMmdCamera = (
    scene: Scene,
    canvas: HTMLCanvasElement,
    mmdRoot: TransformNode
): MmdCamera => {
    const mmdCamera = new MmdCamera("mmdCamera", new Vector3(0, 10, 0), scene);
    mmdCamera.maxZ = 300;
    mmdCamera.minZ = 1;
    mmdCamera.parent = mmdRoot;
    mmdCamera.attachControl(canvas, false);
    mmdCamera.inertia = 0.8;
    return mmdCamera;
};

createMmdCamera 関数でMmdCameraを作成し、位置や描画範囲、操作や動きの慣性などを設定します。

5. ライティングとシャドウの設定

シーンにライティングとシャドウを追加して、よりリアルな見た目を作成します。

5-1. ディレクショナルライトの作成

シーン全体を照らすディレクショナルライトを作成します。

import { DirectionalLight, Scene, Vector3 } from "@babylonjs/core";

// ディレクショナルライトを作成する
const createDirectionalLight = (scene: Scene): DirectionalLight => {
    const directionalLight = new DirectionalLight(
        "DirectionalLight",
        new Vector3(0.5, -1, 1),
        scene
    );
    directionalLight.intensity = 1.0;
    directionalLight.autoCalcShadowZBounds = false;
    directionalLight.autoUpdateExtends = false;
    return directionalLight;
};

createDirectionalLight 関数でDirectionalLightを作成し、方向や明るさを設定します。

5-2. シャドウジェネレーターの作成

影を生成するためのシャドウジェネレーターを作成し、設定します。

import { DirectionalLight, ShadowGenerator } from "@babylonjs/core";

// シャドウジェネレーターを作成する
const createShadowGenerator = (
    directionalLight: DirectionalLight
): ShadowGenerator => {
    const shadowGenerator = new ShadowGenerator(4096, directionalLight, true);
    shadowGenerator.usePoissonSampling = true;
    shadowGenerator.useBlurExponentialShadowMap = true;
    shadowGenerator.usePercentageCloserFiltering = true;
    shadowGenerator.transparencyShadow = true;
    shadowGenerator.forceBackFacesOnly = true;
    shadowGenerator.frustumEdgeFalloff = 0.1;
    return shadowGenerator;
};

createShadowGenerator 関数でShadowGeneratorを作成し、シャドウの品質や計算方法を調整します。

5-3. 地面の作成

影を受けるための地面を作成します。

import {
    CreateGround,
    DirectionalLight,
    Scene,
    TransformNode,
} from "@babylonjs/core";
import { ShadowOnlyMaterial } from "@babylonjs/materials";

// 地面を作成する
const createGround = (
    scene: Scene,
    directionalLight: DirectionalLight,
    mmdRoot: TransformNode
): TransformNode => {
    const ground = CreateGround(
        "ground1",
        { width: 100, height: 100, subdivisions: 2, updatable: false },
        scene
    );
    const shadowOnlyMaterial = new ShadowOnlyMaterial("shadowOnly", scene);
    ground.material = shadowOnlyMaterial;
    shadowOnlyMaterial.activeLight = directionalLight;
    shadowOnlyMaterial.alpha = 0.4;
    ground.receiveShadows = true;
    ground.parent = mmdRoot;
    return ground;
};

createGround 関数で地面を作成し、影を受けるように設定します。

6. MMDモデルの表示

MMDモデルをシーンにロードして表示します。

6-1. アセットの並行ロード

MMDモデル、モーション、カメラモーションを並行してロードします。

import { Scene, SceneLoader, TransformNode, loadAssetContainerAsync } from "@babylonjs/core";
import type { MmdAnimation, MmdMesh, MmdWasmInstance } from "babylon-mmd";
import { BpmxLoader, BvmdLoader, MmdStandardMaterialBuilder, getMmdWasmInstance, MmdWasmInstanceTypeSPR } from "babylon-mmd";

// ロードするファイルのパスを定数として定義
const MOTION_FILE_PATH = "/gimme_gimme_motion.bvmd";
const CAMERA_MOTION_FILE_PATH = "/GimmeGimmeC.bvmd";
const MODEL_FILE_PATH = "/sour_miku_black.bpmx";

// アセットを並行してロード
const loadAssets = async (
    scene: Scene,
    _mmdRoot: TransformNode
): Promise<[MmdWasmInstance, MmdAnimation, MmdAnimation, MmdMesh]> => {
    const materialBuilder = new MmdStandardMaterialBuilder();
    const bvmdLoader = new BvmdLoader(scene);
    bvmdLoader.loggingEnabled = true;
    SceneLoader.RegisterPlugin(new BpmxLoader());

    const loadingTexts: string[] = [];
    const updateLoadingText = (
        engine: AbstractEngine,
        index: number,
        text: string
    ): void => {
        loadingTexts[index] = text;
        engine.loadingUIText = `<br/><br/><br/><br/>${loadingTexts.join(
            "<br/><br/>"
        )}`;
    };

    return Promise.all([
        getMmdWasmInstance(new MmdWasmInstanceTypeSPR()),
        bvmdLoader.loadAsync("motion", MOTION_FILE_PATH, (event) =>
            updateLoadingText(
                scene.getEngine(),
                0,
                `モーションを読み込み中... ${event.loaded}/${
                    event.total
                } (${Math.floor((event.loaded * 100) / event.total)}%)`
            )
        ),
        bvmdLoader.loadAsync("cameraMotion", CAMERA_MOTION_FILE_PATH, (event) =>
            updateLoadingText(
                scene.getEngine(),
                1,
                `カメラモーションを読み込み中... ${event.loaded}/${
                    event.total
                } (${Math.floor((event.loaded * 100) / event.total)}%)`
            )
        ),
        loadAssetContainerAsync(MODEL_FILE_PATH, scene, {
            onProgress: (event) =>
                updateLoadingText(
                    scene.getEngine(),
                    2,
                    `モデルを読み込み中... ${event.loaded}/${event.total} (${Math.floor(
                        (event.loaded * 100) / event.total
                    )}%)`
                ),
            pluginOptions: {
                mmdmodel: {
                    loggingEnabled: true,
                    materialBuilder: materialBuilder,
                },
            },
        }).then((result) => {
            result.addAllToScene();
            return result.rootNodes[0] as MmdMesh;
        }),
    ]);
};

loadAssets 関数でPromise.allを使用して、MMDモデル、モーション、カメラモーションを並行してロードし、ロード進行状況をユーザーに表示します。

7. モーションとカメラの適用

ロードしたモーションとカメラモーションをMMDモデルとカメラに適用します。

7-1. MMDランタイムの設定

MMDモデルのアニメーションを制御するためのMMDランタイムを設定します。

import {
    Scene,
    TransformNode,
    DirectionalLight,
} from "@babylonjs/core";
import type { MmdAnimation, MmdMesh, MmdWasmInstance } from "babylon-mmd";
import {
    MmdCamera,
    MmdPlayerControl,
    MmdWasmAnimation,
    MmdWasmPhysics,
    MmdWasmRuntime,
    StreamAudioPlayer,
} from "babylon-mmd";

// MMDランタイムを設定する
const setupMmdRuntime = (
    scene: Scene,
    wasmInstance: MmdWasmInstance,
    mmdAnimation: MmdAnimation,
    cameraAnimation: MmdAnimation,
    modelMesh: MmdMesh,
    mmdRoot: TransformNode,
    mmdCamera: MmdCamera,
    audioPlayer: StreamAudioPlayer,
    directionalLight: DirectionalLight
): void => {
    const mmdRuntime = new MmdWasmRuntime(
        wasmInstance,
        scene,
        new MmdWasmPhysics(scene)
    );
    mmdRuntime.loggingEnabled = true;
    mmdRuntime.register(scene);

    mmdRuntime.setAudioPlayer(audioPlayer);
    mmdRuntime.playAnimation();

    const mmdPlayerControl = new MmdPlayerControl(scene, mmdRuntime, audioPlayer);
    mmdPlayerControl.showPlayerControl();

    mmdRuntime.setCamera(mmdCamera);

    const mmdWasmAnimation = new MmdWasmAnimation(
        mmdAnimation,
        wasmInstance,
        scene
    );
    const cameraWasmAnimation = new MmdWasmAnimation(
        cameraAnimation,
        wasmInstance,
        scene
    );

    mmdCamera.addAnimation(cameraWasmAnimation);
    mmdCamera.setAnimation("cameraMotion");

    modelMesh.parent = mmdRoot;

    for (const mesh of modelMesh.metadata.meshes) mesh.receiveShadows = true;
    const shadowGenerator = createShadowGenerator(directionalLight);
    shadowGenerator.addShadowCaster(modelMesh);

    const mmdModel = mmdRuntime.createMmdModel(modelMesh);
    mmdModel.addAnimation(mmdWasmAnimation);
    mmdModel.setAnimation("motion");

    mmdRuntime.physics?.createGroundModel?.([0]);

    optimizeScene(scene);
};

setupMmdRuntime 関数でMmdWasmRuntimeを初期化し、モーションとカメラモーションを適用、MmdPlayerControlでアニメーションの制御を可能にします。

8. VR対応

シーンをVRに対応させます。

8-1. XRエクスペリエンスの設定

VRエクスペリエンスを作成し、基本的な設定を行います。

import { Scene, TransformNode, WebXRDefaultExperience, WebXRFeatureName, WebXRState, Vector3 } from "@babylonjs/core";

// XRエクスペリエンスを設定する
const setupXRExperience = async (
    scene: Scene,
    ground: TransformNode,
    mmdCamera: MmdCamera
): Promise<WebXRDefaultExperience> => {
    const xr = await WebXRDefaultExperience.CreateAsync(scene, {
        uiOptions: {
            sessionMode: "immersive-vr",
            referenceSpaceType: "local-floor",
        },
        disableDefaultUI: true,
        disableTeleportation: true,
    });

    // カメラのルートノードを作成し、カメラの親に設定
    const cameraRoot = new TransformNode("cameraRoot", scene);
    xr.baseExperience.camera.parent = cameraRoot;

    const featuresManager = xr.baseExperience.featuresManager;
    featuresManager.enableFeature(WebXRFeatureName.POINTER_SELECTION, "stable", {
        xrInput: xr.input,
        enablePointerSelectionOnAllControllers: true,
    });

    featuresManager.enableFeature(WebXRFeatureName.TELEPORTATION, "stable", {
        xrInput: xr.input,
        floorMeshes: [ground],
        defaultTargetMeshOptions: {
            teleportationRadius: 2,
            torusArrowMaterial: null,
        },
        useMainComponentOnly: true,
        snapPositions: [new Vector3(2.4 * 3.5 * 1, 0, -10 * 1)],
    });

    xr.input.onControllerAddedObservable.add((controller) => {
        controller.onMotionControllerInitObservable.add((motionController) => {
            const thumbstick = motionController.getComponent(
                "xr-standard-thumbstick"
            );
            if (thumbstick) {
                if (motionController.handedness === "right") {
                    // 移動操作
                    thumbstick.onAxisValueChangedObservable.add((axes) => {
                        if (xr.baseExperience.state === WebXRState.IN_XR) {
                            const forward = xr.baseExperience.camera.getDirection(
                                Vector3.Backward()
                            );
                            forward.y = 0;
                            forward.normalize();

                            const right = xr.baseExperience.camera.getDirection(
                                Vector3.Right()
                            );
                            right.y = 0;
                            right.normalize();

                            const movement = forward
                                .scale(axes.y * 0.1)
                                .add(right.scale(axes.x * 0.1));

                            cameraRoot.position.addInPlace(movement);
                        }
                    });
                } else if (motionController.handedness === "left") {
                    // 視点の回転操作
                    thumbstick.onAxisValueChangedObservable.add((axes) => {
                        if (xr.baseExperience.state === WebXRState.IN_XR) {
                            const rotationSpeed = 0.05;
                            cameraRoot.rotation.y -= axes.x * rotationSpeed;
                            cameraRoot.rotation.x -= axes.y * rotationSpeed;
                            // 角度を制限
                            cameraRoot.rotation.x = Math.max(
                                -Math.PI / 2,
                                Math.min(Math.PI / 2, cameraRoot.rotation.x)
                            );
                        }
                    });
                }
            }
        });
    });

    xr.input.onControllerAddedObservable.add((controller) => {
        controller.onMotionControllerInitObservable.add((motionController) => {
            const componentIds = motionController.getComponentIds();
            for (const id of componentIds) {
                const component = motionController.getComponent(id);
                if (component && component.type !== "thumbstick") {
                    component.onButtonStateChangedObservable.add(() => {
                        if (component.pressed) {
                            xr.baseExperience.exitXRAsync();
                        }
                    });
                }
            }
        });
    });

    xr.baseExperience.onStateChangedObservable.add((state) => {
        if (state === WebXRState.NOT_IN_XR) {
            const defaultPipeline = scene.postProcessRenderPipelineManager
                .supportedPipelines[0] as DefaultRenderingPipeline;
            defaultPipeline.fxaaEnabled = true;
            defaultPipeline.chromaticAberrationEnabled = true;

            const enterVrButton = document.getElementById("enterVrButton");
            if (enterVrButton) {
                enterVrButton.style.display = "block";
            }

            scene.activeCamera = mmdCamera;
        }
    });

    return xr;
};

setupXRExperience 関数でWebXRDefaultExperienceを作成し、VRモードを有効にします。コントローラーの入力を検知し、移動や視点変更を実装します。

8-2. VRモードに入るボタンの設定

ユーザーがVRモードに入るためのボタンを作成し、設定します。

import { Scene, WebXRDefaultExperience, DefaultRenderingPipeline } from "@babylonjs/core";

// VRモードに入るボタンを設定する
const setupEnterVrButton = (scene: Scene, xr: WebXRDefaultExperience): void => {
    const enterVrButton = document.createElement("button");
    enterVrButton.id = "enterVrButton";
    enterVrButton.textContent = "VRモード";
    document.

setupEnterVrButton関数でenterVrButtonを作成し、VRモードへの切り替えを実装します。

以上の内容を組み合わせたものをリポジトリに公開しています。

デモ動画

実際に動作するデモです。

まとめ

Babylon.jsとbabylon-mmdを活用することで、Webブラウザ上でMMDモデルの表示とモーション再生を実現できました。TypeScriptやViteなどのモダンなツールを使用することで、効率的かつ快適な開発が可能でした。Babylon.jsやMMDに興味を持っていいただけていれば幸いです。

参考

  1. Babylon.js 公式ドキュメント
  2. babylon-mmd 公式ドキュメント
  3. babylon-mmd GitHub リポジトリ
  4. babylon-mmd 作者の登壇スライド

デモで使用しました

  1. Sour式初音ミクVer.1.02
  2. GimmeGimmeモーション配布用
  3. 八王子P × Giga「Gimme×Gimme feat. 初音ミク・鏡音リン」