锚点

锚点是运行时给你的一个承诺:随着它对房间了解得更多,它会不断修正这个位姿 —— 也就是说,你这一帧读到的位姿和上一帧不是同一个值,而这正是你要的东西。

同样六个物体,一半锚定

每隔两秒左右,运行时会修正一次自己对房间的估计。锚定的物体跟着一起被修正,始终压在自己的环上;没锚定的守着当初拿到的坐标,一步跳开 —— 系绳画出的就是这段时间里估计一共挪了多远。

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

正在加载演示…

坐标系是一个猜测,而且会被改写

跟踪运行时并不知道任何东西在哪。它从摄像头画面和惯性数据里估计出一个模型,而这个估计一直在被修正。当用户走到房间另一头再走回来,运行时认出了之前见过的特征、闭合了这个回环 —— 闭合回环的含义是:它承认一分钟前报出的坐标偏了几厘米,然后把整个模型重新拟合一遍。

如果内容全都锚定了,这次重新拟合是看不见的;如果没锚定,它非常显眼。放在一个字面坐标上的内容会留在那个坐标上,而承载这个坐标的参照系已经在它底下挪走了,于是你放在桌上的杯子现在浮在桌子旁边 —— 而且它是**一步**过去的,不是滑过去的。

锚点就是从这件事里退出来的办法。你把一个位姿交给运行时,它还给你一个对象,并持续更新这个对象的位姿,让它始终描述同一个物理点。位姿在变,物理含义不变。读一次就把结果缓存起来,等于把你唯一要的东西丢掉了。

创建锚点,然后每帧读它

创建方式有两种,而且并不等价。只要手上有命中测试结果,就该用它来创建 —— 运行时知道这个结果来自哪个可跟踪物,能把锚点绑到那个表面上,而不是绑到空间里一个孤立的点。

两种方式都返回 Promise,可能要好几帧才落定。两种都不能在帧回调里 await。

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

// 命中测试拿到了,不代表锚点也拿到了。降级到不锚定的放置,
// 而不是让整个会话失败。
const anchorsEnabled = session.enabledFeatures?.includes('anchors') ?? false;

const tracked = new Map();   // XRAnchor -> 你的场景对象

function placeAt(hitResult, frame, object) {
  if (!anchorsEnabled || !hitResult.createAnchor) {
    // 降级路径:固定坐标。在用户走动之前都是对的。
    const pose = hitResult.getPose(localSpace);
    object.matrix.fromArray(pose.transform.matrix);
    return;
  }

  // 首选路径:运行时把它绑到命中来源的那个可跟踪物上。
  hitResult.createAnchor().then((anchor) => {
    tracked.set(anchor, object);
  }).catch(() => {
    // 配额用完了,或者当前跟踪质量差到无法建锚点。
  });

  // 没有命中测试结果时的另一条路:
  //   frame.createAnchor(new XRRigidTransform(position, orientation), localSpace)
}

function onFrame(time, frame) {
  for (const [anchor, object] of tracked) {
    // 不在 trackedAnchors 里,说明运行时已经不再维护它。
    // 把物体藏起来,不要继续按过期位姿画。
    if (!frame.trackedAnchors.has(anchor)) {
      object.visible = false;
      continue;
    }

    // 每帧重读。这就是全部约定 —— 位姿本来就该随着模型细化而改变。
    const pose = frame.getPose(anchor.anchorSpace, localSpace);
    if (!pose) { object.visible = false; continue; }

    object.visible = true;
    object.matrix.fromArray(pose.transform.matrix);
  }
}

session.addEventListener('end', () => {
  // 锚点占着运行时的跟踪资源,要释放。
  for (const anchor of tracked.keys()) anchor.delete();
  tracked.clear();
});

在 three.js 里,object.matrix.fromArray 只有在该对象关掉 matrixAutoUpdate 时才生效,否则下一次渲染会用 position 与 quaternion 把它覆盖回去。这是「锚点看起来完全没起作用」最常见的原因。

这几个名字,各自负责什么

整个模块就这五个名字。锚点的多数 bug 是对其中某一个的理解偏了,而不是代码写错了。

