人体学接口设备(HID)
游戏是有互动性的计算机模拟,为游戏而设的人体学接口设备(Human Interface Device,HID)种类繁多,包括摇杆、手柄、键盘、鼠标、Wii遥控器,以及方向盘、跳舞毯、电子吉他等等专用输入设备。本文探讨游戏引擎如何自入体学接口设备读取输入,处理输入,以及向玩家反馈输出。
1. HID的接口技术
- 轮询
- 像手柄这样的简单设备,可通过定期轮询硬件来读取输入(通常在主游戏循环里每次迭代轮询一次)
- 要明确查询(输出)设备的状态,可直接读取硬件寄存器;读取经内存映射的I/O端口,或通过较高级的软件接口间接读取
- 微软为Xbox 360手柄而设的XInput API是简单轮询的好例子。游戏在每帧调用
XInputGetState()
函数,它与硬件/驱动通信,适当地读取数据,并把所有结果包装成XINPUT_STATE
结构,此结构含有手柄设备上所有输入的当前状态
- 中断:像鼠标这样的设备没必要在静止时还不断发送数据。这类设备通常以硬件中断方式来通信。中断服务程序用来读取设备状态,把状态储存以供后续处理,然后交还CPU给主程序
- 无线设备:对于蓝牙设备,软件必须以蓝牙协议和它们通信。这种通信一般由引擎主线程以外的线程负责处理,或至少被封装为简单接口供主循环调用。从程序员的角度来说,蓝牙基本和其他传统轮询设备的状态一样
2. HID的输入输出类型
2.1 输入类型
2.1.1 数字式按钮
数字式按钮只有两个状态——按下(down或press)或释放(up或release),软件中则以1或0表示。有时设备上所有按钮的状态会结合为一个无符号整数值,如XBox 360手柄的状态是以XINPUT_GAMEPAD
结构传回。这个结构体第一个字段是一个16位无符号整数变量wButtons,存放所有按钮的状态。每个按钮都会定义一个掩码,实际使用时将wButtons和掩码做&运算来判断是否被按下。
2.1.2 模拟式轴和相对性轴
模拟式输入是指可获取一个范围以内的数值。此类输入通常用来代表摇杆的二维位置(使用两个模拟输入, 一个x轴一个y轴)。模拟式输入信号通常都要被数字化,表示为软件中的整数,再送入引擎处理。像Xbox 360手柄拇指摇杆的偏转量(sThumbLX/sThumbLY/sThumbRX/sThumbRY)取值范围是-32768~32767,而左右扳机(bLeftTrigger/bRightTrigger)取值范围是0(没扣压)~255(完全扣压)。
上述模拟式轴的位置都是绝对的,而有些输入是相对性的。这类设备不能界定在哪个位置的输入值为0,相反,输入为0代表设备的位置没变动,非零值代表自上次读取输入至今的增量,如鼠标、鼠标滚轮和轨迹球等等。
2.1.3 加速计及三维定向
Wii遥控器、PS3的Sixaxis及智能手机都包含加速传感器,能感应xyz三个主轴的加速度。Wii的一些游戏会利用3个加速计去估算控制器在玩家手上的定向。其原理是基于我们在地球表面上玩这些游戏,而地球的1g引力能对物体产生固定的向下加速度。
若把控制器完美地水平放置,并指向电视方向,那么垂直方向(z)的加速计应量度到大约-1g。若垂直握着控制器,使其指向上方,则可以预期z应为0,而y应为1g( 因为y传感器会感受到完整的引力效果)。当我们校准加速计得知每个轴的零点,就可以使用逆正弦和逆余弦,轻松求得偏航角、俯仰角和滚动角。
2.2 输出类型
- 震动反馈:模拟游戏角色在游戏中受到扰动或撞击等感觉。震动通常由若干个马达驱动,每个马达带有稍不平衡的负重,以不同速度旋转。游戏可开关这些马达,并通过调节其旋转速度来向玩家双手产生不同的触觉效果
- 力反馈:通过由马达驱动的制动器,以其产生的力对抗玩家施于HID上的力。常见于街机赛车游戏——当玩家尝试转方向盘时,方向盘会产生阻力,其输出原理同震动反馈
- 其他输入/输出:有些HID设备含扬声器、麦克风等音频接口;较老的像Dreamcast的手柄支持插入记忆卡;Xbox 360手柄和Wii遥控器带有4个软件控制的LED灯;乐器、跳舞毯等特殊设备有其专门的输入/输出类型;近年发展的姿势界面(如Kinect)和VR设备,也是非常独特的HID
3. 游戏引擎的HID系统
多数游戏引擎不会直接使用HID的原始输入数据,而是引入至少一个在HID和游戏之间的间接层,将输入以多种形式抽象化。下面会介绍一些HID系统的典型需求。
3.1 死区
假定模拟轴的输入值范围是到,未触碰模拟轴时,稳定及清晰的“未扰动”输入值为。
HID本质上是模拟设备,其产生的电压含有噪声,以致实际上量度到的输入会轻微附近浮动。解决办法是引入一个围绕的死区。对于摇杆,死区可以定义为;对于扳机,则定义为。任何位于死区的输入值都可以简单地被钳制为。死区必须足够大以容纳未扰动控制的最大噪声,同时也必须足够小以免影响玩家对HID的反应手感。
3.2 模拟信号过滤
即使控制器不在死区范围,其输入仍会有信号噪声问题,这些噪声有时候会导致游戏中的行为显得抖动或不自然。由于噪声信号的频率通常比玩家产生的要高。所以,解决办法之一是,先利用低通滤波器过滤原始输入,再把结果传送至游戏中使用。
结合未过滤输入的时变函数,己过滤输入为,其中参数由帧持续时间和过滤常数所确定,即。公式转换C++代码如下:
F32 lowPassFilter(F32 unfilteredInput, F32 lastFramesFilteredInput, F32 rc, F32 dt) {
F32 a = dt / (rc + dt);
return (1 - a) * lastFramesFilteredInput + a * unfilteredInput;
}
另一个过滤HID输入的方法是计算移动平均。例如,若要计算3帧时间范围内的输入数据平均,只需把原始输入数据简单地储存于3个元素大小的循环缓冲区里,把此数组的值求和除3,就是过滤后的输入值。因为初始时该数组并未填满有效数据,要注意处理前两帧的输入。
3.3 输入事件检测
3.3.1 按下和释放按钮
假设按钮的输入位在释放时为0,按下时为1。可以记录上一帧的状态(32位位整型),和本帧的状态位异或,为1的位表示状态发生变化。再审视每个按钮的当前状态,若某按钮的状态有改变,而当前的状态是按下,则产生按下事件,否则产生释放事件。
3.3.2 弦(chord)
弦是指一组按钮,当同时被按下时,会在游戏中产生一个独特行为。一般通过检测两个或以上的按钮状态,当该组按钮全部同时被按下才执行操作。但弦有许多细节值得注意:
- 小心避免同时产生个别按钮的动作和弦的动作。要在检测个别按钮的时候,同时检查弦里的其他按键并没有被按下
- 弦的检测代码必须健壮,防止人们按下弦中的某一按钮稍早于其他按钮。有几种方法可以处理这些情况
- 从策划的角度,将按钮输入设计为,弦总是作用于某个按钮的动作再加上额外的动作。例如,若按L1是主武器开火,按L2投射手榴弹,可能L1+L2的弦是令主要武器开火、投射手榴弹,并发送能量波使这些武器的伤害力加倍。这样从玩家的角度来说游戏表现出的行为没有不同
- 在个别按钮按下后,加入一段延迟时间,然后才算作是一个有效的游戏事件。在延迟期间(如2或3帧),若检测到一个弦,那么那个弦就会凌驾个别按钮产生事件
- 按下单个按钮时立即执行动作,但容许这些动作被之后弦的动作抢占
- 按下按钮时检测弦,但之后释放按钮时才产生效果
3.3.3 序列检测
序列指玩家通过HID,在一段时间内完成一串动作,最常用于格斗游戏,如在0.5-1秒内连续按下“左右左ABA”。序列检测的基本原理是:保留HID输入的动作短期记录,当检测到序列第一个成分,就会把该成分及其时间戳记录在历史缓冲区中。之后,检测到每个后续成分时,需要检查距上一个成分所经过的时间,若时间仍在容许范围内,就把该成分加入缓冲区中。若整个序列于限定时间内完成,就会产生对应的事件。若在过程中检测到无效输入,或超过规定事件,那么整个历史缓冲区会被重置。
要检测连打按钮频率,只须记录该按钮上一次被按下事件的时间Tlast和两次按下按钮的时间间隔。若该间隔超过了给定的阈值,则不更新Tlast。那么,在有一对新的够迅速的按钮按下事件产生之前,序列会一直判定为无效。
要检测诸如“在1s内连续按下ABA的序列”,可以参照下面的伪代码。其中ButtonsJustWentDown()
函数来检查若干个按钮的按下事件。当成功检测到序列,就会广播指定事件,令整个游戏都能接收到。
class ButtonSequenceDetector {
U32 m_aButtonIds; // 检测的序列
U32 m_buttonCount; // 序列中的按钮数目
F32 m_dtMax; // 整个序列的最大时限
EventId m_eventId ; // 完成序列的事件
U32 m_iButton; // 要检测的下一个按钮
F32 m_tStart; // 序列的开始时间,以秒为单位
public:
void Update() {
ASSERT(m_iButton < m_buttonCount);
// 计算下个预期的按钮,以位掩码表示(把1左移至正确的位索引)
U32 buttonMask = (1U << m_aButtonid[m_iButton]);
// 若玩家按下预期以外的按钮,废止现时的序列(使用位取反运算检测所有其他按钮)
if (ButtonsJustWentDown(~buttonMask)) {
m_iButton = 0; // 重置
}
// 否则,若预期按钮刚被按下,检查dt及适当更新状态
else if (ButtonsJustWentDown(buttonMask)) {
// 序列中第一个按钮
if (m_iButton == 0) {
m_tStart = CurrentTime();
++m_iButton;
} else {
F32 dt = CurrentTime() - m_tStart;
// 时间间隔符合要求,序列仍然有效
if (dt < m_dtMax) {
++m_iButton;
// 判断序列是否完成
if (m_iButton == m_buttonCount) {
// 广播事件并重置
BroadcastEvent(m_eventId);
m_iButton = 0;
}
}
// 按得不够快,重置
else {
m_iButton = 0;
}
}
}
}
}
再复杂的序列包含了摇杆方向,例如检测左拇指摇杆沿顺时针方向旋转一周。可以把遥杆位置的二维范围分割成4个象限。顺时针方向旋转时,经过的象限顺序是左上,右上,右下,左下。只要把象限检测当作按钮处理,就可稍修改上文按钮序列检测代码来完成任务。
3.4 跨平台HID系统
引擎处理多平台的HID数据时,应该提供某形式的硬件抽象层,使游戏代码和硬件相关细节隔离。此抽象层能把目标硬件的原始控制标识符转化为抽象的控制索引。例如Xbox 360及PS3的两款手柄的控制布局几乎相同,所以可以设立一套抽象标识符来屏蔽它们的差异。
3.5 输入的重映射
许多游戏提供给玩家修改键位的选项,这就需要把原始输入映射到最终的游戏功能上。可以给每个游戏功能一个唯一标识符,然后加一个简单的表,把每个抽象的控制索引映射至游戏中的逻辑功能。要改变映射,可以更换整个表,或是让玩家设置该表中的个别条目。
但要小心不同的输入种类和取值范围,像某个游戏逻辑需要轴,就不能改用按钮操控。为了允许合理的输入映射,可以把所有输入分类并归一化:
- 数字式按钮:按钮状态打包成32位字,每一位代表一个按钮的状态
- 单向绝对轴(如扳机、模拟式按钮):产生[O, 1]的浮点数
- 双向绝对轴(如摇杆):产生[-1, 1]的浮点数
- 相对轴(如鼠标滚轮、轨迹球):产生[-1, 1]的浮点数,其中±1代表单帧内最大的相对偏移值
3.6 上下文相关控制
许多游戏里一个物理控制会根据上下文有着不同功能,例如若角色站在门前,按“使用”按钮会开门,若角色附近有一个物体,按“使用”按钮会拾起该物体。上下文相关控制可简单地采用状态机来实现,即根据当前状态个别HID控制可能有不同用途。要注意有时还需要实现优先系统,为不同物体赋予权值,来决定同等条件下优先让哪个物体(状态)生效。
3.7 禁用输入
在某些场合可能需要禁用玩家的输入,例如过场动画禁用所有输入,玩家经过窄巷暂停自由旋转摄像机。一个较差的方法是使用位掩码来禁用设备上的某些控制,这种方法缺陷是如果忘记重置掩码,很可能使玩家持续失去控制。所以应该小心处理游戏逻辑,并加入一些防故障机制。
另一个更好的做法是,把禁用某玩家动作及行为的逻辑写进玩家或摄像机的代码里。这样,若摄像机某时刻决定要忽略右拇指轴的输入,游戏引擎内其他系统仍然能自由读取该输入做其他用途。
参考文献:电子工业出版社《游戏引擎架构》第8章