☰
手柄输入延迟怎么测?XInput采样与端到端归因实践
2026/11/4 3:26:29 网站建设 项目流程

你很难想象,一句“这手柄有延迟”能把一场讨论拖成什么样。上周在实验室里,两个做动作游戏原型的同学为了这句话争了半个小时:一个人坚持认为无线连接根本没法用,换成有线会立刻变好;另一个人说问题出在渲染管线的帧同步上,手柄本身并没有问题。两个人说的都不是空话,前者有“打完一局觉得手累”的体感依据,后者有“帧时间曲线抖动”的截图依据。问题在于,他们讨论的“延迟”根本不是同一个量。

这类争论在涉及输入设备的项目里极其常见,而它几乎从来不是靠嘴能解决的。真正能把争论结束掉的,是把“手感”拆成可以逐项测量的数字:一次按键从物理发生到程序读到,中间经过了几段,每段各占多少毫秒,抖动有多大,最坏情况能坏到什么程度。XInputTest 就是干这件事的:它是一套围绕 XInput 接口搭建的采样与诊断工具,把控制器的原始状态流、变化时间戳、轮询粒度这些东西以较小的干扰记录下来,让你看到一个控制器在你的系统上究竟“慢”在哪里。

这篇内容写给三类人:正在做手柄输入相关功能、被“延迟”问题反复困扰的开发者;需要给硬件或整机做输入性能验收的测试同学;以及纯粹想知道自己那套设备到底什么水平、值不值得换线换接收器的玩家。不需要你有很深的内核或驱动背景,但基本的 C++ 编译、线程、计时的概念要能看懂,代码部分我会一段段拆开讲为什么这么写。

1. 先把“延迟”这个词拆开,否则测了也白测

在动手写第一行采样代码之前,有必要先把讨论对象钉死。绝大多数关于手柄延迟的争论,本质上是三个人在用同一个词指三件不同的事,谁也没说服谁,因为大家衡量的东西压根不重叠。XInputTest 的价值不在于它给了一个“延迟是多少毫秒”的结论,而在于它帮你把这三件事分开,让每一段都有独立的数字。

1.1 体感延迟和测量延迟中间隔着一整条管线

人对手柄输入的感知,来自“我按下去”到“屏幕上出现对应变化”这一整条链路。这条链路至少包含:按键机械行程闭合、控制器固件打包上报、无线或有线传输、主机侧驱动与 HID 栈入队、应用层读取状态、游戏逻辑处理、渲染提交、显示设备刷新。XInputTest 能干净测到的主要是中段——从控制器状态在系统侧可见,到你的程序把它读出来。前段(机械行程、固件打包)和后段(渲染、显示)它是看不到的。

所以第一个必须建立的认知是:测量结果永远只是一段,不是全部。很多人拿工具测出“8ms”,就到处说“这个手柄延迟 8ms”,这是错的,8ms 只是软件侧可见的那部分。真正有意义的说法应该是“在我的配置下,从系统侧观察到状态变化到我程序读取,中位 1.2ms、P95 3.4ms,端到端另算”。把口径说清楚,争论会立刻少一半。

我一般会建议在项目里就把这几个量分别命名,形成团队共识,后面所有讨论都带单位带口径:检测粒度(轮询多快能看到变化)、传输抖动(同一操作重复多次的离散程度)、端到端(含显示)。三者混着说,越说越乱。

1.2 XInputTest 究竟从系统里读到了什么

XInput 这套接口抽象层次很高,它不给你原始 HID 报告,只给你一个结构化的状态快照。核心就是两个东西:一个是按键、摇杆、扳机的当前值,另一个是一个叫dwPacketNumber的包序号。这个序号是关键——它在状态发生变化时会改变,看起来像是一个天然的变化检测器。

但这里有个非常容易被误读的点:dwPacketNumber不是时间,也不是计数器意义上的“第几次轮询”。它是一个标记,同一个状态反复读,序号不变;状态变了,序号跟着变。它不能告诉你变化是“什么时候”发生的,只能告诉你“变了”。所以想拿到时间信息,必须自己在这一侧打时间戳:每次读到序号变化,立刻记下本地高精度时间,然后用外部已知的激励时刻去减。这也是为什么单纯看 XInputTest 的界面数字容易得出错误结论——它显示的是变化被检测到的时刻,不是变化发生的时刻。

