跳转至

WebXR 技术栈深度解析 — truth-lab Pico Ultra 适配实战

目录

  1. 方案演进全景
  2. 技术层次总览
  3. WebGL:底层画布
  4. Three.js:WebGL 的渲染框架
  5. React Three Fiber:React 化 Three.js
  6. Xr 库:打通 R3F 与 WebXR
  7. 技术关系图谱
  8. Pico Ultra 兼容性专题
  9. 常见误区与教训

1. 方案演进全景

truth-lab 进入 VR 的功能经历了 6 轮方案迭代,每轮都换了一条技术路径。

方案 A:@react-three/xr v6(原始实现)

Canvas → <XR store={xrStore}> → <VRButton>
  • 原理@react-three/xr v6 基于 @pmndrs/xr 内核,提供完整的 XR 管理(session 创建、控制器、手柄、交互)
  • 结果:首次能进 VR(用户确认一次),但手柄无法操控小球
  • 问题
  • @pmndrs/xr 内部 buildXRSessionInit() 硬编码 requiredFeatures: ['local-floor']
  • Pico Ultra 不支持 'local-floor' 作为必需特性
  • 尝试用 customSessionInit 覆盖 → 但 setReferenceSpaceType 仍被库覆盖为 'local-floor'

方案 B:原生 WebXR + R3F(中间尝试)

Canvas → 无 <XR> → 手动 requestSession + setSession
  • 原理:跳过 XR 库,直接用浏览器 WebXR API
  • 结果:多次报错、黑屏
  • 问题:R3F v9.6.1 内部有 XR 支持(handleXRFrame),但 referenceSpaceType 默认值 'local-floor'setSession 内部被读取,猴子补丁时序不可靠

方案 C:@react-three/xr + customSessionInit + 猴子补丁

Canvas → <XR store={xrStore}> → 用 customSessionInit 清空 requiredFeatures
                                    → 猴子补丁 setReferenceSpaceType
  • 结果:session 创建成功,但渲染全黑
  • 问题@react-three/xr 添加的控制器、手柄、输入源管理等可能干扰基本渲染

方案 D:最终方案 — 纯 R3F + 原生 WebXR

