Three.js AR Hit Testing: Anchor Objects to the Real World

This article explains how to use the WebXR Hit Test API within Three.js to detect physical surfaces in the real world and anchor 3D objects directly onto them. You will learn how to configure an AR session, request a hit-test source, retrieve collision coordinates from the camera’s viewport, and position digital assets precisely on real-world planes like floors, tables, or walls.


Step 1: Initialize the AR Session with Hit-Test Features

To use hit testing, you must explicitly request the hit-test feature when initializing your WebXR session. In Three.js, this is typically handled by configuring the ARButton utility.

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

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

// Request the session with the 'hit-test' required feature
document.body.appendChild(ARButton.createButton(renderer, {
    requiredFeatures: ['hit-test']
}));

Step 2: Set Up the Hit-Test Source

Once the AR session starts, you need to request an XRHitTestSource. This source continuously tracks the camera’s gaze and calculates intersections with physical surfaces in the environment.

let xrHitTestSource = null;
let localSpace = null;

renderer.xr.addEventListener('sessionstart', async () => {
    const session = renderer.xr.getSession();
    
    // Request reference spaces to define coordinate systems
    const viewerSpace = await session.requestReferenceSpace('viewer');
    localSpace = await session.requestReferenceSpace('local');

    // Create the hit-test source relative to the viewer (camera) space
    session.requestHitTestSource({ space: viewerSpace }).then((source) => {
        xrHitTestSource = source;
    });
});

renderer.xr.addEventListener('sessionend', () => {
    xrHitTestSource = null;
    localSpace = null;
});

Step 3: Create a Reticle (Placement Indicator)

A reticle (or ring indicator) helps users visualize where the 3D object will be placed on the real-world surface. Create a simple ring geometry and add it to your scene.

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

reticle.matrixAutoUpdate = false; // We will manually update its position via matrix
reticle.visible = false;
scene.add(reticle);

Step 4: Perform the Hit Test in the Render Loop

In your Three.js animation/render loop, query the frame for hit-test results. If a real-world surface is detected, extract its transformation matrix, apply it to the reticle, and make the reticle visible.

renderer.setAnimationLoop((timestamp, frame) => {
    if (frame && xrHitTestSource) {
        // Get hit test results relative to the local physical coordinate space
        const hitTestResults = frame.getHitTestResults(xrHitTestSource);

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

            // Show the reticle and match its transform to the hit-test pose
            reticle.visible = true;
            reticle.matrix.fromArray(pose.transform.matrix);
        } else {
            reticle.visible = false;
        }
    }

    renderer.render(scene, camera);
});

Step 5: Anchor the 3D Object on User Interaction

To place your 3D object permanently at the detected coordinate, add an event listener for the controller/screen tap event. When the user taps, spawn your 3D model at the exact position and rotation of the reticle.

const controller = renderer.xr.getController(0);
scene.add(controller);

controller.addEventListener('select', onSelect);

function onSelect() {
    if (reticle.visible) {
        // Create the 3D object you want to anchor
        const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
        const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
        const mesh = new THREE.Mesh(geometry, material);

        // Apply the reticle's matrix (position, rotation, scale) to the new object
        reticle.matrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
        
        scene.add(mesh);
    }
}