做桌面挂件、桌面时钟、动态壁纸这类需求时,很多人第一反应是用WPF、Electron或者Qt去写,但真到要精细控制3D渲染、粒子特效、骨骼动画的时候,这些方案一个比一个难受。绕一圈之后你会发现,用Unity来做“透明软件背景、显示桌面内容”的窗口,反而是最顺手的路子。这篇文章我会把从“Unity窗口为什么默认不透明”到“真正实现透出桌面背景”的完整链路讲清楚,包括相机设置、Win32窗口扩展样式、点击穿透、以及我实测踩过的各种坑。
1. 透明窗口这件事,Unity默认为什么做不到
1.1 游戏引擎的“不透明白底”从哪来
先说个很多人容易忽略的基础点:Unity本质是个游戏引擎,它往屏幕上输出的画面,在操作系统眼里就是“一张矩形纹理”。这张纹理在绘制前会被清屏成某个颜色——默认是深蓝色或天空盒。问题就在于,游戏引擎设计时压根不关心“窗口背后的桌面”,它只关心“把渲染目标填满”,所以每个像素在最开始就被写成了不透明的底色。
你试着把相机背景色改成Alpha为0的颜色,在Windows上打包后依然会看到黑底或者白底,原因是SwapChain在DWM(桌面窗口管理器)合成阶段仍然被当作不透明矩形处理。窗口本身是矩形,系统不认“某个像素是否透明”。这一点必须想明白:透明窗口不是“把一块矩形变成透明”,而是“让窗口整体携带透明通道,并告诉合成器如何把它和桌面混合”。
大多数桌面程序都是不透明窗口,所以Windows默认把所有窗口当成不透明矩形来合成。想让Unity画面真正融进桌面,光改Unity内部分层没用,必须往上走到OS的窗口层去解决问题。
1.2 两条技术路线的取舍
我调研过两种主流做法,这里直接给结论。
| 对比项 | 颜色键抠图方案 | 像素级Alpha方案 |
|---|---|---|
| 实现难度 | 低,稳定性好 | 较高,依赖系统版本 |
| 边缘质量 | 锯齿明显,禁止半透明材质 | 平滑,支持真半透明 |
| 核心思路 | 把背景色设为纯色,再用窗口颜色键抠掉 | 让DWM识别窗口的逐像素Alpha |
| 适用场景 | 卡通桌宠、简单挂件 | 特效类、阴影、柔边UI |
颜色键方案是社区流传最广的做法:Unity相机的背景设成品红色(Magenta),窗口层设置WS_EX_LAYERED,然后把品红色声明为透明色键。系统在合成时会把这个颜色变成全透明,透出桌面。优点是无脑稳定,缺点是只要场景里出现接近品红的颜色,或者有半透明物体,边缘就会发紫、发虚。
像素级Alpha方案则走得更深,借助SetWindowCompositionAttribute把窗口的Accent更新为“透明渐变”状态,让DWM真正参与逐像素混合。效果干净,但Win10/11不同版本行为有差异,需要做成动态开关。下文我会把两套代码都给出。
2. 开始前的工程检查:相机与渲染管线的透明化准备
2.1 渲染管线:内置管线和URP的透明区别
拿到一个新项目,先别急着写Win32代码。不同渲染管线在“透明背景”上的行为差别很大。
内置渲染管线里,把相机Clear Flags从“Skybox”改成“Solid Color”,并把背景色设为RGBA(0,0,0,0),Game视图在编辑器里立刻能看到棋盘格透明背景。URP则要走Camera组件的Background Type属性,改成“Solid Color”,然后把Color的Alpha拖到0。如果用的是HDRP,那透明背景基本是找罪受,后处理、雾效、多级相机耦合都会干扰Alpha通道,个人建议桌面挂件类项目直接用内置管线或者URP。
有一个细节新手特别容易踩:URP里开启HDR时,透明背景在某些显卡驱动上会变“白底”,因为HDR颜色缓冲的Alpha位被后处理占用了。如果发现打包后背景不透明,先去Project Settings里把URP Asset的HDR关掉,再把后处理Volume里所有Bloom、Tonemapping全部禁用。
2.2 Player设置与图形API的选择
工程层面有几项必须提前检查,否则后面透明代码写了也白写。
首先,Fullscreen Mode必须选Windowed,不要勾Resizable Window。透明窗口一旦允许用户拖拽改变大小,DWM合成区域的尺寸同步容易出问题,桌面背景会出现一整片黑边。
其次,Run In Background一定要勾上。透明窗口经常不是焦点窗口,不勾这个,Unity在失焦后会暂停渲染,桌面挂件直接变成一张静帧。
图形API方面,Windows下建议用Direct3D 11。我见过用DX12加透明窗口的案例,在某些Win11版本上会让窗口完全白屏,官方论坛至今没给明确结论。OpenGL Core也能跑,但和SetLayeredWindowAttributes的配合在AMD驱动上有过兼容性问题。保守选择就是D3D11 + IL2CPP,老实用。
3. 核心解法:全透明窗口与Win32扩展样式的组合代码
3.1 最稳的路子:品红背景 + 颜色键抠图
这是我最推荐先跑通的一套,逻辑简单,出问题好排查。整个方案就三步:相机背景设成品红、窗口加上分层扩展样式、把品红声明成透明色键。
相机背景品红色的取值要避开场景里所有其他元素。我习惯用RGB(255,0,255)也就是纯Magenta,代码里这样设置:
Camera cam = Camera.main; cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(0.5f, 0f, 1f, 1f);注意这里用的是Gamma空间中的纯色。如果你项目开了Linear颜色空间,品红的实际渲染值会偏移,透明键匹配不上。要么把Color Space改回Gamma,要么用下面这段代码拿到线性空间下的等效值:
cam.backgroundColor = new Color(0.5f, 0f, 1f, 1f).linear;然后挂一个脚本到相机上,在Start()里调用Win32接口:
using System; using System.Runtime.InteropServices; using UnityEngine; public class TransparentWindow : MonoBehaviour { [DllImport("user32.dll")] private static extern IntPtr GetActiveWindow(); [DllImport("user32.dll")] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); [DllImport("user32.dll")] private static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags); private const int GWL_EXSTYLE = -20; private const int WS_EX_LAYERED = 0x00080000; private const uint LWA_COLORKEY = 0x1; private const uint ColorKey = 0x00FF00FF; // COLORREF格式,BBGGRR void Start() { #if UNITY_STANDALONE_WIN IntPtr hwnd = GetActiveWindow(); int style = GetWindowLong(hwnd, GWL_EXSTYLE); style |= WS_EX_LAYERED; SetWindowLong(hwnd, GWL_EXSTYLE, style); SetLayeredWindowAttributes(hwnd, ColorKey, 0, LWA_COLORKEY); #endif } }COLORREF值是0x00BBGGRR结构,所以品红在代码里写0x00FF00FF。很多教程直接写成0xFFFF00FF,这是ARGB的写法,直接套用会导致颜色键匹配失败,窗口露底。就这一行,能卡住不少人。
3.2 进阶:保留像素级Alpha的Accent方案
颜色键方案通用性强,但如果你做的是带柔影、比粒子还要通透的桌面宠物,那边缘一圈紫边就完全没法看。这种情况下需要走DWM的Accent路线。
思路是让窗口带透明背景状态,让Unity渲染出来的Alpha通道参与DWM最终混合。核心调用的系统API是SetWindowCompositionAttribute,这个接口没有官方文档,参数结构都是逆向出来的,但Win10以来一直可用。
using System; using System.Runtime.InteropServices; using UnityEngine; public class TransparentWindowAccent : MonoBehaviour { [DllImport("user32.dll")] private static extern IntPtr GetActiveWindow(); [DllImport("user32.dll")] private static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); [StructLayout(LayoutKind.Sequential)] private struct AccentPolicy { public int AccentState; public int AccentFlags; public int GradientColor; public int AnimationId; } [StructLayout(LayoutKind.Sequential)] private struct WindowCompositionAttributeData { public int Attribute; public IntPtr Data; public int SizeOfData; } private const int WCA_ACCENT_POLICY = 19; private const int ACCENT_ENABLE_TRANSPARENTGRADIENT = 2; void Start() { #if UNITY_STANDALONE_WIN IntPtr hwnd = GetActiveWindow(); AccentPolicy accent = new AccentPolicy(); accent.AccentState = ACCENT_ENABLE_TRANSPARENTGRADIENT; accent.GradientColor = 0x00000000; // ARGB 全透明 WindowCompositionAttributeData data = new WindowCompositionAttributeData(); data.Attribute = WCA_ACCENT_POLICY; data.SizeOfData = Marshal.SizeOf(accent); data.Data = Marshal.AllocHGlobal(data.SizeOfData); Marshal.StructureToPtr(accent, data.Data, false); SetWindowCompositionAttribute(hwnd, ref data); Marshal.FreeHGlobal(data.Data); #endif } }这套方案比颜色键干净得多,因为相机背景Alpha设为0的地方能真正透出桌面,模型自身阴影和反光也保留原本的半透明混合。代价是ACCENT_ENABLE_TRANSPARENTGRADIENT在部分Win11版本会被系统忽略,表现成窗口变全黑。所以我的建议是:默认用颜色键方案,把Accent方案做成可切换的选项,在用户机器上动态检测,哪个有效用哪个。
4. 点击穿透与可交互状态切换:桌面挂件的必备交互逻辑
4.1 让鼠标穿透窗口直达桌面
透明窗口做了之后,你立刻会撞上另一个问题:窗口矩形区域还在,鼠标挪到透明像素上依然是“吸在Unity窗口上”,点不到桌面图标。要做成真正的桌面挂件,得设置WS_EX_TRANSPARENT扩展样式,这个样式的语义是把鼠标事件全部让给下层窗口。
private const int WS_EX_TRANSPARENT = 0x00000020; public void SetClickthrough(bool enabled) { #if UNITY_STANDALONE_WIN IntPtr hwnd = GetActiveWindow(); int style = GetWindowLong(hwnd, GWL_EXSTYLE); if (enabled) style |= WS_EX_TRANSPARENT; else style &= ~WS_EX_TRANSPARENT; SetWindowLong(hwnd, GWL_EXSTYLE, style); #endif }把SetClickthrough(true)调用放在启动脚本里,挂件主体就能彻底“隐形”于鼠标。但这里有个陷阱:GetActiveWindow()拿到的窗口句柄,在窗口失焦后返回值可能变成IntPtr.Zero。更稳妥的做法是用FindWindow(null, Application.productName),通过Unity窗口的标题文本找句柄。
[DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); IntPtr hwnd = FindWindow(null, Application.productName);注意,如果你在Player Settings里改过产品名,或者在代码里SetTitle,这里要对应改成实际窗口标题,否则会拿不到句柄。
4.2 常用交互模式:常态穿透、点击时临时交互
纯穿透的挂件又没法点按钮了,所以真实项目里一般做“鼠标悬停时临时恢复交互”。方法是和操作系统窗口区域关联,而不是在所有透明区域上做像素检测。
一个实用方案:给挂件做一个不透明的“交互底盘”。在Unity场景里放一个Quad,把MeshCollider挂上,平时它也是可见的或不渲染的;鼠标事件通过Unity的OnMouseEnter取不到系统级鼠标位置。这时候要借助ScreenPointToRay和Physics.Raycast,配合GetCursorPos拿系统光标坐标。
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } void Update() { #if UNITY_STANDALONE_WIN POINT p; GetCursorPos(out p); Vector2 screenPos = new Vector2(p.X, p.Y); // 将系统屏幕坐标换算成Unity屏幕坐标,需要减去窗口位置 // 窗口位置用 GetWindowRect 获取 Ray ray = Camera.main.ScreenPointToRay(screenPos); if (Physics.Raycast(ray, out RaycastHit hit, 100f)) { SetClickthrough(false); } else { SetClickthrough(true); } #endif }实测下来,频率不要每帧都调SetWindowLong,DWM会疲于切换导致窗口闪烁。我在项目里加了0.2秒的冷却时间,只有状态变化时才真正调用API。这个技巧很小,但对稳定性影响非常大。
QQ/微信托盘的“气泡”和“设置面板”,也是走这个逻辑:主体穿透,设置面板所在区域可以点击。你完全可以把“设置面板”做成一个带背景的UI Canvas,鼠标进入UI区域时关闭穿透,离开后恢复。
5. 踩坑实录:从秒退崩溃到透明后残影的排查细节
5.1 Unity编辑器里透明,打包后黑底白底
这是最高频的问题。常见原因是Player Settings里Color Space选了Linear,但透明色键没有做线性转换。我遇到过一位同行,项目里Linear+URP,相机背景品红在编辑器透明得干干净净,打包出来就是紫黑一块,排查了两天才锁定是线性颜色偏移导致的。
解决方案有两个:要么把Color Space改成Gamma,代价是光照效果差异大;要么保持Linear,设置背景色时调用.linear转换属性,让颜色键匹配上实际输出值。改完之后透明就正常了。
5.2 透明窗口区域的残影,以及切换场景后的“鬼影”
挂件运行一段时间后,透明区域偶尔会留下前几帧的画面残影,移动窗口时尤其明显。这个问题的根子在于DWM合成缓存没有及时更新,和Unity渲染没半毛钱关系。
我的处理方式是,在窗口位置移动完成后强制触发一次重绘:调用SetWindowPos并带上SWP_FRAMECHANGED标志,或者简单地在移动结束时往窗口发一个WM_PAINT消息。
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); // 强制重绘 SetWindowPos(hwnd, IntPtr.Zero, 0, 0, 0, 0, 0x0001 /*SWP_NOSIZE*/ | 0x0002 /*SWP_NOMOVE*/ | 0x0020 /*SWP_FRAMECHANGED*/);实测下来,残影在Win10上偶发,Win11稍微好一些。如果你使用Accent方案,还可以在移动时临时切回不透明,移动完再切回透明,虽然会闪一下,但能彻底避开合成器Bug。
5.3 透明背景上出现花花绿绿的噪点
使用颜色键方案时,场景中任何接近品红的像素都会被抠掉,包括模型贴图里的暗红、紫红,甚至UI的抗锯齿边缘。表现就是角色身上全是噪点一样的透明洞。
解决办法是临时的“分层隔离”:挂件主体材质统一偏冷色,把品红留在背景层。场景中如果有红色粒子,我一般把粒子颜色饱和度降到安全区以下,或者在相机渲染的OnRenderImage里把靠近品红的颜色做偏移。最后这个方案不适合生产环境,只是调试用。
比较好的做法是启用URP Renderer Feature做后处理:检测输出图像里接近品红的像素,用一个阈值替换成邻域色。这样既保留颜色键方案,又避免误抠内容。
5.4 点击穿透之后,再也点不回设置界面
如果你一直开启WS_EX_TRANSPARENT,设置按钮永远收不到鼠标事件。很多人会陷入“鼠标在按钮上,但按钮没反应”的幻觉,实际是事件被透传到底层桌面了。
调试技巧是加一个特殊的全局快捷键来切换穿透状态,比如Ctrl+Shift+T。这样无论窗口处于什么状态,你都能用键盘救回来。快捷键用RegisterHotKey注册,比轮询按键状态稳定得多,尤其是在窗口失焦时。
5.5 多显示器DPI缩放导致的坐标错位
做桌面挂件必然有人会在多显示器环境用,DPI缩放一开,GetCursorPos拿到的坐标和Unity屏幕坐标就对不上了。Win32 API返回的是物理像素坐标,而Unity在窗口模式下工作的逻辑坐标会被系统缩放。
目前最省心的做法是,在Player Settings里把Supported Aspect Ratio和分辨率设置好,并在代码里通过Screen.currentResolution拿到Unity实际使用的分辨率,再和GetWindowRect做比例换算。不要自己硬算DPI,Windows 10的每显示器DPI行为太复杂,容易翻车。
5.6 透明窗口+硬件加速导致的窗口闪黑
部分老显卡驱动对WS_EX_LAYERED和硬件加速DX11表面兼容不佳,透明窗口偶尔会闪黑。这种问题通常是驱动层面,不是代码问题。我能给的经验是:把Unity的Graphics Jobs关闭,或者强制Single Threaded Rendering,能减少一部分闪黑触发概率。
如果还不行,就降级到OpenGL Core渲染模式重试。AMD核显的历史驱动对SetLayeredWindowAttributes兼容最差,我遇到过一个案例,同样的代码在独显N卡上完全正常,在核显Intel上窗口拖拽时黑块。最终用户更新驱动解决了,没有改一行代码。
6. 一个可运行的入口工程:场景结构、UI与打包参数参考
6.1 最小场景的编排方式
透明窗口项目最适合做“单一场景常驻”架构。我的标准做法是:场景根节点下挂一个相机、一个Canvas、一个常用特效容器。
相机设置参考:
| 参数 | 建议值 | 原因 |
|---|---|---|
| Clear Flags | Solid Color | 不需要天空盒 |
| Background | RGBA(0,0,0,0)或品红 | 取决于透明方案 |
| Projection | Orthographic | 桌面挂件大多不需要透视 |
| Allow HDR | 关闭 | 防止Alpha通道被破坏 |
| MSAA | 2x | 边缘柔和但兼顾显卡开销 |
Canvas我建议使用Screen Space - Camera模式,并把Plane Distance拉远一点,避免UI和3D物体穿插。注意Canvas本身不要覆盖全屏超大背景,透明窗口的透明部分是会被点击穿透的,如果Canvas里放了一个不可见的Button覆盖全屏,点击穿透就会被破坏。
6.2 透明窗口下的UI文字和图片处理
UI里的图片如果有Alpha边缘,在颜色键方案下会显得发灰,因为品红背景和半透明边缘混合后,系统按颜色键一抠,边缘颜色被错误地处理成半透明白。解决办法是给UI图片关闭半透明渐变,尽量用纯色边缘。
TextMeshPro的文字也容易出问题,尤其在打开抗锯齿时。我在实践里会把TMP的字体Atlas生成参数里Padding调大一点,让字形边缘不依赖帧缓冲混合。如果你发现文字边缘有一圈很淡的“马赛克”,多半就是字体图集在低分辨率下边缘信息丢失了。
6.3 打包发布时容易被忽略的选项
发布Windows独立版时,有几个选项直接影响透明效果:
Fullscreen Mode一定要选Windowed,全屏模式无视一切透明窗口设置。- 不要勾选
Resizable Window,可拖拽改变大小会破坏固定窗口的合成区域。 Run In Background必须开启。Color Space建议保持Gamma,除非你确认自己搞定了线性颜色键。Compress Textures对透明没有任何影响,但如果模型贴图压缩成DXT5,半透明边缘精度反而更好。这个组合可以根据实际项目微调。- 打包后需要确认窗口的Width和Height是偶数。DWM合成器对奇数宽高的窗口存在已知Bug,偶发黑边,我一度以为是代码问题,后来发现分辨率设成偶数就稳定了。
6.4 把本项目扩展成更复杂的挂件
基础透明窗口跑通后,可以往上叠的功能很多。最实用的是“窗口拖拽”:桌面挂件如果没有无边框区域的拖拽能力,用户在桌面上一动不了就非常难用。
方法是注册WM_NCHITTEST消息拦截,把透明区域识别成标题栏区域。这块需要写一个消息钩子或在自己的消息循环里处理,Unity官方没有暴露窗口过程,所以我更推荐变通方案:在Unity场景里放一个“拖拽把手”区域,鼠标落在该区域时关闭穿透,调用ReleaseCapture加SendMessage(WM_NCLBUTTONDOWN, HTCAPTION)模拟标题栏拖动。
[DllImport("user32.dll")] private static extern bool ReleaseCapture(); [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); private const int WM_NCLBUTTONDOWN = 0x00A1; private const int HTCAPTION = 0x0002; public void BeginDrag() { #if UNITY_STANDALONE_WIN IntPtr hwnd = FindWindow(null, Application.productName); ReleaseCapture(); SendMessage(hwnd, WM_NCLBUTTONDOWN, new IntPtr(HTCAPTION), IntPtr.Zero); #endif }这段代码配合点击穿透事件,可以让用户按住挂件任意位置拖拽,松开后依然保持透明穿透。
最后再多说一句,这类型项目永远不要只在一台机器上验证。透明窗口和DWM深度绑定,不同Windows版本、不同显卡驱动、不同DPI设置行为都可能不一样。我自己最后交付的方案,都是把“颜色键方案”和“Accent透明方案”做成运行时自动切换,启动时先尝试Accent,失败就回退到颜色键,这样兼容性覆盖面最大。至于哪个方案是正统,其实已经不重要,能稳定跑在用户桌面上才是硬道理。