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,
requestAnimationFramecallbacks 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 —
setAnimationLoopties 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-floormeans 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— choosinglocal-floorvslocaland 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.