除了状态,XInput 还提供振动输出(XInputSetState)和电池/连接信息。振动输出看着和延迟无关,实测里却经常是干扰源,后面第 4 节会专门讲。

1.3 三个必须分开统计的量:中位、尾部、毛刺率

拿到一串采样之后,最容易犯的错是只算平均值。输入延迟的分布是明显右偏的:绝大多数采样集中在 1~2ms,但偶尔会蹦出一个 20ms 甚至 40ms 的离群值。平均值会把这个离群值摊薄到看起来无害,而玩家真正抱怨的恰恰是这些毛刺——它出现的频率不高,但每一次都足以让人“感觉不对”。

我在实际项目里固定统计三个量:中位数(典型体验)、P95 或 P99(能用但不能爽的边界)、超过阈值的毛刺率(比如超过 10ms 的采样占比)。如果再细一点,可以统计连续两次超阈值之间的间隔,判断是偶发还是周期性——周期性毛刺往往指向系统侧的定时器或电源管理,偶发则更可能是链路干扰。

2. 搭一个干净的最小采样台

工具再花哨,底层就是几十行代码:开一个高优先级线程,循环调XInputGetState,记录变化时刻。但“能跑”和“测得准”之间隔着一堆细节,任何一处处理不当,你测到的其实是自己的测量误差。这一节把一个能直接抄用的最小采样台搭起来,每一处写法都说明原因。

2.1 工程骨架与高精度时间戳

第一件事是把计时源换掉。常见的新手写法是用GetTickCount,它的分辨率大概在 10~16ms 之间,而你测的量本身就在几毫秒量级,等于用米尺量头发丝。必须用QueryPerformanceCounter,它的分辨率在主流硬件上通常是百纳秒级,足够。

#include <windows.h> #include <xinput.h> #include <cstdio> #pragma comment(lib, "xinput.lib") static double QpcMs() { static LARGE_INTEGER freq = [] { LARGE_INTEGER f; QueryPerformanceFrequency(&f); return f; }(); LARGE_INTEGER c; QueryPerformanceCounter(&c); return (double)c.QuadPart * 1000.0 / (double)freq.QuadPart; }

把频率缓存成静态变量是有意为之:QueryPerformanceFrequency每次调用的开销不大,但在一个每秒轮询几千次的循环里,任何多余调用都会被放大成噪声。同时注意返回类型用double而不用整数,毫秒量级下整数的截断误差会非常明显。

2.2 采样环路:记录变化点而不是记录全部

采样线程的写法直接决定结果。我倾向于“只在变化时落记录”,因为如果把每次轮询都存下来,一秒几千条记录里 99% 是重复状态,不仅吃内存,事后分析还得再过滤一遍。

struct Sample { double t_ms; // 本地高精度时间戳 WORD packet; // dwPacketNumber WORD buttons; // wButtons 位掩码 BYTE lt, rt; // 扳机 SHORT lx, ly, rx, ry; }; void PollLoop(int pad, double duration_ms, std::vector<Sample>& out) { XINPUT_STATE st{}; WORD lastPacket = 0xFFFF; const double t0 = QpcMs(); while (QpcMs() - t0 < duration_ms) { if (XInputGetState(pad, &st) == ERROR_SUCCESS) { if (st.dwPacketNumber != lastPacket) { lastPacket = st.dwPacketNumber; out.push_back({QpcMs(), st.dwPacketNumber, st.Gamepad.wButtons, st.Gamepad.bLeftTrigger, st.Gamepad.bRightTrigger, st.Gamepad.sThumbLX, st.Gamepad.sThumbLY, st.Gamepad.sThumbRX, st.Gamepad.sThumbRY}); } } } }

这里的lastPacket初值设成0xFFFF是个小技巧,避免第一次轮询因为初始值恰好等于真实序号而漏掉首条记录。这类细节看起来琐碎,但对短采样(比如 3 秒)影响不小。

还有一个流传很广的说法需要提醒:调用XInputGetState的本身开销并不能简单忽略。在我测过的若干版本上,这个调用在状态无变化时会走一条相对轻的路径,但并不是零成本;有些版本内部还带短暂让出,导致你的实际轮询频率低于预期。这意味着你不能假设轮询频率等于你写的循环速度,必须实测:连续跑 10 秒,统计总轮询次数,得出实际频率。这个数字要写进报告,因为检测粒度直接受它限制。

2.3 线程优先级与定时精度

默认优先级下,采样线程随时可能被系统里其他事情挤走几百微秒到几毫秒,而这些“被挤走”的时刻会被你的代码诚实地记录下来,最后全变成延迟数据的毛刺。你会以为是手柄的问题,其实是自己的线程被调度走了。

SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); timeBeginPeriod(1); // 需要链接 winmm.lib // ... 采样 ... timeEndPeriod(1);

