Enable WebXR Hit Test for Floor Detection in Three.js

This article provides a step-by-step guide on how to request and enable the WebXR Hit-Test API within a Three.js augmented reality (AR) session to detect physical floors and surfaces. You will learn how to configure the WebXR manager, request the required hit-test features, set up a hit-test source, and use the resulting matrix data to place virtual 3D objects accurately on real-world surfaces.

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

To use the hit-test feature, you must explicitly request it when initiating the WebXR AR session. In Three.js, this is done by passing requiredFeatures or optionalFeatures to the ARButton.

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

// Create the renderer and enable WebXR
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);

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

Step 2: Request the Hit-Test Source

Once the AR session starts, you need to obtain a hit-test source. This source uses the device’s camera and sensors to calculate collisions with physical surfaces. You will need to request a reference space (usually 'viewer') to project the hit-test ray from the camera’s perspective.

let hitTestSource = null;
let localSpace = null;

renderer.xr.addEventListener('sessionstart', async () => {
    const session = renderer.xr.getSession();
    
    // Retrieve the viewer reference space to cast rays from the camera
    const viewerSpace = await session.requestReferenceSpace('viewer');
    // Retrieve the local reference space for absolute positioning in the physical world
    localSpace = await session.requestReferenceSpace('local');

    // Request the hit test source using the viewer space
    session.requestHitTestSource({ space: viewerSpace }).then((source) => {
        hitTestSource = source;
    });
});

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

Step 3: Perform Hit-Testing in the Render Loop

Inside the Three.js animation/render loop, query the frame for hit-test results. If a physical surface like a floor is detected, the WebXR frame will return an array of hit results. You can extract the transformation matrix from the first result and apply it to a 3D object (such as a ring-shaped reticle) to visualize where the floor is.

// Create a reticle to visual represent the detected floor
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 update the matrix manually
reticle.visible = false;
scene.add(reticle);

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

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

            // Make the reticle visible and position it on the detected floor
            reticle.visible = true;
            reticle.matrix.fromArray(pose.transform.matrix);
        } else {
            // Hide the reticle if no surface is detected
            reticle.visible = false;
        }
    }

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

Step 4: Spawning Objects on the Detected Floor

To place a 3D object permanently at the detected floor position, listen for a controller selection event. When the user taps the screen, check if the reticle is visible, and copy its matrix position to spawn your 3D mesh.

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

controller.addEventListener('select', () => {
    if (reticle.visible) {
        // Create the 3D object to place on the floor
        const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
        const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
        const mesh = new THREE.Mesh(geometry, material);

        // Position the mesh exactly where the reticle is on the physical floor
        mesh.position.setFromMatrixPosition(reticle.matrix);
        scene.add(mesh);
    }
});