平面检测

平面检测给你的不是房间里表面的清单,而是运行时当下的意见 —— 而这个意见每一帧都在长大、分裂、合并、遗忘,并且不会通知你。

一个正在被识别的房间,循环播放

平面逐个出现,并随着每个表面被认出更多而长大。到中后段,两块地面合并成更大的一块 —— 两个 XRPlane 消失,一个不同的取代了它们。打开顶点,就能看出平面是多边形,不是矩形。

这个演示需要 WebGL,你的浏览器没有提供。下面的正文本身是完整的,不看演示也能读。

正在加载演示…

和命中测试问的不是同一个问题

命中测试问的是一个很尖的问题 —— 这条射线打到哪儿了 —— 得到一个位姿。平面检测问的是一个很宽的问题:你知道哪些平坦表面,它们各自铺到多远?答案是一组多边形,而它恰好适用于单个点做不了的那些事:在墙上排一个菜单、判断沙发放不放得下、把物体吸附到桌沿。

两者不是二选一。命中测试告诉你用户指向哪里,平面告诉你他指的是什么、有多大。多数放置流程两个都要,而且在 ARCore 上,一个命中测试结果往往就指向同一个运行时正通过 detectedPlanes 报出来的那个平面。

平面更难用对的原因在于:它们不是稳定的对象。集合每帧都在变,每个成员的形状也在变。

读取集合,并且只重建变过的那些

平面出现和消失都没有事件,你得自己每帧做差集。每帧给所有平面重建几何体也能跑,同时它会在一个真实房间里吃掉你的帧预算 —— lastChangedTime 就是为这件事准备的。

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

// XRPlane 在存活期间是稳定的身份,所以直接拿它本身做 Map 的键最自然。
const meshes = new Map();   // XRPlane -> { mesh, builtAt }

function onFrame(time, frame) {
  const planes = frame.detectedPlanes;   // XRPlaneSet,这一帧的权威集合

  // 消失:还在我们表里,但已经不在检测集合中。没有「移除」事件。
  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;   // 此刻没定位上。跳过,不要删。

    let entry = meshes.get(plane);

    // 多边形一变,lastChangedTime 就前进 —— 表面还在被探索时这很频繁。
    // 重建之前先比一下它。
    if (!entry || entry.builtAt < plane.lastChangedTime) {
      entry?.mesh.geometry.dispose();
      // plane.polygon 是平面自身空间里的一组 DOMPointReadOnly,y = 0。
      // 它是多边形,不是一个宽和一个高。
      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 只有 'horizontal' | 'vertical'。地面和天花板都是水平的 ——
    // 要区分它们得看位姿。
    entry.mesh.userData.orientation = plane.orientation;
  }
}

一些运行时在 XRPlane 上提供 semanticLabel("floor"、"wall"、"table")。它是扩展,不属于核心模块,不带回退地读它,会让代码在所有没实现它的设备上失败。

XRPlane 上有什么

接口很小。微妙之处全在这些值随时间怎么变,而不在它们各自是什么意思。

polygon
边界,是平面自身空间里 y = 0 的一组点。它的长度会随表面被探索而变化。默认四个点的代码在样板房间里没问题,遇到第一张 L 形办公桌就崩。
planeSpace
位于平面原点的一个 XRSpace。每帧用 frame.getPose 拿它和你的参照空间求解。返回 null 意味着此刻没定位上,**不是**它已经没了。
orientation
只有 horizontal 或 vertical,而且在运行时还没判定时可能是缺失的。水平面同时覆盖地面、桌面和天花板 —— 要区分它们得靠位姿,或者靠平台提供的语义标签(如果有)。
lastChangedTime
多边形上次变化的时间。它是那道很便宜的闸门,让你不必给已经稳定的平面每帧重建网格;而且它是「这个平面长大了」的唯一信号。
frame.detectedPlanes
完整答案,每帧被整体替换。是否在集合里就是生命周期本身:不在集合里的平面已经不存在了,而且没有任何回调会告诉你。

平面会合并,而挂在它上面的东西会因此坏掉

一个已经看到地面两半的运行时会报出两个平面。当它想明白这是同一个表面时,它**不会**把一个撑大、另一个缩掉 —— 它把两个都移除,报出一个覆盖并集的新平面。你手里持有的任何 XRPlane 引用都已经死了,而新平面的原点也不同。

这就是内容应该挂在锚点上、而不是挂在平面位姿上的原因。锚点熬得过这次合并,因为它描述的是一个物理点;平面位姿熬不过,因为它所属的那个平面已经不存在了。用平面来决定东西放哪儿、能放多大,然后在那个位置建一个锚点,接着就把平面忘掉。

同样的道理适用于可视化。在 lastChangedTime 前进时重建高亮网格是对的;而用平面对象之外的任何东西作键去缓存几何体,迟早会画出一个已经不对应任何事物的形状。

上面的演示在做什么

房间 —— 地面、墙、桌子 —— 是物理上存在的东西。半透明的多边形是运行时对它的识别,两者被刻意画成不是一回事。紫色是水平,绿色是垂直,对应 XRPlane.orientation 真正能做出的唯一区分。

平面相隔几秒出现,并且在长大,因为一个表面是随用户环视而被逐步识别的,不是一次到位。在循环走到大约三分之二处,两块地面消失,一块更大的闪现 —— 那就是合并。在真实会话里,这正是挂在平面位姿上的内容发生跳变的时刻。

这里没有一个多边形是矩形。打开顶点数一数角:合并后的地面有六个。这条性质最容易被想当然地抹掉,因为测试房间的表面通常是方的,而真实房间不是。

功能可用性

平面检测比命中测试更不稳定,而语义标签这个扩展的可用平台还要更少。

平台plane-detectionorientation语义标签
Android Chrome(ARCore)支持支持不支持
Meta Quest 3 / Pro 浏览器支持支持部分
visionOS Safari不支持不支持不支持
iOS Safari(iPhone)不支持不支持不支持
桌面浏览器不支持不支持不支持

数据核对于 2026-09。这个模块目前是 Immersive Web CG 的孵化项目,不是推荐标准;依赖任何字段之前请查当前草案。

最费时间的那些坑

前三条都源自同一件事:把平面集合当成一个结果,而不是一份还在变的意见。

只读一次 detectedPlanes
在会话开始时取一次集合,拿到的是第一秒内识别出的东西 —— 在真实房间里那基本等于没有。它必须每帧读。
默认多边形是矩形
取前四个点,或者从包围盒算出宽和高,产出的内容会在 L 形表面上悬空,在切角表面上错位。
把内容挂到平面位姿上
在两个平面合并之前它都能用,合并那一刻你的物体就挂在了一个不存在的平面上。改成挂锚点,平面只用来选位置。
每帧重建每个平面
一个摆满家具的房间能报出几十个平面。每帧重新生成它们全部的几何体,是错过帧期限最直接的办法;先比 lastChangedTime。
把 null 位姿当成已移除
平面可以在集合里、但此刻没定位上。位姿为 null 就删网格,会让表面在用户转身时不断地闪进闪出。
默认水平就是地面
桌子、座椅、天花板全是水平的。在找到的第一个水平面上放内容,正是虚拟宠物站到天花板上的原因。

延伸阅读

平面检测模块仍在孵化,所以 explainer 值得和草案一起读 —— 规范正文略去的那些理由都在 explainer 里。