timeBeginPeriod(1)的作用是把系统定时器精度提到 1ms 级别,这对轮询型采样很重要。要强调两点:一是必须成对调用,忘了timeEndPeriod会让整个系统的功耗和定时行为受影响;二是这个调用影响的是整个进程环境而不只是当前线程,在多线程程序里要放在明确的初始化与收尾位置。

如果条件允许,把采样线程绑到一个相对空闲的核心上也能减少干扰,但要注意别和渲染线程、音频线程抢同一个核心,否则你只是在转移干扰的位置。

2.4 空载基线:先搞清楚噪声本底

在插上手柄真正测试之前,先跑一次“空载”:手柄放在桌上不动,采样 30 秒,看记录的条数。理想情况下应该接近零——状态不变就不该有记录。如果你看到每秒几十条记录在跳,说明摇杆有漂移或者扳机没归零,这些微小变化会被当成有效输入,把后续统计污染得不成样子。

这一步我每次搭台子都会做,而且我发现它对排查“假延迟”特别有用。曾经有一次测出来的延迟分布奇怪地呈现双峰,中间空了一大段,折腾半天才发现是扳机停在临界值附近抖动,一直在触发状态变化,把真正的按键事件淹没了。先看基线,再谈数据,能省掉大量返工。

3. 把时间轴对齐:延迟到底出在哪一段

有了采样数据,接下来是判读。这一节的核心方法是“分段归因”:不要一上来就盯着总数,而是把链路切开,用不同方法分别估算每一段,看哪一段占了大头。切开之后你会发现,很多时候你以为的“手柄慢”,其实慢在别处。

3.1 控制器到系统侧:这段只能间接估

这一段包含固件打包、无线或有线传输、驱动入队。XInput 接口不提供任何时间信息,所以只能用统计方法间接估:用手或机械装置以稳定节奏敲击同一个按键(比如每秒 4 次),记录每次变化被检测到的时刻,算相邻间隔的分布。因为敲击节奏本身的抖动相对可控,间隔的离散程度就近似反映了传输与轮询的抖动之和。

这段有个关键限制:你测到的是“检测到”的时刻,它天然带着最多一个轮询周期的滞后。假设实际报告率是 250Hz(4ms 一报),你的轮询是 1000Hz(1ms 一次),那么检测时刻相对真实变化时刻,误差在 0 到 4ms 之间均匀分布。所以这一段的“中位数”意义不大,“最大偏差”和分布形状反而更有信息量——如果分布明显不是均匀的,说明中间还有别的瓶颈。

3.2 驱动缓存与状态可见性

状态到了系统侧,还要经过驱动栈才变成XInputGetState能读到的样子。这一层的缓存策略没有公开文档,不同版本行为也不完全一致,所以我的做法是:不试图精确建模这一层,而是把它和上一段合并成一个“系统侧可见延迟”,用重复实验的方式测出它的稳定区间。

具体做法是跑一组“同一按键连续敲击 200 次”的实验,把每次变化检测时刻减去敲击的计划时刻(用节拍器音频做参考),得到一组偏差值。这组值的分布形状很能说明问题:如果分布很窄(比如全部落在 5~7ms),说明链路稳定,这一层的贡献是常数,可以在后续计算里直接扣掉;如果分布很宽甚至双峰,说明存在随机因素,需要进一步查干扰。

