[Babylon.js #06] MMDキャラクターを地形上で動かす(複数体・ランダム移動・地面吸着)

はじめに

前回(#05)は、fbm2D で地形を作り、thin instances の草原と WebXR 移動までを実装しました。

今回はその上に MMD キャラクター を載せて、地形上を歩かせます。 やることは次のとおりです。

  • MMD導入(babylon-mmd, .bpmx, .bvmd)
  • setupMmd() への分離(リファクタ)
  • 複数キャラ化(clone と別モデル読み込みの話)
  • ランダム移動(ターゲット移動)
  • 進行方向へ回転(yaw補間)
  • 地面吸着(Ray)
  • ループ監視 / bloom演出
  • VRでも動く(パフォーマンス所感)

前回の記事:

Youtube動画:

動画(PC):

動画(VR):
やや処理落ちてます、草原とMMDのポリゴン数を落としてなかったのが恐らく原因。

1. 今回やること

#06 で作った「地形 + 草原 + WebXR 移動」の上に、MMD キャラクターを載せて“歩かせる”ところまでやります。

今回のゴールは 2 つです。

  1. MMD を Babylon.js に導入して、確実に再生できる状態にする

    • babylon-mmd を使って .bpmx(モデル)と .bvmd(モーション)を読み込む
    • MmdRuntime を初期化して、モーションを適用してループ再生する
  2. キャラを「地形上で移動する存在」にする

    • MMD メッシュ本体はそのまま、移動用の rootTransformNode / Mesh)を作って 親子構造で分離
    • root を「ランダム目標地点に向かって移動」させる(ターゲット移動)
    • 進行方向へ回転(yaw を補間して自然に)
    • Ray で地面を拾って 地形の起伏に吸着(足が浮かない)

さらに発展として、

  • 複数キャラ化
    • まずは clone で複数体(同一モデル)
    • その後、別の .bpmx を読み込んで「別モデル」を並べる構成も紹介
  • ループ監視 + bloom 演出
    • ループ直前に bloom を強めるなど、軽い演出を入れる
  • WebXR(Quest)でも動くか?
    • VR での体感(負荷のかかり方、気を付けるポイント)も触れる

以降のセクションでは、最終的に setupMmd() を呼ぶだけで

  • MMD の読み込み → 再生
  • キャラの移動・回転・地面吸着
  • 複数体の生成(clone / 別モデル)

まで一括で組める形にリファクタしていきます。

2. babylon-mmd を導入する

まずは Babylon.js で MMD を扱うために、babylon-mmd とローダーを入れます。

インストール

npm i babylon-mmd
npm i @babylonjs/core @babylonjs/loaders

@babylonjs/loaders.bpmx 読み込み時にも必要です。


使うファイル形式

今回使うのはこの 2 つです。

  • .bpmx : MMDモデル(PMX の Babylon 向け形式)
  • .bvmd : モーションデータ(VMD系の最適化形式)

記事内の例では、こんな配置を想定しています。

public/
└─ models/
   ├─ lain_v2/
   │  └─ lain_v2.bpmx
   └─ lain/
      └─ lain.bvmd

mmd.ts 側の import

setupMmd() を分離した ./script/mmd.ts では、ローダー登録を忘れずに入れます。

// ./script/mmd.ts

import "@babylonjs/loaders";
import "babylon-mmd/esm/Loader/Optimized/bpmxLoader";

import { MmdRuntime, BvmdLoader } from "babylon-mmd";
import {
  Scene,
  SceneLoader,
  Mesh,
  ShadowGenerator,
  DefaultRenderingPipeline,
} from "@babylonjs/core";

ここで重要なのはこの2行です。

  • @babylonjs/loaders
  • babylon-mmd/esm/Loader/Optimized/bpmxLoader

これを import しておくことで、SceneLoader.ImportMeshAsync().bpmx を読めるようになります。


setupMmd() の入口だけ先に作る

最初は中身を全部書かなくていいので、呼び出し口だけ作っておくと整理しやすいです。

export async function setupMmd(
  scene: Scene,
  shadowGenerator: ShadowGenerator,
  pipeline: DefaultRenderingPipeline,
) {
  // ここに MMD のロード・再生処理を入れていく
}

main.ts 側では、影とポストプロセス(bloom)の準備ができた後に呼びます。

await setupMmd(scene, shadowGenerator, pipeline);

モデル読み込み(最初の一歩)

まずは .bpmx を1体読み込んで、MMD メッシュを見つけるところまで。

const mmdImportResult = await SceneLoader.ImportMeshAsync(
  "",
  "/models/lain_v2/",
  "lain_v2.bpmx",
  scene
);

// 影設定
mmdImportResult.meshes.forEach((mesh) => {
  if (mesh instanceof Mesh) {
    shadowGenerator.addShadowCaster(mesh);
    mesh.receiveShadows = true;
  }
});

// MMD本体メッシュを探す
let mmdMesh = mmdImportResult.meshes.find((m) => (m as any).metadata?.isMmdModel) as Mesh;
if (!mmdMesh) {
  mmdMesh = mmdImportResult.meshes.find((m) => m instanceof Mesh && m.skeleton) as Mesh;
}
if (!mmdMesh) throw new Error("MMD Mesh not found.");

metadata?.isMmdModel が取れないケースもあるので、skeleton付き Mesh を fallback にするのが安定です。


Runtime + モーション読み込み

次に MmdRuntimeBvmdLoader を使って、モーションを読み込みます。

const mmdRuntime = new MmdRuntime(scene);
if (typeof (mmdRuntime as any).register === "function") {
  (mmdRuntime as any).register(scene);
}

const bvmdLoader = new BvmdLoader(scene);
const bvmd = await bvmdLoader.loadAsync("motion", "/models/lain/lain.bvmd");

モーションの長さを補完(ループ安定化)

bvmd によっては終了フレーム情報が素直に入っていないことがあるので、boneTracks を走査して最大フレームを取っておくと安定します。

let maxFrame = 0;
const anyBvmd = bvmd as any;

if (Array.isArray(anyBvmd.boneTracks)) {
  for (const track of anyBvmd.boneTracks) {
    if (track.frameNumbers && track.frameNumbers.length > 0) {
      const lastFrame = track.frameNumbers[track.frameNumbers.length - 1];
      if (lastFrame > maxFrame) maxFrame = lastFrame;
    }
  }
}
if (maxFrame === 0) maxFrame = 450;

// データ補完
anyBvmd.frameCount = maxFrame;
anyBvmd.duration = maxFrame;

モデル作成 + モーション適用 + 再生

const mmdModel = mmdRuntime.createMmdModel(mmdMesh, {
  buildOutlineRenderer: false,
});

const modelAny = mmdModel as any;
if (typeof modelAny.addAnimation === "function") {
  modelAny.addAnimation(bvmd);
  modelAny.setAnimation("motion", true);
} else {
  const handle = modelAny.createRuntimeAnimation(bvmd);
  modelAny.setRuntimeAnimation(handle);
}

mmdRuntime.isLoop = true;
mmdRuntime.playAnimation();

ここまでで、「MMD を1体表示して再生する」 ところまで到達です。


この段階のポイント

  • MMD は メッシュ本体を直接動かすより、親の root を動かす ほうが後で楽
  • .bvmd の終了フレーム補完をしておくと、ループ処理が安定しやすい
  • setupMmd() に分けておくと、main.ts がかなり読みやすくなる

次のセクションでは、この MMD を root にぶら下げて、ランダム移動 + 向き補間 + 地面吸着 を実装していきます。

3. setupMmd() に処理を分離する

MMD まわりの処理は、読み込み・影・モーション・ループ監視まで含めると長くなりがちです。 main.ts に全部書くと、地形や草のコードと混ざって一気に読みにくくなるので、ここで setupMmd() に分離しておきます。


なぜ分けるのか

今回の MMD 処理は、役割としてはこんな流れになります。

  • .bpmx モデルを読む
  • 影の設定を入れる
  • MmdRuntime を作る
  • .bvmd モーションを読む
  • モーションを適用して再生
  • ループ監視や bloom 演出を入れる

これを main.ts に直書きすると、「シーン構築」と「キャラ制御」 が混ざります。 なので、setupMmd() に寄せておくと、main.ts 側は「何を組み立てているか」が見えやすくなります。


main.ts 側は呼び出しだけにする

main.ts では、影とポストプロセスの準備ができたあとに setupMmd() を呼ぶだけにします。

import { setupMmd } from "./script/mmd";

// ... 地形・草・影・pipeline の初期化後

await setupMmd(scene, ground, shadowGenerator, pipeline);

この形にしておくと、main.ts はかなりスッキリします。


setupMmd() の引数

今回の setupMmd() では、最低限この 4 つを受け取る構成にしておくと扱いやすいです。

  • scene : MMD のロード・更新先
  • ground : 地面吸着(Ray)に使う
  • shadowGenerator : キャラの影設定に使う
  • pipeline : ループ時の bloom 演出に使う
export async function setupMmd(
  scene: Scene,
  ground: Mesh,
  shadowGenerator: ShadowGenerator,
  pipeline: DefaultRenderingPipeline,
) {
  // MMD の一連処理
}

ground を渡しているのは、後半でやる 地面吸着 のためです。 (この段階ではまだ使わなくても、先に受け取る形にしておくと後で差し替えが少なくて済みます)


mmd.ts に寄せる処理の単位

最初は setupMmd() の中に全部書いてしまって OK です。 そのあと、長くなってきたら次のように小さく分けると見通しがよくなります。

  • getMaxFrameFromBvmd()(終了フレーム検出)
  • applyMotionToModel()(モーション適用)
  • setupMotionLoopWatcher()(ループ監視 + bloom)
  • createWalkerFromMesh()(歩行キャラ化)
  • setupWalkerBehavior()(ランダム移動・回転・地面吸着)

この分け方にしておくと、あとで 複数キャラ化 するときにも使い回ししやすいです。


分離したあとのメリット

1) main.ts が「シーン組み立て」に集中できる

