はじめに

IIIF 画像を原寸大で鑑賞できる VR ビューアを 前回の記事 で A-Frame ベースで作りました。その後 Next.js 16 + React Three Fiber (r3f) 9 に書き換え、3D 部分は動くようになったものの、肝心の Meta Quest で VR 体験ができない状態でした。

@react-three/xr はインストール済みなのにコード上で一切使われていなかったのです。

この記事では、r3f アプリに WebXR(Meta Quest)対応を追加する過程で遭遇した問題と解決策を、失敗も含めて記録します。


技術スタック

項目バージョン
Next.js16.2.1
React19.2.4
React Three Fiber9.5.0
@react-three/xr6.6.29
Three.js0.183.2
対象デバイスMeta Quest 3

Step 1: @react-three/xr v6 の基本構成

v6 では、v5 以前の <VRButton> コンポーネントは非推奨になりました。代わりにストアベースの API を使います。

// src/lib/xrStore.ts
import { createXRStore } from "@react-three/xr";
export const xrStore = createXRStore();

Canvas 内部を <XR> で囲み、VR セッション開始は xrStore.enterVR() を呼ぶだけ。

// Scene.tsx
<Canvas>
  <XR store={xrStore}>
    <Suspense fallback={null}>
      <SceneContent />
    </Suspense>
  </XR>
</Canvas>
// VRButton.tsx(DOM 側)
<button onClick={() => xrStore.enterVR()}>VR</button>

VR ボタンは navigator.xr?.isSessionSupported("immersive-vr") で WebXR 対応デバイスのみ表示します。


Step 2: コントローラー移動(ロコモーション)

VRChat と同じ操作体系を目指します。

  • 左スティック: 移動
  • 右スティック: 30° スナップターン

@react-three/xr v6 には useXRControllerLocomotion フックがあり、移動対象の Object3D ref を渡すだけで動きます。

// VRLocomotion.tsx
import { useXRControllerLocomotion } from "@react-three/xr";

export default function VRLocomotion({ originRef }) {
  useXRControllerLocomotion(
    originRef,         // 移動対象: XROrigin の ref
    { speed: 2 },      // 左スティック → 移動
    { type: "snap", degrees: 30, deadZone: 0.5 }, // 右スティック → 回転
    "left",            // 左手が移動担当
  );
  return null;
}

<XROrigin> は r3f/xr が提供するプレイヤーの「足元」を表すコンポーネントです。XR カメラはこのグループの子要素として追加されるため、グループを動かせばカメラ(=視点)が追従します。

// Scene.tsx の SceneContent 内
<XROrigin ref={originRef} />
<VRLocomotion originRef={originRef} />

落とし穴 1: CameraRig が XR カメラを横取りする

症状

スティック入力は届いている。XROrigin の position は変わっている。しかし VR 空間上で全く動かない。

原因

デバッグログに答えがありました。

VR 前: children=[PerspectiveCamera]  ← XR カメラが XROrigin の子
VR 中: children=[]                   ← XR カメラが消えている!

犯人は CameraRig コンポーネント。もともと非 VR 用にカメラを自前グループに reparent する処理がありました。

// CameraRig.tsx(問題のコード)
const { camera } = useThree();

useEffect(() => {
  rigRef.current.add(camera); // ← XR セッション開始で camera が XR カメラに切り替わる
  // → XROrigin から XR カメラを奪い取ってしまう!
}, [camera]);

r3f の useThree() は XR セッション開始時に camera を XR カメラに差し替えます(gl.xr.getCamera())。これが useEffect の依存配列に入っているため、セッション開始と同時に CameraRig が XR カメラを自分のグループに add() してしまい、XROrigin の子要素から外れていたのです。

Three.js のオブジェクトは 親を 1 つしか持てないため、add() すると元の親から自動的に外れます。

修正

XR セッション中は reparent をスキップします。

useEffect(() => {
  if (!rigRef.current) return;
  if (xrStore.getState().session) return; // VR 中はスキップ
  rigRef.current.add(camera);
  // ...
}, [camera, scene, startPosition]);

落とし穴 2: useXRControllerLocomotion の callback vs ref

useXRControllerLocomotion は target に refcallback 関数 を渡せます。デバッグのために callback 形式を使ったところ、一瞬で数百メートル吹っ飛ぶ現象が発生しました。

ライブラリのソースを読んで原因判明:

// @pmndrs/xr の内部実装(要約)

// ref 形式: deltaTime 適用、XZ 平面のみ
target.position.x += velocity.x * delta;
target.position.z += velocity.z * delta;

// callback 形式: 生の velocity をそのまま渡す(deltaTime なし、Y 成分あり)
target(vectorHelper, rotationY, ...params);
ref 形式callback 形式
deltaTime内部で適用済み未適用(自分で掛ける必要あり)
Y 軸移動自動で除外カメラの向きに応じて Y 成分が入る
用途通常のロコモーションカスタム移動ロジック

教訓: 通常のロコモーションには ref 形式を使う。callback 形式はカスタム移動が必要な場合のみ。


落とし穴 3: position prop の再レンダー問題

