为 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 步:环境准备与设备识别

  1. 安装 Input System 包: 确保 Unity 项目已通过 Window > Package Manager 安装了 Input System 包。

  2. 获取设备标识符 (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)来精确映射数据包的内存布局。

  1. 创建 C# 脚本: 在 Unity 项目的 Assets 文件夹中创建一个新的 C# 脚本(例如 CustomHIDDeviceSupport.cs)。

  2. 定义结构体: 在脚本中,定义一个结构体,使用 StructLayout 属性指定精确的内存布局和大小,并使用 FieldOffset 指定每个数据字段在数据包中的字节偏移量。

  3. 映射原始字节 (初始阶段): 作为第一步,建议先将所有有意义的数据字节映射为原始 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 接口,指定数据格式为 HID
     public FourCC format => new FourCC('H', 'I', 'D');

    }


第 3 步:创建并注册设备布局 (Device Layout)

接下来,创建一个类来代表这个设备,并告诉 Input System 当匹配到特定 VID/PID 的设备时,应使用上面定义的结构体来解析其数据。

  1. 定义设备类: 在同一个 C# 脚本中,或另一个脚本中,创建一个继承自 InputDevice、Gamepad、Joystick 或 HID 的类。继承 Gamepad 或 Joystick 可以方便后续映射到标准控件。

  2. 添加 InputControlLayout 属性: 使用此属性标记该类,指定使用的状态结构体 (stateType) 和在 Input Debugger 中显示的名称 (displayName)。

  3. 实现静态构造函数: 在类中添加一个静态构造函数 (static ClassName())。这是注册布局的最佳位置,因为它会在类首次被访问时自动执行一次。

  4. 调用 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_BUILD
         Debug.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)时,要确保手柄正常工作,需要依赖以下条件:

  1. VR 设备支持 USB OTG: 设备的 USB 端口必须支持 USB On-The-Go 功能,允许其作为主机连接外部 USB 设备。主流 VR 头显通常支持此功能。

  2. Android 系统识别 HID 设备: VR 设备底层的 Android 操作系统需要能够识别插入的 USB 设备为标准的 HID 游戏手柄。对于大多数遵循 HID 规范的设备,Android 会自动处理。

  3. [RuntimeInitializeOnLoadMethod] 的作用: 正如代码中所示,这个属性确保了即使在没有 Unity 编辑器的设备上运行游戏时,InitializeInPlayer 方法也会被调用,进而触发静态构造函数中的 InputSystem.RegisterLayout。这保证了自定义布局在 APK 运行时被注册。

  4. VID/PID 准确性: 这是最关键的一点。如果在代码中提供的 VID 或 PID 与设备的实际值不符,Input System 将无法将设备与自定义布局匹配,导致设备可能被识别为通用 HID 设备或完全不被识别。

  5. 物理连接: 确保使用的 USB 线缆和任何必要的转接头(如 USB-A to USB-C)工作正常。


第 5 步:创建 Input Actions 并编写脚本读取输入

现在我们已经教会了 Unity 如何识别和理解我们的自定义 HID 设备(如 Phoenix SM600 手柄)发送的原始数据,接下来需要设置 Input Actions 并编写一个脚本来实际读取这些数据,并将其用于游戏逻辑。

  1. 创建 Input Actions Asset:

    • 在 Unity 项目的 Assets 窗口中,右键点击 Create > Input Actions。

    • 给这个新资源文件命名,例如 CustomControllerActions。

    • 双击打开该资源文件,进入 Input Actions 编辑器。

  2. 定义 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 等)

  3. 绑定 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 按钮。

  4. 编写或调整输入读取脚本: 现在我们使用一个脚本来引用并读取这些配置好的 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) 转换为 float
            float 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 一般不是必需的,除非在非常特定的高级场景下。
    }
  5. 将脚本添加到场景并配置:

    • 在 Unity 场景中创建一个空的游戏对象(GameObject),或者选择一个你想用来处理输入的现有对象。

    • 将上面编写的 CustomDeviceInputReader.cs 脚本拖拽到这个游戏对象的 Inspector 面板上。

    • 你会看到脚本组件上有 Left Stick Vertical Raw Action 和 Left Stick Horizontal Raw Action 两个字段(以及你可能添加的其他字段)。

    • 点击每个字段旁边的圆形图标,或者直接将你在 CustomControllerActions 资源文件中定义的相应 Action(例如 Gameplay/LeftStickVerticalRaw)拖拽到对应的字段上。

    • 确保这个挂载了脚本的游戏对象在场景中是激活(Active)的。

  6. 运行与测试:

    • 连接你的自定义 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 步:验证与调试

  1. Unity 编辑器测试: 在编辑器中连接设备,打开 Window > Analysis > Input Debugger。检查 Devices 列表中是否出现了你的自定义设备(使用 displayName 标识)。选中它,观察右侧面板中定义的控件(如 byte1Raw 等)的值是否随手柄操作而变化。

  2. PC Build 测试: 打包一个 PC 版本,确认设备在独立构建中也能正常工作。

  3. 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 设备。

相关推荐
dalancon4 小时前
VSYNC 信号流程分析 (Android 14)
android
dalancon4 小时前
VSYNC 信号完整流程2
android
dalancon4 小时前
SurfaceFlinger 上帧后 releaseBuffer 完整流程分析
android
用户69371750013845 小时前
不卷AI速度,我卷自己的从容——北京程序员手记
android·前端·人工智能
程序员Android5 小时前
Android 刷新一帧流程trace拆解
android
墨狂之逸才6 小时前
解决 Android/Gradle 编译报错:Comparison method violates its general contract!
android
阿明的小蝴蝶6 小时前
记一次Gradle环境的编译问题与解决
android·前端·gradle
汪海游龙7 小时前
开源项目 Trending AI 招募 Google Play 内测人员(12 名)
android·github
qq_283720058 小时前
MySQL技巧(四): EXPLAIN 关键参数详细解释
android·adb
没有了遇见9 小时前
Android 架构之网络框架多域名配置<三>
android