Three.js VR and AR — WebXR Immersive Experiences
In this tutorial, you will learn about Three.js VR and AR. We cover key concepts, practical examples, and best practices to help you master this topic.
Three.js VR and AR capabilities use the WebXR API to render stereoscopic 3D for head-mounted displays, enabling immersive virtual and augmented reality experiences in the browser.
What You'll Learn
By the end of this guide, you will set up WebXR in Three.js, create VR and AR sessions, handle controller input, render stereoscopic views, implement teleportation locomotion, and build a cross-platform XR experience.
Why VR and AR Matter
Browser-based XR removes installation barriers. Users click a link and enter VR. Durga Antivirus Pro uses WebXR for training simulations where security analysts practice threat response in immersive 3D environments, manipulating network nodes with hand controllers.
flowchart LR
A[User Clicks Enter VR] --> B[Request Session]
B --> C{Session Type?}
C -->|VR| D[Stereo Rendering]
C -->|AR| E[Camera Passthrough]
D --> F[Controller Input Loop]
E --> F
F --> G[Render Frame]
G --> H[WebXR Frame Callback]
Setting Up WebXR
import * as THREE from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer));
Expected output: An "Enter VR" button appears. Clicking it enters full-screen VR mode with stereoscopic rendering.
Why xr.enabled: Without this, Three.js ignores WebXR entirely. The renderer must opt into XR rendering mode.
VR Session Loop
renderer.setAnimationLoop(function() {
controller1.update();
controller2.update();
// Update scene objects here
cube.rotation.y += 0.01;
renderer.render(scene, camera);
});
Expected output: The scene renders at the headset's native refresh rate (72-144Hz), with stereoscopic view.
Why setAnimationLoop instead of requestAnimationFrame: The XR system controls frame timing. setAnimationLoop integrates with the XR frame callback.
Controller Input
var controller1 = renderer.xr.getController(0);
controller1.addEventListener('selectstart', function() {
console.log('Trigger pressed');
});
controller1.addEventListener('selectend', function() {
console.log('Trigger released');
});
scene.add(controller1);
// Add controller model
var controllerModelFactory = new XRControllerModelFactory();
var controllerGrip1 = renderer.xr.getControllerGrip(0);
controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1));
scene.add(controllerGrip1);
Expected output: VR controllers appear in the scene with visual models. Pressing the trigger logs to console.
Teleportation Movement
var raycaster = new THREE.Raycaster();
var teleportTarget = new THREE.Mesh(
new THREE.RingGeometry(0.3, 0.5, 32),
new THREE.MeshBasicMaterial({ color: 0x00ff00, visible: false })
);
controller1.addEventListener('selectstart', function() {
raycaster.set(controller1.position, controller1.quaternion);
var intersects = raycaster.intersectObject(ground);
if (intersects.length > 0) {
camera.position.set(intersects[0].point.x, 0, intersects[0].point.z);
}
});
Expected output: Pointing the controller at the ground and pressing the trigger moves the player to that location.
AR Mode
import { ARButton } from 'three/addons/webxr/ARButton.js';
document.body.appendChild(ARButton.createButton(renderer, {
requiredFeatures: ['hit-test']
}));
Expected output: An "Enter AR" button appears. On mobile ARCore/ARKit devices, the camera feed shows through, and 3D objects appear anchored to real-world surfaces.
Common Mistakes
1. Not Enabling renderer.xr
Without renderer.xr.enabled = true, WebXR features silently fail. Always set this flag.
2. Using requestAnimationFrame Instead of setAnimationLoop
In XR mode, use renderer.setAnimationLoop(). requestAnimationFrame bypasses the XR pipeline and breaks stereoscopic rendering.
3. Forgetting Controller Attach
Controller objects must be added to the scene: scene.add(controller1). Without this, controller position and rotation data never update.
4. Not Testing on Real Hardware
Emulators do not perfectly replicate headset rendering. Test on actual hardware for performance and comfort validation.
5. Ignoring Motion Sickness
Fast camera movement in VR causes nausea. Use teleportation for movement, smooth transitions, and maintain a minimum of 60fps for comfort.
6. Incorrect Scale
One unit in Three.js should represent approximately one meter in the real world for VR. Check your scene scale.
Practice Questions
Q1: What is the difference between VR and AR in WebXR? A: VR creates a fully immersive virtual environment. AR overlays virtual objects on the real-world camera feed, requiring hit-testing for surface detection.
Q2: Why should you use setAnimationLoop instead of requestAnimationFrame in XR? A: setAnimationLoop integrates with the WebXR frame callback, providing correct timing for stereoscopic rendering and head tracking.
Q3: How do you add controller models to the scene? A: Use XRControllerModelFactory to create controller mesh models, then add them to controller grip objects retrieved from renderer.xr.
Q4: What is teleportation and why is it used? A: Teleportation is a VR movement method where the user points to a location and instantly moves there. It prevents motion sickness compared to smooth movement.
Q5: What does the hit-test feature provide in AR? A: It detects real-world surfaces (floors, tables, walls) so virtual objects can be placed and anchored to physical surfaces.
Challenge: Build an AR furniture viewer. Users point their phone camera at a floor, tap to place a 3D chair model, and rotate it with touch gestures. The chair should stay anchored to the real-world surface.
FAQ
Try It Yourself — VR Scene Template
Build a complete VR-ready scene with controllers, teleportation, and interactable objects. Run this with a WebXR-compatible browser and VR headset.
<!DOCTYPE html>
<html>
<head>
<title>Three.js VR Template</title>
<style>
body { margin: 0; overflow: hidden; }
#info { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: white; font-family: sans-serif; background: rgba(0,0,0,0.7); padding: 10px 20px; border-radius: 8px; }
</style>
</head>
<body>
<div id="info">VR Scene — Use controllers to interact</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { VRButton } from "three/addons/webxr/VRButton.js";
import { XRControllerModelFactory } from "three/addons/webxr/XRControllerModelFactory.js";
var scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
var camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.6, 3);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.xr.enabled = true;
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(VRButton.createButton(renderer));
var ambient = new THREE.AmbientLight(0x404060, 0.5);
scene.add(ambient);
var dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
scene.add(dirLight);
var ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: 0x333355, side: THREE.DoubleSide })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
var objects = [];
var colors = [0xff6b35, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0xa29bfe];
for (var i = 0; i < 5; i++) {
var mesh = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.4, 0.4),
new THREE.MeshStandardMaterial({ color: colors[i], emissive: colors[i], emissiveIntensity: 0.2 })
);
mesh.position.set((Math.random() - 0.5) * 4, 0.2, (Math.random() - 0.5) * 4 - 2);
mesh.castShadow = true;
scene.add(mesh);
objects.push(mesh);
}
// Controllers
var controllerModelFactory = new XRControllerModelFactory();
[0, 1].forEach(function(index) {
var controller = renderer.xr.getController(index);
controller.addEventListener('selectstart', function() {
objects.forEach(function(obj) {
obj.material.emissiveIntensity = 0;
});
});
scene.add(controller);
var grip = renderer.xr.getControllerGrip(index);
grip.add(controllerModelFactory.createControllerModel(grip));
scene.add(grip);
});
renderer.setAnimationLoop(function() {
objects.forEach(function(obj, i) {
obj.rotation.x += 0.01 * (i + 1);
obj.rotation.y += 0.02 * (i + 1);
});
renderer.render(scene, camera);
});
window.addEventListener("resize", function() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
What's Next
Optimize your Three.js scenes for better performance.
Performance Optimization — Rendering performance, LOD, instancing. Exporting — Exporting Three.js scenes to glTF.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro