The WebXR API enables developers to create immersive augmented reality (AR) and virtual reality (VR) experiences that work across devices—from head-mounted displays to mobile phones—all within the web browser.
What is WebXR?
WebXR is the successor to WebVR, designed to support both VR and AR experiences. It provides a unified interface for:
- Virtual Reality: Fully immersive environments
- Augmented Reality: Overlaying digital content on the real world
- Cross-device support: Works on HMDs, smartphones, and desktops
Browser Support
typescript
// Check for WebXR support
async function checkXRSupport() {
const isSupported = navigator.xr !== undefined;
if (isSupported) {
console.log('WebXR is supported');
// Check for specific modes
const vrSupported = await navigator.xr.isSessionSupported('immersive-vr');
const arSupported = await navigator.xr.isSessionSupported('immersive-ar');
console.log('VR supported:', vrSupported);
console.log('AR supported:', arSupported);
} else {
console.log('WebXR is not supported');
}
}
checkXRSupport();Getting Started with WebXR
Basic Setup
typescript
// WebXR session management
class WebXRApp {
private session: XRSession | null = null;
private renderer: THREE.WebGLRenderer;
private scene: THREE.Scene;
private camera: THREE.Camera;
constructor(canvas: HTMLCanvasElement) {
// Initialize Three.js scene
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
1000
);
// Setup renderer
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
async startVR(): Promise<boolean> {
try {
this.session = await navigator.xr.requestSession('immersive-vr', {
optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking']
});
this.setupSession();
return true;
} catch (error) {
console.error('Failed to start VR session:', error);
return false;
}
}
async startAR(): Promise<boolean> {
try {
this.session = await navigator.xr.requestSession('immersive-ar', {
optionalFeatures: ['dom-overlay', 'hit-test'],
domOverlay: { root: document.body }
});
this.setupSession();
return true;
} catch (error) {
console.error('Failed to start AR session:', error);
return false;
}
}
private setupSession(): void {
if (!this.session) return;
// Update rendering
this.session.updateRenderState({
baseLayer: new XRWebGLLayer(this.session, this.renderer.getContext())
});
// Handle input
this.session.inputSources.addEventListener('add', (event) => {
console.log('Input source added:', event.inputSource);
});
// Start render loop
this.renderer.setAnimationLoop(this.render.bind(this));
}
private render(time: number, frame: XRFrame): void {
if (!this.session || !frame) return;
// Get viewer pose
const pose = frame.getViewerPose(this.session.baseLayer);
if (!pose) return;
// Clear and render
this.renderer.clear();
for (const view of pose.views) {
const viewport = this.session.baseLayer.getViewport(view);
this.renderer.setViewport(
viewport.x,
viewport.y,
viewport.width,
viewport.height
);
this.camera.matrix.fromArray(view.transform.matrix);
this.camera.updateMatrixWorld(true);
this.camera.projectionMatrix.fromArray(view.projectionMatrix);
this.renderer.render(this.scene, this.camera);
}
}
endSession(): void {
if (this.session) {
this.session.end();
this.session = null;
}
}
}
// Usage
const canvas = document.querySelector('#xr-canvas') as HTMLCanvasElement;
const xrApp = new WebXRApp(canvas);
// Start button handlers
document.getElementById('start-vr')?.addEventListener('click', async () => {
const success = await xrApp.startVR();
if (success) {
document.getElementById('start-overlay')?.classList.add('hidden');
}
});
document.getElementById('start-ar')?.addEventListener('click', async () => {
const success = await xrApp.startAR();
if (success) {
document.getElementById('start-overlay')?.classList.add('hidden');
}
});XR Input and Controllers
Handling Controller Input
typescript
// Controller input handling
class XRInputController {
private session: XRSession;
private controllers: Map<XRInputSource, THREE.Object3D> = new Map();
constructor(session: XRSession, scene: THREE.Scene) {
this.session = session;
this.setupControllers(scene);
}
private setupControllers(scene: THREE.Scene): void {
this.session.inputSources.forEach((inputSource) => {
this.addController(inputSource, scene);
});
this.session.addEventListener('inputsourceschange', (event) => {
event.added?.forEach((inputSource) => {
this.addController(inputSource, scene);
});
event.removed?.forEach((inputSource) => {
this.removeController(inputSource, scene);
});
});
}
private addController(inputSource: XRInputSource, scene: THREE.Scene): void {
const controller = this.createControllerModel(inputSource);
scene.add(controller);
this.controllers.set(inputSource, controller);
}
private removeController(inputSource: XRInputSource, scene: THREE.Scene): void {
const controller = this.controllers.get(inputSource);
if (controller) {
scene.remove(controller);
this.controllers.delete(inputSource);
}
}
private createControllerModel(inputSource: XRInputSource): THREE.Object3D {
const geometry = new THREE.CylinderGeometry(0.01, 0.02, 0.1, 8);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
// Add ray
const rayGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -1)
]);
const rayMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00 });
const ray = new THREE.Line(rayGeometry, rayMaterial);
mesh.add(ray);
return mesh;
}
updateControllers(frame: XRFrame): void {
for (const [inputSource, controller] of this.controllers) {
const inputPose = frame.getPose(inputSource.gripSpace, this.session.baseLayer);
if (inputPose) {
controller.matrix.fromArray(inputPose.transform.matrix);
controller.matrix.decompose(
controller.position,
controller.quaternion,
controller.scale
);
}
}
}
getButtonState(inputSource: XRInputSource, buttonIndex: number): boolean {
// Check button press
const gamepad = inputSource.gamepad;
return gamepad?.buttons[buttonIndex]?.pressed || false;
}
getTriggerValue(inputSource: XRInputSource): number {
const gamepad = inputSource.gamepad;
return gamepad?.buttons[0]?.value || 0;
}
getThumbstickValue(inputSource: XRInputSource): { x: number; y: number } {
const gamepad = inputSource.gamepad;
return {
x: gamepad?.axes[0] || 0,
y: gamepad?.axes[1] || 0
};
}
}AR Features
Hit Testing
typescript
// AR hit testing
class ARHitTest {
private session: XRSession;
private hitTestSource: XRHitTestSource | null = null;
constructor(session: XRSession) {
this.session = session;
}
async initialize(): Promise<boolean> {
try {
this.hitTestSource = await this.session.requestHitTestSource({
space: this.session.requestReferenceSpace('viewer') as XRSpace,
entityTypes: ['plane', 'point']
});
return true;
} catch (error) {
console.error('Failed to create hit test source:', error);
return false;
}
}
getHitPositions(frame: XRFrame): THREE.Vector3[] {
if (!this.hitTestSource) return [];
const hitTestResults = frame.getHitTestResults(this.hitTestSource);
const positions: THREE.Vector3[] = [];
for (const result of hitTestResults) {
const pose = result.getPose(this.session.baseLayer);
if (pose) {
const position = new THREE.Vector3();
position.fromArray(pose.transform.position);
positions.push(position);
}
}
return positions;
}
placeObjectOnHit(hitPosition: THREE.Vector3, object: THREE.Object3D): void {
object.position.copy(hitPosition);
object.lookAt(0, 0, 0);
}
}Anchor System
typescript
// AR anchors
class ARAnchorSystem {
private anchors: Map<XRAnchor, THREE.Object3D> = new Map();
createAnchor(pose: XRPose, object: THREE.Object3D): void {
// Create anchor at pose
const anchor = {
anchorSpace: this.session.requestReferenceSpace('anchor') as XRSpace,
pose: pose
};
this.anchors.set(anchor, object);
}
updateAnchors(frame: XRFrame): void {
for (const [anchor, object] of this.anchors) {
const pose = frame.getPose(anchor.anchorSpace, this.session.baseLayer);
if (pose) {
object.matrix.fromArray(pose.transform.matrix);
object.matrix.decompose(
object.position,
object.quaternion,
object.scale
);
}
}
}
removeAnchor(anchor: XRAnchor): void {
this.anchors.delete(anchor);
}
}Performance Optimization
Optimizing XR Rendering
typescript
class XROptimizer {
private renderer: THREE.WebGLRenderer;
private scene: THREE.Scene;
constructor(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
this.renderer = renderer;
this.scene = scene;
this.optimize();
}
private optimize(): void {
// Enable instancing for repeated objects
this.useInstancing();
// Optimize materials
this.optimizeMaterials();
// Level of detail
this.setupLOD();
// Culling
this.enableCulling();
}
private useInstancing(): void {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const instancedMesh = new THREE.InstancedMesh(
geometry,
material,
1000 // Number of instances
);
const matrix = new THREE.Matrix4();
for (let i = 0; i < 1000; i++) {
matrix.setPosition(
Math.random() * 100 - 50,
Math.random() * 100 - 50,
Math.random() * 100 - 50
);
instancedMesh.setMatrixAt(i, matrix);
}
this.scene.add(instancedMesh);
}
private optimizeMaterials(): void {
// Share materials where possible
const sharedMaterial = new THREE.MeshStandardMaterial({
color: 0x00ff00,
roughness: 0.5,
metalness: 0.5
});
// Use fewer texture maps
sharedMaterial.map = null;
sharedMaterial.normalMap = null;
}
private setupLOD(): void {
const lod = new THREE.LOD();
const highDetail = new THREE.Mesh(
new THREE.SphereGeometry(1, 32, 32),
new THREE.MeshStandardMaterial({ color: 0xff0000 })
);
const mediumDetail = new THREE.Mesh(
new THREE.SphereGeometry(1, 16, 16),
new THREE.MeshStandardMaterial({ color: 0xff0000 })
);
const lowDetail = new THREE.Mesh(
new THREE.SphereGeometry(1, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xff0000 })
);
lod.addLevel(highDetail, 0);
lod.addLevel(mediumDetail, 10);
lod.addLevel(lowDetail, 20);
this.scene.add(lod);
}
private enableCulling(): void {
// Enable frustum culling
this.scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.frustumCulled = true;
}
});
}
}Testing WebXR
Testing Without Headset
typescript
// WebXR emulator
class XREmulator {
private canvas: HTMLCanvasElement;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.setupEmulation();
}
private setupEmulation(): void {
// Mouse as controller
let mouseDown = false;
this.canvas.addEventListener('mousedown', () => mouseDown = true);
this.canvas.addEventListener('mouseup', () => mouseDown = false);
// Movement
const keys: { [key: string]: boolean } = {};
window.addEventListener('keydown', (e) => keys[e.key] = true);
window.addEventListener('keyup', (e) => keys[e.key] = false);
// Emulate movement
const update = () => {
const speed = 0.1;
if (keys['w']) this.camera.position.z -= speed;
if (keys['s']) this.camera.position.z += speed;
if (keys['a']) this.camera.position.x -= speed;
if (keys['d']) this.camera.position.x += speed;
requestAnimationFrame(update);
};
update();
}
}Best Practices
- 1Always check for WebXR support before attempting to use it
- 2Provide fallback experiences for non-XR browsers
- 3Optimize performance - XR requires high frame rates (72-90 FPS)
- 4Handle controller disconnection gracefully
- 5Use appropriate reference spaces for your use case
- 6Test on real devices whenever possible
- 7Consider ergonomics - avoid motion sickness
Conclusion
WebXR opens up immersive experiences to billions of web users. By following best practices and optimizing for performance, you can create engaging AR and VR applications that work seamlessly across devices.
Start building your WebXR application today and be part of the future of the web!