mcpField
W
webxr-dev

webxr-dev

github.com/danielcanton開発3258 回インストール34 週間前に更新
リポジトリ

webxr-dev について

webxr-devは、Three.jsでWebXR体験を開発・デバッグするための実践ガイドです。VR・ARを実装する開発者向けに、Meta Questでの開発経験を踏まえ、セッション、描画、操作、移動、VR UIを解説します。

主な機能

  1. 01VRまたはARセッションの選択と設定
  2. 02XRフレームバッファ解像度の設定
  3. 03シェーダー、ステレオ描画、ポストプロセスへの対応
  4. 04カメラリグと移動方式の構築
  5. 05ワールド空間のVR UIパネル作成

活用シーン

  1. 01Three.jsアプリへのVRまたはARの追加
  2. 02WebXR描画のぼやけのデバッグ
  3. 03コントローラー移動とテレポートの実装
  4. 04VR向けの視覚効果の調整

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/danielcanton/webxr-dev-skill --skill webxr-dev

インストール後、Claude Code で/webxr-devを入力して読み込まれたか確認します。

同梱ファイルLICENSEREADME.md

詳細

WebXR 開発ガイド

Meta Quest ヘッドセットでの VR リリースから得られた貴重な教訓に基づく、Three.js で WebXR (VR/AR) 体験を構築するための実践的なガイドです。

このスキルを使用する場面

以下の場合に /webxr-dev で呼び出します:

  • Three.js アプリケーションに VR/AR サポートを追加する場合
  • WebXR のレンダリング問題をデバッグする場合
  • VR インタラクションシステム (パネル、テレポート、コントローラー、ハンド) を実装する場合
  • 既存の VR アプリケーションにパススルー/AR を追加する場合

重要なルール: immersive-vr と immersive-ar

これは WebXR アプリにおいて最も重要な単一の決定事項です。

immersive-vr (デフォルト — これを使用)
  • フレームバッファは不透明です。コンポジターはレンダリング結果をそのまま表示します。
  • scene.background、setClearColor() は通常どおり動作します。
  • Three.js は背景、フォグ、スカイボックスを正しくレンダリングします。
  • 標準的な VR の挙動 — すべてがそのまま動作します。
immersive-ar (パススルー — ここに落とし穴あり)
  • フレームバッファのアルファがカメラのブレンドを制御します: alpha=0 → 完全なパススルー、alpha=1 → 完全なレンダリング。
  • Three.js は AR セッションにおいて scene.background のレンダリングを意図的にスキップします。
  • setClearColor(color, 1) は Three.js の XR パスによって上書きされる可能性があります。
  • すべてのシーンのすべてのピクセルが、カメラフィードを遮るために明示的に alpha=1.0 を出力する必要があります。
  • gl_FragColor = vec4(color, 1.0) を使用するシェーダーは問題ありませんが、MeshBasicMaterial/MeshStandard はそうでない場合があります。
  • セッションの途中で VR と AR を切り替えることはできません — セッションモードはリクエスト時に固定されます。
ルール
Start with immersive-vr. Only switch to immersive-ar when you have:
1. A specific passthrough use case
2. The ability to test on-device
3. A plan for managing alpha in every material and shader

デバイスが対応しているという理由だけで、決して immersive-ar をデフォルトにしないでください。Quest 3 は両方をサポートしていますが、パススルーが主要な体験でない限り、常に immersive-vr を優先してください。


セッションのセットアップ

最小限の動作するセットアップ
const xr = navigator.xr;
const supported = await xr.isSessionSupported("immersive-vr");
if (!supported) return;

const sessionInit: XRSessionInit = {
  optionalFeatures: [
    'local-floor',      // Floor-level reference space
    'bounded-floor',    // Room-scale boundary
    'hand-tracking',    // Quest hand tracking
    'layers',           // XRProjectionLayer + composition layers
    'hit-test',         // Ray-vs-real-world hit testing (AR)
  ],
};

