Skip to content
All essays
CraftMarch 27, 202418 min

Three.js VR: Building Virtual Reality Experiences

Create immersive VR applications using Three.js with WebXR, from basic scenes to advanced interactions

Ü
Ümit Uz
Mobile & Full Stack Developer

Three.js combined with WebXR provides a powerful platform for creating virtual reality experiences that run directly in the browser. This guide will take you from basic VR setup to advanced interactive experiences.

Setting Up Three.js for VR

Basic VR Setup

typescript
import * as THREE from 'three';

class VRScene {
  private scene: THREE.Scene;
  private camera: THREE.PerspectiveCamera;
  private renderer: THREE.WebGLRenderer;
  private vrSession: XRSession | null = null;

  constructor(canvas: HTMLCanvasElement) {
    // Scene setup
    this.scene = new THREE.Scene();
    this.scene.background = new THREE.Color(0x87ceeb);

    // Camera
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    );
    this.camera.position.set(0, 1.6, 5);

    // Renderer with VR support
    this.renderer = new THREE.WebGLRenderer({
      canvas,
      antialias: true
    });
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.xr.enabled = true; // Enable VR

    this.setupLighting();
    this.setupControllers();
    this.setupEnvironment();
  }

  private setupLighting(): void {
    // Ambient light
    const ambient = new THREE.AmbientLight(0x404040, 2);
    this.scene.add(ambient);

    // Directional light
    const directional = new THREE.DirectionalLight(0xffffff, 1);
    directional.position.set(5, 10, 7.5);
    this.scene.add(directional);

    // Hemisphere light for natural lighting
    const hemisphere = new THREE.HemisphereLight(0x87ceeb, 0x8b4513, 0.5);
    this.scene.add(hemisphere);
  }

  private setupControllers(): void {
    // VR controllers
    const controllerModel1 = this.createControllerModel(0x00ff00);
    const controllerModel2 = this.createControllerModel(0xff0000);

    const controller1 = this.renderer.xr.getController(0);
    controller1.add(controllerModel1);
    this.scene.add(controller1);

    const controller2 = this.renderer.xr.getController(1);
    controller2.add(controllerModel2);
    this.scene.add(controller2);

    // Controller ray lines
    this.setupControllerRay(controller1, 0x00ff00);
    this.setupControllerRay(controller2, 0xff0000);
  }

  private createControllerModel(color: number): THREE.Object3D {
    const geometry = new THREE.CylinderGeometry(0.01, 0.02, 0.1, 8);
    const material = new THREE.MeshStandardMaterial({ color });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.rotation.x = -Math.PI / 2;
    return mesh;
  }

  private setupControllerRay(controller: THREE.Group, color: number): void {
    const geometry = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(0, 0, 0),
      new THREE.Vector3(0, 0, -1)
    ]);
    const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.5 });
    const line = new THREE.Line(geometry, material);
    line.name = 'ray';
    controller.add(line);
  }

  private setupEnvironment(): void {
    // Floor
    const floorGeometry = new THREE.PlaneGeometry(50, 50);
    const floorMaterial = new THREE.MeshStandardMaterial({ 
      color: 0x808080,
      roughness: 0.8,
      metalness: 0.2
    });
    const floor = new THREE.Mesh(floorGeometry, floorMaterial);
    floor.rotation.x = -Math.PI / 2;
    floor.receiveShadow = true;
    this.scene.add(floor);

    // Grid helper
    const grid = new THREE.GridHelper(50, 50, 0x000000, 0x000000);
    grid.material.opacity = 0.2;
    grid.material.transparent = true;
    this.scene.add(grid);
  }

  async startVR(): Promise<void> {
    const isSupported = await navigator.xr?.isSessionSupported('immersive-vr');
    
    if (isSupported) {
      try {
        this.vrSession = await navigator.xr!.requestSession('immersive-vr', {
          optionalFeatures: ['local-floor', 'bounded-floor']
        });
        await this.renderer.xr.setSession(this.vrSession);
      } catch (error) {
        console.error('Failed to start VR:', error);
      }
    }
  }

  animate(): void {
    this.renderer.setAnimationLoop(() => {
      this.renderer.render(this.scene, this.camera);
    });
  }
}

