[Babylon.js #09] MMD隊列に焚き火休憩イベントを追加する — Hunyuan3D製モデルを読み込む

1. 今回やること

前々回の記事「#07 MMD party formation follow」では、Babylon.js と babylon-mmd を使って、MMD キャラクターが隊列を組んでランダム徘徊する仕組みを作りました。本稿はその続編として、同じ隊列システムに「焚き火休憩イベント」を追加していきます。

具体的には、ノイズで生成した草原地形の上を MMD パーティが徘徊しているときに、

  • しばらく通常のランダム移動をしたあと、
  • 一定条件を満たすと、フィールド上の焚き火地点まで歩いて集合し、
  • 焚き火を囲むように円陣を組んでしばらく休憩し、
  • 休憩が終わったら再び隊列を組み直してランダム徘徊に戻る、

という一連の流れを、状態マシンとして実装します。

焚き火の 3D モデル自体は、Tencent の Hunyuan3D をローカル環境で動かし、手元で作成した焚き火画像からメッシュ+テクスチャを生成したものを glb でエクスポートし、Babylon.js から読み込んで利用します。生成 AI 製の小物モデルを、そのままゲーム内ギミックとして組み込むところまでを一通り見ていきます。

前回の記事:

■ VMDのポーズデータ制作者

  • さわのつり 様

動画(Youtube):

動画(PC):

2. 前回までのおさらい

まずは前回の記事「#07 MMD party formation follow」で実装した内容を軽く振り返ります。

隊列追従(FOLLOW_SPACING)

複数の MMD キャラクターに対して、先頭キャラの移動履歴(位置+向き)をサンプリングし、 FOLLOW_SPACING だけ距離をずらして過去の履歴を参照させることで、後続キャラが自然に隊列を維持しながら歩くパーティシステムを実装しました。

地面への吸着(Ray による高さ補正)

Babylon.js の Ray を使って、各キャラの足元から地形へ垂直レイキャストを行い、 ノイズ地形の凹凸に合わせてキャラクターの高さを補正する仕組みも組み込み済みです。

これにより、徘徊 AI としての最小セットはすでに完成しており、 キャラ数を増やしたり、フィールドを拡張したりすることも容易になっています。

#08 とのつながり

#08 では「メビウス帯の上を歩く MMD Walker」という別テーマを扱いましたが、 babylon-mmd の操作には慣れてきたため、今回はその流れで 「イベント的な動き(焚き火 → 休憩 → 再集合)」 といった 行動状態の切り替え(ステートマシン) を導入する回になります。

徘徊 → 集合 → 円陣 → 再出発 というループを作ることで、 単なる “歩くデモ” から、より “生活感のある MMD パーティ” に近づけていきます。

3. Hunyuan3D で焚き火モデルを作る

今回の焚き火モデルは、Hunyuan3D をローカル環境に入れて 画像 → 3D メッシュ(glb) を生成しました。 既存の minimal_demo.py から「画像入力〜glb出力」部分だけを抜き出し、焚き火専用のスクリプトにしています。

3.1 minimal_demo.py のカスタマイズ

Hunyuan3D 本体のリポジトリに含まれている minimal_demo.py から、 形状生成(ShapeGen)+テクスチャ生成(Paint)に相当する処理だけを取り出して、 次のようなシンプルなスクリプトを用意しました。

from PIL import Image

from hy3dgen.rembg import BackgroundRemover
from hy3dgen.shapegen import Hunyuan3DDiTFlowMatchingPipeline
from hy3dgen.texgen import Hunyuan3DPaintPipeline

model_path = 'tencent/Hunyuan3D-2'
pipeline_shapegen = Hunyuan3DDiTFlowMatchingPipeline.from_pretrained(model_path)
pipeline_texgen = Hunyuan3DPaintPipeline.from_pretrained(model_path)

image_path = 'assets/takibi.png'  # 自作の焚き火画像
image = Image.open(image_path).convert("RGBA")
if image.mode == 'RGB':
    rembg = BackgroundRemover()
    image = rembg(image)

mesh = pipeline_shapegen(image=image)[0]
mesh = pipeline_texgen(mesh, image=image)
mesh.export('takibi.glb')

ポイントは以下の通りです。

  • image_path に 自作した焚き火イラスト(PNG)を指定するだけでよい
  • 背景付きの画像を使う場合は、BackgroundRemover を通して 背景透過済みの RGBA にしてから ShapeGen に渡す
  • pipeline_shapegen で粗い 3D 形状を生成し、pipeline_texgen で同じ画像を条件としてテクスチャを書き込む
  • 最後に mesh.export('takibi.glb') で Babylon.js / Blender から扱いやすい glb 形式として出力

初回実行時は、tencent/Hunyuan3D-2 の重いモデルを Hugging Face からダウンロードするため、 GPU・回線ともにかなり時間がかかる点には注意してください(一度ダウンロードされればキャッシュから読み込まれます)。

3.2 Blender での確認

生成した takibi.glb は、そのまま Babylon.js に放り込む前に、一度 Blender で中身を確認しておきました。

  • glb をインポートすると、すでに ベースカラー用のテクスチャがノードに接続された状態 になっている

  • 必要に応じて

    • 全体スケールの調整
    • 原点位置(Origin)の位置合わせ
    • 余計なメッシュやマテリアルスロットの整理 などを軽く行っておくと、ゲーム側で扱いやすくなる

(ここに Blender のスクリーンショットを 1 枚)

この段階で「焚き火らしく見える」こと、マテリアルが正しく貼られていることが確認できたので、 あとは public/models/takibi.glb として Babylon.js 側に配置し、 シーン内のキャンプファイヤー位置にスポーンさせて使っていきます。

4. 焚き火モデルを Babylon.js から読み込む

4.1 public/models に配置する

生成した焚き火モデル(takibi.glb / bonfire2.glb など)は、Vite プロジェクトの

public/models/takibi.glb
# or
public/models/bonfire2.glb

のように public 配下 に置いておきます。

Vite の場合、public ディレクトリ以下はビルド時にそのままルートにコピーされるので、

  • public/models/xxx.glb → 実行時のパスは /models/xxx.glb

として、そのまま SceneLoader.ImportMeshAsync から叩くことができます。 /src からの相対パスではなく、常にルート相対パス /models/... になる点だけ覚えておけば OK です。

4.2 campfireRoot の作成と SceneLoader.ImportMeshAsync

焚き火は「シーン内のある一点を中心に、モデルをまとめて置きたい」ので、 まずは TransformNode でルートノード campfireRoot を作り、焚き火の座標に配置します。

// 焚き火のルート
campfireRoot = new TransformNode("campfire_root", scene);
campfireRoot.position.copyFrom(CAMPFIRE_POS); // or campfirePos
campfireRoot.setEnabled(false);               // 最初は非表示

実際のメッシュは SceneLoader.ImportMeshAsync で読み込み、 一番メインになりそうな MeshcampfireRoot の子としてぶら下げます。

SceneLoader.ImportMeshAsync("", "/models/", "bonfire2.glb", scene)
  .then((result) => {
    const main = result.meshes.find((m) => m instanceof Mesh) as Mesh | undefined;
    if (!main) return;

    main.parent = campfireRoot!;
    main.position.set(0, 5, 0); // 地面に少しめり込む場合は Y を持ち上げる
    main.rotation.set(0, 0, 0);
    main.scaling.scaleInPlace(campfireScale); // config 側でスケールを一元管理
  })
  .catch((e) => {
    console.error("Failed to load bonfire.glb", e);
  });

最初に読み込んだときはスケールが合っておらず、 草原ノイズ地形の中に“米粒サイズの焚き火”が埋もれていてほぼ見えない 状態になりました。 そこで一度 Babylon.js のインスペクタでスケールを確認し、campfireScale に倍率を入れてから scaleInPlace で一気にスケーリングするようにしています。

コード上では最終的に

  • 位置ベクトル:campfirePos
  • スケール:campfireScale

のように config.ts 側で管理するようにしておくと、

  • 草原のサイズを変えたときに焚き火の位置だけ差し替える
  • VR 用シーンと PC 用シーンで焚き火のスケールを変える

といった調整が楽になります。

5. MMD隊列ロジックを「焚き火イベント付き」に拡張する

ここからは、#07 で作った「ランダム徘徊+隊列追従」のロジックに対して、

  • たまに焚き火位置へ集合して
  • 円陣でしばらく休憩し
  • 再び隊列を組み直して散開する

というイベントを差し込むための拡張内容を書いていきます。

5.1 PartyMode enum で 4 状態を定義する

まずは「今パーティがどんな状態なのか」を表現するために、シンプルなステートマシンを enum で用意します。

enum PartyMode {
  Roam,      // 普段のランダム徘徊
  GoToFire,  // 焚き火位置へ集合中
  AtFire,    // 焚き火を囲んで待機
  Regroup,   // 再び隊列を組み直す
}

各状態での振る舞いはおおまかに次のようになります。

  • Roam

    • これまで通り、リーダーはランダムなターゲットへ歩き続ける
    • 後続は FOLLOW_SPACING ぶん距離をあけて、履歴を使って追従する
  • GoToFire

    • 各キャラクターに「焚き火の円周上のスロット」を 1 つずつ割り当て
    • そこへ最短ルートで直行する(隊列追従はいったん止める)
  • AtFire

    • 焚き火を中心に向き直り、ポーズに切り替えてしばらく待機
    • 焚き火モデルを有効化して表示
  • Regroup

    • リーダーの背後に再び隊列を組み直す
    • 整列し終わったら Roam に戻り、ランダム徘徊を再開

この 4 状態を scene.onBeforeRenderObservable の中で switch していく形です。


5.2 焚き火周りのスロット計算

焚き火イベントでは、パーティ全員を「焚き火を囲む円周上の座標」にきれいに配置したいので、 まずはスロットとなる位置を計算する関数を用意します。

function computeFireSlots() {
  fireSlots = [];
  const n = roots.length;
  for (let i = 0; i < n; i++) {
    const angle = (2 * Math.PI * i) / n;
    const x = CAMPFIRE_POS.x + Math.cos(angle) * CAMPFIRE_CIRCLE_RADIUS;
    const z = CAMPFIRE_POS.z + Math.sin(angle) * CAMPFIRE_CIRCLE_RADIUS;
    const pos = projectOnGroundXZ(x, z);
    fireSlots.push(pos);
  }
}

単純な円周配置ですが、ここでポイントになるのが projectOnGroundXZ です。

function projectOnGroundXZ(x: number, z: number): Vector3 {
  const rayOrigin = new Vector3(x, RAY_FROM_Y, 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) {
    return new Vector3(x, hit.pickedPoint.y + FOOT_OFFSET_Y, z);
  }
  return new Vector3(x, 0 + FOOT_OFFSET_Y, z);
}
  • XZ 平面上で理想的な円を作ったあと、
  • Ray を下向きに飛ばして実際の地形(ノイズ地形)にスナップする

ことで、「草原ノイズでうねっている地面の上」に、きれいに円陣を組めるようにしています。 #07 でやった「歩行キャラの地面吸着」を、焚き火のスロット計算にも再利用しているイメージです。


5.3 Roam → GoToFire への遷移条件

どのタイミングで焚き火イベントに入るかは、完全なランダムではなく、

  1. 一定回数ランダムターゲットに到達したあとで
  2. そのたびに一定確率でトリガーする

という 2 段階の条件にしています。

const MIN_ROAM_LOOPS_BEFORE_FIRE = 3;        // 焚き火許可までの最低周回数
const FIRE_TRIGGER_PROB_PER_ARRIVAL = 0.4;   // 到達ごとの発火確率(40%)

Roam 状態のターゲット到達処理はこうなります。

if (dist < ARRIVE_DIST) {
  // ターゲット到達
  roamLoopsSinceLastFire++;

  const canTriggerFire = roamLoopsSinceLastFire >= MIN_ROAM_LOOPS_BEFORE_FIRE;
  const shouldTriggerFire = canTriggerFire && Math.random() < FIRE_TRIGGER_PROB_PER_ARRIVAL;

  if (shouldTriggerFire) {
    startGoToFire();
    return; // このフレームはここで終了
  }

  // 焚き火に行かない場合は次のランダムターゲットへ
  target = randomTarget(HALF);
} else {
  moveToward(leader, target, WALK_SPEED);
}
  • 最低 3 回は必ず普通に徘徊させる
  • そのあとでターゲット到達するたびに 40% の確率で焚き火イベントへ

という設計にしていて、 「常に焚き火に向かってしまう」のを避けつつ、時々ふっと休憩が挟まる感じを狙っています。

5.4 AtFire / Regroup の処理フロー

startAtFire : 焚き火円陣へ遷移

焚き火スロットに全員が到達すると startAtFire に入り、ここで

  • MMD モデルを歩きアニメからポーズアニメに切り替え
  • 焚き火モデルを有効化して表示
  • 全員を焚き火の方向へ向ける

といった処理を行います。

function startAtFire() {
  partyMode = PartyMode.AtFire;
  fireTimer = 0;

  setPoseAnimations();          // VPD ポーズへ切り替え

  if (bonfire) bonfire.setEnabled(true);  // 焚き火モデルを表示

  roots.forEach((root) => {
    faceToFire(root);           // 全員を焚き火の方向へ
  });
}

向きの調整は faceToFire でやっています。

function faceToFire(root: TransformNode) {
  const pos = root.position;
  const dir = CAMPFIRE_POS.subtract(pos);
  dir.y = 0;
  if (dir.lengthSquared() < 1e-6) return;
  dir.normalize();

  const yaw = Math.atan2(dir.x, dir.z) + MODEL_FORWARD_OFFSET;
  const dt = scene.getEngine().getDeltaTime() / 1000;
  root.rotation.y += shortestAngleDelta(root.rotation.y, yaw) * Math.min(1, dt * YAW_LERP_SPEED);
}

updateAtFire 一定時間待機

AtFire 状態の updateAtFire では、特に移動はさせず

  • タイマー fireTimer を進める
  • 向き・地面吸着だけは毎フレーム維持

という最小限の処理にしています。

function updateAtFire(dt: number) {
  fireTimer += dt;

  roots.forEach((root) => {
    faceToFire(root);
    snapToGround(root);
  });

  if (fireTimer >= CAMPFIRE_WAIT_SECONDS) {
    startRegroup();
  }
}

CAMPFIRE_WAIT_SECONDS(今回は 8 秒)経過で、隊列再構成フェーズに進みます。

Regroup → Roam への復帰

startRegroup では、リーダーの背後に等間隔で並ぶようなスロットを計算しておきます。

function computeRegroupSlots() {
  regroupSlots = [];

  const { forward } = getLeaderForwardRight();
  const leaderPos = leader.position.clone();

  for (let i = 0; i < roots.length; i++) {
    const backOffset = forward.scale(-FOLLOW_SPACING * i);
    const base = leaderPos.add(backOffset);
    const pos = projectOnGroundXZ(base.x, base.z);
    regroupSlots.push(pos);
  }
}

あとは updateRegroup で全員をそれぞれのスロットに移動させ、 全員到達したら resumeRoam で元のランダム徘徊に戻します。

function updateRegroup(dt: number) {
  let allArrived = true;

  for (let i = 0; i < roots.length; i++) {
    const root = roots[i];
    const dst = regroupSlots[i];
    moveToward(root, dst, WALK_SPEED);
    snapToGround(root);

    const dist = Vector3.Distance(root.position, dst);
    if (dist > ARRIVE_DIST) {
      allArrived = false;
    }
  }

  if (allArrived) {
    resumeRoam();
  }
}

resumeRoam の中では

  • 履歴 history をリセットし直す
  • 焚き火モデルを非表示に戻す
  • 歩行アニメを再セットして Roam に復帰

という後片付けを一括で行っています。

function resumeRoam() {
  partyMode = PartyMode.Roam;

  history.length = 0;
  history.push({
    pos: leader.position.clone(),
    yaw: leader.rotation.y,
  });

  target = randomTarget(HALF);
  roamLoopsSinceLastFire = 0;

  if (bonfire) bonfire.setEnabled(false);  // 焚き火モデルを消す

  setWalkAnimations(); // 再び歩きアニメへ
}

5.5 MMDランタイムとの連携

最後に、この焚き火イベントを MMD アニメーション とどう繋いでいるかを整理しておきます。

歩きモーションの登録

BVMD からロードした「歩きモーション」は、applyMotionToModel で各キャラの mmdModel に登録しつつ、 あとで状態切り替えに使えるよう、名前やハンドルを __walk* として保存しています。

function applyMotionToModel(mmdModel: any, bvmd: any) {
  if (typeof mmdModel.addAnimation === "function") {
    mmdModel.addAnimation(bvmd);
    mmdModel.setAnimation(bvmd.name);
    mmdModel.__walkAnimName = bvmd.name;
  } else {
    const handle = mmdModel.createRuntimeAnimation(bvmd);
    mmdModel.setRuntimeAnimation(handle);
    mmdModel.__walkRuntimeHandle = handle;
  }
}

VPD ポーズの登録

ポーズ用 VPD は、VpdLoader から読み込んで __pose* として保持します。

if (spec.poseFilePath) {
  const poseAnimName = `${spec.name}_pose`;
  const poseAnim = await vpdLoader.loadAsync(poseAnimName, spec.poseFilePath);

  if (typeof anyModel.addAnimation === "function") {
    anyModel.addAnimation(poseAnim);
    anyModel.__poseAnimName = poseAnim.name;
  } else if (typeof anyModel.createRuntimeAnimation === "function") {
    const handle = anyModel.createRuntimeAnimation(poseAnim);
    anyModel.__poseRuntimeHandle = handle;
  }
}

歩き/ポーズの切り替え

焚き火の状態遷移に合わせて、setWalkAnimations / setPoseAnimations で全員のアニメーションを切り替えます。

function setWalkAnimations() {
  if (!mmdRuntime) return;

  for (const model of models) {
    const anyModel = model as any;
    if (anyModel.__walkAnimName && typeof model.setAnimation === "function") {
      model.setAnimation(anyModel.__walkAnimName);
    } else if (anyModel.__walkRuntimeHandle && typeof anyModel.setRuntimeAnimation === "function") {
      anyModel.setRuntimeAnimation(anyModel.__walkRuntimeHandle);
    }
  }

  if (typeof (mmdRuntime as any).seekAnimation === "function") {
    (mmdRuntime as any).seekAnimation(0, false);
  }
  if (typeof (mmdRuntime as any).playAnimation === "function") {
    (mmdRuntime as any).playAnimation();
  }
}

function setPoseAnimations() {
  if (!mmdRuntime) return;

  for (const model of models) {
    const anyModel = model as any;
    if (anyModel.__poseAnimName && typeof model.setAnimation === "function") {
      model.setAnimation(anyModel.__poseAnimName);
    } else if (anyModel.__poseRuntimeHandle && typeof anyModel.setRuntimeAnimation === "function") {
      anyModel.setRuntimeAnimation(anyModel.__poseRuntimeHandle);
    }
  }

  if (typeof (mmdRuntime as any).seekAnimation === "function") {
    (mmdRuntime as any).seekAnimation(0, false);
  }
  if (typeof (mmdRuntime as any).playAnimation === "function") {
    (mmdRuntime as any).playAnimation();
  }
}
  • Roam / GoToFire / Regroup では 歩きアニメ
  • AtFire では ポーズアニメ

という切り替えが入ることで、 「ノイズ地形を行軍しているパーティが、たまに焚き火に集まって一息つく」 という MMD × Babylon.js のイベントシーンが完成します。

6. 実行結果スクショと所感

最後に、今回の実行結果を軽く振り返っておきます。

6.1 スクリーンショット

この記事では、以下のようなスクショを載せています。

  • ランダム徘徊中の隊列

    • ノイズ地形の上を、#07 と同じ要領でリーダー+フォロワーが行軍している状態
    • 遠目に見ると、草原の中をパーティがふらふら散歩している感じになる
  • 焚き火集合中の円陣シーン

    • CAMPFIRE_POS 付近に全員が集まり、円周上のスロットにそれぞれ配置された状態
    • VPD ポーズに切り替わって焚き火の方を向き、中央に Hunyuan3D で自作した焚き火モデルが置かれている

MMD モデル自体は歩きモーションのままでも成立しますが、 ポーズ用 VPD を挟むことで「ちゃんと休んでいる感」が出て、スクショ映えがだいぶ良くなりました。

6.2 パラメータ調整メモ

実際に動かしてみて、よく触ったパラメータをいくつかメモしておきます。

  • CAMPFIRE_CIRCLE_RADIUS

    • 焚き火を中心にした円陣の半径
    • 小さすぎるとキャラ同士が密集しすぎて、モデル同士がめり込みやすい
    • 大きくしすぎると、焚き火から離れた「囲んでいる感」の薄い配置になる
    • 使用している MMD モデルのスケールや人数に合わせて、10〜20 あたりで調整するのが良さそうでした
  • 焚き火モデルのスケールと位置

    • 草原ノイズがそこそこ密に生えているので、最初は焚き火が「草の中の米粒」状態になりがち
    • campfireScale(スケーリング)と campfireRoot.position(特に Y)をかなりシビアに調整する必要があった
    • 草より少しだけ高い位置に持ち上げる (y にオフセットを足す) と、遠目でも炎のシルエットが分かりやすくなる
  • 待機時間 CAMPFIRE_WAIT_SECONDS

    • 短すぎると「来たと思ったらすぐ散開してしまう」ので、雰囲気が出ない
    • 長すぎると、徘徊 → 焚き火 → 徘徊… のサイクルが間延びして見える
    • 今回は 8 秒にして、YouTube ショート向けの尺でも 1 サイクルがしっかり入るようにしている

6.3 作ってみた所感

  • #07 で作った隊列ロジックをそのまま活かしつつ、「状態マシン」として整理し直すことで
    • 焚き火イベントのような「一時的な振る舞い」を割と素直に差し込めた
  • Hunyuan3D で自作した焚き火モデルを実際に Babylon.js 上で使ってみると
    • 「画像 → 3D メッシュ → Vite プロジェクトに組み込み」という一連のパイプラインが手になじんできた感触がある
  • 今回の仕組みは、焚き火以外にも
    • ベンチで座る
    • 拠点に帰って寝る
    • ボス部屋前で集合する といったイベントに横展開しやすいので、今後の MMD × Babylon.js シリーズのベースとしても使えそうです。

次は、焚き火シーンを VR から眺めたり、座り/寝ポーズのバリエーションを増やして もう少し「キャンプ感」を出していきたいところです。

7. おまけ / 今後やりたいこと

最後に、今回の焚き火シーンをベースに「ここから先に広げたいところ」をメモしておきます。

7.1 炎アニメーションをちゃんと作りたい

今回の焚き火モデルは「静止メッシュ+マテリアル」止まりなので、炎の表現はまだ最低限です。 今後やってみたい案としては:

  • ライトのゆらぎ

    • 焚き火用の PointLight / SpotLight を追加して、
    • フレームごとに強度 (intensity) や色味を少しずつランダム変動させる
    • 低コストで「炎っぽい陰影」が出るので、まずはここからが現実的
  • パーティクルで火の粉を飛ばす

    • ParticleSystem でオレンジ〜黄色の小さなパーティクルを上方向に飛ばす
    • 寿命を短くして、焚き火の上にだけひらひら舞うような見た目にする
  • フリップブック / シェーダーで燃え方を表現

    • テクスチャアニメ(フリップブック)で、数枚の炎テクスチャを切り替える
    • もしくはノイズを使ったシェーダーで、UV を歪ませて炎の揺らぎを出す
    • ここまでやると本格的な「焚き火シェーダー」になってくるので、別エピソードで腰を据えてやりたいところ

7.2 VR モード連動の「焚き火イベント」

現状は「隊列の状態マシン」が焚き火イベントを管理していますが、 今後は VR モード側のインタラクション とも絡めてみたいです。

たとえば:

  • プレイヤー(VR カメラ)が焚き火付近 (CAMPFIRE_POS 周辺) に入ったら
    • パーティの PartyMode を強制的に GoToFire にする
    • プレイヤーが「キャンプ地に戻る」と、メンバーも帰ってきてくれるイメージ
  • 一定時間、焚き火のそばにいたら
    • パーティが自動で円陣を組み、全員ポーズ
    • その状態をそのまま VR スクリーンショットや動画撮影に使う

「プレイヤーが焚き火の主で、パーティがそこに集合してくる」ような構図にできるので、 VR 映えするシーン作りにも使えそうです。

7.3 Hunyuan3D でキャンプ場セットを量産したい

焚き火が 1 つできたので、次の小物も Hunyuan3D で量産していきたいです。

  • テント
  • キャンプ用チェア
  • 折りたたみテーブル
  • マグカップやケトル
  • ランタン など

これらを個別の glb として作り:

  • public/models/ 以下に配置
  • Babylon.js 側でそれぞれ専用の TransformNode を用意
  • 好きなレイアウトで「キャンプ場」を組んでいく

という形にすれば、MMDパーティ+キャンプ場+ノイズ地形+VR という かなり「世界観のある一枚絵/一動画」を量産できる土台になりそうです。


今回の焚き火イベントは、

「隊列ロジックにイベントを1つ挟むと、世界が一気に“生活感”に寄る」

という感触があったので、今後も キャンプ・拠点・街・ボス前など、いろいろな「イベント地点」を増やしていきたいところです。