const session = await xr.requestSession("immersive-vr", sessionInit);
renderer.xr.setReferenceSpaceType("local-floor");
renderer.xr.setSession(session);
有効な機能記述子

optionalFeatures / requiredFeatures で認識されるのは、以下の文字列のみです: anchors、bounded-floor、depth-sensing、dom-overlay、hand-tracking、hit-test、layers、light-estimation、local、local-floor、secondary-views、unbounded、viewer。

認識されない文字列は黙って無視されます — エラーは発生しませんが、何も起きません。

ネイティブフレームバッファ解像度 (ピクセル化対策)

重要: デフォルトの WebXR フレームバッファは低解像度です。ピクセル化されたレンダリングを避けるには、ネイティブのスケールファクターで XRWebGLLayer を作成する必要があります:

const onSessionStarted = (session: XRSession) => {
  session.addEventListener("end", onSessionEnded);
  renderer.xr.setReferenceSpaceType("local-floor");

  // Set high-res framebuffer — without this, everything looks pixelated
  const gl = renderer.getContext();
  const glLayer = new XRWebGLLayer(session, gl, {
    framebufferScaleFactor: XRWebGLLayer.getNativeFramebufferScaleFactor(session),
    antialias: true,
  });
  session.updateRenderState({ baseLayer: glLayer });

  renderer.xr.setSession(session);
};

これを行わないと、Quest はネイティブ解像度のほんの一部でレンダリングします。この single な変更が、「ぼやける/ピクセル化する」というほとんどの不満を解消します。

後からパススルーを追加する (必要な場合)

パススルーには immersive-vr ではなく immersive-ar が必要です。'passthrough' というオプション機能は存在しません — その文字列は WebXR 仕様の一部ではありません。

パススルーを有効にするには:

// Request an AR session — the device composites your render over the camera feed
const session = await xr.requestSession("immersive-ar", {
  optionalFeatures: ['local-floor', 'hand-tracking'],
});
// session.environmentBlendMode will be "alpha-blend" on Quest

immersive-ar では、フレームバッファのアルファがカメラのブレンドを制御します: alpha=0 は完全なパススルーを表示し、alpha=1 はレンダリング結果を表示します。セッションの途中で VR と AR を切り替えることはできません — モードはリクエスト時に固定されます。


VR におけるシェーダーの考慮事項

反転球体スカイボックスパターン

カスタムシェーダーのスカイボックス (レイマーチング、プロシージャルな星など) には、DoubleSide を指定した標準的な球体を使用します:

const sphereGeo = new THREE.IcosahedronGeometry(50, 5);
// Do NOT call sphereGeo.scale(-1, 1, 1) — see "Geometry Inversion Trap" below
const material = new THREE.ShaderMaterial({
  vertexShader, fragmentShader,
  side: THREE.DoubleSide,  // Guarantees visibility from inside — no culling surprises
  depthWrite: false,
});
const vrSphere = new THREE.Mesh(sphereGeo, material);
vrSphere.frustumCulled = false;
ジオメトリ反転の罠

sphereGeo.scale(-1, 1, 1) と side: THREE.BackSide を決して組み合わせないでください。

  • scale(-1,1,1) は巻き順と法線の両方を反転させます (法線は内向きになります)
  • BackSide は法線方向の反対側の面をレンダリングします
  • 内向きの法線では、BackSide は外側をレンダリングします — 内側のカメラには何も見えません
  • これは二重否定であり、球体を不可視にします

カメラが球体の内側にある場合のレンダリングにおける安全な選択肢:

  1. DoubleSide (推奨): 両方の面をレンダリングし、すべてのカリングの曖昧さを排除します。シェーダーがレイ方向によって描画内容を決定します — 二重レンダリングによる視覚的なコストはゼロです。
  2. 標準ジオメトリ + BackSide: 理論上は動作します (外向きの法線、BackSide が内面をレンダリング) が、巻き順がジオメトリの種類によって曖昧になる可能性があります。
  3. 反転ジオメトリ + FrontSide: これも動作しますが、同様に脆弱です。

