[Astro #69] WebXRにおける全方位暗転制御と法線スロープフィルタによるホバリング物理の実装

[Astro #69] WebXRにおける全方位暗転制御と法線スロープフィルタによるホバリング物理の実装

1. VR睡眠フェーズにおける暗転処理の修正(詳細解説)

◆ 原因の技術的詳細:前方クリッピングと座標系の乖離

  • Nearプレーン(前方クリッピング面)による消失判定: Three.jsおよびReact Three Fiberのデフォルトカメラ設定において、near(これより手前にあるオブジェクトは描画しないという限界値)は通常 0.1(10cm)に設定されています。従来のPC用実装でフェード用の平面メッシュ(PlaneGeometry)をカメラに近づけすぎた際(例:Z座標 -0.05 への配置など)、メッシュがカメラの描画限界の手前側にはみ出してしまい、GPUのレンダリングパイプライン上で前方クリッピング(Near Plane Culling)が発生し、完全に消失していました。これが、PC環境も含めて画面が一切暗転しなくなった直接の原因です。
  • WebXRのカメラ管理とローカル座標の罠: VRモード(WebXR)に入ると、Three.js内部の WebXRManager は左右の目に対応する2つのサブカメラ(ArrayCamera)を生成し、HMD(ヘッドマウントディスプレイ)のトラッキングデータに基づいてその行列をダイレクトに更新します。また、コントローラーによるプレイヤーの移動は、カメラそのものではなく、カメラの親グループ(VRリグ)の座標を動かす仕様となっています。そのため、useFrame 内で単純に camera.position(ローカル座標)を参照してメッシュの座標へコピーするだけでは、親リグの移動量やHMDによる頭の動きが合算されず、フェード用メッシュが空間上の初期位置に置き去りにされ、暗転がすり抜けていました。

◆ 実装内容の技術的詳細:全方位密閉シールドと絶対座標同期

  • インサイドアウト真球シールド(SphereGeometry)への換装: 周辺視野までカバーするVRヘッドセットの広い視野角(FOV)に対応し、目を動かした際の隙間からの背景漏れを完全に防ぐため、平面から半径 0.3m の真球メッシュ(SphereGeometry、分割数 32×32)へ変更しました。真球の中心にカメラが位置する構造を採ることで、プレイヤーが上下左右どの方向を向いても視界を完全に密閉できるようになり、毎フレーム必要だったクォータニオン(回転情報)の計算および同期処理を不要にしています。
  • レンダリングパイプラインの最適化: シールドのマテリアルおよびメッシュに対し、以下のパラメータを設定して描画順序を制御しています。
  1. side={THREE.BackSide}: 球体の外側ではなく「内側」のポリゴンをレンダリングすることで、中心にあるカメラの視界を確実に黒色で覆います。
  2. depthTest={false} / renderOrder={99999}: 深度テスト(前後関係の判定)をスキップし、描画順序を最上位に引き上げることで、周囲のベッドや壁などの3Dアセットの配置距離に関わらず、常に画面の最前面に黒幕を描画します。
  3. depthWrite={false}: 深度バッファへの書き込みを無効化し、暗転用メッシュ自体がZバッファを汚染して、シーン内にある他のオブジェクトのオクルージョン(遮蔽計算)を破壊するのを防ぎます。
  • ワールド絶対座標の強制インジェクション: useFrame ループ内で、親のトランスフォームやHMDのトラッキング移動がすべて合算された最終的な世界絶対座標を、state.camera.getWorldPosition によって毎フレーム抽出するプロトコルへと変更しました。
if (fadeOpacityRef.current > 0) {
  state.camera.getWorldPosition(fadeMeshRef.current.position);
}

この処理を暗転フラグが有効なタイミング(fadeOpacityRef.current > 0)のみに限定して実行することで、非暗転時のCPU/GPU負荷を最小限に抑えつつ、VR空間内でプレイヤーがどのように移動・テレポートしても、レンダリングされる瞬間の「本物の目の位置」へフレーム単位で密着させるシステムを構築しました。

2. 魔法の箒移動における地形スタック問題の修正(詳細解説)

◆ 原因の技術的詳細:サンプリング密度の低下とすべり演算のデッドロック

  • 離散位置計算(サンプリング)における閾値超過: ゲームループ内の物理演算は、前回のフレームからの経過時間(dt)を用いた離散的なシミュレーションによって行われます。移動速度が通常の 4.0MOVE_SPEED)から 8.5BROOM_SPEED)へと2倍以上に引き上げられたことで、1フレームあたりの移動ベクトル(playerVelocity * dt)の長さが急激に増大しました。これにより地形ポリゴンに対する位置サンプリングの間隔が広がり、通常歩行時であれば滑らかに追従できていた緩やかな上り斜面であっても、1ステップで跨ぐ高低差(heightDifference)が一瞬で段差許容上限である MAX_STEP_HEIGHT(0.6m)の閾値を突破する現象が発生しました。物理エンジンはこれを「進入不可能な直立した絶壁」と誤認し、衝突安全ガードによって前進速度を毎フレーム強制的にゼロリセット(set(0, 0, 0))していました。
  • フロントレイのうねり地形衝突とすべり演算の罠: 前方の障害物を検知するために足元から照射している2本の水平レイ(高度 0.5m および 0.9m)が、フォールドや荒野(POB)特有の細かく激しいうねり地形の斜面ポリゴンに真正面から突き刺さっていました。フロントレイが衝突を検知すると、オブジェクトのすべり演算(壁ずり処理)が作動します。これは、衝突したポリゴン面の法線(hitNormal)と現在の移動ベクトルの内積(dot)を算出し、前進しようとする力を壁の斜面に沿ったベクトルへ変換する処理(addScaledVector(hitNormal, -dot))です。しかし、本来乗り越えるべき「ただの坂道」を壁と誤判定してこのすべり演算を適用した結果、前進ベクトルが真横や後方へと激しく跳ね返され、進行方向の速度が相殺されて「走るより遅くなる」という物理モデルのデッドロックが引き起こされていました。
  • 空配列 useEffect によるPropsクロージャの罠(Stale Closure): PC環境での B キー入力を監視する useEffect は、イベントリスナーの重複登録を徹底的に防いでフレームレートを維持するため、依存配列が空([])の「マウント時1回のみ実行」という設計を採っていました。この内部で単純に currentGrowth などのコンポーネントPropsやStateを直接参照して条件分岐(コメントアウトの解除)を行うと、関数が生成されたゲーム起動時(=レベル1で箒スキル未習得の瞬間)の変数スコープがハンドラー内部に永久にロック(フリーズ)されます。そのため、その後にゲームが進行してプレイヤーのレベルが上がり、ステータス上でスキルを習得しても、キーイベントハンドラー内部からは常に「未習得(false)」と判定され続け、ゲームクリアまで一生箒が解禁されない同期不全が発生していました。

