iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

VR / AR (WebXR)

WebXR brings VR and AR to the browser. Three.js handles the scene; the WebXR Device API provides head, hand, and controller poses; VRButton and ARButton from drei or three/examples bootstrap session UI. Same render loop, dual cameras, immersive.

WebXR, VR session, AR, hit testing

EXAMPLE
import * as THREE from 'three';
import { VRButton } from 'three/examples/jsm/webxr/VRButton.js';
import { ARButton } from 'three/examples/jsm/webxr/ARButton.js';
import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js';

// 1) Enable XR on the renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
renderer.xr.enabled = true;                              // turn on WebXR
document.body.appendChild(renderer.domElement);

// 2) Add the VR enter button
document.body.appendChild(VRButton.createButton(renderer));

// 3) Scene + camera + animation
const scene  = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 100);
camera.position.set(0, 1.6, 3);                          // eye height ~1.6m

scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(2, 5, 3);
scene.add(sun);

// Add a floor for VR
const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(10, 10),
    new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.8 }),
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);

const cube = new THREE.Mesh(
    new THREE.BoxGeometry(0.5, 0.5, 0.5),
    new THREE.MeshStandardMaterial({ color: 0x4f46e5 }),
);
cube.position.set(0, 1, -2);
scene.add(cube);

// 4) Animation loop — use setAnimationLoop, not requestAnimationFrame
renderer.setAnimationLoop((time, frame) => {
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;
    renderer.render(scene, camera);
});

// setAnimationLoop works for both regular display and XR sessions (one render per eye).

// 5) Controllers + hand input
const controller1 = renderer.xr.getController(0);
const controller2 = renderer.xr.getController(1);
scene.add(controller1, controller2);

controller1.addEventListener('selectstart', () => {
    cube.material.color.set(0xff0000);
});
controller1.addEventListener('selectend', () => {
    cube.material.color.set(0x4f46e5);
});

const factory = new XRControllerModelFactory();
const grip1 = renderer.xr.getControllerGrip(0);
grip1.add(factory.createControllerModel(grip1));
scene.add(grip1);

// 6) Ray casting from controller — pointer + select
const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();

function handleSelect(controller) {
    tempMatrix.identity().extractRotation(controller.matrixWorld);
    raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
    raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);

    const intersects = raycaster.intersectObjects([cube]);
    if (intersects.length > 0) {
        intersects[0].object.material.color.setHex(Math.random() * 0xffffff);
    }
}

controller1.addEventListener('select', () => handleSelect(controller1));
controller2.addEventListener('select', () => handleSelect(controller2));

// 7) AR — switch button
// Replace VRButton with ARButton
document.body.appendChild(
    ARButton.createButton(renderer, {
        requiredFeatures: ['hit-test'],
        optionalFeatures: ['dom-overlay', 'light-estimation'],
        domOverlay: { root: document.body },
    })
);

// 8) AR hit testing — place objects on real surfaces
let hitTestSource = null;
let hitTestSourceRequested = false;

const reticle = new THREE.Mesh(
    new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2),
    new THREE.MeshBasicMaterial(),
);
reticle.matrixAutoUpdate = false;
reticle.visible = false;
scene.add(reticle);

renderer.setAnimationLoop((time, frame) => {
    if (frame) {
        const referenceSpace = renderer.xr.getReferenceSpace();
        const session = renderer.xr.getSession();

        if (!hitTestSourceRequested) {
            session.requestReferenceSpace('viewer').then((refSpace) => {
                session.requestHitTestSource({ space: refSpace }).then((source) => {
                    hitTestSource = source;
                });
            });
            session.addEventListener('end', () => {
                hitTestSourceRequested = false;
                hitTestSource = null;
            });
            hitTestSourceRequested = true;
        }

        if (hitTestSource) {
            const hitTestResults = frame.getHitTestResults(hitTestSource);
            if (hitTestResults.length) {
                const hit = hitTestResults[0];
                reticle.visible = true;
                reticle.matrix.fromArray(hit.getPose(referenceSpace).transform.matrix);
            } else {
                reticle.visible = false;
            }
        }
    }
    renderer.render(scene, camera);
});

// Tap to place cube at reticle
controller1.addEventListener('select', () => {
    if (reticle.visible) {
        const placed = cube.clone();
        placed.position.setFromMatrixPosition(reticle.matrix);
        scene.add(placed);
    }
});

// 9) WebXR session events
renderer.xr.addEventListener('sessionstart', () => console.log('XR started'));
renderer.xr.addEventListener('sessionend',   () => console.log('XR ended'));

// 10) Performance — XR demands
// Target 90 fps per eye (180 fps total) on standalone headsets
// Cut polygon count, use baked lighting, atlas textures
// Avoid heavy post-processing (bloom doubles cost)
// Cap draw calls; instanced rendering for repeated objects

// 11) Browser + device support
// • Quest browser, Quest Pro, Vision Pro — full WebXR
// • Chrome Android — WebXR for AR (Scene Viewer too)
// • iOS Safari — WebXR coming; AR.js / WebXR polyfill alternative
// • Desktop Chrome/Edge — VR with Steam VR / Oculus Link

// 12) Hand tracking + advanced inputs
// • renderer.xr.getHand(0) → returns Object3D with joints
// • Each finger joint has pose; build pinch detection
// • Eye tracking on Vision Pro

// 13) Frameworks built on top
// • @react-three/xr — React-native WebXR with hooks (useXR, useController)
// • A-Frame — declarative VR HTML
// • PlayCanvas — visual editor; cloud-hosted

// 14) Common bugs
// • renderer.xr.enabled = true forgotten → button does nothing
// • Using requestAnimationFrame instead of setAnimationLoop → no per-eye render
// • Scene units off → 1 unit must = 1 metre; otherwise scale wrong
// • Camera position pre-XR — XR overrides it; offset using a parent group instead
// • requiredFeatures missing — session refused with feature error
// • HTTPS required — WebXR refuses HTTP; use HTTPS or localhost
// • Heavy post-processing — half framerate; users get sick
// • Forgetting hand/controller cleanup on session end
// • Spawning objects too close to camera → eye strain; minimum 0.5m
// • Not handling 6DoF vs 3DoF differences — fallback to seated experience
// • Buffer / texture leaks across sessions → restart leaks; dispose properly

Why it matters

WebXR through Three.js: turn on renderer.xr, add VRButton or ARButton, switch to renderer.setAnimationLoop, and grab controllers via getController. For AR, request hit-test feature and place objects on detected surfaces. Target 90fps per eye, keep scenes simple, use HTTPS, and disable heavy post-processing inside XR sessions.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
import { VRButton } from 'three/addons/webxr/VRButton.js';
renderer.xr.enabled = true;
document.body.appendChild(VRButton.createButton(renderer));
Try it Yourself »

Discussion

Loading…