DoubleSide を使用して、このことを気にするのはやめましょう。

VR カメラ位置

VR では、カメラはユーザーの物理的な頭の位置(床から約 1.6m 上方)にあります。これは以下に影響します:

  • レイマーチングシェーダー: BH/オブジェクトが原点にある場合、カメラがその内部または近すぎる位置にある可能性があります。
  • 修正: ユーザーが見る位置(目の高さ、数メートル前方)を基準にオブジェクトを配置します。
  • 例: rs=0.2 のブラックホールの場合、(0, 1.6, -5) に配置します — 目の高さで 5m 前方です。シュヴァルツシルト半径の 25 倍では、視界を圧倒することなくレンズ効果がはっきりと確認できます。
VR におけるオブジェクトのスケール

デスクトップでは問題なく見えるオブジェクトも、VR では実際の空間知覚があるため、しばしば巨大に感じられます。VR では常にオブジェクトの実効サイズを縮小してください。 例:mass=1.5(rs=1.5m)のブラックホールはデスクトップでは機能しますが、VR では部屋ほどの大きさの球体になります。扱いやすく劇的な効果を得るには mass=0.2 に縮小してください。反復的にテストしてください — ヘッドセット内でのスケール知覚に代わるものはありません。

ステレオ視差

VR 頂点シェーダーは、正しいステレオのために眼ごとのカメラ位置を渡す必要があります:

varying vec3 vCameraPos;
void main() {
  vCameraPos = cameraPosition; // Three.js sets this per-eye in VR
  // ...
}

カメラ位置をハードコードしないでください — 各眼は異なるオフセットからレンダリングします。


VR におけるポストプロセッシング

postprocessing ライブラリは WebXR をサポートしていません

postprocessing npm パッケージ(EffectComposer、BloomEffect など)には XR/ステレオの認識が一切ありません。XR フレームバッファにレンダリングすることはできません。

レンダリングループでは、VR モードはコンポーザーをバイパスする必要があります:

if (renderer.xr.isPresenting) {
  renderer.render(scene, camera);  // Direct render — no composer
} else {
  composer.render(delta);           // Desktop gets postprocessing
}

結果: スクリーンスペースのエフェクト(ブルーム、歪み、トーンマッピング、色収差)は VR では完全に見えなくなります。

VR 互換の代替手段

スクリーンスペースのポストプロセッシングを 3D シーンオブジェクトに置き換えます:

デスクトップのエフェクトVR での代替
ブルーム発光マテリアル、明るい色の MeshBasicMaterial、加算合成
画面の歪み拡大する衝撃波リングメッシュ(RingGeometry + DoubleSide + フェード)
カラーグレーディングマテリアルの色を直接調整
グロー透明度を持つ、より大きく明るい MeshBasicMaterial の球体

例 — 合体衝突エフェクト:

// Glow sphere: scale up + color shift at peak
this.glowMaterial.opacity = glowIntensity * 0.9;
this.mergerGlow.scale.setScalar(1 + glowIntensity * 5);
// Purple → white shift
this.glowMaterial.color.setRGB(
  0.39 + glowIntensity * 0.61,
  0.40 + glowIntensity * 0.60,
  0.95 + glowIntensity * 0.05
);

// Expanding shockwave ring
const shockProgress = Math.max(0, (playbackTime - mergerNorm) * 6);
if (shockProgress > 0 && shockProgress < 1) {
  shockwaveMaterial.opacity = (1 - shockProgress) * 0.7;
  shockwaveRing.scale.setScalar(1 + shockProgress * 15);
} else {
  shockwaveMaterial.opacity = 0;
}

カメラリグのアーキテクチャ