Canvas → 无任何 XR 库 → 猴子补丁 + 紧邻 setSession 前设 'local'
  • 原理
  • 不加载任何 XR 库(@react-three/xr / @pmndrs/xr
  • R3F v9.6.1 内置 handleXRFrame 自动接管 XR 渲染循环
  • navigator.xr.requestSession('immersive-vr') 无参数
  • gl.xr.setReferenceSpaceType('local') 紧贴 setSession() 之前
  • 猴子补丁作为安全网
  • 结果:✅ 成功

2. 技术层次总览

┌─────────────────────────────────────────────┐
│                 应用层                       │
│  truth-lab (React + Zustand + TypeScript)   │
├─────────────────────────────────────────────┤
│              React 3D 框架层                │
│  @react-three/fiber (R3F)                   │
│  @react-three/drei (工具组件)               │
├─────────────────────────────────────────────┤
│        Three.js 3D 引擎层                   │
│  WebGLRenderer / WebXRManager / Scene       │
├─────────────────────────────────────────────┤
│  浏览器 Web API 层                          │
│  WebXR Device API │ WebGL API              │
├─────────────────────────────────────────────┤
│        GPU (WebGL2 / Vulkan)               │
└─────────────────────────────────────────────┘

3. WebGL:底层画布

是什么

WebGL(Web Graphics Library)是浏览器中访问 GPU 的 JavaScript API。本质上是 OpenGL ES 的浏览器绑定

渲染管线

顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器 → 输出颜色

关键概念

概念 说明
WebGLRenderingContext 渲染上下文,通过 canvas.getContext('webgl') 获得
xrCompatible 上下文属性,标记此上下文可用于 XR 渲染
makeXRCompatible() 将现有 WebGL 上下文切换到 XR 兼容模式(可能重建上下文!
Shader (着色器) GPU 上运行的小程序(顶点/片段)
Framebuffer (帧缓冲) 渲染目标,可以是屏幕或纹理

与 XR 的关系

当进入 VR 时,Three.js 会: 1. 检查 gl.getContextAttributes().xrCompatible 是否为 true 2. 如果不是,调用 gl.makeXRCompatible()这可能导致 WebGL 上下文丢失并重建 3. 创建 XRWebGLLayerXRProjectionLayer,绑定到 XR session 4. XR 帧渲染使用 XR 的帧缓冲而不是默认 canvas

这就是猴子补丁方案没解决的问题makeXRCompatible() 即使成功,也可能重置上下文状态。


4. Three.js:WebGL 的渲染框架

是什么

Three.js 是 WebGL 的**高层封装**,提供场景图、相机、材质、光照、几何体等抽象。

核心类

职责
WebGLRenderer 管理 WebGL 上下文,驱动渲染循环
Scene 场景图根节点,包含所有 3D 对象
PerspectiveCamera 透视相机,定义视锥体
WebXRManager 与管理 XR session 的核心

WebXRManager 内部机制

WebGLRenderer 内部持有一个 WebXRManager 实例(renderer.xr)。

class WebXRManager {
  let referenceSpaceType = 'local-floor';  // 默认!重要!
  let session = null;
  const animation = new WebGLAnimation();

  this.setReferenceSpaceType = function(type) {
    referenceSpaceType = type;  // 这是一个闭包变量
  };

  this.setSession = async function(value) {
    session = value;
    // 1. 可选: makeXRCompatible()
    // 2. 设置渲染目标 (XRWebGLLayer 或 XRProjectionLayer)
    // 3. 请求参考空间:
    referenceSpace = await session.requestReferenceSpace(referenceSpaceType);
    // 4. 启动 XR 动画循环
    animation.setContext(session);
    animation.start();
    // 5. 标记 presenting 状态
    scope.isPresenting = true;
    scope.dispatchEvent({ type: 'sessionstart' });
  };
}

关键设计referenceSpaceType 是一个闭包变量,只能通过 setReferenceSpaceType() 修改。这就是为什么猴子补丁能工作的原因——我们拦截了方法调用。

setAnimationLoop 的陷阱

1
2
3
4
5
6
// WebGLRenderer.setAnimationLoop
this.setAnimationLoop = function(callback) {
  onAnimationFrameCallback = callback;
  xr.setAnimationLoop(callback);     // 同步设置 XR 回调
  (callback === null) ? animation.stop() : animation.start();
};

R3F 不调用 renderer.setAnimationLoop(),它用自己的 RAF 循环。XR 模式下,R3F 通过 gl.xr.setAnimationLoop(handleXRFrame) 设置回调。这调的是 WebXRManager.setAnimationLoop,而非 WebGLRenderer 的。

WebGLAnimation(XR 的驱动)

function WebGLAnimation() {
  return {
    start() {
      context.requestAnimationFrame(onAnimationFrame);  // XR 设备的 RAF
    },
    setAnimationLoop(callback) {
      animationLoop = callback;
    }
  };
}

function onAnimationFrame(time, frame) {
  // 1. 获取 pose
  pose = frame.getViewerPose(referenceSpace);
  // 2. 设置 XR 帧缓冲
  renderer.setRenderTarget(newRenderTarget);
  // 3. 更新左右眼相机
  // 4. 调用用户回调
  if (onAnimationFrameCallback) onAnimationFrameCallback(time, frame);
  // 5. 继续下一帧
  context.requestAnimationFrame(onAnimationFrame);
}

5. React Three Fiber:React 化 Three.js

是什么

R3F 是一个 React Reconciler,将 Three.js 的声明式转换为 React 组件树。你用 JSX 写 Three.js:

1
2
3
4
5
6
<Canvas>
  <mesh>
    <boxGeometry />
    <meshStandardMaterial color="red" />
  </mesh>
</Canvas>

渲染循环

R3F 不用 Three.js 的 setAnimationLoop()。它有自己的 RAF 循环:

function loop(timestamp) {
  frame = requestAnimationFrame(loop);    // 浏览器 RAF
  for (const root of _roots.values()) {
    state = root.store.getState();
    if (state.internal.active 
        && state.frameloop === 'always' 
        && !state.gl.xr?.isPresenting) {  // XR 活跃时跳过
      update(timestamp, state);
    }
  }
}

内置 XR 支持(关键!)

R3F v9.6.1 已经内置了 XR 支持,不需要任何额外库:

// 在 configure() 中,一次性设置
if (!state.xr) {
  const handleXRFrame = (timestamp, frame) => {
    advance(timestamp, true, store.getState(), frame);
  };

  const handleSessionChange = () => {
    const state = store.getState();
    state.gl.xr.enabled = state.gl.xr.isPresenting;
    state.gl.xr.setAnimationLoop(state.gl.xr.isPresenting ? handleXRFrame : null);
  };

  gl.xr.addEventListener('sessionstart', handleSessionChange);
  gl.xr.addEventListener('sessionend', handleSessionChange);
}

这就是为什么最终方案不需要任何 XR 库——R3F 已经做好了。

R3F vs Three.js

Three.js R3F
渲染循环 renderer.setAnimationLoop() 自己的 RAF loop()
场景管理 手动物件添加/移除 React 声明式
XR 支持 WebXRManager(原始 API) 内置 handleXRFrame + session 事件
状态管理 Zustand store
适用场景 任意 JS 项目 React 项目

6. XR 库:打通 R3F 与 WebXR

@react-three/xr v6

定位:R3F 的 XR 官方扩展库。

功能: - 提供 <XR> 组件包裹场景 - 提供 VRButton / ARButton / XRButton 进入 XR - 提供 useController() / useHand() / useXR() hooks - 封装控制器(手柄)建模和视觉 - 支持手部追踪、Teleport、DOM Overlay 等高级功能

内部架构

1
2
3
4
5
6
7
8
@react-three/xr
  └── @pmndrs/xr (核心引擎)
        ├── store.ts — XRStore (基于 Zustand)
        ├── init.ts — buildXRSessionInit (session 配置)
        ├── controller/ — 控制器建模
        ├── hand/ — 手部追踪
        ├── pointer/ — 射线交互
        └── space.ts — 参考空间

@pmndrs/xr 核心分析

store.ts 关键代码

// 创建 XR Store
function createXRStore(options) {
  const store = createStore(...);

  return {
    setWebXRManager(manager) {
      // 连接 Three.js 的 WebXRManager
      xrManager = manager;
      xrManager.setReferenceSpaceType(
        options.bounded ? 'bounded-floor' : 'local-floor'  // ← 硬编码!
      );
      xrManager.addEventListener('sessionstart', onSessionStart);
    },

    async enterVR() {
      return enterXRSession(domOverlayRoot, 'immersive-vr', options, manager);
    }
  };
}

async function enterXRSession(domOverlayRoot, mode, options, manager) {
  const init = buildXRSessionInit(mode, domOverlayRoot, options);
  const session = await navigator.xr.requestSession(mode, init);
  await setupXRManager(manager, session, options);
}

async function setupXRManager(xr, session, options) {
  await xr.setSession(session);  // THREE.js 的 setSession
}

init.js 关键代码

export function buildXRSessionInit(mode, domOverlayRoot, options) {
  if (options.customSessionInit) return options.customSessionInit;  // 覆盖点

  const requiredFeatures = 
    options.bounded == null ? ['local-floor'] :    // ← 默认路径
    options.bounded ? ['bounded-floor'] : 
    ['unbounded', 'local-floor'];

  return { requiredFeatures, optionalFeatures };
}

问题根源: - store.js 第 168 行:xrManager.setReferenceSpaceType(bounded ? 'bounded-floor' : 'local-floor') - init.js 第 7 行:const requiredFeatures = ['local-floor'] - 没有提供 'local' 作为选项 - bounded 参数只能选 undefined / true / false,都不对应 'local'

为什么不使用 react-three/xr

方面 react-three/xr 纯 R3F + 原生
依赖体积 4.3 MB vendor 2.0 MB vendor
控制器建模 自动添加 无(暂不需要)
手部追踪 支持
配置灵活性 受限于库的选项 完全控制
Pico 兼容性 硬编码 'local-floor' 可自定义
维护复杂 依赖版本更新 自己控制

结论:对于基本 VR 渲染需求,R3F 内置支持就够了@react-three/xr 在需要手柄交互、手部追踪、Teleport 等高级功能时才有必要。


7. 技术关系图谱

启动渲染的调用链

桌面模式

浏览器 RAF → R3F loop() → update() → gl.render(scene, camera)

XR 模式(最终方案):

1
2
3
4
5
6
7
8
9
Pico GPU → XR 设备 RAF → WebXRManager.onAnimationFrame
  → 设置帧缓冲 + 更新 XR 相机
  → onAnimationFrameCallback (handleXRFrame)
    → R3F advance()
      → update(timestamp, state, frame)
        → 执行 useFrame 回调
        → gl.render(state.scene, state.camera)
          → THREE 内部: 检测 xr.isPresenting, 使用 XR 相机
  → 提交帧到 XR 设备

各层分工

负责 不负责
WebGL GPU 绘制、着色器、纹理 场景管理、渲染循环
Three.js 场景图、相机、材质、WebXRManager React 集成
R3F React 声明式、渲染循环、useFrame XR session 管理
XR 库 XR session、控制器、交互 基础渲染

数据流

用户点击"进入 VR"
navigator.xr.requestSession('immersive-vr')
  ↓ 返回 XRSession
gl.xr.setReferenceSpaceType('local')  // monkey-patched
gl.xr.setSession(session)
  ↓ THREE 内部
session.requestReferenceSpace('local')  // 成功!
gl.xr.enabled = true
scope.isPresenting = true
dispatchEvent('sessionstart')
  ↓ R3F 监听
handleSessionChange()
  → gl.xr.setAnimationLoop(handleXRFrame)
XR 帧循环开始
  → handleXRFrame → advance → update → gl.render

8. Pico Ultra 兼容性专题

local-floor vs local

类型 含义 适用
viewer 以头显当前位置为原点 基础 AR
local 以用户初始位置为原点,Y 向上 Pico Ultra 支持
local-floor 以用户脚下地面为原点,Y 向上 PC VR、Quest(Pico 不支持!)
bounded-floor 有边界的 local-floor 房间规模 VR
unbounded 无边界的 AR AR

Pico Ultra 使用 local 而不支持 local-floor,意味着参考空间原点在用户**眼睛高度**而非地面。场景物体需要放在用户面前(负 Z)和眼睛高度以下(负 Y)。

关键适配点

  1. navigator.xr.requestSession() 不能带 requiredFeatures: ['local-floor']
  2. gl.xr.setReferenceSpaceType() 必须设为 'local'
  3. **场景位置**需要偏移到用户面前([-5, 2.8, -3] 经验值)
  4. makeXRCompatible() 在 Pico 上可能不是问题(GPU 统一)

9. 常见误区与教训

误区 1:"R3F 需要 react-three/xr 才能做 XR"

事实:R3F v9+ 内置 handleXRFrame 和 session 事件监听。@react-three/xr 是为了高级交互功能(手柄、手部追踪),不是基础 XR 渲染的前提。

误区 2:"gl.xr.setReferenceSpaceType('local-floor') 是必需的"

事实:这行代码实际上是**多余的**——THREE 默认为 'local-floor'。用 'local' 在 Pico 上更兼容。

误区 3:"alert() 能显示 XR 错误"

事实:VR 浏览器中 alert() 通常被屏蔽或不可见。应使用页面内嵌错误提示。

误区 4:"猴子补丁在任何时机设置都行"

事实onCreated 中设 setReferenceSpaceType('local') 可能在 R3F 后续初始化中被覆盖。紧挨着 setSession 之前设置 才可靠。

误区 5:"gl.xr.setSession()navigator.xr.requestSession() 是独立的"

事实setSession 内部调用 session.requestReferenceSpace(),使用当前 referenceSpaceTyperequestSession 只是创建 session,setSession 才将其绑定到渲染器。


附录:关键源码位置(three.js r174)

文件 关键行
src/renderers/WebGLRenderer.js renderer.setAnimationLoop() (~1117)
src/renderers/WebGLRenderer.js onXRSessionStart() (~1108)
src/renderers/webxr/WebXRManager.js L28 let referenceSpaceType = 'local-floor'
src/renderers/webxr/WebXRManager.js L252 this.setSession()
src/renderers/webxr/WebXRManager.js L363 session.requestReferenceSpace(referenceSpaceType)
src/renderers/webxr/WebXRManager.js L702 onAnimationFrame() — XR 帧回调
src/renderers/webxr/WebXRManager.js L826 onAnimationFrameCallback(time, frame)
src/renderers/webgl/WebGLAnimation.js WebGLAnimation

R3F v9.6.1dist/events-b389eeca.esm.js):

内容
~15737 handleSessionChange() — XR 事件处理
~15741 gl.xr.setAnimationLoop(handleXRFrame)
~16072 loop() — 主 RAF 循环
~16086 !state.gl.xr.isPresenting — XR 活跃时跳过
~16041 update() — 渲染一帧