3.3 应用层轮询到画面:最容易被忽略的大头

这是最讽刺的一点:大量“手柄延迟”的抱怨,最终追到的都是这一段。软件侧的输入读取往往只占几毫秒,而从读到状态到画面真的变,可能是 16ms、33ms 甚至 50ms——取决于帧率、缓冲策略、垂直同步和渲染管线深度。

举个具体的账:如果游戏跑 60 帧,帧时间 16.7ms,用双缓冲加垂直同步,从逻辑更新到画面呈现最坏要等接近两帧,也就是 33ms 左右。再加上输入采样在帧内的位置(如果采样发生在一帧的开头,而按键发生在采样之后,就要等下一帧),最坏情况还能再加一帧。所有这些加起来,几十毫秒的体感延迟完全解释得通,跟手柄一点关系都没有。

所以在做归因的时候,我会强制自己先算一遍“渲染侧理论延迟下限”,再去看输入侧的数字。如果渲染侧下限已经是 30ms,那输入侧从 8ms 优化到 4ms 的收益,在体感上几乎不可能被察觉。优化要打在大头上,这是基本的性价比判断。

4. 六个真实踩过的坑,每个都让人白干半天

这一节是我认为整篇里最值钱的部分。下面这些坑,没有一个是靠读文档能提前避开的,全是测出诡异数据之后一点点查出来的。我把排查链路完整写出来,你可以照着复现思路。

4.1 把 dwPacketNumber 当成时间戳用

这个坑我见过太多次。有人直接把包序号差值当成“延迟了几个周期”,然后乘以理论周期得出毫秒数。问题是包序号的递增规则并非严格“每次状态变化加一”,它是驱动侧分配的一个标记,不同设备、不同连接方式下行为并不一致,有些场景下它会跳号,有些场景下重复上报也会变。用它做差值,得到的是一个无量纲的数,不是时间。

正确的做法只有一个:所有时间信息都来自你自己的高精度计时器,包序号只用来判断“变了没有”。这条规则写进代码注释里都不为过。我在评审别人写的诊断代码时,第一眼就找dwPacketNumber出现的所有位置,只要看到它参与算术运算,基本可以判定这段统计不可信。

4.2 混淆“轮询频率”和“报告频率”

采样循环写得再快,也不会让控制器报得更快。控制器的上报频率是设备侧决定的,你的高频轮询只是在更细的粒度上“守”着变化出现。所以当你把轮询从 500Hz 提到 2000Hz,测出来的中位延迟会下降,但下降的幅度有上限——最多趋近于“真实变化时刻 + 传输时间”,再快也没用。

这个坑的危害在于它会误导优化方向。有人看到提高轮询频率有效果,就一路往上加,最后 CPU 单核被吃满,延迟反而因为调度竞争变差。我一般的做法是先测定设备实际报告间隔(连续采一段,统计相邻变化时刻的最小间隔,取众数),把轮询频率设在报告频率的 3~5 倍就足够了,再高收益递减。

4.3 无线链路的环境干扰被算成设备问题

无线连接的延迟分布通常比有线宽,这大家都知道。但“宽”有多少来自设备本身、多少来自环境,很多人没分开。我做过一组对照:同一个控制器、同一台机器,接收器分别放在机箱前置面板、被金属机箱挡住的后置口、以及延长到桌面空旷处。结果差异大得离谱,遮挡状态下的 P95 能比空旷处高好几倍,而且毛刺呈明显的突发聚集——不是均匀变差,而是隔一段时间来一串。

这类问题的排查方法很朴素:把接收器挪位置,重测,对比。别嫌土,有效。判断标准看毛刺的聚集性——如果超阈值事件成簇出现,八成是链路层在重传;如果均匀散布,更可能是轮询或调度问题。

4.4 振动反馈把采样搞脏

这条比较隐蔽。当程序在采样循环里同时调用振动输出,某些配置下会让状态读取的时序发生变化,表现为延迟数据整体抬高或者出现周期性尖峰。我遇到过一次:只要开启振动,测出来的 P95 就从 4ms 涨到 12ms,关掉立刻恢复。

