About threejs-impl-xr
A Three.js WebXR implementation reference for developers building browser-based VR and AR scenes. It covers session setup, controllers, hand tracking, hit testing, locomotion, and performance considerations.
Key features
- 01WebXR 会话配置与参考空间
- 02VR 和 AR 场景初始化
- 03控制器与手部追踪
- 04AR 表面命中测试
- 05XR 渲染性能优化
Use cases
- 01为 Three.js 场景添加 VR 会话
- 02配置 AR 会话并放置虚拟物体
- 03实现控制器或手势交互
- 04调整 XR 场景的帧率与渲染开销
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ impertio-studio/ three.js-claude-skill-package --skill threejs-impl-xrAfter installing, type/threejs-impl-xrin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/anti-patterns.mdexamples.mdmethods.mdDetails
From the project READMEthreejs-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