WebXR / VR / AR(Meta Quest 等の HMD で没入型)

ブラウザだけでMeta Quest / Vision Pro / Pico 向けの没入型コンテンツが作れる。 WebXR Device API を R3F + @react-three/xr で扱うやり方を一通り。

WebXR とは

動くデバイス

デバイスWebXR ブラウザ備考
Meta Quest 2 / 3 / ProMeta Quest Browser, Wolvic最も広く使われる。AR (パススルー) も可
Apple Vision ProSafari (visionOS)WebXR 対応進行中、独自の immersive モードあり
Pico 4 / NeoPico BrowserQuest 互換
HTC Vive / IndexChrome / Edge (PC)SteamVR 経由
Android スマホChromeWebXR AR (ARCore)
iPhone非対応(Safari は WebXR を実装していない)Quick Look や AR.js で代替

WebXR Device API のコア概念

セッション

Reference Space(基準座標系)

Input Source

@react-three/xr

R3F で WebXR を扱うための Pmndrs 製ライブラリ。バージョン v6 系(2024〜)から 旧 v5 と API が大きく変わった。v6 を前提に書く。

インストール

npm install three @react-three/fiber @react-three/xr @react-three/drei

最小サンプル(VR)

import { Canvas } from "@react-three/fiber"
import { XR, createXRStore } from "@react-three/xr"

const store = createXRStore()

export default function App() {
  return (
    <>
      <button onClick={() => store.enterVR()}>Enter VR</button>
      <Canvas>
        <XR store={store}>
          <ambientLight intensity={0.5} />
          <directionalLight position={[2, 4, 1]} />
          <mesh position={[0, 1.5, -2]}>
            <boxGeometry />
            <meshStandardMaterial color="orange" />
          </mesh>
        </XR>
      </Canvas>
    </>
  )
}

Quest Browser でこのページを開き、ボタンをタップすると没入モードに入る。

createXRStore のオプション

const store = createXRStore({
  // コントローラーの見た目("default" | false | カスタム JSX)
  controller: { left: <LeftController />, right: <RightController /> },

  // ハンドトラッキング
  hand: { touchPointer: true },

  // フォビエイテッドレンダリング
  foveation: 1,           // 0〜1 (高いほど周辺を低解像度に)

  // フレームレート
  frameRate: "high",      // "high" | "low"

  // パススルー (AR)
  bounded: false,
  emulate: { syntheticEnvironment: true },
})

store.enterVR()
store.enterAR()
store.exit()

セッションのライフサイクル

import { useXR } from "@react-three/xr"

function SceneInsideXR() {
  const session = useXR((s) => s.session)        // 現在のセッション
  const mode = useXR((s) => s.mode)              // "immersive-vr" | "immersive-ar" | null
  const referenceSpace = useXR((s) => s.originReferenceSpace)

  if (!session) return null  // 没入していないとき

  return <mesh />
}

XR Origin(プレイヤー)

XR ではカメラ位置 = ヘッドの位置。プレイヤーの足元を移動させたいときは XR Origin を動かす。<XROrigin> でラップ。

import { XROrigin } from "@react-three/xr"

<XR store={store}>
  <XROrigin position={[0, 0, 5]} rotation={[0, Math.PI, 0]}>
    {/* この中の Camera / Controller / Hand が一緒に動く */}
  </XROrigin>
</XR>

コントローラー

標準の表示

デフォルトでメーカー提供の 3D モデル + レイ(指す光線)が出る。何もしなくて出る。

入力イベント

import { useXRInputSourceEvent, useXRControllerLocomotion } from "@react-three/xr"

// トリガー / グリップなどのイベント
useXRInputSourceEvent("all", "select", (e) => {
  console.log("select", e.inputSource.handedness) // "left" | "right"
})

useXRInputSourceEvent("all", "selectstart", (e) => {})
useXRInputSourceEvent("all", "selectend",   (e) => {})
useXRInputSourceEvent("all", "squeeze",     (e) => {})

スティック / ボタン値の取得

import { useXRInputSourceState } from "@react-three/xr"
import { useFrame } from "@react-three/fiber"

function ThumbstickReader() {
  useFrame(() => {
    const state = useXRInputSourceState.getState() // 全 input source
    // ↑ Hooks は Hook ルール上 useFrame の中で呼べないので、
    //   実際は Hook を上で呼んで ref で取る or zustand selector

    // 代替: useXR で gamepad から
  })
  return null
}