最初、XROrigin に position prop を渡していました。

// NG: React の再レンダーで position がリセットされる
<XROrigin ref={originRef} position={camStartPos} />

ストアの状態変更で SceneContent が再レンダーされるたび、position prop が再適用されてロコモーションの移動が巻き戻されます。

修正として、初期位置は ref callback で一度だけ設定します。

const originInitialized = useRef(false);

<XROrigin
  ref={(node) => {
    if (node && !originInitialized.current) {
      node.position.set(...camStartPos);
      originInitialized.current = true;
    }
    originRef.current = node;
  }}
/>

Quest 実機デバッグの工夫

Quest の VR 内ではブラウザの DevTools が使えません。USB 接続のリモートデバッグも手間がかかります。

そこで API ルート経由で PC のターミナルにログを送る方式を採用しました。

// src/app/api/vr-debug/route.ts
export async function POST(req: NextRequest) {
  if (process.env.NODE_ENV !== "development") {
    return NextResponse.json({ error: "not available" }, { status: 404 });
  }
  const body = await req.json();
  console.log("[VR]", body.msg);
  return NextResponse.json({ ok: true });
}
// VR コンポーネント側
function sendDebug(msg: string) {
  fetch("/api/vr-debug", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ msg }),
  }).catch(() => {});
}

PC のターミナルにリアルタイムでログが流れます。

[VR] idle pos=(0.00,0.00,3.00) children=[PerspectiveCamera]
[VR] MOVED pos=(-0.75,0.00,2.42) children=[] rot=0.00

この children=[] を見て CameraRig の問題に気づけました。production ビルドでは process.env.NODE_ENV チェックにより無効化されます。


Quest からの開発サーバーアクセス

Quest ブラウザから PC の開発サーバーにアクセスするには、いくつかの設定が必要です。

1. ホスト名の設定

npm run dev -- --hostname 0.0.0.0 --experimental-https

--hostname 0.0.0.0 で全インターフェースからのアクセスを許可。WebXR はセキュアコンテキストが必要なので --experimental-https も指定します。

2. クロスオリジン許可

Next.js 16 では、デフォルトで外部オリジンからの HMR WebSocket 接続がブロックされます。

// next.config.ts
const nextConfig: NextConfig = {
  allowedDevOrigins: ["192.168.11.58"], // PC の IP アドレス
};

3. Quest からアクセス

Quest ブラウザで https://192.168.11.58:3000 にアクセス。自己署名証明書の警告は「詳細設定 → 安全でないページに進む」で許可します。


Three.js 0.183 の deprecation 警告

Three.js 0.183 では THREE.ClockPCFSoftShadowMap が非推奨になりました。

PCFSoftShadowMap

r3f の <Canvas shadows>shadows={true}PCFSoftShadowMap を使います。"percentage" を指定すると PCFShadowMap に切り替わります。

<Canvas shadows="percentage">

THREE.Clock

@react-three/fiber 内部で new THREE.Clock() を呼んでおり、アプリ側では修正不可。patch-package でコンストラクタ内の console.warn をコメントアウトしました。

npm install --save-dev patch-package
# node_modules/three/build/three.cjs と three.core.js を編集
npx patch-package three

package.json"postinstall": "patch-package" を追加すれば npm install 後に自動適用されます。


最終的なアーキテクチャ

Canvas
 └─ <XR store={xrStore}>
     └─ <Suspense>
         └─ <SceneContent>
             ├─ Sky, Lighting, Room, Garden
             ├─ IIIF ImagePlanes
             ├─ <XROrigin ref={originRef}>  ← VR カメラの親
             ├─ <VRLocomotion>              ← スティック移動
             ├─ <CameraRig>                 ← 非VR用カメラ制御
             ├─ <Avatar>
             └─ <PlayerControls>            ← 非VR用 WASD/マウス
  • VR モード: XROrigin が XR カメラの親。VRLocomotion がスティック入力で XROrigin を移動。CameraRig と PlayerControls は xrStore.getState().session チェックでスキップ。
  • 非 VR モード: CameraRig がカメラを管理。PlayerControls がキーボード・マウス・タッチ入力を処理。VRLocomotion の useXRControllerLocomotion はコントローラーが見つからないため自動的にスキップ。

まとめ

やったことポイント
<XR> + <XROrigin> 統合v6 はストアベース。createXRStore()<XR store>
コントローラー移動useXRControllerLocomotionref 形式を使う
CameraRig 問題の修正XR セッション中は camera の reparent をスキップ
position prop 問題ref callback で初期位置を一度だけ設定
Quest デバッグAPI ルートで PC ターミナルにログ送信
HTTPS + クロスオリジン--experimental-https + allowedDevOrigins

最大のハマりポイントは CameraRig が XR カメラを横取りする問題でした。Three.js のオブジェクトは親を 1 つしか持てないという基本原則が、r3f + XR の組み合わせで思わぬ形で顕在化します。

デバッグの鍵は、API ルート経由のリモートログでした。VR ヘッドセット内のデバッグは DevTools が使えないため、別の方法で状態を可視化する工夫が重要です。