// Usage
const canvas = document.getElementById('vr-canvas') as HTMLCanvasElement;
const vrScene = new VRScene(canvas);

document.getElementById('start-vr')?.addEventListener('click', async () => {
  await vrScene.startVR();
  vrScene.animate();
});

Interactive Objects in VR

Grab and Manipulate Objects

typescript
class VRInteractions {
  private scene: THREE.Scene;
  private raycaster: THREE.Raycaster;
  private selectedObject: THREE.Object3D | null = null;
  private grabOffset: THREE.Vector3 = new THREE.Vector3();

  constructor(scene: THREE.Scene) {
    this.scene = scene;
    this.raycaster = new THREE.Raycaster();
    this.setupInteractables();
  }

  private setupInteractables(): void {
    // Create interactable objects
    const objects = [
      this.createInteractableCube(0xff0000, new THREE.Vector3(-2, 1, 0)),
      this.createInteractableCube(0x00ff00, new THREE.Vector3(0, 1, 0)),
      this.createInteractableCube(0x0000ff, new THREE.Vector3(2, 1, 0))
    ];

    objects.forEach(obj => {
      obj.userData.interactable = true;
      this.scene.add(obj);
    });
  }

  private createInteractableCube(color: number, position: THREE.Vector3): THREE.Mesh {
    const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
    const material = new THREE.MeshStandardMaterial({ 
      color,
      roughness: 0.5,
      metalness: 0.5
    });
    const cube = new THREE.Mesh(geometry, material);
    cube.position.copy(position);
    cube.castShadow = true;
    return cube;
  }

  onSelectStart(event: any): void {
    const controller = event.target;
    const intersections = this.getIntersections(controller);

    if (intersections.length > 0) {
      const intersection = intersections[0];
      this.selectedObject = intersection.object;
      
      // Calculate offset for smooth grabbing
      this.grabOffset.subVectors(
        this.selectedObject.position,
        controller.position
      );

      // Attach to controller
      controller.attach(this.selectedObject);
    }
  }

  onSelectEnd(event: any): void {
    if (this.selectedObject) {
      const controller = event.target;
      this.scene.attach(this.selectedObject);
      this.selectedObject = null;
    }
  }

  private getIntersections(controller: THREE.Object3D): THREE.Intersection[] {
    // Get ray from controller
    const tempMatrix = new THREE.Matrix4();
    tempMatrix.identity().extractRotation(controller.matrixWorld);

    this.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
    this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

    // Check for intersections
    const objects = this.scene.children.filter(
      obj => obj.userData.interactable
    );

    return this.raycaster.intersectObjects(objects, false);
  }
}

VR Teleportation

typescript
class VRTeleportation {
  private scene: THREE.Scene;
  private camera: THREE.Camera;
  private renderer: THREE.WebGLRenderer;
  private teleportMarker: THREE.Mesh;
  private isTeleporting = false;
  private teleportPosition = new THREE.Vector3();

  constructor(scene: THREE.Scene, camera: THREE.Camera, renderer: THREE.WebGLRenderer) {
    this.scene = scene;
    this.camera = camera;
    this.renderer = renderer;

    // Create teleportation marker
    const markerGeometry = new THREE.RingGeometry(0.3, 0.5, 32);
    const markerMaterial = new THREE.MeshBasicMaterial({ 
      color: 0x00ff00,
      transparent: true,
      opacity: 0.7,
      side: THREE.DoubleSide
    });
    this.teleportMarker = new THREE.Mesh(markerGeometry, markerMaterial);
    this.teleportMarker.rotation.x = -Math.PI / 2;
    this.teleportMarker.visible = false;
    this.scene.add(this.teleportMarker);
  }

