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不支持一次点击产生一个瞬时输入源,只在触摸持续期间存在。
visionOStransient-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 注册表是大多数人不知道存在的那一块,而它正是让控制器模型能在你从没测过的硬件上正常显示的原因。