threejs-impl-xr
threejs-impl-xr
Quick Reference
WebXRManager Properties
| Property | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
Enable XR rendering |
isPresenting |
boolean |
read-only | Whether an XR session is active |
cameraAutoUpdate |
boolean |
true |
Auto-update camera from XR device pose |
WebXRManager Methods
| Method | Signature | Description |
|---|---|---|
getSession() |
() => XRSession | null |
Current XR session |
setSessionInit(options) |
(XRSessionInit) => void |
Configure session features before entry |
setReferenceSpaceType(type) |
(string) => void |
Set reference space type |
getController(index) |
(number) => Group |
Get controller target ray space |
getControllerGrip(index) |
(number) => Group |
Get controller grip space |
getHand(index) |
(number) => Group |
Get hand tracking group |
setFoveation(level) |
(number) => void |
Set foveated rendering (0.0–1.0) |
getFoveation() |
() => number |
Get current foveation level |
getEnvironmentBlendMode() |
() => string |
Get blend mode (opaque, additive, alpha-blend) |
setFramebufferScaleFactor(scale) |
(number) => void |
Adjust XR render resolution |
Session Types
| Type | Use Case |
|---|---|
'immersive-vr' |
Full VR headset experience |
'immersive-ar' |
AR passthrough on headset or phone |
'inline' |
Non-immersive XR in a browser window |
Reference Space Types
| Type | Origin | Use Case |
|---|---|---|
'viewer' |
Head position | HUD elements, gaze-locked UI |
'local' |
Initial head position | Seated experiences |
'local-floor' |
Floor level at start | Standing VR, ALWAYS preferred for room-scale |
'bounded-floor' |
Floor with boundary | Room-scale with guardian |
'unbounded' |
World origin | Large-scale AR experiences |
XR Addon Classes
| Class | Import Path | Purpose |
|---|---|---|
VRButton |
three/addons/webxr/VRButton.js |
Creates "Enter VR" button with feature detection |
ARButton |
three/addons/webxr/ARButton.js |
Creates "Enter AR" button with feature detection |
XRControllerModelFactory |
three/addons/webxr/XRControllerModelFactory.js |
Loads appropriate controller 3D model |
XRHandModelFactory |
three/addons/webxr/XRHandModelFactory.js |
Creates hand tracking visualization |
XRHandPrimitiveModel |
three/addons/webxr/XRHandPrimitiveModel.js |
Simple geometric hand representation |
XREstimatedLight |
three/addons/webxr/XREstimatedLight.js |
AR environment lighting estimation |
XRPlanes |
three/addons/webxr/XRPlanes.js |
AR plane detection visualization |
Critical Warnings
NEVER use requestAnimationFrame() for XR rendering — ALWAYS use renderer.setAnimationLoop(). The WebXR API requires its own frame timing; requestAnimationFrame stops firing when an XR session is active.
NEVER apply heavy post-processing in VR — each effect runs TWICE (once per eye), doubling GPU cost. Dropped frames cause motion sickness.
ALWAYS target 72fps (Quest) or 90fps (PC VR) — dropped frames cause nausea and discomfort. There is NO acceptable lower target.
ALWAYS set renderer.xr.enabled = true BEFORE creating VRButton/ARButton — the button checks this property for feature detection.
NEVER forget to add controllers to the scene — getController() returns a Group that MUST be added via scene.add() or it will not render or fire events.
VR Setup
Minimal VR Scene
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// Enable XR BEFORE creating VRButton
renderer.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
document.body.appendChild(VRButton.createButton(renderer));
// MUST use setAnimationLoop — NEVER requestAnimationFrame
renderer.setAnimationLoop((time, frame) => {
renderer.render(scene, camera);
});
AR Setup
import * as THREE from 'three';
import { ARButton } from 'three/addons/webxr/ARButton.js';
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.xr.enabled = true;
// Configure AR features BEFORE creating ARButton
renderer.xr.setSessionInit({
requiredFeatures: ['hit-test'],
optionalFeatures: ['dom-overlay'],
domOverlay: { root: document.getElementById('overlay') }
});
document.body.appendChild(ARButton.createButton(renderer, {
requiredFeatures: ['hit-test']
}));
renderer.setAnimationLoop((time, frame) => {
renderer.render(scene, camera);
});
Controllers
Controller Spaces
Three.js exposes THREE distinct spaces per physical controller:
| Method | Space | Use Case |
|---|---|---|
getController(index) |
Target ray | Pointing direction, laser pointer |
getControllerGrip(index) |
Grip | Where the hand holds the controller |
getHand(index) |
Hand | Full hand tracking skeleton |
ALWAYS add ALL spaces you use to the scene. Each returns a THREE.Group.
Controller Events
| Event | Trigger |
|---|---|
selectstart |
Primary trigger pressed |
selectend |
Primary trigger released |
select |
Primary trigger press-and-release |
squeezestart |
Grip button pressed |
squeezeend |
Grip button released |
squeeze |
Grip button press-and-release |
connected |
Controller detected (event.data = XRInputSource) |
disconnected |
Controller lost |
Controller Models
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
const factory = new XRControllerModelFactory();
// Target ray space — for laser pointer / interaction ray
const controller0 = renderer.xr.getController(0);
controller0.addEventListener('selectstart', onSelectStart);
controller0.addEventListener('selectend', onSelectEnd);
scene.add(controller0);
// Grip space — for rendering the controller model
const grip0 = renderer.xr.getControllerGrip(0);
grip0.add(factory.createControllerModel(grip0));
scene.add(grip0);
Laser Pointer Visual
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -5)
]);
const material = new THREE.LineBasicMaterial({ color: 0xffffff });
const line = new THREE.Line(geometry, material);
controller0.add(line);
Hand Tracking
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js';
// Request hand-tracking feature
renderer.xr.setSessionInit({
optionalFeatures: ['hand-tracking']
});
const handFactory = new XRHandModelFactory();
const hand0 = renderer.xr.getHand(0);
hand0.add(handFactory.createHandModel(hand0, 'mesh'));
scene.add(hand0);
const hand1 = renderer.xr.getHand(1);
hand1.add(handFactory.createHandModel(hand1, 'mesh'));
scene.add(hand1);
Hand model profiles: 'mesh' (realistic), 'spheres' (joint spheres), 'boxes' (joint boxes).
Hand tracking events fire on the hand group:
connected— hand detecteddisconnected— hand lostpinchstart/pinchend— thumb-index pinch gesture
AR Hit Testing
Hit testing places virtual objects on real-world 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 session = renderer.xr.getSession();
const referenceSpace = renderer.xr.getReferenceSpace();
if (!hitTestSourceRequested) {
session.requestReferenceSpace('viewer').then((viewerSpace) => {
session.requestHitTestSource({ space: viewerSpace }).then((source) => {
hitTestSource = source;
});
});
hitTestSourceRequested = true;
}
if (hitTestSource) {
const results = frame.getHitTestResults(hitTestSource);
if (results.length > 0) {
const pose = results[0].getPose(referenceSpace);
reticle.visible = true;
reticle.matrix.fromArray(pose.transform.matrix);
} else {
reticle.visible = false;
}
}
}
renderer.render(scene, camera);
});
Teleportation Pattern
const tempMatrix = new THREE.Matrix4();
const raycaster = new THREE.Raycaster();
const marker = new THREE.Mesh(
new THREE.CircleGeometry(0.25, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial({ color: 0x00ff00 })
);
scene.add(marker);
const controller = renderer.xr.getController(0);
controller.addEventListener('selectend', () => {
tempMatrix.identity().extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
const intersects = raycaster.intersectObject(floor);
if (intersects.length > 0) {
const point = intersects[0].point;
// Move the XR camera rig, NOT the camera directly
cameraRig.position.set(point.x, 0, point.z);
}
});
scene.add(controller);
ALWAYS move a camera rig group (containing the camera), NEVER the camera directly — the WebXR API controls camera position relative to its parent.
VR Performance
Target Frame Rates
| Platform | Target FPS | Notes |
|---|---|---|
| Meta Quest 2/3 | 72–120 fps | 72 default, 90/120 optional |
| PC VR (SteamVR) | 90 fps | Standard target |
| PSVR2 | 90–120 fps | Platform-dependent |
Optimization Techniques
- Foveated rendering —
renderer.xr.setFoveation(1.0)for maximum performance. Range 0.0 (none) to 1.0 (maximum). - Framebuffer scale —
renderer.xr.setFramebufferScaleFactor(0.75)to reduce resolution when GPU-bound. - Minimize draw calls — Use
THREE.InstancedMeshfor repeated objects. Target < 100 draw calls. - Avoid post-processing — Each effect renders TWICE in stereo. Remove bloom, SSAO, and anti-aliasing passes when possible.
- Use baked lighting — Real-time shadows are expensive at 2x. Pre-bake where possible.
- LOD (Level of Detail) — Use
THREE.LODto reduce polygon count for distant objects. - Texture compression — Use KTX2/Basis textures to reduce GPU memory.
Camera Rig Pattern
ALWAYS use a camera rig group for VR locomotion:
const cameraRig = new THREE.Group();
cameraRig.add(camera);
scene.add(cameraRig);
// Move the rig, not the camera
cameraRig.position.set(0, 0, 5);
The WebXR API sets camera position/rotation each frame relative to its parent. Moving the camera directly is overwritten immediately.
XR Estimated Light (AR)
import { XREstimatedLight } from 'three/addons/webxr/XREstimatedLight.js';
const xrLight = new XREstimatedLight(renderer);
xrLight.addEventListener('estimationstart', () => {
scene.add(xrLight);
scene.environment = xrLight.environment;
// Remove default lights
});
xrLight.addEventListener('estimationend', () => {
scene.remove(xrLight);
scene.environment = null;
// Restore default lights
});
Reference Links
- references/methods.md — Complete API signatures for WebXRManager and XR addon classes
- references/examples.md — Working code examples for VR, AR, controllers, and hand tracking
- references/anti-patterns.md — What NOT to do in WebXR development