  update(controller: THREE.Object3D): void {
    const intersections = this.getFloorIntersections(controller);

    if (intersections.length > 0 && this.isTeleporting) {
      const point = intersections[0].point;
      this.teleportMarker.position.copy(point);
      this.teleportMarker.position.y += 0.01;
      this.teleportMarker.visible = true;
      this.teleportPosition.copy(point);
    } else {
      this.teleportMarker.visible = false;
    }
  }

  teleport(): void {
    if (this.teleportMarker.visible) {
      const baseSpace = this.renderer.xr.getReferenceSpace();
      if (baseSpace) {
        const position = this.teleportPosition;
        // Apply teleportation offset
        const transform = new XRRigidTransform(
          { x: position.x, y: position.y, z: position.z },
          { x: 0, y: 0, z: 0, w: 1 }
        );
        const newSpace = baseSpace.getOffsetReferenceSpace(transform);
        this.renderer.xr.setReferenceSpace(newSpace);
      }
    }
  }

  private getFloorIntersections(controller: THREE.Object3D): THREE.Intersection[] {
    const raycaster = new THREE.Raycaster();
    
    const tempMatrix = new THREE.Matrix4();
    tempMatrix.identity().extractRotation(controller.matrixWorld);

    raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
    raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

    const floor = this.scene.children.find(
      obj => obj instanceof THREE.Mesh && obj.geometry instanceof THREE.PlaneGeometry
    );

    if (floor) {
      return raycaster.intersectObject(floor);
    }

    return [];
  }

  startTeleport(): void {
    this.isTeleporting = true;
  }

  endTeleport(): void {
    if (this.isTeleporting) {
      this.teleport();
      this.isTeleporting = false;
      this.teleportMarker.visible = false;
    }
  }
}

VR UI Elements

VR Canvas-Based UI

typescript
class VRUI {
  private scene: THREE.Scene;
  private panels: THREE.Mesh[] = [];

  constructor(scene: THREE.Scene) {
    this.scene = scene;
  }

  createButton(position: THREE.Vector3, text: string, onClick: () => void): THREE.Mesh {
    // Button geometry
    const geometry = new THREE.PlaneGeometry(0.3, 0.1);
    
    // Button texture
    const canvas = document.createElement('canvas');
    canvas.width = 512;
    canvas.height = 128;
    const ctx = canvas.getContext('2d')!;
    
    // Background
    ctx.fillStyle = '#4CAF50';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    // Border
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 10;
    ctx.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
    
    // Text
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold 48px Arial';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, canvas.width / 2, canvas.height / 2);

    const texture = new THREE.CanvasTexture(canvas);
    const material = new THREE.MeshBasicMaterial({ 
      map: texture,
      transparent: true
    });

    const button = new THREE.Mesh(geometry, material);
    button.position.copy(position);
    button.userData.onClick = onClick;
    
    this.scene.add(button);
    this.panels.push(button);

    return button;
  }

  createInfoPanel(position: THREE.Vector3, text: string): THREE.Mesh {
    const geometry = new THREE.PlaneGeometry(0.5, 0.5);
    
    const canvas = document.createElement('canvas');
    canvas.width = 512;
    canvas.height = 512;
    const ctx = canvas.getContext('2d')!;
    
    // Background
    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    
    // Border
    ctx.strokeStyle = '#ffffff';
    ctx.lineWidth = 10;
    ctx.strokeRect(5, 5, canvas.width - 10, canvas.height - 10);
    
    // Text
    ctx.fillStyle = '#ffffff';
    ctx.font = '32px Arial';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'top';
    
    const words = text.split(' ');
    let line = '';
    let y = 20;
    
    for (const word of words) {
      const testLine = line + word + ' ';
      const metrics = ctx.measureText(testLine);
      
      if (metrics.width > canvas.width - 40 && line !== '') {
        ctx.fillText(line, 20, y);
        line = word + ' ';
        y += 40;
      } else {
        line = testLine;
      }
    }
    ctx.fillText(line, 20, y);

    const texture = new THREE.CanvasTexture(canvas);
    const material = new THREE.MeshBasicMaterial({ 
      map: texture,
      transparent: true
    });

    const panel = new THREE.Mesh(geometry, material);
    panel.position.copy(position);
    panel.lookAt(new THREE.Vector3(0, 1.6, 0));
    
    this.scene.add(panel);
    this.panels.push(panel);

    return panel;
  }