main.ts は地形・草・XR・ライト・ポストプロセスの構成だけを読むファイルになります。

2) MMD の修正がしやすい

mmd.ts の中だけ触ればよくなるので、モーションや歩行ロジックの試行錯誤がしやすいです。

3) 複数キャラ化に進みやすい

このあとやる「2体・5体に増やす」処理は、setupMmd() 側にまとまっていたほうが圧倒的に楽です。


この時点では「完璧な分離」を目指さなくていい

最初から細かく分けすぎると、逆に追いづらくなります。 なのでおすすめは次の順番です。

  1. まず setupMmd() に丸ごと移す
  2. 動作確認する
  3. 長くなった部分だけ関数化する

この順番なら、リファクタ中に壊しにくいです。

4. MMDモデルを読み込んで再生する(bpmx / bvmd)

ここでは setupMmd() の中で、まず 1体の MMD モデルを読み込んで再生する ところまでを作ります。

やることはシンプルで、流れは次の 4 ステップです。

  • .bpmx モデルを読み込む
  • 影(shadow caster)を設定する
  • .bvmd モーションを読み込む
  • MmdRuntime に登録して再生する

4-1. .bpmx モデルを読み込む

babylon-mmd を使う場合、最初にモデル(.bpmx)を SceneLoader.ImportMeshAsync() で読み込みます。

const mmdImportResult = await SceneLoader.ImportMeshAsync(
  "",
  "/models/lain_v2/",
  "lain_v2.bpmx",
  scene
);

ImportMeshAsync() の戻り値には複数メッシュが入るので、あとで「MMD本体のメッシュ」を探して取り出します。


4-2. 影の設定を入れる

読み込んだメッシュ群に対して、影を落とす設定を入れておきます。

mmdImportResult.meshes.forEach((mesh) => {
  if (mesh instanceof Mesh) {
    shadowGenerator.addShadowCaster(mesh);
    mesh.receiveShadows = true;
  }
});

ここは地味ですが、やっておくとキャラがシーンに馴染みます。 草原+地形の上に置くなら、影はかなり効きます。


4-3. MMD本体メッシュを見つける

読み込んだ配列の中から、MMDモデル本体を拾います。 metadata.isMmdModel がある場合はそれを優先し、無ければ skeleton 付きメッシュを拾う形にしておくと安全です。

let mmdMesh = mmdImportResult.meshes.find((m) => (m as any).metadata?.isMmdModel) as Mesh;

if (!mmdMesh) {
  mmdMesh = mmdImportResult.meshes.find((m) => m instanceof Mesh && m.skeleton) as Mesh;
}

if (!mmdMesh) {
  throw new Error("MMD Mesh not found.");
}

この fallback を入れておくと、モデル差し替え時にも壊れにくいです。


4-4. MmdRuntime を作って登録する

次に MmdRuntime を作成します。 環境やバージョン差で register() の有無が違うことがあるので、ガード付きで呼んでおくと安全です。

const mmdRuntime = new MmdRuntime(scene);

if (typeof (mmdRuntime as any).register === "function") {
  (mmdRuntime as any).register(scene);
}

4-5. .bvmd モーションを読み込む

モーションは BvmdLoader で読み込みます。

