Plane detection

Plane detection does not give you a list of the surfaces in a room. It gives you the runtime's current opinion, and that opinion grows, splits, merges and forgets — every frame, without telling you.

A room being discovered, on a loop

Planes appear one at a time and grow as more of each surface is recognised. Partway through, the two floor planes merge into a single larger one — two XRPlane objects disappear and a different one replaces them. Turn on the vertices to see that a plane is a polygon, not a rectangle.

This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.

Loading demo…

A different question from hit testing

Hit testing asks a pointed question — where does this ray meet something? — and gets a single pose back. Plane detection asks a broad one: what flat surfaces do you know about, and how far do they extend? The answer is a set of polygons, and it is useful for exactly the things a single point is not: laying out a menu on a wall, checking whether a sofa fits, snapping an object to the edge of a table.

The two are not alternatives. A hit test tells you where the user is pointing; a plane tells you what they are pointing at and how big it is. Most placement flows want both, and on ARCore a hit test result often refers to a plane the same runtime is reporting through detectedPlanes.

What makes planes harder to use correctly is that they are not stable objects. The set changes every frame, and so does the shape of each member.

Reading the set, and only rebuilding what changed

There is no event for a plane appearing or disappearing. You diff the set yourself each frame. Rebuilding geometry for every plane every frame works and will also destroy your frame budget in a real room, which is what lastChangedTime is for.

const session = await navigator.xr.requestSession('immersive-ar', {
  requiredFeatures: ['plane-detection'],
});
const localSpace = await session.requestReferenceSpace('local-floor');

// XRPlane objects are stable identities while they live, so a Map keyed
// on the plane itself is the natural bookkeeping.
const meshes = new Map();   // XRPlane -> { mesh, builtAt }

function onFrame(time, frame) {
  const planes = frame.detectedPlanes;   // XRPlaneSet, authoritative this frame

  // Gone: in our map but no longer detected. There is no "removed" event.
  for (const [plane, entry] of meshes) {
    if (!planes.has(plane)) {
      entry.mesh.parent.remove(entry.mesh);
      entry.mesh.geometry.dispose();
      meshes.delete(plane);
    }
  }

  for (const plane of planes) {
    const pose = frame.getPose(plane.planeSpace, localSpace);
    if (!pose) continue;   // Not localised right now. Skip, do not delete.

    let entry = meshes.get(plane);

    // lastChangedTime advances when the polygon changes -- which is often,
    // while a surface is still being explored. Compare it before rebuilding.
    if (!entry || entry.builtAt < plane.lastChangedTime) {
      entry?.mesh.geometry.dispose();
      // plane.polygon is an array of DOMPointReadOnly in plane space, y = 0.
      // It is a polygon, not a width and a height.
      const geometry = buildPolygon(plane.polygon);
      entry = entry
        ? Object.assign(entry, { builtAt: plane.lastChangedTime })
        : addMesh(geometry, plane);
      entry.mesh.geometry = geometry;
      meshes.set(plane, entry);
    }

    entry.mesh.matrix.fromArray(pose.transform.matrix);

    // orientation is 'horizontal' | 'vertical'. Floors and ceilings are both
    // horizontal -- check the pose if you need to tell them apart.
    entry.mesh.userData.orientation = plane.orientation;
  }
}

Some runtimes expose a semanticLabel on XRPlane ("floor", "wall", "table"). It is an extension, not part of the core module, and reading it without a fallback makes the code fail on every device that does not implement it.

What is on an XRPlane

The interface is small. The subtleties are all in what the values do over time rather than in what they mean.

polygon
The boundary, as points in the plane's own space with y = 0. Its length changes as the surface is explored. Code that assumes four points works in a demo room and breaks on the first L-shaped desk.
planeSpace
An XRSpace at the plane's origin. Resolve it against your reference space every frame with frame.getPose. A null result means the plane is not localised right now, not that it is gone.
orientation
Either horizontal or vertical, and it can be absent when the runtime has not decided. Horizontal covers floors, tables and ceilings alike — distinguishing them needs the pose, or a semantic label if the platform offers one.
lastChangedTime
When the polygon last changed. It is the cheap guard that keeps you from rebuilding meshes every frame for planes that are already stable, and it is the only signal that a plane grew.
frame.detectedPlanes
The whole answer, replaced each frame. Membership is the lifecycle: a plane not in the set no longer exists, and there is no callback to tell you so.

Planes merge, and that breaks anything attached to them

A runtime that has seen two halves of a floor reports two planes. When it works out that they are one surface, it does not grow one and shrink the other — it removes both and reports a new plane covering the union. Any XRPlane reference you were holding is now dead, and the new plane has a different origin.

This is why content should be attached to an anchor rather than to a plane pose. An anchor survives the merge because it describes a physical point; a plane pose does not, because the plane it belonged to no longer exists. Use the plane to decide where to put something and how large it can be, then create an anchor at that spot and forget the plane.

The same reasoning applies to visuals. Rebuilding a highlight mesh when lastChangedTime advances is correct; caching a plane's geometry keyed on something other than the plane object itself will eventually draw a shape that no longer corresponds to anything.

What the demo above is doing

The room — floor, wall, table — is what physically exists. The translucent polygons are the runtime's recognition of it, and they are deliberately not the same thing. Purple is horizontal, green is vertical, matching the only distinction XRPlane.orientation actually makes.

Planes appear a few seconds apart and grow, because a surface is recognised incrementally as the user looks around rather than all at once. Around two-thirds through the loop, the two floor planes vanish and a single larger one flashes into place — that is the merge, and in a real session it is when content attached to a plane pose jumps.

None of the polygons are rectangles. Turn on the vertices and count the corners: the merged floor has six. This is the property most likely to be assumed away, because a test room usually has rectangular surfaces and a real one does not.

Feature availability

Plane detection is less settled than hit testing, and the semantic label extension is available on fewer platforms still.

Platformplane-detectionorientationSemantic labels
Android Chrome (ARCore)YesYesNo
Meta Quest 3 / Pro browserYesYesPartial
visionOS SafariNoNoNo
iOS Safari (iPhone)NoNoNo
Desktop browsersNoNoNo

Checked 2026-09. The module is an incubation in the Immersive Web CG rather than a Recommendation; check the current draft before depending on any field.

Mistakes that cost the most time

The first three all come from treating the plane set as a result rather than as a running opinion.

Reading detectedPlanes once
Fetching the set at session start gives you whatever had been recognised in the first second, which in a real room is close to nothing. It has to be read every frame.
Assuming the polygon is a rectangle
Taking the first four points, or computing a width and height from the bounds, produces content that overhangs an L-shaped surface and misses a bevelled one.
Attaching content to a plane pose
It works until two planes merge, at which point your object is parented to a plane that no longer exists. Anchor the content instead and use the plane only to choose the spot.
Rebuilding every plane every frame
A furnished room can report dozens of planes. Regenerating all of their geometry per frame is a straightforward way to miss frame deadlines; compare lastChangedTime first.
Treating a null pose as removal
A plane can be present in the set but temporarily unlocalised. Deleting your mesh on a null pose makes surfaces flicker in and out as the user turns around.
Assuming horizontal means floor
Tables, seats and ceilings are all horizontal. Placing content on the first horizontal plane you find is how a virtual pet ends up standing on the ceiling.

Further reading

The plane detection module is still an incubation, so the explainer is worth reading alongside the draft — it carries the reasoning the spec text leaves out.