handedness 別に取得

import { useXRInputSourceState } from "@react-three/xr"

function Player() {
  const left = useXRInputSourceState("controller", "left")
  const right = useXRInputSourceState("controller", "right")

  useFrame(() => {
    // gamepad はネイティブな Gamepad API 互換
    const xAxis = right?.gamepad?.["xr-standard-thumbstick"]?.xAxis ?? 0
    const yAxis = right?.gamepad?.["xr-standard-thumbstick"]?.yAxis ?? 0

    const aPressed = right?.gamepad?.["a-button"]?.button ?? 0
    const triggerVal = right?.gamepad?.["xr-standard-trigger"]?.button ?? 0
  })
}

移動(Locomotion)

テレポート(drei + xr)

import { TeleportTarget, useXRControllerLocomotion } from "@react-three/xr"

function Floor() {
  return (
    <TeleportTarget>
      <mesh rotation-x={-Math.PI / 2}>
        <planeGeometry args={[20, 20]} />
        <meshStandardMaterial />
      </mesh>
    </TeleportTarget>
  )
}

スティック移動(Smooth Locomotion)

import { useXRControllerLocomotion } from "@react-three/xr"

function PlayerMovement() {
  // 左スティック移動 + 右スティック回転(スナップ or スムーズ)
  useXRControllerLocomotion({
    speed: 2,
    rotationSpeed: 1,
    rotationType: "snap",       // "snap" | "smooth"
    snapAngle: 30,              // degrees
  })
  return null
}

ハンドトラッキング

Quest はコントローラーを置けば素手の追跡に切り替わる。25 個の関節データ。

import { useXRInputSourceState, XRHandModel } from "@react-three/xr"

// store でハンド有効化(デフォルト on)
const store = createXRStore({
  hand: { touchPointer: true, rayPointer: true, grabPointer: true },
})

function HandReader() {
  const left = useXRInputSourceState("hand", "left")
  const right = useXRInputSourceState("hand", "right")

  useFrame(() => {
    // ピンチ判定
    const pinching = right?.inputSource?.hand?.get("index-finger-tip")
    // ジョイント位置: hand.get("wrist"), "thumb-tip", "middle-finger-tip" 等
  })
}

関節名

UI(Quest で動く HUD)

DOM の HTML はそのままでは XR で見えない。3D 空間に配置するには drei の Html(XR では使えない)ではなく、 テクスチャ化された UI(uikit / koestlich / @react-three/uikit)を使う。

@react-three/uikit

import { Container, Text, Root } from "@react-three/uikit"

<Root>
  <Container width={400} height={200} backgroundColor="black" padding={16}>
    <Text fontSize={32} color="white">Score: 100</Text>
  </Container>
</Root>

Tailwind 風の API。VR / AR でクリック・ホバーが効く HUD を 3D 空間に置ける。

AR(パススルー)

Meta Quest 3 / Pro はカメラパススルーで現実が見える。

<button onClick={() => store.enterAR()}>Enter AR</button>

<Canvas>
  <XR store={store}>
    {/* 背景は透過。シーンが現実に重畳表示される */}
    <mesh position={[0, 1, -1]}>
      <sphereGeometry args={[0.1]} />
      <meshStandardMaterial color="cyan" />
    </mesh>
  </XR>
</Canvas>

Hit Test(平面検出)

スマホ AR や Quest AR で「現実の床にオブジェクトを置く」やつ。

import { useXRHitTest, XROrigin } from "@react-three/xr"

function Reticle() {
  const ref = useRef<THREE.Mesh>(null)
  useXRHitTest((results, getWorldMatrix) => {
    if (results.length > 0 && ref.current) {
      const m = getWorldMatrix(new THREE.Matrix4(), results[0])
      ref.current.matrix.copy(m!)
      ref.current.matrixAutoUpdate = false
      ref.current.visible = true
    }
  }, "viewer")  // viewer space で測る

  return (
    <mesh ref={ref} visible={false}>
      <ringGeometry args={[0.07, 0.1, 32]} rotation-x={-Math.PI/2} />
      <meshBasicMaterial color="white" />
    </mesh>
  )
}

Anchors(位置固定)

