---
name: xr-session-setup
description: Use when starting a WebXR VR app in three.js or when "Enter VR" does nothing, the canvas stays 2D, the scene is monoscopic in the headset, or the render loop won't run in immersive mode — sets up the immersive-vr session, renderer, XR render loop, and a desktop fallback.
---

# XR Session Setup

## Overview

A WebXR app is an ordinary three.js app with three differences that, if missed, mean it silently never enters VR: the renderer must be XR-enabled, the frame loop must be driven by `renderer.setAnimationLoop` (not `requestAnimationFrame`), and the user must start the session from a real user gesture (a button click).

**Core principle:** in XR, the headset's compositor owns the frame clock. Hand it the loop via `setAnimationLoop` or you get no stereo frames.

## When to use

- Bootstrapping any WebXR scene — this is the foundation every other `webxr/` skill builds on.
- Symptoms: "Enter VR" button missing or inert, scene renders flat/monoscopic in-headset, animation freezes after entering VR, `requestAnimationFrame` callbacks stop firing in immersive mode.

**When NOT to use:** for *where* the player stands and how tall they are (floor vs. seated, 1:1 scale), use `xr-reference-space`. For reading controller buttons/axes, use `xr-controller-input`.

## Quick reference

| Need | API |
| ---- | --- |
| Enable XR on renderer | `renderer.xr.enabled = true` |
| Drive frames in XR | `renderer.setAnimationLoop(fn)` |
| Standard "Enter VR" button | `VRButton.createButton(renderer)` from `three/examples/jsm/webxr/VRButton.js` |
| Check support (for fallback) | `await navigator.xr?.isSessionSupported('immersive-vr')` |
| Floor-relative space | `renderer.xr.setReferenceSpaceType('local-floor')` → see `xr-reference-space` |
| Session lifecycle | `renderer.xr.addEventListener('sessionstart' \| 'sessionend', fn)` |
| Per-eye camera | use `renderer.xr.getCamera()` in XR; never read the base camera's matrix yourself |

## Implementation

Minimal, complete bootstrap. The render loop is the same function whether or not a session is active — `setAnimationLoop` works on desktop too, so there is no second code path to maintain.

```ts
import * as THREE from 'three';
import { VRButton } from 'three/examples/jsm/webxr/VRButton.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio); // resolution scaling is a perf lever — see vr-performance-budget
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;

// 1. Make the renderer XR-aware. Without this, the session attaches but no stereo frames are produced.
renderer.xr.enabled = true;
// 'local-floor' puts y=0 at the physical floor (room-scale). See xr-reference-space for the trade-offs.
renderer.xr.setReferenceSpaceType('local-floor');

document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
// The base camera is used as-is on desktop; in XR, three.js drives a stereo rig from it.
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.6, 3); // ~standing eye height as a desktop default; XR overrides this from the headset

// 2. Add the "Enter VR" button. It handles the user-gesture requestSession() for you and
//    hides itself when WebXR is unavailable.
document.body.appendChild(VRButton.createButton(renderer));

// 3. Drive the loop through the renderer. In XR this is called at the headset's refresh rate
//    (72/90/120 Hz); on desktop it falls back to rAF. NEVER use requestAnimationFrame in an XR app.
renderer.setAnimationLoop((time, frame) => {
  // `frame` is an XRFrame only while a session is active — use it for input/hit-test, null-check it.
  update(time, frame);
  renderer.render(scene, camera);
});

window.addEventListener('resize', () => {
  // Resizing only matters for the desktop mirror; the headset owns its own framebuffer size.
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

function update(_time: number, _frame: XRFrame | null) {
  // game logic per frame
}
```

### Graceful degradation

`VRButton` already disables itself when WebXR is missing, but make the *scene* usable without a headset so the page isn't dead on desktop:

```ts
async function vrSupported(): Promise<boolean> {
  return (await navigator.xr?.isSessionSupported('immersive-vr')) ?? false;
}

if (!(await vrSupported())) {
  // Desktop fallback: add OrbitControls (or your own) so the scene is still inspectable.
  const { OrbitControls } = await import('three/examples/jsm/controls/OrbitControls.js');
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.target.set(0, 1.6, 0);
  controls.update();
}
```

### React to entering / leaving VR

Use lifecycle events to pause menus, reset poses, or swap controls — don't poll.

```ts
renderer.xr.addEventListener('sessionstart', () => {
  // e.g. hide the 2D DOM menu, enable controllers
});
renderer.xr.addEventListener('sessionend', () => {
  // e.g. restore desktop controls, show the 2D menu
});
```

## Domain principles honored here

- **Performance is comfort** — `setAnimationLoop` ties rendering to the compositor's clock; dropped frames cause nausea. Pixel ratio and resolution are your first perf levers (`vr-performance-budget`).
- **Room-scale aware** — defaulting to `local-floor` means the world sits on the physical floor instead of assuming a fixed eye height.
- **Degrade gracefully** — feature-detect and provide a desktop path; never ship a page that only works in a headset.

## Common mistakes

| Mistake | Symptom | Fix |
| ------- | ------- | --- |
| `requestAnimationFrame` for the loop | Freezes on entering VR; no stereo frames | Use `renderer.setAnimationLoop` |
| Forgot `renderer.xr.enabled = true` | Session starts but view stays 2D/mono | Set it before creating the session |
| `requestSession` on page load | Browser rejects — needs a user gesture | Start from a click (`VRButton` does this) |
| Reading `camera.matrixWorld` for head pose in XR | Wrong pose; ignores the stereo rig | Use `renderer.xr.getCamera()` |
| `setSize` to fix in-headset resolution | No effect in VR | Use pixel ratio / `xr.setFramebufferScaleFactor` — see `vr-performance-budget` |
| Far plane huge / near plane tiny | Z-fighting, judder | Keep a sane near/far (e.g. 0.1 / 100) |

## Verify

- **In headset:** open the page on-device (or via a tethered/remote session), click **Enter VR**, confirm stereo rendering and that animation runs smoothly at the device refresh rate.
- **Without headset:** use the [Immersive Web Emulator](https://github.com/meta-quest/immersive-web-emulator) browser extension to fake a device and confirm the button activates and the loop runs.
- **Desktop fallback:** with no XR device, confirm the scene still renders and OrbitControls work.

## References

- `xr-reference-space` — choosing `local-floor` vs `local` and getting 1:1 scale right.
- `xr-controller-input` — reading controllers once the session is live.
- `vr-performance-budget` — framebuffer scale, pixel ratio, and keeping frames VR-safe.