const bvmdLoader = new BvmdLoader(scene);
const bvmd = await bvmdLoader.loadAsync("motion", "/models/lain/lain.bvmd");

4-6. 終了フレームを検出して補完する

bvmd のデータによっては、終了フレーム情報の扱いが曖昧なことがあるので、 boneTracks を走査して最後のフレームを拾っておくとループ制御しやすくなります。

let maxFrame = 0;
const anyBvmd = bvmd as any;

if (Array.isArray(anyBvmd.boneTracks)) {
  for (const track of anyBvmd.boneTracks) {
    if (track.frameNumbers && track.frameNumbers.length > 0) {
      const lastFrame = track.frameNumbers[track.frameNumbers.length - 1];
      if (lastFrame > maxFrame) maxFrame = lastFrame;
    }
  }
}

if (maxFrame === 0) maxFrame = 450;

// 補完
anyBvmd.frameCount = maxFrame;
anyBvmd.duration = maxFrame;

この maxFrame は、あとでやる ループ監視 に使います。


4-7. モデル化してモーション適用・再生

読み込んだ mmdMeshcreateMmdModel() に渡して、モーションを適用します。

const mmdModel = mmdRuntime.createMmdModel(mmdMesh, {
  buildOutlineRenderer: false,
});

const modelAny = mmdModel as any;
if (typeof modelAny.addAnimation === "function") {
  modelAny.addAnimation(bvmd);
  modelAny.setAnimation("motion", true);
} else {
  // fallback
  const handle = modelAny.createRuntimeAnimation(bvmd);
  modelAny.setRuntimeAnimation(handle);
}

mmdRuntime.isLoop = true;
mmdRuntime.playAnimation();

buildOutlineRenderer: false は、まずは軽め構成で始めるための設定です。 (後から見た目を詰めたくなったら調整でOK)


4-8. ここまでの setupMmd() 最小構成

この段階の setupMmd() は、だいたい次の形になります。

import "@babylonjs/loaders";
import "babylon-mmd/esm/Loader/Optimized/bpmxLoader";
import { MmdRuntime, BvmdLoader } from "babylon-mmd";
import {
  Scene,
  SceneLoader,
  Mesh,
  ShadowGenerator,
  DefaultRenderingPipeline,
} from "@babylonjs/core";

export async function setupMmd(
  scene: Scene,
  ground: Mesh,
  shadowGenerator: ShadowGenerator,
  pipeline: DefaultRenderingPipeline,
) {
  try {
    const mmdImportResult = await SceneLoader.ImportMeshAsync(
      "",
      "/models/lain_v2/",
      "lain_v2.bpmx",
      scene
    );

    mmdImportResult.meshes.forEach((mesh) => {
      if (mesh instanceof Mesh) {
        shadowGenerator.addShadowCaster(mesh);
        mesh.receiveShadows = true;
      }
    });

    let mmdMesh = mmdImportResult.meshes.find((m) => (m as any).metadata?.isMmdModel) as Mesh;
    if (!mmdMesh) {
      mmdMesh = mmdImportResult.meshes.find((m) => m instanceof Mesh && m.skeleton) as Mesh;
    }
    if (!mmdMesh) throw new Error("MMD Mesh not found.");

    const mmdRuntime = new MmdRuntime(scene);
    if (typeof (mmdRuntime as any).register === "function") {
      (mmdRuntime as any).register(scene);
    }

    const bvmdLoader = new BvmdLoader(scene);
    const bvmd = await bvmdLoader.loadAsync("motion", "/models/lain/lain.bvmd");

    let maxFrame = 0;
    const anyBvmd = bvmd as any;
    if (Array.isArray(anyBvmd.boneTracks)) {
      for (const track of anyBvmd.boneTracks) {
        if (track.frameNumbers && track.frameNumbers.length > 0) {
          const lastFrame = track.frameNumbers[track.frameNumbers.length - 1];
          if (lastFrame > maxFrame) maxFrame = lastFrame;
        }
      }
    }
    if (maxFrame === 0) maxFrame = 450;

    anyBvmd.frameCount = maxFrame;
    anyBvmd.duration = maxFrame;

    const mmdModel = mmdRuntime.createMmdModel(mmdMesh, {
      buildOutlineRenderer: false,
    });

    const modelAny = mmdModel as any;
    if (typeof modelAny.addAnimation === "function") {
      modelAny.addAnimation(bvmd);
      modelAny.setAnimation("motion", true);
    } else {
      const handle = modelAny.createRuntimeAnimation(bvmd);
      modelAny.setRuntimeAnimation(handle);
    }

    mmdRuntime.isLoop = true;
    mmdRuntime.playAnimation();
  } catch (e) {
    console.error("MMD Setup Error:", e);
  }
}

この時点での状態

ここまでで、キャラは その場で足踏みして再生 される状態になります。 まだ歩かせていませんが、次の段階(root を作って移動させる)に進むための土台はこれで完成です。

次のセクションでは、MMDメッシュを TransformNode / Mesh の root にぶら下げて、setupMmd() の中でキャラを動かせる構造にしていきます。

5. キャラクター移動用 root を作る

前のセクションで、MMDモデルは「その場で再生」できるようになりました。 ここからは キャラクター自体をシーン内で移動させる ために、MMDメッシュを直接動かすのではなく、移動用の root(親ノード) を1つ用意します。

この構成にしておくと、あとでやる以下がかなり楽になります。

  • ランダム移動
  • 進行方向への回転
  • 地面吸着(Ray)
  • 複数キャラ管理

5-1. なぜ root を作るのか

MMDのメッシュ本体 (mmdMesh) を直接動かしても動作はしますが、実際には次の理由で root を噛ませた方が扱いやすいです。

  • MMD側の内部姿勢(ボーン/モーション)と、シーン上の移動を分離できる
  • モデル差し替え時も「rootを動かす」処理を使い回せる
  • 高さ補正・向き補正を root 側で吸収できる

要するに、「アニメーション担当」と「移動担当」を分離する」 ための構造です。


5-2. mmdRoot を作って親子付けする

mmdMesh を取得したあとで、まず root を作ります。 Mesh でも TransformNode でもOKですが、表示不要の親ノードなので TransformNode が素直です。

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

// 移動用 root
const mmdRoot = new TransformNode("mmdRoot", scene);

// 初期位置(シーン上の配置)
mmdRoot.position.set(0, 0, 0);

// MMDメッシュを root の子にする
mmdMesh.parent = mmdRoot;

// メッシュ側のローカル位置/回転はリセットしておく
mmdMesh.position.set(0, 0, 0);
mmdMesh.rotation.set(0, 0, 0);

