旧输入系统(PlayerInput 的轴映射/动作映射)的问题用一句话就能说清:按键和游戏逻辑之间是一层硬编码的查表,改键位要改项目设置,"按住 0.5 秒"这种条件得自己写计时。Enhanced Input 把这一层换成了可数据驱动的四段管线。源码:Plugins/EnhancedInput/Source/EnhancedInput/Private/EnhancedPlayerInput.cpp、EnhancedInputComponent.cpp(UE5.3)。
四层管线
玩家按下一个键,到 BindAction 的 lambda 被执行,中间经过四个抽象层:
物理输入(键盘/手柄事件)
→ UInputMappingContext:此刻这个键值映射到哪些 UInputAction
→ UInputModifier:对原始值做数学变换(死区、缩放、轴向交换)
→ UInputTrigger:按条件判定动作状态(Pressed/Hold/Tap…)
→ UEnhancedInputComponent:按状态分发 Started/Ongoing/Triggered/Completed
分层的动机是各自独立的演化压力:键位布局随平台/场景变,数值变换随设备变(手柄要死区,键鼠不要),触发条件随玩法变,而事件契约要对蓝图稳定。四层各自数据资产化,互不牵连。
MappingContext 的求值与冲突仲裁
UEnhancedPlayerInput 持有按优先级排序的 IMC 栈(AddMappingContext 时插入,数值大的优先级高)。每帧 ProcessInputStack 做第一件大事:把当前所有生效 IMC 的键值映射合并成一张 ActionValue 表。
冲突处理是这里唯一的"算法":同一个物理输入映射到两个不同 Action 时,高优先级 IMC 的映射胜出,低优先级的该条映射被丢弃(不是合并、不是都触发)。这个选择是为了让"载具接管移动输入"这种场景可表达:把载具 IMC 以更高优先级压栈,角色移动的 WASD 映射自动失效,弹出即恢复——不需要手动禁用动作。
代价是隐式的:同名键在不同 IMC 里的语义切换没有日志,排查"为什么我的 Action 不响应"第一步永远是 showdebug enhancedinput 看当前栈。
Modifier:纯函数的值变换
Modifier 是一串对 FInputActionValue(Vector3 + 枚举类型的联合)的顺序变换:
// EnhancedPlayerInput.cpp 的求值骨架(UE5.3)
FInputActionValue Value = RawValue;
for (UInputModifier* Modifier : Mapping.Modifiers)
{
Value = Modifier->ModifyRaw(this, Value); // 纯函数,无状态
}
数学上这是一组函数复合 $v' = (f_n \circ \dots \circ f_1)(v)$,顺序敏感——先 Negate 再 Swizzle 和反过来结果不同。引擎不做交换性检查,配置时就要想清楚顺序。常见的 DeadZone 实现是逐分量钳制:
$$ f_{\text{dead}}(x) = \begin{cases} 0 & |x| < d \\ \dfrac{x - d\,\text{sgn}(x)}{1 - d} & \text{otherwise} \end{cases} $$
rescale 的意义是把死区消耗的量纲补回来:不 rescale 的话,摇杆刚过死区输出仍接近 0,角色起步会有"死区附近的迟钝带"。
Trigger:状态机才是核心
Trigger 判定的是"这个 Action 现在处于什么状态"。每个 Action 实例维护一个状态机,状态集合(ETriggerState):
None(未触发)→ Started(本帧刚进入条件)→ Ongoing(条件持续中)→ Triggered(满足触发)
↘ Completed(条件结束,正常收尾)
↘ Canceled(被打断,如 Hold 期间松开)
以 UInputTriggerHold(按住 N 秒)为例,它的判定源码逻辑:UpdateState 里看输入是否非零,非零则累加计时 HeldTime += DeltaTime,HeldTime >= HoldTimeThreshold 时返回 Triggered。关键细节是状态转换边沿产生事件:Started 只在 None→触发条件首次成立的帧派发,Triggered 每帧派发(所以移动绑定要挂 Triggered 而不是 Started,Started 只响一次)。Canceled 语义存在的理由是 Hold 场景:玩家按了 0.3 秒松开,计时器作废,如果这时候只发 Completed 而不发 Canceled,监听"长按施法被打断"的逻辑就没有挂点。
UInputTriggerPressed(默认)在轴值越过 actuation threshold 时触发——这个阈值默认 0.5,手柄漂移调到 0.2 就能解决"没碰摇杆角色在慢慢走"。
事件派发的最后一棒
UEnhancedInputComponent::BindAction 把 Action + 触发事件枚举绑到回调。每帧 Trigger 求值完成后,组件遍历绑定表,把本帧发生的状态边沿翻译成 Started/Ongoing/Triggered/Completed/Canceled 回调。这里有个性能注意点:绑定表里每个 (Action, Event) 对每帧都要查一次状态变化,Action 数量多时(我项目里 80+ 个)ProcessInputStack 在 stat game 里能占到 0.3ms 左右——对单个玩家无感,但本地分屏或多个 UEnhancedInputComponent 叠加时值得看一眼。
比旧输入强在哪、代价是什么
强的地方:条件(Trigger)和变换(Modifier)数据化,策划可以在资产里调;IMC 栈让"场景化输入覆盖"有了一致的仲裁规则;Action 语义化让蓝图事件自解释。旧系统里同样的功能要写一个继承 PlayerInput 的 C++ 类,热修键位要出补丁。
代价:每一层都是间接。物理输入到回调之间有四次查表/变换,叠 profiling 时要多跨两层栈;Trigger 状态机的边沿语义(Started 只响一次)是旧系统没有的心智负担, blueprint 新手最常见的 bug 就是把移动绑到 Started 上然后问"为什么只动一帧"。调试工具链(showdebug enhancedinput、IMC 栈可视化)必须配套学会,否则排查成本会吃掉设计收益。