[Astro #62] Three.js/R3Fで作るVR対応3Dパズルゲームの完成と極限の最適化ハック

[Astro #62] Three.js/R3Fで作るVR対応3Dパズルゲームの完成と極限の最適化ハック

はじめに

3日前から作成を始めたパズルゲームが、EDまで含めて簡単ですが作成完了しました。

詳しくは、先程アップしたデモプレイのYoutube動画を参照ください。

本日も実装内容を軽くまとめてみます。

ゲームはトップページから、左下のターミナルに

pzl

と入力してエンターキーを押すと起動します。

1. InstancedMeshを用いたブロック破壊エフェクトのメモリ最適化

ブロック消去時に生成されるパーティクルエフェクトにおいて、個別のMeshオブジェクトを大量に生成・破棄する手法は、ブラウザのメモリリークおよびVR環境下での致命的なフレームレート低下(ハングアップ)を誘発していました。これを解決するため、THREE.InstancedMesh を用いた一括描画システム <EraseEffectCluster> を実装しました。

背景と技術的課題

従来のシステムでは、ブロックが消去されるたびに複数の独立したMeshオブジェクトを動的に生成し、アニメーション終了後にそれを配列から削除する設計をとっていました。しかし、このアプローチは以下の2つの致命的なボトルネックを抱えていました。

  1. Draw Call(描画命令)のインフレ: パーティクル(粒子)の数だけ個別にGPUへ描画命令が発行されるため、WebXR(VRモード)が要求する「常時90fps維持」のレンダリングパイプラインを一瞬で破綻させていました。
  2. GC(ガベージコレクション)のスパイク: メモリの確保と解放が短時間に高頻度で繰り返されるため、ブラウザのガベージコレクションが追いつかず、VR空間全体の描画が数フレームにわたって完全にハングアップする現象を引き起こしていました。

実装の特徴

ジオメトリの共通化と軽量な形状選択

クラスター外部で静的に一度だけ生成した THREE.TorusGeometry を、すべてのインスタンス(全粒子)で完全に共有する設計としました。これにより、VRAM(ビデオメモリ)へのバッファ転送コストを最小限に抑えています。 また、VR環境下での描画負荷をさらに最適化するため、1クラスターあたりの粒子数を14個に制限しています。形状にTorus(ドーナツ型)のワイヤーフレームを採用することで、少ないポリゴン数でありながら、サイバー空間のノイズ packet(パケット)が霧散するような、密度感のある視覚効果を両立させています。

コンポーネント自律型のメモリ管理

エフェクトの発生源となる親コンポーネント(ゲーム管理レイヤー)のState配列を、毎フレーム配列操作(Push/Splice等)することを完全に排除しました。 エフェクトオブジェクト自身に age(内部経過時間 Ref)を持たせ、React Three Fiber(R3F)の useFrame ループ内で自律的にアニメーションの進行度(t = age.current / duration)を計算させています。これにより、Reactの再レンダリングトリガーを一切引くことなく、生のアニメーションマトリクス(.setMatrixAt())のみを高効率に更新し続ける処理を実現しました。

確実なリソース解放とライフサイクル制御

アニメーションが終了条件(t >= 1.0)に達した段階で、以下のクリーンアップシーケンスを完全に自動連動させています。

  1. 即時非表示化: meshRef.current.visible = false を実行し、次フレームの描画パス(Render Pass)から対象オブジェクトを即座に除外します。
  2. マテリアルの破棄: useEffect のクリーンアップ職能と連動し、個別に生成された THREE.MeshStandardMaterial.dispose() を確実に実行します。これにより、GPU上のシェーダープログラムおよびメモリの残留を完全に防止します。
  3. 自律アンマウント: 内部の alive 状態を false に切り替え、コンポーネント自身が null を返却して仮想DOM(Scene Graph)から離脱します。

この一連の自律分散型のライフサイクル制御により、親コンポーネントの負担をゼロにしつつ、WebXRランタイムにおけるメモリヒープの完全なフラット化(リークゼロ)を達成しました。

2. WebXR操作系および画面遷移のハイブリッド同期

WebXR(VRモード)特有の入力制限と、通常のブラウザ(PCキーボード)入力におけるイベントリスナーの乖離を解消するため、入力レイヤーのリファクタリングを行いました。

背景と技術的課題

ブラウザ向けに設計されたReact Three Fiber(R3F)アプリケーションをWebXR(VRモード)へ対応させる際、最も大きな障壁となるのが「デバイスごとの入力イベントの非互換性」です。PCブラウザ環境では従来の window.addEventListener('keydown', ...) によるキーボード監視が有効ですが、VRゴーグルを被ったVRモード環境下ではWebXRセッションが独立するため、これらのDOMイベントが一切発火しなくなります。

また、パズルを操作するロジック(コントローラー)が稼働するのはゲーム本編(PLAYING フェーズ)のみであり、タイトル画面やエンディング画面ではその操作コンポーネント自体がアンマウントされているため、VRコントローラーからの入力をゲーム全体でシームレスにハンドリングできないというアーキテクチャ上の課題がありました。これらを包括的に解決するため、デバイスの差分を吸収する仮想入力レイヤーを構築しました。

実装の詳細

グローバルフレームによる入力キャッチと画面遷移同期

ゲームの進行を管理する最上位の useFrame ループ(毎フレーム実行されるレンダリングランタイム)内に、VRコントローラーの入力状態を監視するグローバルキャッチ機構を実装しました。 これにより、パズル操作用のコントローラーコンポーネントが画面上にマウントされていない「タイトル画面」や「エンディング画面」であっても、外部からWebXR経由で注入される人差し指トリガーの要求(window.wiredPlayerVrTriggerRequested)を常時監視することが可能となりました。トリガーの引き込みを検知した瞬間、システム内部でPCの「Enterキー」が押された場合と全く同一のイベント挙動を安全に偽装・発火させる共通シーケンスを確立し、VR空間内におけるノーキーボードでの完全な画面遷移同期を実現しました。

回転操作のハイブリッド仕様化(アクセシビリティの向上)

VR空間でのプレイにおいて、利き手やデバイスの持ち方、操作の好みに左右されない直感的なUXを提供するため、パズルブロックの回転エントリを拡張しました。 具体的には、利き手のコントローラーの人差し指トリガーによる操作だけでなく、親指側にある「Aボタン」(window.wiredPlayerVrAButtonPressed)の押し込み動作も並列してキャッチする設計としました。どちらの入力がトリガーされた場合でも、内部の同一回転アルゴリズム(triggerRotation())へルーティングされるハイブリッド仕様としたことで、デバイスに縛られない快適なパズル操作性を確保しています。

未定義変数によるコンソールクラッシュ(タイポ)の即時修正

VR入力テスト中に、マウントされた PuzzleController.tsx の内部ロジックにおいて、JavaScriptの実行エラー(Uncaught ReferenceError: debugEvent is not defined)が発生し、描画ランタイム全体が完全に停止・クラッシュする致命的なバグに遭遇しました。 プロファイラおよびコンソールログの解析により、特定のVRデバッグボタン(Bボタン)の解放判定(KeyUp)を処理する分岐において、スコープ内に初期化・定義されていない変数 debugEvent に対して null を代入しようとしていた単純なタイポ(記述ミス)が原因であることを特定しました。当該箇所をクリーンに削除・修正するパッチを適用したことで、例外の発生を抑止し、ランタイムの完全な安定性を確保しました。

本番環境(プロダクション)向けの安全なフラグ管理

開発段階における全5ステージの通しプレイ確認や、エンディングへの遷移チェックを円滑に行うため、内部に「Dキー(またはVRコントローラーのBボタン)」を押すだけでステージスコープを一撃で満たして強制クリアさせる強力なデバッグチートコマンドを仕込んでいました。 しかし、このバックドアを開放したまま git リポジトリへコミットを送信(push)することは、プレイヤーの誤操作による意図しないゲーム終了を引き起こす深刻なリスクとなります。そのため、コードの最上部にグローバルなデバッグクリアフラグ(const isDebugClear = false;)を導入しました。本番ビルド時にはデフォルトでこのフラグが false(無効化)を返却するように設計し、チートロジックへの進入路を安全に遮断した状態(プロダクションコード)へ昇華させた上で git push を完了しました。

3. JSON駆動型エンディングシステムとVRM配置ハック

ゲームの全5面化に伴い、VR空間内でのエンディング演出コンポーネント <PuzzleEndingScene> を新規に構築しました。5面をクリア、あるいはデバッグハックによって最深部へ到達した瞬間、パズルフィールドのUI要素をすべて画面から消去し、3D空間上の演出へとシームレスに世界線をシフトさせる設計としました。

3-1. VRMモデルの多重配置における軽量化

過去のブラウザVR環境において、複数の3Dモデルを動的アニメーション・物理演算付きで同時描画した際にフリーズが発生する課題がありました。本システムでは、以下の軽量化ハックを採用することで、PCVR環境下でフレームレートを一切低下させずに5体の異なるVRMモデルを同時配置することに成功しています。

  • 物理演算のバイパス: エンディングフェーズ中は、各VRMアバターの VRMSpringBone などの揺れモノ更新処理を意図的に停止させ、CPUの計算負荷を事実上ゼロに抑えています。
  • 静止(Idle)ポーズの固定: winnerStatus="PLAYING" 状態による静止配置(ボーンの回転固定)を行い、余計な骨格のブレンド計算をパスして描画命令(Draw Call)のみをGPUに委ねるアプローチを取っています。これによって5体のVRMが空間に配置されても、VRランタイムの負荷を安全圏に維持できています。

3-2. データ構造の完全一元化

puzzleConfig.json 内の stages 配列に対して、敵キャラクターのアセットパス(avatarPath)だけでなく、VRoid Hubの規約(再配布OK・クレジット不要の最緩ライセンス)に準拠した作者名(creator)およびモデルURLをプロパティとして完全に定義しました。

これにより、以下の同期構造が自動的に成立します。

  1. 戦闘画面: JSON内の各ステージの難易度(difficultyLevel)や自由落下速度(dropInterval)の設定に基づき、敵CPUの思考・落下ルーチンが作動します。
  2. エンディング画面: 中央に配置された mesh(半透明ブラックボードとサイバーエッジラインを組み合わせた大型プレゼンスクリーン)の下部および周囲に、5体のVRMが事前に微調整された等間隔の座標で自動的に配置・整列されます。
  3. クレジットスクロール: スクリーン上に流れるテキスト行が、JSONの creator データを基に flatMap 展開され、3Dクリエイターへの謝意を示すスタッフロールとして動的に生成・スクロールされます。

3-3. ユーザー入力待機と音響フェードアウト

クレジットのスクロールが最上端(SCREEN_TOP)を通過した後は、自動的にタイトル画面へ戻る仕様を廃止し、感謝メッセージ(Thank you for playing.)を表示した状態で空間の更新を無期限にホールドします。これにより、プレイヤーは好みのタイミングまでVR空間の余韻に浸ることが可能となりました。

プレイヤーがEnterキー、またはキーボードイベントへ橋渡しされたVRコントローラーの決定トリガーを入力した瞬間に FADE フェーズへと移行します。最前面(プレイヤーの視点直前となる position={[0, 0, 1.0]})に配置した大きな黒シールド(planeGeometry)の不透明度を useFrame 内で毎フレーム滑らかに加算し、約1.5秒をかけて視界全体をディープブラックへと遮断します。完全に暗転が完了したタイミングで親コンポーネントのタイトル戻し関数(onComplete())を発火させることで、安全な帰還シーケンスを完了させています。

また、これに伴うオーディオマッピングも完全にバインドしました。本編クリアの瞬間にパズル用の激しい主音源を停止(stopBGM())させてエンディングテーマ曲(share_your_life.mp3)へとスイッチし、フェードアウト完了後にタイトルへ戻るタイミングで再度タイトル用音源へと復帰・リセットさせる処理を構築したことで、視覚と聴覚の双方が完全に同期した演出が完成しました。