これで、今後は mmdRoot.position / mmdRoot.rotation を触るだけでキャラ全体を動かせます。


5-3. まずは固定移動で確認する(テスト)

いきなりランダム移動にせず、まずは「一定方向に進むだけ」で root が効いているか確認すると安全です。

const walkSpeed = 10.0; // m/s 相当

scene.onBeforeRenderObservable.add(() => {
  const dt = scene.getEngine().getDeltaTime() / 1000;

  // +X方向へ移動(テスト)
  mmdRoot.position.x += walkSpeed * dt;
});

この段階で、足踏みモーションを再生したままキャラ全体が移動していればOKです。 (見た目としては「足踏みしながらスライド移動」ですが、次で自然にしていきます)


5-4. 向き補正の考え方(ここで入れておくと後が楽)

モデルによって「正面方向」が違うので、進行方向に向けたときに横を向くことがあります。 そのため、モデル固有の向き補正値 を1つ持っておくのがおすすめです。

const MODEL_FORWARD_OFFSET = Math.PI; // 例:180度補正(モデル次第)

この値はあとで yaw を計算するときに足します。 (0, Math.PI / 2, Math.PI, -Math.PI / 2 あたりを試すと大体合います)


5-5. setupMmd() に組み込む位置

root の作成は、mmdMesh を取得した直後 に入れるのが分かりやすいです。 つまり、流れとしてはこうなります。

  1. .bpmx を読む
  2. mmdMesh を見つける
  3. mmdRoot を作って mmdMesh をぶら下げる
  4. MmdRuntime を作る
  5. .bvmd を読む
  6. モーション適用・再生

この順番にしておくと、後で「移動ロジック」だけを切り出しやすいです。


5-6. この段階の差分(最小)

前セクションの setupMmd() に対して、追加するのは主にこの部分です。

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

// ... mmdMesh を見つけたあと

const mmdRoot = new TransformNode("mmdRoot", scene);
mmdRoot.position.set(0, 0, 0);

mmdMesh.parent = mmdRoot;
mmdMesh.position.set(0, 0, 0);
mmdMesh.rotation.set(0, 0, 0);

この時点での状態

ここまでで、キャラは 「MMD再生」と「シーン内移動」を分離できる構造 になりました。 まだ移動ロジック自体は簡単ですが、次のセクションでこの mmdRoot を使って、ランダムターゲットへ向かって歩かせる処理を入れていきます。

6. ランダム移動(ターゲット移動)を実装する

mmdRoot を作ったので、次はこの root を使って ランダムな地点へ向かって移動する処理 を入れます。 「毎フレーム少しずつ目標に近づく」方式にすると、実装がシンプルで破綻しにくいです。

この時点ではまだ地面吸着や回転補間は最低限にして、まずは ターゲット移動そのもの を成立させます。


6-1. 方式の考え方

やることはシンプルです。

  1. 移動範囲内でランダムな target を1つ決める
  2. mmdRoot.position から target への方向ベクトルを作る
  3. 正規化して速度を掛けて進める
  4. 近づいたら次の target を作る

この方式の良いところは、往復移動より自然に見えやすい点です。 また、複数キャラ化するときも 各キャラが自分の target を持つだけ で拡張できます。


6-2. パラメータを用意する

まずは調整しやすい値をまとめて定義しておきます。

const WALK_SPEED = 25.0;   // 移動速度
const ARRIVE_DIST = 2.0;   // 目標に到達したとみなす距離
const HALF = 180;          // ランダム移動範囲(-HALF ~ +HALF)
  • WALK_SPEED はモデルの歩行モーションに合わせて調整
  • ARRIVE_DIST は小さすぎると目標付近で微振動しやすい
  • HALF は地面サイズより少し内側にしておくと安全

地面サイズが 500 なら、HALF = 180〜220 くらいが扱いやすいです。


6-3. ランダムターゲット生成関数

XZ 平面だけ使えば十分なので、y = 0 で target を作ります。

function randomTarget(half: number): Vector3 {
  return new Vector3(
    (Math.random() * 2 - 1) * half,
    0,
    (Math.random() * 2 - 1) * half,
  );
}

初期ターゲットも1回作っておきます。

let target = randomTarget(HALF);

6-4. onBeforeRender でターゲットへ向かう

scene.onBeforeRenderObservable の中で、毎フレーム移動を更新します。

scene.onBeforeRenderObservable.add(() => {
  const dt = scene.getEngine().getDeltaTime() / 1000;

  // root → target の方向(XZのみ)
  const to = target.subtract(mmdRoot.position);
  to.y = 0;

  const dist = to.length();

  // 到着したら次の目標へ
  if (dist < ARRIVE_DIST) {
    target = randomTarget(HALF);
    return;
  }

  // 正規化して一定速度で移動
  to.normalize();
  const move = to.scale(WALK_SPEED * dt);
  mmdRoot.position.addInPlace(move);
});

ポイントは to.y = 0 です。 ここでY成分を無視しておくと、地形の高低差は後で別処理(地面吸着)に任せる という分離ができます。


6-5. setupMmd() に入れる位置

この移動処理は、mmdRoot を作った直後(MMD再生の前でも後でもOK)に置けます。 構造としては次の順番が見通し良いです。

  1. mmdMesh を取得
  2. mmdRoot を作成して親子付け
  3. ランダム移動の target / onBeforeRender を設定
  4. MMD Runtime 初期化
  5. モーション読み込み・再生

MMD再生と独立しているので、移動だけ先に確認できるのも利点です。


6-6. ここで起きやすいハマりどころ

① キャラが速すぎる / 遅すぎる

WALK_SPEED がモーションと合っていないだけです。 足踏みモーションなら、まず 10〜20 くらいから試すと調整しやすいです。

② 目標到達時にガクッと止まる

ARRIVE_DIST が小さすぎると、目標を行き過ぎて往復しやすくなります。 1.5〜4.0 くらいにすると安定しやすいです。

③ 画面外に出る

HALF を地面サイズより小さくするのが基本です。 (後で「中心寄りに重みを付ける」こともできます)


6-7. この段階の完成コード(移動部分)

mmdRoot 作成後に入れる最小構成はこんな形です。

const WALK_SPEED = 25.0;
const ARRIVE_DIST = 2.0;
const HALF = 180;

function randomTarget(half: number): Vector3 {
  return new Vector3((Math.random() * 2 - 1) * half, 0, (Math.random() * 2 - 1) * half);
}

let target = randomTarget(HALF);

