Three.js From Zero · Article s2-05

WebXR Basics (VR)

← threejs-from-zero S2 · Article 05 Season 2
Article S2-05 · Three.js From Zero

WebXR Basics (VR)

Your scene has physics, a character, joints that collapse — now put it in VR. The same Three.js scene renders to a headset with about five lines of extra code. The browser handles stereo rendering, head tracking, hand controllers, teleport locomotion — you just mark your scene as "also VR" and it works.

The demo is a small room you can explore. If your browser supports WebXR and you're on a device with a VR headset (Quest 3 Browser, Vision Pro Safari, PCVR with SteamVR Chrome), the Enter VR button is live. On desktop you'll see the scene in a regular window with the button disabled — that's expected.

Tested on: Meta Quest 3 Browser, Vision Pro Safari (immersive-vr mode), Chrome desktop with an Index/Vive/Quest-Link PC VR rig. Supported mode: immersive-vr.
initializing…

What WebXR actually is

WebXR is a browser API that represents a VR or AR session. The browser takes over the rendering loop, asks your app for frames, and presents them to whatever hardware is attached. Your job: describe a scene, hand the renderer a VR-capable loop, respond to controller input. The browser handles:

  • Stereo rendering (two views per frame, one per eye)
  • Head tracking (pose every frame)
  • Controller / hand input (poses, buttons, axes)
  • Foveation and lens distortion (Quest, Vision Pro)
  • Frame pacing (72/90/120Hz depending on device)

Three.js wraps this with renderer.xr.*. You enable it, you provide a button that requests the session, and you swap renderer.setAnimationLoop to let Three.js drive the XR render loop for you.

The 5-line enable

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.xr.enabled = true;                        // enable the XR render path
renderer.xr.setReferenceSpaceType('local-floor');    // where (0,0,0) lives — see below
document.body.appendChild(renderer.domElement);

renderer.setAnimationLoop((t, frame) => {
  // your scene update here — runs for both VR and non-VR
  renderer.render(scene, camera);
});

That's it. If no VR session is active, setAnimationLoop behaves exactly like requestAnimationFrame. If a session starts, Three.js takes over — your callback runs twice per frame (once per eye) internally without you having to care.

The Enter-VR button

You don't request a session from code out of nowhere — browsers require a user gesture (button click). Three.js ships VRButton as a one-liner, but I recommend writing your own: you get custom styling and exact control over device capability checks.

async function setupXRButton() {
  if (!navigator.xr) {
    btn.textContent = 'WebXR not supported in this browser';
    return;
  }
  const supported = await navigator.xr.isSessionSupported('immersive-vr');
  if (!supported) {
    btn.textContent = 'No VR device detected';
    return;
  }

  btn.disabled = false;
  btn.textContent = 'Enter VR';
  btn.addEventListener('click', async () => {
    const session = await navigator.xr.requestSession('immersive-vr', {
      optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking', 'layers'],
    });
    await renderer.xr.setSession(session);
  });
}

Three things:

  • isSessionSupported is an async capability check — no device, no button.
  • optionalFeatures — these don't block the session if missing. Required features (via requiredFeatures) will reject the session on devices that don't have them.
  • renderer.xr.setSession wires the session to Three.js and starts the XR render loop.

Reference spaces — where is (0, 0, 0)?

This is the WebXR concept that bites everyone the first time. A "reference space" defines the origin and up direction of your XR scene. Three common modes:

TypeOriginUse for
'local'Wherever the user's head is at session startSeated experiences. Camera starts at origin.
'local-floor'Directly under the head, on the detected floorMost VR. Scene origin is the floor between the user's feet.
'bounded-floor'Like local-floor + a polygon bounding the play areaRoom-scale apps that need to draw the guardian / chaperone.
'unbounded'Real-world position (AR)Large-scale AR. Rarely used in VR.
'viewer'Always centered on the user's headHUDs that stay attached to the headset.

Use local-floor for most VR. Your scene's Y=0 is the floor. The user walks around at Y≈1.6 or so automatically. Build your world on that plane.

renderer.xr.setReferenceSpaceType('local-floor');

If local-floor isn't available (the device doesn't know where its floor is), the browser falls back to local — your origin might be at eye height. Write your scene to be forgiving of both cases.

The XR render loop

In regular rendering, your animation callback runs once per frame. In XR, Three.js internally renders the scene twice per frame (once per eye) on your behalf, but the callback still runs once. The magic is in the camera — Three.js swaps the regular PerspectiveCamera for an ArrayCamera with two sub-cameras when a session is active:

renderer.setAnimationLoop((t, frame) => {
  // t is the regular timestamp
  // frame is an XRFrame — only non-null inside a session

  // Update your scene normally — positions, animations, etc.
  mixer.update(dt);
  physics.step();

  // Render — Three handles the two-eye render internally when in VR
  renderer.render(scene, camera);
});