XRAnchor
句柄。它本身不带一个可以直接读的位姿 —— 它暴露一个空间,你每帧拿这个空间去和自己的参照空间求解。**拿着这个对象不等于它还在被跟踪。**
anchorSpace
一个以锚定点为原点的 XRSpace。把它和你场景所用的参照空间一起交给 frame.getPose。当这个锚点的跟踪暂时不可用时它返回 null,这是正常的,而且可以恢复。
XRFrame.trackedAnchors
这一帧里运行时仍在维护的锚点集合。锚点是会离开这个集合的 —— 那片区域被遗忘了,或者运行时放弃了它。是否在集合里,是判断一个锚点还有没有意义的唯一可靠信号。
XRAnchor.delete()
释放锚点和它背后的跟踪开销。各平台对单个会话能持有的锚点数量有上限,撞到上限后 createAnchor 就会开始 reject。
requestPersistentHandle()
返回一个 UUID,你可以存下来,在之后的会话里交给 session.restorePersistentAnchor(),让一次放置在应用关闭后仍然有效。它的支持面比普通锚点窄得多 —— 当增强功能用,永远不要当成存储层。

跳变不是漂移,而这个区别本身就是诊断

人们描述锚点问题时通常说内容「漂了」。这个词指向了错误的原因。平滑、连续地滑动是**跟踪质量**问题:光线太暗、面对一堵没有特征的白墙、设备丢失了视觉参照只能靠惯性数据硬撑。这种情况加多少锚点都没用,因为运行时自己也不知道东西在哪。

离散的跳变是完全相反的情况。运行时刚刚搞明白了一件事、修正了模型,而你的内容没跟上。这是锚点问题,几行代码就能修。观察误差是平滑到来的还是一步到位的,能让你在改任何代码之前就知道自己面对的是哪一种。

还有第三种情况值得单独点名:锚定了但仍然错位 —— 因为位姿是在创建时读了一次就缓存住了。它看起来和完全没锚定一模一样,所以能在代码库里活很久:锚点建了,API 调用全部成功,而 bug 是帧循环里少了一次读取。

上面的演示在做什么

六个物体摆在两个表面上。绿色圆环标出它们真正的物理位置 —— 也就是锚点承诺会一直描述的那个点。默认的混合模式下隔一个锚一个,所以两种行为同屏出现,不用来回切换就能直接对照。

每隔两秒左右运行时修正一次模型。圆环会在那一刻涨一下,因为「离散」正是这里最该被看见的性质:误差不是渗进来的,是一次性到位的。锚定的物体被运行时重新定位,留在自己的环里;没锚定的守着旧坐标,从环里迈了出去。

系绳的存在是因为两次修正之间画面完全静止,而一幅静止的画面会让人觉得不锚定也挺好。那条线就是累计误差,画出来是为了在什么都不动的时候仍然看得见它。把修正幅度拖到 0,累计误差会被清掉 —— 那正是你在办公桌前测试时让你相信代码没问题的那个世界。

功能可用性

anchors 是可选功能,和 hit-test 是分开授予的。持久锚点的支持面更窄,而且它的缺席绝不能让放置功能一起失效。

平台anchors从命中结果创建持久句柄
Android Chrome(ARCore)支持支持部分
Meta Quest 3 / Pro 浏览器支持支持部分
visionOS Safari部分部分不支持
iOS Safari(iPhone)不支持不支持不支持
桌面浏览器不支持不支持不支持

数据核对于 2026-09。运行时请读 session.enabledFeatures,不要相信任何一张表,包括这一张 —— 请求可能成功,而某个功能被静默地没有授予。

最费时间的那些坑

前两条其实是同一个 bug 换了身衣服,而且它们在你站着不动时能通过你跑的每一项测试。

只读一次锚点位姿
建了锚点,然后在创建那一刻把位姿抄下来 —— 你得到的恰好是你本来想避免的行为。位姿必须每帧从 anchorSpace 重新求解;它会变,这是功能,不是故障。
不看 trackedAnchors
锚点是会停止被维护的。如果你只检查位姿是不是 null,那个已经死掉的锚点会永远留在你的表里,每帧重试一次,什么也画不出来。
什么都锚定
每个锚点都要运行时付出真实的跟踪开销,而且平台对总数有上限。只锚定那些必须占住物理位置的东西,装饰件挂到它们下面去。
从不调用 delete()
锚点不会替你回收。长会话里反复放置又丢弃内容,会一路泄漏到 createAnchor 开始 reject —— 而且看不出任何原因。
默认锚点已经拿到了
即使某个可选功能被拒绝,requestSession 一样会 resolve。查 session.enabledFeatures,并保留一条不锚定的路径 —— 一个没有锚点的会话仍然值得跑。
把持久句柄当成存储
那个 UUID 只对该设备上的那个运行时有意义。它不是一个位置,不会同步,而且 restorePersistentAnchor 可能因为房间和当初长得不一样而失败。

延伸阅读

锚点模块很短,值得从头到尾读一遍。命中测试模块是它天然的搭档 —— 实践中多数锚点都是从一个命中测试结果创建出来的。