scene.onBeforeRenderObservable.add(() => {
  const dt = scene.getEngine().getDeltaTime() / 1000;

  const to = target.subtract(mmdRoot.position);
  to.y = 0;

  const dist = to.length();

  if (dist < ARRIVE_DIST) {
    target = randomTarget(HALF);
    return;
  }

  to.normalize();
  const move = to.scale(WALK_SPEED * dt);
  mmdRoot.position.addInPlace(move);
});

次のセクションでやること

このままだとキャラは移動しても、見た目の向きが進行方向を向かないことがあります。 次は move ベクトルを使って 進行方向へ回転(yaw補間) を入れて、歩いている感を出していきます。

7. 進行方向へ回転する(yaw補間)

ランダム移動だけでもキャラは動きますが、この状態だと 進行方向を向かずに横滑り して見えることがあります。 そこで、前セクションで作った move ベクトルを使って、mmdRoot を 進行方向へ回転 させます。

ここではさらに、いきなり角度を切り替えるのではなく、少しずつ追従させる(補間) ことで自然な向き変化にします。


7-1. まずは atan2 で目標の向きを作る

Babylon.js では Y軸回転(rotation.y)で向きを変えるので、XZ 平面の移動ベクトルから yaw を計算します。

const targetYaw = Math.atan2(move.x, move.z);

Math.atan2(x, z) にしているのは、Babylon の前方向(Z軸)に合わせるためです。 ただし、MMDモデルの正面向き はモデルごとにズレていることがあるので、補正角を足します。

const MODEL_FORWARD_OFFSET = Math.PI; // モデルの向き補正(必要に応じて調整)
const targetYaw = Math.atan2(move.x, move.z) + MODEL_FORWARD_OFFSET;

ここは 0, Math.PI, Math.PI * 0.5 などを試して、モデルの正面に合わせるのが定番です。


7-2. いきなり代入するとカクつく

まず動作確認としてはこれでもOKです。

mmdRoot.rotation.y = targetYaw;

ただ、ターゲットが切り替わった瞬間などに 急にクルッと回る ので、見た目がやや硬くなります。 そこで、targetYaw に向かって少しずつ近づける形にします。


7-3. yaw補間を入れる(基本形)

シンプルな補間は次の形です。

const YAW_LERP_SPEED = 6.0;

mmdRoot.rotation.y += (targetYaw - mmdRoot.rotation.y) * Math.min(1, dt * YAW_LERP_SPEED);

YAW_LERP_SPEED を上げるほど素早く向き、下げるほどゆっくり向きます。

  • 4〜6:自然寄り
  • 8〜12:キビキビ
  • 2以下:かなりゆっくり

7-4. π跨ぎ問題(逆回転)を防ぐ

角度補間でよくあるのが、 の境目を跨いだときに、 本当は少し回ればいいのに、逆方向へ大回りする 問題です。

これを防ぐために、最短角度差を返す関数を用意しておくと安定します。

function shortestAngleDelta(from: number, to: number): number {
  let d = to - from;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  return d;
}

補間はこの差分を使います。

mmdRoot.rotation.y += shortestAngleDelta(mmdRoot.rotation.y, targetYaw) * Math.min(1, dt * YAW_LERP_SPEED);

これで、向きの補間がかなり安定します。


7-5. setupWalkerBehavior() に組み込むコード

前セクションのランダム移動に、回転処理を追加した形はこうなります。

const MODEL_FORWARD_OFFSET = Math.PI;
const YAW_LERP_SPEED = 6.0;

scene.onBeforeRenderObservable.add(() => {
  const dt = scene.getEngine().getDeltaTime() / 1000;

  const to = target.subtract(mmdRoot.position);
  to.y = 0;

  const dist = to.length();
  if (dist < ARRIVE_DIST) {
    target = randomTarget(HALF);
    return;
  }

  to.normalize();
  const move = to.scale(WALK_SPEED * dt);
  mmdRoot.position.addInPlace(move);

  // 進行方向へ回転
  if (move.lengthSquared() > 1e-6) {
    const targetYaw = Math.atan2(move.x, move.z) + MODEL_FORWARD_OFFSET;
    mmdRoot.rotation.y +=
      shortestAngleDelta(mmdRoot.rotation.y, targetYaw) * Math.min(1, dt * YAW_LERP_SPEED);
  }
});

move.lengthSquared() > 1e-6 のチェックは、ほぼ停止状態で atan2 を連続計算して 細かくブレるのを防ぐためのガードです。


7-6. 補足:回転をもっと自然にしたい場合

見た目をさらに良くしたい場合は、次のような調整が効きます。

  • 移動速度に応じて回転速度を変える

    • 速いときだけ素早く向く
  • 到着直前は回転を弱める

    • target更新直前のクルつきを減らせる
  • ターゲット更新時に少し待機

    • 人っぽい挙動になる

ただ、まずは今回の yaw補間 だけで十分に自然に見えるはずです。


次のセクションでやること

ここまでで「歩く方向を向く」ようになりました。 次は地形の起伏に合わせるために、Ray を使って 地面吸着(地面に張り付く処理) を入れます。

8. 地形に吸着させる(Ray)

ここまででキャラはランダム移動+進行方向への回転までできました。

ただし地面に起伏があると、そのままでは キャラが地形を貫通したり、少し浮いたり します。

そこで、毎フレーム Ray を上から下に飛ばして、地面の高さを拾い、mmdRoot.position.y を補正します。 この処理を入れると、キャラが地形に「貼り付いて」歩いているように見えるようになります。


8-1. 方針

やることはシンプルです。

  1. キャラの XZ 座標を使う
  2. その真上(高いY)から下向きに Ray を飛ばす
  3. ground に当たった位置の y を取得する
  4. mmdRoot.position.y をその高さに寄せる

8-2. 必要な定数

まずは吸着用のパラメータを用意します。

const RAY_FROM_Y = 100;   // 十分高い位置から飛ばす
const FOOT_OFFSET_Y = -0.2; // モデルの足元補正(モデルに合わせて調整)
const Y_LERP = 0.25;      // 高さ補間(ガタつき軽減)
  • RAY_FROM_Y 地形の最大高さより上ならOKです(例: 地形振幅が20なら100で十分)。
  • FOOT_OFFSET_Y モデルの原点が足元にない場合の補正です。 0, -0.1, -0.2, +0.1 あたりを試すと合わせやすいです。
  • Y_LERP いきなり高さを合わせるとガタつくことがあるので、少し補間します。

8-3. Ray を飛ばして地面の高さを拾う

onBeforeRenderObservable の中で、移動・回転の後にこの処理を追加します。

const rayOrigin = new Vector3(mmdRoot.position.x, RAY_FROM_Y, mmdRoot.position.z);
const ray = new Ray(rayOrigin, Vector3.Down(), RAY_FROM_Y + 50);

