はじめに
IIIF 画像を原寸大で鑑賞できる VR ビューアを 前回の記事 で A-Frame ベースで作りました。その後 Next.js 16 + React Three Fiber (r3f) 9 に書き換え、3D 部分は動くようになったものの、肝心の Meta Quest で VR 体験ができない状態でした。
@react-three/xr はインストール済みなのにコード上で一切使われていなかったのです。
この記事では、r3f アプリに WebXR(Meta Quest)対応を追加する過程で遭遇した問題と解決策を、失敗も含めて記録します。
技術スタック
| 項目 | バージョン |
|---|---|
| Next.js | 16.2.1 |
| React | 19.2.4 |
| React Three Fiber | 9.5.0 |
| @react-three/xr | 6.6.29 |
| Three.js | 0.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 に ref か callback 関数 を渡せます。デバッグのために 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.Clock と PCFSoftShadowMap が非推奨になりました。
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> |
| コントローラー移動 | useXRControllerLocomotion の ref 形式を使う |
| 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 が使えないため、別の方法で状態を可視化する工夫が重要です。