VR 控制器
控制器到你手里时是一个 XRInputSource:两个各自独立的空间、一个可有可无的 gamepad,以及一个在所有设备上行为一致的 select 事件 —— 包括那些根本没有控制器的设备。
指向射线与 select 演示
一个控制器横扫三个目标。射线的起点是 targetRaySpace 而不是手的位置 —— 打开握持标记就能看清这两者差多远,并观察命中如何触发 select 事件与一次触觉脉冲。
这个演示需要 WebGL,你的浏览器没有提供。下面的正文不依赖演示,可以单独读完。
控制器连上时到底来了什么
会话通过 session.inputSources 暴露输入,那是一个动态的 XRInputSource 列表。每一项描述一个「用户可以用来指向或操作的东西」:一个被追踪的控制器、一只手、手机屏幕上的一次点击,甚至是完全没有控制器的设备上的头部注视。把这个列表当成「那两个手柄」是第一个错误 —— 一个会话完全可能有零个、一个或三个输入源。
说它动态,是因为它真的会变:控制器会唤醒、会休眠、会在会话中途被拿起来。如果你只在会话开始时读一次 inputSources,那么晚一秒才连上的控制器你全都收不到 —— 而在一体机上,绝大多数控制器都是这么连上的。正确做法是监听 inputsourceschange,把「连接」当成事件,而不是启动时的一次性条件。
每个输入源都带一个 handedness(left / right / none),以及一个 profiles 数组,按从具体到宽泛的顺序列出硬件标识。选控制器模型要匹配 profiles —— 不要匹配产品名,更不要用你自己手写的那张列表的下标。
两个空间,它们不在同一个地方
这是「控制器看着没问题、但指向总是差一点」最常见的根源。一个输入源暴露两个姿态,它们的存在是为了回答两个不同的问题。
- targetRaySpace
- 指向射线从哪里出发、朝哪个方向。在被追踪的控制器上,它位于设备前方并带一个倾角,以匹配人自然瞄准时的习惯 —— 它被刻意设计成不等于设备原点。所有指向、拾取和 UI 交互都该用它。
- gripSpace
- 用户的手在哪里,朝向按「手里握着东西」来定义。用它挂控制器模型、剑、手电筒 —— 任何被握在手里的物体。拿它做指向,射线会从手腕穿出去。
- targetRayMode
- 射线是怎么产生的:tracked-pointer 是真实控制器;gaze 用于没有控制器的头显(射线就是头的朝向);screen 是 AR 里手机屏幕上的点击;transient-pointer 是只在捏合手势期间存在的指向。交互代码应该按它分支,而不是按「有没有 gamepad」分支。
select 才是可移植的那个动作
每个输入源都会为它的主要动作触发 select、selectstart 和 selectend。扣扳机、捏合、点屏幕、注视停留 —— 全都以这同样三个事件抵达。改成轮询 gamepad.buttons[0] 的话,在你手上那台头显能用,在你没有的那些上会安静地什么都不做。
// three.js 已经把输入源包装成位于世界坐标里的 Object3D。
// getController(i) 跟随 targetRaySpace;getControllerGrip(i) 跟随手的位置。
const controller = renderer.xr.getController(0);
const grip = renderer.xr.getControllerGrip(0);
scene.add(controller, grip);
// 射线作为 controller 的子节点,姿态自动继承,不用每帧手动同步。
controller.add(new THREE.Line(rayGeometry, rayMaterial));
controller.addEventListener('selectstart', (event) => {
// event.data 就是这个控制器背后的 XRInputSource。
const source = event.data;
// handedness 是 'left' | 'right' | 'none' —— 'none' 对注视和屏幕输入
// 是正常值,不是需要防御的异常。
if (source.handedness === 'right') { /* ... */ }
const hit = raycastFromController(controller);
if (hit) pulse(source, 0.6, 40);
});
// 控制器会在会话中途连上或断开。只在启动时读一次 session.inputSources,
// 会漏掉几乎所有一体机的控制器。
session.addEventListener('inputsourceschange', (event) => {
for (const source of event.added) attachModel(source);
for (const source of event.removed) detachModel(source);
});controller.addEventListener 之所以可行,是因为 three.js 把会话事件转发到了 controller 对象上。底层仍然是会话的 select 事件,没有任何东西在被轮询。
触觉反馈要写得足够防御
触觉执行器在每一层都是可选的:gamepad 可能不存在,hapticActuators 数组可能是空的,pulse() 也可能 reject。这些都不是异常情况,所以都不该抛错。
function pulse(source, intensity = 0.5, durationMs = 30) {
// 一路可选链:注视输入没有 gamepad,手没有执行器,
// 有些运行时还会给出一个空数组。
const actuator = source.gamepad?.hapticActuators?.[0];
if (!actuator) return;
// 发出去就不管了。在帧回调里 await 它会让循环停在脉冲时长上,
// 而一次失败的震动不值得打断整个交互。
actuator.pulse(intensity, durationMs)?.catch(() => {});
}intensity 取 0–1,duration 单位是毫秒。按键确认这类离散反馈控制在 50 ms 以内 —— 再长就不像确认,像设备出了故障。
上面那个演示在做什么
一个控制器从左到右扫过三个目标。你看到的射线起点是 targetRaySpace;打开握持标记后会多出一个标记,落在手应该在的位置 —— 两者之间的那段距离,正是「用 gripSpace 做指向会偏低偏侧」的全部原因。
射线扫过目标时,演示做的事和真实交互一致:触发等价于 select 的动作、高亮命中物、并发出一次短脉冲。桌面上没有可震动的硬件,脉冲被画成控制器上的一次闪烁;在真实会话里,同一条代码路径调用的就是 hapticActuators[0].pulse()。
各平台给到的东西
要紧的是 targetRayMode 这一列。按它写的代码在下面每一行都能跑;按「有没有 gamepad」写的代码只在第一行能跑。
| 平台 | targetRayMode | 触觉 | 说明 |
|---|---|---|---|
| Meta Quest 手柄 | tracked-pointer | 支持 | 完整 gamepad:摇杆、扳机、握持键;profiles 会给出确切型号。 |
| Quest 手部追踪 | tracked-pointer | 不支持 | 捏合会产生 select,但没有 gamepad —— 可移植的那条路是唯一的路。 |
| Android AR(手机) | screen | 不支持 | 一次点击产生一个瞬时输入源,只在触摸持续期间存在。 |
| visionOS | transient-pointer | 不支持 | 注视加捏合。输入源在捏合发生时出现,结束后消失。 |
| 桌面 + OpenXR 运行时 | tracked-pointer | 通常支持 | 取决于运行时和实际手柄;执行器一律当可选处理。 |
数据核对于 2026-09。依赖其中任何一行之前,请以 WebXR input profiles 注册表和 MDN 为准。
最费时间的几个坑
这几个的共同点是:写出来的代码在你手边那台设备上跑得好好的,错在别的设备上 —— 所以它们能活很久。
- 用 gripSpace 做指向
- 射线从手腕而不是指尖射出,并且低几度。它看起来「几乎是对的」,所以通常能活到有人试图打中房间那头一个小目标为止。
- 写死 gamepad 按键下标
- 不同控制器的按键布局不同。要匹配 profiles 与标准映射,或者干脆只用 select 和 squeeze —— 这两个对所有设备都有定义。
- 只在启动时读 inputSources
- 一体机上控制器经常在会话开始之后才连上。不处理 inputsourceschange,你的应用会以「完全没有输入」的状态启动。
- 假定 handedness 非左即右
- 'none' 对注视、屏幕和部分瞬时输入是正常值。只判断 left/right 然后默默走空分支的代码,等于把这些设备整个丢掉了。
- 在帧循环里 await pulse()
- 触觉返回的是 promise。在动画回调里等待它,渲染会停满整个脉冲时长。发出去就别管。
延伸阅读
input profiles 注册表是大多数人不知道存在的那一块,而它正是让控制器模型能在你从没测过的硬件上正常显示的原因。
- MDN — XRInputSource — 两个空间、handedness、profiles 与 gamepad 关联的参考文档。
- WebXR Input Profiles 注册表 — 从 profiles 字符串到控制器模型与按键布局的映射。
- W3C — WebXR Gamepads Module — gamepad 状态与触觉执行器如何暴露给 WebXR。
- WebXR 会话 — inputSources 从哪里来,以及这些姿态是相对哪个参考空间解算的。
- 手部追踪 — 当手里根本没有控制器时,同样的事件模型如何工作。