const hit = scene.pickWithRay(ray, (m) => m === ground);
if (hit?.hit && hit.pickedPoint) {
  const targetY = hit.pickedPoint.y + FOOT_OFFSET_Y;
  mmdRoot.position.y += (targetY - mmdRoot.position.y) * Math.min(1, Y_LERP * 60 * dt);
}

ポイントはこの部分です。

scene.pickWithRay(ray, (m) => m === ground)

判定対象を ground のみに限定しているので、草やキャラ自身に当たって高さが狂うのを防げます。


8-4. 直接代入でもいいけど、補間がおすすめ

動作確認だけなら、次のように直接代入でも動きます。

mmdRoot.position.y = targetY;

ただ、地形の法線やフレームごとの差で見た目が少しカクつくことがあります。 そのため、この記事のコードでは補間を入れています。

mmdRoot.position.y += (targetY - mmdRoot.position.y) * Math.min(1, Y_LERP * 60 * dt);

この形にすると、フレームレート差の影響も受けにくく、見た目が安定しやすいです。


8-5. setupWalkerBehavior() 全体で見るとこの位置

ランダム移動・回転に続けて、最後に吸着を入れる流れです。

scene.onBeforeRenderObservable.add(() => {
  const dt = scene.getEngine().getDeltaTime() / 1000;

  // 1) ターゲット方向へ移動
  const to = target.subtract(root.position);
  to.y = 0;

  const dist = to.length();
  if (dist < ARRIVE_DIST) {
    target = randomTarget(HALF);
    return;
  }

  to.normalize();
  const move = to.scale(WALK_SPEED * dt);
  root.position.addInPlace(move);

  // 2) 進行方向へ回転
  if (move.lengthSquared() > 1e-6) {
    const targetYaw = Math.atan2(move.x, move.z) + MODEL_FORWARD_OFFSET;
    root.rotation.y +=
      shortestAngleDelta(root.rotation.y, targetYaw) * Math.min(1, dt * YAW_LERP_SPEED);
  }

  // 3) 地面吸着(Ray)
  const rayOrigin = new Vector3(root.position.x, RAY_FROM_Y, root.position.z);
  const ray = new Ray(rayOrigin, Vector3.Down(), RAY_FROM_Y + 50);

  const hit = scene.pickWithRay(ray, (m) => m === ground);
  if (hit?.hit && hit.pickedPoint) {
    const targetY = hit.pickedPoint.y + FOOT_OFFSET_Y;
    root.position.y += (targetY - root.position.y) * Math.min(1, Y_LERP * 60 * dt);
  }
});

これで、起伏のある地形でも自然に歩かせやすくなります。


8-6. よくあるハマりどころ

1) キャラが地面に埋まる / 浮く

FOOT_OFFSET_Y の調整不足です。モデルごとに原点位置が違うので、ここは微調整が必要です。

  • 埋まる → FOOT_OFFSET_Y を上げる(例: -0.2 -> 0.0
  • 浮く → FOOT_OFFSET_Y を下げる(例: 0.0 -> -0.2

2) 高さがブルブルする

次のどちらかで改善しやすいです。

  • Y_LERP を少し下げる(例: 0.25 -> 0.15
  • Ray の判定対象を ground のみに限定する(←かなり重要)

3) たまに地面を見失う

RAY_FROM_Y が低すぎる可能性があります。 地形の最大高さより十分大きい値にしておくと安定します。


8-7. Ray以外の方法(高さ関数)

今回は pickWithRay を使いましたが、地形を fbm2D で生成しているなら、 同じノイズ関数から高さを直接計算する 方法もあります。

  • Ray方式

    • 実際の地面メッシュに追従する
    • 実装が分かりやすい
    • メッシュ形状を変えても使い回ししやすい
  • 高さ関数方式

    • 軽い(Ray判定が不要)
    • ただし「地面変形ロジック」と完全に同じ式を維持する必要がある

記事としてはまず Ray 方式が分かりやすくておすすめです。


次のセクション

ここまでで、MMDキャラが

  • モーション再生
  • ランダム移動
  • 進行方向へ回転
  • 地形への吸着

まで揃いました。 次は、ループ終端の違和感を抑えるための ループ監視 / bloom演出 を入れて仕上げます。

9. 複数キャラ化する(clone / 別モデル)

1体だけでも十分動きますが、ここまで作れると 複数キャラを歩かせたくなる はずです。 実際、同じ地形上を2〜5体くらい歩かせるだけでも、かなり「世界感」が出ます。

このセクションでは、複数キャラ化の方法を2パターンに分けて整理します。

  • A. clone する(同じモデルを増やす)
  • B. 別モデルを読み込む(違う見た目のキャラを増やす)

9-1. まず結論(どっちを使う?)

clone 方式

  • 実装が楽
  • 読み込み1回で済む(軽い)
  • 同じ見た目のキャラを増やすのに向く

別モデル読み込み方式

  • 見た目を変えられる
  • キャラごとに個性を出せる
  • モデル数ぶんロード処理が増える

最初は clone 方式 で動かして、あとから一部だけ別モデルに差し替えるのがやりやすいです。


9-2. 構成の考え方(root を1体ずつ持つ)

すでにやっている通り、キャラは mmdRoot(または TransformNode)にぶら下げる構成にしておくと、複数体でも管理しやすいです。

type WalkerHandle = {
  root: TransformNode;
  mesh: Mesh;
};

この形にしておくと、各キャラに対して

  • 位置
  • 回転
  • ランダム移動
  • 地面吸着

を独立して適用できます。


9-3. clone で増やす(同モデル複数体)

baseMmdMesh を1体ロードしたあと、clone() で増やすパターンです。

function cloneWalker(scene: Scene, ground: Mesh, sourceMesh: Mesh, options: WalkerOptions): WalkerHandle {
  const cloned = sourceMesh.clone(options.name, null, true) as Mesh;
  if (!cloned) {
    throw new Error(`Failed to clone mesh: ${options.name}`);
  }

  const root = new TransformNode(`${options.name}_root`, scene);
  root.position.copyFrom(options.startPos);

  cloned.parent = root;
  cloned.position.set(0, 0, 0);
  cloned.rotation.set(0, 0, 0);

  setupWalkerBehavior(scene, ground, root, options);

  return { root, mesh: cloned };
}

ポイントは clone(..., true)true です。 スケルトン付きモデルでは、この指定が重要になることがあります(環境やモデル構成で挙動差あり)。


9-4. 2体以上を作る例(clone版)

const walker1 = createWalkerFromMesh(scene, ground, baseMmdMesh, {
  name: "lain_01",
  startPos: new Vector3(0, 0, 0),
  walkSpeed: 25,
});

const walker2 = cloneWalker(scene, ground, baseMmdMesh, {
  name: "lain_02",
  startPos: new Vector3(30, 0, 30),
  walkSpeed: 20,
});

const walker3 = cloneWalker(scene, ground, baseMmdMesh, {
  name: "lain_03",
  startPos: new Vector3(-40, 0, 10),
  walkSpeed: 27,
});

速度を少し変えるだけでも、全員同じ動き感が薄れて自然に見えます。


9-5. MMDモーションを各キャラに適用する

MmdRuntime は1つにして、各キャラの meshcreateMmdModel() すればOKです。

const mmdModel1 = mmdRuntime.createMmdModel(walker1.mesh, { buildOutlineRenderer: false });
applyMotionToModel(mmdModel1, bvmd);

const mmdModel2 = mmdRuntime.createMmdModel(walker2.mesh, { buildOutlineRenderer: false });
applyMotionToModel(mmdModel2, bvmd);

const mmdModel3 = mmdRuntime.createMmdModel(walker3.mesh, { buildOutlineRenderer: false });
applyMotionToModel(mmdModel3, bvmd);

mmdRuntime.isLoop = true;
mmdRuntime.playAnimation();

bvmd は同じものを使い回せるので、モーションロードは1回で済みます。


9-6. 「1体しか出ない」になりやすい原因

ここ、かなりハマりやすいです。 あなたが遭遇したように、コード上は2体作っているのに 1体しか見えない ことがあります。

よくある原因はこのあたりです。

1) 同じ位置に重なっている

