news 2026/9/20 23:03:49

Unity多鼠标同屏交互:基于Raw Input API的设备独立输入方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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类。

鼠标移动时,驱动把增量数据交给 Windows,Windows 更新系统光标的位置,并把相应的WM_MOUSE*消息投递到焦点窗口。Unity 底层通过 Win32 API 轮询系统光标位置和按键状态,然后封装成Input.mousePositionGetMouseButton这些接口。麻烦就在这: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_INPUTWM_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));

usUsagePageusUsage的组合说明我们只关心鼠标设备。RIDEV_INPUTSINK让后台也能收到输入,RIDEV_DEVNOTIFY开启热插拔通知。这里有个小坑:如果不设hwndTarget或设为NULL,系统会把消息发给当前焦点窗口,后台时数据就断了,所以通常要把这个句柄指向你创建的隐藏窗口。

然后是在WndProc里处理消息。处理WM_INPUT时,用GetRawInputData取出数据并解析出RAWMOUSE

case WM_INPUT: { UINT dwSize = 0; GetRawInputData((HRAWINPUT)lParam, RID_INPUT, NULL, &dwSize, sizeof(RAWINPUTHEADER)); std::vector<BYTE> 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消息会带上设备句柄,插件处理时需要先根据句柄找到旧设备,清掉它的坐标和按键状态,再删除设备引用。如果要做持久识别,必须用设备路径。实测用GetRawInputDeviceInfoRIDI_DEVICENAME能拿到类似\\?\HID#VID_046D&PID_C077#7&1a2b3c4d&0&0000#{...}的路径,这个值在重插后保持不变,适合作为设备的持久 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 交互,分层实现会更稳,排查问题也更快。这套插件目前在我的几个展览项目里跑了快一年,热插拔、双屏、多用户同时操作都没再出过大问题。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 23:01:26

开源铁路信号模拟游戏:亲手体验闭塞联锁与进路排定

简介&#xff1a;这是一款铁路信号模拟游戏Train Signalling Simulation的开源资源包&#xff0c;面向铁路调度爱好者、游戏开发者和信号系统学习者&#xff0c;旨在通过模拟真实铁路交通管理&#xff0c;帮助理解信号控制、列车运行与调度决策。压缩包共含102个文件&#xff0…

作者头像 李华
网站建设 2026/9/20 22:55:17

GEO优化做了多久能看到效果?附完整时间线与阶段预期

GEO&#xff08;生成式引擎优化&#xff09;通常在 2–4 周出现首次可监测的曝光变化&#xff0c;6–8 周进入稳定收录期&#xff0c;3–6 个月形成可持续的 AI 引用位。速度较快的项目 10–15 天就能在部分 AI 平台被主动提及&#xff0c;而医疗、金融、法律等强监管行业往往需…

作者头像 李华