WebXR session setup

Set up immersive sessions and the XR render loop, with recovery and a useful desktop fallback.

By pavlin-dev

Use this skill

Three.js and TypeScript; headset support varies by WebXR browser.

Add the skill folder in Settings. The in-app library is coming in the next desktop update.

The skill

The source instructions, with their original wording. Read as Markdown.

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.

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:

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.

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 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.