尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity多鼠标监测插件实战:Raw Input底层原理与避坑指南

Unity多鼠标监测插件实战:Raw Input底层原理与避坑指南 简介这是一款面向Unity引擎开发者的多鼠标监测插件专为策略游戏、合作游戏及模拟类游戏等需要多人同屏操作的场景设计。插件能够识别并跟踪多个无线鼠标的输入信号为每个鼠标分配独立光标并将位置、移动、点击等信息转化为Unity可处理的游戏事件同时提供用户配置接口方便自定义控制范围与敏感度并针对不同硬件和操作系统做了兼容性处理。压缩包共2000个文件大小约110.65MB内容涵盖459个md文档、397个bin二进制资源、171个txt文本、95个json配置、24个cs脚本及prefab、mat、unity、png等类型包含完整的Unity工程结构、项目设置、脚本组件和资源文件便于开发者直接导入或参考实现。目前已222人学习适合希望快速为项目加入多鼠标独立控制能力的Unity开发者可有效提升多人互动游戏的操作体验与玩法拓展性。 最近我把一套“Unity多鼠标监测插件”从零开始做到了能稳定跑前前后后踩了不少Raw Input的坑。如果你也在做多人互动大屏、教室同屏协作、展厅导览这类项目多半会撞上同一个需求让两只以上的鼠标同时操作界面而不是被系统锁成一个指针。这篇就围绕“Unity多鼠标监测插件”的实现思路把Windows底层那个能区分每个物理设备的Raw Input API讲清楚并给出一个可以复现的插件骨架。严格来说Unity本身并不提供“多套鼠标输入”的高级API官方Input System里只有一个Mouse.current它拿到的永远是系统合成的鼠标状态。想真正区分“哪个鼠标动了”“哪个鼠标按了键”必须绕到底层去监听系统原始输入事件。这篇文章适合已经接触过Unity插件、知道P/Invoke大概怎么用的开发者如果你刚入门建议先把RAWINPUT、WM_INPUT这几个结构体和消息啃一遍再回看代码会轻松很多。1. 项目背景为什么Unity默认鼠标输入搞不定双鼠标1.1 你遇到过的“第二个鼠标”困惑我做这个插件的起因是接手了一个双人知识问答大屏项目需求很直接两个人各拿一个鼠标在同一个大屏上同时点击不同的选项。第一版我天真地用了Unity自带的鼠标输入结果发现两个鼠标在系统层面被合并成一个指针两个人根本没法同时操作。这背后的机制并不复杂Windows在桌面环境下会把所有指向设备鼠标、轨迹球、触摸板汇总为一个“系统光标”应用程序通过WM_MOUSEMOVE拿到的坐标就是系统光标坐标。也就是说系统已经帮你把多个鼠标“搅匀”了Unity再从上往下读自然读不到独立的设备数据。1.2 这个多鼠标监测插件到底能解决什么问题这个插件的目标是把每个物理鼠标的独立通道重新“挖”出来。每个鼠标会有一个虚拟指针能够单独获取相对位移、左右中键状态并且可以自定义每帧的灵敏度和光标贴图。有了这套数据你就能在项目中自己做碰撞检测、UI点击、轨迹判定等逻辑。我后面做出来的效果是屏幕上同时存在两个自定义光标左右两边的用户各控各的互不干扰。某个鼠标点击时只有自己那个虚拟光标的Raycast生效不会误触到另一个人的UI区域。对于大屏问答、协作批注、双人竞技这类玩法这是从“能不能做”到“做得好不好”的关键一步。2. 技术方案选Raw Input的底层逻辑2.1 Windows对多个鼠标的“合流”机制在Windows的输入架构里鼠标输入分成两层底层设备输入每个物理鼠标驱动上报的是独立的设备输入数据。上层应用输入系统窗口消息层只提供合成后的光标位置和按键状态。普通应用拿到的WM_MOUSEMOVE实际上是系统将多个鼠标的光标位置“求和平均”之后的结果。你移动鼠标A和鼠标B对系统来说都是“让整个光标移动”最终光标落点是所有鼠标动作叠加后的结果。这在你只用一个鼠标时毫无问题但一旦变成双鼠标场景就会完全失控。所以想在应用层恢复“每个鼠标是独立设备”的事实就必须跳过WM_MOUSEMOVE这套合成机制直接访问底层设备上报的数据。2.2 为什么必须用Raw Input APIWindows专门为这类需求提供了一个底层接口Raw Input。它允许程序直接接收原始输入设备的报文并且每个设备都有一个唯一的hDevice句柄用来区分。当你注册了鼠标设备后系统会通过WM_INPUT消息把RAWINPUT结构体发到窗口里面包含RAWMOUSE记录了这个物理鼠标的相对位移lLastX、lLastY以及按键状态和数据来源设备ID。相比DirectInputRaw Input更现代不需要枚举设备也能拿到数据也不需要把它们放进一个独占模式里。关键是它对Unity这种和窗口消息系统绑定的引擎兼容性更好非常适合做插件底座。2.3 消息拦截路径的选择既然要用Raw Input下一步就是解决“WM_INPUT消息怎么拿到”的问题。Unity的窗口消息循环是它自己管理的C#层拿不到原始的WndProc。我第一次尝试用SetWindowsHookEx在C#里挂钩子发现能给特定线程加消息钩子但由于Unity的主窗口消息不经过托管钩子回调的线程队列经常出现消息漏捕稳定性很差。后来我改用C插件方案拿到Unity主窗口句柄后用SetWindowLongPtr把默认窗口过程替换成自己写的WndProc在替换后的窗口过程里捕获WM_INPUT并处理再用CallWindowProc把消息交还给Unity原来的处理流程。这样消息一口都不会漏整个方案可控性更高。还有一点要注意如果希望程序在后台不聚焦时也能收到鼠标消息注册设备时dwFlags要带上RIDEV_INPUTSINK并指定一个目标窗口句柄。这也是展厅项目里的必备选项。3. 实现拆解四个关键环节3.1 注册Raw Input设备第一步告诉系统“我要监听哪些设备”。对鼠标来说usUsagePage是0x01usUsage是0x02。RAWINPUTDEVICE rid; rid.usUsagePage 0x01; // Generic Desktop Page rid.usUsage 0x02; // Mouse Usage rid.dwFlags RIDEV_INPUTSINK; // 即使窗口不聚焦也能接收 rid.hwndTarget mainWindowHandle; // 目标窗口 RegisterRawInputDevices(rid, 1, sizeof(rid));注意RIDEV_INPUTSINK会带来一个副作用在窗口不聚焦时后台也会收到鼠标输出。如果不想在后台被输入干扰可以去掉这个Flag。我当时为了测试方便保留了这个能力反而顺带解决了原来窗口失焦后虚拟光标不更新的问题。3.2 窗口过程替换与消息捕获拿到Unity主窗口句柄后用自定义的WndProc替换原来的窗口过程// 保存旧窗口过程 WNDPROC originalWndProc (WNDPROC)GetWindowLongPtr(hwnd, GWLP_WNDPROC); // 设置新窗口过程 SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR)CustomWndProc);然后在自定义窗口过程中处理WM_INPUTLRESULT CALLBACK CustomWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg WM_INPUT) { ProcessRawInput((HRAWINPUT)lParam); return 0; // 事件已处理 } return CallWindowProc(originalWndProc, hwnd, msg, wParam, lParam); }这段代码的核心思想是“代理”把所有消息先过一遍自己的逻辑再把不属于自己的消息交给原窗口过程。这里有个坑替换窗口过程期间如果插进线程问题Unity主窗口可能卡死。所以我建议在Awake里挂接、在OnApplicationQuit里还原并且整个插件只在播放模式下生效。3.3 数据结构与解析RAWINPUT结构体比较大解析时需要先从lParam拿到hRawInput再调用GetRawInputData两次第一次取大小第二次取数据。伪代码如下UINT dataSize 0; GetRawInputData(hRawInput, RID_INPUT, NULL, dataSize, sizeof(RAWINPUTHEADER)); BYTE* buffer new BYTE[dataSize]; if (GetRawInputData(hRawInput, RID_INPUT, buffer, dataSize, sizeof(RAWINPUTHEADER)) dataSize) { RAWINPUT* raw (RAWINPUT*)buffer; if (raw-header.dwType RIM_TYPEMOUSE) { RAWMOUSE mouse raw-data.mouse; // mouse.lLastX / mouse.lLastY 是相对位移 // mouse.usButtonFlags 是按键事件 } } delete[] buffer;我把解析结果简化成一个MouseData结构体丢到一个环形队列里C#侧每帧去取。这样可以避免C直接调用C#委托带来的生命周期管理问题实测下来帧率影响可以忽略。3.4 每个鼠标一个虚拟光标系统光标永远只有一个这限制了我们画“第二个鼠标”。所以插件里每个鼠标的指针实际上是自己画的UI对象。我建议用一个独立的Camera或者Screen Space Camera Canvas专门放这些光标确保不会被场景遮挡。每个虚拟光标维护自己的Vector2 position每一帧累加Raw Input上报的dx、dy。初版我直接把位移像素映射到UI坐标结果发现鼠标抖得厉害后来加了灵敏度系数和少量平滑插值才解决。4. 核心代码与Unity侧集成4.1 C插件核心代码片段下面是我整理过的核心代码片段去掉了项目里多余的日志和上报逻辑// MultiMouseInput.cpp #include windows.h #include queue struct MouseData { UINT deviceId; int dx; int dy; DWORD buttonFlags; }; static std::queueMouseData g_mouseQueue; static WNDPROC originalWndProc nullptr; void ProcessRawInput(HRAWINPUT hRawInput) { UINT size 0; GetRawInputData(hRawInput, RID_INPUT, NULL, size, sizeof(RAWINPUTHEADER)); if (size 0) return; BYTE* buffer new BYTE[size]; if (GetRawInputData(hRawInput, RID_INPUT, buffer, size, sizeof(RAWINPUTHEADER)) size) { RAWINPUT* raw (RAWINPUT*)buffer; if (raw-header.dwType RIM_TYPEMOUSE) { MouseData md; md.deviceId (UINT)raw-header.hDevice; md.dx raw-data.mouse.lLastX; md.dy raw-data.mouse.lLastY; md.buttonFlags raw-data.mouse.usButtonFlags; g_mouseQueue.push(md); } } delete[] buffer; } LRESULT CALLBACK CustomWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg WM_INPUT) { ProcessRawInput((HRAWINPUT)lParam); return 0; } return CallWindowProc(originalWndProc, hwnd, msg, wParam, lParam); } extern C __declspec(dllexport) void AttachToWindow(HWND hwnd) { originalWndProc (WNDPROC)GetWindowLongPtr(hwnd, GWLP_WNDPROC); SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR)CustomWndProc); RAWINPUTDEVICE rid; rid.usUsagePage 0x01; rid.usUsage 0x02; rid.dwFlags RIDEV_INPUTSINK; rid.hwndTarget hwnd; RegisterRawInputDevices(rid, 1, sizeof(rid)); } extern C __declspec(dllexport) bool PollMouseData(MouseData* outData, int maxCount) { int count 0; while (!g_mouseQueue.empty() count maxCount) { outData[count] g_mouseQueue.front(); g_mouseQueue.pop(); } return count 0; } extern C __declspec(dllexport) void DetachFromWindow(HWND hwnd) { SetWindowLongPtr(hwnd, GWLP_WNDPROC, (LONG_PTR)originalWndProc); }这里把hDevice直接转成UINT作为设备ID在某些Windows版本上可能指针截断但在实际使用中常用足够。推荐用uint64存句柄免得Debug时看着十六进制发懵。4.2 C#侧DllImport与数据轮询Unity侧需要声明C接口并在Update里每帧读取using System; using System.Runtime.InteropServices; using UnityEngine; public struct MouseData { public uint deviceId; public int dx; public int dy; public uint buttonFlags; } public class MultiMouseInput : MonoBehaviour { [DllImport(MultiMouseInput, EntryPoint AttachToWindow)] private static extern void AttachToWindow(IntPtr hwnd); [DllImport(MultiMouseInput, EntryPoint PollMouseData)] private static extern bool PollMouseData([Out] MouseData[] data, int maxCount); [DllImport(MultiMouseInput, EntryPoint DetachFromWindow)] private static extern void DetachFromWindow(IntPtr hwnd); [DllImport(user32.dll)] private static extern IntPtr GetActiveWindow(); private MouseData[] buffer new MouseData[64]; private IntPtr windowHandle; private void OnEnable() { windowHandle GetActiveWindow(); AttachToWindow(windowHandle); } private void Update() { if (PollMouseData(buffer, buffer.Length)) { foreach (var md in buffer) { if (md.deviceId 0 md.dx 0 md.dy 0) continue; // 这里把每个鼠标的数据分发给对应的虚拟光标 ProcessDeviceData(md); } } } private void OnDisable() { DetachFromWindow(windowHandle); } private void ProcessDeviceData(MouseData md) { // 业务逻辑更新虚拟光标位置、检测按下抬起等 } }这里有几个C#侧容易踩的编码问题结构体布局必须用默认内存布局C侧不要加#pragma pack改对齐PollMouseData的数组要预先分配好长度回调函数也不要频繁new数组。我就是因为频繁分配缓冲导致GC峰值后来固定缓冲后好多了。4.3 接入Unity场景的流程把插件接到场景里其实只需要四步把C编译出的MultiMouseInput.dll放到Unity项目的Plugins/x86_64目录下。新建一个空物体挂上MultiMouseInput脚本。在场景里创建两个Image作为虚拟光标并隐藏系统光标Cursor.visible false; Cursor.lockState CursorLockMode.None;。把两个Image拖给脚本对应的光标管理字段运行工程测试。如果你有两个物理鼠标插上就能看到两个光标独立运动。如果只有一个鼠标另一个设备ID是虚拟的也能看到一静一动两个光标方便验证数据链路是否通畅。5. 实测避坑常见问题与排查5.1 光标漂移与灵敏度问题Raw Input上报的lLastX和lLastY不是严格的屏幕像素坐标而是鼠标驱动换算后的“count/mickey”直接乘一个固定比例就能用但不同设备、不同系统的鼠标速度设置会让同一个比例表现出完全不同的手感。我最后在选项里暴露了一个Sensitivity参数默认值设成1.0同时加了一阶低通滤波float smooth 1f - Mathf.Exp(-Time.deltaTime * 20f); cursorPos new Vector2(md.dx, md.dy) * sensitivity * smooth;这个公式比直接累加更跟手又不会感觉到明显延迟。5.2 DPI缩放和坐标错位如果你的Windows缩放是150%或200%Unity的坐标系按照物理像素运行时Raw Input的位移会和你肉眼看到的光标错位。最直接的解决办法是取消系统级缩放或者把Unity Player Settings里的Resizable Window关掉用一个固定逻辑分辨率的全屏窗口。我在插件里加了一个WindowScaleFactor通过GetDpiForWindow读取当前窗口DPI把Raw Input位移除以缩放系数。实测在缩放125%的笔记本上也能基本对齐。5.3 焦点丢失与后台监听如果不加RIDEV_INPUTSINK程序一旦失去焦点Raw Input消息就断了。展厅项目经常需要多屏拼接主程序窗口可能不是用户正在看着的窗口所以一定要在注册设备时带上RIDEV_INPUTSINK。但要注意后台监听时所有鼠标操作都会被当成原始输入上报包括切屏时的点击。如果你的项目不需要后台响应还是在失去焦点时手动暂停虚拟光标逻辑避免误触发。5.4 没有第二只鼠标怎么调试不是所有伙伴手边都有两个鼠标。我测试时用了一段时间的输入模拟工具在系统层面生成第二个鼠标设备事件效果和真实鼠标基本一致。还用过一台虚拟机配合USB直通模拟第二只鼠标但虚拟机的输入延迟偏高仅适合验证链路不适合调手感。如果你只有一台电脑也可以把系统里的触摸屏或数位板当作“第二只鼠标”来用Raw Input同样能识别为额外设备但要注意它的usUsage可能是0x04笔需要额外注册。6. 个人心得与后续扩展6.1 这套方案还能往哪些方向扩展多鼠标监测最直接的下游玩法是双人竞答和协作批改。在展会项目里我还把虚拟光标数据和手势识别结合让两个人同时拖拽物体到不同目标点效果很不错。另外Raw Input不只能监听鼠标也可以注册键盘、手柄和自定义HID设备。把同一套消息钩子逻辑改成监听触摸屏坐标就变成了“多点触摸检测”很多一体机方案可以直接复用。数据层面的设备ID还可以用来做行为分析比如记录每个用户的操作轨迹、答题用时和点击热区。6.2 我用下来最想提醒的三件事第一窗口过程替换一定要在OnDisable里还原否则编辑器停掉后主窗口消息循环可能被野指针影响出现“编辑器按钮点了没反应”的诡异问题。第二Raw Input设备ID在设备重插拔后会变如果你要长期保存某个鼠标的自定义配置建议同时记录usUsagePage和usUsage不要只记hDevice。第三虚拟光标最好单独用一个Canvas并且把raycastTarget关掉否则它自己会挡住场景里其他UI的点击。这个坑我调试了两个小时才意识到光标贴图一直在抢鼠标事件。最后再分享一个小技巧调试时我给每个虚拟光标加了不同的颜色和ID文本这样瞄一眼屏幕就知道是哪只鼠标在动比盯着Device ID十六进制字符串猜高效多了。如果你也在做多鼠标联动项目多考虑“输入分层、渲染分离”这个思路很多看似互相打架的交互逻辑会清晰很多。本文还有配套的精品资源点击获取
返回列表