移动与舒适度
在 WebXR 里你永远不移动相机。你把参考空间换成它自己的一份偏移副本 —— 而这件事是瞬间完成还是逐渐完成,决定了这个应用是舒适还是催吐。
传送、平滑移动,以及会动的那个原点
地面上那个橙色圆环是参考空间的原点;头显是它的子节点,偏移量由追踪系统给出。切换传送与平滑移动,看清楚动的是什么 —— 是原点,永远不是头相对它的那段偏移。
这个演示需要 WebGL,你的浏览器没有提供。下面的正文独立成篇,不看演示也能读完。
相机不归你管
在一个 XR 会话里,相机位姿每帧都由运行时写入,来源是某个人头上那台头显的真实位置。你赋给 camera.position 的任何值,在绘制之前就被覆盖掉了;具体到 three.js,会话一旦开始呈现,相机就完全归 renderer.xr 所有。移动相机的代码在桌面预览里完美运行,在头显里什么也不会发生。
真正要移动的是参考空间 —— 头部位姿相对于报告的那套坐标系。把它挪走,头就带着自己被追踪出来的那段偏移,整个游玩区落到新的地方。这也是为什么移动的 API 是 XRReferenceSpace 上的一个方法,而不是任何长得像相机的东西上的方法。
这个方法叫 getOffsetReferenceSpace,而让人意外的一点是它不修改任何东西:它返回一个新的参考空间。你传进去的变换描述的是**原点**移动到哪里,所以符号跟「把玩家往前移一米」的直觉相反 —— 把玩家往前移一米,等于把原点相对他往后移一米。
传送,以及转向
每一步移动都从当前空间派生出一个新空间并保存下来。旧空间不会失效,但你不再使用它 —— 你传给 getViewerPose 的那个空间,定义了玩家在哪。
// 这一帧所有位姿都相对它解析。移动就是把它换掉。
let referenceSpace = await session.requestReferenceSpace('local-floor');
function teleportTo(x, z) {
// 变换移动的是「原点」,所以要把玩家放到 (x, z),原点得去 (-x, -z)。
// 把这个符号写反,是第一版传送实现里最常见的 bug。
const offset = new XRRigidTransform({ x: -x, y: 0, z: -z });
referenceSpace = referenceSpace.getOffsetReferenceSpace(offset);
}
function snapTurn(degrees) {
const half = (degrees * Math.PI) / 360; // 半角,弧度
// 绕 Y 轴的四元数。要绕玩家的头部位置转,不是绕原点转,
// 否则一次转向会把人顺带甩到旁边去。
const orientation = { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) };
referenceSpace = referenceSpace.getOffsetReferenceSpace(
new XRRigidTransform({ x: 0, y: 0, z: 0 }, orientation),
);
}
function onFrame(time, frame) {
// 永远用「当前」的空间 —— 会话开始时抓的那份旧引用,
// 会静默地忽略此后发生过的每一次传送。
const pose = frame.getViewerPose(referenceSpace);
if (!pose) return;
render(pose);
}在 three.js 里同一件事写作 renderer.xr.setReferenceSpace(space),更常见的做法是把相机装进一个 Group 再移动这个 Group —— 那是同一个想法在场景图里的表达,而不是在 WebXR API 里的表达。
为什么平滑移动会让人恶心
VR 里的晕动症是一种感官冲突。眼睛报告你正在加速穿过房间,内耳报告你站着没动。大脑处理这种分歧的方式,和处理中毒是同一套 —— 所以症状是恶心,而不是困惑。
自我运动幻觉(vection)有多强,决定了症状有多重;而它主要由周边视野里的光流驱动。这就给出了所有缓解手段的形状:要么削弱周边光流,要么干脆取消连续运动。这也解释了为什么同一个应用在空荡荡的灰房间里没事,加上几根柱子就难受了 —— 静止参照物从视野边缘流过,正是产生 vection 的原因。
旋转比平移更糟,而偏航(yaw)是最糟的那个轴。所以即便是已经用传送的应用,也仍然把快速转向单列为一项舒适度选项:连续转向会在整个视野里产生强光流,而头部没有任何与之匹配的运动。
加速度比匀速更糟。如果你确实提供平滑移动,请尽快加到全速而不是缓入 —— 一段温柔的加速曲线看起来体贴,实测下来更难受。
值得提供的几项舒适度选项
这些都不新奇。已上线的 VR 应用大致收敛到了这一套,用户也已经学会去找它们。
- 传送
- 瞬间位移,没有中间帧。没有连续光流就没有 vection,也就基本不会晕。代价是空间连续性被打断,通常用一次短暂的黑场淡入淡出来缓和。
- 快速转向(snap turn)
- 按离散档位旋转,常见 30° 或 45°。跟传送同一个原理,只是用在偏航上。档位要可配置:45° 操作次数少,30° 更容易保持方向感。
- 舒适度晕影
- 移动期间压暗周边视野。它拿掉的正是驱动 vection 的那部分视野,中心保持清晰。要用几帧淡入 —— 一个突然弹出的晕影本身就是干扰。
- 静止参考框架
- 一个座舱、一个鼻梁参照、一层固定在头上的栅格。任何不随世界移动的东西,都能给大脑一个可信的锚,实测能明显减轻冲突。
- 站姿与坐姿
- 坐着的用户没法侧身让开,所以任何移动他们的操作都更难受。尊重你拿到的参考空间:bounded-floor 意味着房间尺度,local 意味着人可能坐着。
上面这个演示在做什么
橙色圆环是参考空间的原点,从它引出的那根短线是原点的朝向。头显固定地悬在它上方偏一侧,并且像真实追踪的头那样轻微漂移。每一次移动动的都是这个圆环;头相对圆环的偏移从不改变,因为那段偏移就是追踪数据,不归你改写。
传送模式下,会从手柄画一条弧到落点,停留时间一到,原点直接跳过去 —— 没有中间帧。切到平滑模式,原点改为按你设定的速度滑行,移动期间面罩前会画出舒适度晕影。
最值得拨一拨的是快速转向那个控件。设成 0 时原点连续旋转,这正是最容易让真实用户难受的那种运动;设成 30° 或 45°,同一次转向变成几次离散跳跃,中间角度根本不会被渲染 —— 整个机制就是这么回事,而亲眼看同一次转向以两种方式发生一遍,比读一段文字好懂得多。
怎么选移动方案
没有唯一正确的答案,但有一个明确错误的:只提供平滑移动,且不带任何舒适度选项。
| 方案 | 舒适度 | 精度 | 适合 |
|---|---|---|---|
| 传送 + 快速转向 | 高 | 粗 | 大多数应用。安全的默认值,也是用户预期能找到的。 |
| 平滑 + 晕影 + 快速转向 | 中 | 细 | 连续移动本身就是玩法的游戏;永远同时提供传送选项。 |
| 平滑,无任何缓解 | 低 | 细 | 带强静止参考框架的坐姿座舱体验,除此之外基本没有。 |
| 纯房间尺度(不做移动) | 最高 | 物理 | 任何塞得进 bounded-floor 游玩区的内容。真的用腿走,什么都比不过。 |
| 抓取式拖动世界 | 高 | 细 | 缩微模型与检视类应用;用户拖动世界,而不是穿过世界。 |
舒适度评级反映的是截至 2026-09 已上线 VR 作品的普遍共识,不是对照实验结论。个体敏感度差异极大 —— 永远把选择权交给用户。
最费时间的几个坑
前两个的共同点是:在桌面显示器上看起来完全正确,一戴上头显就是坏的。
- 直接改 camera.position
- 运行时每帧都会用真实头部位姿覆盖它。预览里好用,头显里毫无反应,然后浪费掉一个下午。
- 继续用最初那个参考空间
- getOffsetReferenceSpace 返回的是新空间。继续把旧的传给 getViewerPose,每一次传送都会被静默丢弃。
- 偏移符号写反
- 变换移动的是原点,不是玩家。把玩家往前移,等于把原点往后移 —— 符号反了,上面那个演示会朝相反方向跳。
- 绕原点转而不是绕头转
- 绕游玩区原点做的快速转向,会顺带让玩家沿一段弧线甩出去。要绕他当前的头部位置转。
- 给平滑移动加缓入
- 加速度比速度更能催晕。缓慢起步显得客气,实际比直接切到全速更糟。
- 只提供一种移动方案
- 个体敏感度的差异比你能做的任何调参都大。舒适度设置是无障碍功能,不是偏好设置。
延伸阅读
建议先读参考空间那篇 —— 只有当原点变成一个你能想象出来的东西,移动才讲得通。
- MDN — XRReferenceSpace.getOffsetReferenceSpace() — 偏移变换的确切语义,符号写得很明确。
- MDN — Movement, orientation, and motion in WebXR — 一个更长的完整示例:如何用参考空间偏移驱动玩家移动。
- W3C — WebXR Device API — 参考空间与刚体变换的规范性定义。
- WebXR 会话 — 参考空间从哪来,以及 local-floor 与 bounded-floor 各保证了什么。
- VR 手柄输入 — 输入这一侧:摇杆、select,以及一条传送弧究竟从哪里发出。