AR 命中测试

命中测试问的是:从设备发出的这条射线,落在运行时确实识别出来的哪个表面上 —— 而返回的答案带着朝向,那恰恰是第一版实现最容易扔掉的部分。

带着这个问题试一试

切换表面,观察射线与落点的关系,再比较使用锚点前后的表现。找到表面与让内容留在原处是两个步骤。

每次只改变一个参数,对照变化阅读下方解释。这里的模拟不代表真实设备的性能或兼容性。

跟着真实表面走的落点环

设备在房间里扫过,从观察者空间发出射线。指示环落在射线打中的表面上,并按该面的法线倾斜。关掉锚点,看放下去的物体如何慢慢漂离原位。

这个演示需要 WebGL,你的浏览器没有提供。下面的正文独立成篇,不看演示也能读完。

演示加载中…

命中测试到底是什么

设备正在用摄像头和运动传感器持续构建一个房间模型。命中测试就是向这个模型提问:如果我投出这条射线,它会打在你有把握的哪个表面上?答案是一个位姿 —— 位置加朝向 —— 它来自运行时对世界的理解,跟你场景图里的任何东西都无关。

这带来两个值得在动手前就记住的推论。第一,只有设备已经识别出几何的地方才会有结果,所以「空结果」是会话最初几秒的正常状态,也是用户还没看过的任何表面的正常状态。第二,答案是一个完整的位姿:打在墙上或斜桌面上的结果,带着那个面的朝向回来;一个忽略朝向、永远平躺的指示环,会明显地穿墙而过。

命中测试是名为 hit-test 的可选特性,在 Web 上目前属于 AR 的范畴 —— 你要连同 immersive-ar 会话一起申请它。这类会话通常还想要 dom-overlay,好让普通 HTML 控件能叠在相机画面上继续可用。

申请一个源,然后每帧读结果

这里的不对称最容易绊人:命中测试源是异步申请一次,在帧循环之外;结果是同步读取,每帧从 frame 对象上取。在帧回调里 await 任何东西,方向就错了。

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');

// 只创建一次。射线默认是所给空间的 -Z 方向,
// 对 'viewer' 来说就是「从设备正前方射出去」。
const hitTestSource = await session.requestHitTestSource({ space: viewerSpace });

reticle.matrixAutoUpdate = false;

function onFrame(time, frame) {
  reticle.visible = false;
  // 同步调用。这里没有 await —— 这一帧的结果早就算好了,
  // 想跟踪移动,靠的是下一帧再问一次。
  const results = frame.getHitTestResults(hitTestSource);
  if (results.length === 0) {
    reticle.visible = false;   // 正常情况,不是错误。
    return;
  }

  // 结果沿射线由近到远排序。
  const pose = results[0].getPose(localSpace);
  if (!pose) return;

  reticle.visible = true;
  // 用完整矩阵而不只是位置 —— 环能平躺在桌面上、立在墙面上,
  // 靠的就是这一行。
  reticle.matrix.fromArray(pose.transform.matrix);
}

session.addEventListener('end', () => {
  // 一个源会一直留在会话的「活动命中测试源」集合里、每帧被求值,
  // 直到你取消它。
  hitTestSource.cancel();
});

这个片段把地面空间设为必需特性,因为场景依赖它。调用方需要处理会话和源申请失败。扫描期间得到空命中数组是正常帧结果,与申请源失败是两回事。onFrame 需要注册到 XR 渲染循环;reticle 是应用提供的 three.js 物体。

几个部件,以及各自属于哪个空间

几乎所有命中测试的 bug 都是空间搞混。这里同时有三个空间在起作用,各自回答不同的问题。

源空间(通常是 viewer)
射线从哪里出发、朝哪个方向。viewer 表示「从设备射出」,正是举着手机对桌子时想要的。换成手柄的 targetRaySpace,得到的就是一个跟着手柄走的命中测试。
结果空间(通常是 local-floor)
你把结果解析到的空间,也就是你的场景所在的空间。错解析到 viewer 空间,内容就会被粘在相机上。
XRRay
可以给源指定的自定义射线:一个原点加一个方向,都相对源空间。不传就是默认的 -Z 射线,多数情况下这就是对的。
瞬态输入的命中测试
requestHitTestSourceForTransientInput 处理手机点按那种场景 —— 输入源只在手指按下期间存在。它的结果按输入源分组返回,不是一个扁平列表。
锚点(anchors)
另一个独立特性。createAnchor 把内容钉在一个点上,而运行时会随着对房间了解的加深不断修正这个点。没有锚点,内容就停在给定的坐标上,而房间在它底下移动。

为什么没有锚点的内容会漂

设备对「东西在哪」的认知是一个估计值,而这个估计会被修订。用户走动时,运行时发现那面它以为在 3.1 米外的墙其实在 3.0 米外,于是修正整个世界模型。带锚点的内容会跟着一起被修正;你按固定坐标放下去的内容不会 —— 它留在旧估计说的地方,而那里现在是错的。

这也是为什么漂移在测试里特别难抓:站着不动,一切完美;走到房间另一头再走回来,那只虚拟马克杯已经悬在桌子旁边而不是桌子上了。解法是对命中结果调用 createAnchor,然后每帧从锚点的位姿更新你的物体,而不是设一次位置就不管了。

