Skip to content

Three.js VR and AR — WebXR Immersive Experiences

DodaTech Updated 2026-06-28 6 min read

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

Which browsers support WebXR?

WebXR is supported in Chrome, Edge, and Opera on desktop and mobile. Firefox and Safari have partial or experimental support. Check caniuse.com for current status.

Do I need a VR headset to develop for WebXR?

Yes for VR testing. Chrome DevTools has an XR emulation panel for basic development, but final testing requires actual hardware.

What is the difference between ARButton and VRButton?

ARButton requests an 'immersive-ar' session with camera passthrough. VRButton requests 'immersive-vr'. AR requires compatible mobile devices.

How do I handle multiple controllers?

Use renderer.xr.getController(index) with index 0 and 1 for left and right hands. Each has independent position, rotation, and input state.

What is the recommended unit scale for VR?

One Three.js unit = one meter. This ensures objects appear at correct real-world sizes inside the headset.

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