WebXR 会话

WebXR 会话是网页与头显之间的那次握手:一个调用接管两块屏幕,而你选的参考空间决定了地板在哪里。

带着这个问题试一试

切换 viewer、local 和 local-floor,再改变视点高度。原点是跟着头走,还是留在原处?把这个问题接到下一篇「参考空间」。

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

会话生命周期演示

同一个场景,两条驱动路径。切换参考空间,看原点标记相对地板如何移动;调整模拟瞳距,看合成器拿两个投影在做什么。

这个演示需要 WebGL,你的浏览器没有提供。下面的正文不依赖演示,可以单独读完。

正在加载演示…

会话到底是什么

在 XR 之外,网页把画面画进 canvas,浏览器再把这块 canvas 合成进文档。沉浸式会话把这个关系反了过来:网页不再拥有那一帧,而是把画完的帧交给设备合成器,由它重投影后显示在头显的两块屏幕上,刷新率由硬件决定 —— 72、90、120 Hz —— 基本不受你的 JavaScript 跑多快影响。

会话带来的两样东西都比像素本身重要。第一是一个由设备驱动的帧循环,而不是由显示器驱动的;第二是一套坐标系:参考空间规定了原点在哪、哪个方向朝上,以及 —— 取决于你申请了哪一种 —— 真实地板处在什么高度。

沉浸式会话还是独占的。同一台设备同一时刻只能有一个,已有会话在跑时再调 requestSession 会被拒绝。这是有意为之,不是能力不足:两个页面同时抢一台头显,那是安全问题,不只是渲染问题。

申请一个会话

头显愿意把帧交给你之前,有三件事必须成立:浏览器暴露了 navigator.xr、页面处在安全上下文、并且这次调用源自一次用户手势。最后这条几乎每个人第一次都会踩。

// 1. 能力探测。navigator.xr 在不支持 WebXR 的浏览器里是 undefined,在任何非安全上下文
//    (非 HTTPS 且非 localhost)的页面里同样是 undefined,所以判一次就覆盖了两种情况。
//    模式不受支持时 promise 落定为 false;它只在 xr-spatial-tracking 权限策略拦截时
//    才会以 SecurityError reject —— 典型场景是没被授予该特性的 iframe。
const xr = navigator.xr;
const supported = xr
  ? await xr.isSessionSupported('immersive-vr').catch(() => false)
  : false;
enterButton.disabled = !supported;

// 2. requestSession() 必须在用户手势内调用。写在页面加载时、定时器里,
//    或者放在一个先 await 了别的东西之后 —— 手势的瞬时激活已经耗尽,
//    这时调用会以 SecurityError 被拒。
enterButton.addEventListener('click', async () => {
  const session = await xr.requestSession('immersive-vr', {
    // 定位不到地板就直接拒绝这次会话,不要半初始化地进去。
    requiredFeatures: ['local-floor'],
    // 有更好,没有也照样给会话。
    optionalFeatures: ['bounded-floor', 'hand-tracking', 'layers'],
  });

  // 3. 剩下的交给 three.js:它会装上自己的帧循环、
  //    为每个视图建一个相机,并驱动 XR 合成器。
  await renderer.xr.setSession(session);
});

requiredFeatures 是你对自己的承诺:写进去的能力只要设备给不了,requestSession 就整个 reject,于是你永远不会进入一个「缺了场景所依赖的东西」的半吊子会话。

参考空间:决定原点在哪

WebXR 坐标系的困惑大半来自 requestReferenceSpace()。你申请的类型决定了原点的含义,选错就会得到那两个经典 bug:场景埋进地板里,或者整个飘在天花板上。

