手部追踪
一只被追踪的手以 XRHand 的形式到达代码里:25 个具名关节,每个都有自己的位姿、朝向和半径 —— 而且每一个都允许在任何一帧缺席。
手部骨架与捏合阈值
WebXR 手部模型的 25 个关节,正在做一次捏合。距离是每帧从两个指尖关节的世界坐标量出来的,跟真实代码里的做法一样 —— 拖动阈值,看看手势的哪一段会被算作捏合。
这个演示需要 WebGL,你的浏览器没有提供。下面的正文独立成篇,不看演示也能读完。
手不是另一套 API,它就是输入源
被追踪的手并不走什么并行通道。它跟别的输入一样是一个 XRInputSource,只是在 hand-tracking 特性被授予时多带一个属性:inputSource.hand,类型是 XRHand。你已经写好的 inputSources、handedness、select 那一整套照常有效 —— 一次捏合触发 select,和扣一下扳机没有区别。
这件事比听上去重要。处理手最可移植的方式,通常是**根本不特殊处理**:交互交给 select 驱动,只在真的需要骨架时才去取关节位姿。那些在输入代码开头就先判断「这是不是手」的应用,最后都会维护两套逐渐跑偏的交互系统。
手部追踪是可选特性,按名字申请。除非你的应用离开它就完全没用,否则请放进 optionalFeatures 而不是 requiredFeatures —— 设备无法授予的必需特性会让整个 requestSession 直接失败,而大多数头显都允许用户在系统设置里关掉手部追踪。
申请手部追踪并读取一个关节
关节位姿只能在帧回调里拿到,而且必须相对某个参考空间。循环之外没有办法问「食指指尖现在在哪」,因为在循环之外这个问题没有确定答案。
const session = await navigator.xr.requestSession('immersive-vr', {
// 用可选而不是必需:用户可以在系统设置里关掉手部追踪,
// 而一个无法授予的必需特性会让整个会话请求失败。
optionalFeatures: ['hand-tracking'],
});
function onFrame(time, frame) {
for (const source of session.inputSources) {
// 没有 hand 属性说明这是手柄,或者特性没被授予。两种都正常。
if (!source.hand) continue;
const indexTip = source.hand.get('index-finger-tip');
const thumbTip = source.hand.get('thumb-tip');
if (!indexTip || !thumbTip) continue;
// getJointPose 在**任何**一帧都可能返回 null:遮挡、手移出追踪范围,
// 或者运行时单纯对这一帧没把握。
const a = frame.getJointPose(indexTip, referenceSpace);
const b = frame.getJointPose(thumbTip, referenceSpace);
if (!a || !b) continue;
const dx = a.transform.position.x - b.transform.position.x;
const dy = a.transform.position.y - b.transform.position.y;
const dz = a.transform.position.z - b.transform.position.z;
const distance = Math.hypot(dx, dy, dz);
// a.radius 是运行时估计的关节粗细(米)。用它来缩放阈值,
// 大手小手就都能自动适配,不需要一个标定页面。
const threshold = (a.radius + b.radius) * 1.4;
if (distance < threshold) onPinch(source);
}
}注意那两处判空是分开的。get() 对运行时根本不暴露的关节返回 undefined;getJointPose 对这一帧定位不到的关节返回 null。把两者混为一谈的代码,会在两只手第一次交叠时崩掉。
25 个关节,以及它们的命名
关节名是固定词表里的字符串。每只手 25 个:手腕一个,拇指四个,其余四指各五个。
- wrist
- 唯一的根关节。概念上其他关节都在它下游,不过 API 是把每个位姿单独给你,并不返回一棵树。
- thumb-metacarpal … thumb-tip
- 四个关节:掌骨、近节指骨、远节指骨、指尖。拇指是那个例外 —— 它没有中节指骨,所以按「每指五个关节」写的循环会在它这里出错。
- {index,middle,ring,pinky}-finger-metacarpal … -tip
- 各五个:掌骨、近节、中节、远节、指尖。掌骨那个藏在手掌里,拿来判断朝向有用,拿来判断接触几乎从来没用。
- XRJointPose.radius
- 每个关节的粗细估计值,单位米,也是整套 API 里最常被忽略的一项。想写出一个对小孩和成年人都成立、又不需要标定界面的捏合阈值,靠的就是它。
为什么捏合判定不是算个距离
最直觉的写法 —— 拇指尖与食指尖的距离小于某个常数 —— 在你自己手上好用,在别人手上就不对。有三件事会出问题。人手的尺寸差异比阈值本身还大,按大手调出来的值在小手上永远不触发。追踪噪声会让距离在边界上抖动,于是你收到的不是一次捏合,而是一连串捏合与松开。还有,手一旦被遮挡,位姿就不再送达,一次保持中的捏合会悄无声息地结束。
解法都不大。别写死米数,用关节半径去缩放阈值。加滞回:触发捏合用一个更紧的距离,解除捏合用一个更松的,手势就抖不起来。再把「拿不到位姿」当成「状态保持几帧不变」而不是「已松开」—— 手一歪捏合就掉,是手势界面被抱怨最多的一件事。
更好的做法是:当交互本身只是「用户选中了什么」时,压根别自己检测捏合。运行时已经在做,而且做得比你好,结果就叫 select。自己写手势检测,只有在检测运行时不提供的手势时才值回成本。
上面这个演示在做什么
骨架是按真实的关节布局搭的 —— 你数得出 25 个,而且拇指确实比其他手指少一节。拇指与食指相向弯曲,另外三指保持一个松弛的静息姿态,这大致就是一次真实捏合在追踪器眼里的样子。
驱动高亮的那个距离,是每帧在世界坐标下从两个指尖关节量出来的,没有烘进动画里。所以拖动阈值改变的是结果而不只是一个标签:拉到 5 毫米,几乎没有哪一刻算得上捏合;拉到 6 厘米,手指明明还分得很开就已经「检测到」了 —— 而这正是阈值定得太松时在真机上的表现。
打开关节半径,会把每个关节的粗细估计画成一颗半透明的球。两颗球相碰时手指才真的接触了 —— 这个定义比你能挑出的任何常数都稳。
手部追踪实际能用在哪
支持是真的,但并不整齐,而且在大多数平台上都是用户可以自己关掉的开关。按「它就是关着的」来设计。
| 平台 | 手部追踪 | 关节半径 | 说明 |
|---|---|---|---|
| Meta Quest 2 / 3 / Pro | 支持 | 有 | 系统设置里可关;放下手柄时可能自动切换。 |
| visionOS Safari | 部分 | 有 | 主输入是注视加捏合;完整骨架需要额外授权。 |
| Pico 4 | 支持 | 有 | 行为与 Quest 接近,包括手柄/手势的自动切换。 |
| Android AR(手机) | 不支持 | — | 只有屏幕输入,inputSource.hand 恒为 undefined。 |
| 桌面 OpenXR 运行时 | 视情况 | 视情况 | 完全取决于运行时与所接硬件;默认按不支持处理。 |
数据核对于 2026-09。支持情况会随固件变化,依赖任何一行之前请去 WebXR 手部输入规范与 caniuse 复核。
最费时间的几个坑
这几个的共同点是:都默认手一直在,而且尺寸永远一样。
- 把 hand-tracking 写进 requiredFeatures
- 结果是:只要用户把手部追踪关掉,requestSession 就直接 reject。用可选特性申请,拿不到就降级。
- 默认 getJointPose 一定非空
- 只要追踪丢失它就返回 null —— 一只手挡住另一只、手到了摄像头视野边缘、或者动得太快。这是常态,不是偶发。
- 写死捏合距离
- 人手尺寸的差异比阈值本身还大。用关节半径去缩放,同一份代码就能适配所有人。
- 对每根手指都按五个关节遍历
- 拇指只有四个。按固定五元数组索引的代码会越界,要么抛错,要么默默用错关节。
- 自己把捏合重新实现成 select
- 运行时已经为捏合触发 select,还带着自己的调参和滞回。手写的那版更差,而且对手柄不生效。
延伸阅读
第一次写这类代码时,建议把关节名词表开在旁边 —— 名字都很长,而拼错的后果是静默返回 undefined,不会抛错。
- W3C — WebXR Hand Input Module — 规范性的关节清单、XRHand 与 radius 的语义。
- MDN — XRHand — get() 与关节名字符串的实用参考。
- MDN — XRFrame.getJointPose() — 返回的位姿包含什么,以及什么时候是 null。
- VR 手柄输入 — 手所接入的那套输入源模型,以及为什么 select 是可移植的动作。
- WebXR 会话 — 如何申请可选特性,以及关节位姿相对哪个参考空间解析。