处理办法很简单:测量期间不要开振动。如果业务上必须同时验证振动,那就分开跑两组,一组纯采样,一组带振动,用差值来评估振动的影响。另外扳机振动和握把振动对时序的影响可能不同,需要分别验证。

4.5 死区和触发阈值造成的“假延迟”

摇杆死区、扳机触发阈值这些参数会让“变化”的定义变得模糊。比如扳机从 0 缓慢推到 30,如果程序以 30 作为按下阈值,那么从物理动作开始到判定按下,中间隔了一大段行程时间——这段被算进延迟里,但它根本不是传输或处理延迟,是人手推杆的速度。

我遇到的最典型一次:测出来扳机响应比按键慢十几毫秒,一度怀疑是硬件问题。后来把扳机阈值从 30 降到 5,数字立刻正常。结论不是“扳机慢”,而是“阈值设得高”。所以做对比测试时,一定要把所有阈值参数固定下来写进报告,否则跨实验的数据根本不可比。

4.6 显示延迟被算进输入延迟

最后这个坑最普遍。有人用高速摄像拍屏幕,看到按键到画面变化的间隔是 60ms,就断定手柄延迟 60ms。这个数字里包含了显示设备自身的响应时间和扫描输出延迟,一台普通液晶电视的这部分就能占 10~30ms。要拆开,得先单独测显示链路:用一个已知时刻的画面切换(比如全屏黑白闪烁)配光电传感器,测出显示延迟,再从端到端数据里扣掉。

这条说起来简单做起来烦,但如果不做,所有关于输入侧的优化讨论都是在猜。我的建议是:项目里至少做一次显示链路的标定,把这个常数记下来,后面所有端到端数据都可以直接减。

5. 不用实验室设备也能做的对照实验

讲了一堆原理和坑,落到操作上,很多人会问:我没有示波器、没有光电传感器、没有高速摄像,能不能做有意义的测量?能。下面是几个我用过、成本极低但结论可靠的方法组合。

5.1 节拍器 + 手工敲击:估传输抖动

最土的办法往往最好用。开一个节拍器软件,设成 120 BPM(每 500ms 一次),跟着节拍敲同一个按键,敲 100 次,让程序记录每次变化检测的时刻。因为节拍器的间隔是精确的,你把相邻两次检测时刻的间隔和 500ms 相减,得到的就是“抖动”。这个方法的绝对精度不高,但分布形状非常可信。

判断标准:抖动分布如果集中在 ±3ms 以内,说明链路健康;如果出现大量 ±10ms 以上的点,或者分布明显双峰,说明有问题需要查。这个方法的另一个好处是它同时把“人手的抖动”混进去了,所以它测出来的是“用户实际感受到的稳定度”,而不是纯硬件指标——对产品验收来说,这个口径反而更贴近现实。

5.2 机械敲击夹具:把人的因素去掉

如果要更纯净的数据,做一个简单的机械敲击装置:一个小舵机或者电磁铁,带着一个软头,固定在手柄按键上方,用程序控制它按固定节奏敲击。动作时刻由控制信号给出,检测时刻由采样程序记录,两者相减就是延迟。这套东西的成本可能就几十块,但能把人手的不确定性彻底排除,重复性大幅提升。

做夹具时有两个细节要注意:一是敲击力度要刚好能稳定触发按键,太轻会漏,太重会损伤手柄;二是敲击头的材质要用软的,硬物撞击会产生二次反弹,一次动作被识别成两次。我第一次做的时候没注意第二点,数据里全是成对出现的记录,排查了半天。

5.3 光电+拾音:逼近端到端

想测真正的端到端,可以用一个折中方案:光线传感器贴在屏幕边缘,麦克风靠近按键。程序控制屏幕上出现一个白块,同时敲击按键;拾音记录敲击声的时刻,光电记录屏幕亮起的时刻,两个时刻相减就是端到端延迟。精度不如专业设备,但用于同一台机器上做 A/B 对比完全够用——比如换有线换无线、开垂直同步关垂直同步,看差值就够了。

这种方法的系统性误差是固定的,做对比时会被抵消掉,所以特别适合验证“某个改动到底有没有用”。我做过一次对比:同一台机器,帧率从 60 锁到 120,端到端延迟的中位数下降了差不多一半,这个结论用这个土办法就能得出,不需要任何昂贵设备。

