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!