[Astro #38] R3F製ルービックキューブのAstro移植とWebXR(v6)対応の死闘

[Astro #38] R3F製ルービックキューブのAstro移植とWebXR(v6)対応の死闘

1. はじめに:Next.jsからAstroへの移植とWebXR化の野望

当サイト『PROTOCOL.LAIN』のAstroへの移行作業に伴い、過去の実験成果たちも順次新しい環境へと再構築しています。今回のターゲットは、以前(Next.js環境下)に実装した「ドラッグ回転式の3Dルービックキューブ」でした。

Astro環境でReact Three Fiber(以下、R3F)のコンポーネントを動かすこと自体は、それほど難しくありません。しかし、せっかくモダンな環境へ移植するなら、単なるコードの引っ越しでは面白くない。そこで浮上したのが「WebXRへの対応」という野望でした。

最新の @react-three/xr(v6)を導入し、「PCブラウザではこれまで通りマウスでグリグリと操作し、VRゴーグルを被れば、目の前に浮かぶキューブをコントローラーで直接カシャカシャと遊べる」。そんな、PCとVRの世界がシームレスに繋がるハイブリッド仕様を目指したのです。

キューブの回転ロジックや当たり判定はすでに完成している。あとはCanvasをXRのコンテキストで包み込むだけ……そのはずでした。

しかし、そこからが地獄の始まりでした。

PCのモニター上では完璧に動作し、美しく輝いていたキューブは、VRゴーグルを通した瞬間に全く別の顔を見せました。視界を覆い尽くす「完全な真っ暗闇」、コントローラーで触れた瞬間に起きる「WebGLの即死(完全フリーズ)」、そしてなんとか表示させても画面の奥を軸にして「謎の大旋回」を始める始末。

Reactの宣言的なライフサイクルと、XR空間の絶対的なトラッキング制御。その両者を繋ぐブリッジには、R3Fの便利コンポーネントたちが引き起こす「見えない罠」が無数に仕掛けられていたのです。

本記事では、数時間に及ぶデバッグの末に見つけ出した、WebXR(v6)対応における致命的な落とし穴と、Reactのコンポーネント分離を用いた「PC/VRハイブリッド環境の完全な両立」までの死闘の記録を書き残しておきます。

[Astro #38] R3F製ルービックキューブのAstro移植とWebXR(v6)対応の死闘

2. 罠その1:VRに入った瞬間、画面が「真っ暗」になる

PCブラウザ上では、指定したマテリアルの反射と美しいライティングが適用されたキューブが鎮座しています。いよいよHMD(私の環境ではQuest)を被り、期待に胸を膨らませて「ENTER VR」をクリックしました。

――しかし、次の瞬間、視界は完全な暗黒空間(虚無)に包まれました。

グリッドすら見えず、PCモニター側では下の方にキューブが映っているにもかかわらず、ゴーグルの中は一切の光がない真っ暗闇。しかも、コンソールを開いてもクリティカルなエラーを吐いていないのが非常にタチの悪いところでした。

原因の切り分けのため、ライトやオブジェクトを一つずつコメントアウトして検証を繰り返した結果、WebXRのパイプラインを破壊していた犯人は、なんとリッチな影を落とすための便利コンポーネント、@react-three/dreiContactShadows でした。

なぜ影を落とすだけでVRが壊れるのか?

ContactShadows は、単なるテクスチャではありません。内部で「影を生成するための特殊な専用カメラ」と「フレームバッファ(レンダーターゲット)」を生成し、オブジェクトを下から撮影してぼかすことで、あのリアルな接地影を作り出しています。

一方でWebXRは、右目と左目の映像を同時に高速で描画する「マルチビュー・レンダリング」という非常に特殊でデリケートなパイプラインで動いています。VRモードに入った瞬間、この ContactShadows が内部で回している別カメラのレンダリング処理がXRの描画パイプラインと激しく衝突し、WebGLのコンテキスト全体を道連れにしてクラッシュ(暗転)させていたのです。

解決策:Reactの真骨頂「コンポーネントの完全消去」

解決策は、Reactの基本に立ち返ることでした。 「VR中は影を薄くする」といった中途半端なプロパティ操作ではなく、「VRモード中は、影を描画するコンポーネント自体をDOMツリーから完全に消去(アンマウント)する」というアプローチを取ります。

@react-three/xruseXR フックを使ってXRセッションの状態(isPresenting)を監視し、条件付きレンダリングで隔離します。

import { useXR } from '@react-three/xr'
import { ContactShadows } from '@react-three/drei'

function SceneContent() {
  // XRセッションがアクティブ(VR中)かどうかを取得
  const isPresenting = useXR((state) => state.isPresenting);

  return (
    <group position={[0, 1.2, -1.0]}>
      <RubiksCube />

      {/* 🚨重要: VR起動中は ContactShadows をレンダリングツリーから完全に消し去る */}
      {!isPresenting && (
        <ContactShadows
          position={[0, -2, 0]}
          opacity={0.4}
          scale={10}
          blur={2.5}
          far={2}
        />
      )}
    </group>
  );
}

この隔離措置により、ようやく暗黒空間から脱出。ゴーグルの中に、実体を持ったルービックキューブが鮮やかに浮かび上がりました。

「よし、これで遊べる!」とVRコントローラーのレーザーをキューブに向け、トリガーを引いた瞬間――安堵したのも束の間、次の絶望が待ち構えていました。

3. 罠その2:VRでキューブを触ると「即フリーズ」する

暗黒空間を抜け出し、ついにVR空間へ顕現したルービックキューブ。 私は感動とともにVRコントローラーを持ち上げ、レーザーポインターをキューブのブロックに向けました。トリガーを引き、手首をひねってブロックを回転させ、そしてトリガーを離す。

――その瞬間、WebGLが完全にフリーズしました。

キューブは回転を終えることなく空中で停止し、ヘッドトラッキングも効かなくなり、ブラウザのタブごと沈黙。何度再起動しても、キューブを「掴んで、離した」瞬間に確定でクラッシュします。

これもエラーログが出ないパターンの厄介な不具合でしたが、フリーズするタイミングが「トリガーを離した瞬間(onPointerUp)」であることにヒントがありました。過去に書いたルービックキューブのロジックを見直したとき、私は「自分で仕掛けた地雷」の存在に気がついたのです。

原因:スリープ中のカメラ制御を「強制的に叩き起こす」罠

PC環境において、ルービックキューブをドラッグで回す際、裏で画面全体のカメラ操作(OrbitControls)まで一緒に動いてしまうと非常に操作しづらくなります。それを防ぐため、Next.js時代に以下のような「誤動作防止ロジック」を仕込んでいました。

  • キューブをクリックした時(onPointerDown):controls.enabled = false(カメラ操作を一時停止)
  • キューブから指を離した時(onPointerUp):controls.enabled = true(カメラ操作を復帰)

PCではこれで完璧な挙動をします。しかし、VR環境ではこれが最悪のトリガーとなりました。

VR空間では、カメラの制御権は「HMD(ヘッドセット)のトラッキング」が完全に握っていなければなりません。そのため、VR突入と同時に OrbitControls はスリープ状態(enabled={false})に設定していました。

ところが、VR空間でキューブを操作してトリガーを離した瞬間、キューブの独自ロジックが「よし、操作が終わったからOrbitControlsを起動するぞ!(controls.enabled = true)」と、眠っていたコントロールを強制的に叩き起こしてしまったのです。

目覚めたOrbitControlsは「俺がカメラを操作する!」と主張し、HMDは「いや、俺がトラッキングしている!」と激突。結果としてXRパイプラインの主導権争いが起き、WebGLが致命的なクラッシュ(自滅)を起こしていました。

解決策:Three.jsの最下層からXR状態を検知する

解決策は2段階の防流堤を築くことでした。

① キューブ側のロジックに「VR中はカメラ制御を触らない」ガードを設ける R3Fの useThree フックから gl(生のWebGLRenderer)を取り出し、直接 gl.xr.isPresenting を確認してガードをかけました。

// RubiksCubeコンポーネント内
const { controls, gl } = useThree() as any;

const onPointerUp = (e: any) => {
  // ... (中略) ...
  // 🚨修正:VR中じゃなければ(PCなら)コントロールを復活させる
  if (controls && !gl.xr.isPresenting) {
    controls.enabled = true;
  }
};

② OrbitControlsをDOMツリーから完全に「消滅」させる さらに念を入れる必要があります。中途半端にスリープさせるから叩き起こされるのであれば、「VR中はOrbitControlsそのものを存在させない(アンマウントする)」のが一番安全です。

しかしここで @react-three/xruseXR フックを使って状態を監視すると、React側の再レンダリング処理がXRの繊細な描画ループに干渉し、また別のフリーズを引き起こすというジレンマに陥りました。

そこで、Reactのフックに頼らず、Three.jsの最下層である gl.xr のイベントリスナーを直接使って、安全にコンポーネントを切り替える独自の <PCOrbitControls> を作成しました。

import { useEffect, useState } from "react";
import { useThree } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";

function PCOrbitControls() {
  const { gl } = useThree();
  const [isVR, setIsVR] = useState(false);

  useEffect(() => {
    // フックを使わず、生のXRイベントから状態を検知する
    const onStart = () => setIsVR(true);
    const onEnd = () => setIsVR(false);

    gl.xr.addEventListener('sessionstart', onStart);
    gl.xr.addEventListener('sessionend', onEnd);

    return () => {
      gl.xr.removeEventListener('sessionstart', onStart);
      gl.xr.removeEventListener('sessionend', onEnd);
    };
  }, [gl]);

  // ★超重要: VR中はコントロールそのものをレンダリングしない(消滅させる)
  if (isVR) return null;

  return (
    <OrbitControls
      makeDefault
      enableDamping
      dampingFactor={0.1}
    />
  );
}

この「既存のロジック(RubiksCube)を極力壊さず、外側のコンテキストだけを完全に切り替える」というReactらしいアプローチにより、ようやくフリーズの呪縛から解放されました。VR空間でどれだけ激しくキューブをいじり回しても、カメラがクラッシュすることは無くなったのです。

しかし、安堵の息をついてPCブラウザ側の画面を確認した私を、最後の罠が待ち構えていました。

4. 罠その3:PCで回すと「大旋回」してしまう

VR空間での忌まわしいフリーズ問題を乗り越え、ついにHMD内でキューブを自由に遊べるようになりました。達成感に包まれながらヘッドセットを外し、ふとPCのモニター画面(2Dブラウザ)でマウスをドラッグしてみたときのことです。

――キューブが、画面の奥を支点にして「謎の大旋回」を始めました。

その場でくるくると回るのではなく、まるで透明な糸に繋がれた振り子のように、画面の端から端へと大きく振り回されてしまうのです。

原因:OrbitControlsの頑固な「注視点(Target)」

なぜこんな動きになったのか。原因は、VR環境に合わせてキューブの「配置座標」を変更したことと、OrbitControls のデフォルトの仕様の噛み合わせにありました。

初期の実装では、キューブを空間の原点 [0, 0, 0] に配置していました。しかし、VRに入った瞬間、ユーザーの頭(カメラ)の位置も床である [0, 0, 0] が基準となります。つまり、キューブを原点に置いたままだと、「VRに入った瞬間に、自分の足元(あるいは自分のお腹の中)にキューブが埋まっている」という状態になってしまうのです。

これを防ぐため、キューブの座標を「VR空間で目の前に来る高さと距離」、すなわち [0, 1.0, -0.8] へ移動させていました。

しかし、PC側のカメラ操作を担う OrbitControls は、デフォルトで「原点 [0, 0, 0] を中心(target)として旋回する」という頑固な仕様を持っています。 カメラは原点を見つめて回っているのに、肝心のキューブはそこからズレた [0, 1.0, -0.8] にある。結果として、キューブが原点の周りを大振りな円軌道でグルグルと振り回される「大旋回」が発生していたのです。

解決策:注視点をキューブの中心にロックする

解決策は非常にシンプルでした。「罠その2」で作成したPC専用のカメラコントローラーに対して、target プロパティを明示的に指定し、OrbitControls の注視点をキューブの座標と完全に一致させます。

function PCOrbitControls() {
  const { gl } = useThree();
  const [isVR, setIsVR] = useState(false);

  // ... (VR判定のuseEffect処理は省略) ...

  if (isVR) return null;

  return (
    <OrbitControls
      makeDefault
      enableDamping
      dampingFactor={0.1}
      // ★追加:カメラの回転軸(target)をキューブの座標にロックする
      target={[0, 1.0, -0.8]}
    />
  );
}

さらに、Canvasの初期カメラ位置(camera={{ position: [3, 4, 5] }}など)もこのターゲットを見下ろすような角度に微調整しました。

これにより、PCブラウザ上ではマウスドラッグでキューブが「その場で」美しく回転するようになり、VR空間では目の前に浮かぶキューブを直接手で弄れるという、完全に矛盾のないハイブリッド環境がついに完成したのです。

5. まとめ:ReactでXRを作るということ

今回、Next.js時代の遺産であったルービックキューブをAstro環境へ移植し、WebXR対応させる過程で、予想以上に多くの時間をデバッグ(というより死闘)に費やすことになりました。数々のフリーズや暗転を引き起こした根底には、「Reactの思想」と「XRの思想」の根本的なパラダイムの衝突があります。

「状態(State)が変わったときだけ、差分を計算して再描画する」というReactの宣言的UIの世界。一方で、「1秒間に90回〜120回、無条件にすべてのピクセルを上書きして描き直す」というXR(ゲームエンジン)の命令的ループの世界。

この真逆の思想を react-three-fiber という魔法で繋いでいる以上、OrbitControls のようなDOMイベントとWebGLを跨ぐ複雑なコンポーネントが、両者の間で主導権争いを起こして自滅するのは、ある意味で必然だったのかもしれません。

しかし、解決策は常に「Reactの基本」にありました。 中途半端にフラグで状態を管理してXRの繊細なレンダリングループに干渉するのではなく、「コンポーネントのライフサイクル(マウントとアンマウント)」を正しく制御し、コアのロジック(キューブ本体)と、コンテキスト(PC用UIとVR用カメラ制御)のレイヤーを完全に分離すること。

この原則さえ守れば、たった1つのソースコードで「PCブラウザ上でマウス操作できる美しい3Dサイト」と「ゴーグルの中で実際に手で触って遊べる没入型VRコンテンツ」を、一切の矛盾なくシームレスに行き来させることが可能になります。

WebXR、特にReactエコシステムとの連携には、今回踏み抜いたような「見えない地雷」がまだまだ多数潜んでいます。それでも、それを乗り越えた先に構築できる「PCとVRの境界を溶かす体験」には、エンジニアの探求心を刺激する圧倒的な面白さがあります。

生まれ変わったこのルービックキューブは、『PROTOCOL.LAIN』の実験アーカイブとして稼働しています。PCブラウザからでも、XRデバイスからでも、ぜひこの「境界線のない空間」にアクセスして、実際にカシャカシャと回してみてください。