Files
XCEngine/docs/api/_guides/Input/Input-Flow-and-Frame-Semantics.md

11 KiB
Raw Permalink Blame History

Input Flow And Frame Semantics

这篇指南解决什么问题

这篇文档不是“列 API 名称”,而是解释 XCEngine 当前输入系统到底按什么规则工作。它直接基于当前这些实现和测试:

  • engine/src/Input/InputManager.cpp
  • engine/include/XCEngine/Input/InputManager.h
  • tests/Input/test_input_manager.cpp

如果你在游戏层、编辑器运行态桥接、脚本绑定或平台输入后端里使用 InputManager,最重要的不是记住方法名,而是先建立一致的语义模型。

设计理念

当前输入系统刻意做成两层:

  • InputModule
    • 负责接平台消息,把 WM_KEYDOWNWM_MOUSEMOVE 这类原始系统消息翻译成引擎调用
  • InputManager
    • 负责保存状态、提供轮询 API、同步分发事件

这种设计接近商业引擎里常见的“平台适配层 + 游戏语义层”分工。好处是:

  • 游戏逻辑和编辑器逻辑不需要直接依赖 Win32 之类的平台细节
  • 平台后端可以替换,输入查询接口可以保持稳定
  • 轮询和事件可以共享同一份底层状态,不需要上层再维护第二套缓存

从使用风格上看,它也明显借鉴了 Unity 旧版 Input Manager 的思想:

  • 支持具名轴,如 HorizontalVertical
  • 支持具名按钮,如 JumpFire1
  • GetAxis()GetButton() 一类游戏层查询接口为中心

但它当前还是一个轻量级、同步、帧驱动的输入管理器,不是完整的设备抽象框架。

输入流到底怎么走

当前一帧内的典型流向是:

  1. 平台窗口系统产生键盘、鼠标消息。
  2. 平台后端把消息翻译成 ProcessKeyDown()ProcessKeyUp()ProcessMouseMove()ProcessMouseButton()ProcessMouseWheel() 等调用。
  3. InputManager 立即更新内部缓存,并同步触发 OnKeyEvent()OnMouseMove() 等事件。
  4. 游戏逻辑、编辑器运行态桥接或脚本绑定在当前帧查询 IsKeyDown()GetAxis()GetButtonDown() 等 API。
  5. 帧末或统一帧边界调用 Update(),清空瞬时输入缓存,准备下一帧。

这说明一个关键点:Update() 不是“收集输入”,而是“推进帧边界并清空瞬时状态”。

为什么同时提供事件和轮询

商业级引擎通常不会强迫所有系统只用一种输入风格。XCEngine 当前的取舍也是一样:

  • 事件更适合 UI、文本输入、编辑器面板、平台桥接
  • 轮询更适合角色移动、相机控制、游戏规则判断

InputManager 把两者放在同一个状态中心里,能减少几类常见问题:

  • 上层不需要再手写“本帧按下 / 本帧释放”缓存
  • 事件订阅者和轮询调用者看到的是同一组真实状态
  • 平台输入桥接和游戏输入消费不需要重复实现状态机

帧边界语义

当前实现里,Update() 会清理这些瞬时状态:

  • m_keyDownThisFrame
  • m_keyUpThisFrame
  • m_mouseButtonDownThisFrame
  • m_mouseButtonUpThisFrame
  • m_mouseDelta
  • m_mouseScrollDelta

因此下面这些接口都天然是“本帧有效,过帧清空”:

  • IsKeyPressed()
  • IsKeyReleased()
  • IsMouseButtonClicked()
  • IsMouseButtonReleased()
  • GetButtonDown()
  • GetButtonUp()
  • IsAnyKeyPressed()
  • GetMouseDelta()
  • GetMouseScrollDelta()

推荐实践只有三条,但都很重要:

  1. 每帧只调用一次 Update()
  2. 保持调用时机稳定,不要一会儿逻辑前一会儿逻辑后
  3. 先消费瞬时输入,再执行 Update()

四类最常用的输入语义

先按语义理解 API比按方法名死记更可靠。

语义 典型 API 当前真正含义
按住态 IsKeyDown()IsMouseButtonDown()GetButton()GetAxis()GetAxisRaw()IsAnyKeyDown() 当前是否仍处于按住状态
抬起态 IsKeyUp()IsMouseButtonUp() 当前没有按住,不包含“本帧刚释放”含义
按下边沿 IsKeyPressed()IsMouseButtonClicked()GetButtonDown()IsAnyKeyPressed() 本帧出现按下消息
释放边沿 IsKeyReleased()IsMouseButtonReleased()GetButtonUp() 本帧出现释放消息

这也是很多团队最容易踩错的地方:UpReleased 不是同义词。

UpReleased 为什么必须分开

这是当前模块最需要讲透的概念。

  • IsKeyUp() / IsMouseButtonUp()
    • 只表示“现在没有按住”
    • 本质上等价于 !IsKeyDown() / !IsMouseButtonDown()
    • 即使未初始化或索引越界,当前实现里也会表现为 true
  • IsKeyReleased() / IsMouseButtonReleased()
    • 只表示“这一帧刚收到释放消息”
    • ProcessKeyUp() / ProcessMouseButton(..., false, ...) 写入
    • 会在下一次 Update() 时被清空

为什么商业引擎通常都要保留这两套语义:

  • “当前没按住”适合状态判断
  • “这一帧刚释放”适合一次性触发逻辑

例如“角色是否仍在蓄力”更像 IsKeyDown() / IsKeyUp() 问题;“松开蓄力键时发射一次”则应该看 IsKeyReleased()

Pressed 当前到底是不是“首次按下”

不是严格意义上的“首次物理按下”。

