WebXR Hit Testing in Three.js AR Tutorial

This article provides a step-by-step guide on how to implement WebXR hit testing in Three.js to place 3D objects onto real-world surfaces. You will learn how to set up an AR session, request hit-test features, detect physical environments using a device camera, and position virtual objects accurately using a visual reticle indicator.

Step 1: Enable the WebXR Hit-Test Feature

To perform hit testing, you must request the hit-test feature when initializing your AR session. Three.js provides the ARButton utility to simplify this process.

import { ARButton } from 'three/addons/webxr/ARButton.js';

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.xr.enabled = true;

document.body.appendChild(ARButton.createButton(renderer, {
    requiredFeatures: ['hit-test']
}));

Step 2: Create a Visual Reticle (Target Marker)

A reticle is a 3D marker that lies flat on detected real-world surfaces, showing users where their object will be placed. Create a ring mesh and set its matrix auto-updates to false, as WebXR will control its transform matrix directly.

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

Step 3: Request the Hit-Test Source

Once the AR session starts, you need to request a XRHitTestSource. This source continuously calculates intersection points between the user’s camera view and physical surfaces.

let hitTestSource = null;
let hitTestSourceRequested = false;

function requestHitTestSource(session) {
    session.requestReferenceSpace('viewer').then((referenceSpace) => {
        session.requestHitTestSource({ space: referenceSpace }).then((source) => {
            hitTestSource = source;
        });
    });

    session.addEventListener('end', () => {
        hitTestSourceRequested = false;
        hitTestSource = null;
    });

    hitTestSourceRequested = true;
}

Step 4: Perform the Hit Test in the Animation Loop

In your Three.js render loop, retrieve the hit test results using the current XR frame. If a surface is detected, extract its pose matrix and apply it to the reticle.

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

        if (!hitTestSourceRequested) {
            requestHitTestSource(session);
        }

        if (hitTestSource) {
            const referenceSpace = renderer.xr.getReferenceSpace();
            const hitTestResults = frame.getHitTestResults(hitTestSource);

            if (hitTestResults.length > 0) {
                const hit = hitTestResults[0];
                const pose = hit.getPose(referenceSpace);

                reticle.visible = true;
                reticle.matrix.fromArray(pose.transform.matrix);
            } else {
                reticle.visible = false;
            }
        }
    }
    renderer.render(scene, camera);
});

Step 5: Place the 3D Object on Tap

To place an object, listen for the WebXR controller select event. When the user taps their screen and the reticle is visible, spawn a 3D mesh at the reticle’s current position and rotation.

const controller = renderer.xr.getController(0);
controller.addEventListener('select', onSelect);
scene.add(controller);

function onSelect() {
    if (reticle.visible) {
        // Create the 3D object to place (e.g., a box)
        const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
        const material = new THREE.MeshPhongMaterial({ color: 0xff0000 });
        const mesh = new THREE.Mesh(geometry, material);

        // Position and orient the object matching the reticle matrix
        mesh.position.setFromMatrixPosition(reticle.matrix);
        mesh.quaternion.setFromRotationMatrix(reticle.matrix);
        
        scene.add(mesh);
    }
}