So your update logic doesn't change. Your render call doesn't change. The only difference is camera is now a parent of two sub-cameras, and camera.position reflects the user's head pose each frame.

Reading the head pose

Inside the loop, camera.position and camera.quaternion are updated from the headset's pose. Read them for things like audio listener position, visibility checks, or aim-based targeting:

renderer.setAnimationLoop(() => {
  const headPos = camera.getWorldPosition(new THREE.Vector3());
  const headDir = camera.getWorldDirection(new THREE.Vector3());
  // ...use them
});

You can also dig into the raw XRFrame for extra data (like all XRViews if you want per-eye control), but 95% of the time the camera is all you need.

DPR and pixel budget

VR demands high resolution — stereo means 2× pixels, plus headsets typically render at 1.2–1.7× supersample to combat pixel artifacts through lenses. A Quest 3 renders at ~2064×2208 per eye by default.

The browser chooses the framebuffer resolution, but you can set a scale:

renderer.xr.setFramebufferScaleFactor(0.9);  // 0.9× default — faster, slight softness

Use 1.0 for crisp (default). Use 0.75–0.9 if your scene is complex and dropping frames. Use 1.2+ if your scene is simple and you want extra-crisp text / UI.

Frame rate targets

  • Quest 3 — 72Hz default, 90/120Hz opt-in via session.updateTargetFrameRate(90)
  • Vision Pro — 90Hz
  • Index / Vive Pro 2 — 90/120/144Hz depending on PC
  • Quest 2 — 72Hz default, 90Hz opt-in, 120Hz game-only

Missing a frame in VR is much more noticeable than on a monitor — it's a visual stutter in your peripheral vision which the brain reads as physical motion. Budget aggressively. 60fps is not acceptable in VR.

Controllers (brief — S2-06 goes deep)

const controller0 = renderer.xr.getController(0);
const controller1 = renderer.xr.getController(1);

controller0.addEventListener('selectstart', () => console.log('trigger down'));
controller0.addEventListener('selectend', () => console.log('trigger up'));

scene.add(controller0);  // its position/rotation updates each frame

Indices 0 and 1 correspond to left and right hand (not guaranteed which is which until you check inputSource.handedness). Children of the controller inherit its transform — parent a mesh to draw a "laser pointer" or hold a virtual object.

For the full input story — controller models, hand tracking, haptics — see S2-06.

AR mode (immersive-ar)

One character different. Request 'immersive-ar' instead of 'immersive-vr'. Set scene.background = null so the passthrough camera shows through. Add 'local-floor' or 'hit-test' to optional features depending on what you need.

const session = await navigator.xr.requestSession('immersive-ar', {
  requiredFeatures: ['local-floor'],
  optionalFeatures: ['hit-test', 'dom-overlay', 'hand-tracking'],
});

Vision Pro Safari currently only ships immersive-vr (passthrough is the default background, making it "AR-ish" despite the mode name). Quest 3 has true AR. Android Chrome has AR on phones.

Graceful fallback on desktop

Your site should always render something on desktop, not just a broken page. The demo does this:

renderer.setAnimationLoop((t) => {
  if (!renderer.xr.isPresenting) {
    // Desktop mode — orbit camera, user previews the scene
    controls.update();
  }
  // In VR, renderer.xr manages the camera directly
  renderer.render(scene, camera);
});

Wire OrbitControls when not presenting. When a session starts, the XR render path takes over and OrbitControls quietly stops mattering.

Common first-time pitfalls

  • Enter-VR button does nothing. Your site isn't HTTPS. WebXR requires secure context. Also true for localhost (exempt).
  • Scene is floating in space in VR. You used 'local' reference space, which puts origin at head height. Switch to 'local-floor'.
  • Super low frame rate in VR. setPixelRatio is adding to an already-high native res. Drop pixel ratio to 1 for VR — the headset supersamples anyway.
  • Controller appears at origin, not in hand. You didn't scene.add(controller). The controller's transform is only updated if it's in the scene graph.
  • Vision Pro shows a black screen. Check scene.background — Vision Pro Safari doesn't show passthrough if your background is opaque. Set to null for AR-like passthrough feel.
  • "XR session end" with no explanation. User pulled the headset off, or the system triggered a safety stop. Listen for session.addEventListener('end', ...) to clean up.

Exercises

  1. Teleport locomotion: on trigger, raycast from the controller to the floor, show a ring, on trigger-release teleport the camera-rig group to the ring position.
  2. Frame rate upgrade: on session start, call session.updateTargetFrameRate(90) if available. Log before/after.
  3. AR passthrough: change the demo's session request to immersive-ar. Observe the scene overlaying your real room.

What's next

Article S2-06 — WebXR Interaction. Controllers, 25-joint hand tracking, pinch / grab / poke gestures, haptic pulses, and world-space UI. Plus the bonus that ties the season together: physics grab-and-throw in VR (Rapier + XR).