AR hit testing
A hit test asks the runtime where a ray from the device meets a surface it has actually recognised — and the answer arrives with an orientation, which is the part most first implementations throw away.
A question to explore
Switch the surface and follow the ray to its hit, then compare anchoring on and off. Finding a surface and keeping content in place are separate steps.
Change one parameter at a time, then compare the result with the explanation below. These simulations do not measure device performance or support.
A reticle following a real surface
The device sweeps the room, casting a ray from the viewer space. The reticle sits on whatever surface the ray meets and tilts to match its normal. Turn anchors off and watch the placed objects slowly drift away from where they were put.
This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.
What a hit test actually is
The device is continuously building a model of the room from its cameras and motion sensors. A hit test asks that model a question: if I cast this ray, where does it meet a surface you are confident about? The answer is a pose — position and orientation — and it comes from the runtime's understanding of the world, not from anything in your scene graph.
That has two consequences worth internalising before you write any code. First, results only exist where the device has recognised geometry, so an empty result is the normal state for the first few seconds of a session and for any surface the user has not looked at yet. Second, the answer is a full pose: a hit on a wall or a sloped table comes back oriented to that surface, and a reticle that ignores the orientation and always lies flat will visibly float through walls.
Hit testing is an optional feature named hit-test, and on the web it is currently an AR concern — you request it alongside an immersive-ar session. Sessions also usually want dom-overlay so that ordinary HTML controls stay usable on top of the camera feed.
Requesting a source, reading results
The asymmetry here trips people up: you request the hit test source once, asynchronously, outside the frame loop; you read results synchronously, every frame, from the frame object. Trying to await anything inside the frame callback is the wrong shape.
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['hit-test', 'local-floor'],
optionalFeatures: ['anchors', 'dom-overlay'],
domOverlay: { root: document.getElementById('ar-ui') },
});
const viewerSpace = await session.requestReferenceSpace('viewer');
const localSpace = await session.requestReferenceSpace('local-floor');
// One source, created once. The ray defaults to -Z from the given space,
// which for 'viewer' means straight out of the device.
const hitTestSource = await session.requestHitTestSource({ space: viewerSpace });
reticle.matrixAutoUpdate = false;
function onFrame(time, frame) {
reticle.visible = false;
// Synchronous. No await here -- the results are already computed for
// this frame and asking again next frame is how you track movement.
const results = frame.getHitTestResults(hitTestSource);
if (results.length === 0) {
reticle.visible = false; // Normal, not an error.
return;
}
// Results are ordered nearest-first along the ray.
const pose = results[0].getPose(localSpace);
if (!pose) return;
reticle.visible = true;
// The full matrix, not just the position -- this is what makes the
// reticle lie flat on a table and stand upright on a wall.
reticle.matrix.fromArray(pose.transform.matrix);
}
session.addEventListener('end', () => {
// A source stays in the session's set of active hit test sources, and keeps
// being evaluated every frame, until you cancel it.
hitTestSource.cancel();
});This fragment requires a floor space because the scene uses it. Handle session and source request failures in the caller. An empty hit-result array during scanning is a normal frame result, not the same as a failed source request. onFrame must be registered with your XR render loop; reticle is an application-provided three.js object.
The pieces, and which space each one lives in
Almost every hit test bug is a space mix-up. Three different spaces are in play and they each answer a different question.
- The source space (usually viewer)
- Where the ray starts and which way it points. viewer means "out of the device", which is what you want for a phone held up at a table. Passing a controller's targetRaySpace instead gives you a hit test that follows the controller.
- The result space (usually local-floor)
- The space you resolve the result into, and the one your scene is built in. Getting a pose in viewer space instead produces content glued to the camera.
- XRRay
- An optional custom ray for the source: an origin and a direction, both relative to the source space. Omit it and you get the -Z ray, which is right most of the time.
- Transient input hit tests
- requestHitTestSourceForTransientInput handles the phone-tap case, where the input source only exists while a finger is down. Results arrive grouped per input source rather than as a flat list.
- Anchors
- A separate feature. createAnchor pins content to a point the runtime keeps correcting as it learns more about the room. Without one, content stays at the coordinates it was given while the room moves underneath it.
Why unanchored content drifts
The device's idea of where things are is an estimate, and it gets revised. As the user walks around, the runtime recognises that a wall it thought was 3.1 metres away is actually 3.0, and it corrects its whole world model. Anchored content is corrected along with it. Content you placed at a fixed coordinate is not — it stays where the old estimate said, which is now the wrong place.
This is why drift is so hard to catch in testing. Stand still and everything is perfect. Walk to the other side of the room and back, and the virtual mug is now hovering beside the table instead of on it. The fix is to call createAnchor on the hit test result and update your object from the anchor's pose every frame, rather than setting a position once and forgetting it.
Anchors are not free — each one costs the runtime tracking work, and platforms cap how many you can hold. Anchor the things that must stay put, not every particle.
What the demo above is doing
The room has exactly two recognised surfaces, the floor and the table, which is the honest version of what a real session gives you — the rest of the room exists but the runtime has no geometry for it. Restrict the recognised surfaces to just one and the reticle disappears whenever the ray points at the other, which is exactly how a real hit test behaves before the user has scanned an area.
The ray starts at the device and travels along its -Z, the same as a hit test source built on the viewer space. The reticle is oriented from the surface normal, so it lies flat on the floor and on the tabletop, and tilts on the table's sides — that tilt is the part you lose if you only copy the position out of the pose.
Objects are placed automatically every second and a half. With anchors on they stay exactly where they were put. Turn anchors off and new objects come out in a different colour and begin to wander — the demo has no real tracking to correct, so the drift is simulated, but the shape of the failure is what you will see on a phone.
Feature availability
hit-test and anchors are separate features and are not always granted together. Request anchors optionally and degrade to unanchored placement rather than failing the session.
| Platform | immersive-ar | hit-test | anchors |
|---|---|---|---|
| Android Chrome (ARCore) | Yes | Yes | Yes |
| Meta Quest 3 / Pro browser | Yes | Yes | Yes |
| visionOS Safari | Yes | Partial | Partial |
| iOS Safari (iPhone) | No | No | No |
| Desktop browsers | No | No | No |
Checked 2026-09. iOS Safari still has no WebXR AR session; verify against caniuse and the WebXR hit test module before relying on any row.
Mistakes that cost the most time
The first three all look fine on a table in front of you and fall apart the moment someone walks around.
- Using only the position from the pose
- The pose carries orientation too. Drop it and the reticle lies flat on every surface, including walls, and placed objects ignore the slope of what they are standing on.
- Placing without anchors
- Works perfectly while standing still. Walk around and the content is no longer where you put it, because the runtime revised its model of the room and your object did not.
- Treating an empty result list as an error
- No results is the normal state before the device has recognised a surface. Hide the reticle and wait — do not log, retry, or tear down the source.
- Leaving entityTypes at its default
- The option defaults to ["plane"], so an unconfigured source only ever hits detected planes and returns nothing until plane detection has caught up. Passing entityTypes: ["plane", "point"] lets the ray land on feature points as well, which gives a reticle seconds earlier at the cost of noisier, less flat results. "mesh" is the third value, for devices that reconstruct one.
- Requesting a hit test source per frame
- It is an async call that allocates runtime resources. Request one at session start and reuse it; requesting inside the loop stalls the frame and leaks.
- Never calling cancel()
- A source is evaluated every frame for as long as it sits in the session's set of active hit test sources, and cancel() is the only thing the specification defines for taking it out. Cancel the ones you have stopped reading, a preview ray you replaced for instance, rather than leaving the runtime to cast rays nobody looks at.
- Resolving results into viewer space
- Content ends up parented to the camera and follows the user around, which looks like a physics bug and is a one-word fix.
Reproduce a stale-reticle placement bug
A reticle can remain on a table after getPose starts returning null. Hiding it only when the result array is empty misses that case. A second bug appears when a tap is queued during tracking loss and applied later to a newly found surface. The user tapped one moment, but the app acts on another.
The gate below consumes a queued tap on the next frame, whether or not that frame has a pose. Its input is a current pose matrix or null. It copies a matrix when placing so later reticle updates cannot move an already placed object. This is an application-level policy; the API does not choose it for you.
Run the sample without WebXR first. A valid pose makes the reticle visible. A tap followed by a missing pose produces no placement, and tracking recovery alone still produces no placement. Only a fresh tap places at z = -2.
function createPlacementGate() {
let queued = false;
return {
select() { queued = true; },
frame(matrix) {
const selected = queued;
queued = false; // Consume the tap even when this frame has no pose.
const valid = matrix !== null;
return {
visible: valid,
placement: selected && valid ? Array.from(matrix) : null,
};
},
reset() { queued = false; },
};
}
const gate = createPlacementGate();
const pose = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -2, 1];
console.log(gate.frame(pose).visible); // true
gate.select();
console.log(gate.frame(null).placement); // null: tracking lost
console.log(gate.frame(pose).placement); // null: old tap was discarded
gate.select();
console.log(gate.frame(pose).placement[14]); // -2: a fresh tapThis example tests selection state using a synthetic matrix. It does not raycast against your room, create an anchor, or measure placement stability.
Wire the policy into the XR frame loop
Connect reticle.visible to the returned visible value on every frame. For a three.js object whose matrix you set directly, set matrixAutoUpdate to false. If you need an anchor, create it from the current hit within the active frame and handle rejection separately; a copied matrix on its own provides no ongoing world correction.
| Stage | Required action | Failure avoided |
|---|---|---|
| Session select event | Call gate.select(). | Do not place using a cached reticle transform. |
| Animation frame | Resolve this frame’s hit with getPose in the scene reference space. | An old result is not evidence that tracking remains valid. |
| No hit or null pose | Call gate.frame(null) and hide the reticle. | Consume a pending tap instead of saving it for another surface. |
| Valid pose | Pass pose.transform.matrix; place only if placement is returned. | Preview updates do not mutate a placed transform. |
| Hidden or ended session | Reset the gate and hide the reticle. | Pending selection cannot survive an interruption. |
Use the same reference space for resolving the hit and interpreting the returned matrix. The policy intentionally collapses multiple taps between two frames into one placement.
Further reading
The hit test module and the anchors module are separate specs, and reading them in that order is the shortest path to a placement flow that survives someone walking around.
- W3C — WebXR Hit Test Module — Normative definitions of hit test sources, XRRay, and the transient input variant.
- MDN — XRSession.requestHitTestSource() — Parameters, rejection cases, and the lifecycle of a source.
- W3C — WebXR Anchors Module — createAnchor, anchor spaces, and what the runtime promises to keep corrected.
- Anchors — What createAnchor actually promises, and why the failure is a jump not a drift.
- Plane detection — Asking about the whole surface rather than a single point along a ray.
- Environment blend modes — Whether the user can see the room at all, and what that means for what you draw.
- WebXR sessions — Requesting immersive-ar, optional features, and the reference spaces used above.
- VR controllers — Where a screen tap fits in the input model, and what targetRayMode says about it.