◆ 実装内容の技術的詳細:幾何学的スロープフィルタとRef同期プロトコル

  • 照射高度の動的シフトと法線ベクトルのワールド変換: 箒モードの起動時、フロントレイの照射高度を人間の足元から胴体〜頭上にあたる高度 [1.2, 1.7] へと動的に引き上げ、床面の細かなうねりそのものをレイが物理的にまたぐように調整しました。 さらに、交差したポリゴン面からローカル法線ベクトル(validHit.face.normal)を抽出。これは3Dモデル自身のローカル座標系における向きであるため、ステージ全体の回転やスケールが合算された絶対空間行列である matrixWorld を用い、transformDirection によって世界絶対座標系における法線方向へと正確に変換・抽出するロジックを配備しました。
  • 法線Y成分(傾斜角)による幾何学的バイパスアルゴリズム: ワールド変換された法線ベクトルの Y 成分(Math.abs(faceNormal.y))は、そのポリゴン面がどれだけ「水平な床(1.0)」、あるいは「垂直な壁(0.0)」に近いかという純粋な傾斜度合いを表します。 新設した「スロープフィルタ」では、箒モード時の閾値(slopeThreshold)を 0.3(傾斜角が約72.5度未満のすべての斜面)に設定しました。照射高度を上げたレイが前方の地形ポリゴンを捉えたとしても、その面のY成分が 0.3 を超えていれば(水平に近い坂道であれば)、continue によって衝突判定ループをスキップさせ、壁扱いから完全に除外します。これにより、斜面でのすべり演算の発動が100%カットされ、地形のうねりを無効化して上空から滑らかにスルーする「ホバリング巡航物理」への切り替えを達成しました。
  • クロージャの壁を貫通する超高速Ref同期プロトコル: Reactのレンダリングサイクルと、毎フレーム超高速で割り込みが発生する useFrame およびイベントハンドラー間のスコープの壁を粉砕するため、コンポーネントのルート直下でPropsの値をRefへと即座に退避させるプロトコルを敷きました。
const hasBroomRef = useRef(false);
hasBroomRef.current = currentGrowth.skills.includes('MAGIC_BROOM');