viewer
原点永远跟着头走。适合做头部锁定的 UI,也适合用来验证追踪是否正常 —— 但绝不能拿它摆放世界内容,那样内容会跟着用户满屋子跑。
local
原点大致落在会话开始时观察者所在的位置。坐姿体验里足够稳定,但它对地板高度不作任何承诺:y = 0 是当时头所在的高度,不是地面。
local-floor
和 local 一样,但原点被放到真实地板上,于是 y = 0 就是地面,1.6 m 大致是成年人视线高度。任何站立体验都该以它为默认选择。
bounded-floor
在 local-floor 之上多一个 boundsGeometry 多边形,描述用户可以安全走动的范围。需要用户真实走动时申请它,运行时不给就退回 local-floor。
unbounded
面向走出单个房间的大范围体验。运行时可能为了维持追踪精度而悄悄调整原点,所以十分钟前记下的坐标不一定还在原处。

帧循环易主

会话跑起来之后,requestAnimationFrame 就是错的循环。它绑定的是页面所在的显示器而不是头显,在沉浸式会话里可能被降频甚至完全停止。会话自带循环,而且每一帧都会带来一个 XRFrame,姿态必须从它身上查。

// 没有 XR 时你会自己调 requestAnimationFrame(tick)。
// 会话启动后 three.js 会替你换掉循环:setAnimationLoop
// 内部会转由 session.requestAnimationFrame() 驱动。
renderer.xr.enabled = true;

renderer.setAnimationLoop((time, frame) => {
  // 会话之外 `frame` 是 undefined,会话之内是一个 XRFrame。
  if (frame) {
    const pose = frame.getViewerPose(referenceSpace);

    // 追踪一旦丢失 pose 就是 null —— 头显被摘下、传感器被挡、
    // 用户走出了活动范围。丢掉这一帧即可,
    // 千万不要把上一次的姿态当成当前姿态继续用。
    if (pose) {
      for (const view of pose.views) {
        // 每只眼睛一个视图。现售头显都是两个,但规范允许别的数量,
        // 所以要遍历,不要写死 [0] 和 [1]。
      }
    }
  }

  renderer.render(scene, camera);
});

漏掉 renderer.xr.enabled = true 是 WebXR 里最安静的一种失败:会话正常启动,头显里也能看到你的场景,但它是用错误的相机单眼渲染的 —— 一切都「差不多对」,这正是它难查的原因。

上面那个演示在做什么

演示把同一个场景走了两条路径。在桌面浏览器里根本没有会话:一个环绕相机顶替头显,一对模拟的眼睛展示立体分离对两个投影意味着什么。参考空间选择器移动的是原点标记而不是房间 —— 这才是诚实的呈现方式:房间没动,动的是零点。

在报告支持 immersive-vr 的设备上,「进入 VR」按钮会出现,交给真实会话的是一模一样的场景图。没有任何东西被重建,改变的只是相机姿态的来源。这正是演示要面向内部姿态抽象、而不是直接面向 navigator.xr 编写的全部理由 —— 桌面路径是一等路径,不是残缺的替代品。

哪些环境能跑

支持与否不是一个开关。浏览器可以完整实现 WebXR,却因为没接设备而对某个会话模式返回 false,所以判断条件永远应该是针对具体模式的 isSessionSupported(),而不是 navigator.xr 是否存在。

浏览器 / 平台immersive-vrimmersive-ar说明
Meta Quest 浏览器支持支持一体机上的事实参考实现;手部追踪作为可选特性提供。
Chrome / Edge,Android取决于设备支持(ARCore)AR 可跑在支持 ARCore 的手机上;VR 需要受支持或已连接的头显。
Chrome / Edge,Windows需运行时不支持需要 SteamVR 或其他 OpenXR 运行时,外加一台已连接的头显。
Safari,visionOS支持有限会话本身可用,但特性支持面与 Quest 浏览器不同 —— 要实测,不要想当然。
Safari,macOS / iOS不支持不支持没有 WebXR 设备 API。应当优雅降级,而不是催访客换浏览器。
Firefox,桌面不支持不支持WebVR 已被移除,WebXR 在桌面端始终未默认启用。

数据核对于 2026-09。浏览器与运行时的版本更迭会改变这张表 —— 依赖其中任何一行之前,请以 MDN 的兼容性数据为准。