リグパターン

すべての可動オブジェクト(カメラ、コントローラー、テレポートターゲット)は、単一の cameraRig グループの下に配置します:

scene
  └── cameraRig (THREE.Group)
      ├── camera (PerspectiveCamera)
      ├── controller1
      ├── controller2
      └── teleportTarget

リグを移動すると、すべてが一緒に移動します。XR システムはヘッドセットのトラッキングからカメラのローカルトランスフォームを更新し、リグがワールドオフセットを提供します。

重要:ワールド空間とローカル空間の座標

cameraRig の子は、リグローカル空間の位置を受け取ります。 ワールド空間のオブジェクト(地面平面、シーンオブジェクト)に対してレイキャストする場合、リグの子に割り当てる前にヒットポイントを変換する必要があります:

// WRONG — world hit assigned as local position → offset by rig transform
teleportTarget.position.copy(worldHitPoint);

// CORRECT — convert to rig-local space first
teleportTarget.position.copy(worldHitPoint);
cameraRig.worldToLocal(teleportTarget.position);

これは以下に適用されます:

  • テレポートターゲットのリング/レティクル
  • リグの子であるあらゆるプレビューマーカーやインジケーター
  • レイキャスト結果から配置されるあらゆるもの

worldToLocal() がないと、マーカーは実際のレイ交差位置からずれて表示されます — リグが原点から遠ざかるほどずれが大きくなります。


ロコモーション

サムスティックのマッピング(Quest コントローラー)
Left controller:
  axes[2] = strafe (left/right)
  axes[3] = forward/back
  buttons[1] = grip (hold for fly mode)
  buttons[3] = thumbstick press (menu toggle)
  buttons[4] = X button

Right controller:
  axes[2] = horizontal turn (left/right)
  axes[3] = vertical fly (up/down)
  buttons[1] = grip
  buttons[3] = thumbstick press
地面固定モードとフライモード
if (leftGrip) {
  // Fly mode: full 3D movement following head direction
  // forward/right keep Y component from camera quaternion
} else {
  // Ground mode: project to XZ plane
  forward.y = 0; forward.normalize();
  right.y = 0; right.normalize();
}
右スティック:水平方向の旋回 + 垂直方向のフライ
// Horizontal: rotate the rig (yaw)
if (Math.abs(rx) > DEAD_ZONE) {
  cameraRig.rotateY(-rx * TURN_SPEED * dt);
}
// Vertical: move up/down (NOT pitch rotation — see below)
if (Math.abs(ry) > DEAD_ZONE) {
  cameraRig.position.y += -ry * MOVE_SPEED * 0.5 * dt;
}
リグにピッチ回転を使用しない理由

垂直方向の視線に cameraRig.rotateX() を使用しないでください。 XR ヘッドセットはヘッドトラッキングによってカメラの向きを制御します。X 軸でのリグの回転はヘッドセット自身のピッチと競合します — 回転は適用されますが、すぐにヘッドセットのポーズと合成され、方向感覚を失わせる、または見えない結果を生み出します。

  • ヨー(rotateY)は機能します。リグをワールド空間で全体として回転させるため、ユーザーがその場で向きを変える動作と一致します。
  • ピッチ(rotateX)は機能しません。ヘッドセットがすでに物理的な頭の動きによって垂直方向の視線を処理しているためです。
  • 代わりに垂直フライ(position.y)を使用してください — ユーザーが上下に移動し、頭を使って周囲を見回します。
テレポート
  • コントローラーから地面プレーンに対してレイキャストします
  • トリガーを離したときに、カメラリグをヒットポイントへ移動します
  • 地面レベルのテレポートでは、常に cameraRig.position.y = 0 を設定します
  • 覚えておいてください: テレポート先はリグの子です — ヒットポイントに対して worldToLocal() を使用します
移動速度

