Unity多鼠标同屏交互:基于Raw Input API的设备独立输入方案 简介这是一款面向Unity引擎开发者的多鼠标监测插件用于在游戏中同时监听多个无线鼠标设备的输入实现多光标独立移动、点击与操作适合策略类、合作类或模拟类等多人协作场景也可作为学习Unity输入系统高级用法的参考工程。压缩包共2000个文件大小约110.65MB核心内容涵盖C#脚本、预制体、材质与动画资源同时包含完整Unity工程设置文件Assets、ProjectSettings、Packages、Library等和大量说明文档便于直接导入或按需查阅。插件提供多鼠标输入监测、独立光标控制、输入事件转换、用户自定义配置接口等功能内部工程还展示了结合事件管理与多线程逻辑处理不同设备输入的写法。目前已有222人学习浏览适合希望快速为游戏加入多人光标操控能力或深入研究Unity输入系统底层机制的开发者。 接到过一个人双人协同白板项目甲方要求两个使用者各拿一个鼠标在同一块屏幕上同时批注、拖拽互不干扰。当时我第一反应是Unity 不是有Input.mousePosition吗直接读鼠标位置不就行了真把两个 USB 鼠标插到同一台电脑上才发现Unity 的输入系统把两个鼠标合并成了一个指针屏幕上只有一个光标在动两个鼠标的移动量互相叠加按下左键时你根本不知道是谁按的。查了一圈社区方案有人建议用新输入系统 Input System Package实际测下来它对多个物理鼠标的支持并不完整同一台设备插两个鼠标依旧被合并成一个主指针。最后只能自己动手走 Windows 底层的 Raw Input API写了一个多鼠标监测插件把每个物理鼠标的设备级数据独立取出来再在 Unity 里做分发和业务处理。这篇博文就是当时完整的技术方案与踩坑记录适合所有在 Unity 里做本地多人交互、多用户同屏协作、互动展览项目的开发者参考。1. 这个需求从哪来单鼠标假设撑不起双人同屏交互1.1 常规 Input 系统为什么搞不定多个鼠标Unity 的老版输入系统里一切鼠标数据都汇入同一个全局状态Input.mousePosition是唯一的Input.GetMouseButtonDown不区分设备。就算你接十个鼠标Unity 里看到的也只是一个合成后的鼠标——所有设备的移动量会叠加到同一个指针上。新输入系统 Input System Package 引入了多指针Pointer概念但实测下来它把多个物理鼠标注册进同一个Mouse设备通道依然合并成一个主指针。这是 Windows 输入架构决定的鼠标硬件事件先进入系统级鼠标状态合成系统光标Unity 默认读的就是这个系统光标逻辑上就认为只有一个鼠标。方案是否能区分物理设备是否能独立读取每个鼠标坐标实现门槛Input 类否否零成本Input System Package部分但多鼠标仍合并否中等Windows Raw Input API是是较高需要写原生插件如果你只是用鼠标玩单机游戏完全不用关心这些。但凡是两个用户在同一台电脑上操作同一块屏幕的场景单鼠标假设就撑不住了。1.2 真实业务场景谁会需要同时多个鼠标这套能力并不是极客炫技实际项目中需求不少双人协同白板/教学终端老师与学生、或者两个学生共用一块大屏各自拿鼠标批注、拖拽图片要求互不干扰。互动展览/商场大屏多个参观者同时操作一块巨型触摸屏或鼠标台每个操作者有自己的指针点不同的内容。本地多人游戏共享屏幕的对抗或合作游戏两个玩家各持鼠标同时点击按钮、同时拖拽角色。数据可视化大屏多个运营人员同时在大屏上分析不同区域的数据。这些场景的共同点用一句话说就是系统里只有一个光标远远不够必须让每个物理鼠标拥有独立的逻辑指针、独立的输入事件流。这也是我做这个插件的核心动机。2. 底层原理Unity 的输入管线在系统层断掉了哪一环2.1 从硬件消息到 Unity 内部状态的完整链路要理解为什么必须写原生插件得先看清一条链路鼠标硬件 - 设备驱动 - Windows 消息系统 - 系统光标状态 - Unity 底层轮询 -Input类。鼠标移动时驱动把增量数据交给 WindowsWindows 更新系统光标的位置并把相应的WM_MOUSE*消息投递到焦点窗口。Unity 底层通过 Win32 API 轮询系统光标位置和按键状态然后封装成Input.mousePosition和GetMouseButton这些接口。麻烦就在这Windows 把多个物理鼠标的输入全部合入同一个系统光标Unity 拿到的永远是合并后的结果而不是哪个设备产生了什么输入。要拿到设备级数据必须绕开这条链路直接读取 HID 层上报的原始输入。2.2 为什么是 Raw Input API 而不是全局鼠标钩子网上找多鼠标方案时有人会提全局钩子WH_MOUSE_LL。这个方案能截获系统里的所有鼠标消息但它只能告诉你鼠标动了、按了哪个键拿不到确切的设备标识。两个鼠标同时滚轮时你根本无法区分哪个是哪个。后来我转向 Windows 的 Raw Input API它有几个关键优势设备唯一标识WM_INPUT消息里携带RAWINPUT结构体其中hDevice是物理设备的唯一句柄还能通过GetRawInputDeviceInfo拿到设备名称和路径。后台接收能力注册时加上RIDEV_INPUTSINK标志即使窗口没有焦点也能收到输入消息这对 Unity 编辑器里调试和全屏运行时都很关键。热插拔感知通过WM_INPUT_DEVICE_CHANGE消息能实时感知设备插入与拔出做状态清理非常方便。能力全局鼠标钩子 WH_MOUSE_LLRaw Input API区分多个物理设备困难原生支持后台窗口接收输入可以RIDEV_INPUTSINK获取设备路径/名称有限完整感知热插拔需额外处理内置消息简单说Raw Input API 是 Windows 专门为精确读取设备输入设计的入口硬件厂商、游戏外设驱动都在用同一套机制。选择它是技术上最自然的结果。3. 插件实现原生层与 C# 层的完整链路3.1 原生插件骨架注册设备与处理消息我用 C 写了一个 Win32 插件核心就做三件事注册 Raw Input 设备、在消息循环里接收WM_INPUT和WM_INPUT_DEVICE_CHANGE、把数据解析后回传给 C#。先看注册部分RAWINPUTDEVICE rid; rid.usUsagePage 0x01; // Generic Desktop rid.usUsage 0x02; // Mouse rid.dwFlags RIDEV_INPUTSINK | RIDEV_DEVNOTIFY; rid.hwndTarget hwnd; // 接收消息的窗口句柄 RegisterRawInputDevices(rid, 1, sizeof(rid));usUsagePage和usUsage的组合说明我们只关心鼠标设备。RIDEV_INPUTSINK让后台也能收到输入RIDEV_DEVNOTIFY开启热插拔通知。这里有个小坑如果不设hwndTarget或设为NULL系统会把消息发给当前焦点窗口后台时数据就断了所以通常要把这个句柄指向你创建的隐藏窗口。然后是在WndProc里处理消息。处理WM_INPUT时用GetRawInputData取出数据并解析出RAWMOUSEcase WM_INPUT: { UINT dwSize 0; GetRawInputData((HRAWINPUT)lParam, RID_INPUT, NULL, dwSize, sizeof(RAWINPUTHEADER)); std::vectorBYTE buffer(dwSize); if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buffer.data(), dwSize, sizeof(RAWINPUTHEADER)) dwSize) { RAWINPUT* raw (RAWINPUT*)buffer.data(); if (raw-header.dwType RIM_TYPEMOUSE) { RAWMOUSE rm raw-data.mouse; // 此处通过 raw-header.hDevice 区分物理设备 // 把 rm.usFlags/ulButtons/ulRawButtons/usButtonFlags/lLastX/lLastY 打包成结构体 // 通过回调函数指针传给 C# 侧 } } break; }在 C 里定义好回调类型然后把插件导出给 C# 用。导出函数就两个StartMouseMonitor(HWND hwnd, CallbackFunc fn)和StopMouseMonitor()。3.2 C# 侧如何接收并分发多鼠标事件C# 侧主要工作是把原生回调的数据转成 Unity 能消费的事件。先用DllImport导入插件函数再定义一个安全的回调委托[UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void MouseRawDataCallback(ref RawMouseData data); [DllImport(MouseMonitorPlugin)] private static extern void StartMouseMonitor(IntPtr hwnd, MouseRawDataCallback callback); [DllImport(MouseMonitorPlugin)] private static extern void StopMouseMonitor();这里要注意原生回调线程不是 Unity 主线程不能在里面直接访问UnityEngine.Object或调用任何 API否则会随机崩溃。我的做法是回调里只把数据写进ConcurrentQueue然后在Update里统一取出来处理void Update() { while (_queue.TryDequeue(out RawMouseData data)) { // 找到对应设备更新设备状态、光标坐标、按键状态 _devices[data.deviceHandle].position new Vector2(data.relX, data.relY); // 派发移动/按下/抬起/滚轮事件 } }RawMouseData里应包含设备句柄、事件类型、相对位移、绝对坐标如果有、按键标志位。相对位移适合直接累加成自定义坐标绝对坐标则需要做窗口坐标转换后映射到 Unity 的屏幕坐标。另一个容易踩的坑是设备句柄不能当长期 ID 用。USB 设备重新插拔后hDevice值会变。我建议用GetRawInputDeviceInfo拿到的设备路径作为唯一标识句柄只作为会话期查找用的 key。3.3 接入 UGUI 事件系统的思路多鼠标数据拿到后最直接的需求是让两个鼠标能独立点击 UI。Unity 自带EventSystem不区分物理设备它默认以Input.mousePosition为准做 raycast所以不能直接复用。我的简化方案是绕开EventSystem自己用Graphic.Raycast做逐设备检测public PointerEventData CreateEventData(MouseDevice device) { var eventData new PointerEventData(EventSystem.current); eventData.position device.logicalPosition; return eventData; }每帧为每个物理设备构造独立的PointerEventData然后传入GraphicRaycaster.Raycast拿到命中的 UI 元素再手动调用IPointerClickHandler.OnPointerClick这类接口。这样两个鼠标可以点不同的按钮、拖不同的列表互不干扰。如果你需要更完整的交互支持可以写一个自定义InputModule覆盖Process方法把多鼠标事件注入EventSystem但初期项目建议先用轻量 raycast稳定之后再升级。4. 实测踩坑窗口焦点、坐标换算和热插拔4.1 后台窗口收不到消息的真相第一版插件跑在 Unity 编辑器的 Game 视图里一切正常一发布成独立 exe 就发现鼠标消息丢失。排查到最后原因是发布后窗口不是激活状态时系统默认把 Raw Input 消息只发给焦点窗口而我的插件窗口拿不到数据。解决办法是注册时加上RIDEV_INPUTSINK并确保hwndTarget指向自己创建的隐藏窗口。加了之后即使 Unity 窗口在后台只要鼠标在屏幕上移动数据依然能进来。还有一个更隐蔽的问题Unity 全屏模式时如果使用独占全屏Exclusive Fullscreen显示模式切换可能会打断消息链。实测建议用无边框窗口模式替代独占全屏稳定性会好很多多鼠标场景也更好调试。4.2 DPI 缩放、多屏坐标和鼠标增量换算这是整个项目里最折磨人的部分。Windows 默认有 DPI 缩放Unity 的Screen.width/height是逻辑像素而 Raw Input 上报的坐标可能是物理像素也可能是相对增量。解析RAWMOUSE.usFlags时要注意如果设置了MOUSE_MOVE_ABSOLUTE说明lLastX/lLastY是绝对坐标且范围可能是 0-65535归一化坐标也可能已经是屏幕像素。如果没设置则lLastX/lLastY是相对上次位置的移动增量。对大多数鼠标默认是相对移动所以我用累加方式维护每个设备的独立坐标。但如果你的应用需要鼠标绝对定位比如触控一体机模拟鼠标必须处理坐标归一化否则指针会乱飞。换算公式大致是float screenX absoluteX / 65535f * Screen.width; float screenY absoluteY / 65535f * Screen.height;多屏场景下还要把鼠标坐标扣掉主屏原点偏移再换算到 Unity 窗口内的局部坐标。我实测的做法是记录 Unity 窗口在屏幕坐标系中的矩形位置然后用鼠标全局坐标 - 窗口原点得到窗口内坐标。不处理这一步副屏上的鼠标事件坐标会整体偏移。4.3 USB 热插拔和设备重枚举热插拔场景在展览项目里非常常见——参观者随手拔掉一个鼠标插到旁边机器上。如果代码里只按hDevice关联设备拔掉重插后同一个鼠标会变成新设备旧状态如果不清理屏幕上就会留下一个不动的幽灵光标。WM_INPUT_DEVICE_CHANGE消息会带上设备句柄插件处理时需要先根据句柄找到旧设备清掉它的坐标和按键状态再删除设备引用。如果要做持久识别必须用设备路径。实测用GetRawInputDeviceInfo的RIDI_DEVICENAME能拿到类似\\?\HID#VID_046DPID_C077#71a2b3c4d00000#{...}的路径这个值在重插后保持不变适合作为设备的持久 ID。4.4 不要在回调线程里直接碰 Unity API这是 Unity 原生插件开发的老生常谈但每次还是能看到有人踩。C 回调触发时线程并不在 Unity 主线程上直接调用Debug.Log都可能闪退。标准做法是回调里只用Interlocked或简单锁保护队列数据扔进去就立刻返回。到Update时再取出数据、更新状态、派发事件。我一开始把累加坐标的逻辑放在了回调里结果两个鼠标快速移动时经常出现坐标跳变。后来才意识到回调里虽然能安全累加整数但如果同时读 Unity 的Screen属性做坐标换算就会出问题。把所有逻辑搬回主线程后问题消失。另外高频移动事件会积压队列所以在主线程消费时我做了合并只保留每个设备最新的位置增量中间过程丢弃性能和精度都能接受。5. 从能监测到好用业务层的进一步打磨5.1 让每个物理鼠标拥有独立指针和独立坐标插件能上报设备数据后只是解决了数据源问题真正让业务跑起来还需要在 Unity 里做一层抽象。我给每个设备维护了以下状态public class MouseDevice { public string persistentId; public Vector2 logicalPosition; public bool leftButtonPressed; public bool rightButtonPressed; public float wheelDelta; }logicalPosition是累计坐标用来驱动自定义光标。实际项目中我让不同用户的光标使用不同颜色必要时还加了尾迹效果方便使用者快速识别自己的指针。UI 交互则通过Graphic.Raycast做逐设备检测每个设备独立触发 hover、click、drag 事件。这一步完成后两个用户才能在同一块屏幕上互不干扰地操作。5.2 主从设备逻辑与多用户状态机真实项目里并不是每个鼠标都要有完全一样的控制权。比如白板应用中菜单、翻页、保存这类全局操作最好只由某个主鼠标控制否则所有人同时点保存操作就乱了。我当时实现了一套简单的主从规则第一个被系统识别到的鼠标设为主设备负责全局菜单和导航。其他鼠标作为辅助设备只操作白板画布、素材拖拽等业务元素。主设备拔出时按插入顺序自动升迁一个新的主设备并立刻广播状态变化。这么设计之后两个用户同时操作也不会互相干扰——一个在画布上画画另一个可以把工具面板拖到自己旁边偶尔切换主鼠标权限也能做到。多用户状态机虽然简单但能避免大量交互冲突。5.3 Cross-Platform 方向的简明思路如果你只在 Windows 上跑上面这套方案够用了。如果项目有跨平台需求我可以把方向简单列一下macOS 上可以考虑 IOKit 的 HID 事件监听能拿到每个设备的输入事件Linux 上可以通过读取/dev/input/eventX获取设备事件但需要处理设备节点枚举和权限问题。Unity 侧抽象一套IMouseDeviceProvider接口把平台差异挡在接口后面上层只处理逻辑指针和业务状态这样适配不同平台时改动会小很多。这是我在另一个项目里用过的分层方式效果不错。整套插件的难点其实不在 API 调用而在业务层怎么定义每个鼠标的指针属于谁、做什么操作。尤其是两个鼠标同时点击同一个按钮时冲突处理很关键。我在项目中用的是按设备锁定交互对象的方式某个 UI 元素一旦被某用户拖住就只对该设备响应释放后其他设备才允许点选。实测下来交互冲突少很多现场观众也不会觉得操作失灵。另外想提醒一句如果只是做两个鼠标同时点击的简单验证先把 Raw Input 的注册、回调、队列跑通再考虑 UI 交互分层实现会更稳排查问题也更快。这套插件目前在我的几个展览项目里跑了快一年热插拔、双屏、多用户同时操作都没再出过大问题。本文还有配套的精品资源点击获取