Unity打造Windows桌面悬浮应用:透明窗口与鼠标穿透交互实战

📅 2026/7/12 8:02:07
Unity打造Windows桌面悬浮应用:透明窗口与鼠标穿透交互实战
1. 项目概述为什么桌面悬浮应用值得投入做Unity开发这么多年从手游到VR项目都折腾过但真正让我觉得“有意思”且能直接提升桌面工作效率的还得是透明窗口应用。你可能见过一些桌面悬浮的时钟、系统监控小部件或者像某些音乐播放器那样歌词能半透明地“贴”在屏幕最上层不影响你干别的。这种应用的核心就是创建一个没有传统边框、背景透明、且能始终置顶显示的窗口。用Unity来做这件事听起来有点“杀鸡用牛刀”但实际体验下来你会发现它的优势非常明显一套代码既能利用Unity强大的UI系统UGUI和动画能力做出酷炫的视觉效果又能通过C#脚本与Windows系统底层API深度交互实现原生应用级别的窗口控制。这比用WinForm或者WPF从头搭建一个带复杂动画的UI要高效得多。这个教程的目标就是带你从零开始打通Unity工程到Windows桌面可执行程序.exe的完整链路实现一个功能完备的桌面悬浮应用。我们会深入窗口透明化的原理、解决鼠标穿透的交互难题、处理窗口置顶与拖拽并分享实际开发中那些文档里不会写的“坑”和优化技巧。无论你是想做一个个性化的桌面助手、一个游戏内的实时数据悬浮窗还是仅仅对Unity的跨平台潜力感到好奇这篇指南都能给你提供可直接复现的实战方案。2. 核心原理与系统层交互拆解在开始写代码之前我们必须搞清楚Unity的普通窗口和我们要的透明悬浮窗口有什么区别以及系统层面需要做什么。2.1 透明窗口的本质不只是把Alpha调成0很多人的第一反应是把Unity Camera的背景设为透明或者把Canvas的渲染模式改成覆盖不就行了吗这确实能让画面变透明但窗口本身那个方框还在。Windows操作系统管理窗口时每个窗口都有一个“窗口样式”Window Style其中包含边框、标题栏、背景等属性。一个标准的Unity打包出的窗口默认带有这些装饰。透明悬浮窗口的第一步就是要去掉这些装饰并告诉系统“我这个窗口的背景是透明的请直接让我下面的桌面内容透过来。”这需要通过调用Windows的User32.dll中的API来实现。核心是SetWindowLong函数它可以修改窗口的扩展样式Extended Style。我们需要添加两个关键样式WS_EX_LAYERED启用分层窗口。这是实现透明和半透明效果的基础。WS_EX_TRANSPARENT这个样式很关键它让窗口对鼠标点击“透明”。也就是说鼠标点击会直接穿透你的窗口落到它下面的窗口或桌面上。这对于一个不想干扰正常操作的悬浮应用来说是必须的否则你的窗口会挡住后面的按钮。但是注意WS_EX_TRANSPARENT是一把双刃剑。它实现了鼠标穿透但也意味着你的窗口自身也无法接收鼠标消息了。如果你希望窗口上的某个按钮能被点击就需要更精细的控制我们会在后续的交互章节详细解决这个矛盾。2.2 Unity与原生平台的通信桥梁Unity虽然强大但直接调用系统API需要借助平台特定的方式。对于WindowsStandalone平台我们使用[DllImport(“user32.dll”)]来引入User32.dll中的函数。这是C#调用非托管代码的标准做法。我们需要封装几个核心函数FindWindow: 根据类名和窗口名找到我们的Unity窗口句柄HWND。句柄是操作系统识别窗口的唯一标识。SetWindowLong: 修改窗口样式。SetLayeredWindowAttributes: 设置分层窗口的属性包括透明度颜色键Color Key和整体Alpha值。我们可以指定一种颜色比如纯黑RGB(0,0,0)作为透明色窗口中所有这个颜色的像素都会变成完全透明。SetWindowPos: 控制窗口的位置、大小和Z序即谁在前谁在后用它来实现“始终置顶”。实操心得在Unity编辑器中这些API调用是无效的因为编辑器本身不是一个独立的Windows窗口。所有相关代码都必须放在#if UNITY_STANDALONE_WIN || UNITY_EDITOR这样的编译指令中并且只在打包后的运行时执行。测试时频繁地打包EXE会很麻烦。我的做法是在编辑器模式下用一个模拟的UI界面来调试业务逻辑而窗口样式相关的代码则通过一个开关来控制是否生效正式打包时才开启。3. 项目搭建与核心脚本实现理论清楚了我们开始动手。这里我会给出最精简且健壮的实现步骤。3.1 创建Unity项目与基础设置新建项目使用Unity Hub创建一个新的3D项目选择3D模板即可我们主要用UI。导入UI资源在Package Manager中确保已导入Unity UI。设置渲染这是关键一步。进入Edit - Project Settings - Player在Resolution and Presentation下取消勾选Fullscreen ModeWindow Mode选择Windowed。这确保我们以窗口化启动。重要在Splash Image部分如果你不需要Unity Logo开场建议禁用以减少启动时的干扰。场景设置删除默认的Main Camera。创建一个UI Canvas。将Render Mode设置为Screen Space - Camera然后新建一个Camera将其拖入Render Camera槽。将这个新Camera的Clear Flags设置为Solid Color并且将Background的Alpha值设为0完全透明。在Canvas下创建你的UI元素比如一个Image作为背景面板一个Text显示信息。将你希望完全透明的UI部分的颜色Alpha也设为0。例如如果你希望整个窗口只有文字是可见的那么背景面板的颜色就应该是(0,0,0,0)。3.2 编写窗口管理核心脚本创建一个C#脚本命名为TransparentWindowManager.cs。以下是其核心内容using System; using System.Runtime.InteropServices; using UnityEngine; public class TransparentWindowManager : MonoBehaviour { // 引入Windows API [DllImport(user32.dll)] private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); [DllImport(user32.dll)] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, uint dwNewLong); [DllImport(user32.dll)] private static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags); [DllImport(user32.dll)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); // 窗口样式常量 private const int GWL_EXSTYLE -20; private const uint WS_EX_LAYERED 0x00080000; private const uint WS_EX_TRANSPARENT 0x00000020; private const uint LWA_COLORKEY 0x00000001; private const uint LWA_ALPHA 0x00000002; // 窗口位置常量 private static readonly IntPtr HWND_TOPMOST new IntPtr(-1); private const uint SWP_NOSIZE 0x0001; private const uint SWP_NOMOVE 0x0002; private const uint SWP_SHOWWINDOW 0x0040; // 定义透明色键例如纯黑 public Color transparentColor Color.black; // 是否启用鼠标穿透 public bool enableClickThrough true; // 是否窗口置顶 public bool alwaysOnTop true; private IntPtr _windowHandle; void Start() { #if !UNITY_EDITOR // 仅在打包后执行 InitializeTransparentWindow(); #endif // 编辑器下可以做一些模拟逻辑 } void InitializeTransparentWindow() { // 1. 获取当前窗口句柄 _windowHandle FindWindow(null, Application.productName); // 窗口标题默认为产品名 if (_windowHandle IntPtr.Zero) { Debug.LogError(无法找到窗口句柄); return; } // 2. 获取当前扩展样式并添加新样式 uint extendedStyle (uint)SetWindowLong(_windowHandle, GWL_EXSTYLE, 0); // 先获取 uint newStyle extendedStyle | WS_EX_LAYERED; if (enableClickThrough) { newStyle | WS_EX_TRANSPARENT; // 添加鼠标穿透样式 } SetWindowLong(_windowHandle, GWL_EXSTYLE, newStyle); // 3. 设置透明色 uint colorKey ((uint)transparentColor.b 16) | ((uint)transparentColor.g 8) | (uint)transparentColor.r; SetLayeredWindowAttributes(_windowHandle, colorKey, 0, LWA_COLORKEY); // 4. 设置窗口置顶 if (alwaysOnTop) { SetWindowPos(_windowHandle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW); } Debug.Log(透明窗口初始化完成。); } // 提供一个方法可以在运行时动态切换鼠标穿透例如点击某个按钮后让窗口可交互 public void SetClickThrough(bool isClickThrough) { #if !UNITY_EDITOR if (_windowHandle IntPtr.Zero) return; uint extendedStyle (uint)SetWindowLong(_windowHandle, GWL_EXSTYLE, 0); uint newStyle extendedStyle; if (isClickThrough) { newStyle | WS_EX_TRANSPARENT; } else { newStyle ~WS_EX_TRANSPARENT; // 移除该样式 } SetWindowLong(_windowHandle, GWL_EXSTYLE, newStyle); enableClickThrough isClickThrough; #endif } }将这个脚本挂载到场景中一个不会被销毁的GameObject上比如一个空的Manager对象。关键参数解析transparentColor在Inspector中设置为纯黑(0,0,0,0)。这意味着你场景中所有渲染为纯黑色的像素都将完全透明。确保你的UI背景或任何你希望透明的地方使用这个颜色。enableClickThrough启动时是否立即让鼠标穿透。根据你的应用需求决定。alwaysOnTop是否置顶。4. 交互难题的攻克动态鼠标穿透与窗口拖拽上面我们已经实现了基础透明和全局鼠标穿透。但一个实用的悬浮窗需要能与之交互。比如我们需要一个区域可以拖拽来移动窗口或者有些按钮需要点击。4.1 矛盾与解决方案矛盾在于WS_EX_TRANSPARENT让整个窗口对鼠标事件“失效”。Unity的EventSystem根本收不到任何鼠标输入。解决方案是动态地切换窗口样式。思路如下默认状态下窗口启用WS_EX_TRANSPARENT鼠标穿透。当鼠标移动到我们指定的“可交互区域”比如一个拖拽条或按钮时临时移除WS_EX_TRANSPARENT样式。这样Unity就能接收到这个区域的鼠标事件了。当鼠标移出可交互区域或者交互结束后重新添加WS_EX_TRANSPARENT样式恢复鼠标穿透。4.2 实现可拖拽的标题栏我们来实现一个最常见的功能通过拖拽一个UI条来移动整个窗口。在Canvas上创建一个Image作为拖拽条DragBar为其添加Event Trigger组件。创建一个脚本WindowDragger.csusing UnityEngine; using UnityEngine.EventSystems; using System.Runtime.InteropServices; public class WindowDragger : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [DllImport(user32.dll)] private static extern bool ReleaseCapture(); [DllImport(user32.dll)] private static extern bool SendMessage(IntPtr hWnd, int msg, int wParam, int lParam); private const int WM_NCLBUTTONDOWN 0xA1; private const int HTCAPTION 0x2; private IntPtr _windowHandle; private TransparentWindowManager _windowManager; private bool _isDragging false; void Start() { _windowManager FindObjectOfTypeTransparentWindowManager(); // 通常WindowManager会持有句柄这里假设我们能获取到 // 实际项目中可能需要通过Manager传递句柄 } public void OnPointerDown(PointerEventData eventData) { if (_windowManager null) return; // 开始拖拽时临时禁用鼠标穿透否则拖拽操作会被穿透 _windowManager.SetClickThrough(false); _isDragging true; // 捕获鼠标并向Windows发送拖拽标题栏的消息 ReleaseCapture(); SendMessage(_windowManager.GetWindowHandle(), WM_NCLBUTTONDOWN, HTCAPTION, 0); } public void OnDrag(PointerEventData eventData) { // 拖拽逻辑主要由Windows API消息处理这里可以空着或更新一些UI状态 } public void OnPointerUp(PointerEventData eventData) { EndDrag(); } void OnDisable() { if (_isDragging) { EndDrag(); } } void EndDrag() { if (_windowManager null) return; _isDragging false; // 拖拽结束恢复鼠标穿透假设没有其他交互区域需要保持非穿透状态 // 这里可以加一个延迟避免鼠标刚松开瞬间就穿透导致误操作 Invoke(nameof(RestoreClickThrough), 0.1f); } void RestoreClickThrough() { if (!_isDragging _windowManager ! null) { _windowManager.SetClickThrough(true); } } }同时我们需要在TransparentWindowManager脚本中增加一个公共方法GetWindowHandle()来返回_windowHandle。这个方案的巧妙之处它没有通过计算鼠标偏移来手动设置窗口位置而是直接发送了Windows系统的标准消息WM_NCLBUTTONDOWN并附带HTCAPTION参数模拟了用户点击标题栏的行为。这样窗口的拖拽就交给了系统原生管理包括拖拽时的半透明预览效果体验非常完美。4.3 处理按钮点击与其他交互对于按钮思路类似。我们可以为按钮添加自定义脚本或在Event Trigger的PointerEnter和PointerExit事件中调用SetClickThrough方法。更优雅的做法是创建一个InteractiveArea管理器所有需要交互的UI元素按钮、滑块、拖拽条都注册到这个管理器。当鼠标进入任何一个交互区域时管理器通知WindowManager禁用全局鼠标穿透。当鼠标离开所有交互区域时延迟一小段时间例如0.5秒后再启用鼠标穿透。这个延迟可以避免鼠标在多个小按钮间移动时频繁切换样式导致的闪烁或不跟手。注意事项频繁调用SetWindowLong修改窗口样式在理论上可能带来微小的性能开销或闪烁但在实际桌面应用中这个频率非常低完全在可接受范围内。关键是延迟恢复穿透的逻辑要设计好以平衡交互响应和穿透需求。5. 打包、部署与性能优化让应用在用户桌面上稳定、优雅地运行最后这一步至关重要。5.1 打包设置与注意事项目标平台在File - Build Settings中选择PC, Mac Linux StandaloneTarget Platform 选择Windows。分辨率在Player Settings的Resolution and Presentation中设置一个合适的默认窗口大小例如400x300。取消Fullscreen Mode勾选。图标与产品名设置好应用图标和产品名Product Name这个产品名就是窗口的默认标题也是FindWindow函数查找的依据。图形API对于简单的2D UI应用使用默认的Direct3D 11即可。如果遇到兼容性问题可以尝试勾选Auto Graphics API让Unity选择。构建点击Build生成.exe文件。同时会生成一个同名的_Data文件夹和一些其他文件这些需要和.exe放在一起才能运行。一个巨大的坑Unity 2021及以后的一些版本在打包独立应用时默认会启用“全屏优化”和“DPI感知”。这可能导致透明窗口在缩放比例不是100%的屏幕上位置错乱、模糊或透明失效。解决方案是在Player Settings的Resolution and Presentation最下方找到Disable Fullscreen Optimizations并勾选它。同时可以考虑在应用启动后通过APISetProcessDPIAware()来声明DPI感知但这对多显示器环境处理更复杂需要谨慎测试。5.2 性能优化要点桌面悬浮应用通常是长期驻留的性能优化很重要。帧率限制一个时钟或监控悬浮窗完全不需要60FPS。在代码中使用Application.targetFrameRate 30;甚至15来大幅降低CPU和GPU占用。对于静态内容多的窗口可以尝试-1与屏幕刷新率同步或一个较低的值。UI优化合批Batching确保UI元素的材质和纹理尽可能少促进Unity进行动态合批。避免频繁改变UI元素的材质属性。隐藏与显示如果窗口有折叠/展开功能不显示的部分最好直接SetActive(false)而不是仅仅调整Alpha。避免昂贵的操作不要在Update中频繁进行Find、GetComponent或实例化/销毁操作。内存管理如果是监控类应用定期更新数据注意字符串拼接产生的GC垃圾回收压力。使用StringBuilder或对象池来优化。5.3 让应用更像一个“原生”应用开机自启可以通过在Windows注册表HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run下添加键值来实现。注意需要管理员权限且要处理好应用路径。系统托盘图标这是桌面应用的标配。Unity原生不支持需要再次借助Windows APIShell_NotifyIcon或使用第三方插件如Hardcodet.NotifyIcon.Wpf的.NET封装通过进程间通信与Unity交互。实现起来稍复杂但能极大提升体验。单实例运行使用Mutex互斥锁确保同一时间只运行一个应用实例。6. 常见问题排查与实战心得这里记录了我踩过的一些坑和解决方案希望能帮你节省大量时间。6.1 透明色失效窗口背景变黑问题描述打包后运行窗口背景不是透明桌面而是纯黑色。排查步骤检查Camera背景确认场景中渲染UI的Camera其Background颜色的Alpha是否为0。检查UI层级确认你的UI Canvas下没有全屏的、带有不透明颜色或图片的RawImage/Image挡在最下面。最底层的UI元素颜色应为(0,0,0,0)。检查透明色键确认脚本中transparentColor设置的颜色如纯黑与你希望透明的UI区域颜色完全一致。RGB值必须精确匹配。有时UI材质或Shader的细微差别可能导致颜色值有偏差。检查渲染顺序确保没有其他Camera如不小心留下的旧Main Camera在渲染不透明背景。终极方案在SetLayeredWindowAttributes调用后尝试再调用一次并尝试将颜色键设置为0x00000000纯黑并确认LWA_COLORKEY标志已设置。6.2 鼠标穿透不生效或交互区域失灵问题描述鼠标无法穿透点击后方窗口或者可交互区域点击无反应。排查步骤确认样式已添加在InitializeTransparentWindow方法中打印出设置前后的扩展样式值确认WS_EX_TRANSPARENT已被成功添加。检查窗口句柄确认FindWindow获取到的句柄不是IntPtr.Zero。窗口标题Application.productName是否与打包设置一致动态切换逻辑对于交互区域确保在鼠标进入时成功移除了WS_EX_TRANSPARENT样式。使用GetWindowLong获取当前样式验证。注意移除样式后窗口可能需要重绘或刷新一下才能响应鼠标但通常发送消息如拖拽会触发。EventSystem确保场景中有EventSystemGameObject。没有它UI交互根本不会工作。图形射线投射检查交互UI元素的Raycast Target是否勾选。对于希望穿透的区域不要勾选。6.3 窗口位置异常或拖拽卡顿问题描述窗口不在预期位置或者拖拽时很卡。解决方案使用SetWindowPos在初始化时精确定位窗口。坐标是屏幕坐标。拖拽卡顿很可能是因为在OnDrag中写了低效的逻辑或者频繁调用SetWindowPos。我们之前实现的方案利用了系统原生拖拽通常很流畅。如果自定义拖拽确保计算逻辑简单且不要在每帧调用SetWindowPos可以每几帧调用一次或者只在鼠标移动距离超过一定阈值时调用。6.4 在编辑器里一切正常打包后出问题这是最典型的情况。牢记所有涉及Windows API的代码都必须用#if !UNITY_EDITOR包裹起来或者通过一个运行时开关控制。在编辑器里FindWindow找到的不是你的游戏视图窗口API调用会失败或产生未定义行为。我的习惯是在编辑器模式下用一个布尔变量simulateInEditor来控制是否执行这些代码并提供一个模拟的UI效果来预览。6.5 与其他Unity功能/插件的兼容性UGUI兼容性最好是本方案的首选。UI Toolkit (UIE)UI Toolkit是新一代UI系统但它在独立应用窗口的渲染层级和事件处理上与传统的GameObjectCanvas方式有差异。实现透明窗口和鼠标穿透的底层原理相同但UI Toolkit的PanelSettings等需要额外配置且事件系统不同需要更多测试。后处理效果如果窗口内有使用后处理Post Processing需要特别注意透明背景下的渲染结果。某些效果可能需要调整。第三方UI插件如DoTween、LeanGUI等只要它们基于标准的UGUI或Canvas Renderer工作一般不会有问题。但涉及屏幕坐标转换时要注意因为我们的窗口可能不在“标准”的Game视图里。开发这类应用本质上是在Unity的“游戏循环”和Windows的“窗口消息循环”之间架起一座桥。理解两边的基本规则耐心测试你就能打造出既拥有Unity强大表现力又具备原生应用体验的桌面悬浮工具。