Hit Test で得た位置をセッション間で永続化する仕組み。 部屋を再認識して「あの場所にあった仮想テレビ」を復元できる(Quest の「Spatial Anchors」)。

Plane Detection

Quest 3 / Vision Pro は床・壁・テーブルを平面メッシュとして返す。 オブジェクトを正しく置くのに使う。<XRPlanes />

Mesh Detection(Scene Mesh)

Quest 3 が部屋全体を 3D メッシュとして提供。物理衝突や occlusion に使える。

Layers(高品質 UI)

WebXR Layers(Quad / Cylinder / Equirect)はテクスチャをシーンを通さず直接 HMD に表示する仕組み。 テキストやビデオを歪まず鮮明に出せる。

import { useVideoTexture } from "@react-three/drei"

// uikit / @react-three/xr の VideoLayer など
<mesh>
  <planeGeometry args={[2, 1.125]} />
  <meshBasicMaterial map={videoTexture} />
</mesh>

パフォーマンス(最重要)

VR は1 フレーム遅れると酔う。Quest 2 で 90Hz、Quest 3 で 120Hz、 両眼で 2 回描画=非 XR の 4 倍のコスト感覚。

守るべきライン

フォビエイテッドレンダリング

視野中央のみ高解像度、周辺を低解像度で描画。foveation: 1 で最大。 Quest はハードでサポート。

解像度スケール

<Canvas
  gl={{ powerPreference: "high-performance" }}
  dpr={[1, 1.5]}
>
  ...
</Canvas>

// XR 中は内部で解像度が決まる。Three.js の場合:
gl.xr.setFramebufferScaleFactor(1.0)  // 0.5 〜 1.5

インスタンシング

同じ形が大量にあるなら必ず <Instances> / InstancedMesh。 100 個 → 1 ドローコール。

バッチング

BatchedMesh で異なる形でも 1 ドローコールに。

カメラとサイズ感

HTTPS 必須

Quest 実機テスト方法

1. Quest Browser を使う

2. ADB + リモートデバッグ

  1. Quest を開発者モードに(Meta アカウントで dev 登録)
  2. USB-C で PC に接続
  3. adb devices で認識確認
  4. Quest Browser で URL を開く
  5. PC の Chrome で chrome://inspect → デバイスが出る
  6. Inspect でフル DevTools が使える(コンソール、ネットワーク、Performance)
brew install android-platform-tools

adb devices
# List of devices attached
# 1WMHHxxxxxxxx   device

# 開発機の Web サーバを Quest にフォワード
adb reverse tcp:5173 tcp:5173

# Quest Browser で http://localhost:5173 にアクセスできる!

3. WebXR API Emulator(Chrome 拡張)

WebXR Polyfill / iimmersive-web

非対応端末でもマジックウィンドウとして動かす polyfill が一応ある(immersive-web/webxr-polyfill)。 実用は限定的。

非 XR / XR 共存

同じ React コンポーネントツリーで普通の Web ページ表示と XR の両方に対応する。

import { IfInSessionMode } from "@react-three/xr"

<XR store={store}>
  <OrbitControls />   {/* XR セッション中は自動で無効 */}

  <IfInSessionMode allow="immersive-vr">
    <PlayerMovement />  {/* VR 中だけ */}
  </IfInSessionMode>

  <IfInSessionMode allow={["immersive-ar"]}>
    <Reticle />
  </IfInSessionMode>

  <IfInSessionMode deny={["immersive-vr", "immersive-ar"]}>
    {/* 非 XR 表示時 */}
  </IfInSessionMode>
</XR>

サウンド / 空間オーディオ

Three.js の PositionalAudio がそのまま使える。両耳に応じた立体音響が出る。 Quest はヘッドホンスピーカー。

import { PositionalAudio } from "@react-three/drei"

<mesh>
  <PositionalAudio url="/sound.mp3" distance={1} loop />
</mesh>

触覚フィードバック(Haptics)

function rumble(controller: XRInputSource) {
  const actuator = controller.gamepad?.hapticActuators?.[0]
  actuator?.pulse(0.7, 100)  // intensity, ms
}

視線(Eye Tracking)

Vision Pro / Quest Pro が対応。WebXR Gaze Tracking API がドラフト中。 現状は視野中心 = 視線で代用するのが一般的。

サンプルプロジェクト構造

