
Threejs Impl Xr
- 19 installs
- 11 repo stars
- Updated July 8, 2026
- openaec-foundation/three.js-claude-skill-package
Helps with ai & agent building tasks.
About
threejs-impl-xr is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- threejs-impl-xr
- AI & Agent Building
- AI-coding skill
Threejs Impl Xr by the numbers
- 19 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #10,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/three.js-claude-skill-package --skill threejs-impl-xrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 11 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/three.js-claude-skill-package ↗ |
What it does
Helps with ai & agent building tasks.
Files
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` |
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
1. Foveated rendering — renderer.xr.setFoveation(1.0) for maximum performance. Range 0.0 (none) to 1.0 (maximum). 2. Framebuffer scale — renderer.xr.setFramebufferScaleFactor(0.75) to reduce resolution when GPU-bound. 3. Minimize draw calls — Use THREE.InstancedMesh for repeated objects. Target < 100 draw calls. 4. Avoid post-processing — Each effect renders TWICE in stereo. Remove bloom, SSAO, and anti-aliasing passes when possible. 5. Use baked lighting — Real-time shadows are expensive at 2x. Pre-bake where possible. 6. LOD (Level of Detail) — Use THREE.LOD to reduce polygon count for distant objects. 7. 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
Official Sources
- https://threejs.org/docs/#api/en/renderers/webxr/WebXRManager
- https://threejs.org/docs/#manual/en/introduction/How-to-create-VR-content
- https://immersiveweb.dev/
- https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API
threejs-impl-xr — Anti-Patterns
Anti-Pattern 1: Using requestAnimationFrame for XR
Wrong
renderer.xr.enabled = true;
function animate() {
requestAnimationFrame(animate); // BREAKS in XR
renderer.render(scene, camera);
}
animate();Why It Fails
requestAnimationFrame stops firing when a WebXR session is active. The WebXR API uses its own frame loop synchronized to the headset's refresh rate. The scene will freeze the moment the user enters VR/AR.
Correct
renderer.xr.enabled = true;
renderer.setAnimationLoop((time, frame) => {
renderer.render(scene, camera);
});setAnimationLoop works for BOTH XR and non-XR rendering. ALWAYS use it when XR is enabled, even if the app also runs in non-XR mode.
---
Anti-Pattern 2: Moving the Camera Directly in VR
Wrong
// Trying to teleport by moving the camera
camera.position.set(5, 0, 10);Why It Fails
The WebXR API overwrites the camera's position and rotation EVERY frame based on the headset's physical pose. Any direct camera manipulation is immediately overridden. The user sees no movement.
Correct
const cameraRig = new THREE.Group();
cameraRig.add(camera);
scene.add(cameraRig);
// Move the rig — camera position is relative to rig
cameraRig.position.set(5, 0, 10);ALWAYS use a camera rig group. The WebXR API positions the camera relative to its parent, so moving the parent moves the entire viewpoint.
---
Anti-Pattern 3: Heavy Post-Processing in VR
Wrong
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new SSAOPass(scene, camera));
composer.addPass(new UnrealBloomPass());
renderer.setAnimationLoop(() => {
composer.render(); // Runs ALL passes for EACH eye
});Why It Fails
In stereo VR, each post-processing pass runs TWICE (once per eye). SSAO + Bloom + other passes can easily exceed the frame budget. At 90fps, each frame has only 11.1ms. Dropped frames cause motion sickness.
Correct
// Minimize or eliminate post-processing in VR
renderer.setAnimationLoop(() => {
renderer.render(scene, camera); // Direct render, no composer
});
// If post-processing is essential, use ONLY lightweight passes
// and monitor frame timing with renderer.infoNEVER use SSAO, screen-space reflections, or multi-pass bloom in VR. If visual effects are needed, bake them into textures or use material-based alternatives.
---
Anti-Pattern 4: Forgetting to Add Controllers to Scene
Wrong
const controller = renderer.xr.getController(0);
controller.addEventListener('select', onSelect);
// Controller exists but is never added to the sceneWhy It Fails
getController() returns a THREE.Group that MUST be added to the scene graph. Without scene.add(controller), the controller's matrixWorld is never updated. Event handlers fire, but controller.position and controller.matrixWorld contain stale identity matrices. Raycasting from the controller produces incorrect results.
Correct
const controller = renderer.xr.getController(0);
controller.addEventListener('select', onSelect);
scene.add(controller); // MUST add to scene
// Same for grip and hand spaces
const grip = renderer.xr.getControllerGrip(0);
scene.add(grip);
const hand = renderer.xr.getHand(0);
scene.add(hand);ALWAYS add EVERY controller space you use to the scene (or to a camera rig group if using locomotion).
---
Anti-Pattern 5: Wrong Reference Space for Standing VR
Wrong
renderer.xr.setReferenceSpaceType('local');
// User stands up — floor is at eye levelWhy It Fails
'local' places the origin at the headset's initial position (eye level). Objects placed at y=0 appear at head height, not on the floor. The user's real-world floor has no representation in the coordinate system.
Correct
renderer.xr.setReferenceSpaceType('local-floor');
// y=0 is at floor levelALWAYS use 'local-floor' for standing/room-scale VR. Use 'local' ONLY for seated experiences where floor position is irrelevant.
---
Anti-Pattern 6: Not Setting enabled Before Creating XR Button
Wrong
// renderer.xr.enabled is still false (default)
document.body.appendChild(VRButton.createButton(renderer));
renderer.xr.enabled = true; // Too lateWhy It Fails
VRButton.createButton() checks navigator.xr.isSessionSupported() and configures the button based on the renderer's XR state. Setting enabled after button creation can cause the button to show incorrect state or fail to initialize the session properly.
Correct
renderer.xr.enabled = true; // FIRST
renderer.xr.setReferenceSpaceType('local-floor');
document.body.appendChild(VRButton.createButton(renderer)); // AFTERALWAYS configure renderer.xr properties BEFORE creating VRButton or ARButton.
---
Anti-Pattern 7: Ignoring Foveated Rendering
Wrong
renderer.xr.enabled = true;
// Default foveation is 0 (no foveation)
// Rendering full resolution across entire field of viewWhy It Fails
Without foveated rendering, the GPU renders at full resolution across the entire lens area, including the periphery where the user cannot perceive detail. This wastes GPU budget that could maintain stable frame rates.
Correct
renderer.xr.enabled = true;
renderer.xr.setFoveation(1.0); // Maximum foveated renderingALWAYS set foveation to 1.0 unless visual quality in the periphery is critical. This provides the largest performance gain with minimal perceived quality loss.
---
Anti-Pattern 8: Creating Objects Every Frame in XR
Wrong
renderer.setAnimationLoop((time, frame) => {
// Allocating new objects every frame
const direction = new THREE.Vector3(0, 0, -1);
const matrix = new THREE.Matrix4();
matrix.extractRotation(controller.matrixWorld);
direction.applyMatrix4(matrix);
renderer.render(scene, camera);
});Why It Fails
Allocating objects in the render loop causes garbage collection pauses. In VR, even a 5ms GC pause causes a visible frame drop and potential nausea. At 90fps, the ENTIRE frame budget is 11.1ms.
Correct
// Pre-allocate outside the loop
const direction = new THREE.Vector3();
const tempMatrix = new THREE.Matrix4();
renderer.setAnimationLoop((time, frame) => {
direction.set(0, 0, -1);
tempMatrix.extractRotation(controller.matrixWorld);
direction.applyMatrix4(tempMatrix);
renderer.render(scene, camera);
});ALWAYS pre-allocate vectors, matrices, quaternions, and other math objects outside the render loop. Reuse them every frame.
---
Anti-Pattern 9: Not Handling Session End Cleanup
Wrong
let hitTestSource = null;
renderer.setAnimationLoop((time, frame) => {
if (frame && hitTestSource) {
const results = frame.getHitTestResults(hitTestSource);
// hitTestSource becomes invalid after session ends
// but code still tries to use it
}
});Why It Fails
When an XR session ends, all session-specific resources (hit test sources, reference spaces, anchors) become invalid. Using them after session end throws errors or returns empty results. Re-entering XR creates a new session with new resources.
Correct
let hitTestSource = null;
const session = renderer.xr.getSession();
session.addEventListener('end', () => {
hitTestSource = null; // Clean up session-specific state
});
renderer.setAnimationLoop((time, frame) => {
if (frame && hitTestSource) {
const results = frame.getHitTestResults(hitTestSource);
// Safe — hitTestSource is nulled on session end
}
});ALWAYS listen for the 'end' event on the XR session and reset all session-specific state.
threejs-impl-xr — Examples
Example 1: Complete VR Scene with Controllers
A fully functional VR scene with controller models, laser pointers, and object interaction.
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x505050);
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);
renderer.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer));
// Lighting
scene.add(new THREE.HemisphereLight(0x808080, 0x606060));
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
// Floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
new THREE.MeshStandardMaterial({ color: 0x222222 })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
// Interactive objects
const group = new THREE.Group();
scene.add(group);
for (let i = 0; i < 20; i++) {
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(0.15, 0.15, 0.15),
new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff })
);
mesh.position.set(
Math.random() * 4 - 2,
Math.random() * 2 + 0.5,
Math.random() * 4 - 2
);
group.add(mesh);
}
// Controllers
const controllerModelFactory = new XRControllerModelFactory();
const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();
function setupController(index) {
const controller = renderer.xr.getController(index);
controller.addEventListener('selectstart', () => {
tempMatrix.identity().extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
const intersects = raycaster.intersectObjects(group.children, false);
if (intersects.length > 0) {
controller.userData.selected = intersects[0].object;
controller.attach(intersects[0].object);
}
});
controller.addEventListener('selectend', () => {
if (controller.userData.selected) {
group.attach(controller.userData.selected);
controller.userData.selected = null;
}
});
scene.add(controller);
// Laser pointer
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -5)
]),
new THREE.LineBasicMaterial({ color: 0xffffff })
);
controller.add(line);
// Controller model on grip space
const grip = renderer.xr.getControllerGrip(index);
grip.add(controllerModelFactory.createControllerModel(grip));
scene.add(grip);
return controller;
}
setupController(0);
setupController(1);
// MUST use setAnimationLoop
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
});---
Example 2: AR Hit Test with Object Placement
Place objects on real-world surfaces using AR hit testing.
import * as THREE from 'three';
import { ARButton } from 'three/addons/webxr/ARButton.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.01, 20);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(ARButton.createButton(renderer, {
requiredFeatures: ['hit-test']
}));
// Light
scene.add(new THREE.HemisphereLight(0xffffff, 0xbbbbff, 1));
// Reticle (placement indicator)
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);
// Place object on tap
const controller = renderer.xr.getController(0);
controller.addEventListener('select', () => {
if (reticle.visible) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.05, 0.2, 32),
new THREE.MeshStandardMaterial({ color: 0x00ff88 })
);
mesh.position.setFromMatrixPosition(reticle.matrix);
scene.add(mesh);
}
});
scene.add(controller);
// Hit testing
let hitTestSource = null;
let hitTestSourceRequested = false;
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;
session.addEventListener('end', () => {
hitTestSourceRequested = false;
hitTestSource = null;
});
}
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);
});---
Example 3: VR Hand Tracking with Pinch Interaction
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.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.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
// Request hand-tracking feature
renderer.xr.setSessionInit({
optionalFeatures: ['hand-tracking']
});
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer));
scene.add(new THREE.HemisphereLight(0x808080, 0x606060));
// Hand setup
const handFactory = new XRHandModelFactory();
const spheres = [];
function setupHand(index) {
const hand = renderer.xr.getHand(index);
hand.add(handFactory.createHandModel(hand, 'mesh'));
scene.add(hand);
hand.addEventListener('pinchstart', () => {
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.02, 16, 16),
new THREE.MeshStandardMaterial({ color: 0xff4444 })
);
// Place sphere at index fingertip (joint 9)
const indexTip = hand.joints['index-finger-tip'];
if (indexTip) {
sphere.position.copy(indexTip.position);
scene.add(sphere);
spheres.push(sphere);
}
});
return hand;
}
setupHand(0);
setupHand(1);
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
});---
Example 4: VR Teleportation with Camera Rig
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
// Camera rig — ALWAYS move the rig, NEVER the camera directly
const cameraRig = new THREE.Group();
cameraRig.add(camera);
scene.add(cameraRig);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer));
// Floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(50, 50),
new THREE.MeshStandardMaterial({ color: 0x333333 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// Teleport marker
const marker = new THREE.Mesh(
new THREE.RingGeometry(0.2, 0.3, 32).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial({ color: 0x00ff00 })
);
marker.visible = false;
scene.add(marker);
// Teleport arc (parabolic)
const arcGeometry = new THREE.BufferGeometry();
const arcMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00 });
const arcLine = new THREE.Line(arcGeometry, arcMaterial);
arcLine.visible = false;
scene.add(arcLine);
const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();
let teleportTarget = null;
const controller = renderer.xr.getController(0);
controller.addEventListener('selectstart', () => {
// Show teleport arc while holding trigger
arcLine.visible = true;
});
controller.addEventListener('selectend', () => {
arcLine.visible = false;
marker.visible = false;
if (teleportTarget) {
cameraRig.position.copy(teleportTarget);
cameraRig.position.y = 0;
teleportTarget = null;
}
});
// Add controller to camera rig so it moves with teleportation
cameraRig.add(controller);
const grip = renderer.xr.getControllerGrip(0);
const factory = new XRControllerModelFactory();
grip.add(factory.createControllerModel(grip));
cameraRig.add(grip);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444));
renderer.setAnimationLoop(() => {
// Update teleport target while trigger held
if (arcLine.visible) {
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) {
teleportTarget = intersects[0].point;
marker.position.copy(teleportTarget);
marker.visible = true;
} else {
marker.visible = false;
teleportTarget = null;
}
}
renderer.render(scene, camera);
});---
Example 5: AR with Estimated Lighting
import * as THREE from 'three';
import { ARButton } from 'three/addons/webxr/ARButton.js';
import { XREstimatedLight } from 'three/addons/webxr/XREstimatedLight.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.01, 20);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(ARButton.createButton(renderer, {
optionalFeatures: ['light-estimation']
}));
// Default lighting (used when estimation is unavailable)
const defaultLight = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 1);
scene.add(defaultLight);
let defaultEnvironment = null;
// XR estimated light
const xrLight = new XREstimatedLight(renderer);
xrLight.addEventListener('estimationstart', () => {
scene.add(xrLight);
scene.environment = xrLight.environment;
scene.remove(defaultLight);
});
xrLight.addEventListener('estimationend', () => {
scene.remove(xrLight);
scene.environment = defaultEnvironment;
scene.add(defaultLight);
});
// Place a PBR sphere that reacts to real-world lighting
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.1, 32, 32),
new THREE.MeshStandardMaterial({ metalness: 0.8, roughness: 0.2 })
);
sphere.position.set(0, 0.1, -0.5);
scene.add(sphere);
renderer.setAnimationLoop(() => {
renderer.render(scene, camera);
});threejs-impl-xr — Methods Reference
WebXRManager
Accessed via renderer.xr. Manages the complete WebXR session lifecycle.
Properties
renderer.xr.enabled: boolean // Default: false. MUST set to true for XR.
renderer.xr.isPresenting: boolean // Read-only. True when XR session is active.
renderer.xr.cameraAutoUpdate: boolean // Default: true. Auto-updates camera from XR device pose.Methods
// Session management
renderer.xr.getSession(): XRSession | null
renderer.xr.setSessionInit(sessionInit: XRSessionInit): void
renderer.xr.setReferenceSpaceType(type: string): void
renderer.xr.getReferenceSpace(): XRReferenceSpace | null
// Controllers
renderer.xr.getController(index: number): THREE.Group
renderer.xr.getControllerGrip(index: number): THREE.Group
renderer.xr.getHand(index: number): THREE.Group
// Performance
renderer.xr.setFoveation(foveation: number): void // 0.0 (none) to 1.0 (max)
renderer.xr.getFoveation(): number
renderer.xr.setFramebufferScaleFactor(scale: number): void
// Environment
renderer.xr.getEnvironmentBlendMode(): string // 'opaque' | 'additive' | 'alpha-blend'---
VRButton
import { VRButton } from 'three/addons/webxr/VRButton.js';
VRButton.createButton(renderer: THREE.WebGLRenderer): HTMLElementReturns a DOM button element that:
- Checks for WebXR
immersive-vrsupport - Shows "ENTER VR" when supported
- Shows "VR NOT SUPPORTED" when unavailable
- Handles session request/end lifecycle automatically
---
ARButton
import { ARButton } from 'three/addons/webxr/ARButton.js';
ARButton.createButton(
renderer: THREE.WebGLRenderer,
sessionInit?: XRSessionInit
): HTMLElementReturns a DOM button element that:
- Checks for WebXR
immersive-arsupport - Accepts optional
sessionInitfor required/optional features - Handles session request/end lifecycle automatically
XRSessionInit Structure
interface XRSessionInit {
requiredFeatures?: string[]; // Session fails if unavailable
optionalFeatures?: string[]; // Session proceeds without these
domOverlay?: { root: HTMLElement };
}Common features: 'hit-test', 'hand-tracking', 'dom-overlay', 'anchors', 'plane-detection', 'depth-sensing', 'light-estimation'.
---
XRControllerModelFactory
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
const factory = new XRControllerModelFactory(gltfLoader?: GLTFLoader);
factory.createControllerModel(controllerGrip: THREE.Group): THREE.Object3D- ALWAYS pass the grip space (
getControllerGrip), NOT the target ray space - Automatically loads the correct 3D model for the detected controller hardware
- Uses the WebXR Input Profiles library for model matching
---
XRHandModelFactory
import { XRHandModelFactory } from 'three/addons/webxr/XRHandModelFactory.js';
const factory = new XRHandModelFactory();
factory.createHandModel(
hand: THREE.Group,
profile?: 'mesh' | 'spheres' | 'boxes'
): THREE.Object3D'mesh'— Realistic hand mesh (default)'spheres'— Joint positions as spheres'boxes'— Joint positions as boxes
---
XREstimatedLight
import { XREstimatedLight } from 'three/addons/webxr/XREstimatedLight.js';
const xrLight = new XREstimatedLight(renderer: THREE.WebGLRenderer);
xrLight.environment: THREE.Texture // Environment map for PBR materials
xrLight.lightProbe: THREE.LightProbe // Spherical harmonics light probe
xrLight.directionalLight: THREE.DirectionalLight // Primary directional lightEvents:
'estimationstart'— Light estimation data available'estimationend'— Light estimation lost
---
XRPlanes
import { XRPlanes } from 'three/addons/webxr/XRPlanes.js';
const planes = new XRPlanes(renderer: THREE.WebGLRenderer);
scene.add(planes);Requires 'plane-detection' in session features. Automatically creates mesh visualizations for detected real-world planes.
---
renderer.setAnimationLoop
renderer.setAnimationLoop(
callback: ((time: DOMHighResTimeStamp, frame?: XRFrame) => void) | null
): void- MUST use this instead of
requestAnimationFramefor XR - The
frameparameter is anXRFramewhen an XR session is active,undefinedotherwise - Pass
nullto stop the loop - Works for both XR and non-XR rendering (safe to use unconditionally)
---
Controller Events
All events fire on the THREE.Group returned by getController():
controller.addEventListener('select', (event: { target: THREE.Group }) => void): void
controller.addEventListener('selectstart', (event: { target: THREE.Group }) => void): void
controller.addEventListener('selectend', (event: { target: THREE.Group }) => void): void
controller.addEventListener('squeeze', (event: { target: THREE.Group }) => void): void
controller.addEventListener('squeezestart', (event: { target: THREE.Group }) => void): void
controller.addEventListener('squeezeend', (event: { target: THREE.Group }) => void): void
controller.addEventListener('connected', (event: { data: XRInputSource, target: THREE.Group }) => void): void
controller.addEventListener('disconnected', (event: { target: THREE.Group }) => void): voidXRInputSource Properties (from connected event)
event.data.handedness: 'none' | 'left' | 'right'
event.data.targetRayMode: 'gaze' | 'tracked-pointer' | 'screen'
event.data.profiles: string[] // e.g., ['oculus-touch-v3', 'oculus-touch-v2']
event.data.gamepad: Gamepad | null // Buttons and axes for gamepad-style input
event.data.hand: XRHand | null // Hand joint data if hand tracking active---
Hand Tracking Events
Events fire on the THREE.Group returned by getHand():
hand.addEventListener('connected', (event) => void): void
hand.addEventListener('disconnected', (event) => void): void
hand.addEventListener('pinchstart', (event) => void): void
hand.addEventListener('pinchend', (event) => void): void---
WebXR Hit Test API (Browser API, used within Three.js loop)
// Request hit test source (once per session)
const viewerSpace = await session.requestReferenceSpace('viewer');
const hitTestSource = await session.requestHitTestSource({ space: viewerSpace });
// Each frame
const results: XRHitTestResult[] = frame.getHitTestResults(hitTestSource);
const pose: XRPose = results[0].getPose(referenceSpace);
// pose.transform.matrix is a Float32Array(16)