startPos が近い、または同じだと重なって1体に見えます。

2) root ではなく元メッシュを再利用して上書きしている

mmdMesh.parent = root を何度もやると、最後の親だけ有効になってしまいます。 1体目は元メッシュ、2体目以降は clone にするのが安全です。

3) clone 後のメッシュ階層を見落としている

モデルによっては、表示される本体が子メッシュ側にいることがあります。 その場合、getChildMeshes() 側の影設定やマテリアル確認が必要です。


9-7. 別モデルを読み込んで増やす(おすすめの設計)

「クローンじゃなくて別モデルを表示したい」場合は、setupMmd() の中で1回だけ読むのではなく、 “1体分を作る関数” を用意すると整理しやすいです。

イメージはこんな感じです。

async function loadMmdCharacter(
  scene: Scene,
  modelDir: string,
  modelFile: string,
  motionPath: string
) {
  const importResult = await SceneLoader.ImportMeshAsync("", modelDir, modelFile, scene);

  let mmdMesh = importResult.meshes.find((m) => (m as any).metadata?.isMmdModel) as Mesh;
  if (!mmdMesh) {
    mmdMesh = importResult.meshes.find((m) => m instanceof Mesh && m.skeleton) as Mesh;
  }
  if (!mmdMesh) throw new Error("MMD Mesh not found");

  const bvmdLoader = new BvmdLoader(scene);
  const bvmd = await bvmdLoader.loadAsync("motion", motionPath);

  return { mmdMesh, bvmd, meshes: importResult.meshes };
}

この形にすると、

  • lain_v2.bpmx
  • 別キャラの xxx.bpmx

をそれぞれ読み込んで、個別に WalkerHandle 化できます。


9-8. clone方式と別モデル方式の混在もできる

実運用だと、次みたいな構成が扱いやすいです。

  • 1体目: 別モデルA(メイン)
  • 2体目: 別モデルB(サブ)
  • 3〜5体目: AやBの clone

これなら見た目に変化が出つつ、ロードコストも抑えやすいです。


9-9. 5体くらいまで増やしたときの所感(VR含む)

すでに試している通り、構成が整理されていれば 5体程度は十分現実的 です。 特に今回の構成は、地形や草の負荷が大きいので、MMD側を増やすときは以下を意識すると安定しやすいです。

  • buildOutlineRenderer: false を維持する
  • 影の解像度や shadowGenerator 設定を必要以上に重くしない
  • 草の本数(thin instances)を先に調整して余力を作る
  • キャラ数が増えたら walkSpeed / halfRange をばらけさせる(重なり防止)

9-10. 次のセクションにつなぐ

複数キャラ化までできると、見た目としてはかなり完成度が上がります。 次は仕上げとして、モーションのループ終端の違和感を抑える処理(ループ監視 + bloom演出)を整理しておくと、記事としても流れがきれいです。

10. ループ監視と bloom 演出

MMDを再生していると、モーションによってはループのつなぎ目で少し違和感が出ることがあります。 今回のように歩行モーションを常時流す場合は、終了タイミングを監視して明示的に先頭へ戻す だけでもかなり安定します。

あわせて、ループ直前に bloom を少し強めると、切り替わりの違和感を“演出”として吸収しやすくなります。


10-1. やっていること(シンプル版)

処理の考え方はこの2つだけです。

  • mmdRuntime.currentTime を毎フレーム監視する
  • 終端付近で bloomWeight を上げる
  • 終端を超えたら seekAnimation(0) + playAnimation() で戻す

すでに分離した setupMotionLoopWatcher() は、この役割にちょうど良い構成です。


10-2. 実装コード(分離済み関数)

function setupMotionLoopWatcher(
  scene: Scene,
  mmdRuntime: any,
  maxFrame: number,
  pipeline: DefaultRenderingPipeline
) {
  const durationSeconds = maxFrame / 30;

  scene.onBeforeRenderObservable.add(() => {
    // ループ直前だけ bloom を強める(演出)
    if (durationSeconds - mmdRuntime.currentTime < 0.5) {
      pipeline.bloomWeight = 1.0;
    } else {
      pipeline.bloomWeight = 0.3;
    }

    // 終端を超えたら先頭へ戻す
    if (mmdRuntime.currentTime >= durationSeconds) {
      if (typeof mmdRuntime.seekAnimation === "function") {
        mmdRuntime.seekAnimation(0, false);
      }
      mmdRuntime.playAnimation();
    }
  });
}

maxFrame / 30 にしているのは、MMDモーションを 30fps前提 として秒に変換しているためです。


10-3. なぜ監視が必要?

mmdRuntime.isLoop = true だけでもループするケースはありますが、モーションやデータ構造によっては

  • 終端の取り方が微妙にズレる
  • 一瞬止まるように見える
  • つなぎ目で不自然に見える

ということがあります。

そこで、記事の実装では 「最後のフレームを自分で把握して、そこを超えたら戻す」 方式にしています。 この方法は泥臭いですが、挙動が読みやすく、デバッグしやすいです。


10-4. bloom演出を入れる理由

