转载请注明出处:🔗https://blog.csdn.net/weixin_44013533/article/details/132534422
作者:CSDN@|Ringleader|
主要参考:
- 官方文档:Unity官方Input System手册与API
- 官方测试用例:Unity-Technologies/InputSystem
- 如果c#的委托和事件不了解,参考我这篇:【C#学习笔记】委托与事件 (从观察者模式看C#的委托与事件)
关键词: Unity New Input System,New Input System,InputSystem,NewInputSystem,PlayerInput,UnityEvent,C#Event,Binding Conflict,绑定冲突,冲突解决,PlayerLoop,ActonPhase,Interaction,Processor,Bingding,ActionMap,InputActionState,control,InputControl
注:本文使用的unity版本是2021.3.25f,InputSystem版本为1.5.1
注:带⭐的小节是重点或难点
一 总述
1.1 安装
package manager安装Input system插件,重启应用新输入系统。
1.2 基本概念
完整的输入响应流程应包括:游戏行为的定义与用户输入操作间的绑定、用户交互触发与方法响应。所以主要包括设备与输入、Action与绑定、监听与响应三大块。
输入按值类型分为模拟量和数字量输入,按维度分一维、二维、三维、四维输入等。
将输入名称等描述和值类型定义封装在一起就是Control类。
Device设备就是一系列Control的集合。
用户按键与具体游戏行为间可以解耦,比如按Space角色会跳,可以在中间加一层Action命名为Jump,将Space按键与Jump Action进行绑定(Binding),当用户按Space键时就会触发Jump这个Action,然后Jump Action通知具体方法控制角色跳跃。
加入Action这样的中间层进行解耦的好处就是,如果需要更换键位,在代码层面无需改动,只需要在Bingding层进行处理即可。
而且还可以在中间层对输入数据进行预处理,过滤需要的输入,比如只接受双击输入、快速敲击输入等,这就是Interaction,只有满足规定的交互要求才能触发Action。
如果称Interaction叫输入的前置处理,那Processor就是输入的后置处理。Processor可以对输入的值进行取反、缩放、归一化、限制极值、设置死区等;
至于Action触发具体的Action Method部分,就涉及到control状态监控与事件响应了,这是整个InputSystem的核心,包含两个最重要的过程:1. control状态更新 2.control状态变更处理。
想要完整弄清整个过程需要先对InputSystem整体结构有个大概的认知。
1.3 系统结构
结构图来自官网
上图就是New Input System底层主要结构,分为:
- Unity底层(InputRuntime):传递连接到平台的原生设备数据(设备发现、设备数据上报等)到New InputSystem,以及将控制指令(如震动)下发到设备。
- New InputSystem层(InputManager):处理从Unity底层传来的设备发现、设备数据上报事件,为设备控件分配内存空间,并及时更新所代表的输入值。
New InputSystem的核心逻辑在InputManager.cs这个类中。其最核心的方法有以下几个:
OnNativeDeviceDiscovered(int deviceId, string deviceDescriptor)
- 尝试匹配断线的设备TryMatchDisconnectedDevice,匹配上直接add这个device,并触发设备重连事件
InputDeviceChange.Reconnected
- 否则根据设备描述符匹配Layout (
TryFindMatchingControlLayout
),根据匹配的layout创建InputDevice实例,并添加到系统中(调用AddDevice(InputDevice device)
)
- 尝试匹配断线的设备TryMatchDisconnectedDevice,匹配上直接add这个device,并触发设备重连事件
ShouldRunUpdate(InputUpdateType updateType)
根据InputSetting设置的更新模式(手动模式、dynamic update、fixedUpdate),判断是否处理来自Unity的输入事件OnUpdate(InputUpdateType updateType, ref InputEventBuffer eventBuffer)
这是整个InputSystem最核心的方法。它会根据事件类型(StateEvent、DeltaStateEvent、DeviceRemoveEvent、DeviceConfigurationEvent等)进行不同的处理。其中最重要的StateEvent/DeltaStateEvent事件中最核心的步骤有两个:- 将外部设备的输入数据保存到对应的control内存中:
WriteStateChange
- 触发监听control状态变更的监视器,通知control上绑定的action:
FireStateChangeNotifications
- 将外部设备的输入数据保存到对应的control内存中:
如果想深入了解整个new InputSystem,就可以看上面所列的方法。
接下来本文会对上面涉及到的部分内容进行详细论述,其中也会包含一些基础知识点的罗列。本文主要分为三大块:
- 设备与输入:介绍layout、Device、Control三个重要的概念,包含设备生命周期和updateType等知识讲解。
- Action与绑定:包含Action、Binding、Interaction、processor等内容,这是开发者使用newInputSystem时最需要了解的部分。本文对其中不同ActionType以及Interaction触发ActionPhase的区别、不同Binding冲突解决、controState监听与Action触发逻辑等内容进行了详细地辨析与论述。
- New Input System的应用:着重介绍了PlayerInput组件的使用,详细分析了三种behavior(sendMeassage、unityEvent、C#Event)之间的差别。
当然有些内容由于个人水平和精力限制并未进行论述,望见谅。如果在论述中存在不妥之处,也欢迎大家批评指正。当然,如果本文对您产生了帮助,千万不要吝惜点赞收藏和评论哦,感谢大家~
二 设备与输入(Layout、Device与Control)
2.1 Layout
Layouts 是Input System识别输入设备和输入控件类型的核心机制。每个Layout都代表一些输入控件的特定组合。通过将设备的描述与layout相匹配,输入系统能够创建正确类型的设备并正确解释传入的输入数据。
输入系统附带了一组用于常见控件类型和常见设备的布局,可以从Input Debug窗口查看:
这些layout都代表不同类别的Device或者Control。
- 对于Devices,有的是代表抽象或者叫通用布局,比如Gamepad;有的就是具体设备布局,比如继承自Gamepad的Switch Pro Controller,像它的布局里就会多一个Capture用来截屏的控件等。
- 对于Controls,不同layout代表不同类型的Control,有的是原始Control,比如Axis代表一维浮点类型的输入、Vector2代表二维浮点输入,其他的就是继承和组合自不同的原始Control了,比如ButtonControl就是继承自AxisControl,DpadControl就是继承自Vector2Control以及组合4个ButtonControl,keyControl就是继承自ButtonControl。下图是输入系统中所有原始Control:
以上都继承自InputControl<TValue>
,其中TValue指的这个Control所反映的输入值类型。
Control path
像在之后的Action中做Control绑定时,需要通过path指定control,这里的path就是指在layout中的路径,因为Device由Control组成,一些Control如Dpad是由多种Control组合而成,所以这个path的结构就如:<Gamepad>/leftStick/x
、<Gamepad>/buttonSouth
、<SwitchProControllerHID>/dpad/x
、<Keyboard>/space
等
官网还详细定义了path的路径语法,结构遵循:component/component/component...
每个component
是一个或多个<layoutName>{usageName}controlName#(displayName)
这样的可选字段组成,字段大小写敏感,具体含义如下:
这layoutName和controlName会让人有些迷惑,这两种似乎都能代表同一个路径项,但看给的path实例都只有第一层用layoutName,我用Binding Path测试了下,其实像<Gamepad>/<dpad>/down
、<gamepad>/<dpad>/left
也是能绑定成功的(没错甚至大小写也不是那么严格),但<Gamepad>/dpad/<left>
就不行了。
从Input Debug看默认的Layout结构,比如xbox,是由多个Controls组成,对于一些合成Controls比如Dpad也是由多个Controls组成,所以大概能理解上面的语法规律。
2.2 Device
2.2.1 设备的识别与Layout类型匹配
InputDeviceDescription
描述了一个设备所属平台、制造商、产品名、硬件版本、设备序列号以及功能等。输入系统主要在设备发现过程中使用它。当报告有新设备(由运行时或用户报告)时,报告包含设备描述。根据描述,系统然后尝试找到与描述匹配的设备布局。此过程基于设备匹配器InputDeviceMatcher
。
在创建设备后,您可以通过InputDevice.description
属性检索创建设备时使用的描述。
每个描述都有一组标准字段:
InputDeviceMatcher
实例负责将InputDeviceDescription
与已注册的布局进行匹配。每个匹配器在某种程度上充当正则表达式的一种形式。描述中的每个字段都可以与普通字符串或正则表达式独立匹配。匹配不区分大小写。要应用匹配器,必须使其所有单独的表达式都匹配。如下PS3手柄两个匹配器:
要将匹配器与任何布局匹配,调用InputSystem.RegisterLayoutMatcher
。您还可以在注册布局时提供它们。
// Register a new layout and supply a matcher for it.
InputSystem.RegisterLayoutMatcher<MyDevice>(matches: new InputDeviceMatcher().WithInterface("HID").WithProduct("MyDevice.*").WithManufacturer("MyBrand");
如果多个匹配器匹配相同的InputDeviceDescription
,则输入系统选择具有更多属性进行匹配的匹配器(在InputManager中遍历各matcher比较MatchPercentage
)。
2.2.2 设备生命周期
设备创建
当设备识别为特定的layout后,系统根据这个layout自动创建设备实例,最后调用FinishSetup
查找所有子控件并将它们存储在本地属性中。
protected override void FinishSetup(){buttonWest = GetChildControl<ButtonControl>("buttonWest");...startButton = GetChildControl<ButtonControl>("start");base.FinishSetup();}
创建完成后会调用一些方法更新变量或flag:
-
调用
InputDevice.MakeCurrent()
:将此设备作为同类型最近一次所使用的设备。(当添加设备或接收到输入时,输入系统会自动调用该方法。许多类型的设备都有 .current 访问器,可以直接查询特定类型设备的最后使用情况。) -
调用
InputDevice.OnAdded()
: 设备被添加后调用,一般用来把新设备添加到对应设备大类数组中(比如Gamepad、Mouse等)。//以Gamepad的实现为例protected override void OnAdded(){ArrayHelpers.AppendWithCapacity(ref s_Gamepads, ref s_GamepadCount, this);}
-
设备添加到
InputManager.devices
数组中// Add to list. device.m_DeviceIndex = ArrayHelpers.AppendWithCapacity(ref m_Devices, ref m_DevicesCount, device);
-
InputDevice.added
置为true
。public bool added => m_DeviceIndex != kInvalidDeviceIndex;
其中
kInvalidDeviceIndex = -1
,m_DeviceIndex
就是存储在InputManager.m_Devices
数组中的Index。
设备移除
当设备被移除时,其实例不会消失。可以通过InputDevice.added
属性检查设备是否激活。
设备remove后会调用一些方法更新变量或flag:
InputDevice.added
置为false
,同设备添加,也是通过getter访问器比较index得来的。- 调用
InputDevice.OnRemoved()
:在移除设备后,一般调用此方法来将基类(Keyboard、Gamepad、Mouse等)静态current变量置null,从基类静态数组中移除此实例。
设备重置
用于重置设备控件状态到默认值。常用于不支持后台运行的设备,如(iOS and Android,可能是出于性能考虑),当应用失焦时,自动触发设备重置。也可以手动重置,调用InputSystem.ResetDevice(Gamepad.current,alsoResetDontResetControls:true);
设备重置分Soft Resets
和Hard Resets
:
Soft Resets
:这是参数alsoResetDontResetControls
的默认设置。使用此类型时,只有未标记为dontRet的控件才会重置为其默认值。比如Pointer.position就设置成dontRet,这会将Pointer.position控件排除在Soft Resets之外,从而防止鼠标位置重置为(0,0)。Hard Resets
:在这种类型中,无论是否设置了DontRet,所有控件都会重置为默认值。
设备失焦与后台运行相关参见:application focus.
设备同步
可通过 RequestSyncCommand
请求设备发送包含其当前状态的事件。是否支持此功能取决于平台和设备类型。
同步请求可以使用 InputSystem.TrySyncDevice
明确发送。如果设备支持同步请求,该方法将返回 true
,并且设备上将排队等待下一次update时处理一个 InputEvent
。
设备同步请求会在Background and focus change behavior等场景中使用。
设备disable和enable
添加设备时,输入系统会向其发送一个初始的 QueryEnabledStateCommand
(查询已启用状态命令),以确定设备当前是否已启用。查询结果将反映在 InputDevice.enabled
属性中。
禁用时,除了移除(DeviceRemoveEvent
)和配置更改(DeviceConfigurationEvent
)事件外,不会处理任何其他事件,即使已发送也是如此。
可以分别通过 InputSystem.DisableDevice
和 InputSystem.EnableDevice
手动禁用和重新启用设备。
在某些情况下,输入系统可能会自动禁用和重新启用设备,详见Background and focus change behavior。
2.2.3 设备指令下发
输入事件从 "设备 "发送数据,而命令则将数据发回 “设备”。输入系统使用这些命令从设备中检索特定信息,触发设备上的功能(如震动等效果),以及满足其他各种需求。
InputSystem通过 InputDevice.ExecuteCommand<TCommand>
向设备发送命令。要监控设备命令,请使用 InputSystem.onDeviceCommand
。
2.3 Control
Control代表是输入值的来源。这些输入值要求是blittable
类型的(即可直接复制到本机结构中的类型)。
Blittable是站在基于P/Invoke的互操作(InterOp)角度对传递的值是否需要进行转换(Marshaling)而作的分类。Blittable类型要求在托管内存和非托管内存具有完全一致的表示。如果某个参数为Blittable类型,在一个P/Invoke方法调用非托管方法的时候,该参数就无需要作任何的转换。与之类似,如果调用方法的返回值是Blittable类型,在回到托管世界后也无需转换。如下的类型属于Blittable类型范畴:
- 除Boolean(bool)和Char(char)之外的12种基元类型(Primitive Type)
- 整数型10个:Byte(byte)/SByte(sbyte), Int16(short)/UInt16(ushort), Int32(int)/UInt32(uint), Int64(long)/UInt64(ulong), IntPtr(nint)/UIntPtr(nuint);浮点型2个:Float(float), Double(double)
- 因为布尔值True在不同的平台可能会表示成1或者-1,对应的字节数可能是1、2或者4,字符涉及不同的编码(Unicode和ANSI),所以这两种类型并非Blittable类型;
- Blittable基元类型的一维数组;
- 采用Sequential和Explicitly布局的且只包含Blittable类型成员的结构或者类,因为采用这两种布局的对象最终会按照一种确定的格式转换成对应的C风格的结构体。如果采用Auto布局,CLR会按照少占用内存的原则对字段成员重新排序,意味着其内存结构是不确定的。
参见:
- .NET的基元类型包括哪些?Unmanaged和Blittable类型又是什么?
- Blittable and Non-Blittable Types
前面章节提到的基本InputControl支持的TValue就是blittable
类型,float double int是primitive类型,TouchState、Quaternion、TouchPhase、Vector2、Vector3、PoseState、Bone、Eyes等都是只包含Blittable类型成员的值类型(结构体/枚举类)。(其中PoseState包含bool类型的isTracked变量,所以上面说法有待商榷)
2.3.1 InputControl
每个控件都有一个单一的、固定的值类型。且控件可以有子级,子级可以再有子级。在子级层次结构的根部始终是一个InputDevice,InputDevice本身也是InputControls。而大多数类型的控件都是从InputControl派生的。所以有必要了解以下这个InputControl
类结构:
-
parent
属性:控件的直接父级,如果控件没有父级,则为null(一旦完全构造完成,只有InputDevices才会出现这种情况)。 -
children
属性:直接子级控件数组。 -
每个控件在其
parent
的children
中必须有一个唯一的name
。可以使用别名(请参阅aliases
)为控件分配多个名称。名称查找不区分大小写。 -
displayName
属性:出于显示目的,控件可能具有单独的displayName
。此名称通常对应于控件在实际底层硬件上的名称。例如,在Xbox游戏手柄上,名称为"buttonSouth"的控件的显示名称将是"A"。具有非常长显示名称的控件还可能具有shortDisplayName。例如,Mouse上的"Left Button"就是这种情况,通常缩写为"LMB"。 -
usages
属性:除了名称,控件还可以与其关联使用(请参阅usages
)。使用指示控件的预期用途。例如,按钮可以被分配"PrimaryAction"使用,表示它是设备上的主要操作按钮。在设备内,使用必须是唯一的。请参阅CommonUsages
以获取标准使用列表。 -
stateBlock
属性:控件实际上并不存储值。相反,每个控件都接收一个InputStateBlock
结构体,在控件的设备添加到系统后,用于从设备的后备存储(Backing Store)中读取值。此后备存储在API中被称为"state",而不是"values",后者代表从读取状态产生的数据。每个控件存储状态的格式是特定于控件的。不仅可以在不同类型的控件之间变化,而且在相同类型的控件之间也可以变化。例如,AxisControl可以存储为float,也可以存储为byte或其他多种格式。stateBlock同时标识了控件存储其状态的位置和存储的格式。这个Backing Store有两种理解方式:
- 指InputControl的私有变量
InputStateBlock m_StateBlock
,这是相对stateBlock
属性来说的。
A private field that stores the data exposed by a public property is called a backing store or backing field.
来源:Fields (C# Programming Guide) - 后备存储这个术语指的是存储数据但不执行数据的任何内存。
来源:Backing storage
我认为文中的Backing Store更符合第二种定义,是相对于实际产生数据的物理设备来说的。
其他参考:Using Streams
- 指InputControl的私有变量
-
layouts
属性:通常不直接创建控件,而是由输入系统从称为"layouts"的数据中内部创建的(参见InputControlLayout
)。每个此类布局描述特定控件层次结构的设置。系统内部维护布局注册表,并根据需要从中生成设备和控件。可以使用layout查询控件所创建的布局。对于大多数用途,可以忽略控件布局机制的细节,只需知道一小组常见设备布局的名称即可,例如"Keyboard"、“Mouse”、“Gamepad"和"Touchscreen”。
2.3.2 InputControl<TValue>
InputControl<TValue>
继承于InputControl
类:
public abstract class InputControl<TValue> : InputControlwhere TValue : struct
其额外包含如下几个特殊的属性和方法:
-
valueType
属性:public override Type valueType => typeof(TValue);
TValue是控件捕获的值的类型。请注意,这并不意味着控件必须以给定的值格式存储数据。例如,捕获浮点值的控件在状态中可能以字节值的形式存储。
-
public ref readonly TValue value
属性: 当前control对应的值。后面Action读值时也是通过读取绑定的control的value来获得的。 -
TValue ReadValue()
方法:返回当前control对应的值,也即InputControl<TValue>.value
属性 -
ReadUnprocessedValueFromState
方法:获取输入值的核心方法,具体实现交由各个Control实现类实现,其核心就是stateBlock
中与指针与解引用相关的内存操作。public abstract unsafe TValue ReadUnprocessedValueFromState(void* statePtr);/// 以AxisControl的实现为例 public override unsafe float ReadUnprocessedValueFromState(void* statePtr){switch (m_OptimizedControlDataType){case InputStateBlock.kFormatFloat:return *(float*)((byte*)statePtr + m_StateBlock.m_ByteOffset);case InputStateBlock.kFormatByte:return *((byte*)statePtr + m_StateBlock.m_ByteOffset) != 0 ? 1.0f : 0.0f;default:{var value = stateBlock.ReadFloat(statePtr);return Preprocess(value);}}}
2.3.3 输入值更新 ⭐
2.3.3.1 InputUpdateType
InputSystem中会有以下枚举表明输入更新类型,系统根据类型决定是否处理输入更新。
-
None :
- 不执行实际更新,但仍允许设备进行重置,以便Awake和Start等脚本方法可以获取设备。通常在域重新加载(domain reload)后立即发生。(参见
InputSystem.RunInitialUpdate()
和InputManager.ShouldRunUpdate()
)
- 不执行实际更新,但仍允许设备进行重置,以便Awake和Start等脚本方法可以获取设备。通常在域重新加载(domain reload)后立即发生。(参见
-
Dynamic :
- 对应于
MonoBehaviour.Update
的Input update。 - 每帧只有一个dynamic update。如果未使用PlayerLoop进行重新配置,dynamic update将在该帧的所有(0次或多次)fixed update之后运行。
- Dynamic Input update在 MonoBehaviours 的脚本回调之前运行。
- 对应于
-
Fixed :
- 对应于
MonoBehaviour.FixedUpdate
的Input update。 - 每帧有零次或多次fixed update。这些在该帧的dynamic update之前运行。
- Fixed Input update在 MonoBehaviours 的脚本回调之前运行。
- 对应于
-
BeforeRender :
- 在渲染之前发生的输入更新。
- BeforeRender 更新仅影响启用了渲染前更新的设备。必须通过设备的布局(
InputControlLayout.updateBeforeRender
)进行配置,并通过InputDevice.updateBeforeRender
可见。 - BeforeRender 更新非常有用,可用于最小化渲染中使用的变换数据的延迟,该数据来自外部跟踪设备。例如,头戴式显示器(HMDs)就是一个例子。如果在渲染之前未同步用于渲染摄像机的头部变换,可能导致头部和摄像机移动之间出现明显的延迟。
-
Editor:
- 在更新 UnityEditor.EditorWindow 之前发生的输入更新。
- 此更新仅在编辑器中发生。它在 UnityEditor.EditorApplication.update 之前触发。
-
Manual:
- 使用Manual则输入更新不会自动发生,而必须通过调用
InputSystem.Update
手动触发。
- 使用Manual则输入更新不会自动发生,而必须通过调用
-
Default :
- 默认的update mask。包含
Dynamic
、Fixed
和Editor
更新模式。
- 默认的update mask。包含
2.3.3.2 PlayerLoop
在 Unity 2018.1 中,引入了 PlayerLoop 和 PlayerLoopSystem 类以及 UnityEngine.Experimental.PlayerLoop 命名空间,允许用户移除和重新排序引擎更新系统,以及实现自定义系统。
PlayerLoopSystem 是一个以递归、树状结构组织的结构体。可以通过下面代码打印默认结构:
[RuntimeInitializeOnLoadMethod]
private static void AppStart()
{var def = PlayerLoop.GetDefaultPlayerLoop();var sb = new StringBuilder();RecursivePlayerLoopPrint(def, sb, 0);Debug.Log(sb.ToString());
}private static void RecursivePlayerLoopPrint(PlayerLoopSystem def, StringBuilder sb, int depth)
{if (depth == 0){sb.AppendLine("ROOT NODE");}else if (def.type != null){for (int i = 0; i < depth; i++){sb.Append("\t");}sb.AppendLine(def.type.Name);}if (def.subSystemList != null){depth++;foreach (var s in def.subSystemList){RecursivePlayerLoopPrint(s, sb, depth);}depth--;}
}
得到的结果如下:
ROOT NODEInitializationPlayerUpdateTimeAsyncUploadTimeSlicedUpdateSynchronizeInputsSynchronizeStateXREarlyUpdateEarlyUpdatePollPlayerConnectionProfilerStartFrameGpuTimestampUnityConnectClientUpdateCloudWebServicesUpdateUnityWebRequestUpdateExecuteMainThreadJobsProcessMouseInWindowClearIntermediateRenderersClearLinesPresentBeforeUpdateResetFrameStatsAfterPresentUpdateAllUnityWebStreamsUpdateAsyncReadbackManagerUpdateTextureStreamingManagerUpdatePreloadingRendererNotifyInvisiblePlayerCleanupCachedDataUpdateMainGameViewRectUpdateCanvasRectTransformUpdateInputManagerProcessRemoteInputXRUpdateTangoUpdateScriptRunDelayedStartupFrameUpdateKinectDeliverIosPlatformEventsDispatchEventQueueEventsDirectorSampleTimePhysicsResetInterpolatedTransformPositionNewInputBeginFrameSpriteAtlasManagerUpdatePerformanceAnalyticsUpdateFixedUpdateClearLinesNewInputEndFixedUpdateDirectorFixedSampleTimeAudioFixedUpdateScriptRunBehaviourFixedUpdateDirectorFixedUpdateLegacyFixedAnimationUpdateXRFixedUpdatePhysicsFixedUpdatePhysics2DFixedUpdateDirectorFixedUpdatePostPhysicsScriptRunDelayedFixedFrameRateScriptRunDelayedTasksNewInputBeginFixedUpdatePreUpdatePhysicsUpdatePhysics2DUpdateCheckTexFieldInputIMGUISendQueuedEventsNewInputUpdateSendMouseEventsAIUpdateWindUpdateUpdateVideoUpdateScriptRunBehaviourUpdateScriptRunDelayedDynamicFrameRateDirectorUpdatePreLateUpdateAIUpdatePostScriptDirectorUpdateAnimationBeginLegacyAnimationUpdateDirectorUpdateAnimationEndDirectorDeferredEvaluateUpdateNetworkManagerUpdateMasterServerInterfaceUNetUpdateEndGraphicsJobsLateParticleSystemBeginUpdateAllScriptRunBehaviourLateUpdateConstraintManagerUpdatePostLateUpdatePlayerSendFrameStartedDirectorLateUpdateScriptRunDelayedDynamicFrameRatePhysicsSkinnedClothBeginUpdateUpdateCanvasRectTransformPlayerUpdateCanvasesUpdateAudioParticlesLegacyUpdateAllParticleSystemsParticleSystemEndUpdateAllUpdateCustomRenderTexturesUpdateAllRenderersEnlightenRuntimeUpdateUpdateAllSkinnedMeshesProcessWebSendMessagesSortingGroupsUpdateUpdateVideoTexturesUpdateVideoDirectorRenderImagePlayerEmitCanvasGeometryPhysicsSkinnedClothFinishUpdateFinishFrameRenderingBatchModeUpdatePlayerSendFrameCompleteUpdateCaptureScreenshotPresentAfterDrawClearImmediateRenderersPlayerSendFramePostPresentUpdateResolutionInputEndFrameTriggerEndOfFrameCallbacksGUIClearEventsShaderHandleErrorsResetInputAxisThreadedLoadingDebugProfilerSynchronizeStatsMemoryFrameMaintenanceExecuteGameCenterCallbacksProfilerEndFrame
和newInputSystem相关的有:FixedUpdate.NewInputFixedUpdate
PreUpdate.NewInputUpdate
对应的应该就是newInputSystem的FixedUpdate
和Dynamic
类型
对比结构树中script的几种更新,可以看到input更新顺序都是在其所对应script更新之前,以确保使用时输入数据已更新完毕。
参考:
- 如何使用Unity Profile定位性能热点
- Unity 2018 and PlayerLoop
2.3.3.3 update逻辑
update分手动更新和自动更新,手动更新就是调用 InputSystem.Update
手动触发;自动更新就是交由PlayerLoop触发fixedUpdate或者dynamic update。
Unity InputRuntime调用ShouldRunUpdate方法判断是否处理输入更新,然后调用NotifyUpdate,通知InputManager执行OnUpdate,这就是真正的输入更新执行方法。
执行输入更新相关逻辑比较杂,包含很多边界问题的处理,比如应用失焦、事件缓冲区中没有事件等可以提前退出update处理,以及包含合并inputEvent、处理Action Interaction超时等。但最核心的还是前面提到过的两个方法:
- 将外部设备的输入数据保存到对应的control内存中:
WriteStateChange
- 触发监听control状态变更的监视器,通知control上绑定的action:
FireStateChangeNotifications
writeState就是control stateBlock的内存操作,不再论述;stateChangeNotify就是处理Action的触发,这在Action章节还会详细论述。
2.3.4 输入值读取
每个控制都连接到被视为控制“状态”的内存块。通过InputControl.stateBlock属性,您可以从控制查询此内存块的大小、格式和位置。
控制的状态存储在由输入系统在内部处理的非托管内存中。添加到系统的所有设备共享一个非托管内存块,其中包含设备上所有控件的状态。
控制的状态可能不以该控制的自然格式存储。例如,系统通常将按钮表示为位字段,将轴控件表示为8位或16位整数值。此格式由平台、硬件和驱动程序的组合确定。每个控制都知道其存储的格式以及如何根据需要转换值。输入系统使用布局来理解此表示。
通过其ReadValue
方法,您可以访问控制的当前状态。
Gamepad.current.leftStick.x.ReadValue();
三 Action的绑定、交互与后置处理(Action、Binding、Interaction与Processor)
从上一节可以知道,可以直接读取InputControl
然后交给后续游戏逻辑进行处理,比如读取摇杆值代表角色移动:
void Update(){var gamepad = Gamepad.current;if (gamepad == null){return; // No gamepad connected.}Vector2 move = gamepad.leftStick.ReadValue();{// 'Move' code here}}
但这种方式有些缺点:
- 当游戏支持的设备变多,就会多出很多判断设备是否存在的代码;
- 而且这些读值通常是写在update中进行轮询,效率比较低,像攻击、开火这种低频操作使用这种轮询的方式就显得过于浪费了;
- 如果开发者或者用户需要改键,这种设计方式就显得很笨拙。
为此,newInputSystem就抽象出一层Action类,用其定义实际游戏行为,比如角色的走、跑、跳等,用来解耦用户操作和实际游戏中对应的行为:
public InputAction moveAction;public void Update(){var move = moveAction.ReadValue<Vector2>();Move(move);}
这样比如操作leftStick就只是leftStick control值变更,move这个Action触发就处理move逻辑,至于control值变更会触发什么、以及Action是从哪个control读值的,它俩自身是不知道的,而是通过Binding来绑定它俩的关系才能得知。
而对于Action的不同交互行为、Action读取输入值、Action后置处理等就关乎Interaction、Processor,以及输入监听与事件触发等内容了,下面会依次介绍。
3.1 Action
在API中,有三个关键的类用于处理Action:
类 | 描述 |
---|---|
InputActionAsset | 包含一个或多个Action Map 的资源。 |
InputActionMap | 一组具名的Action集合。 |
InputAction | 响应输入触发回调的具名Action。 |
核心就是InputAction,下面将介绍Action的基础概念。
3.1.1 Action的创建
有以下四种方式:
-
使用Input Action Assets进行UI编辑
-
嵌入MonoBehaviours脚本中
public class ExampleScript : MonoBehaviour {public InputAction fireAction;public InputAction lookAction;void Awake(){fireAction.performed += OnFire;lookAction.performed += OnLook;}void OnEnable(){fireAction.Enable();lookAction.Enable();}void OnDisable(){fireAction.Disable();lookAction.Disable();}}
此时可以在对象的Inspector进行额外编辑
-
从json文件加载
// Load a set of action maps from JSON. var maps = InputActionMap.FromJson(json);// Load an entire InputActionAsset from JSON. var asset = InputActionAsset.FromJson(json);
-
全代码控制
// Create free-standing Actions. var lookAction = new InputAction("look", binding: "<Gamepad>/leftStick"); var moveAction = new InputAction("move", binding: "<Gamepad>/rightStick");lookAction.AddBinding("<Mouse>/delta"); moveAction.AddCompositeBinding("Dpad").With("Up", "<Keyboard>/w").With("Down", "<Keyboard>/s").With("Left", "<Keyboard>/a").With("Right", "<Keyboard>/d");// Create an Action Map with Actions. var map = new InputActionMap("Gameplay"); var lookAction = map.AddAction("look"); lookAction.AddBinding("<Gamepad>/leftStick");// Create an Action Asset. var asset = ScriptableObject.CreateInstance<InputActionAsset>(); var gameplayMap = new InputActionMap("gameplay"); asset.AddActionMap(gameplayMap); var lookAction = gameplayMap.AddAction("look", "<Gamepad>/leftStick");
3.1.2 对 Action 的响应 ⭐
一个Action 本身并不代表对输入的实际响应。相反,一个Action 通知您的代码发生了某种类型的输入。然后,您的代码对此信息作出响应。
有几种方法可以实现这一点:
- 每个Action 都有一个
started
、performed
和canceled
回调。 - 每个ActionMap都有一个
actionTriggered
回调。 - 输入系统有一个全局的
InputSystem.onActionChange
回调。 - 您可以在需要时轮询 Action 的当前状态。
InputActionTrace
可以记录在操作上发生的变化。
还有两种更高级、更简化的方式来从Action 中获取输入,即使用 PlayerInput,后面会介绍。
Action Callback
每个Action都有一组不同的阶段,用于标定Action的触发状态,便于后续对交互的模拟与处理。
阶段 | 描述 |
---|---|
Disabled | Action 已禁用,无法接收输入。 |
Waiting | Action 已启用,正在积极等待输入。 |
Started | 输入系统已接收到启动与Action 相关的输入。 |
Performed | 与Action 的交互已完成。 |
Canceled | 与Action 的交互已取消。 |
你可以使用 InputAction.phase
读取Action 的当前阶段。
每个 Started、Performed 和 Canceled 阶段都有与之关联的回调:
var action = new InputAction();action.started += ctx => /* Action 已启动 */;
action.performed += ctx => /* Action 已完成 */;
action.canceled += ctx => /* Action 已取消 */;
-
Started 对于 UI 反馈可能很有用。例如,在一个可以充能武器的游戏中,当动作开始时可以启动 UI 反馈。
-
每个回调都接收一个
InputAction.CallbackContext
结构,其中包含上下文信息,您可以使用它来查询Action 的当前状态,并从触发Action 的控件中读取值 (InputAction.CallbackContext.ReadValue
)。 -
注意:结构的内容仅在回调期间有效。特别是下面行为是不安全的:将接收到的上下文存储起来,然后在回调外部访问其属性。
-
回调何时以及如何触发取决于绑定上的相应交互。如果绑定没有适用于它们的任何交互,那么将应用默认交互。
InputActionMap.actionTriggered 回调
与监听单个Action不同,您可以在整个ActionMap上监听ActionMap中任何Action的状态变更。
var actionMap = new InputActionMap();
actionMap.AddAction("action1", "<Gamepad>/buttonSouth");
actionMap.AddAction("action2", "<Gamepad>/buttonNorth");actionMap.actionTriggered +=context => { /* ... */ };
接收到的参数与通过 started
、performed
和 canceled
回调收到的 InputAction.CallbackContext
结构相同。
注意:输入系统对所有三个Action的各自回调都调用 InputActionMap.actionTriggered
。也就是说,actionTriggered
会监听 started
、performed
和 canceled
三种状态变更。
InputSystem.onActionChange 回调
类似于 InputSystem.onDeviceChange
,您的应用程序可以全局监听任何与Action相关的变更。
InputSystem.onActionChange +=(obj, change) =>{// obj can be either an InputAction or an InputActionMap// depending on the specific change.switch (change){case InputActionChange.ActionStarted:case InputActionChange.ActionPerformed:case InputActionChange.ActionCanceled:Debug.Log($"{((InputAction)obj).name} {change}");break;}}
Polling Action
有时候与其使用回调,不如在代码中需要时轮询Action的值可能更简单。
您可以使用 InputAction.ReadValue<>()
轮询Action的当前值:
public InputAction moveAction;public float moveSpeed = 10.0f;public Vector2 position;void Start(){moveAction.Enable();}void Update(){var moveDirection = moveAction.ReadValue<Vector2>();position += moveDirection * moveSpeed * Time.deltaTime;}
要确定在当前帧中是否执行了某个Action,您可以使用 InputAction.WasPerformedThisFrame()
:
void Update(){if (action.WasPerformedThisFrame())Debug.Log("A button on gamepad was held for one second");}
最后,有三种方法可以用于轮询Button的按下和释放:
方法 | 描述 |
---|---|
InputAction.IsPressed() | 如果Action的激活水平越过了按压点并且尚未降到或低于释放阈值,则返回True。 |
InputAction.WasPressedThisFrame() | 如果在当前帧的任何时刻,Action的激活水平达到或超过按压点,则返回True。 |
InputAction.WasReleasedThisFrame() | 如果在当前帧的任何时刻,Action的激活水平从按压点以上降到或低于释放阈值,则返回True。 |
InputActionTrace记录
您可以使用InputActionTrace
跟踪Actions以生成发生在特定一组Actions上的所有活动的日志。
注意:InputActionTrace
分配非托管内存,需要进行处理以防止内存泄漏。
var trace = new InputActionTrace();// Subscribe trace to single Action.
// (Use UnsubscribeFrom to unsubscribe)
trace.SubscribeTo(myAction);// Subscribe trace to entire Action Map.
// (Use UnsubscribeFrom to unsubscribe)
trace.SubscribeTo(myActionMap);// Subscribe trace to all Actions in the system.
trace.SubscribeToAll();// Record a single triggering of an Action.
myAction.performed +=ctx =>{if (ctx.ReadValue<float>() > 0.5f)trace.RecordAction(ctx);};// Output trace to console.
Debug.Log(string.Join(",\n", trace));// Walk through all recorded Actions and then clear trace.
foreach (var record in trace)
{Debug.Log($"{record.action} was {record.phase} by control {record.control}");// To read out the value, you either have to know the value type or read the// value out as a generic byte buffer. Here, we assume that the value type is// float.Debug.Log("Value: " + record.ReadValue<float>());// If it's okay to accept a GC hit, you can also read out values as objects.// In this case, you don't have to know the value type.Debug.Log("Value: " + record.ReadValueAsObject());
}
trace.Clear();// Unsubscribe trace from everything.
trace.UnsubscribeFromAll();// Release memory held by trace.
trace.Dispose();
3.1.3 三种Action类型 ⭐
每个Action可以是三种不同的Action类型之一。可以在Input Action编辑窗口中选择Action类型,或者在调用InputAction()构造函数时通过指定type参数来选择。Action类型影响Input System如何处理Action的状态变化。默认的Action类型是Value。
var action = new InputAction(type: InputActionType.PassThrough, binding: "<Gamepad>/rightTrigger");
-
Value
-
这是默认的Action类型。用于跟踪控件状态的连续变化的任何输入。
-
Value类型的Action持续监视绑定到该Action的所有控件,然后选择最活跃的控件作为驱动该Action的控件,并在值发生变化时触发回调。如果其他绑定的控件更活跃,那么该控件将成为驱动Action的控件,并且Action开始从该控件反馈输入值。这个过程称为冲突解决。如果希望允许不同的控件在游戏中控制一个Action,但同时只接受一个控件的输入,这是很有用的。
-
当Action初始启用时,它执行所有绑定控件的初始状态检查。如果其中任何一个被激活,Action将触发一个带有当前值的回调。
-
-
Button
- 这与Value非常相似,但是Button类型的操作只能绑定到ButtonControl控件,而且不像Value Actions那样执行初始状态检查。在这种情况下,初始状态检查通常没有用处,因为它可能在启用Action时仍按住按钮,从而触发动作。
-
Pass-Through
- Pass-Through Actions绕过了上面描述的Value Actions的冲突解决过程,并且无需使用特定控件来驱动Action。相反,任何绑定控件的任何更改都会触发带有该控件值的回调。如果希望处理一组控件的所有输入,这是很有用的。
注意: 1.5.1版本 button 和 pass-through是可选择初始状态检测的,但测试下来似乎没有预想中的效果,可能功能还不够完善。
3.2 Interaction
交互表示特定的输入模式。例如,hold 是一种交互,它要求控件至少保持最少一段时间。
交互驱动对Action的响应。你可以把它们放在单独的绑定上,也可以把它们作为一个整体放在一个Action上,在这种情况下,它们应用于Action上的每个Binding。在运行时,当一个特定的交互完成时,将触发Action。
注意:
- 如果一个单一的 Binding 或 Action 上存在多个 Interactions,则 Input System 按照它们在 Binding 上的顺序检查这些 Interactions。
- 在任何时候,只有一个 Interaction 能够 “驱动” Action 。如果堆栈中较高位置的 Interaction 被取消,堆栈中较低位置的 Interactions 可以接管。
- 如果交互同时应用于一个Action和它的绑定,那么效果就像将Action 的交互添加到每个绑定上的交互列表中一样。这意味着首先应用绑定的交互,然后再应用Action 的交互。
InputSystem包含五种预设的Interaction:Press、Hold、Tap、SlowTap、MultiTap,用于模拟不同的交互行为,比如持续按压、双击等。如果没有这种特殊的交互需求,那么Action将会以默认的方式进行处理,即Default Interaction。如果使用预设的特殊交互,对Action的处理会有别于Default Interaction。
先熟悉下Interaction的创建。
3.2.1 Interaction的创建
可以通过UI、json或者code方式创建:
-
Asset UI
-
json
上面asset保存后就得到下面json文件:"actions": [{"name": "fire","type": "Button","id": "1077f913-a9f9-41b1-acb3-b9ee0adbc744","expectedControlType": "Button","processors": "","interactions": "SlowTap(duration=1)","initialStateCheck": false}]"bindings": [{"name": "","id": "ae1a79f3-9ec1-44cb-bc63-51c850e4b5e0","path": "<Keyboard>/space","interactions": "MultiTap(tapTime=0.2,tapDelay=0.75,tapCount=3,pressPoint=0.5)","processors": "","groups": "","action": "fire","isComposite": false,"isPartOfComposite": false}]
-
code string
1. 应用到到Action var actionWithoutInteraction = new InputAction(type: InputActionType.Button, binding: "<Gamepad>/buttonSouth"); var holdAction = new InputAction(binding: "<Gamepad>/buttonSouth", interactions: "hold(duration=2)"); var tapAction = new InputAction(binding: "<Gamepad>/buttonSouth", interactions: "tap(duration=2)"); var multiTapAction = new InputAction(binding: "<Gamepad>/buttonSouth", interactions: "multitap(tapCount=2,tapTime=2,tapDelay=2)");2.应用到到binding var keyboard = InputSystem.AddDevice<Keyboard>(); var asset = ScriptableObject.CreateInstance<InputActionAsset>(); var map1 = new InputActionMap("map1"); asset.AddActionMap(map1);var action1 = map1.AddAction("action1"); action1.AddBinding("<Keyboard>/a", interactions: "press(behavior=0)");
3.2.2 Default Interaction ⭐
如果你没添加Tap/Multi-Tap等预设交互,那么将使用默认交互,对不同的Action值类型将会有不同行为,
不同的状态变化会触发Action对应的回调函数:
started
:Waiting→Started(Button/Value)、Performed→Started(Button)performed
:Waiting→Performed(PassThrough)、Performed→Performed(PassThrough) 、Started→Performed(Button/Value)canceled
:Started→Canceled(Button/Value)、Performed→Canceled(Button)
注意以下状态变化不会被监控,不会触发回调:
Value
类型在Performed
后会回到Started
状态,Canceled
后回到Waiting
。Button
类型在Performed
后会保持Performed
状态(但Button没有Performed→Performed状态,所以按压回弹期间不会多次触发performed),Canceled
后回到Waiting
。
说明:
-
Value
类型的Action具有以下行为:- 一旦绑定的控件被激活,操作从
Waiting
状态切换到Started
状态,然后立即切换到Performed
状态,再返回到Started
状态。在InputAction.started
上触发一个回调,然后在InputAction.performed
上触发一个回调。 - 只要绑定的控件保持激活状态,
Action
将保持在Started
状态,并在控件值发生更改时触发Performed
(也就是说,在InputAction.performed
上发生一次调用)。 - 当绑定的控件停止激活时,操作切换到
Canceled
状态,然后返回到Waiting
状态。在InputAction.canceled
上触发一个调用。 - 案例:
- 例如,如果一个Action绑定到 leftStick,而摇杆从 (0,0) 移动到 (0.5,0.5),该Action就会依次触发Started和Performed事件。如果控制杆变为 (0.75,0.75),然后又变为 (1,1),则会触发两次Performed事件。如果摇杆移回 (0,0),将触发Canceled事件。
- 一旦绑定的控件被激活,操作从
-
Button
类型的Action具有以下行为:- 一旦绑定的控件被激活(按压阈值 > 0),Action从
Waiting
状态切换到Started
状态。在InputAction.started
上触发一个回调。 - 如果控件达到或超过按钮按压阈值,Action从
Started
状态切换到Performed
状态。在InputAction.performed
上触发一个回调。按压阈值的默认值在输入设置中定义,单个控件也可以覆盖此值。 - Action
Performed
后,如果控件值仍然高于按压阈值,Action将保持Performed
状态并不会触发任何回调。 - Action
Performed
后,如果控件值低于按压阈值但高于释放阈值,Action会回到Started
状态,并触发回调。 - Action
Performed
后,如果控件值回到或低于释放阈值,Action从Performed
状态切换到Canceled
状态,并在InputAction.canceled
上触发一次调用。 - 如果Action从未
Performed
,一旦按压值回到0,它将转换为Canceled
状态。在InputAction.canceled
上触发一次调用。
- 一旦绑定的控件被激活(按压阈值 > 0),Action从
-
PassThrough
类型的Action具有更简单的行为。输入系统不尝试将绑定的控件跟踪为单一的输入源。相反,它会为每个值更改触发一次Performed
回调。-
PassThrough在某些方面类似于 Value。然而,有两个关键差异。
-
首先,当同时绑定到多个控件时,该Action不会执行任何消除歧义。这意味着例如,如果该Action同时绑定到游戏手柄的左摇杆和右摇杆,左摇杆移动到 (0.5,0.5),然后右摇杆移动到 (0.25,0.25),该Action将Performed两次,首先产生值 (0.5,0.5),然后是值 (0.25, 0.25)。这与 Value 不同,后者在激活到 (0.5,0.5) 时,左摇杆将驱动该Action,并且右摇杆的激活将被忽略,因为它没有超过左摇杆的激活幅度。
-
第二个关键差异是只使用 Performed,并且将在每次值变化时触发,无论值是什么。这与 Value 不同,后者在移动离开其默认值时触发 Started,并在返回默认值时触发 Canceled。在键盘等输入时这两者差别尤为明显,passthrough一次按压释放会触发两次
performed
,而value仅触发一次performed
。 -
请注意,PassThrough Action仍可能被取消,因此可能会看到调用 canceled。当除了设备上的输入之外的其他因素导致正在进行的Action被取消时,会发生这种情况。例如,当禁用Action或失去焦点以及设备与Action的连接被重置时。
-
-
注意:
-
PassThrough
类型在Performed
后会保持Performed
状态(源码的注释有问题,注释说会回到waiting其实并没有)。感兴趣的可以打断点验证下:
[Test]public void Actions_passthroughActions(){var gamepad = InputSystem.AddDevice<Gamepad>();var rightTriggerValue = new InputAction(type: InputActionType.PassThrough, binding: "<Gamepad>/rightTrigger");rightTriggerValue.Enable();using (var rightTriggerValueTrace = new InputActionTrace(rightTriggerValue)){Set(gamepad.rightTrigger, 0.25f);Assert.That(rightTriggerValueTrace, Performed(rightTriggerValue, gamepad.rightTrigger, value: 0.25f));rightTriggerValueTrace.Clear();Set(gamepad.rightTrigger, 0.6f); Assert.That(rightTriggerValueTrace, Performed(rightTriggerValue, gamepad.rightTrigger, value: 0.6f));rightTriggerValueTrace.Clear(); Set(gamepad.rightTrigger, 0.9f); Assert.That(rightTriggerValueTrace, Performed(rightTriggerValue, gamepad.rightTrigger, value: 0.9f));}}
-
passThrough
实测下来是无法触发started
的。canceled
可以通过Action.disable()
或者InputSystem.RemoveDevice(oneDevice)
是可以触发的。感兴趣的可以打断点验证:[Test]public void Actions_passthroughCanceled(){var gamepad = InputSystem.AddDevice<Gamepad>();var rightTriggerValue = new InputAction(type: InputActionType.PassThrough, binding: "<Gamepad>/rightTrigger");rightTriggerValue.started += OnStarted;rightTriggerValue.performed += OnPerformed;rightTriggerValue.canceled += OnCanceled;rightTriggerValue.Enable();Set(gamepad.rightTrigger, 0.6f);rightTriggerValue.Disable();// 或者InputSystem.RemoveDevice(gamepad);}private void OnCanceled(InputAction.CallbackContext obj){Debug.Log("Canceled");}private void OnStarted(InputAction.CallbackContext obj){Debug.Log("Started");}private void OnPerformed(InputAction.CallbackContext obj){Debug.Log("Performed");
-
-
用表格总结:
Callback | InputActionType.Value | InputActionType.Button | InputActionType.PassThrough |
---|---|---|---|
started | 控件状态值离开默认值时触发 | 1. 按钮被按下,按压值离开0时触发 2. 或者按钮已经performed,然后回落到低于按压阈值但高于释放阈值,这时也会触发started | |
performed | 控件状态值改变时触发 | 按钮被按住,按压值达到按压阈值时触发 | 控件状态值改变时触发(值回到0也会performed,所以键盘按键按压并释放会触发两次performed) |
canceled | 控件状态值回到默认值时触发 | 按钮被松开, 1. 如果曾已达到按压阈值,则会在达到或低于释放阈值时触发; 2. 如果按压值未曾达到按压阈值,则会在回到0时触发. | Action 被禁用时触发 |
可以用这个测试用例检验自己是否真的理解了:
- deafault Interaction-ButtonAndValue测试用例
- deafault Interaction-passthrough测试用例
3.2.3 五种预设的Interaction
Press
-
press Interaction同样使用了pressPoint(即按压点或按压阈值)和releaseThreshold(释放阈值,是百分值,实际释放点
releasePoint = pressPoint * releaseThreshold
)的概念。- 这里说明下,尽管releaseThreshold在系统中代表释放点与按压点的比值,但本文中可能还是会用释放阈值来表示实际的释放点。按压点和按压阈值也混用表示同样概念,不影响理解。
-
press Interaction和Button的default interaction类似,不过分得更细,包含三种behavior:
PressOnly
,ReleaseOnly
,PressAndRelease
,默认PressOnly。- PressOnly这种形式和Button action触发回调类似,即达到按压阈值performed,低于释放阈值canceled。
- ReleaseOnly超过按压阈值是不触发performed的,只有从高于按压点回落到释放点才会performed。
- 注意:如果不超过presspoint然后回落到releasePoint是不会触发performed的。
- PressAndRelease结合上面两种行为:达到触发阈值或者从高于按压点回落到释放点都会触发performed。
表格总结:
behavior /callback | PressOnly | ReleaseOnly | PressAndRelease |
---|---|---|---|
started | 1. 按压值离开默认0值时触发 2. 从高于按压点降到释放点以下且非0时,也会触发started(与Button Action有些差异) | 按压值离开默认0值时触发 | 按压值离开默认0值时触发 |
performed | 首次高于按压阈值时触发 | 从高于按压点降到释放点以下时触发performed,canceled紧随其后 | 1. 首次高于按压阈值时触发 2. 从高于按压点降到释放点以下时触发performed |
canceled | 回落0时触发 | 1.触发perform时紧随着canceled 2. 回落0时触发 | 回落0时触发 (当且仅当触发非0 release performed后立即降为0时,不会触发canceled,应该是bug,不过实际项目估计不会有这种情况) |
- 注意官方文档对Press Interaction的描述完全错误!
- 测试用例参见:【Unity学习笔记】第十三 · New Input System 2(部分源码解读、测试用例等补充)- Press Interaction测试用例
Hold
长按一定时间才能触发Action,这个时间duration可以自定义,且使用的是实际时间,与timescale无关。
Action回调触发方式:
Action callbacks | 触发方式 |
---|---|
started | 控件值大小 ≥ presspoint |
performed | 控件值大小≥ presspoint的时间超过duration |
canceled | 1. performed后控件值大小回落presspoint之下 2. 或者保持时常不足duration 时触发(提前回落到0) |
peroform的两种方式:
- 定时器超时 2. 输入时间与started time判断
为什么需要定时器超时,用两次输入比较time间差值,只要大于duration不就行了吗?
- 有这么一种情况,比如键盘的ctrl、shift键,只会在按下时发送一次事件以及抬起再发一次事件,也就是说长按期间不发送事件(这与字母、数字键不同,它们长按期间也会发送输入事件,可以去Input debugger验证下)。所以对于这种特殊情况,是无法进行两次输入时间比较的,所以要用定时器进行判断。(
这是我的理解,不一定对
) - 还有就是hold interaction希望保持时间一到就触发performed,而不是依赖按钮释放才进行判断,否则的话就是slowTap的交互模式了,所以hold有定时器,而slowTap不需要定时器。
定时器:
定时器触发的方法调用链:
Tap
快速敲击时触发,要求一次按下与释放间隔不超过Max Tap Duration(默认0.2秒)
Action回调触发方式:
Action callbacks | 触发方式 |
---|---|
started | 控件值大小 ≥ presspoint时触发 (开始按压到阈值) |
performed | 释放时,控件值大小≥ presspoint的时间不超过duration (按压与释放时间差值小于duration) |
canceled | 控件值大小 ≥ presspoint保持时长超过duration 时触发(定时器超时 或 按压与释放差值超过) |
SlowTap
慢速敲击,要求一次按压与释放间隔一定的时间(Min Tap Duration),敲击太快仅会触发canceled。
Action回调触发方式:
Action callbacks | 触发方式 |
---|---|
started | 控件值大小 ≥ presspoint时触发 (开始按压到阈值) |
performed | 释放时,控件值大小≥ presspoint的时间超过duration (按压与释放时间差值大于duration) |
canceled | 释放时,控件值大小 ≥ presspoint保持时长小于duration 时触发(按压与释放差值小于duration ) |
注意:slowTap未使用定时器。
MultiTap
多次快速敲击时触发performed
。
- 首先是快速敲击,所以,依旧要求一次按下与释放间隔不超过
Max Tap Duration
(默认0.2秒) - 其次,要求每次释放与按压间隔不超过
Max Tap Spacing
(也叫tapDelay
,默认0.75秒) - 释放时,敲击次数达到
Tap Count
时才会触发performed
注意:如果Action-Asset没有 MultiTap选项,尝试重新导入InputSystem(不知道为啥我会遇到这个bug)
Action回调触发方式:
Action callbacks | 触发方式 |
---|---|
started | 控件值大小 ≥ presspoint时触发 (开始按压到阈值) |
performed | 释放时,敲击次数达到Tap Count时触发performed |
canceled | 1. 释放时,一次按压与释放间隔超过MaxTapTime 2. 按压时,与其上一次释放间隔时长超过tapDelay 3. 定时器超时(包含按着不放和敲击次数不够两种情况) |
3.2.4 interaction总结
注意:
- Tap、multiTap、hold、slowTap的按压都是指超过按压点pressPoint,这样才会触发started,这与Press不同,press或者button指的离开默认值0就视为按压,会触发started。
- 对于释放,Tap、multiTap、press都比较统一,采用释放点releasePoint,但slowTap用低于pressPoint视为结束按压,而Hold用回到0值才视为结束按压,比较奇怪。
如果下图每个细节都能看懂,说明真正掌握了Interaction(虽然可能并没什么用 ):
疑惑:
-
虽然看起来ActionType(Value、Button、Passthrough)和预设的五种Interaction二者可以同时作用,但实际上使用预设Interaction后,你ActionType设不设置都一个样,无论是单测还是实测。
- 比如我用手柄左右扳机键设置hold interaction,action type设为pass through,同时触发左右扳机键,按文档上说的应该两个都能触发,实际上依旧有冲突检测,最终只有一个按键起作用。
-
再考虑到initial state check似乎有bug(勾不勾都会检测)我觉得官方在Action这一块设计得就有毛病。特别你看它
ProcessControlStateChange
那一块,并发冲突解决、 组合绑定处理、ButtonState处理、defaultInteraction处理、PredefinedInteraction处理等都杂糅到一块,非常乱。如果Value、Button、Passthrough作为interaction的一种的话,为何不与PredefinedInteraction一样继承IInputInteraction,导致后面处理defaultInteraction一会if(action==value)一会isPassThough,非常不优美。 -
还有就是各interaction中对按键视为释放的定义不统一,不知道是bug还是我理解不到位,虽然实际操作影响并不大就是了。
3.3 Binding
一个Binding表示一个Action与一个或多个由Control path标识的控件之间的连接。
- 一个Action可以有任意数量的Bindings指向它。多个Bindings可以引用相同的Control。
- binding是在运行时进行解析,解析时查找control path string所对应的实际device control。注意可以使用通配符等绑定到多个control。
- 多个control绑定到同一个Action会造成输入歧义,非passthrough类型的Action会默认进行输入消歧。
- 注意:目前测试下来,就算使用passthrough,如果带有预设的五种Interaction,依旧会有输入消歧。
- 可以组合多个control形成组合绑定,实现如组合WASD模拟二维输入,以及组合shift+F 实现特殊输入等。
- Binding具有复杂度的概念,用以解决组合绑定的输入歧义问题,非组合绑定复杂度为1,组合绑定复杂度等于组合键个数,如wasd组合复杂度4,ctrl+shift+f组合复杂度3。
- 允许相同control(非组合绑定时)绑定到多个Action上,会同时触发这些Action,不属于输入歧义。
下面将详细介绍Binding相关内容。
3.3.1 绑定的创建
Binding可以通过代码或者Action/ActionAsset ui或者.inputactions
Asset进行操作:
-
code
moveAction.AddBinding("<Gamepad>/leftStick");myAction.AddCompositeBinding("1DAxis") // Or just "Axis".With("Positive", "<Gamepad>/rightTrigger").With("Negative", "<Gamepad>/leftTrigger");myAction.AddCompositeBinding("OneModifier").With("Binding", "<Keyboard>/1").With("Modifier", "<Keyboard>/ctrl")
-
Action/ActionAsset ui
当代码使用public InputAction或者InputActionAsset时,ui会出现对action的编辑(InputActionAsset需要添加
.inputactions
文件)Action/ActionAsset ui 然后就能添加绑定(或组合绑定):
.inputactions
Asset.
上面ActionAsset本质上就是一个json文件,其中就包含对binding的定义:"bindings": [{"name": "","id": "7cf9d814-ede0-4443-a235-6ce00499fcc0","path": "<Gamepad>/leftStick","interactions": "","processors": "","groups": "","action": "move","isComposite": false,"isPartOfComposite": false}]
.inputactions
资产在被组件(比如PlayerInput)引用时会被InputActionImporter
导入并解析成InputActionAsset,然后被后续使用。
运行时在编辑器更新ActionAsset保存后会调用importer
3.3.2 组合绑定
组合绑定有两类,一类是 1D-Axis, 2D-Vector, 3D-Vector 这种,通过不同control组合模拟线性轴、二维轴、三维轴输入的情况;另一类就是 One Modifier and Two Modifiers ,如Shift+S、Ctrl+Shift+A这种,实现快捷组合键操作。
ActionAsset中可以如下添加不同组合绑定:
下面分别介绍。
1D-Axis
创建:
myAction.AddCompositeBinding("1DAxis") // Or just "Axis".With("Positive", "<Gamepad>/rightTrigger").With("Negative", "<Gamepad>/leftTrigger");
说明:
1D-Axis 绑定两个control,Positive 和 Negative。
- 当Negative激活时,组合绑定取
mid - (mid - minValue) * negativeValue
(若是buttonControl,值就是minValue,否则会乘以按压程度。为方便叙述,简单说取minValue,下同); - 当Positive 激活时,组合绑定取
mid + (maxValue - mid) * positiveValue
(若是buttonControl,值就是maxValue ); - Positive 和 Negative同时激活时,组合绑定值取决于
whichSideWins
whichSideWins = Neither
:组合绑定的值取(maxValue + minValue) / 2
(默认值0);whichSideWins = Positive
:组合绑定的值取maxValue
;whichSideWins = Negative
:组合绑定的值取minValue
。
注意:
Action type
为button
时- 当
whichSideWins = Neither
时:依次按下两个键不放,会先触发这个action的performed
,然后触发canceled
;依次释放两个键,会又依次触发performed
和canceled
;(1010) - 当
whichSideWins = Positive / Positive
时:依次按下两个键不放,只会在前一个键按压时触发performed
;并且只会在最后一个键释放时触发canceled
。(1110)
- 当
Action type
为value
或passthrough
时
- 当
whichSideWins = Neither
时:与button
结果相同;(1010) - 当
whichSideWins = Positive / Positive
时:依次按下两个键不放,会依次触发两次performed
;第一个键释放时触发performed
,后一个键释放触发canceled
。(1110)
Action type不论为button、value抑或passthrough,组合绑定的值都是一致的,区别就在于button在perform后,对按键再次激活不反应(对组合绑定同理),而value和passthrough performer后依旧会触发performed。这种区别在2D-vector、3D-vector同样适用。
所以在使用1D-Axis、2D-vector时,如果action回调没按预期中的执行,就要注意上面的情况。
2D-vector
创建:
myAction.AddCompositeBinding("2DVector") // Or "Dpad".With("Up", "<Keyboard>/w").With("Down", "<Keyboard>/s").With("Left", "<Keyboard>/a").With("Right", "<Keyboard>/d");// To set mode (2=analog, 1=digital, 0=digitalNormalized):
myAction.AddCompositeBinding("2DVector(mode=2)").With("Up", "<Gamepad>/leftStick/up").With("Down", "<Gamepad>/leftStick/down").With("Left", "<Gamepad>/leftStick/left").With("Right", "<Gamepad>/leftStick/right");
说明:
2D-vector与1D-Axis类似,Action的回调触发会更复杂,但1D-Axis的情况弄懂了这个也很好理解。
值得注意的就是2D-vector组合binding的mode对组合控件值大小的影响。
-
如果设置为
Mode.DigitalNormalized
,则将输入视为按钮(如果低于 defaultButtonPressPoint 则关闭,如果等于或大于则打开)。每个输入的值为 0 或 1,具体取决于按钮是否被按下。由上/下/左/右部分组成的向量将被标准化。结果是一个菱形的 2D 输入范围。 -
如果设置为
Mode.Digital
,行为基本上与 Mode.DigitalNormalized 相同,只是生成的向量不会被单位化。 -
最后,如果设置为
Mode.Analog
,则将输入视为模拟控件(即完整的浮点值),除了 down 和 left 被倒转之外,其他值将按原样传递。 -
默认值是 Mode.DigitalNormalized。
注:
-
Analog能识别模拟量输入,比如手柄扳机;digital的话就全当作按键类型的,激活为1 不激活为0,就算是扳机,只有扳到大约中程时才会激活输入,而digitalNormalized对输入会有归一化,同时按up和left输入值就是(-0.71,0.71),而digital就是(-1,1)。
-
Analog要用类似手柄扳机这种control才能体现出analog的作用,否则值是按键类型的效果和digital相同。
3D vector
创建:
myAction.AddCompositeBinding("3DVector").With("Up", "<Keyboard>/w").With("Down", "<Keyboard>/s").With("Left", "<Keyboard>/a").With("Right", "<Keyboard>/d");// To set mode (2=analog, 1=digital, 0=digitalNormalized):
myAction.AddCompositeBinding("3DVector(mode=2)").With("Up", "<Gamepad>/leftStick/up").With("Down", "<Gamepad>/leftStick/down").With("Left", "<Gamepad>/leftStick/left").With("Right", "<Gamepad>/leftStick/right");
说明:
- Mode跟二维类似,归一化数字量两个轴同时按的话值为(0.71, 0, 0.71), xyz三轴同时按时值为(0.58, 0.58, 0.58)即(√3/3)。
One Modifier / Two Modifiers
可以利用One Modifier / Two Modifiers实现快捷键功能。
// Add binding for "CTRL+1".
myAction.AddCompositeBinding("OneModifier").With("Binding", "<Keyboard>/1").With("Modifier", "<Keyboard>/ctrl")// Add binding to mouse delta such that it only takes effect
// while the ALT key is down.
myAction.AddCompositeBinding("OneModifier").With("Binding", "<Mouse>/delta").With("Modifier", "<Keyboard>/alt");myAction.AddCompositeBinding("TwoModifiers").With("Button", "<Keyboard>/1").With("Modifier1", "<Keyboard>/leftCtrl").With("Modifier1", "<Keyboard>/rightCtrl").With("Modifier2", "<Keyboard>/leftShift").With("Modifier2", "<Keyboard>/rightShift");
说明:
override Modifiers Need To Be Pressed First
:如果设置为true(勾选时),则可以先按Button再按Modifier,依旧可以触发Action。默认false不勾选,要求One / Two Modifiers先按Modifier,再按Button才能触发。- Modifiers组合绑定功能需要修改setting,开启输入消费(enable input consumption)才能按照预期使用,否则比如按Shift+B,依旧会触发按B的Action。(可以通过
InputSystem.settings.shortcutKeysConsumeInput
获取)
- Modifiers组合绑定这块功能出过严重bug,导致此功能完全没用,而且官方花了一两年才修复。参见论坛。
- 目前还是会出现切换完设置,功能依旧不正常的情况,可以尝试多切换几次,或者重启Unity试试。[sigh~]
- 目前wasd的s优先级还是高于shift+s的s,所以遇到这种组合冲突问题,也许可以考虑使用不同ActionMap或者临时disable Action解决。
3.3.3 冲突解决⭐
可能存在冲突的几种情况:
-
同一Action下不同binding引用同一个control,且都非组合绑定
- button action:仅触发一次performed
- value action:触发多次performed,有几个相同control就触发几次
- passthrough:同value(不过注意按键释放值归0时passthrough也会触发performed)
-
不同Action下引用同相同control,且都非组合绑定
- 不存在冲突(不论什么action type),会触发各自Action的performed
-
同一Action下不同binding引用同不同control
- button:仅触发一次performed
- value:例如绑定了JKL,按下J不放时触发一次perforemed;继续按下KL不放,不触发;然后依次释放JK,则又会触发两次performed;最后释放L不触发performed。
- passthrough:比如绑定JKL,每个键的按压释放都会触发一次performed。
说明: 这种情况和组合绑定情况有些类似,比如jkl,(j,k,l)的任何组合都认定为不同值,都会触发value或passthrough的值变更触发performed(jkl变成全0时不触发value类型的performed,但passthrough依旧会)。但是对于value来说,似乎跟binding在Action中的顺序有关,比如上面jkl绑定,如果按lkj顺序释放是不触发performed的,就比较奇怪。
-
不同Action下引用同一个control,且binding复杂度不同
- 两action都设为value、button:
- 单按space键,触发fireCube;
- 按ctrl+space,触发fireSphere;overrideModifiedFirst=true时,space+ctrl也触发fireSphere。
- 也就是触发了高复杂度的Action,低复杂度action不再触发,这对组合绑定的冲突都同样使用。
- 不考虑passthrough的情况,否则结果无预期效果。
- 两action都设为value、button:
-
同一Action下不同binding引用同一个control,且冲突的binding复杂度不同
- 按ctrl+space只会触发一次fireSphere
- 按space会触发一次fireSphere
- 对于overrideModifiedFirst=true,按space不放后会触发一次fireSphere,但此时再按ctrl,不会再触发fireSphere
-
上面4和5的结合
- 只按space,fireCube和fireSphere都会触发一次,这满足上面2和5的结论
- 但按ctrl+space, fireCube和fireSphere也都会触发一次,这与4的结论不符合。因为从4、5情况的冲突解决想法,如果有高复杂度的绑定被消费了,冲突的低复杂度绑定就不再消费了,所以6这种案例可能是未曾考虑的边界情况,或许是bug。
总结:
总之,不考虑上面 第6点 这种特列,当存在冲突,优先高复杂度binding触发action,其所包含的冲突control就不同时触发其他action了。
而非组合绑定时,action下绑定多个相同control的binding,是允许同时触发多次action的。不同Action下引用同相同control,也允许同时触发两个action的。这两种不属于触发冲突,是由用户来控制的。
而对于同一Action下不同binding引用同不同control,官方文档举了个例子,如同时绑定手柄的左右扳机键,同时扣动时,会进行消除歧义的操作:当动作尚未启动时,它将对具有非默认值的第一个输入做出反应。一旦接收到这样的输入,它将开始跟踪该输入的来源。在动作正在进行时,如果接收到来自当前正在跟踪的控件以外的输入,它将检查输入是否具有比当前正在跟踪的控件更大的幅度。如果是这样,动作将从当前控件切换到具有更强输入的控件。
而如果当前control存在冲突且非"最激活"的,就会略过defaultInteraction处理,所以对于上面 第3点,依次按下jkl后,如果先释放kl是不会触发action的,只有先释放j才会触发action,但之后哪个control能拿到"最激活"的control,似乎和action上binding的顺序有关。
考虑到源码中处理controlStateChange的代码比较乱,而且modified 组合绑定曾出现过大bug,目前还不能相信官方对于按键冲突的处理没有问题,所以在使用Action绑定时,尽量避免冲突的发生。
3.3.4 绑定的解析
无论是code还是ui添加绑定的方式,添加的其实都是control的path,只有在运行时才能根据path获取实际设备的control然后进行绑定,这就是解析的主要目的。
除此之外,interaction和processor也会根据其string创建对应的interaction/processor类进行解析,然后保存在InputActionState
对应数组中。
当然设计者为了便于后续操作,将ActionMap、Action、Binding、Interaction等内容进行打平,统一保存在InputActionState
中,通过索引相映射的方式,将action与control、map与action、control与interaction/processor等进行关联绑定。
解析过程
核心逻辑写在InputActionMap.ResolveBindings()
和InputBindingResolver.AddActionMap()
中。主要功能如下:
- 利用
InputControlPath.TryFindControls()
或InputSystem.FindControls()
查找在线设备中各个Binding所指定的control,并保存到control数组中 - 根据Interaction/processer string实例化Interaction/processer,并保存在Interaction/processer数组中
InputActionState
的bindingState、InteractionState、TriggerState等指针数组初始化- Action中各成员间进行索引映射,目的就是搞清某个action有哪些binding引用,binding实际使用的control/interaction实例在哪等。索引映射比较复杂,但通过这种映射将原先的树状ActionMap打平,我觉得主要是为了方便官方源码开发人员的,可以借鉴他们的想法。
ActionMap包含的各种索引
解析绑定的时机
可能触发绑定解析的动作主要有以下几种:
- ActionMap、Action、Binding等发生变更时
- 当前在线设备列表发生变更时
- Action / ActionMap enable时
- 真正处理ControlStateChange时
- PlayerInput组件enable/disable时
当然上面有些动作只会标定需要重新解析,会把解析动作推迟到真正需要的时候。
3.4 Control State监听与Action触发 ⭐
上面讲了control和action间关系通过binding解析得到InputActionState,但当control状态变更时如何触发action呢?
我们看一下control状态变更时和Action相关的方法调用链:
简单说一下过程:
-
首先Unity底层收到输入事件后,会将相关信息传递给newInputSystem的
InputManager.OnUpdate()
进行分析处理,和Action相关的最重要的过程有两个:WriteStateChange
:将外部设备的输入数据保存到对应的control内存中FireStateChangeNotifications
:触发监听control状态变更的监视器,通知control上绑定的action
-
找到control对应的状态监视器(
IInputStateChangeMonitor
),通知control状态变更listener.monitor.NotifyControlStateChanged(listener.control, time, eventPtr,listener.monitorIndex);
这个monitor是个接口,InputActionState继承它,然后交由对应InputActionState处理此control状态变更。
-
InputActionState.ProcessControlStateChange()
:处理control state change(这块逻辑感觉很乱,按键冲突解决、compositeBinding处理、五种预设interaction处理、三种ActionType处理等内容没有调理得很清爽) -
ChangePhaseOfActionInternal
:更新actionPhase,并触发action对应事件
action的不同状态,触发不同事件:
(CallActionListeners
会调用三种级别的回调函数,InputSystem级别、ActionMap级别,以及action级别,这由开发者注册回调方法的方式决定):
读值:
因为Action上started
、performed
、canceled
回调都带有CallbackContext
参数,我们可以使用CallbackContext
上的ReadValue<TValue>()
、ReadValueAsButton()
方法来获取当前驱动action的control对应值。
-
ReadValueAsButton()
:读取Action的当前值作为float,如果它 ≥ 按钮按下阈值,则返回true。 -
ReadValue<TValue>()
:返回Action的值。注意使用时TValue需要与control相匹配,否则会报InvalidOperationException
异常。public void OnMove(InputAction.CallbackContext context){m_Move = context.ReadValue<Vector2>();}
3.5 Processor
在上面的读值过程中,会有个额外处理过程,可以对读取的值做取反、归一化、缩放等操作,这就是Processor。Processor可以在Action或者binding上添加,可以UI或者code方式。
var action = new InputAction();action.AddBinding("<Gamepad>/buttonSouth", processors: "invert");var action = new InputAction(processors: "invertVector2(invertX=false)");
预设了以下几种Processor:
名称 | 操作数类型 | 参数 | 描述 |
---|---|---|---|
Clamp | float | float min, float max | 将输入值限制在[min…max]范围内。 |
Invert | float | N/A | 反转来自Control的值(即将值乘以-1)。 |
InvertVector2 | Vector2 | bool invertX, bool invertY | 反转来自Control的值(即将值乘以-1)。如果invertX为true,则反转向量的x轴;如果invertY为true,则反转向量的y轴。 |
InvertVector3 | Vector3 | bool invertX, bool invertY, bool invertZ | 反转来自Control的值(即将值乘以-1)。如果invertX为true,则反转向量的x轴;如果invertY为true,则反转向量的y轴;如果invertZ为true,则反转向量的z轴。 |
Normalize | float | float min, float max, float zero | 如果min >= zero,将输入值在[min…max]范围内归一化为无符号归一化形式[0…1];如果min < zero,则将其归一化为带符号的归一化形式[-1…1]。 |
NormalizeVector2 | Vector2 | N/A | 将输入向量归一化为单位长度(1)。这与调用Vector2.normalized相同。 |
NormalizeVector3 | Vector3 | N/A | 将输入向量归一化为单位长度(1)。这与调用Vector3.normalized相同。 |
Scale | float | float factor | 将所有输入值乘以因子。 |
ScaleVector2 | Vector2 | float x, float y | 将所有输入值分别沿x轴乘以x,沿y轴乘以y。 |
ScaleVector3 | Vector3 | float x, float y, float z | 将所有输入值分别沿x轴乘以x,沿y轴乘以y,沿z轴乘以z。 |
AxisDeadzone | float | float min, float max | 轴死区处理器将Control的值缩放,使得任何绝对值小于min的值为0,任何绝对值大于max的值为1或-1。避免了来自那些没有精确静止点的Control的意外输入。同时,确保在某些Control在移动轴到最大值时,始终获得最大值。 |
StickDeadzone | Vector2 | float min, float max | 摇杆死区处理器将Vector2Control的值缩放,使得任何输入向量的大小小于min的结果为(0,0),任何输入向量的大小大于max的将被归一化到长度1。避免了来自那些没有精确静止点的Control的意外输入。同时,确保在某些Control在移动轴到最大值时,始终获得最大值。 |
四 Input System的使用
4.1 应用new Input Seytem的四种方式(Workflow)
New Input Seytem的使用,根据工作流程抽象程度的不同,分四种方式:
- 直接读取设备状态(Directly Reading Device States )
- 脚本使用InputAction类(Using Embedded Actions )
- 使用行为资产(Using an Actions Asset )
- 使用行为资产+ PlayerInput组件(Using an Actions Asset and a PlayerInput component )
4.1.1 Directly Reading Device States
public class MyPlayerScript : MonoBehaviour
{void Update(){var gamepad = Gamepad.current;if (gamepad == null){return; // No gamepad connected.}if (gamepad.rightTrigger.wasPressedThisFrame){// 'Use' code here}Vector2 move = gamepad.leftStick.ReadValue();{// 'Move' code here}}
}
4.1.2 Using Embedded Actions
public class SimpleController_UsingActions : MonoBehaviour
{public float moveSpeed;public InputAction moveAction;public void Update(){var move = moveAction.ReadValue<Vector2>();Move(move);}public void OnEnable(){moveAction.Enable();}public void OnDisable(){moveAction.Disable();}
}
这样Inspector就会多出Actions的配置框:
点击action右侧的加号,添加一个或多个Binding
注意每个value Type的action可以设定control Type,表明此action目标接收的control值类型,这会影响后续binding过滤control行为。如果发现control候选列表缺少或者listen不到,尝试切换下control type。
4.1.3 Using an Actions Asset
上面使用Aciton方式有个缺点,就是action的配置无法复用,于是将action形成资产形式方便后续复用的方式就是新的workflow形式。Actions Asset文件的扩展名为 .inputactions
,以纯 JSON 格式存储。
除此之外, Action Asset还包含Action Maps
,相当于不同场景的控制方案。例如,游戏可能涉及驾驶车辆和步行导航,并且可能有游戏内UI菜单。在“驾驶”场景中,行为可能包含“转向”,“加速”,“刹车”,“手刹”等,而“步行”场景中可能就是“移动”,“跳跃”,“蹲下”,“使用”等,相同的按键在不同Action Maps代表不同的行为。
两种方式使用Action Asset:
- Use an inspector reference to the Actions Asset
- Generate a C# class that wraps your Actions Asset.
Use an inspector reference to the Actions Asset
使用步骤:
- 创建public的InputActionsAsset字段。
- 在Inspector中为其分配引用。
- 利用反射方式访问 InputActionAsset 中各Actions。
样例代码:
using UnityEngine;
using UnityEngine.InputSystem;public class ExampleScript : MonoBehaviour
{// assign the actions asset to this field in the inspector:public InputActionAsset actions;// private field to store move action referenceprivate InputAction moveAction;void Awake(){// find the "move" action, and keep the reference to it, for use in UpdatemoveAction = actions.FindActionMap("gameplay").FindAction("move");// for the "jump" action, we add a callback method for when it is performedactions.FindActionMap("gameplay").FindAction("jump").performed += OnJump;}void Update(){// our update loop polls the "move" action value each frameVector2 moveVector = moveAction.ReadValue<Vector2>();}private void OnJump(InputAction.CallbackContext context){// this is the "jump" action callback methodDebug.Log("Jump!");}void OnEnable(){actions.FindActionMap("gameplay").Enable();}void OnDisable(){actions.FindActionMap("gameplay").Disable();}
}
Generate a C# class that wraps your Actions Asset.
要通过C#包装器使用Action Asset:
- 在项目窗口中选择您的Actions资源。
- 在检视器中,启用Generate C# Class并选择Apply。您应该在项目窗口中看到一个与您的Actions资源同名的C#资源。
可以看到,生成的类包含InputActionAsset,而这个InputActionAsset asset = InputActionAsset.FromJson(),就是将InputActionAsset json文件反序列化为InputActionAsset对象。
可以简单看一下这个Json资源格式:{"name": "SimpleControls","maps": [{"name": "gameplay","id": "265c38f5-dd18-4d34-b198-aec58e1627ff","actions": [{"name": "fire","type": "Button","id": "1077f913-a9f9-41b1-acb3-b9ee0adbc744","expectedControlType": "Button","processors": "","interactions": "Tap,SlowTap","initialStateCheck": false},{"name": "move","type": "Value","id": "50fd2809-3aa3-4a90-988e-1facf6773553","expectedControlType": "Vector2","processors": "","interactions": "","initialStateCheck": true},{"name": "look","type": "Value","id": "c60e0974-d140-4597-a40e-9862193067e9","expectedControlType": "Vector2","processors": "","interactions": "","initialStateCheck": true}],"bindings": [{"name": "","id": "abb776f3-f329-4f7b-bbf8-b577d13be018","path": "*/{PrimaryAction}","interactions": "","processors": "","groups": "","action": "fire","isComposite": false,"isPartOfComposite": false},{"name": "","id": "e1b8c4dd-7b3a-4db6-a93a-0889b59b1afc","path": "<Gamepad>/leftStick","interactions": "","processors": "","groups": "","action": "move","isComposite": false,"isPartOfComposite": false},{"name": "","id": "c106d6e6-2780-47ff-b318-396171bd54cc","path": "<Gamepad>/rightStick","interactions": "","processors": "","groups": "","action": "look","isComposite": false,"isPartOfComposite": false}]}],"controlSchemes": [] }
- 在您的脚本中创建Actions C#类的实例。
- 通过使用您的Actions C#类的API,在您的脚本中访问Actions。
第二种使用方式比第一种方便的地方在自动生成的ActionAsset类已经将Map、Action等对象反射好了,更方便使用
样例代码:
public class SimpleController_UsingActionAsset : MonoBehaviour
{private SimpleControls m_Controls;public void Awake(){// instantiate the actions wrapper classm_Controls = new SimpleControls();}public void Update(){var look = m_Controls.gameplay.look.ReadValue<Vector2>();// our update loop polls the "move" action value each framevar move = m_Controls.gameplay.move.ReadValue<Vector2>();// Update orientation first, then move. Otherwise move orientation will lag behind by one frame.Look(look);Move(move);}public void OnEnable(){m_Controls.Enable();}public void OnDisable(){m_Controls.Disable();}
}
C# 中 “@” 作用
读上面生成的代码发现很多变量名前都加上@符号,很奇怪,查了下:
- 字符串前面带上@,代表这个字符串里的一些转义字符可以无需特别处理,使得代码可以简短清晰,常用于文件路径,如
string filePath = @"c:\Docs\Source\a.txt" // rather than "c:\\Docs\\Source\\a.txt"
- 变量前面加@,使得我们可以采用关键字来做变量名。好比说,static在c#里是个关键字,但我们偏要把自己的变量命名为“static”,好吧,这时我们就可以在前面加个@,命名为 @static,这样就满足阁下的需要了。
据说好处是给跨语言移植(准确说,应该是别的语言移植到C#)带来了便利,因为在语言A里可能不是关键字,但语言B里可能就是了,如果将语言A复制粘贴到语言B,修修改改语法,可能连变量的名字都要换,真不爽。现在好了,只需在前面加个@,搞定。
摘自:C#中,变量前的@符号 码龄23年的大佬…
4.1.4 Using PlayerInput component
Player Input组件是Input System提供的最高抽象级使用方式。详细用法在下节中介绍。
4.2 PlayerInput 组件 ⭐
- Actions :关联ActionAsset,决定如何接收和响应输入。
- Default Map:默认启用的ActionMap。如果设置为None,则不启用任何Action。
- Camera:与玩家关联的相机。仅在使用分屏设置时需要,在其他情况下没有影响。
- Behavior :决定响应输入的方式。
4.2.1 三类Behavior辨析
Send / Broadcast Messages
PlayerInput.cs类内部使用GameObject.SendMessage()
或者GameObject.BroadcastMessage()
方法,本质都是使用反射方式实现方法调用的。
- 这两种Behavior采用约定的方式,当某Action触发时,自动查找当前PlayerInput所属GameObject的 " On+对应action名 " 方法,进行方法调用。所以使用时,遵循这种约定,添加 " On+对应action名 " 的方法即可。
- Broadcast Messages 与Send Message的区别就是Broadcast 除了当前GameObject,还会查找所有子对象是否有对应方法,会一并调用。
- 这种约定的方法名是PlayerInput初始化时进行缓存的:
void CacheMessageNames() {if (m_Actions == null)return;if (m_ActionMessageNames != null)m_ActionMessageNames.Clear();else m_ActionMessageNames = new Dictionary<string, string>();foreach (var action in m_Actions){action.MakeSureIdIsInPlace();var name = CSharpCodeHelpers.MakeTypeName(action.name);m_ActionMessageNames[action.m_Id] = "On" + name;} }
特别注意:
-
Send/Broadcast Messages方式,目前只处理
performed
或者type为value的canceled
的回调,所以对于这两种方式,Button类型Action的按键释放通知是不处理的。
-
反射对方法名大小写敏感,方法大小写不匹配时方法不触发,但不报异常
- action name在上面cacheName 时首字母会转成大写,所以写方法时注意,比如action叫fireCube,但方法要写成OnFireCube,写成OnfireCube或者OnFirecube都是无法触发的
-
方法可以带
InputValue
参数,但无参和有参同时存在只会调用首先声明的那一个- 如果方法名正确,但带错参数会报
MissingMethodException
,比如你像另两个behavior一样带CallbackContext
是不行的。
//哪个写在前面调用谁 public void OnFireCube(InputValue value) {Debug.Log("父对象触发fire action,value=" + value.Get<float>()); } public void OnFireCube() {Debug.Log("父对象触发fire action"); }
- 如果方法名正确,但带错参数会报
Invoke CSharp Events
使用原生c#事件方式,利用PlayerInput.onActionTriggered事件间接监听action事件触发:
private void Start()
{var playerInput = GetComponent<PlayerInput>();playerInput.onActionTriggered += context =>{switch (context.action.name){case "fireCube":OnFire(context);break;}};
}
public void OnFire(InputAction.CallbackContext context)
{switch (context.phase){case InputActionPhase.Performed:Debug.Log("c#Event performed:value="+context.ReadValue<float>());break;case InputActionPhase.Canceled:Debug.Log("c#Event canceled:value="+context.ReadValue<float>());break;case InputActionPhase.Started:Debug.Log("c#Event start:value="+context.ReadValue<float>());break;}
}
当然你也可以不用PlayerInput的事件,直接用InputSystem、ActionMap或者Action注册回调。
class MyPlayerInputScript : MonoBehaviour
{private void Awake(){// 需要访问PlayerInput组件和相关的ActionPlayerInput playerInput = GetComponent<PlayerInput>();InputAction hit = playerInput.actions["Fire"];// 手动注册回调函数hit.started += OnFireStarted;hit.performed += OnFirePerformed;hit.canceled += OnFireCanceled;}void OnFireStarted(InputAction.CallbackContext context){var v = context.ReadValue<float>();Debug.Log(string.Format("Fire Started:{0}", v));}void OnFirePerformed(InputAction.CallbackContext context){var v = context.ReadValue<float>();Debug.Log(string.Format("Fire Performed:{0}", v));}void OnFireCanceled(InputAction.CallbackContext context){var v = context.ReadValue<float>();Debug.Log(string.Format("Fire Canceled:{0}", v));}
}
Invoke Unity Events ⭐
Invoke Unity Event方式,和原生c#逻辑是类似的,只不过是用UI操作代替code罢了。因为需要UI操作,所以用了Unity Event,在PlayerInput组件引入ActionEvent[] m_ActionEvents
即可,Inspector中就会多出Events列表,对ActionAsset中每个action都可以注册一个回调方法,注册的方法是
-
前一个候选框选择方法所在的对象(或对象上的任意组件,不能单是脚本!)
-
选择方法所在的脚本,并选择此方法
这种behavior的回调方法也可以带CallbackContext参数,使用和c#event相同:public void OnFire(InputAction.CallbackContext context) {if(context.phase == InputActionPhase.Performed){Debug.Log("c#Event performed:value="+context.ReadValue<float>());} }
如果你好奇UnityEvent 这种behavior是如何起到相同作用的,可以参见 UnityEvent方式注册回调关键逻辑
4.2.2 三类消息通知方式比较
-
sendMessage / boardcast
这种方式虽然使用简单,但需要搜索一个潜在的庞大组件列表,以找到那些包含匹配方法的组件,这引入了大量的开销。更糟糕的是,由于它们使用字符串作为方法名称,因此它们使用反射来标识匹配的方法。在这种情况下,反射是在运行时与类型系统交互和修改类型系统的能力,但通过反射调用方法比以正常方式调用方法慢。如果你使用一次或两次反射,这很好,但如果你经常使用,那么这些小的性能影响就会加起来。不仅如此,由于所有这些都发生在运行时,因此根本没有编译时错误检查。这使得方法名称中的拼写错误等小错误更容易需要很长时间才能调试。 (参见: Unity Tips | Part 7 - Events and Messaging) -
PlayerInput的输入触发通知的本质就是给Action的三个事件
performed、canceled、started
注册回调,只不过差别在,C#Event需要手动通过代码方式注册,而sendMessage 用的约定、UnityEvent用的UI绑定默认注册罢了。 -
总的来说:sendMessage 最方便,但性能最差、灵活性不佳;UnityEvent最直观,性能一般;C#Event 最灵活、性能最好,但操作稍麻烦。根据需要选择适合的Behavior。
五 总结
至此,本文花了很大篇幅将new Input System主要结构讲解完毕。通览整个系统设计,能感受到输入系统并没有原先以为的那样简单,源码涉及了很多C#事件委托、索引器、指针等基础知识;整个系统层次也非常清晰,比如底层的control与InputManager、中层的InputActionState、上层的PlayerInput,并通过InputSystem统一暴露API给开发者使用;而且对于频繁更新的数据部分也考虑使用非托管内存来手动进行管理,比如InputControl的stateBlock和InputActionState的BingdingState等,都很有借鉴意义。
而且着重需要表扬的是NewInputSystem的文档远比UnityEngine文档要好上很多很多,而且测试用例给的也很充足。
感谢这段时间NewInputSystem给予我的陪伴与收获,未来将会在实战场再相遇了。