Skip to content
All essays
CraftMarch 27, 202416 min

WebXR Guide: Building Immersive Web Experiences

Master WebXR API for creating AR and VR experiences directly in the browser

Ü
Ümit Uz
Mobile & Full Stack Developer

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

  1. 1Always check for WebXR support before attempting to use it
  2. 2Provide fallback experiences for non-XR browsers
  3. 3Optimize performance - XR requires high frame rates (72-90 FPS)
  4. 4Handle controller disconnection gracefully
  5. 5Use appropriate reference spaces for your use case
  6. 6Test on real devices whenever possible
  7. 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!

Enjoyed this essay? Share it.

Related essays

Next essay
Three.js VR: Building Virtual Reality Experiences