Refオブジェクト(.current)への参照は、Reactの関数再生成によるクロージャの罠を完全に貫通し、常に同一のメモリ実態(最新のポインタ)をダイレクトに指し示す特性を持っています。これにより、キーイベントを空配列([])で固定してパフォーマンスを限界まで最適化したまま、レベルアップした瞬間にフレーム遅延が1ミリ秒も存在しない「本物の最新ステータス」を入力処理および物理演算スレッドへ直撃させることに成功しました。

3. バトルUIの動的拡張と座標修正(詳細解説)

◆ 原因の技術的詳細:静的レイアウトの限界と空間行列の不整合

  • 固定サイズによるバウンディングボックスの破綻(オーバーフロー): 従来の BattleUI.tsx では、コマンドメニューウィンドウの背景パネル(planeGeometry)の垂直幅およびテキストの描画間隔(垂直オフセット)が静的な固定値としてハードコーディングされていました。そのため、プレイヤーのレベルアップに伴って battleMagics の配列要素が増加(スキルを多数習得)した際、流れてくる commands 配列の総数が事前定義されたウィンドウの描画領域をیه一瞬で超過していました。その結果、メニュー枠を表す edgesGeometry の境界線をテキストが突き抜けて下部へと露出してしまい、JRPG型のUIウィンドウシステムにおける典型的なレイアウト・オーバーフローを引き起こしていました。
  • 戦闘シーンにおける初期配置および突撃ベクトル演算のズレ: エンカウントフェーズへの移行時、敵オブジェクト(ADVAvatar)の初期位置(enemyStartPosition)や、プレイヤーに向けて突撃する際の物理座標(enemyCurrentPos)の内部計算において、ステージ自体の拡大縮小率(scale)や遭遇時のプレイヤーの回転角(props.encounterRotY)が適用される際、特定の移動ベクトル演算や線形補間(lerp)のベースラインとなるワールド空間行列との同期に微細な不整合が生じていました。これが、特定のステージで敵の初期配置が意図した位置からオフセットされてしまう原因となっていました。

◆ 実装内容の技術的詳細:可変パラメータ数理モデルと座標系の正規化

  • ポリモーフィックなUI矩形を生成する動的スケールアルゴリズム: BattleUI.tsx のコマンドメニュー描画プロトコル内部において、静的な値を完全に排除し、受け取った配列の要素数(commands.length)をインプットとする動的パラメータ算出ロジックを実装しました。
const itemSpacing = commands.length > 3 ? 0.30 : 0.38;
const panelHeight = Math.max(1.5, commands.length * itemSpacing + 0.5);

コマンド総数が3行を超える場合はテキストの行間(itemSpacing)を 0.30 に縮小して描画密度を高め、3行以下の場合は 0.38 の十分な余白を持たせるように分岐させます。さらに、背景となる planeGeometry args={[3.0, panelHeight]} および外枠の edgesGeometry の高さを数理的に算出(commands.length * itemSpacing + 0.5)し、最小値を 1.5 にクリップ(Math.max)する機構を導入しました。これにより、データ(JSON)の拡張に応じてポリゴンの形状そのものがフレーム単位で可変スケールする、堅牢なデータ駆動型UIコンテナへと刷新されました。

  • 垂直描画原点の動的相殺(オフセット)ロジック: 背景パネルが上下に動的拡張されるのに伴い、テキストの描画基準となる Y 座標の算出式を以下のように刷新しました。
position={[0, (panelHeight / 2 - 0.3) - idx * itemSpacing, 0.02]}

ウィンドウの上端(panelHeight / 2)から一定のマージン(0.3)を差し引いた絶対位置を常に1行目の描画原点(基準点)として定義し、そこからインデックス(idx)に応じた負のオフセットを等間隔で減算していく数理モデルを採用しています。これにより、メニュー全体の中心位置(ピボット)が空間上でブレるのを防ぎつつ、中身のコマンド群を常にウィンドウ内部へ完璧に整列収穫させることに成功しました。

  • 戦闘突撃物理のトランスフォーム正規化: ADVBattle.tsx 側において、敵アバターの描画位置(position)に渡すベクトル(enemyCurrentPos)を、静的な初期位置(battleConfig.enemyStartPosition)から突撃時のリアルタイム補間座標(enemyDisplayPos)へとシームレスに切り替える条件判定および更新ロジックを最適化しました。敵がプレイヤー側の targetPos へ向けて突撃する際の lerp 補間(delta * 6.5)計算において、空間内の回転行列やステージ全体のスケール値が正確に合算・正規化されるように処理を適正化したことで、3Dグラフィックス空間上におけるアバターの描画位置の不連続性と座標ズレを完全に解消しました。

4. エネミーの特殊攻撃シーケンスの実装(詳細解説)