锚点不是免费的 —— 每一个都要占用运行时的追踪开销,平台也会限制同时持有的数量。给必须待住的东西加锚点,不要给每一个粒子都加。

上面这个演示在做什么

房间里恰好只有两个被识别的表面:地面和桌子。这是真实会话给你的东西的诚实版本 —— 房间的其余部分确实存在,但运行时对它没有几何。把已识别表面限定到其中一个,射线指向另一个时指示环就会消失,这正是用户还没扫过某片区域时命中测试的真实行为。

射线从设备出发,沿它的 -Z 前进,跟建立在观察者空间上的命中测试源一致。指示环按命中面的法线摆放,所以它在地面和桌面上平躺,在桌子侧面上立起来 —— 而那份倾斜,正是只从位姿里抄位置就会丢掉的东西。

物体每隔一秒半自动放置一次。开着锚点时,它们精确地停在被放下的位置。关掉锚点,新放下的物体换一种颜色,并开始游走 —— 这个演示里没有真实追踪需要修正,所以漂移是模拟出来的,但失效的形状跟你在手机上会看到的是同一个。

特性可用情况

hit-test 与 anchors 是两个独立特性,并不总是一起被授予。用可选方式申请 anchors,拿不到就降级成无锚点放置,而不是让会话失败。

平台immersive-arhit-testanchors
Android Chrome(ARCore)支持支持支持
Meta Quest 3 / Pro 浏览器支持支持支持
visionOS Safari支持部分部分
iOS Safari(iPhone)不支持不支持不支持
桌面浏览器不支持不支持不支持

数据核对于 2026-09。iOS Safari 至今没有 WebXR AR 会话;依赖任何一行之前请去 caniuse 与 WebXR 命中测试模块复核。

最费时间的几个坑

前三个的共同点是:对着眼前这张桌子看起来都正常,一旦有人走动就散架。

只从位姿里取位置
位姿里还带着朝向。丢掉它,指示环会在所有表面上平躺(包括墙面),放置的物体也会无视它站着的那个斜面。
不用锚点就放置
站着不动时完美。走一圈回来,内容就不在你放的地方了 —— 因为运行时修订了它对房间的模型,而你的物体没有跟着改。
把空结果当成错误
设备还没识别出表面时,没有结果是正常状态。把指示环藏起来等着就好 —— 不要打日志、不要重试、更不要销毁源。
让 entityTypes 停在默认值
这个选项默认是 ["plane"],所以一个没配置过的源只会命中已检测到的平面,在平面检测跟上之前什么都不返回。传 entityTypes: ["plane", "point"] 让射线也能落在特征点上,指示环能早出现好几秒,代价是结果更抖、没那么平。第三个取值是 "mesh",给会重建网格的设备用。
每帧申请一次命中测试源
这是个会分配运行时资源的异步调用。会话开始时申请一个然后复用;放进循环里既卡帧又泄漏。
从不调用 cancel()
只要一个源还在会话的活动命中测试源集合里,它就每帧被求值;而规范里把它拿出来的唯一办法就是 cancel()。不再读的源就取消掉(比如被你换掉的那条预览射线),别让运行时一直替你投没人看的射线。
把结果解析到 viewer 空间
内容会变成相机的子节点、跟着用户走。看起来像物理 bug,实际是改一个词的事。

可复现案例:准星消失后,旧点击不该在别处落物

getPose 返回 null 后,准星可能还留在桌面上。只在结果数组为空时隐藏准星,会漏掉这种情况。还有一个更难发现的问题:追踪丢失期间的点击被缓存,下一次找到表面时才执行,结果用户点击的时刻与物体放置的时刻对不上。

下面的状态门在下一帧消耗掉待处理点击,无论该帧有没有位姿。输入是当前有效的矩阵,或 null。真正放置时复制矩阵,让后续准星更新不会拖动物体。这是示例选择的交互规则,API 不会替应用决定。

先在没有 WebXR 的控制台运行:有效矩阵会显示准星;点击后遇到缺失位姿,不放物体;追踪恢复也不补放。再次点击才会在 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 tap

这里用构造的矩阵测试点击状态,没有扫描真实房间、创建锚点或测量放置稳定性。

接入 XR 帧循环时逐项核对

每帧都把返回的 visible 赋给 reticle.visible。若直接给 three.js 物体设置矩阵,应关闭 matrixAutoUpdate。需要锚点时,要在有效帧内从当前命中创建,并单独处理失败;复制矩阵本身不提供持续的世界坐标修正。

阶段应执行的操作避免的问题
会话 select 事件调用 gate.select()。不使用缓存的准星变换直接放置。
动画帧把本帧命中用 getPose 转为场景参考空间下的位姿。上一帧有效不代表这一帧仍在追踪。
没有命中或位姿为 null调用 gate.frame(null),隐藏准星。丢弃待处理点击,不等下一块表面出现。
位姿有效传入 pose.transform.matrix;仅在返回 placement 时放置。准星移动不会修改已放置物体的矩阵。
会话隐藏或结束重置状态并隐藏准星。中断之前的点击不会继续生效。

解析命中与解释矩阵必须使用同一个参考空间。这个交互规则会把两帧之间的多次点击合并为一次放置。

延伸阅读

命中测试模块与锚点模块是两份独立规范,按这个顺序读,是做出一套「用户走动也不散架」的放置流程的最短路径。