最费时间的几个坑

这些都不会给出清晰的报错,这正是值得在遇到之前先知道的原因。

在手势之外申请会话
任何让瞬时激活过期的写法 —— 定时器、先 await 一个 fetch、在后续任务里才 resolve 的 promise 链 —— 都会让调用变成 SecurityError。先申请会话,资源加载放到之后。
假定一定是两个视图
pose.views 是个列表,因为规范允许别的数量。写死 [0] 和 [1],换一台光学方案不同的设备就会渲染错。
把 null 姿态当成致命错误
追踪丢失是家常便饭,通常一两帧内就恢复。重呈上一帧或者跳过即可,不要拆掉会话。
桌面循环忘了停
会话期间如果非 XR 的 requestAnimationFrame 循环还活着,每帧就会渲染两遍场景,然后你会花一个下午找帧率去哪了。
从不处理 end 事件
会话结束的原因常常和你的按钮无关:用户摘下头显、运行时收回设备、标签页被关掉。监听 'end' 并在那里恢复桌面循环,而不是写在自己的退出处理函数里。

可复现案例:申请不到地面空间时怎么办

假设你在做一个坐着也能使用的看图应用,它不必知道真实地面的位置。创建会话时把 local-floor 放在 optionalFeatures 中,然后尝试取得这个空间。取不到时降级到 local,并明确告诉场景:现在不能依赖地面高度。如果一开始把 local-floor 列为必需特性,会话可能直接失败,后面的降级逻辑根本没有执行机会。

下面用一个模拟会话记录请求顺序,主动让第一次请求失败。整段代码可以直接在浏览器控制台运行,不需要头显。接入项目时,把 requestSession 返回的 XRSession 传给 chooseSpace,替换这里的 fakeSession。

只有 NotSupportedError 会走降级。会话已经结束等其他异常仍然抛给调用者处理,避免把原始错误藏起来。requestSession 仍须在用户点击时调用;这个辅助函数是在会话已经创建后选择参考空间。

async function chooseSpace(session) {
  try {
    const space = await session.requestReferenceSpace('local-floor');
    return { space, floorKnown: true };
  } catch (error) {
    if (error.name !== 'NotSupportedError') throw error;
    const space = await session.requestReferenceSpace('local');
    return { space, floorKnown: false };
  }
}

// A synthetic runtime: local-floor fails, local succeeds.
const requested = [];
const fakeSession = {
  async requestReferenceSpace(type) {
    requested.push(type);
    if (type === 'local-floor') {
      throw Object.assign(new Error('No floor space'), {
        name: 'NotSupportedError',
      });
    }
    return { type };
  },
};
chooseSpace(fakeSession).then(result => {
  console.log(requested.join(' -> ')); // local-floor -> local
  console.log(result.floorKnown);     // false
});

完整运行后依次输出 local-floor -> local 和 false。这里验证的是应用逻辑,没有进行头显兼容性测试。

降级后应该验收什么

floorKnown 表示参考空间提供的坐标约定,不是对地面精度的测量。不要给 local 悄悄减去一个假定的 1.6 米,就当作取得了真实地面。坐着的读者、儿童和站立的成年人会看到不同的偏差。如果产品确实依赖地面,继续把 local-floor 设为必需特性,并说明设备要求,也是一种合理选择。

输入条件预期行为场景需要做的事
取得地面空间只请求一次;floorKnown 为 true用返回的空间放置依赖地面的内容。
地面空间返回 NotSupportedError继续请求 local;floorKnown 为 false使用坐姿布局,或明确提供校准步骤。
地面空间出现其他错误抛出原错误,不再请求 local显示失败原因,恢复进入按钮。
两个空间都失败抛出 local 的申请错误结束无法使用的会话,清理待处理状态。

这些结果来自模拟输入,不代表 Quest、Pico 或 visionOS 的实测记录。

延伸阅读

规范本身比它的名声好读得多,而且它是唯一一份会随实现演进保持正确的资料。