はじめに

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 は第3引数で別途渡される)
target(vectorHelper, rotationY, deltaTime, state, frame);
ref 形式callback 形式
deltaTime内部で適用済み第3引数で渡されるが自分で掛ける必要あり
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
Playwright 動画撮影実行中シーンから座標を読み取り、自動操作で録画

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

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


落とし穴 4: Playwright で VR 動画撮影 — 座標計算のズレ

VR 空間の紹介動画を撮影するため、Playwright でブラウザを自動操作しました。IIIF の xywh パラメータで指定した領域(例: xywh=14550,18540,300,344)をカメラで閲覧するシーンを撮りたかったのですが、何度やっても正しい場所が映りませんでした。

失敗 1: ピクセル→ワールド座標の計算が合わない

画像はメッシュとして床に配置されています。ピクセル座標をワールド座標に変換する計算式は:

// IiifImagePlane.tsx のタイル配置ロジックから導出
worldX = centerX - widthM / 2 + (pixelX / pxW) * widthM;
worldZ = centerZ - heightM / 2 + (pixelY / pxH) * heightM;

式自体は正しいのですが、代入する値が間違っていました

失敗 2: オフライン計算 vs 実際のシーン

最初のアプローチでは、Playwright から IIIF Collection API を直接 fetch して画像サイズを計算していました。

// オフライン計算(間違い)
widthM = 5;  // fallback
heightM = 5 * (pxH / pxW);

しかし実際のアプリでは、IIIF マニフェストに physicalScale サービスが含まれる画像は実寸で配置されます。

オフライン計算実際のシーン
widthM5.00 m6.216 m
heightM2.855 m3.549 m
centerZ2.8710.833
startZ6.044.28

ほぼ全ての値が違っていました。IIIF マニフェストの physicalScale パースが Playwright 側の簡易実装では抜けていたのが原因です。

失敗 3: カメラの視線方向を考慮していない

座標が合ったとしても、もう一つの落とし穴があります。プレイヤーが地点の真上に立っても、カメラは足元ではなく前方の床を見ます。

sensitivity = 0.0025 rad/px
drag(0, 200) → pitch = -0.5 rad(28°下向き)

eye height 1.52m で 28° 下向き:
  可視距離 = 1.52 / tan(28°) ≈ 2.8m 前方

つまり足元ではなく 2.8m 先の床を見ていたのです。

解決策: 実行中シーンから直接座標を読む

オフライン計算を諦め、実行中の React アプリから window 経由でシーンデータを公開する方法に切り替えました。

// Scene.tsx — デバッグ用 window 公開
useEffect(() => {
  if (iiifImages.length > 0) {
    (window as any).__VR_DEBUG = {
      images: iiifImages.map((img) => ({
        centerX: img.centerX,
        centerZ: img.centerZ,
        widthM: img.data.widthM,
        heightM: img.data.heightM,
        pxW: img.data.pxW,
        pxH: img.data.pxH,
      })),
      roomHalfW, roomHalfD,
      startZ: roomHalfD - 1,
    };
  }
}, [iiifImages, roomHalfW, roomHalfD]);

Playwright 側で読み取り:

await page.waitForFunction(() => window.__VR_DEBUG?.images?.length > 0);
const debug = await page.evaluate(() => window.__VR_DEBUG);

// 実際の値で座標変換
const nearest = debug.images[2]; // 最寄りの画像
const worldX = nearest.centerX - nearest.widthM / 2
             + (TARGET_PX_X / nearest.pxW) * nearest.widthM;
const worldZ = nearest.centerZ - nearest.heightM / 2
             + (TARGET_PX_Y / nearest.pxH) * nearest.heightM;

Playwright 撮影スクリプトの構成

最終的なスクリプトは以下の流れです。

// 1. ページ読み込み + シーンデータ取得
await page.goto(url);
const debug = await page.evaluate(() => window.__VR_DEBUG);

// 2. ターゲットへの角度・距離を計算
const dx = worldX - 0;
const dz = worldZ - startZ;
const angle = Math.atan2(-dx, -dz);
const dist = Math.sqrt(dx * dx + dz * dz);

// 3. ターゲット方向を向く(yaw 回転)
const yawPixels = Math.round(angle / 0.0025);
await drag(-yawPixels, 0, 1500);

// 4. 少し下を向いて歩く
await drag(0, 150, 800);
await hold("KeyW", dist / 2 * 1000); // speed=2m/s

// 5. 真下を向いてターゲットを確認
await drag(0, 400, 1200);

// 6. しゃがみ → うつ伏せ → 立ち上がり
await page.keyboard.press("KeyC"); // crouch
await page.keyboard.press("KeyC"); // prone
await page.keyboard.press("KeyC"); // stand

recordVideo で自動録画されるため、操作が終われば page.close() で動画ファイルが保存されます。

教訓

  1. 3D シーンの座標は実行時に読む。IIIF のメタデータパースを再実装するより、window 経由で公開して Playwright から読む方が確実
  2. カメラの pitch(視線角度)も計算に含める。プレイヤー位置 ≠ 視線の着地点
  3. Playwright の recordVideoheadless: false + --use-gl=angle が必要。WebGL レンダリングはヘッドレスでは動かない