| name | threejs-impl-xr |
| description | Use when building VR or AR experiences with Three.js using WebXR. Prevents the common mistake of using requestAnimationFrame instead of setAnimationLoop, not handling controller events, or wrong reference space. Covers WebXRManager, VRButton, ARButton, controllers, hand tracking, hit testing, teleportation. Keywords: VR, AR, XR, WebXR, VRButton, ARButton, immersive, controller, hand tracking, hit test, teleportation, headset, virtual reality, augmented reality, VR app, AR experience.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires Three.js r160+. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
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);
renderer.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
document.body.(.(renderer));
renderer.( {
renderer.(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;
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();
const controller0 = renderer.xr.getController(0);
controller0.addEventListener('selectstart', onSelectStart);
controller0.addEventListener('selectend', onSelectEnd);
scene.add(controller0);
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';
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 detected
disconnected — hand lost
pinchstart / 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;
}
(hitTestSource) {
results = frame.(hitTestSource);
(results. > ) {
pose = results[].(referenceSpace);
reticle. = ;
reticle..(pose..);
} {
reticle. = ;
}
}
}
renderer.(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);
(intersects. > ) {
point = intersects[].;
cameraRig..(point., , point.);
}
});
scene.(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.InstancedMesh for 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.LOD to 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);
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;
});
xrLight.addEventListener('estimationend', () => {
scene.remove(xrLight);
scene.environment = null;
});
Reference Links
Official Sources