引擎输入系统
Game Engine HID System
多数引擎不直接使用「原始」HID 输入,而是先加工(死区、滤波)使游戏内行为平滑直观,并在 HID 与游戏之间加至少一层间接(如按键映射表把原始按钮翻译成逻辑游戏动作)。GEA 列出引擎输入系统的典型需求并逐一讲解实现:死区、模拟信号滤波、按下/松开事件、组合键与序列手势、多设备多玩家、跨平台抽象、输入重映射、上下文相关控制与输入禁用。(卷 I 9.5–9.6 节)
典型需求(卷 I 9.5.1)
死区;模拟信号滤波;事件检测(按下、松开);按钮序列与多键同按(和弦 (chord))检测;手势检测;多玩家多设备管理;多平台 HID 支持;控制器输入重映射;上下文相关输入;临时禁用某些输入。
死区(卷 I 9.5.2)
模拟轴输入在 到 之间;不触碰时应输出稳定的「未扰动值」(数值上通常为 0;双向控件如摇杆轴位于中点,单向控件如扳机与 重合)。但模拟设备电压有噪声,实际读数会在 附近抖动。常用做法是在 周围设小死区 (dead zone):摇杆为 ,扳机为 ,落在其中的值直接钳为 。死区要宽到能吸收未触碰时最大的噪声,又要窄到不影响玩家对灵敏度的感受。
模拟信号滤波(卷 I 9.5.3)
死区之外噪声仍会让游戏内行为显得抖动。噪声相对玩家产生的信号通常是高频的,所以可让原始输入先通过低通滤波器。离散一阶低通把本帧未滤波输入 与上一帧滤波结果 结合:
其中 是滤波常数(传统模拟 RC 低通电路中电阻与电容之积), 为帧时长(书中式 9.1、9.2;参考文献 [224])。代码:F32 a = dt / (rc + dt); return (1 - a) * lastFramesFilteredInput + a * unfilteredInput;,由调用方保存上一帧的滤波结果。
另一种是简单滑动平均:如在 3/30 秒(3 帧)上平均,就把原始值存入 3 元素环形缓冲,输出为总和除以 3;要处理前两帧缓冲未填满的情况。书中模板 MovingAverage<TYPE, SIZE> 维护样本数组、累计和、当前下标与样本数:满了先减去被覆盖的旧样本,否则样本数加一;写入新样本、加到和中、下标回绕;平均值为和除以实际样本数(无样本时返回 0)。
检测输入事件(卷 I 9.5.4)
底层接口给的是当前状态,游戏常关心的是状态变化事件。
按下与松开(9.5.4.1):保存上一帧按钮状态与本帧比较。设 1 = 按下:
buttonChanges = m_buttonStates ^ m_prevButtonStates(XOR 得出变化的位)m_buttonDowns = buttonChanges & m_buttonStates(变化且当前按下)m_buttonUps = buttonChanges & ~m_buttonStates(变化且当前松开)
和弦(9.5.4.2):同时按下产生特殊行为的一组按钮,例:《超级马里奥银河》开始画面需同按 A+B;Wiimote 同按 1 和 2 进入蓝牙配对(任何游戏中);格斗游戏的投技常为双键组合;《最后生还者 第二部》开发版中同按 DualSense 左右扳机可关碰撞自由飞行(发售版无效)——Quake 引擎称之为 no-clip 模式。原理只是所有键都按下才执行,但有两个细节:
- 若和弦中的键各自另有用途,不能同时触发单键动作和和弦动作,通常在检测单键时检查和弦的其他键是否未按下;
- 人按键不完全同步,可能第 i 帧看到一部分键、第 i+1 帧(或更晚)才看到其余。对策:
| 对策 | 说明 |
|---|---|
| 和弦 = 单键动作 + 额外动作 | 如 L1 开主武器、L2 扔手雷,L1+L2 既开火又扔雷再加一道使伤害翻倍的能量波;无论是否先检测到单键,玩家看到的效果一致 |
| 单键延迟生效 | 单键按下后过一段延迟(如 2–3 帧)才算有效,期间检测到和弦则和弦优先 |
| 按下时检测、松开时触发 | — |
| 先开始单键动作,允许被和弦动作抢占 | — |
序列与手势(9.5.4.3):单键延迟生效其实是手势检测的特例。手势是玩家一段时间内通过 HID 完成的一串动作,如格斗游戏的 A-B-A,也可包含非按钮输入(A-B-A-左-右-左,后三者为摇杆左右摆动);通常须在最大时长内完成(四分之一秒内的 A-B-A 算,一两秒的不算)。实现:保留简短的动作历史,第一个分量带时间戳入缓冲,后续分量检查与前一分量的时间间隔,在窗口内则加入;全部按时完成(缓冲填满)就发出事件通知引擎其他部分;出现无效的中间输入或超时则整个缓冲重置。三个例子:
- 快速连按:记录上次按下时间 ,频率 ,(频率可映射为跑速等游戏量,也用来判定有效性)。要求最低频率 时,直接比较 与 。书中正文说「满足阈值才更新 ,不满足就不更新,直到出现一对足够快的按下」;但书中
ButtonTapDetector示例代码的Update()每次按下都无条件更新m_tLast,有效性由IsGestureValid()实时比较「当前时间 − m_tLast < m_dtMax」判断(构造时置m_tLast = CurrentTime() - dtMax使初始无效)。正文与代码不完全一致,原文并列记录。按钮 id 是 0 到 N−1 的下标,用1U << buttonId转为位掩码;ButtonsJustWentDown(mask)在掩码中任一按钮本帧刚按下时返回非零。 - 多键序列(如 1 秒内 A-B-A):用按钮 id 数组定义序列,维护当前期待的下标 i(初始 0)与整个序列的开始时间 。本帧按下了非期待按钮(用
~buttonMask检测)则重置 i = 0;按下期待按钮时:若 i = 0 则记录 并前进;否则若距 小于最大时长则前进,序列完成时广播事件并重置,超时则重置。原书问题:书中
ButtonSequenceDetector代码有几处无法编译或与正文不符:构造函数初始化了m_eventId,但类中没有声明该成员;Update()里写m_aButtonId[m_iButton],而成员名是m_aButtonIds;正文说重置时要把 设为无效值(如 0),代码重置时只把m_iButton清零。另外,因为「按下非期待键就重置」与「按下期待键」是 if/else if 关系,导致重置的那个按键本身即使就是序列第一个键也不会被算作新序列的开头(例如期待 B 时按了 A,A-B-A 的新一轮要从下一次按 A 才开始)。 - 摇杆转圈:把摇杆二维范围分成四个象限,顺时针旋转依次经过左上、右上、右下、左下,把每个象限当作一次「按键」,用稍作修改的序列检测代码即可(书中留作练习)。
下图是多键序列检测器的状态流转:
stateDiagram-v2 [*] --> 等待第1键 等待第1键 --> 等待下一键: 按下期待键,记录 T_start 等待下一键 --> 等待下一键: 按下期待键且未超时,i++ 等待下一键 --> 等待第1键: 按下其他键 或 超时 等待下一键 --> 等待第1键: 序列完成,广播事件
多玩家多设备(卷 I 9.5.5)
引擎要跟踪哪些设备已连接,把各自输入路由给对应玩家:可以是控制器下标与玩家下标一一对应,也可以在按下 Start 时才分配。即使单人单设备也要能应对意外拔出或没电:多数游戏暂停、显示消息、等待重连;有些多人游戏暂停或临时移除该玩家的角色而让其他人继续,重连后恢复。电池供电设备的低电量由游戏或操作系统检测,通常用不打扰的屏幕提示和/或音效警告。
跨平台 HID(卷 I 9.5.6)
- 最笨的办法是到处写条件编译(
#if TARGET_XBOX ... #elif TARGET_PS5 ... #elif TARGET_STEAMDECK),能用但不理想。更好的是硬件抽象层。 - 运气好时用一组抽象按钮与轴 id 就能覆盖大部分差异:Xbox 与 PlayStation 手柄布局几乎相同,如
AINDEX_RPAD_DOWN(Xbox A / PS5 ×)、AINDEX_RPAD_UP(Y / △)、AINDEX_RPAD_LEFT(X / □)、AINDEX_RPAD_RIGHT(B / ○)、肩键 L1/R1、扳机轴AINDEX_LTRIGGER(Xbox −Z / PS5 L2)等。读取按钮字后做位重排 (bit-swizzling) 映射到抽象下标,模拟输入同样重排。 - 有时要动点脑筋:书中说 Xbox 左右扳机是同一个轴(按左为负、都不按为零、按右为正),为匹配 DualShock/DualSense 可拆成两个独立轴并缩放到相同取值范围。(注:同一章 9.3.2 节讲 XInput 时又说扳机是两个独立的 8 位
bLeftTrigger/bRightTrigger;两处说法并存,原文如此。)原书问题:据 Microsoft Learn「XInputGetState」备注,用 XInput 读取时左右扳机分别报告;只有旧的 DirectInput 出于历史原因让两个扳机共用一个轴(Windows 游戏控制器面板因使用 DirectInput 而表现如此)。所以「Xbox 扳机是同一个轴」只适用于 DirectInput 路径,用 XInput 时不需要拆轴。
- 其他做法:按游戏功能而非物理位置命名抽象控件;提供更高层的抽象手势检测、各平台各自实现;或干脆为需要 HID 的游戏代码各写平台版本。几乎所有跨平台引擎都会以某种方式让游戏与硬件细节隔离。
输入重映射(卷 I 9.5.7)
常见选项有右摇杆垂直轴是否反转(推前抬镜头 vs 像飞机操纵杆那样拉后抬镜头)、在几套预设映射间选择,PC 游戏常允许完全自定义键盘、鼠标按键与滚轮。实现借用作者大学老师(滑铁卢大学 Jay Black 教授)的名言「计算机科学中的任何问题都可以通过增加一层间接来解决」:给每个游戏功能分配唯一 id,用一张表把物理或抽象控件下标映射到逻辑功能;游戏查询某功能时查表再读对应控件状态;改映射就整表替换或允许逐项编辑。逻辑动作与具体输入解耦的思路也可对照 命令模式。
细节:不同控件的输入类型不同,只能允许合理的映射(不能把按钮映射给需要轴的功能)。全部归一化到 [0,1] 并不够,因为有的轴天生双向、有的单向;更好的是分成几类、类内归一化并只允许兼容类之间重映射:
| 类别 | 归一化 |
|---|---|
| 数字按钮 | 打包为 32 位字,每键 1 位 |
| 单向绝对轴(扳机、模拟按钮) | 浮点 [0, 1] |
| 双向绝对轴(摇杆) | 浮点 [−1, 1] |
| 相对轴(鼠标轴、滚轮、轨迹球) | 浮点 [−1, 1],±1 表示一帧(1/30 或 1/60 秒)内可能的最大相对位移 |
有的平台代劳重映射,如 Steamworks SDK 的 Steam Input(书中参考文献 [225])。
上下文相关控制与控件归属(卷 I 9.5.8)
同一个物理控件随上下文有不同功能:「使用」键在门前开门、在物品旁拾取;模态控制方案下步行时控制移动与镜头,驾驶载具时控制转向,镜头控制也可能不同。用状态机实现很直接(见 状态模式与有限状态机),难点是决定处于哪个状态:玩家站在武器和医疗包正中间、面朝两者中点时按「使用」用哪个?有的游戏用优先级/权重打破平局(武器权重更高则选武器)。这需要大量试错,要预留迭代和焦点测试时间。
控件归属 (control ownership):不同控件可能「属于」游戏的不同部分——玩家控制、摄像机、外壳与菜单系统(暂停等)。有些引擎引入逻辑设备 (logical device):只含物理设备部分输入的子集,分别给玩家控制、摄像机系统和菜单系统使用。
禁用输入(卷 I 9.5.9)
例如游戏内过场时暂时禁用全部玩家控制,穿过窄门时暂时禁用自由镜头旋转。
- 较粗暴的做法:在输入设备层用位掩码禁用个别控件,读取时若对应位被置位就返回中性值或零。必须小心:忘记清除掩码会让玩家永远失去控制只能重启;要仔细检查逻辑,并加入保险机制,在关键时刻(如玩家死亡重生时)清除掩码。
- 在设备层禁用会对所有客户端屏蔽该输入,限制过度。更好的做法可能是把禁用特定玩家动作或镜头行为的逻辑放进玩家或摄像机代码本身:摄像机决定忽略右摇杆时,其他系统仍可读取它。
实践中的 HID(卷 I 9.6)
HID 概念上简单,但坑不少:不同物理设备的差异、低通滤波的正确实现、无 bug 的控制方案映射、手柄震动「手感」的拿捏、主机厂商技术要求清单 (TRC) 的限制等。团队应为完整细致的 HID 系统投入不小的时间和工程力量,因为它是游戏最宝贵的资源——玩家操作机制——的基础。
与 UE5 的关系
UE5 中对应的输入映射、上下文与修饰器机制见 增强输入;本页只记录 GEA 的通用引擎视角,两页之间的具体差异以各自来源为准。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 9 章 9.5–9.6 节