6. 把测出来的数字变成可落地的调优动作

测量本身不产生价值,产生价值的是“知道该改哪里”。这一节把常见的优化项按“收益大、低”和“收益小、稳”分开列,形成一个可以直接照着做的清单。需要说明的是,这些项的具体收益高度依赖你的配置,下面的排序是经验性的,不是普适结论。

6.1 优先做:调整采样位置和帧内顺序

在所有软件侧改动里,收益最大的一项往往不是在输入读取上,而是在“读到的状态什么时候被用上”。如果你的主循环是“先渲染、再更新逻辑”,那么这一帧读到的输入要等下一帧才生效,白白多了一帧延迟。把输入采样和逻辑更新挪到帧的最前面,收益立竿见影,而且风险极低。

第二项是减少管线深度。如果渲染用了多帧并行,输入处理也会被拖后。可以做一个实验:把并行帧数从 3 降到 1,重新测端到端,看差值有多大。如果差值明显,就说明管线深度是主要矛盾;如果不明显,就别在这一项上耗时间,去看别处。

6.2 谨慎做:提高轮询频率和线程优先级

把轮询频率调高、把采样线程优先级调到最高,这两件事立竿见影但都有副作用。轮询频率过高会持续占用一个核心,在低功耗设备上还会影响续航和温度策略;优先级调到最高则可能挤压音频或渲染线程,导致掉帧——而掉帧带来的体感损失,远大于输入延迟降低带来的收益。

我的建议是设一个上限:轮询频率不超过设备实际报告频率的 5 倍,优先级用THREAD_PRIORITY_TIME_CRITICAL但在测量结束后立刻降回正常。同时监控帧时间,如果帧时间方差明显变大,说明优先级调得过高了。

6.3 硬件侧:先排除物理层

软件侧折腾一圈之后如果收益有限,就回到物理层。检查顺序建议是:换到机箱后置的直连端口(避开前置面板延长线)、换一根质量好一点的线、把无线接收器用延长线挪到桌面空旷处、确保周围没有大功率无线设备。这些操作每换一次重测一次,用数据说话。

这里有个经验值可以参考:同一台机器上,同一控制器的不同连接方式,中位延迟的差距通常在一个轮询周期量级,而尾部的差距可能大好几倍。也就是说,连接方式主要影响的是“稳定度”而不是“平均速度”。如果你的痛点是偶发卡顿感,优先查物理层;如果是整体偏慢,优先查软件管线。

下表是我在若干套配置上反复测得的量级区间,仅用于建立直觉,不是标准值:

连接方式检测粒度中位P95 抖动尾部毛刺特征
有线直连后置端口约 1ms约 2~3ms偶发,稀疏
有线经前置延长/hub约 1.2ms约 3~5ms成簇出现
无线,接收器敞开放置约 2ms约 5ms均匀散布
无线,接收器被金属遮挡约 3~5ms15ms 以上明显成簇

6.4 建立回归:让每次改动都可对比

最后一项容易被忽略但价值很高:把测量流程固化成一个可重复的脚本,每次改动前后各跑一次,把关键指标(中位、P95、毛刺率、实际轮询频率、实际报告间隔)记到一张表里。时间一长,你就有了一份自己的基线库,遇到新的“感觉有延迟”能立刻判断是设备退化了、环境变了、还是代码改坏了。

我自己维护的这张表已经记了两年多,中间抓到过好几次边缘性的性能退化——某次改动让 P95 从 3ms 涨到 6ms,体感上完全察觉不到,但如果放任不管,后面再叠加几个改动就会突破阈值。这种问题是靠感觉永远发现不了的。

最后分享一个我自己踩过的小坑:测量脚本一定要把环境信息(采样频率、线程优先级、是否开振动、死区与阈值参数、是否开垂直同步、当前帧率)一起落盘。我曾经有过一份数据,测出来特别漂亮,回头想复现却怎么也对不上,翻了两小时才发现那次忘了关振动。从那以后,我把所有环境参数都写进输出文件名里了,虽然文件名难看,但再没为这种事返工过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询