中EN
概念页

引擎输入系统

Game Engine HID System

多数引擎不直接使用「原始」HID 输入,而是先加工(死区、滤波)使游戏内行为平滑直观,并在 HID 与游戏之间加至少一层间接(如按键映射表把原始按钮翻译成逻辑游戏动作)。GEA 列出引擎输入系统的典型需求并逐一讲解实现:死区、模拟信号滤波、按下/松开事件、组合键与序列手势、多设备多玩家、跨平台抽象、输入重映射、上下文相关控制与输入禁用。(卷 I 9.5–9.6 节)

典型需求(卷 I 9.5.1)

死区;模拟信号滤波;事件检测(按下、松开);按钮序列与多键同按(和弦 (chord))检测;手势检测;多玩家多设备管理;多平台 HID 支持;控制器输入重映射;上下文相关输入;临时禁用某些输入。

死区(卷 I 9.5.2)

模拟轴输入在 IminI_{min} 到 ImaxI_{max} 之间;不触碰时应输出稳定的「未扰动值」I0I_0(数值上通常为 0;双向控件如摇杆轴位于中点,单向控件如扳机与 IminI_{min} 重合)。但模拟设备电压有噪声,实际读数会在 I0I_0 附近抖动。常用做法是在 I0I_0 周围设小死区 (dead zone):摇杆为 [I0−δ, I0+δ][I_0-\delta,\ I_0+\delta],扳机为 [I0, I0+δ][I_0,\ I_0+\delta],落在其中的值直接钳为 I0I_0。死区要宽到能吸收未触碰时最大的噪声,又要窄到不影响玩家对灵敏度的感受。

模拟信号滤波(卷 I 9.5.3)

死区之外噪声仍会让游戏内行为显得抖动。噪声相对玩家产生的信号通常是高频的,所以可让原始输入先通过低通滤波器。离散一阶低通把本帧未滤波输入 u(t)u(t) 与上一帧滤波结果 f(t−Δt)f(t-\Delta t) 结合:

f(t)=(1−a) f(t−Δt)+a u(t),a=ΔtRC+Δtf(t) = (1-a)\,f(t-\Delta t) + a\,u(t),\qquad a = \frac{\Delta t}{RC + \Delta t}