my-xr-app/
├── src/
│   ├── App.tsx        # XR セッション管理
│   ├── XRScene.tsx    # シーン本体
│   ├── components/
│   │   ├── Controllers.tsx
│   │   ├── Hands.tsx
│   │   ├── Player.tsx
│   │   ├── Floor.tsx
│   │   └── HUD.tsx
│   └── lib/
│       └── store.ts   # createXRStore
├── public/
│   └── models/        # GLTF
└── vite.config.ts     # https + host 設定
      

Vite で HTTPS + ホスト公開

// vite.config.ts
import { defineConfig } from "vite"
import basicSsl from "@vitejs/plugin-basic-ssl"

export default defineConfig({
  plugins: [basicSsl()],
  server: { host: true, https: true, port: 5173 },
})

起動時に表示される https://192.168.x.x:5173 を Quest Browser で開く。 証明書警告は「詳細」→「アクセス」で進めば OK。

デプロイ

SideQuest / App Lab / Web の使い分け

方式長所短所
WebXRURL 配布、即時更新、課金不要パフォーマンス制約、Quest Store に出ない
SideQuestAPK 直配布、玄人向けユーザに敷居
App LabQuest Store の半公式枠審査あり
Quest Store本格配布厳しい審査

ハマりポイント

症状対処
VR ボタンが押せないHTTPS か確認、ユーザのジェスチャから呼ぶ必要あり
地面に埋まる / 浮くlocal-floor を使う、XROrigin の高さ調整
動かしたら酔うカクつき / FPS 低下。最適化必須
UI がぼやけるテキストは 3D 空間で大きめサイズ、レイヤー検討
コントローラーモデルが出ないstore.controller を "default" にする / バージョン確認
パススルーが透けない背景の scene.background を null に、Canvas に gl: {{ alpha: true }}
Quest 2 で AR 出ないQuest 2 はパススルーが粗いだけで一応可。Quest 3 推奨
ハンドトラッキング切れる明るさ不足。コントローラーを置けば自動切替

没入型で気を付けるデザイン

サンプル: VR シューティングの骨格

import { Canvas, useFrame } from "@react-three/fiber"
import {
  XR, createXRStore, XROrigin, useXRInputSourceState,
  useXRInputSourceEvent, IfInSessionMode,
} from "@react-three/xr"
import { useRef, useState } from "react"
import * as THREE from "three"

const store = createXRStore({ foveation: 1 })

function Bullet({ from, dir }: { from: THREE.Vector3, dir: THREE.Vector3 }) {
  const ref = useRef<THREE.Mesh>(null!)
  useFrame((_, dt) => {
    ref.current.position.addScaledVector(dir, 30 * dt)
  })
  return (
    <mesh ref={ref} position={from}>
      <sphereGeometry args={[0.02]} />
      <meshBasicMaterial color="yellow" />
    </mesh>
  )
}

function Player() {
  const right = useXRInputSourceState("controller", "right")
  const [bullets, setBullets] = useState<any[]>([])

  useXRInputSourceEvent("right", "select", (e) => {
    const space = e.inputSource.targetRaySpace
    // gripSpace の world matrix から発射位置 / 方向を計算
    // 省略:ここで bullet を spawn
    setBullets((b) => [...b, { id: Date.now(), from, dir }])
  })

  return (
    <>
      {bullets.map((b) => <Bullet key={b.id} from={b.from} dir={b.dir} />)}
    </>
  )
}

export default function App() {
  return (
    <>
      <button onClick={() => store.enterVR()}>Enter VR</button>
      <Canvas>
        <XR store={store}>
          <ambientLight intensity={0.7} />
          <directionalLight position={[5, 10, 5]} />
          <XROrigin />
          <mesh rotation-x={-Math.PI/2} receiveShadow>
            <planeGeometry args={[50, 50]} />
            <meshStandardMaterial />
          </mesh>
          <IfInSessionMode allow="immersive-vr">
            <Player />
          </IfInSessionMode>
        </XR>
      </Canvas>
    </>
  )
}

関連リソース

最初に手を動かす流れ

1) Quest と PC を同 Wi-Fi に / 2) vite --host --https で開発サーバ起動 / 3) Quest Browser で https://192.168.x.x:5173 を開く / 4) Enter VR を押せば没入。最小サンプルが動いたら、コントローラー → ハンド → AR の順に拡張する。