これは完全に演出ですが、かなり効きます。

ループ直前に少し発光を強くすると、視覚的に

  • 「切り替わり」が目立ちにくくなる
  • 世界観として自然な瞬間演出になる

という効果があります。

特に今回みたいに、夜っぽい地形+草+MMDの構成だと、bloom が相性いいです。


10-5. 調整しやすいパラメータ

ループ直前判定の時間

durationSeconds - mmdRuntime.currentTime < 0.5

この 0.5 は「ループの何秒前から光らせるか」です。 短くすると控えめ、長くすると演出感が強くなります。

  • 0.2 … かなり控えめ
  • 0.5 … ちょうどいい
  • 1.0 … 演出強め

bloomの強さ

pipeline.bloomWeight = 1.0; // ループ直前
pipeline.bloomWeight = 0.3; // 通常時

ここはシーン全体の明るさ次第です。 草シェーダや空の色が明るい場合は、1.0 だと強すぎることもあります。


10-6. さらに自然にしたい場合(任意)

今のコードでも十分ですが、気になる場合は「段階的に戻す」こともできます。

たとえば、ループ直前にいきなり 1.0 にするのではなく、残り時間に応じて徐々に上げる方法です。

const remain = durationSeconds - mmdRuntime.currentTime;
const t = Math.max(0, Math.min(1, 1 - remain / 0.5)); // 0〜1
pipeline.bloomWeight = 0.3 + 0.7 * t;

ただ、記事としてはまず 固定値で十分 です。 凝り始めると演出調整だけで時間が溶けます。


10-7. 注意点(キャラ複数時)

キャラを複数体にしていても、MmdRuntime を1つで管理しているなら、ループ監視も基本は1箇所でOKです。 ただし将来的に、

  • キャラごとに別モーション
  • キャラごとに別再生タイミング

にしたくなったら、監視処理もキャラ単位に分ける必要が出てきます。

今回の #07 の範囲では、共通モーション1本を全員で使う構成 なので、このままで問題ありません。


10-8. ここまでで #07 の内容はほぼ完成

これで #07 の主題だった

  • MMD導入
  • setupMmd() 分離
  • 複数キャラ化
  • ランダム移動
  • 進行方向への回転
  • 地面吸着
  • ループ監視 + bloom演出

まで、一通りつながりました。

最後にまとめとして、VRでのパフォーマンス所感や、今後やりたいこと(別モデル追加、モーション差し替え、簡易AI移動)を書いて締めると、記事として綺麗に終わります。

11. WebXRでも動かしてみた所感

ここまで実装した状態で、Quest 2 から WebXR に入って動かしてみました。 結論からいうと、想像よりかなり普通に動く という印象でした。

もちろん「何をどこまで載せるか」で負荷は変わりますが、今回の構成(地形 + 草 + MMDキャラ複数体)でも、調整次第で十分遊べるレベルに持っていけます。


11-1. 体感として良かった点

地形の起伏 + 移動がかなり効く

平面の上を歩くだけでもWebXRは楽しいですが、 地形に起伏があるだけで「空間を歩いている感」が一気に出ます。

特に今回のように

  • 地面にノイズで起伏をつける
  • プレイヤー側も地面に吸着して移動する

という構成にすると、VRで見たときの没入感がかなり上がります。


草の大量配置は見た目コスパが高い

thin instances は本当に強いです。 1本ずつメッシュを作るのではなく、同じ草メッシュを大量にインスタンス化 しているので、見た目の密度に対して比較的軽いです。

さらに風揺れを入れると、VR空間で止まって見えにくくなるので、 「そこに世界がある感」が出しやすいです。


MMDキャラが動いているだけで空間が生きる

キャラが1体いるだけでも印象は変わりますが、複数体がランダム移動していると、 静的なデモ感が薄れて、かなり“場”っぽくなります。

今回の構成だと、キャラの移動はシンプルでも

  • ランダムターゲット移動
  • 進行方向へ回転
  • 地面吸着

が入っているので、見た目の説得力が高いです。


11-2. 気をつけたほうがいい点(VR向け)

① 草の本数は最初から盛りすぎない

thin instances でも、VRは通常表示より負荷が上がりやすいです(左右2眼分の描画になるため)。

なので最初は、たとえば

  • 10万本
  • 20万本
  • 30万本

くらいから増やしていって、様子を見るのがおすすめです。 PCブラウザで余裕でも、Quest実機だと急に重くなることがあります。


② シャドウは効くけど重い

影は見た目に効きますが、VRではコストも大きいです。 特に

  • シャドウマップ解像度
  • 影を落とすキャラ数
  • 透過物(草)周辺の見え方

あたりは影響が出やすいです。

今回みたいに「草はシェーダでそれっぽく」「影は主にキャラと地面」で割り切ると、バランスが取りやすいです。


③ bloomはやりすぎると見づらい

bloom は雰囲気作りに効きますが、VRだと強すぎると

  • まぶしい
  • 輪郭がぼやける
  • 長時間見づらい

になりやすいです。

ループ演出のタイミングだけ少し上げる、普段は控えめ、くらいがちょうど良いです。


11-3. 今回の構成で「効いていた」ポイント

今回の実装で、VRでも比較的安定して見えた要因はこのあたりだと思います。

  • 草を thin instances にしている
  • 草をアルファブレンドではなく アルファテスト寄り にしている
  • キャラの移動ロジックがシンプル(物理演算なし)
  • 地面吸着が Ray ベースで分かりやすい
  • 処理を setupMmd() / vr() などに分けていて調整しやすい

要するに、見た目は増やしているけど、ロジックはできるだけ素直にしている のが効いています。


11-4. 今後やると面白そうな拡張

ここから先は、かなり遊べる余地があります。

  • 別モデル追加(cloneではなく別MMD)
  • モーションの複数化(歩く / 待機 / 向き直り)
  • 簡易AI化(互いに近づきすぎない)
  • 地形に応じた速度変化(坂で少し遅くする)
  • キャラごとの見た目差分(色・スケール・速度)

特に「別モデル + 別モーション」まで入ると、かなり賑やかな空間になります。


11-5. まとめ

WebXRで動かしてみた感想としては、 Babylon.js + thin instances + MMD の組み合わせ、かなり相性がいい です。

最初は「重そう」「難しそう」と感じますが、実際には

  • 地形
  • キャラ
  • VR移動

を段階的に足していけば、ちゃんと形になります。

このあたりまで来ると、もう“技術検証”というより、普通に「小さい作品」を作るフェーズに入ってきます。 次は、キャラの行動パターンや演出を少し増やして、空間としての完成度を上げていくのが面白そうです。