为 Unity 项目添加自定义 USB HID 设备支持 (适用于 PC 和 Android/VR)-任何手柄、无人机手柄、摇杆、方向盘
这是一份关于如何在 Unity 中为特定 USB HID 设备(如 Phoenix SM600 手柄)添加支持,并确保其在打包成 APK 安装到独立 VR 设备后仍能正常工作的教程。
目标: 使 Unity 能够识别并处理特定 USB HID(Human Interface Device)游戏手柄的输入,即使该设备没有被 Unity 的 Input System 默认支持。确保该支持在 PC 和打包后的 Android 应用(例如,安装在独立 VR 头显上)中均有效。
核心工具: Unity Input System 包。
背景: Unity 的 Input System 提供了强大的设备支持,但对于一些非标准或小众的 HID 设备,可能需要手动定义其数据布局并注册,以便系统能够正确解析其输入信号。
第 1 步:环境准备与设备识别
-
安装 Input System 包: 确保 Unity 项目已通过 Window > Package Manager 安装了 Input System 包。
-
获取设备标识符 (VID & PID):
-
将目标 USB HID 设备连接到 PC。
-
打开 Windows 的“设备管理器”。
-
找到该设备(可能在“人体学输入设备”或“通用串行总线控制器”下)。
-
右键点击设备,选择“属性”。
-
切换到“详细信息”选项卡。
-
在“属性”下拉菜单中选择“硬件 ID”。
-
记录下 VID_XXXX 和 PID_YYYY 中的十六进制数值(例如,VID_1781 和 PID_0898)。这是设备的唯一厂家 ID 和产品 ID。
-
第 2 步:定义设备输入报告结构 (Input Report Struct)
为了让 Input System 理解设备发送的原始数据流,需要定义一个 C# 结构体(struct)来精确映射数据包的内存布局。
-
创建 C# 脚本: 在 Unity 项目的 Assets 文件夹中创建一个新的 C# 脚本(例如 CustomHIDDeviceSupport.cs)。
-
定义结构体: 在脚本中,定义一个结构体,使用 StructLayout 属性指定精确的内存布局和大小,并使用 FieldOffset 指定每个数据字段在数据包中的字节偏移量。
-
映射原始字节 (初始阶段): 作为第一步,建议先将所有有意义的数据字节映射为原始 byte 类型的 InputControl。这有助于在 Input Debugger 中观察原始值,方便后续映射到具体的摇杆轴或按钮。
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.LowLevel;
using System.Runtime.InteropServices;// 定义设备发送的数据包结构
// [StructLayout(LayoutKind.Explicit, Size = N)] N 为数据包的总字节数
// : IInputStateTypeInfo 是 Input System 要求状态结构实现的接口
[StructLayout(LayoutKind.Explicit, Size = 9)] // 假设设备报告大小为 9 字节
public struct ExampleHIDInputReport : IInputStateTypeInfo
{// Report ID 通常是 HID 报告的第一个字节[FieldOffset(0)] public byte reportId;// 使用 [InputControl] 将数据字段标记为输入控件// layout = "Byte" 表示读取原始字节值 (0-255)// offset = X 指定该字节在结构体中的偏移量// name = "uniqueInternalName" 内部使用的控件名称// displayName = "User Friendly Name" 在 Input Debugger 中显示的名字[InputControl(name = "byte1Raw", layout = "Byte", offset = 1, displayName = "Data Byte 1")][FieldOffset(1)] public byte byte1_raw;[InputControl(name = "byte2Raw", layout = "Byte", offset = 2, displayName = "Data Byte 2")][FieldOffset(2)] public byte byte2_raw;// ... 为所有相关的数据字节添加类似的定义 ...[InputControl(name = "byte8Raw", layout = "Byte", offset = 8, displayName = "Data Byte 8")][FieldOffset(8)] public byte byte8_raw;// 实现 IInputStateTypeInfo 接口,指定数据格式为 HIDpublic FourCC format => new FourCC('H', 'I', 'D');
}
第 3 步:创建并注册设备布局 (Device Layout)
接下来,创建一个类来代表这个设备,并告诉 Input System 当匹配到特定 VID/PID 的设备时,应使用上面定义的结构体来解析其数据。
-
定义设备类: 在同一个 C# 脚本中,或另一个脚本中,创建一个继承自 InputDevice、Gamepad、Joystick 或 HID 的类。继承 Gamepad 或 Joystick 可以方便后续映射到标准控件。
-
添加 InputControlLayout 属性: 使用此属性标记该类,指定使用的状态结构体 (stateType) 和在 Input Debugger 中显示的名称 (displayName)。
-
实现静态构造函数: 在类中添加一个静态构造函数 (static ClassName())。这是注册布局的最佳位置,因为它会在类首次被访问时自动执行一次。
-
调用 InputSystem.RegisterLayout: 在静态构造函数中,调用 InputSystem.RegisterLayout。使用 InputDeviceMatcher 指定匹配条件:接口类型为 "HID",并提供正确的 vendorId 和 productId。
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.HID;
using UnityEngine.InputSystem.Utilities;
#if UNITY_EDITOR
using UnityEditor;
#endif// [InitializeOnLoad] 确保在编辑器启动时注册布局
#if UNITY_EDITOR
[InitializeOnLoad]
#endif
// [InputControlLayout] 关联状态结构体和显示名称
[InputControlLayout(stateType = typeof(ExampleHIDInputReport), displayName = "Custom USB HID Device (Raw)")]
public class CustomHIDController : Gamepad // 或 HID, InputDevice 等
{// 静态构造函数,用于注册布局static CustomHIDController(){// 使用 VID 和 PID 注册设备布局InputSystem.RegisterLayout<CustomHIDController>(matches: new InputDeviceMatcher().WithInterface("HID") // 必须是 HID 接口.WithCapability("vendorId", 0x1781) // 替换为实际的 Vendor ID.WithCapability("productId", 0x0898) // 替换为实际的 Product ID);// (可选) 在控制台输出日志确认注册成功// 只在编辑器模式下或开发版本中输出,避免干扰发布版本#if UNITY_EDITOR || DEVELOPMENT_BUILDDebug.Log($"Custom HID Controller layout registered for VID:0x1781 PID:0x0898.");#endif}// [RuntimeInitializeOnLoadMethod] 确保在游戏运行时也能触发注册// BeforeSceneLoad 保证在任何场景加载前完成注册[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]static void InitializeInPlayer(){// 该方法体可以为空。// 其存在和标记会确保静态构造函数在运行时被调用(如果尚未调用)。}// 后续步骤: 在 FinishSetup() 中可以将原始字节映射到标准化控件 (如 leftStick, buttons)// protected override void FinishSetup()// {// base.FinishSetup();// // 例如: leftStick = GetChildControl<StickControl>("leftStick");// // 需要在结构体中定义更复杂的 InputControl (如 Stick, Button) 并在此处获取它们// }
}
IGNORE_WHEN_COPYING_START
content_copydownload
Use code with caution. C#
IGNORE_WHEN_COPYING_END
重要: 此脚本无需挂载到任何游戏对象上。其包含的 [InitializeOnLoad] 和 [RuntimeInitializeOnLoadMethod] 属性会使其自动执行注册逻辑。
第 4 步:针对 Android/VR 平台的注意事项
将应用打包成 APK 并安装到独立 VR 设备(如 Meta Quest, Pico)时,要确保手柄正常工作,需要依赖以下条件:
-
VR 设备支持 USB OTG: 设备的 USB 端口必须支持 USB On-The-Go 功能,允许其作为主机连接外部 USB 设备。主流 VR 头显通常支持此功能。
-
Android 系统识别 HID 设备: VR 设备底层的 Android 操作系统需要能够识别插入的 USB 设备为标准的 HID 游戏手柄。对于大多数遵循 HID 规范的设备,Android 会自动处理。
-
[RuntimeInitializeOnLoadMethod] 的作用: 正如代码中所示,这个属性确保了即使在没有 Unity 编辑器的设备上运行游戏时,InitializeInPlayer 方法也会被调用,进而触发静态构造函数中的 InputSystem.RegisterLayout。这保证了自定义布局在 APK 运行时被注册。
-
VID/PID 准确性: 这是最关键的一点。如果在代码中提供的 VID 或 PID 与设备的实际值不符,Input System 将无法将设备与自定义布局匹配,导致设备可能被识别为通用 HID 设备或完全不被识别。
-
物理连接: 确保使用的 USB 线缆和任何必要的转接头(如 USB-A to USB-C)工作正常。
第 5 步:创建 Input Actions 并编写脚本读取输入
现在我们已经教会了 Unity 如何识别和理解我们的自定义 HID 设备(如 Phoenix SM600 手柄)发送的原始数据,接下来需要设置 Input Actions 并编写一个脚本来实际读取这些数据,并将其用于游戏逻辑。
-
创建 Input Actions Asset:
-
在 Unity 项目的 Assets 窗口中,右键点击 Create > Input Actions。
-
给这个新资源文件命名,例如 CustomControllerActions。
-
双击打开该资源文件,进入 Input Actions 编辑器。
-
-
定义 Action Map 和 Actions:
-
Action Maps: 在左侧面板点击 "+" 号添加一个新的 Action Map,命名为例如 Gameplay。Action Map 用于组织一组相关的操作(比如所有玩家控制的动作)。
-
Actions: 在中间面板为 Gameplay Action Map 添加 Actions。根据你的需求定义动作。基于你提供的脚本,我们可能需要读取至少两个轴的输入。**重要:**由于我们在第 2 步中只映射了原始字节,我们需要创建 Actions 来读取这些原始字节值。
-
点击 "+" 添加一个 Action,命名为例如 LeftStickVerticalRaw。
-
设置 Action Type 为 Value。
-
设置 Control Type 为 Axis (或者 Integer 如果你只想读取原始 0-255 值,但 Axis 通常更灵活用于后续处理)。
-
-
添加另一个 Action,命名为例如 LeftStickHorizontalRaw。
-
同样设置 Action Type 为 Value 和 Control Type 为 Axis。
-
-
(根据需要添加更多 Actions,例如对应右摇杆的原始字节 RightStickVerticalRaw, RightStickHorizontalRaw 等)
-
-
-
绑定 Actions 到自定义设备控件: 这是将抽象动作连接到具体设备输入的关键步骤。
-
选中 LeftStickVerticalRaw Action。
-
在右侧的 Properties 面板中,点击 Path 属性旁边的 "+" 号,选择 Add Binding。
-
在弹出的绑定窗口 (Listen / Path) 中,展开 HID 或你设备继承的类型(如 Gamepad)。
-
找到你的自定义设备布局名称(在第 3 步中 InputControlLayout 的 displayName 定义的,例如 "Custom USB HID Device (Raw)" 或 "Phoenix SM600 Drone Controller (Raw)")。
-
展开该设备,找到你在第 2 步中定义的对应摇杆垂直方向的原始字节控件(例如 Data Byte 4 或你在 displayName 里标记的 "左摇杆上下?" 对应的 byte4Raw)。选择这个原始字节控件。
-
对 LeftStickHorizontalRaw Action 重复此过程,将其绑定到代表左摇杆水平方向的原始字节控件(例如 Data Byte 5 或 byte5Raw)。
-
(为其他需要读取的原始字节 Action(如右摇杆)重复绑定过程)
-
完成后,点击 Input Actions 编辑器窗口顶部的 Save Asset 按钮。
-
-
编写或调整输入读取脚本: 现在我们使用一个脚本来引用并读取这些配置好的 Actions。以下是你提供的脚本的一个修正和解释版本,假设我们读取上面定义的 LeftStickVerticalRaw 和 LeftStickHorizontalRaw。
using UnityEngine; using UnityEngine.InputSystem; // 引入 Input System 命名空间// 脚本名称(建议更通用,如 CustomDeviceInputReader) public class CustomDeviceInputReader : MonoBehaviour {// 使用 [SerializeField] 在 Inspector 中关联 Action Reference// 这些变量将链接到 Input Actions Asset 中定义的 Action[SerializeField]private InputActionReference leftStickVerticalRawAction; // 关联 LeftStickVerticalRaw Action[SerializeField]private InputActionReference leftStickHorizontalRawAction; // 关联 LeftStickHorizontalRaw Action// --- 如果需要读取右摇杆,也添加对应的引用 ---// [SerializeField]// private InputActionReference rightStickVerticalRawAction;// [SerializeField]// private InputActionReference rightStickHorizontalRawAction;// 存储读取到的原始值 (可选,用于调试或复杂处理)private byte rawVerticalValue;private byte rawHorizontalValue;// 存储处理后的轴值 (-1 to +1 范围)private float processedVerticalAxis;private float processedHorizontalAxis;// Awake 在脚本对象被加载时调用void Awake(){// 检查 Inspector 中的引用是否已设置,给出明确错误提示if (leftStickVerticalRawAction == null || leftStickVerticalRawAction.action == null)Debug.LogError("Left Stick Vertical Raw Action Reference not set in Inspector.", this);if (leftStickHorizontalRawAction == null || leftStickHorizontalRawAction.action == null)Debug.LogError("Left Stick Horizontal Raw Action Reference not set in Inspector.", this);// ... (添加对其他 Action 引用的检查) ...}// OnEnable 在对象或组件启用时调用void OnEnable(){// 启用需要监听的 Action。Action 必须启用后才能读取值或触发事件。leftStickVerticalRawAction?.action.Enable(); // ?. 安全调用,避免空引用错误leftStickHorizontalRawAction?.action.Enable();// ... (启用其他 Actions) ...}// OnDisable 在对象或组件禁用时调用void OnDisable(){// 禁用 Action,释放资源,停止监听。leftStickVerticalRawAction?.action.Disable();leftStickHorizontalRawAction?.action.Disable();// ... (禁用其他 Actions) ...}// Update 每帧调用一次void Update(){// --- 读取原始字节值 ---// 如果 Action 绑定到 byte 类型的 InputControl,即使 Action Type 是 Axis,// ReadValue<byte>() 也可以直接读取原始 0-255 值。if (leftStickVerticalRawAction != null && leftStickVerticalRawAction.action != null){rawVerticalValue = leftStickVerticalRawAction.action.ReadValue<byte>();}if (leftStickHorizontalRawAction != null && leftStickHorizontalRawAction.action != null){rawHorizontalValue = leftStickHorizontalRawAction.action.ReadValue<byte>();}// ... (读取其他原始字节值) ...// --- 处理原始字节值 ---// **非常重要:** 原始字节 (0-255) 需要根据设备的具体行为进行解释。// 常见的处理方式是:假定 128 是中心静止位置。// 小于 128 是一个方向,大于 128 是另一个方向。// 将其标准化到 -1 到 +1 的范围,方便用于游戏逻辑。// 注意:某些设备可能使用 0 作为起始点,或有不同的中心值和范围,需要根据实际情况调整!processedVerticalAxis = NormalizeByteAxis(rawVerticalValue);processedHorizontalAxis = NormalizeByteAxis(rawHorizontalValue);// ... (处理其他轴的原始值) ...// --- 输出调试信息 (可选) ---// 使用 F2 格式化输出,保留两位小数Debug.Log($"Raw Left Stick - V: {rawVerticalValue}, H: {rawHorizontalValue}");Debug.Log($"Processed Left Stick - V: {processedVerticalAxis:F2}, H: {processedHorizontalAxis:F2}");// ... (输出其他轴的值) ...// --- 在这里使用处理后的轴值控制游戏对象 ---// 例如,控制无人机的移动:// Vector3 movement = new Vector3(processedHorizontalAxis, 0, processedVerticalAxis);// transform.Translate(movement * Time.deltaTime * speed);// (具体逻辑取决于你的游戏需求)}// 辅助函数:将 0-255 的字节值标准化到大约 -1 到 +1 的范围// (假设 128 为中心点)private float NormalizeByteAxis(byte rawValue){// 将 byte (0-255) 转换为 floatfloat floatValue = rawValue;// 计算偏离中心 (128) 的值 (-128 to 127)float deviation = floatValue - 128f;// 标准化到 -1.0 到 ~1.0 的范围// 如果 deviation >= 0, 除以 127 (128 -> 0, 255 -> 1)// 如果 deviation < 0, 除以 128 (-128 -> -1, 0 -> 0)if (deviation >= 0){return deviation / 127f; // Max positive deviation is 255-128 = 127}else{return deviation / 128f; // Max negative deviation is 0-128 = -128}// // 简化版(假设正负范围对称,结果略有不同):// return (rawValue - 128f) / 128f;}// 注意: 使用 InputActionReference 时,Input System 通常会自动处理资源的生命周期,// 手动调用 Dispose 一般不是必需的,除非在非常特定的高级场景下。 }
-
将脚本添加到场景并配置:
-
在 Unity 场景中创建一个空的游戏对象(GameObject),或者选择一个你想用来处理输入的现有对象。
-
将上面编写的 CustomDeviceInputReader.cs 脚本拖拽到这个游戏对象的 Inspector 面板上。
-
你会看到脚本组件上有 Left Stick Vertical Raw Action 和 Left Stick Horizontal Raw Action 两个字段(以及你可能添加的其他字段)。
-
点击每个字段旁边的圆形图标,或者直接将你在 CustomControllerActions 资源文件中定义的相应 Action(例如 Gameplay/LeftStickVerticalRaw)拖拽到对应的字段上。
-
确保这个挂载了脚本的游戏对象在场景中是激活(Active)的。
-
-
运行与测试:
-
连接你的自定义 HID 设备。
-
运行 Unity 场景。
-
观察 Console 窗口的输出。当你移动手柄的左摇杆时,你应该能看到 Raw Left Stick 的字节值 (0-255) 和 Processed Left Stick 的标准化值 (-1 to +1) 相应地变化。
-
根据输出调整 NormalizeByteAxis 函数中的逻辑(特别是中心值 128 和除数 127/128),以确保静止时轴值接近 0,推到极限时接近 -1 或 +1。
-
下一步/优化:
-
直接映射标准控件: 如果你确定了哪些原始字节对应标准的游戏手柄控件(如左摇杆、右摇杆、按钮),可以回到第 2 步和第 3 步,修改设备布局 (struct 和 class)。使用 Input System 提供的更高级的 InputControl 布局(如 StickControl, ButtonControl),并在 FinishSetup() 方法中将原始字节数据处理后映射到这些标准控件上。这样做的好处是,你的 Input Actions 可以直接绑定到标准的 leftStick, rightStick, buttonSouth 等路径,使输入配置更通用,读取脚本也更简单(可以直接 ReadValue<Vector2>() 获取摇杆值)。但这需要对设备的数据格式有更深入的理解。
-
处理按钮: 按钮通常隐藏在某个字节的特定位(bit)中。需要使用位运算(如 & 按位与)来检查特定位是否为 1,以判断按钮是否按下。同样可以在设备布局中定义 ButtonControl 并进行映射。
现在,你应该拥有一个完整的流程:从识别未知 HID 设备、定义其数据布局、在 Unity Input System 中注册它,到最后通过 Input Actions 读取其(目前是原始的)输入值并在游戏中使用。
第 6 步:验证与调试
-
Unity 编辑器测试: 在编辑器中连接设备,打开 Window > Analysis > Input Debugger。检查 Devices 列表中是否出现了你的自定义设备(使用 displayName 标识)。选中它,观察右侧面板中定义的控件(如 byte1Raw 等)的值是否随手柄操作而变化。
-
PC Build 测试: 打包一个 PC 版本,确认设备在独立构建中也能正常工作。
-
Android/VR 设备测试:
-
将 APK 安装到 VR 设备。
-
连接手柄。
-
运行游戏,测试手柄输入是否符合预期。
-
如果无效:
-
确认 VR 设备系统层面是否识别手柄(例如,在系统设置或某些原生应用中测试)。
-
使用 adb logcat 查看设备日志,搜索与 Input System、HID 或你的设备 VID/PID 相关的错误或信息。
-
尝试远程连接 Unity Profiler 和 Input Debugger 到运行在 VR 设备上的开发版本,以进行更深入的调试。
-
-
结论: 通过精确定义 HID 设备的输入报告结构,并使用正确的 VID/PID 在 Input System 中注册自定义布局,可以实现对特定 USB HID 设备的支持。利用 [RuntimeInitializeOnLoadMethod] 确保该注册过程在最终打包的应用程序(包括 Android/VR APK)中也能自动执行,从而使自定义手柄能够在不同平台上一致地工作,前提是底层操作系统能够识别该 HID 设备。