WebXR 会话

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

会话生命周期演示

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

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

正在加载演示…

会话到底是什么

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

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

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

申请一个会话

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

// 1. 能力探测。不支持 WebXR 的浏览器里 navigator.xr 是 undefined,
//    而在非安全上下文(非 HTTPS 且非 localhost)里 isSessionSupported() 会 reject。
const xr = navigator.xr;
const supported = xr ? await xr.isSessionSupported('immersive-vr') : 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' 并在那里恢复桌面循环,而不是写在自己的退出处理函数里。

延伸阅读

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