WebXR / VR / AR(Meta Quest 等の HMD で没入型)
ブラウザだけでMeta Quest / Vision Pro / Pico 向けの没入型コンテンツが作れる。 WebXR Device API を R3F + @react-three/xr で扱うやり方を一通り。
WebXR とは
- ブラウザでVR / AR / MRを扱う標準 API(W3C / WebXR Device API)
- 旧 WebVR の後継。現代はこれ一択
- HMD のヘッド位置・コントローラー・ハンドトラッキングを取れる
- 同じ Three.js シーンを非 XR / XR の両方で動かせる
- ネイティブアプリが要らない → 配信は URL を共有するだけ
動くデバイス
| デバイス | WebXR ブラウザ | 備考 |
|---|---|---|
| Meta Quest 2 / 3 / Pro | Meta Quest Browser, Wolvic | 最も広く使われる。AR (パススルー) も可 |
| Apple Vision Pro | Safari (visionOS) | WebXR 対応進行中、独自の immersive モードあり |
| Pico 4 / Neo | Pico Browser | Quest 互換 |
| HTC Vive / Index | Chrome / Edge (PC) | SteamVR 経由 |
| Android スマホ | Chrome | WebXR AR (ARCore) |
| iPhone | 非対応(Safari は WebXR を実装していない) | Quick Look や AR.js で代替 |
WebXR Device API のコア概念
セッション
- immersive-vr: 完全没入型(HMD 必須)
- immersive-ar: パススルー / 透過 (AR)
- inline: 通常の Web ページ内(HMD なし、3D 表示だけ)
Reference Space(基準座標系)
- viewer: 視点(カメラ)原点
- local: セッション開始位置(小範囲)
- local-floor: 床を Y=0 にした local(VR の標準)
- bounded-floor: プレイエリア境界が分かる
- unbounded: 大規模 AR 用(位置追跡が広い)
Input Source
- コントローラー / ハンド / 視線
- 各 Input Source は targetRaySpace(指す方向)と gripSpace(手の位置)を持つ
- ボタン / アナログスティック値は
gamepadプロパティ
@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" 等
})
}
関節名
wristthumb-metacarpal/thumb-phalanx-proximal/thumb-phalanx-distal/thumb-tipindex-finger-metacarpal〜index-finger-tip(4 関節)- 同様に middle / ring / pinky
- 計 25 ジョイント
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 倍のコスト感覚。
守るべきライン
- 常時 90fps(最低でも 72fps)。一瞬でも下がると酔う
- ドローコール: Quest 2 で200 以下、Quest 3 で 500 以下目安
- 三角形数: Quest 2 で 100k 以下、Quest 3 で 500k 以下
- マテリアルは MeshStandardMaterial 1〜2 種類に統一(バッチング)
- Shadow は1 ライトのみ or 焼き込み
- Post Processing は原則禁止(フルスクリーンパスは重すぎる)
フォビエイテッドレンダリング
視野中央のみ高解像度、周辺を低解像度で描画。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 ドローコールに。
カメラとサイズ感
- 1 ユニット = 1 メートルと決めておくと、HMD と物理的に一致
- 人の目の高さは 1.6m 前後(local-floor で y=1.6 を想定)
- 近すぎる物(0.1m 以下)は両眼の調整負担で疲れる
- テキストは1.5m 先で 60〜80px 相当が読みやすい
HTTPS 必須
- WebXR はHTTPS でしか動かない
- ローカル開発:
localhostは HTTP でも OK - 同じ Wi-Fi の Quest からアクセスするには:
vite --host --https- Cloudflare Tunnel / ngrok で公開
- Quest 側で「無視できない自己署名」になるので
chrome://flags系が必要なことも
Quest 実機テスト方法
1. Quest Browser を使う
- Quest をかぶり、Browser を開いて URL 入力
- VR ボタンを押すだけで没入
- キーボード入力は仮想キーボードかペアリングしたスマホで
2. ADB + リモートデバッグ
- Quest を開発者モードに(Meta アカウントで dev 登録)
- USB-C で PC に接続
adb devicesで認識確認- Quest Browser で URL を開く
- PC の Chrome で
chrome://inspect→ デバイスが出る - 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 拡張)
- HMD 無しでもブラウザ上で WebXR を試せる
- マウス操作で頭・コントローラーを動かせる
- Meta が公開している純正拡張
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。
デプロイ
- Cloudflare Pages / Vercel / Netlify — HTTPS 自動。Quest からそのまま開ける
- SPA なら
npm run build→ デプロイで配信完了 - ファイルサイズに気を付ける(モバイル GPU でも動く程度のメッシュサイズ)
- iframe 埋め込みはpermissions-policy: xr-spatial-tracking が必要
SideQuest / App Lab / Web の使い分け
| 方式 | 長所 | 短所 |
|---|---|---|
| WebXR | URL 配布、即時更新、課金不要 | パフォーマンス制約、Quest Store に出ない |
| SideQuest | APK 直配布、玄人向け | ユーザに敷居 |
| App Lab | Quest 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 推奨 |
| ハンドトラッキング切れる | 明るさ不足。コントローラーを置けば自動切替 |
没入型で気を付けるデザイン
- 酔わせない: 急な視点移動 NG。テレポート or トンネル視野(vignette)で軽減
- UI は 1.5〜2m 先: 近すぎは目が疲れる
- コンフォートゾーン: 視野 30 度内に重要 UI
- 音で補助: 視覚以外の手がかりが没入感を増す
- 休憩を促す: 30 分以上の連続使用で警告
- 座位 / 立位: ターゲットを明示。立位前提で頭ぶつかる演出は NG
サンプル: 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>
</>
)
}
関連リソース
- WebXR Device API 仕様
- @react-three/xr リポジトリ
- R3F 公式
- WebXR Samples(公式)
- Meta Quest WebXR Docs
- Apple visionOS
1) Quest と PC を同 Wi-Fi に / 2) vite --host --https で開発サーバ起動 /
3) Quest Browser で https://192.168.x.x:5173 を開く /
4) Enter VR を押せば没入。最小サンプルが動いたら、コントローラー → ハンド → AR の順に拡張する。