◆ 導入の技術的背景:静的行動パターンの脱却とデータ駆動の要求

  • 単一行動モデルからの脱却: 従来の戦闘フェーズにおける敵(エネミー)の行動ルーチンは、プレイヤーの座標に向けて直線的に移動して近接攻撃を繰り出す通常攻撃(executeEnemyNormalAttack)のみに限定されていました。この単純な物理移動モデルだけでは、遠隔魔法やバフ・デバフといった多様な戦術表現に対応できず、JRPGとしての戦闘の緊張感や演出の多様性を制限していました。
  • 密結合(ハードコーディング)の回避: 敵ごとに異なる遠隔攻撃や固有魔法を実装する際、コンポーネントの内部コードに敵のID(activeEnemyId)に応じた switch-case などの条件分岐を増設していくアプローチは、コードの肥大化と可読性の低下(スパゲッティコード化)を招きます。そのため、プログラムのコアロジックを完全にカプセル化したまま、外部データ(JSON)の書き換えのみで敵の行動アルゴリズムや演出パラメータを無限に拡張できる「データ駆動型アーキテクチャ」への刷新が不可欠でした。

◆ 実装内容の技術的詳細:パラメータインジェクションと非同期タイムライン同期

  • 宣言的パラメータインジェクションの確立: AVATARS.json のエネミー・ステータス構造の内部に、拡張属性として特殊攻撃定義(specialAttack オブジェクト)をインジェクションできるスキーマを構築しました。 敵のターン(ENEMY_TURN)が開始されると、システムは現在選出されている敵の静的設定(enemyConfig)から specialAttack パラメータを動的に走査します。
const enemyConfig = AVATARS.find(a => a.id === props.activeEnemyId);
const special = enemyConfig?.status?.specialAttack;

if (special && Math.random() < (special.chance || 0)) {
  executeEnemySpecialAttack(special);
} else {
  executeEnemyNormalAttack();
}

Math.random() を用いた確率論的判定により、指定された chance(発動確率)の閾値を通過した場合のみ、一元化された特殊攻撃プロトコル(executeEnemySpecialAttack)へと状態を分岐させるルーチンを確立しました。

  • 非同期タイマーによるアニメーションとイベントの厳密な同期(タイムライン制御): 3Dアバターのモーション(vrm アニメーション)の再生タイムラインと、3D空間上における魔弾粒子の生成およびSE再生のタイミングをフレーム単位で合致させるため、setTimeout を並列駆動させるインターバル・タイムライン制御を実装しました。
  1. 500ms(詠唱・前振りディレイ): 特殊攻撃専用アニメーション(special.animId)が再生され、敵が手を掲げる、あるいは杖を構えるといった「予備動作」の中盤に達するタイミングを狙って 500ms のディレイを発生させます。この瞬間に同期して SE_MAGIC_CAST を再生し、空間上の敵の右手付近の座標(pStart)からプレイヤーの胸元座標(pTarget)へ向けて、魔弾インスタンス(activeEnemyProjectile)を射出します。この際、弾丸の色彩(color)、スケール(size)、および内部ダメージ(damage)には、JSONから解釈された可変値がダイレクトにバインドされます。
  2. 1500ms(フォロースルー・硬直ディレイ): 攻撃モーション終了後の残身や反動による硬直時間を物理的に表現するため、さらに時間をずらして 1500ms 後に setEnemyActiveAnim("IDLE_BATTLE_02") を呼び出し、安全にデフォルトの戦闘待機状態へと遷移させます。
  • 着弾イベント駆動型ダメージ判定プロトコル: 射出された activeEnemyProjectile は、MagicProjectile コンポーネント内の useFrame ループによって毎フレームXZ/Y空間上を線形補間しながらプレイヤーへ向かって接近します。魔弾がプレイヤーのバウンディングボックス(衝突判定領域)に到達した瞬間、コンポーネントに設定されたコールバック(onHit)を介して handleEnemyProjectileHitPlayer プロトコルがキックされます。
const damage = activeEnemyProjectile?.damage || 15;
const nextPlayerHp = Math.max(0, workingPlayerHp - damage);
setWorkingPlayerHp(nextPlayerHp);
setIsPlayerHurt(true);

この着弾イベント駆動方式(Event-Driven)を採用したことで、弾が目視で「当たった瞬間」に正確に同期して、setWorkingPlayerHp によるプレイヤーのバイタル減算、被弾リアクションモーション(IDLE_REACTION)への切り替え、および空間上への DamagePopup のマウントがミリ秒の狂いもなく連鎖発動する、極めて整合性の高い戦闘物理シークエンスを実現しました。