VR / AR (WebXR)
WebXR brings VR and AR to the browser. Three.js handles the scene; the WebXR Device API provides head, hand, and controller poses; VRButton and ARButton from drei or three/examples bootstrap session UI. Same render loop, dual cameras, immersive.
WebXR, VR session, AR, hit testing
EXAMPLE
import * as THREE from 'three';
import { VRButton } from 'three/examples/jsm/webxr/VRButton.js';
import { ARButton } from 'three/examples/jsm/webxr/ARButton.js';
import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js';
// 1) Enable XR on the renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
renderer.xr.enabled = true; // turn on WebXR
document.body.appendChild(renderer.domElement);
// 2) Add the VR enter button
document.body.appendChild(VRButton.createButton(renderer));
// 3) Scene + camera + animation
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 100);
camera.position.set(0, 1.6, 3); // eye height ~1.6m
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(2, 5, 3);
scene.add(sun);
// Add a floor for VR
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.8 }),
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(0.5, 0.5, 0.5),
new THREE.MeshStandardMaterial({ color: 0x4f46e5 }),
);
cube.position.set(0, 1, -2);
scene.add(cube);
// 4) Animation loop — use setAnimationLoop, not requestAnimationFrame
renderer.setAnimationLoop((time, frame) => {
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
});
// setAnimationLoop works for both regular display and XR sessions (one render per eye).
// 5) Controllers + hand input
const controller1 = renderer.xr.getController(0);
const controller2 = renderer.xr.getController(1);
scene.add(controller1, controller2);
controller1.addEventListener('selectstart', () => {
cube.material.color.set(0xff0000);
});
controller1.addEventListener('selectend', () => {
cube.material.color.set(0x4f46e5);
});
const factory = new XRControllerModelFactory();
const grip1 = renderer.xr.getControllerGrip(0);
grip1.add(factory.createControllerModel(grip1));
scene.add(grip1);
// 6) Ray casting from controller — pointer + select
const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();
function handleSelect(controller) {
tempMatrix.identity().extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
const intersects = raycaster.intersectObjects([cube]);
if (intersects.length > 0) {
intersects[0].object.material.color.setHex(Math.random() * 0xffffff);
}
}
controller1.addEventListener('select', () => handleSelect(controller1));
controller2.addEventListener('select', () => handleSelect(controller2));
// 7) AR — switch button
// Replace VRButton with ARButton
document.body.appendChild(
ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
optionalFeatures: ['dom-overlay', 'light-estimation'],
domOverlay: { root: document.body },
})
);
// 8) AR hit testing — place objects on real surfaces
let hitTestSource = null;
let hitTestSourceRequested = false;
const reticle = new THREE.Mesh(
new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial(),
);
reticle.matrixAutoUpdate = false;
reticle.visible = false;
scene.add(reticle);
renderer.setAnimationLoop((time, frame) => {
if (frame) {
const referenceSpace = renderer.xr.getReferenceSpace();
const session = renderer.xr.getSession();
if (!hitTestSourceRequested) {
session.requestReferenceSpace('viewer').then((refSpace) => {
session.requestHitTestSource({ space: refSpace }).then((source) => {
hitTestSource = source;
});
});
session.addEventListener('end', () => {
hitTestSourceRequested = false;
hitTestSource = null;
});
hitTestSourceRequested = true;
}
if (hitTestSource) {
const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length) {
const hit = hitTestResults[0];
reticle.visible = true;
reticle.matrix.fromArray(hit.getPose(referenceSpace).transform.matrix);
} else {
reticle.visible = false;
}
}
}
renderer.render(scene, camera);
});
// Tap to place cube at reticle
controller1.addEventListener('select', () => {
if (reticle.visible) {
const placed = cube.clone();
placed.position.setFromMatrixPosition(reticle.matrix);
scene.add(placed);
}
});
// 9) WebXR session events
renderer.xr.addEventListener('sessionstart', () => console.log('XR started'));
renderer.xr.addEventListener('sessionend', () => console.log('XR ended'));
// 10) Performance — XR demands
// Target 90 fps per eye (180 fps total) on standalone headsets
// Cut polygon count, use baked lighting, atlas textures
// Avoid heavy post-processing (bloom doubles cost)
// Cap draw calls; instanced rendering for repeated objects
// 11) Browser + device support
// • Quest browser, Quest Pro, Vision Pro — full WebXR
// • Chrome Android — WebXR for AR (Scene Viewer too)
// • iOS Safari — WebXR coming; AR.js / WebXR polyfill alternative
// • Desktop Chrome/Edge — VR with Steam VR / Oculus Link
// 12) Hand tracking + advanced inputs
// • renderer.xr.getHand(0) → returns Object3D with joints
// • Each finger joint has pose; build pinch detection
// • Eye tracking on Vision Pro
// 13) Frameworks built on top
// • @react-three/xr — React-native WebXR with hooks (useXR, useController)
// • A-Frame — declarative VR HTML
// • PlayCanvas — visual editor; cloud-hosted
// 14) Common bugs
// • renderer.xr.enabled = true forgotten → button does nothing
// • Using requestAnimationFrame instead of setAnimationLoop → no per-eye render
// • Scene units off → 1 unit must = 1 metre; otherwise scale wrong
// • Camera position pre-XR — XR overrides it; offset using a parent group instead
// • requiredFeatures missing — session refused with feature error
// • HTTPS required — WebXR refuses HTTP; use HTTPS or localhost
// • Heavy post-processing — half framerate; users get sick
// • Forgetting hand/controller cleanup on session end
// • Spawning objects too close to camera → eye strain; minimum 0.5m
// • Not handling 6DoF vs 3DoF differences — fallback to seated experience
// • Buffer / texture leaks across sessions → restart leaks; dispose properly
Why it matters
WebXR through Three.js: turn on renderer.xr, add VRButton or ARButton, switch to renderer.setAnimationLoop, and grab controllers via getController. For AR, request hit-test feature and place objects on detected surfaces. Target 90fps per eye, keep scenes simple, use HTTPS, and disable heavy post-processing inside XR sessions.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import { VRButton } from 'three/addons/webxr/VRButton.js';
renderer.xr.enabled = true;
document.body.appendChild(VRButton.createButton(renderer));
Try it Yourself »
Discussion
Loading…