平面检测
平面检测给你的不是房间里表面的清单,而是运行时当下的意见 —— 而这个意见每一帧都在长大、分裂、合并、遗忘,并且不会通知你。
一个正在被识别的房间,循环播放
平面逐个出现,并随着每个表面被认出更多而长大。到中后段,两块地面合并成更大的一块 —— 两个 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-detection | orientation | 语义标签 |
|---|---|---|---|
| Android Chrome(ARCore) | 支持 | 支持 | 不支持 |
| Meta Quest 3 / Pro 浏览器 | 支持 | 支持 | 部分 |
| visionOS Safari | 不支持 | 不支持 | 不支持 |
| iOS Safari(iPhone) | 不支持 | 不支持 | 不支持 |
| 桌面浏览器 | 不支持 | 不支持 | 不支持 |
数据核对于 2026-09。这个模块目前是 Immersive Web CG 的孵化项目,不是推荐标准;依赖任何字段之前请查当前草案。
最费时间的那些坑
前三条都源自同一件事:把平面集合当成一个结果,而不是一份还在变的意见。
- 只读一次 detectedPlanes
- 在会话开始时取一次集合,拿到的是第一秒内识别出的东西 —— 在真实房间里那基本等于没有。它必须每帧读。
- 默认多边形是矩形
- 取前四个点,或者从包围盒算出宽和高,产出的内容会在 L 形表面上悬空,在切角表面上错位。
- 把内容挂到平面位姿上
- 在两个平面合并之前它都能用,合并那一刻你的物体就挂在了一个不存在的平面上。改成挂锚点,平面只用来选位置。
- 每帧重建每个平面
- 一个摆满家具的房间能报出几十个平面。每帧重新生成它们全部的几何体,是错过帧期限最直接的办法;先比 lastChangedTime。
- 把 null 位姿当成已移除
- 平面可以在集合里、但此刻没定位上。位姿为 null 就删网格,会让表面在用户转身时不断地闪进闪出。
- 默认水平就是地面
- 桌子、座椅、天花板全是水平的。在找到的第一个水平面上放内容,正是虚拟宠物站到天花板上的原因。
延伸阅读
平面检测模块仍在孵化,所以 explainer 值得和草案一起读 —— 规范正文略去的那些理由都在 explainer 里。
- Immersive Web CG — WebXR Plane Detection — 工作草案:XRPlane、多边形约定,以及 detectedPlanes。
- MDN — XRPlane — 已实现的接口,含 lastChangedTime 与 orientation。
- MDN — XRFrame.detectedPlanes — 每帧的集合,以及「在不在集合里」所隐含的生命周期。
- AR 命中测试 — 那个很尖的问题,以及它的答案为什么自带朝向。
- 锚点 — 平面告诉你位置之后,内容真正该挂在哪里。
- 深度感知 — 了解房间的第三条路,也是唯一能应付杂物的那条。