[Astro #43] @react-three/uikitによるWebXR用3Dメニューの実装と仮想・現実のコマンド同期

[Astro #43] @react-three/uikitによるWebXR用3Dメニューの実装と仮想・現実のコマンド同期

はじめに

前回の[Astro #42]では、WebXR 空間に VRM アバターを立たせ、当たり判定(Hitbox)や視線追従の基礎を構築した。 しかし、VR 空間にダイブしたはいいものの、「システムを操作するインターフェース」が存在しなければ、ただの鑑賞アプリで終わってしまう。

今回は、XR 空間内で直接システムをハックするための 「3D ターミナルコンソール(VR メニュー)」 を実装し、さらに VR 空間から現実の PC ターミナルへコマンドを送信する「仮想と現実の双方向通信」を完成させた記録である。

NOTE:

Youtube:

Video:

1. @react-three/uikit による 3D メニューの構築

VR空間(WebXR)にダイブした時、私たちが普段PCブラウザで慣れ親しんでいる HTML の <div><input> といった DOM 要素は一切表示されない。仮想空間では、UIもまた一つの「3Dメッシュ(ポリゴンの集合体)」として描画される必要があるからだ。

従来の Three.js でUIを構築しようとすると、平面(PlaneGeometry)にテクスチャを貼り付けたり、複雑なレイキャスト(当たり判定)を自作したりと、想像を絶する手間がかかる。今回はその苦行をパスするため、@react-three/uikit を採用した。これは Tailwind CSS のような直感的な記法(Flexboxの概念)を 3D 空間に持ち込める画期的なライブラリであり、React 開発者にとっての最適解と言える。

空間座標の罠と「理不尽への回答」

UIの「ガワ」は作れたものの、実際にVRゴーグルを被ってメニューを召喚しようとした瞬間、WebXR特有の強烈な洗礼を浴びることになった。

「メニューが遥か頭上に飛んでいく」「真後ろに出現する」、あるいは「自分が横を向いているのにメニューは元の方向を向いたまま(ペラペラの側面しか見えない)」といった現象だ。

通常、3Dゲームなどで「カメラの目の前」にオブジェクトを出す場合、カメラの向いている方向(Vector)を取得し、距離を掛け算して足し合わせるのがセオリーだ。しかし、WebXR 環境ではこれが通用しない。 XR 環境では、カメラの基礎座標(XROrigin)にヘッドセットの物理的な高さとトラッキング情報が非同期で加算され続けているため、単純なベクトルの計算では空間が歪んでしまうのだ。

この理不尽な挙動に対する回答として、私は「カメラのローカル座標を定義し、それをワールド空間の絶対座標に変換する」という数学的アプローチをとった。

// src/components/WiredScene.tsx (抜粋)
useEffect(() => {
  if (showVRMenu) {
    // 1. 最新の立ち位置を強制確定させる
    // XRモードではカメラ位置の更新が非同期になることがあるため、計算直前に最新の行列を確定する
    camera.updateMatrixWorld();

    // 2. カメラ空間における「理想の相対位置」を定義する
    // X: 0 (左右の中央)
    // Y: -0.2 (少し下。0だと目の前に被って視界を塞いでしまうため)
    // Z: -0.8 (Three.jsではZ軸マイナスが前方。コントローラーで操作しやすい80cmの距離)
    const localPos = new THREE.Vector3(0, -0.2, -0.8);

    // 3. ローカル座標をワールド座標(実際の絶対的な表示位置)に変換!
    // 自分がどこに立っていようが、これが物理的な「今の目の前」の座標となる
    const targetPos = localPos.applyMatrix4(camera.matrixWorld);

    // 4. カメラの回転から「Y軸(左右の首振り)」だけを抽出する
    const euler = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ');

    // 5. 計算結果をメニューのStateに適用
    setVrMenuPos([targetPos.x, targetPos.y, targetPos.z]);

    // 回転は Y軸のみを適用(見上げたり見下ろしたりしても、メニュー自体は地面と垂直を保つため)
    setVrMenuRot([0, euler.y, 0]);
  }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showVRMenu]);

数式がもたらす完璧な追従性

このコードのキモは2つある。 1つ目は、applyMatrix4(camera.matrixWorld) による絶対座標への変換。これにより、身長(物理的なY軸の高さ)やプレイスペースの移動といった不確定要素を完全に吸収できる。 2つ目は、Quaternion(四元数)から YXZ 順の Euler(オイラー角)に変換し、Y軸の回転のみを採用している点だ。もしカメラの回転をそのままメニューに適用してしまうと、ユーザーが見上げた時にメニューも上を向いてしまい、非常に操作しづらい。Y軸だけを同期することで、常に「地面に対して垂直に立つメニューパネル」が目の前に現れる。

この数式により、プレイヤーがルームスケールのどこへ移動しようと、あさっての方向を向いていようと、ボタンを押した瞬間に「必ず今の視界のやや下」へピタッとメニューが召喚される、極めて堅牢なUI基盤が完成した。

2. コントローラー監視システムの「堅牢化」

UI の位置座標と同じくらい悩まされたのが、「入力の取りこぼし」だ。

React のイベントやラッパーライブラリ(@react-three/xr の提供するフックなど)を使えば、ボタンの押下は簡単に検知できるように思える。しかし、実際の VR 空間(90Hz〜120Hzという高速で描画され続ける世界)では、React のライフサイクルを介したイベント検知は遅延や取りこぼしが発生しやすく、「メニューが開いたり開かなかったりする」という UX として致命的なストレスを生んでいた。

そこで、React のイベントシステムを完全にバイパスし、Three.js のレンダリングループ(useFrame)内で WebXR のネイティブなハードウェア状態を毎フレーム直接監視(ポーリング)するという、泥臭いが最も確実な手法に切り替えた。

// VRモード時のみ動作する、絶対失敗しない監視コンポーネント
useFrame(() => {
  // 1. Reactではなく、Three.js側の情報からVRプレゼンティング中か判定
  if (!gl.xr.isPresenting) return;
  const session = gl.xr.getSession();
  if (!session) return;

  let isActionTriggered = false;

  // 2. コントローラーの物理的な押下状態を直接スキャン
  for (const source of session.inputSources) {
    if (source.gamepad) {
      // Quest 2 / Quest 3 コントローラーのボタンマッピング
      // [4] = Aボタン (右) / Xボタン (左)
      // [5] = Bボタン (右) / Yボタン (左)
      const btn4 = source.gamepad.buttons[4];
      const btn5 = source.gamepad.buttons[5];

      if ((btn4 && btn4.pressed) || (btn5 && btn5.pressed)) {
        isActionTriggered = true;
      }
    }
  }

  // 3. 押しっぱなしによる連続発火(チャタリング)防止のトグルロジック
  if (isActionTriggered && !wasPressedRef.current) {
    setShowVRMenu((prev) => !prev);
    wasPressedRef.current = true; // 押された状態を記録
  } else if (!isActionTriggered) {
    wasPressedRef.current = false; // 離されたらリセット
  }
});

ハードウェアレベルのスキャンとエッジ検出

このコードにおける技術的なポイントは2つある。

ひとつは、source.gamepad.buttons[4][5] というマジックナンバーだ。WebXRの Gamepad API において、Meta Quest シリーズ等の標準的な VR コントローラーは、トリガーが [0]、グリップが [1] に割り当てられており、親指で操作する A/X ボタンが [4]、B/Y ボタンが [5] と定義されている。これを直接スキャンすることで、ラッパーライブラリの不具合に依存することなく、ハードウェアの物理的な入力状態を 100% の精度で捕捉できる。

もうひとつは、wasPressedRef を用いた状態管理(エッジ検出)である。 useFrame は1秒間に約90回という猛スピードで実行される。もし単に btn.pressedtrue の時にメニューを開く処理を書いてしまうと、人間が「一瞬ポチッと押した」つもりのわずかな時間(数十ミリ秒)に useFrame が何回も回り、メニューが開閉を繰り返してフリーズしたような挙動になる。

そこで「直前のフレームで押されていなかったが、今のフレームで押された(isActionTriggered && !wasPressedRef.current)」という状態の変化(立ち上がりエッジ)のみを捉えるロジックを組んだ。

スマートな抽象化ではないかもしれない。しかし、システムへのアクセスインターフェースというものは、最終的に「絶対に失敗しない(堅牢である)」ことが何よりも美しい。この実装により、「ボタンを押せば必ずメニューが出る」という現実世界では当たり前の安心感を、VR 空間にも確立することができた。

3. 仮想(VR)と現実(PC)の同期:Event-Driven Architecture

最大の難関にして、今回最もテンションが上がったのがこの実装だ。

そもそも、React Three Fiber (WebGL) が描画する「VR空間」と、React (HTML/DOM) が管理する「PCブラウザのUI」は、同じブラウザ上にありながらも全く別の次元(コンテキスト)に存在する。 VR 空間のメニューから、PC ブラウザ上に実装した「Wired Terminal(天気 API や VOICEVOX 音声合成、翻訳機能、履歴管理などを持つ中枢システム)」をどうやって操作するのか?

答えは、カスタムイベント(CustomEvent)を利用した「次元を跨ぐ見えない電波」の送受信である。

送信側(VR 空間)

VR内の3Dキーボードやクイックボタンをレーザーで撃ち抜いた瞬間、wired-remote-command というイベントに文字列(コマンド)を乗せて、window オブジェクトという共通の空間へ向けてブロードキャストする。

// WiredVRMenu.tsx
const sendCommand = (cmd: string) => {
  // VR空間から現実(PCブラウザ全体)へ向けてコマンドを放つ
  window.dispatchEvent(new CustomEvent('wired-remote-command', { detail: cmd }));
};

この設計の美しい点は、VR側(送信側)は「天気の取得方法」や「音声合成の仕組み」を一切知らなくて良いということだ。複雑なAPIのロジックをVRメニュー内に持ち込むことなく、ただ「この文字列を実行しろ」と叫ぶだけで役割が完結する。

受信側(PC ターミナル)

PC側のシステムは、常にその電波を傍受している。しかし、ここで React 特有の罠「Stale Closure(古いクロージャの参照)」問題が牙を剥いた。

イベントリスナー(useEffect 内で登録した関数)の中で、直接コマンドのパース処理や複雑なステート(コマンド履歴やトークデータなど)を参照しようとすると、コンポーネントが初回マウントされた時の「空っぽの古い記憶」を参照してしまい、コマンドが不発に終わる現象が発生したのだ。キュー(待ち行列)を作る正攻法も試したが、非同期のタイミング問題が絡みコードが肥大化してしまった。

そこで行き着いたのが、「人間がキーボードで打ってエンターキーを押した」のと同じ物理的挙動を、JavaScript で強制的に再現するというアプローチである。

// WiredTerminal.tsx
const formRef = useRef<HTMLFormElement>(null);

useEffect(() => {
  const handleRemoteCmd = (e: any) => {
    const cmd = e.detail;

    // 1. 入力欄(DOM)にVRから届いたコマンドをセットする
    setInputValue(cmd);

    // 2. Reactが画面の入力欄を更新し終えるまでのわずかな時間(50ms)だけ待つ。
    // その後、ネイティブなDOM APIを使って強制的にSubmit(エンターキー)を叩き込む!
    setTimeout(() => {
      if (formRef.current) {
        formRef.current.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
      }
    }, 50);
  };

  window.addEventListener('wired-remote-command', handleRemoteCmd);
  return () => window.removeEventListener('wired-remote-command', handleRemoteCmd);
}, []); // 依存配列は空でOK。常に最新のformRefへ物理的にアクセスする

電波を受信したら、入力欄に文字を流し込み、一瞬のタメを作ってから form の送信イベントを意図的に発火させる。 React の複雑なライフサイクルや非同期の依存関係をすべてすっ飛ばし、「すでに完璧に動いているPC版のターミナル処理」へ物理的に合流させるのだ。スマートな抽象化ではないかもしれないが、いかなる状態異常も受け付けない、泥臭くも100%確実に動作する最強のハックである。

結実:フルダイブ環境の完成

VR ゴーグルを被り、右手で X ボタンを押す。 視界の端に緑色に光る『PROTOCOL.LAIN』の 3D ターミナルコンソールが浮かび上がる。 レーザーポインターで手元の 3D キーボードから weather tokyo と刻み込み、EXECUTE をトリガーする。

すると、VR 空間の裏側で現実の PC が API を叩き、VOICEVOX エンジンが起動し、目の前のアバターが「東京の天気は、快晴。気温、24度です。」と、私としっかりと目を合わせながら喋り出す。

現実のシステムを、仮想空間から完全に支配した瞬間だ。

UI の配置バグ、ステートの不整合によるフリーズ、アバターが白目を剥く(LookAt の Z 軸反転によるバグ)、キーボードが出現しない WebXR の仕様など、数々の理不尽なエラーに見舞われたが、コードによる「理不尽への回答」を見つけ出し、つなぎ合わせる作業は、やはり最高に面白い。

明日はこの環境で、さらに没入感の最適化を進めていきたい。