AR 命中测试

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

跟着真实表面走的落点环

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

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

演示加载中…

命中测试到底是什么

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

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

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

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

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

const session = await navigator.xr.requestSession('immersive-ar', {
  requiredFeatures: ['hit-test'],
  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 });

function onFrame(time, frame) {
  // 同步调用。这里没有 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();
});

requestHitTestSource 可能 reject —— 特性被授予了但暂时不可用也算,比如设备还完全没有世界模型的时候。把 reject 当成「这次会话没有命中测试」然后降级,而不是让整个体验失败。

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

几乎所有命中测试的 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 命中测试模块复核。

最费时间的几个坑

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

只从位姿里取位置
位姿里还带着朝向。丢掉它,指示环会在所有表面上平躺(包括墙面),放置的物体也会无视它站着的那个斜面。
不用锚点就放置
站着不动时完美。走一圈回来,内容就不在你放的地方了 —— 因为运行时修订了它对房间的模型,而你的物体没有跟着改。
把空结果当成错误
设备还没识别出表面时,没有结果是正常状态。把指示环藏起来等着就好 —— 不要打日志、不要重试、更不要销毁源。
每帧申请一次命中测试源
这是个会分配运行时资源的异步调用。会话开始时申请一个然后复用;放进循环里既卡帧又泄漏。
从不调用 cancel()
源的生命周期比帧循环长。会话结束时取消它,否则同一个页面里反复开会话会让它们越积越多,追踪质量随之下降。
把结果解析到 viewer 空间
内容会变成相机的子节点、跟着用户走。看起来像物理 bug,实际是改一个词的事。

延伸阅读

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