其中 RCRC 是滤波常数(传统模拟 RC 低通电路中电阻与电容之积),Δt\Delta t 为帧时长(书中式 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 模式。原理只是所有键都按下才执行,但有两个细节:

  1. 若和弦中的键各自另有用途,不能同时触发单键动作和和弦动作,通常在检测单键时检查和弦的其他键是否未按下;
  2. 人按键不完全同步,可能第 i 帧看到一部分键、第 i+1 帧(或更晚)才看到其余。对策:
对策说明
和弦 = 单键动作 + 额外动作如 L1 开主武器、L2 扔手雷,L1+L2 既开火又扔雷再加一道使伤害翻倍的能量波;无论是否先检测到单键,玩家看到的效果一致
单键延迟生效单键按下后过一段延迟(如 2–3 帧)才算有效,期间检测到和弦则和弦优先
按下时检测、松开时触发—
先开始单键动作,允许被和弦动作抢占—

序列与手势(9.5.4.3):单键延迟生效其实是手势检测的特例。手势是玩家一段时间内通过 HID 完成的一串动作,如格斗游戏的 A-B-A,也可包含非按钮输入(A-B-A-左-右-左,后三者为摇杆左右摆动);通常须在最大时长内完成(四分之一秒内的 A-B-A 算,一两秒的不算)。实现:保留简短的动作历史,第一个分量带时间戳入缓冲,后续分量检查与前一分量的时间间隔,在窗口内则加入;全部按时完成(缓冲填满)就发出事件通知引擎其他部分;出现无效的中间输入或超时则整个缓冲重置。三个例子:

  • 快速连按:记录上次按下时间 TlastT_{last},频率 f=1/ΔTf = 1/\Delta T,ΔT=Tcur−Tlast\Delta T = T_{cur}-T_{last}(频率可映射为跑速等游戏量,也用来判定有效性)。要求最低频率 fminf_{min} 时,直接比较 ΔT\Delta T 与 ΔTmax=1/fmin\Delta T_{max} = 1/f_{min}。书中正文说「满足阈值才更新 TlastT_{last},不满足就不更新,直到出现一对足够快的按下」;但书中 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)与整个序列的开始时间 TstartT_{start}。本帧按下了非期待按钮(用 ~buttonMask 检测)则重置 i = 0;按下期待按钮时:若 i = 0 则记录 TstartT_{start} 并前进;否则若距 TstartT_{start} 小于最大时长则前进,序列完成时广播事件并重置,超时则重置。

    原书问题:书中 ButtonSequenceDetector 代码有几处无法编译或与正文不符:构造函数初始化了 m_eventId,但类中没有声明该成员;Update() 里写 m_aButtonId[m_iButton],而成员名是 m_aButtonIds;正文说重置时要把 TstartT_{start} 设为无效值(如 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 的通用引擎视角,两页之间的具体差异以各自来源为准。

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 9 章 9.5–9.6 节

相关页面

游戏开发人机接口设备Human Interface Devices人机接口设备 (HID) 是玩家向游戏提供输入、游戏向玩家提供反馈的硬件:手柄、键鼠、摇杆、Wii 遥控器、方向盘、跳舞毯、吉他等。GEA 从三个层面介绍它们:游戏如何与设备通信(轮询、中断、无线协议),设备提供哪几类输入,以及设备能输出哪些反馈。引擎如何加工这些原始输入见 引擎输入系统。(卷 I 9.1–9.4 节)游戏开发 · UE5增强输入Enhanced Input增强输入 (Enhanced Input) 是 UE5 默认启用的输入插件,把输入拆成四层:输入动作 (Input Action) 表示“玩家想做什么”,输入映射上下文 (Input Mapping Context) 规定“哪些按键在什么情境下触发它”,输入修饰器 (Input Modifier) 预处理原始值,输入触发器 (Input Trigger) 判定是否触发。上下文可在运行时按优先级增删,从而用数据资产而非代码处理情境切换、按键冲突与改键。游戏开发命令模式Command Pattern命令模式 (Command Pattern) 把一次方法调用“物化”成对象,从而可以存储、传递、延迟执行、排队或撤销。游戏里最典型的三种用法是:可配置的按键映射、让玩家输入和 AI 共用同一套“驱动角色”的接口、以及撤销/重做。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。游戏开发游戏时间线与时钟Game Timelines & Clocks游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 \Delta t 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 \Delta t 处理。(卷 I 第 8 章 引言、8.4–8.5 节)游戏开发运行时引擎分层架构Runtime Engine ArchitectureGregory 把游戏视为“软实时、交互式、基于代理的计算机模拟”,把游戏引擎定义为数据驱动、可复用来做多款游戏的软件。运行时引擎按层组织:从目标硬件、驱动、操作系统、第三方 SDK、平台无关层、核心系统、资源管理器,到渲染、物理、动画、音频、网络等子系统,再到游戏性基础层和游戏专属子系统;上层依赖下层,避免循环依赖。游戏开发游戏内调试工具In-Game Debugging Tools各子系统(渲染、动画、碰撞、物理、音频、网络、玩家机制、AI……)都有大量配置项,能在游戏运行时调整而无需改代码、重新编译链接再运行,可大幅节省调试和搭建关卡/玩法的时间。GEA 介绍支撑这一点的几类游戏内工具:游戏内菜单(及 Dear ImGui 等第三方 GUI)、游戏内控制台、调试摄像机与暂停/慢动作、作弊功能、截图与录像。(卷 I 10.3–10.7 节)游戏策划游戏界面Game Interface界面是分隔玩家(阳)与游戏(阴)那层无限薄的膜;界面失灵,体验之火即熄。界面的目标不是好看或流畅,而是让玩家感到掌控自己的体验。Schell 把界面拆成物理输入、物理输出、虚拟界面三部分和其间的六种映射 (mapping),并讨论透明、反馈、多汁 (juiciness)、原始性 (primality)、信息通道与维度、界面模式,以及十条实用建议(第 3 版第 15 章 Players Play Games through an Interface;第 1 版为第 13 章,无原始性一节,建议为八条)。

被这些页面引用1 篇

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原