既定の 2 m/s は大きなシーンでは遅く感じられます。5 m/s が探索のための良い基準です。垂直フライは半分の速度(2.5 m/s)にすると自然に感じられます。


VR UI パネル

パターン

ワールド空間に浮かぶ、テクスチャ付きのプレーンメッシュを使用します。UI をキャンバスにレンダリングし、テクスチャとして適用します。

class VRPanel {
  mesh: THREE.Mesh;      // Plane with CanvasTexture
  canvas: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;

  // Position in front of camera
  positionInFront(camera: THREE.Camera, distance: number) {
    const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
    this.mesh.position.copy(camera.position).addScaledVector(forward, distance);
    this.mesh.lookAt(camera.position);
  }
}
インタラクション
  • コントローラー/ハンドからパネルメッシュに対してレイキャストします
  • 交差した UV をキャンバス座標に変換します
  • ボタン領域に対してヒットテストします
  • 視覚的フィードバック: 交差点のレティクルドット、レイの色の変化、ホバー状態
ヒント
  • パネルは非表示で開始し、メニューボタン(サムスティック押し込みまたは X ボタン)で切り替えます
  • 狙いやすくするために、ボタンは大きく保ちます(高さ 0.15m 以上)
  • 高コントラストの色を使用します(パネルは VR 解像度でレンダリングされます)
  • レンダー順序が重要です — パネルが常に表示されるように renderOrder を設定します

テストとデバッグ

ヘッドセットなしでは VR をテストできません

これは譲れません。デスクトップブラウザでのテストでは、以下について何もわかりません:

  • ステレオレンダリングの正確さ
  • コントローラー/ハンドのインタラクションの感触
  • VR フレームレート(72〜120 fps)でのパフォーマンス
  • AR モードでのアルファブレンディングの挙動
  • 空間オーディオとハプティクス
リモートデバッグのセットアップ
  1. Meta スマートフォンアプリで 開発者モード を有効にします(デバイス > ヘッドセット設定 > 開発者モード)
  2. Quest を USB で PC に接続し、ヘッドセット内の接続ダイアログを承認します
  3. デスクトップ Chrome で chrome://inspect/#devices を開きます
  4. Quest のブラウザタブが表示されます — inspect をクリックすると完全な DevTools が使えます
  5. ワイヤレスオプション: adb tcpip 5555 の後に adb connect <quest-ip>:5555

注記: 開発者メニューの場所は Quest のファームウェアバージョンによって変わります。次を試してください:

  • 設定 > システム > 開発者
  • 設定 > 開発者
  • 設定 > 詳細設定 > 開発者

開発者オプションが存在しない場合は、まず developer.meta.com で登録し(無料)、スマートフォンアプリ経由で有効化し、Quest を再起動する必要があります。

Quest ブラウザと PWA
  • 常に Quest ブラウザで最初にテストしてください(PWA ではなく)
  • PWA は積極的にキャッシュします — 古いビルドが混乱を招きます
  • 最新のコードを確実に使うには、シークレットタブまたはハードリフレッシュを使用します
  • PWA がインストールされている場合、まったく異なるバージョンが動作している可能性があります
イテレーションループ
  1. 変更を加えます
  2. デプロイします(またはローカルネットワーク URL を使用します)
  3. Quest ブラウザでハードリフレッシュします
  4. 特定の機能をテストします
  5. リモートデバッグでコンソールを確認します

やってはいけないこと: デバイス上でのテストなしに盲目的にイテレーションすること。盲目的な修正を 4 回繰り返すと、リモートデバッグを一度セットアップするよりもコストがかかります。


よくある落とし穴

1. immersive-vr のつもりで immersive-ar をリクエストする

上記の重要なルールを参照してください。この単一の間違いは、数十のレンダリング問題に連鎖します。

2. カメラが (0, 1.6, 0) にあるのにオブジェクトが原点にある

