XR / Spatial Computing

WebXR Tutorial: Getting Started With Three.js and No Game Engine

Three lines turn a Three.js scene into a VR scene. No Unity, no build pipeline, no app store — just a URL a headset can open.

The default route into XR is Unity, and for games it’s the right answer. For an installation, a gallery piece or a festival commission, it means a build pipeline, a platform store, a device someone has to sideload, and a project that stops working when the engine’s licensing or API changes.

WebXR is the other route. Your piece is a URL. The visitor opens it in the headset’s browser and they’re in it.

What you need

  • Three.js (any recent version)
  • HTTPS — WebXR requires a secure context. localhost works for development; anything else needs a certificate.
  • A headset with a WebXR browser — Quest’s browser, Vision Pro’s Safari, Android XR, Wolvic. Or the WebXR emulator browser extension for developing without hardware.

The minimal VR scene

Take any Three.js scene and add three things:

import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.xr.enabled = true;                    // 1. enable XR
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer));  // 2. the button

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100);

const cube = new THREE.Mesh(
  new THREE.BoxGeometry(0.3, 0.3, 0.3),
  new THREE.MeshStandardMaterial({ color: 0x44aa88 })
);
cube.position.set(0, 1.5, -1);
scene.add(cube);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 3));

// 3. setAnimationLoop, NOT requestAnimationFrame
renderer.setAnimationLoop(() => {
  cube.rotation.y += 0.01;
  renderer.render(scene, camera);
});

The third point is the one that trips everyone up. requestAnimationFrame will not work in an XR session — the headset drives its own frame timing, and you must hand your loop to renderer.setAnimationLoop() so the XR system can call it. If your scene renders fine on desktop and freezes the moment you enter VR, this is why.

Also note the units and the origin. WebXR is metres, and by default the origin is the floor with the user standing at 0,0,0. A cube at y = 1.5, z = -1 is at roughly eye height, one metre away. Scenes authored in arbitrary units will be either microscopic or enormous.

Controllers and hands

import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js';

const controller = renderer.xr.getController(0);
controller.addEventListener('selectstart', () => { /* trigger pressed */ });
controller.addEventListener('selectend',   () => { /* released */ });
scene.add(controller);

// controller model
const grip = renderer.xr.getControllerGrip(0);
grip.add(new XRControllerModelFactory().createControllerModel(grip));
scene.add(grip);

// hand tracking — same slot, different accessor
const hand = renderer.xr.getHand(0);
hand.add(new XRHandModelFactory().createHandModel(hand, 'mesh'));
scene.add(hand);

select fires for both a controller trigger and a hand pinch, which is why designing against select rather than against a specific button gets you both input methods free.

AR in the browser

Swap VRButton for ARButton and request the features you need:

import { ARButton } from 'three/addons/webxr/ARButton.js';

document.body.appendChild(
  ARButton.createButton(renderer, { requiredFeatures: ['hit-test'] })
);

Hit-testing is what lets you place content on real surfaces — the session returns the pose where a ray from the device meets detected geometry. That plus local-floor reference space is most of what a “place this object in the room” AR piece needs.

Why this is the right choice for exhibition work

No installation. A QR code on the wall, and the visitor is in the piece. For a gallery this removes the single largest point of friction.

No store review, no sideloading. You can change the work at 9am on opening day.

It outlives engines. WebXR is a W3C specification implemented by browsers. A piece that runs on the open web has a much better chance of still running in five years than one built against a specific engine version and store SDK.

It runs on everything with a browser. Quest, Vision Pro, Android XR — plus a graceful fallback to mouse-and-keyboard on a desktop, which matters for documentation and for anyone who can’t wear a headset.

The honest trade-offs

  • Performance. You’re in a browser, on a mobile GPU. Budget accordingly and profile on the target device, never on desktop.
  • No asset pipeline. Unity gives you import, compression, LODs and baking. On the web you do that yourself — glTF with Draco or Meshopt compression, and your own discipline.
  • Feature lag. Passthrough, depth, mesh detection and anchors arrive in browsers later than in native SDKs, and support varies. Check navigator.xr.isSessionSupported() and feature-detect rather than assuming.
  • Audio needs a gesture. Browsers block autoplay; your audio context must start from a user interaction.