こんにちは!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
今回使用した主要な技術は以下の通りです。
Bunを使用していますがnpmなどお好みのランタイムで大丈夫です。
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
続けて指示通りプロジェクトのあるディレクトリに移動してコマンドを実行します。
cd your-project-name
bun install
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と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.config.ts
を作成し、MMDのロードをサポートするための設定を記載します。
import { defineConfig } from 'vite';
export default defineConfig({
server: {
headers: {
"*.wasm": {
"Content-Type": "application/wasm"
}
}
},
optimizeDeps: {
exclude: ['babylon-mmd']
}
});
モデルデータの準備 MMDではPMXファイルを用いてMMDモデルをインポートしますが、babylon-mmdではPMXを更に最適化したBPMXファイルを使用することができます。 PMXファイルの変換についてはbabylon-mmdの作者様が変換ページを公開していますので以下のリンクから変換してください。
モーションデータの準備 同様に、babylon-mmdではVMDを更に最適化したBVMDファイルを使用できます。 babylon-mmdの作者様が変換ページを公開していますので以下のリンクから変換してください。
babylon-mmdでは通常のPMX・VMDファイルもサポートしています。
Babylon.jsでサポートされているサウンド形式は、.mp3、.ogg、.wav、.m4a、.mp4です。
まずは、Babylon.jsで基本的なシーンを構築します。
エンジンの初期化
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);
});
ランタイムの作成
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,
};
}
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
でテクスチャローダーを登録します。
次に、シーンの背景色とアンビエントカラーを設定します。
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
でアンビエントカラーを定義します。
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モデルとカメラの親ノードとします。
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
を作成し、位置や描画範囲、操作や動きの慣性などを設定します。
シーンにライティングとシャドウを追加して、よりリアルな見た目を作成します。
シーン全体を照らすディレクショナルライトを作成します。
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
を作成し、方向や明るさを設定します。
影を生成するためのシャドウジェネレーターを作成し、設定します。
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
を作成し、シャドウの品質や計算方法を調整します。
影を受けるための地面を作成します。
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
関数で地面を作成し、影を受けるように設定します。
MMDモデルをシーンにロードして表示します。
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モデル、モーション、カメラモーションを並行してロードし、ロード進行状況をユーザーに表示します。
ロードしたモーションとカメラモーションを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
でアニメーションの制御を可能にします。
シーンをVRに対応させます。
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モードを有効にします。コントローラーの入力を検知し、移動や視点変更を実装します。
ユーザーが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に興味を持っていいただけていれば幸いです。