源码里 ProcessKeyDown() 的当前行为是:

  • 无论 repeattrue 还是 false
  • 都会把 m_keyDown[index] = true
  • 也都会把 m_keyDownThisFrame[index] = true

这带来一个很关键的结果:

  • IsKeyPressed()
  • GetButtonDown()
  • IsAnyKeyPressed()

它们当前表达的更准确含义是“这一帧收到了按下消息”,而不是“从这次物理按下开始到松开之前只触发一次”。

如果平台在后续帧继续发送自动重复 KeyDown

  • OnKeyEvent() 会把事件类型标成 KeyEvent::Repeat
  • 但按下边沿缓存仍会被写入
  • 所以上述查询接口仍可能在这些帧再次返回 true

如果业务真的需要“严格首次按下”,需要在平台层或更高层自己过滤 repeat。

轴和按钮映射的真实行为

逻辑轴

当前 RegisterAxis() 把一个名字映射到“正方向键 + 负方向键”。

GetAxis()GetAxisRaw() 目前都直接读 IsKeyDown()

  • 正方向键按住时加 1.0f
  • 负方向键按住时减 1.0f
  • 同时按下则抵消为 0.0f

当前两者没有行为差异。它们都:

  • 不读取 InputAxis::m_value
  • 不做平滑、重力、灵敏度或死区处理
  • 不会因为只是“本帧刚按下”而产生非零值

这和很多商业引擎早期的“raw axis / smoothed axis”区分还不一样当前实现更接近“两个名字不同但逻辑相同的持续态接口”。

逻辑按钮

当前 RegisterButton() 只是把名字映射到一个 KeyCode

  • GetButton() 转发到 IsKeyDown()
  • GetButtonDown() 转发到 IsKeyPressed()
  • GetButtonUp() 转发到 IsKeyReleased()

因此按钮系统当前不是独立状态机,而只是对键盘查询的命名包装。

和 Unity 的关系

如果你熟悉 Unity这里最容易建立直觉但也最容易因为“看起来像”而误判。

相似点:

  • 都有具名轴和具名按钮
  • 都强调游戏层直接查询,而不是让业务处理原始平台消息
  • 都适合快速搭建角色控制、相机控制和简单交互

不同点:

  • 当前没有 Unity 旧版 Input Manager 里的 gravity、sensitivity、dead zone、snap 等参数
  • GetAxisRaw() 目前并不比 GetAxis() 更“底层”,两者等价
  • 默认注册的 Mouse X / Mouse Y 还没有绑定到鼠标移动增量
  • GetButtonUp() 是释放边沿,不是“当前按钮没按着就算 up”

所以当前更适合把它理解成“Unity 风格接口外观 + XCEngine 当前真实实现语义”,而不是直接按 Unity 经验套用全部行为。

any-key 目前到底统计什么

当前 any-key 会把鼠标按钮也算进去。

  • IsAnyKeyDown()
    • 扫描 m_keyDownm_mouseButtonDown
  • IsAnyKeyPressed()
    • 扫描 m_keyDownThisFramem_mouseButtonDownThisFrame

它目前不包含:

  • 触摸
  • 滚轮
  • 文本输入
  • 摇杆或手柄输入

同时,IsAnyKeyPressed() 也继承键盘 repeat 语义。只要平台后端在某一帧再次调用 ProcessKeyDown(),它就可能再次成立。

默认映射与当前落地程度

初始化后默认注册这些逻辑项:

    • Horizontal: D / A
    • Vertical: W / S
    • Mouse X: None / None
    • Mouse Y: None / None
  • 按钮
    • Jump: Space
    • Fire1: LeftCtrl
    • Fire2: LeftAlt
    • Fire3: LeftShift

这说明当前默认配置的实际可用性是分层的:

  • Horizontal / Vertical 可以直接驱动基础移动逻辑
  • Jump / Fire1-3 可以直接用于具名按钮查询
  • Mouse X / Mouse Y 当前更像预留命名位,而不是已经接通的鼠标轴

平台桥接为什么值得保留独立层

当前 Windows 路径里,WindowsInputModule::HandleMessage() 会把 Win32 消息翻译成 InputManager::Process*() 调用。

这种分层设计的直接收益是:

  • 游戏层不用理解平台消息细节
  • 测试可以绕开平台窗口系统,直接驱动 InputManager::Process*() 来验证行为
  • 将来替换平台层时,输入消费方可以尽量保持不变

这也是为什么现有测试大多直接命中 InputManager,而不是依赖完整窗口消息环境。

当前版本最该知道的限制

  • KeyCode 底层值当前存在重复项,部分按键会共享状态槽。
  • Windows 路径当前不能准确区分左右 Ctrl / Alt / Shift。
  • 触摸与摇杆接口大多还是预留位。
  • 事件回调同步执行,不是异步消息队列。
  • Shutdown() 会清空状态和映射,但不会清空事件监听器。
  • ClearAxes() 当前会同时清空轴和按钮映射。

推荐接入方式

初始化阶段:

using namespace XCEngine::Input;

InputManager::Get().Initialize(windowHandle);

平台消息阶段:

windowsInputModule.HandleMessage(hwnd, msg, wParam, lParam);

游戏逻辑阶段:

if (InputManager::Get().GetButton("Jump")) {
    // 持续按住态
}

if (InputManager::Get().GetButtonDown("Jump")) {
    // 本帧按下边沿
}

if (InputManager::Get().GetButtonUp("Jump")) {
    // 本帧释放边沿
}

帧边界阶段:

InputManager::Get().Update(deltaTime);

如果你把 Update() 放错位置,或者在同一帧里调用多次,最先出问题的通常就是 Pressed / Released / MouseDelta 这些瞬时语义。

相关 API