Tutorial · Intermediate · ~40 min
AR in Browsers: Plane Detection & Hit Testing
On Android Chrome, WebXR exposes the camera feed, SLAM tracking, and plane detection — no app install needed. Hit testing casts a ray from the screen centre and reports intersection with detected surfaces, letting you place virtual objects on real floors and tables.
1Request an immersive-ar session
const supported = await
navigator.xr?.isSessionSupported('immersive-ar'); if (!supported) {
document.getElementById('ar-btn').textContent = 'AR not available';
return; } let xrSession, hitTestSource, refSpace;
document.getElementById('ar-btn').addEventListener('click', async ()
=> { xrSession = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['hit-test'], optionalFeatures: ['plane-detection',
'dom-overlay'], domOverlay: { root: document.getElementById('overlay')
}, }); await renderer.xr.setSession(xrSession); // Set up reference
space (viewer = headset, local = world) refSpace = await
xrSession.requestReferenceSpace('local'); // Request a hit-test source
relative to the viewer's gaze const viewerRef = await
xrSession.requestReferenceSpace('viewer'); hitTestSource = await
xrSession.requestHitTestSource({ space: viewerRef });
xrSession.addEventListener('end', cleanup); });
The dom-overlay feature lets you overlay HTML
(buttons, scores, labels) on top of the AR view — essential for a
polished AR UI.
2Transparent background for AR
import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha:
true, // transparent canvas so camera shows through });
renderer.setPixelRatio(devicePixelRatio); renderer.xr.enabled = true;
// Scene background must be null (transparent) — not black const scene
= new THREE.Scene(); scene.background = null; // Light that works in
AR (HemisphereLight approximates real-world env) scene.add(new
THREE.HemisphereLight(0xffffff, 0xbbbbff, 1)); const dirLight = new
THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(0.5, 1,
0.866); scene.add(dirLight); const camera = new
THREE.PerspectiveCamera();
3Hit testing from screen centre
The hit-test source fires a ray from the viewer's gaze direction. In the render loop, check intersection results:
// Reticle — shows where the object will land const reticleGeo = new
THREE.RingGeometry(0.05, 0.07, 32).rotateX(-Math.PI / 2); const
reticle = new THREE.Mesh(reticleGeo, new THREE.MeshBasicMaterial({
color: 0x22c55e })); reticle.visible = false; reticle.matrixAutoUpdate
= false; scene.add(reticle); renderer.setAnimationLoop((time, frame)
=> { if (frame && hitTestSource) { const results =
frame.getHitTestResults(hitTestSource); if (results.length > 0) {
const hit = results[0]; const pose = hit.getPose(refSpace);
reticle.visible = true;
reticle.matrix.fromArray(pose.transform.matrix); } else {
reticle.visible = false; } } renderer.render(scene, camera); });
4Place an object on tap
xrSession.addEventListener('select', () => { if (!reticle.visible)
return; // Clone the reticle matrix to place an object at the hit
point const pos = new THREE.Vector3(); const quat = new
THREE.Quaternion(); const scl = new THREE.Vector3();
reticle.matrix.decompose(pos, quat, scl); const obj = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.1, 0.1), new THREE.MeshStandardMaterial({
color: 0xff4444 }) ); obj.position.copy(pos);
obj.quaternion.copy(quat); scene.add(obj); // Optional: play a haptic
click const gp = xrSession.inputSources[0]?.gamepad;
gp?.hapticActuators?.[0]?.pulse(0.3, 30); });
5Plane detection and visualisation
// Detect detected planes and draw a semi-transparent overlay const
planeMap = new Map(); // XRPlane → THREE.Mesh
renderer.setAnimationLoop((time, frame) => { if (!frame) return; //
detectedPlanes is a Set of XRPlane objects (requires
'plane-detection') const planes = frame.detectedPlanes ?? new Set();
for (const plane of planes) { const pose =
frame.getPose(plane.planeSpace, refSpace); if (!pose) continue; if
(!planeMap.has(plane)) { // Build a polygon mesh from the plane's
boundary points const pts = plane.polygon; // Array of {x,y,z} in
plane space const shape = new THREE.Shape(pts.map(p => new
THREE.Vector2(p.x, p.z))); const geo = new THREE.ShapeGeometry(shape);
const mat = new THREE.MeshBasicMaterial({ color: 0x22c55e, side:
THREE.DoubleSide, transparent: true, opacity: 0.2, }); const mesh =
new THREE.Mesh(geo, mat); mesh.rotation.x = -Math.PI / 2;
scene.add(mesh); planeMap.set(plane, mesh); } const mesh =
planeMap.get(plane); mesh.matrix.fromArray(pose.transform.matrix);
mesh.matrixAutoUpdate = false; } renderer.render(scene, camera); });
Plane detection is supported on Android Chrome with ARCore. On iOS,
Safari supports WebXR AR via the
worldSensing feature
flag available in Safari 17+.