
Gamepad API 实战避坑指南:轮询、轴屏蔽与摇杆漂移判定
Gamepad API 实战避坑指南:轮询、轴屏蔽与摇杆漂移判定
浏览器 Gamepad API 只有四个属性和一个函数,却藏着几个不写在规范里的坑:必须逐帧轮询、快照不会自动更新、轴在静止前一律上报 0、pad.id 在 Windows 上认不出硬件。本文讲清这些行为背后的机制,并给出一套区分「硬件漂移」和「人手扶着摇杆」的判定方法。
Gamepad API 是浏览器里体量最小的 API 之一:四个属性、一个函数、不需要任何权限弹窗,十几行代码就能把控制器状态画到屏幕上。问题在于,这十几行代码同时也会把一个已经损坏的手柄报告成完全正常。本文整理的是规范文档里不会写、但实际调试中一定会撞上的几件事:为什么必须轮询、为什么页面刚加载时读到的值不是硬件真实发出的值、为什么你无法可靠地识别插进来的是哪只手柄,以及怎样把模拟摇杆的漂移和真人握持区分开。文中所有代码都可以直接粘进浏览器控制台运行。
准备工作
这是一篇动手向的内容,不需要安装任何东西,也没有构建步骤,但有几项前提需要先满足,否则下面的代码不会有任何输出。
需要具备的基础
- 能熟练使用 JavaScript:函数、数组及其
reduce/filter等方法、箭头函数、解构赋值。 - 理解什么是动画帧循环。文中多个示例运行在
requestAnimationFrame里。 - 会打开浏览器开发者工具,并能把代码粘贴进控制台执行。
- 有一节会用到少量向量运算:一组 x、y 采样点的均值,以及该均值向量的长度。如果你看得懂
Math.hypot(x, y),那一节就没有障碍。
需要准备的硬件与环境
- 一个支持 Gamepad API 的桌面浏览器。Chrome、Edge、Firefox、Safari 从 2017 年起都已支持,手边任意一个现代浏览器基本都没问题。
- 一只真实的游戏手柄,通过 USB 或蓝牙连接。这个 API 没有软件模拟的途径,没有硬件接入时下面的代码都不会产生有效结果。
- 最好是一只你已知有故障的手柄,比如存在摇杆漂移的那只。本文描述的若干行为只在损坏硬件上才会显现,一只健康的手柄会把它们全部藏起来。
不需要任何框架、库,也不需要 npm install。下面每一段都是可以直接运行的纯 JavaScript。
操作步骤
第一步:先看清那个「看起来能用」的错误写法
下面这段是绝大多数人第一次会写出来的版本,也是大部分教程里给出的版本:
window.addEventListener("gamepadconnected", (e) => {
const pad = navigator.getGamepads()[e.gamepad.index];
console.log(pad.axes); // [0, 0, 0, 0]
console.log(pad.buttons.filter(b => b.pressed).length); // 0
});把一只漂移严重、在任何游戏里都会自己把角色推着走的手柄插上去,这段代码打印出来的仍然是 [0, 0, 0, 0]。这五行里藏着两个彼此独立的缺陷,第二个才是真正有意思的那个。
第二步:理解为什么必须自己轮询
第一个缺陷是:这个 API 根本没有输入事件。gamepadconnected 和 gamepaddisconnected 就是全部的事件面,不存在 gamepadaxischange,也不存在 gamepadbuttondown。想知道摇杆现在推到哪了,只能一遍遍去问,通常放在 requestAnimationFrame 里。
同一个缺陷还有后半段:每一帧都必须重新调用 navigator.getGamepads()。它返回的是快照。如果你把某个 Gamepad 对象存下来稍后再读,读到的永远是取到它那一刻的值,从此冻结不变。
function loop() {
const pads = navigator.getGamepads(); // 每帧重新读取,不要缓存
for (const pad of pads) {
if (!pad) continue; // 数组里有空槽位,必须做保护
render(pad.index, pad.axes, pad.buttons);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);关于这个循环有两点实践提醒。
第一,返回的数组是稀疏的。navigator.getGamepads() 返回一个定长数组,没有设备接入的槽位是 null,所以不加 if (!pad) 保护的 for...of 会在第一个 null 上直接抛错。
第二,轮询不是免费的。一个从页面加载就开始、永不停止的 requestAnimationFrame 循环,是实打实的主线程开销,而这个页面上可能压根没有手柄,将来也不会有。一个比较好用的模式是:空闲时用 setTimeout 以较低频率(比如每秒 8 次)轮询,只用来发现手柄接入;一旦真的接入了,再切到完整的 requestAnimationFrame;断开后再降回低频。采样这个 API 本身很便宜,但在每一次页面访问上无意义地每秒采样 60 次,是会在性能面板里看得见的浪费。
第三步:理解轴屏蔽规则
接下来是真正缺乏文档说明的部分,也是那只漂移手柄被报告成全零的原因。
Chromium 在看到某个轴至少静止过一次之前,不会上报它的真实值。注意,不是「用户移动过它」,而是「浏览器观察到它接近零」。
机制是这样的:浏览器为每只已连接的手柄维护两个位域,一个 axis_mask,一个 button_mask。当某个轴的位还没被置上时,它上报的值被强制为 0.0。这个位会在该轴第一次上报出小于某个常量(kMinAxisResetValue,值为 0.1f)的幅度时被置上。从那一刻起,真实值才会透传出来。
按键走的是同一套逻辑,通过 button_mask,但判定更严格:这个位在该按键第一次上报为「未按下」时才会被置上。一个在页面加载时就被按住不放的按键,或者被坏掉的弹簧卡在半程的扳机,在浏览器看到它被松开一次之前,都会一直上报 pressed: false 和 value: 0。
这不是 bug,理解它存在的理由很重要:手柄可能因为硬件故障、或者被什么重物压住了摇杆,在没人碰它的时候持续上报输入。如果没有这条规则,这些杂散输入会被当成用户手势,页面就会得知一个用户从未主动暴露过的设备。所以每个轴、每个按键都必须先证明自己能够静止,浏览器才愿意告诉你关于它的任何事。
这条规则的后果需要仔细读,因为它和直觉正好相反:漂移越严重,浏览器坚持认为手柄正常的持续时间就越长。偏移量很小的摇杆,总会在某一帧落到 0.1 以下,从而解除屏蔽。而一只磨损严重、永远回不到这个窗口内的摇杆,会无限期地保持被屏蔽状态。最需要被报告出来的那只手柄,恰恰是报告不出任何东西的那只。
这也解释了控制器测试工具里那些看起来像玄学的提示。诸如「把两个摇杆各转一整圈」这样的说明,起作用的原因并不是「移动解锁了轴」,而是「转一整圈的过程中摇杆经过了中心点」。
下面这段可以直接粘进控制台。接上手柄、加载页面、不要碰摇杆;然后把左摇杆推到边缘再松手让它弹回。
const start = performance.now();
let woke = false;
requestAnimationFrame(function loop() {
const pad = navigator.getGamepads()[0];
if (pad && !woke) {
const [x, y] = pad.axes;
if (x !== 0 || y !== 0) {
woke = true;
console.log(
"left stick started reporting after",
Math.round(performance.now() - start), "ms,",
"first values:", x.toFixed(3), y.toFixed(3)
);
}
}
requestAnimationFrame(loop);
});在一只静止的健康手柄上,轴几乎立刻解除屏蔽,因为健康的摇杆本来就停在接近零的位置。而在一只漂移的手柄上,除非你亲手把摇杆推过中心点,否则什么都不会打印出来。
由此得出的实用规则是:永远不要根据连接后的第一帧对硬件下结论。等到你亲眼见过每个轴至少上报过一次非零值,或者直接请用户动一下摇杆,之后再去相信你读到的数据。
第四步:接受你无法识别硬件这件事
第二个意外小一些,但它会在 UI 层咬你一口。规范给了你 pad.id,一个由浏览器拼出来的字符串。
在 Linux 上、以及 macOS 上很多时候,它包含十六进制的 USB 厂商 ID 和产品 ID,你可以据此查到设备型号。但在 Windows 上,XInput 设备(也就是绝大多数 Xbox 风格手柄)完全不暴露厂商 ID 和产品 ID。字符串长这样:"Xbox 360 Controller (XInput STANDARD GAMEPAD)",第三方山寨手柄和原厂硬件报告的内容一模一样。macOS 有它自己的版本:在 macOS 的 Chrome 上接入一只 DualShock 4,得到的是 "Wireless Controller (STANDARD GAMEPAD)",没有厂商 ID,没有产品 ID,而且这个名字足够通用,好几款毫不相干的手柄都共用它。
最后这一条造成过一个真实的 bug:按键图标渲染逻辑依赖解析 id 字符串,于是 Mac 上每一只 DualShock 4 都落进了通用兜底分支,在 PlayStation 手柄上画出了 Xbox 风格的按键标签。这个功能的每一位 Mac 用户都看到了错误的东西,而且任何 Windows 或 Linux 上的测试都不可能发现它。
正确的做法是按能力分支,而不是按型号分支:
function describe(pad) {
return {
standard: pad.mapping === "standard", // 只有为 true 时才信任 axes/buttons 的顺序
axes: pad.axes.length, // 普通双摇杆手柄为 4
buttons: pad.buttons.length, // 标准映射下带 guide 键为 17
analogTriggers: pad.buttons.slice(6, 8).every(b => typeof b.value === "number"),
rumble: Boolean(pad.vibrationActuator)
};
}pad.id 可以用来展示,用来让用户确认自己插的是哪只手柄,但不要用它来决定代码的行为。
第五步:把漂移和真人握持区分开
当你能真正读到摇杆数据之后,才会撞上真正的问题:读数偏离中心,并不意味着硬件坏了,通常意味着有人正扶着摇杆。
最直观的检测器是「阈值 + 计时器」:如果某个轴超过某个值持续了 N 毫秒,就判定为漂移。这个方案被实际发布过,它是错的,而且错在最糟糕的方向上——因为页面上自己的提示就是让用户把两个摇杆各转一整圈,而一个缓慢的圆周运动会让某个轴长时间保持在阈值之上。结果是测试工具告诉用户:你那只正常的手柄坏了。
区分这两种情况的关键,不是摇杆离中心有多远,而是两件事同时成立。
第一道闸门:是否接近静止。真实的漂移是一个小而持续的偏移,通常远小于半程偏转;而手扶在摇杆上,位置通常要远得多。要求采样窗口内的平均幅度低于大约 0.6。
第二道闸门:方向是否一致。这一条才是真正起作用的那道。漂移来自磨损或失准的传感器,所以它会稳定地指向一个方向,变化极小;而人的手即使在努力保持不动时也会游移。把均值向量的长度与各个样本幅度的均值做比较:如果每个样本都指向同一个方向,这两个数几乎相等,比值趋近 1;如果样本散开,均值向量就比平均幅度短,比值随之下降。要求这个比值高于大约 0.9。
// samples: 滚动窗口内逐帧收集的 { x, y } 数组
function looksLikeDrift(samples) {
if (samples.length < 30) return false; // 证据还不够
const magnitude = s => Math.hypot(s.x, s.y);
const meanMagnitude =
samples.reduce((sum, s) => sum + magnitude(s), 0) / samples.length;
if (meanMagnitude < 0.02) return false; // 停在中心,没有问题
if (meanMagnitude > 0.6) return false; // 第一道闸门:离得太远,不像漂移
const meanX = samples.reduce((sum, s) => sum + s.x, 0) / samples.length;
const meanY = samples.reduce((sum, s) => sum + s.y, 0) / samples.length;
const coherence = Math.hypot(meanX, meanY) / meanMagnitude;
return coherence > 0.9; // 第二道闸门:始终指向同一方向
}喂给它的采集器大致如下:
const samples = [];
const WINDOW = 120; // 60fps 下约两秒
requestAnimationFrame(function loop() {
const pad = navigator.getGamepads()[0];
if (pad) {
const [x, y] = pad.axes;
samples.push({ x, y });
if (samples.length > WINDOW) samples.shift();
if (looksLikeDrift(samples)) {
console.log("suspected stick drift");
}
}
requestAnimationFrame(loop);
});一个完整示例
把上面的要点串起来,就是一个能跑通的最小控制器检查器:低频等待接入、接入后逐帧采样、等轴解除屏蔽后再开始判定漂移。
let pad = null;
const samples = [];
const WINDOW = 120;
let unmasked = false;
// 空闲阶段:低频轮询,只为了发现手柄
const idle = setInterval(() => {
const pads = navigator.getGamepads();
for (const p of pads) {
if (p) {
pad = p;
clearInterval(idle);
requestAnimationFrame(loop);
return;
}
}
}, 125); // 约每秒 8 次
function loop() {
const pads = navigator.getGamepads();
pad = pads[pad.index] || null;
if (!pad) {
// 断开连接,降回低频等待
samples.length = 0;
unmasked = false;
setInterval(() => {}, 125);
return;
}
const [x, y] = pad.axes;
// 等轴解除屏蔽:见过一次非零值才认为数据可信
if (!unmasked && (x !== 0 || y !== 0)) {
unmasked = true;
console.log("axes unmasked, values are now trustworthy");
}
if (unmasked) {
samples.push({ x, y });
if (samples.length > WINDOW) samples.shift();
if (looksLikeDrift(samples)) {
console.log("suspected stick drift");
}
}
requestAnimationFrame(loop);
}
function looksLikeDrift(samples) {
if (samples.length < 30) return false;
const magnitude = s => Math.hypot(s.x, s.y);
const meanMagnitude =
samples.reduce((sum, s) => sum + magnitude(s), 0) / samples.length;
if (meanMagnitude < 0.02) return false;
if (meanMagnitude > 0.6) return false;
const meanX = samples.reduce((sum, s) => sum + s.x, 0) / samples.length;
const meanY = samples.reduce((sum, s) => sum + s.y, 0) / samples.length;
const coherence = Math.hypot(meanX, meanY) / meanMagnitude;
return coherence > 0.9;
}运行方式:接上手柄,把代码粘进控制台,先不要碰摇杆,观察是否打印出解除屏蔽的提示;然后把左摇杆推到边缘再松手,看采样是否开始。如果手柄本身存在漂移,在窗口攒够样本后就会看到漂移提示。
注意事项
- 先按一下按键。在多数浏览器里,如果用户从未与页面交互过,API 会表现得像什么都没插一样。运行任何代码之前先按一下手柄上的键。
- 不要缓存 Gamepad 对象。它是快照,存下来再读只会得到冻结的旧值,必须每帧重新调用
navigator.getGamepads()。 - 数组是稀疏的。空槽位为
null,遍历时不做保护会直接抛错。 - 第一帧的数据不可信。轴和按键在被观察到静止之前一律上报零值,漂移越严重的手柄被屏蔽得越久。
- 不要用
pad.id判断硬件。Windows 上的 XInput 设备不暴露厂商与产品 ID,macOS 上部分手柄的名字也高度通用。用pad.mapping === "standard"和能力探测来分支。 - 漂移判定需要两道闸门同时成立。只看阈值和时长会把正在按提示转动摇杆的用户误判为硬件故障。
- 轮询有成本。没有手柄接入时不要跑满帧率的循环,用低频等待 + 接入后切换的方式。
- 浏览器对 Gamepad API 的具体实现细节、支持的浏览器版本与行为可能随时间变化,涉及版本、兼容性与配额等信息请以各浏览器官方当前公布的内容为准。