为 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 设备。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Unity青子

难题的解决使成本节约,求打赏

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值