[Astro #75] three-mesh-bvhでハイポリ地形のレイキャスト問題を根絶。テストコード混入バグとボスイベント欠落の修正

[Astro #75] three-mesh-bvhでハイポリ地形のレイキャスト問題を根絶。テストコード混入バグとボスイベント欠落の修正

はじめに

前回(#74)でゲームが完成したと書いたが、テストプレイを続けると複数のバグが出てきた。今回はその修正記録。

最大の問題は、FOREST.glbのハイポリ地形でキャラクターが3秒ほど歩くと突然停止し、カメラだけが動き続けてその後ワープするという現象で、数時間格闘した末にthree-mesh-bvhで根絶できた。

日本語でMeshBVHを扱った記事が4件しかなかったので、記録しておく。

RPG制作をした感想

今回のバグ修正でPCに関してはほぼまともに遊べる状態にはなったと思います。

ただ、地形をジャンプで飛び越えて挟まってしまったり、出られなくなったり、下に落下して二度と這い上がれなくなるようなバグがのこってますが、 これを完全に直すとなると途方もない時間と労力がかかるので、これだけは勘弁してください。🙇‍♀️

本来は、こういうRPGゲームで遊ぶことを想定して Blender でキチンと地形を設計するべきなのですが、フリー素材に頼ってる以上、どうしても品質にばらつきがあったり、そもそもゲームで使う事を想定して作られていないので、地形の作り方に問題があるなど、それならば、Blender地形を自力で修正したらいいのですが、 そこまでのスキルと時間をかける労力もない為、地形に関しては妥協させてください。

挟まって出られなくなった場合は、申し訳ないですが、一度リセットしてデータをロードしてやり直してください。

ジャンプ機能を実装しているので、多少、ハマった場合でも抜け出せる余地は残っていると思います。

プロのゲーム開発の現場でテスターを雇って、一日中壁に当たり続けるテストをしてる動画を見たことがありますが、こういうゲーム制作をしてみてその理由が分かりました。

3DのRPG制作は面白いですが、実際作って見てこんな単純なRPGを作るだけでも、かなり複雑で大変なのもよくわかりました。

もうしばらくは、RPGは作りたくないかな…。

いやでも、今日作ったRPGを機能拡張する修正を今後していくかもしれません。

地形の修正もゆとりがあればやるかも…。

1. three-mesh-bvh によるレイキャスト問題の根絶

1.1 現象

FORESTステージのみ、歩き始めて3秒ほどでキャラクターが完全に停止する。カメラとの追従が切れてカメラだけが移動し、その後キャラクターが突然ワープする。他のステージでは発生しない。

1.2 原因の特定

まず毎フレームのtraverseがキャッシュされずに走っていたことを修正したが、それでも直らなかった。

根本原因はそこではなく、ポリゴン数ではなくメッシュ数にあった。

glb軽量化ツールでFOREST.glbを加工した際に、1枚の地形メッシュが内部で数十〜数百のサブメッシュに細分割されていた。Three.jsのレイキャストは以下の計算量で動く。

通常のレイキャスト:O(n)
 レイ × 全メッシュ × 各メッシュ内の全三角形
 → メッシュ数が増えるほど線形に重くなる

ポリゴン数を削ってもメッシュ数が爆増していたため、レイキャストの処理が60fpsに追いつかなくなり、フレームが詰まってキャラクターの物理演算が破綻していた。

1.3 解決:BVH(境界ボリューム階層)の導入

three-mesh-bvhを導入することで解決した。

npm install three-mesh-bvh --legacy-peer-deps

BVHとは空間をツリー状に分割して管理する手法で、レイキャストの計算量が根本的に変わる。

BVH使用時:O(log n)
 「この方向にはポリゴンがない」枝を瞬時に切り捨て
 → ポリゴン数が100倍になっても数ステップしか増えない

実装はほぼ3行で済む。

// ADVController.tsx

import { MeshBVH, acceleratedRaycast } from 'three-mesh-bvh';

// グローバルに1回だけ差し替える。以降のintersectObjects呼び出しが自動的にBVH経由になる
THREE.Mesh.prototype.raycast = acceleratedRaycast;

そしてキャッシュ構築時(ステージ読み込み後の1回)にBVHを全メッシュへ一括構築する。

// BVH構築(キャッシュ確定後に一度だけ実行)
if (isCacheInitialized.current && !bvhBuiltRef.current) {
  for (const mesh of cachedMeshes.current) {
    if (mesh.geometry && !mesh.geometry.boundsTree) {
      mesh.geometry.boundsTree = new MeshBVH(mesh.geometry);
    }
  }
  bvhBuiltRef.current = true;
  console.log(`🚀 BVH_BUILT: ${cachedMeshes.current.length}個のメッシュにBVHを構築しました`);
}

BVH構築自体はステージ読み込み時の1回だけなので、ランタイムへの追加負荷はほぼゼロ。これでFORESTの停止・ワープ問題が完全に消えた。

1.4 補足:キャッシュとBVHの関係

今回の最終的な構成は以下になっている。

ステージ読み込み時(1回)
  ├ traverse → メッシュ配列を構築(Material2など不要メッシュを除外)
  └ MeshBVH  → 各メッシュのgeometryにBVHツリーを構築

毎フレーム(useFrame内)
  └ cachedMeshes.current を参照するだけ(traverseもBVH構築もしない)

traverseのキャッシュ化は「走査コスト」を下げる。BVHは「判定コスト」を下げる。両方必要だった。

ステージ切り替え時にはキャッシュとBVHフラグを両方リセットして次のステージで再構築する。

// initialPosition変更(ステージ切り替え)を検知して両方リセット
useEffect(() => {
  cachedMeshes.current = [];
  isCacheInitialized.current = false;
  bvhBuiltRef.current = false;
  // ...
}, [initialPosition]);

2. ENEMY_BUNYA テストコード混入によるエンディング誤起動バグ

2.1 現象

雑魚キャラを倒しただけでエンディングが始まる。

2.2 原因

handleBattleEnd内のVICTORY判定に、テスト用雑魚敵ENEMY_BUNYAがラスボスと同列に書かれたままになっていた。

// 修正前:テスト用雑魚がラスボスと同じ扱いになっている
else if (activeEnemyId === 'BOSS_AKUMA' || activeEnemyId === 'ENEMY_BUNYA') {
  setStoryStep(4); // ← ENEMY_BUNYAを倒してもstoryStepが4になる
  ...
}

storyStepが4になると、次のダイアログ送りのタイミングでhandleAdvanceDialogue内の以下の条件に引っかかってエンディングが起動する。

if (storyStep === 4) {
  setIsEndingActive(true); // ← 雑魚を倒した後の会話でも発火してしまう
}

2.3 修正

ENEMY_BUNYAをVICTORY条件から除外した。

// 修正後:本番ボスのみ
else if (activeEnemyId === 'BOSS_AKUMA') {
  setStoryStep(4);
  ...
}

ENEMY_BUNYAはバトルログ上のコメントに名前が残っているだけで、ゲームバランス上の影響はなし。


3. BOSS_HACKLOAD 接触イベントの欠落

3.1 現象

FORESTステージでBOSS_HACKLOADに近づいてもボストークが発生せず、バトルに突入しない。

3.2 原因

useFrame内のボス接触検知ロジックを確認すると、BOSS_AKUMA(CAVEステージ)の処理しか存在しなかった。BOSS_HACKLOAD(FORESTステージ)の処理が丸ごと実装されていなかった。

// 修正前:AKUMAしかない
useFrame(() => {
  if (STAGE_CAVE && storyStep === 3) {
    // BOSS_AKUMAの接触検知
  }
  // ← BOSS_HACKLOADが存在しない
});

handleAdvanceDialogueにはダイアログ終了後のエンカウント起動コードが存在していたが、そこへ到達するための入口(接触検知)が抜けていた。

3.3 修正

FORESTステージのBOSS_HACKLOAD接触検知ブロックを追加した。

useFrame(() => {
  // 【追加】FOREST ボス接触イベント
  if (gameState === 'EXPLORE' && currentStageId === 'STAGE_FOREST' && storyStep === 1) {
    const hackloadNpc = stageConfig.npcs?.find((n: any) => n.id === 'BOSS_HACKLOAD');
    if (hackloadNpc) {
      const hackloadPos = new THREE.Vector3(...hackloadNpc.position);
      if (avatarPos.distanceTo(hackloadPos) < 2.2) {
        bossTalkTriggeredRef.current = true;
        setDialogueList([
          { speaker: "HACKLOAD", text: "…侵入者か。この森の感情はすべて我が糧となった。" },
          { speaker: "イレイナ", text: "街の人たちの感情を返してください!" },
          { speaker: "HACKLOAD", text: "ならば力で証明してみせろ。" },
        ]);
        setDialogueIndex(0);
        setIsDialogueActive(true);
        setIsMoving(false);
      }
    }
  }

  // 既存:CAVE ボス接触イベント
  if (gameState === 'EXPLORE' && currentStageId === 'STAGE_CAVE' && storyStep === 3) {
    // BOSS_AKUMA の処理
  }
});

ダイアログ終了後のバトル起動は既存のhandleAdvanceDialoguestageConfig.encounterConfig?.battleStageIdの経路を流れるため、そちらは変更不要だった。


4. 箒モードの移動速度が逆転していた問題

箒モード(isBroomMode)で速度が上がるはずが、通常歩行より遅くなっていた問題。

今回のBVH修正でレイキャストの詰まりが解消されたことで同時に直った。処理落ちによってdt(デルタタイム)が異常な値になり、速度計算が壊れていたことが原因だった。コードの変更は不要。


まとめ

問題原因対処
FOREST停止・ワープハイポリ地形でレイキャストの計算量超過three-mesh-bvh導入でO(n)→O(log n)
雑魚でED誤起動ENEMY_BUNYAのテストコードが本番に混入VICTORY判定からENEMY_BUNYAを除外
ボスイベント未発動BOSS_HACKLOADの接触検知ブロックが欠落useFrameに検知ロジックを追加
箒速度が逆転処理落ちによるdt異常(BVH修正で連鎖解決)BVH導入で根本解消

three-mesh-bvhはThree.jsでリアルな地形を扱うゲームなら最初から入れておくべきライブラリだった。acceleratedRaycastを差し込むだけで既存のintersectObjects呼び出しがすべて自動的にBVH経由になるので、導入コストもほぼゼロ。日本語の記事がほぼない割に効果が絶大なので、Three.jsゲーム開発者には広めたい。