[Astro #84] WITコアエンジンにおける二重ループ内探索の全廃とスポーン時パラメータ焼き付けによるランタイムクラッシュの根本解決

[Astro #84] WITコアエンジンにおける二重ループ内探索の全廃とスポーン時パラメータ焼き付けによるランタイムクラッシュの根本解決

本日の開発目的

背景と現状の課題

現在開発を進めているReact Three Fiber(R3F)を用いた3Dシューティングゲームのコアエンジン(WITManager.tsx)において、ステージ2の追加および敵キャラクター、破壊可能オブジェクトのデータ駆動拡張(Enemies.jsonへの定義追加など)を行いました。

これに伴い、特定の敵キャラクター(動的に投下されるミサイルオブジェクトや新規ギミックなど)が画面内にスポーン、あるいは自機や弾と接触した瞬間に、メインループがフリーズしゲームが停止するランタイムエラーが発生していました。

原因を調査したところ、毎フレーム(60fps)駆動するuseFrame内の衝突判定二重ループ(O(N×M)O(N \times M))および描画バッファへのマッピング処理において、出現中の敵オブジェクトが持つenemyIdをキーとし、マスターデータの配列(WIT_ENEMIES.enemies)から毎回線形探索(.find())を実行している箇所が確認されました。

タイムラインデータとマスターデータの間でIDの文字列が完全一致していない、あるいはデータ定義が不足しているオブジェクトが生成された際、.find()の戻り値がundefinedとなり、その状態でhitOffsetなどのプロパティを強制参照(as any)しようとしたためにTypeErrorが誘発され、メインループ全体を巻き込んでクラッシュする挙動となっていました。

リファクタリングの目的

本リファクタリングは、上記に伴うランタイムエラーの完全な解消と、メインループにおける計算コストの最適化を目的として実施されました。具体的な目的は以下の通りです。

  1. クラッシュの構造的根絶: データソース側の不備(設定漏れやIDのミスマッチ)があった場合でも、不完全なデータ参照による例外を発生させず、安全なデフォルト値(フォールバック値)を自動的に配給してゲーム進行を継続できる堅牢なデータ解決構造を確立します。
  2. メインループ内の計算コスト排除(パフォーマンス最適化): 毎フレーム、かつループの最深部で高頻度に実行されていた不変データへの動的探索(.find()による全走査)を完全に全廃します。 敵キャラクターが生成される瞬間(スポーン時)に、必要な静的パラメータ(判定半径、各種オフセット等)を一度だけルックアップし、エンティティ自身に直接焼き付けて保持させる方式(インスタンス保持方式)へと移行します。これにより、ループ内でのデータ検索コストを完全にゼロ(オブジェクトのプロパティ直接参照のみ)にし、CPUスパイクや処理落ちの懸念を根本から排除します。
  3. 型キャストの排除とコードのクリーン化: 各処理ブロックに分散していた冗長なルックアップ処理や、型安全性を放棄した型キャスト(as any)を整理・統合し、保守性の高いソースコード構造へと再構築します。

発生していた問題

1. ランタイムエラーの発生事象と影響

ステージ2の特定のシーケンスにおいて、特定の敵キャラクターやボス(複葉機ボスなど)が投下する破壊可能ミサイルオブジェクトが出現した瞬間、またはそれらに対して自機の通常弾やミサイルが接触した瞬間に、画面が完全にフリーズする現象が発生していました。

本ゲームのメインループおよび描画駆動はReact Three Fiber(R3F)のuseFrameフックに依存しているため、この内部で例外がスローされたことにより、Three.jsのレンダリングループ(requestAnimationFrameの巡回)が即座に停止し、ゲームオーバー処理やUIの更新を含むすべてのシステムが巻き込まれてフリーズする状態となっていました。

2. 検出されたエラーログ

ブラウザのコンソールに記録されていたエラーメッセージおよびスタックトレースの詳細は以下の通りです。

Uncaught TypeError: Cannot read properties of undefined (reading 'hitOffset')
    at Object.current (WITManager.tsx:1370:52)
    at update (chunk-VOXEWE72.js?v=f9c69909:9895:22)
    at loop (chunk-VOXEWE72.js?v=f9c69909:9916:17)

このエラーは、WITManager.tsx内の通常弾と敵の衝突判定処理(1370行目付近)、プレイヤー本体と敵の体当たり判定処理(1660行目付近)、ミサイルと敵の衝突判定処理、および後半のJSXによる敵グラフィック描画マッピング処理(visualEnemies.map)の複数箇所で同様に発生する潜在的リスクを抱えていました。

3. 例外が発生した根本原因

このランタイムクラッシュの根本原因は、メインループの内部において「不変の静的データに対する動的な線形探索」と「型安全性を欠いたオブジェクトプロパティの強制参照」が組み合わさっていた点にあります。

  • メインループ内での線形探索: 毎フレーム駆動するuseFrameの内部で、現在画面上に存在しているアクティブな敵エンティティを走査する際、オブジェクトが持つenemyIdを基準にして、マスターデータであるWIT_ENEMIES.enemiesEnemies.json)の配列に対して毎回.find()による全走査(線形探索)を行っていました。
  • データ定義のミスマッチと未定義値の返却: ステージ2の開発に伴い新設された破壊可能オブジェクト(WIT_ENEMY_STYLIZED_WW1_PLANE_MISSILEなど)において、タイムラインデータやスポーン処理側が指定するID文字列と、Enemies.jsonに登録されているID定義との間に記述の不一致(タイポ等)がある、あるいは定義自体が未登録の状態となっていました。そのため、.find()は該当データを発見できず、一律でundefinedを返却していました。
  • 強制参照によるクラッシュ: コード上では、TypeScriptの型エラーをバイパスするために(enemyConfig as any).hitOffset(enemyConfig as any).heightOffsetといった明示的な型キャスト(as any)を用いて直接プロパティの値を読み込もうとしていました。これにより、データの検索に失敗してenemyConfigundefinedになった瞬間、JavaScriptのランタイムが例外をスローし、防衛機構のないままメインループが巻き込まれて破綻する構造になっていました。

修正の変遷と課題

1. 初期対応としてのインライン・フォールバックとその限界

ランタイムエラーの発生直後、応急処置として例外の発生箇所にオプショナルチェイニング(?.)や論理和演算子(||)を導入する対応を行いました。具体的には、undefined を検知した際に安全なデフォルト値を配給するコード(例: enemyConfig?.hitOffset || 0)への差し替えです。

しかし、このインラインによるフォールバック処理は以下の課題を有していました。

  • ロジックの分散と重複: 同様の設定ルックアップおよびフォールバックの処理が、「自機通常弾 vs 敵」、「ミサイル vs 敵」、「敵本体 vs プレイヤー(体当たり判定)」の各衝突判定ブロック、および「JSX描画レイヤー(visualEnemies.map)」の計4箇所に分散してインラインで記述される結果となりました。これによりコードの重複が発生し、保守性を低下させていました。
  • 根本原因の未解決: インラインでのガードはクラッシュを回避する一時的な処理に過ぎず、タイムライン側とマスターデータ(Enemies.json)のデータ不整合そのものを解決、あるいは検知する仕組みとしては不完全な状態でした。

2. メインループ内における計算量爆発(負荷面)の課題

インラインでの安全対策を施した後も、ゲームプログラミングのパフォーマンス観点から、極めて致命的な設計上の課題が残されていました。

  • O(N×M×K)O(N \times M \times K) の計算量: 毎フレーム(60fps)駆動する useFrame 内において、特に「画面上の自機弾数(NN) × 画面上の敵オブジェクト数(MM)」で実行される二重ループの最深部で、毎回マスターデータの配列(要素数 KK)に対する .find()(線形探索)を走らせる構造になっていました。これにより、最深部の総計算量は O(N×M×K)O(N \times M \times K) となり、オブジェクトの増加に対して指数関数的に負荷が増大する状態でした。
  • CPUスパイクおよび処理落ちの誘発: シューティングゲームの性質上、ステージが進行して敵の出現数や弾数が増大した際、この線形探索がレンダリングフレームの許容時間(約16.6ms)を圧迫し、深刻なCPUスパイク(フレームレートの瞬間的な低下)や処理落ちを引き起こす地雷となっていました。
  • 型キャストへの依存継続: ループ内部で静的なデータ構造が担保されていないため、TypeScriptのコンパイルを通すために各所で (enemyConfig as any) のような強制キャストに依存せざるを得ず、静的解析による最適化や安全性の恩恵を十分に受けられない状態が続いていました。

最終的な実装内容(リファクタリングの事実)

1. アーキテクチャの変更:「スポーン時パラメータ焼き付け方式」への転換

メインループの最深部で発生していた動的なルックアップおよび配列の全走査を根本から全廃するため、敵オブジェクトの生成フェーズで判定や描画に必要なすべての静的設定を一度だけ抽出し、エンティティ自身に固定値として保持させる「スポーン時パラメータ焼き付け方式」への構造変更を実施しました。

これにより、動的な検索処理をフレームレート駆動(毎フレーム実行)の文脈から切り離し、オブジェクト生成時の単発の処理へと移行させています。

2. スポーン時における静的設定データの即時解決

パラメータの抽出・解決は、以下のオブジェクトが生成および配列へ追加(push)される瞬間に実行されます。

  • タイムラインに基づく雑魚敵・中ボスの発生時: ステージの進行時間(stageAge)がタイムライン(currentStageData.timeline)の定義時間に達し、enemiesRef.current.push が実行される瞬間。
  • ボスによる子機オブジェクトの動的生成時: ステージ2ボス(複葉機ボス)の行動パターンに基づき、誘導ミサイル(破壊可能オブジェクトとしての敵エンティティ)がリアルタイムに投下・追加される瞬間。

これらのタイミングにおいて、指定された enemyId(または設定漏れ時のフォールバックID)を基準に、マスターデータ(WIT_ENEMIES.enemies)から一度だけ設定オブジェクトを検索し、その場で各数値を確定させます。

3. エンティティへのプロパティ固着とデフォルト値の配給

ルックアップによって解決された静的パラメータは、生成される敵オブジェクトのインスタンスのプロパティとして直接割り当てられます。固着されるパラメータおよび、データ未定義時(IDのミスマッチを含む)に上流で配給される安全なデフォルト値の定義は以下の通りです。

パラメータ名役割・用途定義がない場合のデフォルト値
hitRadius衝突判定の基準となる球体の半径0.07
hitOffsetモデル中心から判定位置(赤リング)へのY軸高さズレ0
heightOffset3D空間への配置時におけるグラフィックの高さ補正0
scaleGLBモデル描画時の基準スケール倍率[0.05, 0.05, 0.05]
spin自転アニメーション適用の有無(フラグ)true
baseRotation3D空間配置時におけるモデルの初期回転角度(ラジアン)1.57

これらの設定を内包した完全なオブジェクトとして enemiesRef.current 配列に格納するため、以降の処理スレッドに対して不完全なデータ構造(undefined を含むオブジェクト)が混入するのを、スポーンの段階で完全に遮断する実装へと変更されました。

実装による効果と結果

1. メインループにおける計算コストの完全排除

今回のリファクタリングにより、useFrame 内に存在する各種衝突判定ブロック(自機通常弾 vs 敵、ミサイル vs 敵、敵本体 vs プレイヤー)の最深部、および毎フレーム実行されるJSXの描画ループ内から、配列の走査(.find())を伴う動的な検索処理が完全に排除されました。

各敵エンティティが必要なパラメータをあらかじめ自身のプロパティとして保持する構造へ移行したため、衝突判定の計算量が実質的に O(N×M)O(N \times M)(弾数 × 敵オブジェクト数)の純粋な代数・幾何演算のみに圧縮されました。ハッシュマップの参照コストすら発生しない「オブジェクトプロパティの直接参照」となったことで、ルックアップコストは理論上完全にゼロとなり、敵や弾が画面内に大量に同期・表示されるシチュエーションにおいても、CPUスパイク(フレームレートの瞬間的な低下)の発生を防ぎ、実行負荷を大幅に低減させました。

2. ランタイムクラッシュの構造的根絶

不完全なデータや未知のIDに対する防衛策が、データ生成の上流にあたる「スポーンフェーズ」へ集約されました。これにより、タイムラインデータ(Stage2.json 等)から未定義のIDが指定された場合や、新しい敵アセットの設定がマスターデータ側(Enemies.json)に不足している場合でも、生成の段階で安全な初期値(デフォルト値)がオブジェクトへと確実に焼き付けられます。

結果として、衝突判定や描画マッピングを行うメインループの最深部へ undefined なオブジェクトが流入するルート自体が物理的に遮断され、プロパティの強制参照による TypeError を引き起こす余地が構造的に消滅しました。これにより、データ駆動拡張に伴うデータソース側のタイポや不備に対しても、システム全体が巻き込まれてフリーズしない極めて堅牢なゲームエンジンへと最適化されました。

3. ソースコードのクリーン化と見通しの向上

各衝突判定処理や描画処理のブロック内部に分散・重複していた、冗長なデータ検索処理およびそのフォールバック用の記述が一掃されました。

また、動的にマスターデータの型をパースする必要性がなくなったため、TypeScriptのコンパイルを無理やり通すために多用されていた不適切な型キャスト(as any)をループ内部から排除することに成功しました。これにより、静的型解析が本来持つ安全性の恩恵を受けられるようになり、エンジンの主軸となる物理演算ロジックと描画ロジックの見通しが向上し、今後のステージ追加や機能拡張時における保守性が高められました。

4. 通しプレイテストによる動作検証

リファクタリングの適用後、ゲーム起動からステージ1、および今回新規実装されたステージ2のボス撃破、クリアシーケンスの起動に至るまでの通しプレイテストを実施し、詳細な動作検証を行いました。

検証の結果、以前発生していた特定の破壊可能オブジェクト生成時や被弾時におけるランタイムエラーは一切再発せず、完全なゲーム進行の継続を確認いたしました。また、オブジェクトが多数出現する激しい戦闘シーンにおいても、処理落ちやフレームドロップの発生しない、安定した処理パフォーマンスが維持されている事実を確認いたしました。