参考空间
WebXR 里你写下的每一个位置都是相对某个东西的。那个东西就是参考空间 —— 坐标系里唯一一件你的代码从不明说的事。
同一个坐标,五个落点
紫色箱子在每一种下面都是 (0, 0, -1),这行代码全程没变。变的是原点:那个圆环和三根轴。选中 local 时拖一下身高滑块,就能看出为什么它不能用来把东西摆在地上。
演示需要 WebGL。五种空间各自锚定在什么上,下面的文字都写了。
它会造成的那个 bug
你把一张桌子放在场景的 y = 0,戴上头显一看,桌子浮在胸口高度。或者你在头显上开发,看着一切正常,同事一试,所有东西都低了十几厘米。场景代码没有任何一处是错的,是参考空间和你以为的那个不一样。
会这样是因为 WebXR 的位置单独拿出来没有意义。你向系统要一个姿态,要的是**相对某个参考空间**的姿态,而那个空间决定了原点是什么:脚下的地板、会话开始那一刻头恰好所在的高度,或者你的头本身 —— 它跟着你动。
API 的形状让这件事很容易被跳过。你在初始化时请求一次,存进一个变量,之后每帧把它传给 getViewerPose,从此它就隐形了。一开始就选对,比事后去查「一个按米建模的场景为什么整体偏了一个说不出名字的量」要便宜得多。
五种类型
这是你传给 requestReferenceSpace 的取值。设备可以拒绝其中任何一种,所以每一次请求都可能 reject。
- viewer
- 原点就是观察者的头,跟着人走。放进这个空间的内容是贴在脸上的,所以它适合加载指示器和调试读数,不适合任何应该留在房间里的东西。所有设备都支持它,也是唯一不会失败的那个。
- local
- 原点大致是会话开始时观察者所在的位置,**y = 0 在他头的高度,不是地板**。适合坐姿体验,也适合那种「该出现在启动者面前」的内容。不适合任何需要落地的东西,因为这个偏移量因人而异。
- local-floor
- 和 local 一样,但 y = 0 是地板,由运行时确定,而不是由某个头恰好在哪决定。站姿内容的正确默认值。地板高度可能来自设备设置时配置的值而不是当场测量,所以把它当成「准到几厘米」来对待。
- bounded-floor
- 同样以地板为基准,但额外给你 boundsGeometry:一个描述用户可安全走动范围的多边形。当你的内容应该贴合用户真实拥有的房间时用它。这个多边形不一定是矩形,而且会话期间可能变。
- unbounded
- 给跨越大范围的内容用,用户可能走得足够远,以至于追踪漂移和浮点精度开始成为问题。运行时被允许移动原点来保持追踪稳定 —— 这意味着你缓存下来的世界坐标,已经不在你放它的地方了。
怎么请求,以及怎么降级
没有能力查询接口可以事先问设备提供哪几种空间。你只能请求一次、处理 reject —— 上面那条降级链是标准写法的原因就在这里。
floorOffset 那一行是最常被省掉的。从 local-floor 降到 local 却不补偿,正是内容浮起来的完整过程:你保留了坐标,丢掉了 y = 0 的含义。
// 从最具体的开始要,要不到就往下降。
async function getSpace(session) {
for (const type of ['local-floor', 'local', 'viewer']) {
try {
return { space: await session.requestReferenceSpace(type), type };
} catch {
// 这台设备不提供这一种。试下一个。
}
}
throw new Error('no usable reference space');
}
const { space, type } = await getSpace(session);
// 如果降到了 'local',y = 0 是头的高度,不是地板。
// 按一个假定身高把内容整体下移,它才还落在地上。
const floorOffset = type === 'local' ? -1.6 : 0;
world.position.y = floorOffset;
session.requestAnimationFrame(function onFrame(time, frame) {
session.requestAnimationFrame(onFrame);
const pose = frame.getViewerPose(space);
if (!pose) return; // 追踪可能丢一帧。跳过,别崩。
render(pose);
});requestReferenceSpace 是 reject 而不是返回 null,所以每次尝试都要自己的 catch。按具体程度排序去要,支持好的设备就永远走不到降级分支。
每种空间锚定在什么上
最要紧的是第三列,因为它正是代码里看不见的那一列。
| 类型 | 原点 | y = 0 是什么 | 典型用途 |
|---|---|---|---|
| viewer | 头,跟着动 | 永远是眼睛高度 | 头锁 UI、调试叠加层 |
| local | 起始位置 | 会话开始时头的高度 | 坐姿体验 |
| local-floor | 起始位置 | 地板 | 站姿内容,通常的默认值 |
| bounded-floor | 起始位置 | 地板 | 需要尊重安全区的房间尺度内容 |
| unbounded | 运行时决定,可能移动 | 地板 | 大范围 AR 与户外体验 |
依据 WebXR Device API 规范核对于 2026-09。可用性因设备而异:viewer 和 local 是通用的,bounded-floor 与 unbounded 不是。
原点会在你脚下移动
参考空间不是一个永久承诺。运行时可以判定原点需要移动 —— 最常见的是用户从系统菜单里做了一次重新居中 —— 并通过在这个空间上触发 reset 事件来通知你。
会坏掉的是你算过一次、然后存起来的东西。存成世界坐标的传送落点、会话开始时摆好的一件家具、一个空间音源:reset 之后它们相对用户全都到了别处,因为坐标系动了,而你存的那串数字没动。
正确的处理方式是把这个事件当成「重算」的指令,而不是一条日志。锚点这套机制存在的理由正是让你不必手工做这件事 —— 这也是用 XRAnchor 摆放现实世界内容,比自己存坐标更稳的原因。
响应 reset
reset 不是错误,也不会结束会话。这里不处理不会引发崩溃 —— 这正是它难找的原因:会话照常跑着,只是从那一刻起内容都在错的地方。
space.addEventListener('reset', (event) => {
// 任何用这个空间的坐标缓存下来的东西,现在都过期了。
clearPlacedObjects();
recomputeTeleportTargets();
// bounded-floor:安全区多边形也可能一起变了。
if (space.boundsGeometry) {
updatePlayArea(space.boundsGeometry);
}
});有些运行时会在第一帧也触发它,所以这个处理函数必须在「还什么都没摆」的时候跑起来也是安全的。
读边界,如果有的话
bounded-floor 会给你 boundsGeometry:一组 DOMPointReadOnly,按逆时针围成一个多边形,用的是这个空间自己的坐标,y 为零。它描述的是用户可以走到哪,不是你的内容必须放在哪 —— 这是两件不同的事。
这个多边形不是矩形。把它当包围盒处理,你得到的安全区会包含用户够不着的角落,然后你的游戏就会把一个目标物放进其中一个角。如果你确实需要一个矩形,算「内接于多边形的最大矩形」,而不是「包住多边形的那个」。
即使设备批准了这个空间,它也可能是空的或者根本没有,通常意味着用户没设过边界。要按「对这个房间一无所知」来设计,把多边形当成一份到手就更好的额外信息。
移动用户,而不是移动世界
getOffsetReferenceSpace 从你手上这个空间派生出一个新的,并施加一个固定变换。它是传送、快速转向、以及「把用户安置在某个特定位置」的官方做法,不需要你重写场景里的每一个坐标。
这样做而不是平移场景根节点,有两个实在的理由。一是被追踪的内容保持一致,因为运行时知道这个偏移,会把它同样施加到姿态和命中测试上。二是原空间没有被改动,你随时可以从它派生一个全新的偏移,而不是靠一次次累加把浮点误差堆起来。
派生出来的是一个真正的参考空间,它转发 reset 事件 —— 挂在原空间上的处理函数不会为它触发。你实际传给 getViewerPose 的是哪个,就把监听挂在哪个上。
// 派生一个平移过的空间,而不是搬动你的场景。
const offset = new XRRigidTransform(
{ x: 0, y: 0, z: -5 }, // 向前五米
{ x: 0, y: 0, z: 0, w: 1 }, // 不旋转
);
const moved = space.getOffsetReferenceSpace(offset);
// 从此把 'moved' 而不是 'space' 传给 getViewerPose。
// 房间还在原地,动的是用户的坐标。这个变换作用在原空间上,符号和直觉相反:z = -5 的偏移让用户**向前**走,因为你是把原点相对他往后挪。
最费时间的几个坑
- 默认 local-floor 一定有
- 它通常都有,然后你在一台没有的设备上测试,promise 在初始化阶段 reject。因为这发生在第一帧之前,故障看起来像「会话起不来」,而不是「有一个请求失败了」。
- 降级了却不补偿
- 从 local-floor 掉到 local 还沿用同一套场景坐标,会让所有东西高出大约 1.6 米。场景是对的,空间是对的,结果是错的。
- 把平均身高写死
- 固定 1.6 米的偏移是个合理的兜底,也是个糟糕的假定。用户的身高远超出这个范围,而差 20 厘米的偏移恰好属于那种「感觉哪里不对」而不会被写成 bug 报告的误差。
- 忽略 reset 事件
- 一切正常,直到有人重新居中了一下,然后内容就到了别处。因为会话照常继续,这个问题几乎从不由开发者自己复现,而几乎总是由用户报上来。
- 一帧里混用多个空间
- 在不同参考空间里拿到的姿态属于不同的坐标系。拿 local-floor 里的手柄姿态去和 viewer 里缓存的位置比较,得到的数字看着合理,实则没有意义。
延伸阅读
- XRReferenceSpace — MDN — 这个接口、reset 事件,以及 getOffsetReferenceSpace。
- WebXR Device API 规范 — 五种类型及其保证定义在这里。
- WebXR 会话 — 会话是怎么起来的,以及参考空间的请求落在初始化的哪一步。
- 锚点 — 让内容在 reset 之后依然待在原处的机制,不需要你重算任何东西。
- 移动方式 — 通过偏移参考空间来移动用户,而不是移动整个世界。