  checkIntersections(controller: THREE.Object3D): void {
    const raycaster = new THREE.Raycaster();
    const tempMatrix = new THREE.Matrix4();
    
    tempMatrix.identity().extractRotation(controller.matrixWorld);
    raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
    raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

    const intersections = raycaster.intersectObjects(this.panels);

    for (const intersection of intersections) {
      const object = intersection.object as THREE.Mesh;
      
      if (object.userData.onClick) {
        // Highlight effect
        object.scale.setScalar(1.1);
        
        // Trigger click on button release
        controller.addEventListener('selectend', () => {
          object.scale.setScalar(1.0);
          object.userData.onClick();
        }, { once: true });
      }
    }
  }
}

Performance Optimization

Optimizing VR Scenes

typescript
class VROptimizer {
  private scene: THREE.Scene;

  constructor(scene: THREE.Scene) {
    this.scene = scene;
    this.optimize();
  }

  private optimize(): void {
    this.enableInstancing();
    this.optimizeGeometry();
    this.optimizeMaterials();
    this.setupLOD();
  }

  private enableInstancing(): void {
    // Use instanced rendering for repeated objects
    const geometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
    const material = new THREE.MeshStandardMaterial({ 
      color: 0x00ff00,
      roughness: 0.7
    });

    const instancedMesh = new THREE.InstancedMesh(geometry, material, 500);
    const matrix = new THREE.Matrix4();

    for (let i = 0; i < 500; i++) {
      matrix.setPosition(
        (Math.random() - 0.5) * 50,
        0.25 + Math.random() * 2,
        (Math.random() - 0.5) * 50
      );
      instancedMesh.setMatrixAt(i, matrix);
    }

    this.scene.add(instancedMesh);
  }

  private optimizeGeometry(): void {
    // Merge geometries where possible
    const geometries: THREE.BoxGeometry[] = [];
    
    for (let i = 0; i < 10; i++) {
      geometries.push(new THREE.BoxGeometry(1, 1, 1));
    }

    // Merge geometries (conceptual - use BufferGeometryUtils)
    // const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
  }

  private optimizeMaterials(): void {
    // Limit number of draw calls
    const sharedMaterial = new THREE.MeshStandardMaterial({
      color: 0x808080,
      roughness: 0.7,
      metalness: 0.1
    });

    this.scene.traverse((object) => {
      if (object instanceof THREE.Mesh) {
        object.material = sharedMaterial;
      }
    });
  }

  private setupLOD(): void {
    const lod = new THREE.LOD();

    // High detail
    const highDetail = new THREE.Mesh(
      new THREE.SphereGeometry(1, 32, 32),
      new THREE.MeshStandardMaterial({ color: 0xff0000 })
    );

    // Medium detail
    const mediumDetail = new THREE.Mesh(
      new THREE.SphereGeometry(1, 16, 16),
      new THREE.MeshStandardMaterial({ color: 0xff0000 })
    );

    // Low detail
    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);
  }
}

Conclusion

Three.js with WebXR provides a powerful and accessible platform for creating VR experiences. By following best practices for performance, interaction, and user experience, you can build immersive virtual reality applications that run seamlessly in the browser.

Start experimenting with simple scenes, add interactive elements, and gradually build more complex VR experiences. The future of web-based VR is here!

Enjoyed this essay? Share it.

Related essays

Next essay
Hardhat Testing Framework