VR では、カメラは頭の高さにあります。原点にあるオブジェクトは足元にあります。最高の体験のために、オブジェクトを目の高さに配置してください。

3. AR では scene.background が機能しない

Three.js は AR セッションでは意図的に背景のレンダリングをスキップします。背景は自分で埋める必要があります(不透明なスカイスフィア、alpha=1 のシェーダー出力)。

4. ジオメトリ反転 + BackSide = 不可視

scale(-1,1,1) + BackSide は面カリングを二重に打ち消します。代わりに DoubleSide を使用してください。上記の「ジオメトリ反転の罠」を参照してください。

5. VR での透明マテリアル

transparent: true を持つマテリアルは、AR と VR で異なるレンダリングになります。VR では、透明性は視覚的なものにすぎません。AR では、低いアルファ = パススルーカメラになります。

6. frustumCulled = false を忘れる

大きなスカイボックススフィアは、VR カメラのフラスタムによってカリングされる可能性があります。スカイボックスメッシュでは常に frustumCulled = false を設定してください。

7. モード切り替え時にジオメトリ/マテリアルを破棄しない

ジオメトリを交換する場合(例: 大きなスフィア ↔ 小さなスフィア)、GPU メモリリークを防ぐために常に古い方を .dispose() してください。

8. デフォルトのフレームバッファ解像度が低い

getNativeFramebufferScaleFactor() を使用して XRWebGLLayer を明示的に作成しない場合、Quest はネイティブ解像度の数分の一でレンダリングします。すべてがピクセル化されて見えます。常にネイティブのスケールファクターを設定してください。

9. VR でポストプロセッシング効果が表示されない

postprocessing ライブラリは WebXR をサポートしていません。renderer.xr.isPresenting のとき、ブルーム、ディストーション、トーンマッピングはスキップされます。代わりに 3D シーンオブジェクトを使用してください。

10. テレポート/レティクルがコントローラーのレイとずれる

テレポートのターゲットまたはレティクルが cameraRig の子である場合、ワールド空間のヒットポイントを cameraRig.worldToLocal() を介してローカル空間に変換する必要があります。そうしないと、マーカーがリグの位置分だけずれます。

11. 右スティックのピッチ回転が機能しない

cameraRig.rotateX() はヘッドセットのトラッキングと競合します。代わりに垂直方向の位置移動(上下にフライ)を使用してください。リグではヨー(rotateY)のみが確実に機能します。


アーキテクチャに関する推奨事項

セッションモードはアプリレベルの決定事項

個々のシーンにセッションモードを選択させないでください。XR マネージャーがセッションを要求し、シーンは与えられたものに適応すべきです。

// Good: XR manager decides
const mode = "immersive-vr"; // App-level decision

// Bad: Scene tries to change session type
if (userWantsPassthrough) requestSession("immersive-ar"); // Can't switch mid-session
VR 用のシーンインターフェース
interface Scene {
  supportsXR?: boolean;
  init(ctx: SceneContext): Promise<void>;
  update(dt: number, elapsed: number): void;
  dispose(): void;
}

シーンは以下を行うべきです:

  • 更新ループで renderer.xr.isPresenting を検出する
  • デスクトップと VR のレンダリングモードを切り替える
  • XR マネージャーに VR パネルを登録/登録解除する
  • dispose で VR の状態をクリーンアップする
パススルーは将来の機能として

後でパススルーが必要になった場合は、次のように実装してください:

  1. 別個の「AR を開始」ボタン(「VR を開始」とは別のもの)
  2. 実機でテスト済みの完全なアルファ管理戦略を備えた immersive-ar の使用
  3. すべてのマテリアルとシェーダーが正しいアルファ値を出力する必要があります(alpha=0 → パススルー、alpha=1 → 不透明レンダリング)

immersive-vr セッションでパススルーを有効にする方法はありません。専用の実機テストなしに、動作している VR アプリにパススルーを後付けしないでください。

コメント