手部追踪
一只被追踪的手以 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;
setPinchPreview(source, distance < threshold);
}
}这段代码每帧更新预览,setPinchPreview 是应用提供的界面回调,不是点击处理器;动作事件请参考下方状态示例。注意这两处判断是分开的。get() 只在你传的键不是关节名时才返回 undefined —— XRHand 永远装着全部 25 个关节。getJointPose 返回 null 才是追踪信号,而且规范规定它对一只手是「全有或全无」:手被部分遮住时,运行时要么把看不见的关节模拟出来,要么所有关节一起报 null。你不会拿到一只「缺了拇指」的手,你拿到的是一只完整的手,只是其中几个指尖是猜的 —— 在相信一个「另一只手挡着的时候检测到的捏合」之前,值得记住这一点。
25 个关节,以及它们的命名
关节名是固定词表里的字符串。每只手 25 个:手腕一个,拇指四个,其余四指各五个。
- wrist
- 唯一的根关节。概念上其他关节都在它下游,不过 API 是把每个位姿单独给你,并不返回一棵树。
- thumb-metacarpal … thumb-tip
- 四个关节:掌骨、近节指骨、远节指骨、指尖。拇指是那个例外 —— 它没有中节指骨,所以按「每指五个关节」写的循环会在它这里出错。
- {index,middle,ring,pinky}-finger-metacarpal … -tip
- 各五个:掌骨、近节、中节、远节、指尖。掌骨那个藏在手掌里,拿来判断朝向有用,拿来判断接触几乎从来没用。
- XRJointPose.radius
- 每个关节的粗细估计值,单位米,也是整套 API 里最常被忽略的一项。它可以帮助选择阈值尺度,但可能由运行时模拟,仍需在不同用户和设备上验证。
为什么捏合判定需要保存状态
阈值比较只给出每一帧的真假值。交互需要处理开始、保持、松开,以及追踪丢失后的取消。用户试图保持不动时,边界附近的噪声也可能让单一阈值反复切换。
触发时用较小距离,解除时用较大距离。位姿缺失时要有明确的取消规则:短暂宽限可以跨过一次追踪丢失,但不能让旧的保持状态无限延续。关节半径能帮助选取尺度,却可能是运行时模拟的值,不能证明指尖物理接触,也不能保证同一个阈值适合所有手。
普通选择优先使用 select。只有交互需要运行时没有提供的手势时,才考虑自定义检测。下方可运行的轨迹把事件和超时逻辑单独展示,便于与上方读取位姿的片段分别检查。
上面这个演示在做什么
骨架是按真实的关节布局搭的 —— 你数得出 25 个,而且拇指确实比其他手指少一节。拇指与食指相向弯曲,另外三指保持一个松弛的静息姿态,这大致就是一次真实捏合在追踪器眼里的样子。
驱动高亮的那个距离,是每帧在世界坐标下从两个指尖关节量出来的,没有烘进动画里。所以拖动阈值改变的是结果而不只是一个标签:拉到 5 毫米,几乎没有哪一刻算得上捏合;拉到 6 厘米,手指明明还分得很开就已经「检测到」了 —— 而这正是阈值定得太松时在真机上的表现。
打开关节半径,会把每个关节的粗细估计画成一颗半透明的球。球体相交表示这个几何模型发生接触,不能证明真实追踪中的指尖已经物理接触。
检查这次会话真正收到的手部输入
浏览器名称不能证明这次会话能拿到关节数据。逐步检查,并保留手柄或其他主操作输入。
| 检查 | 能确认什么 | 失败时怎么办 |
|---|---|---|
| 以可选特性申请 hand-tracking | 会话有机会提供手部骨架。 | 继续支持其他输入,不默认手部存在。 |
| 检查 source.hand | 这个输入源提供关节映射。 | 使用它支持的主操作。 |
| 在帧内读取 getJointPose | 这一帧该手有可用位姿。 | 隐藏过时的骨架,执行取消规则。 |
| 测试基于半径的阈值 | 应用能响应返回的估计值。 | 实际调试验证;半径可能由运行时模拟。 |
接口行为参照文末 W3C 手部输入规范。这是验证步骤,不是设备兼容性表或真机测试报告。
最费时间的几个坑
这几个的共同点是:都默认手一直在,而且尺寸永远一样。
- 把 hand-tracking 写进 requiredFeatures
- 结果是:只要用户把手部追踪关掉,requestSession 就直接 reject。用可选特性申请,拿不到就降级。
- 默认 getJointPose 一定非空
- 只要追踪丢失它就返回 null —— 一只手挡住另一只、手到了摄像头视野边缘、或者动得太快。这是常态,不是偶发。因为规则是按整只手而不是按单个关节,查一个关节就知道这一帧整只手有没有被追踪到。
- 每帧调用五十次 getJointPose
- 两只手是 50 个关节,每次调用都会分配一个姿态对象。XRFrame.fillPoses 把全部变换写进一块你自己持有的 Float32Array,fillJointRadii 对半径做同样的事;数据无效时两者返回 false。要画完整骨架时用它们,捏合只需要的那两个关节继续用 getJointPose。
- 写死捏合距离
- 手型和位姿估计会变化。关节半径可以帮助选取阈值,但仍应让不同用户试用,并允许调整。
- 对每根手指都按五个关节遍历
- 拇指只有四个。按固定五元数组索引的代码会越界,要么抛错,要么默默用错关节。
- 自己把捏合重新实现成 select
- 运行时已经为捏合触发 select,还带着自己的调参和滞回。手写的那版更差,而且对手柄不生效。
可复现案例:一次捏合只触发一次操作
把距离判断直接放在每帧回调里,一次保持中的捏合就可能执行几十次操作。应当为每个输入源保存状态,只在状态切换时发事件,并分别设置进入与离开的距离。下面用 18 毫米触发、26 毫米松开,让边界附近的小幅抖动不会反复点击。
这两个距离和 100 毫秒的追踪超时都是教学参数,没有针对特定手型或头显标定。输入距离的单位是毫米,时间使用单调递增的毫秒值。用 WebXR 位姿计算的距离以米为单位,传入前乘以 1000。
把整段代码放进控制台:输入先在 17 与 19 毫米之间抖动,中途短暂缺失一次,随后另一段捏合长时间失去追踪。cancel 允许绘图工具撤销未完成的笔画,避免把追踪丢失当作用户主动松手。普通按钮选择仍应优先使用运行时的 select 事件。
function createPinch() {
let held = false;
let lastValid = null;
return function update(distanceMm, timeMs) {
if (!Number.isFinite(distanceMm) || distanceMm < 0) {
if (held && timeMs - lastValid >= 100) {
held = false;
return 'cancel';
}
return null;
}
// Also expire a hold when callbacks stopped during tracking loss.
if (held && lastValid !== null && timeMs - lastValid >= 100) {
held = false;
lastValid = timeMs;
return 'cancel';
}
lastValid = timeMs;
if (!held && distanceMm <= 18) {
held = true;
return 'start';
}
if (held && distanceMm >= 26) {
held = false;
return 'release';
}
return null;
};
}
const update = createPinch();
const samples = [
[0, 30], [16, 17], [32, 19], [48, 17],
[64, null], [80, 20], [96, 27],
[112, 17], [224, null],
];
for (const [time, distance] of samples) {
const event = update(distance, time);
if (event) console.log(time, event);
}
// 16 start; 96 release; 112 start; 224 cancel预期事件依次为 16 start、96 release、112 start、224 cancel。每个输入源单独调用 createPinch;输入源移除或会话结束时清除其状态。
先读懂输入轨迹,再修改阈值
把 19 毫米那一帧改成 27 毫米,应该先松开,再在下一次 17 毫米时重新开始。随后移除中间的缺失帧,让回调隔很久才恢复,旧捏合仍应被取消。输入源移除、会话不可见需要另外处理,因为这些情况下帧回调可能完全停止。
| 时间(毫秒) | 距离(毫米) | 结果 |
|---|---|---|
| 0 | 30 | 空闲。 |
| 16 | 17 | 只触发一次 start。 |
| 32 / 48 | 19 / 17 | 保持捏合,不重复触发。 |
| 64 / 80 | 缺失 / 20 | 短暂丢失后恢复,不切换状态。 |
| 96 | 27 | 只触发一次 release。 |
| 112 / 224 | 17 / 缺失 | 重新开始,随后超时取消。 |
距离和时间均为构造的测试输入。这项检查验证状态切换,不证明设备追踪精度,也不提供通用捏合阈值。
延伸阅读
第一次写这类代码时,建议把关节名词表开在旁边 —— 名字都很长,而拼错的后果是静默返回 undefined,不会抛错。
- W3C — WebXR Hand Input Module — 规范性的关节清单、XRHand 与 radius 的语义。
- MDN — XRHand — get() 与关节名字符串的实用参考。
- MDN — XRFrame.getJointPose() — 返回的位姿包含什么,以及什么时候是 null。
- VR 手柄输入 — 手所接入的那套输入源模型,以及为什么 select 是可移植的动作。
- WebXR 会话 — 如何申请可选特性,以及关节位姿相对哪个参考空间解析。