简介这是一份基于Unity引擎开发桌面精灵的完整工程资源面向希望实现类似QQ宠物透明浮动桌面形象的Unity开发者。资源围绕窗口无边界设置、透明材质渲染、UI布局、Animator动画控制以及鼠标交互事件等关键技术点组织适合有一定Unity基础、想复刻桌面宠物类应用的开发者参考。压缩包共1267个文件约41.45MB包含大量fbx模型、anim动画、mat材质、prefab预制体、cs脚本及tga贴图等Unity核心资源同时附带windows构建产物文件可较快用于本地运行与二次调整。目前已有1593人学习下载。通过该资源可以直观看到透明精灵的完整实现结构包括资源导入组织、动画状态机配置、窗口模式与交互逻辑脚本对理解桌面透明应用在Unity中的落地方式有直接帮助。1. Unity 桌面精灵透明窗口和动画跳动值得自己动手做一次桌面精灵这个需求说简单也简单——一个半透明的卡通角色悬浮在桌面带点待机动画烦躁的时候点一下还能换个表情。但真正动手用 Unity 做一遍你会发现它其实是「窗口透明化、鼠标穿透、动画状态切换、常驻内存表现」四件事的合集。这套东西做好了不只是多一个桌面摆件而是等于把 Unity 从游戏引擎里拉出来干了一件系统桌面工具的活。我最初是在某开源社区看到一个用 Unity 做的桌面宠物 Demo下载下来跑通之后就想自己改一版。结果发现网上现成的教程大都只讲了 UGUI 放图片、挂 Animator真正决定成败的透明窗口和鼠标穿透反而没人讲透。这篇文章就把我从工程搭建到打包成 exe 的全过程拆开讲适合已经会 Unity 基础操作、想做个桌宠或者桌面工具练手的开发者。跑通这个项目你对 Unity 窗口模式、动画驱动和 Win32 API 的认知会直接上一个台阶。2. 透明窗口与桌面精灵先搞懂 Unity 窗口的边界在哪2.1 桌面精灵的「透明」不是 PNG 透明而是窗口透明很多第一次做桌面精灵的人踩的第一个坑是把精灵图片做成透明 PNG放进 Unity 场景里发现背景是深蓝色或者纯色怎么调都不对。原因很简单Unity 编辑器预览时Game 视图默认给透明区域垫了一个固定的背景色那是编辑器行为真正打包出来的窗口默认是不透明的哪怕你的素材是全透明窗口底色依然是黑的。要让窗口本身透明关键不在素材而在 Unity 的 Player Settings。这里塞了两个开关一个是 Run In Background另一个是 Resolution 下的透明参数。具体路径是Edit → Project Settings → Player → Resolution and Presentation把Use DX11 Flip Model Swapchain关掉这一步很多人会漏后面细说然后把Transparent Swapchain打开。这一步做完窗口本身才具备透明通道的能力。但麻烦在于这两个开关不是所有版本都长得一样。我在某个 Unity 2021.3 LTS 版本上找这两个选项找了好一阵后来发现 2021 之后还多了一个Allow Transparency的选项需要一并打开。如果你同时要窗口无边框置顶还需要在 Player Settings 里把Fullscreen Mode设为Windowed然后把窗口尺寸手动设成和精灵素材一致否则透明区域会多出一圈看不见但实际存在的矩形挡住桌面图标。提示Transparent Swapchain 只在 Windows 平台打包时生效编辑器里最好用 Game 视图的透明背景选项模拟但模拟结果和实际 exe 有差异最终以打包后的窗口为准。2.2 窗口穿透是桌面精灵的关键鼠标不挡桌面点击穿透窗口透明了但还有一个更现实的问题一个矩形的透明窗口悬浮在桌面上鼠标点上去时窗口会截获点击事件。这意味着你没法透过精灵去操作桌面上的文件这显然不是一个合格的桌面工具。要让桌面精灵「看得见、摸不着」得靠 Win32 API 做窗口穿透。这里有两个层级全穿透和半穿透。全穿透是把窗口的WindowLong属性里加一个WS_EX_TRANSPARENT意思是整块窗口对鼠标完全透明鼠标点击会直接落到底下的桌面或应用上半穿透则需要你在代码里做区域判断比如精灵身体范围内可点击、透明区域穿透这通常配合热区检测来做。对大多数桌面精灵全穿透 键盘上的切换快捷键就够用因为精灵本身没有 UI 交互需求。最常用的做法是在打包后的 exe 运行那一刻通过 User32.dll 的SetWindowLong拿到当前窗口句柄再调用SetLayeredWindowAttributes设置透明色键。注意这一步必须在OnGUI的早于逻辑里做或者放在协程里等第一帧渲染完成后再调否则窗口句柄还没生成API 找不到目标。代码结构后面第三章会完整贴出来先记住这个顺序先找句柄再设穿透。2.3 置顶与穿透的关系处理还有一个容易忽视的细节置顶和穿透是两套独立的 Win32 属性置顶靠SetWindowPos的HWND_TOPMOST穿透靠WS_EX_TRANSPARENT。你可以在穿透的同时置顶这样精灵永远浮在最上层但鼠标永远不会被它挡住。也可以做成不置顶、只穿透那精灵会被其他窗口盖住。我实际使用的组合是置顶 全穿透。因为桌宠要的就是「一直在眼前晃」如果被其他窗口盖住存在感就没了。而交互需求很少全穿透完全够用。如果要做点击换表情这种交互那就得把穿透做成可切换的监听鼠标事件当光标位置落在精灵图片的不透明区域时临时移除WS_EX_TRANSPARENT让窗口可以接收点击点击完再加回去。这个逻辑用 Unity 的ScreenPointToUV配合RectTransformUtility判断像素 alpha 即可做到篇幅有限不展开但思路要提前想清楚。3. 完整复现从建工程到窗口穿透的 C# 脚本3.1 工程配置我从 URP 换成内置管线的原因第一次做这个项目我无脑建了一个 URP 模板工程。结果发现 URP 的渲染管线对透明窗口的支持要额外处理某些版本的 URP 在打包后透明 Swapchain 会失效窗口底色直接变黑。后来我干脆换回Built-In Render Pipeline内置管线透明支持最稳性能也更可控。具体操作Unity Hub 新建工程时选择3D内置渲染管线模板版本用 2020.3 LTS 或 2021.3 LTS 都行。工程名叫什么无所谓但创建完第一件事是去 Player Settings 做三处改动整理成表方便对照配置项路径设定值Transparent SwapchainPlayer Settings → Resolution and Presentation勾选Use DX11 Flip Model Swapchain同路径下取消勾选Run In Background同路径下勾选防止切窗口时动画暂停注意Use DX11 Flip Model Swapchain这个开关我在某个 2021 版本里找了十分钟没找到后来发现它被折叠在Resolution and Presentation面板的更下面需要展开More Options。如果不开着这个开关透明效果偶尔会闪黑。这块比较玄学网上说法也不一致我的实际经验是关掉后稳定。3.2 精灵素材结构Sprite 摆放和 Pivot 的讲究精灵素材方面我用的是一套开源像素风角色序列帧包含 Idle、Jump、Wave 三组动画。导入 Unity 后需要把 Texture Type 设为Sprite (2D and UI)Mesh Type 用Full Rect这样透明区域不会被裁掉。然后是 Pivot 设置我把精灵的 Pivot 设在底部中心即脚底这样后续做「跳动」动画时精灵是脚不动、身体往上弹而不是中心点上下晃。这里有个实际好处Pivot 设在底部中心后控制精灵在屏幕上方的位置只需设置 RectTransform 的 anchoredPosition 的 y 值就能让精灵的脚贴合一条虚拟地面线跳动动画看起来是在真实的重力环境下做抛物线运动。3.3 C# 脚本窗口穿透 透明色键的完整代码接下来是核心代码。我在 Unity 工程里建了一个DesktopSpriteWindow.cs挂在场景里的 Camera 上。Camera 的 Background 要设为纯色比如纯黑并且把 Alpha 设为 0具体是在Camera 的 Clear Flags 里选 Solid Color然后颜色选 (0, 0, 0, 0)。下面贴完整脚本using System; using System.Runtime.InteropServices; using UnityEngine; public class DesktopSpriteWindow : MonoBehaviour { [DllImport(user32.dll)] private static extern IntPtr GetActiveWindow(); [DllImport(user32.dll)] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); [DllImport(user32.dll)] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); [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 int WS_EX_LAYERED 0x80000; private const int WS_EX_TRANSPARENT 0x20; private const uint SWP_NOSIZE 0x0001; private const uint SWP_NOMOVE 0x0002; private const uint SWP_SHOWWINDOW 0x0040; private static readonly IntPtr HWND_TOPMOST new IntPtr(-1); void Start() { // 等一帧确保窗口句柄已生成 StartCoroutine(ApplyTransparentAfterFrame()); } private System.Collections.IEnumerator ApplyTransparentAfterFrame() { yield return null; // 跳过第一帧 IntPtr hWnd GetActiveWindow(); if (hWnd IntPtr.Zero) { Debug.LogError(无法获取窗口句柄); yield break; } // 先读取旧的扩展样式再叠加穿透和分层属性 int style GetWindowLong(hWnd, GWL_EXSTYLE); style | WS_EX_LAYERED | WS_EX_TRANSPARENT; SetWindowLong(hWnd, GWL_EXSTYLE, style); // 置顶显示 SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW); Debug.Log(桌面精灵窗口已设置为穿透 置顶); } }这段代码做了三件事取当前窗口句柄、在原有扩展样式上追加穿透和分层标志、把窗口置顶。逻辑说明GetActiveWindow在 Unity 打包后的 exe 里拿到的是主窗口句柄这个句柄在 Start 里拿不到必须等第一帧渲染完所以用协程 yield 一帧。如果你发现穿透没生效第一件事检查这个 yield 是否被删了。参数说明GWl_EXSTYLE是扩展样式索引传 -20WS_EX_TRANSPARENT让鼠标点击穿透WS_EX_LAYERED配合 Transparent Swapchain 做颜色键透明。SetWindowPos的 hWndInsertAfter 传 HWND_TOPMOST 时窗口会保持在 Z 序最顶层并且可以配合 SWP_NOACTIVATE 防止抢焦点。如果你发现置顶后每次开机第一次运行窗口会跑到任务栏后面多半是 HWND_TOPMOST 和 SWP_SHOWWINDOW 的组合顺序问题我会在后续避坑章里专门讲。3.4 场景结构Camera、Canvas 和 Sprite 的层级关系场景结构推荐Camera挂脚本 清空背景透明→CanvasScreen Space - Camera渲染模式指定给主摄像机→Sprite挂 Image 组件。我试过两种方案一种是直接用 SpriteRenderer 放精灵图片另一种是 UGUI 的 Image 组件。结论是必须用 UGUI 的 Image因为 Unity 的透明窗口机制只对 UI 渲染通道有效SpriteRenderer 在打包后有时会被剔除或闪黑。具体设置Canvas 的 Render Mode 选Screen Space - CameraRender Camera 拖主摄像机Canvas Scaler 的 Scale Mode 选Scale With Screen Size参考分辨率设成 1920x1080这样在不同分辨率屏幕上精灵大小一致。Image 组件的 Source Image 拖入精灵序列帧的第一帧然后挂 Animator。这里有一个容易翻车的细节Image 的 Raycast Target 一定要取消勾选。因为窗口穿透是 Win32 层面的但 Unity 内部 UGUI 的 EventSystem 还是会拦截射线不取消的话鼠标悬停在精灵上时不会有反应但可能影响 Unity 内部的点击逻辑。取消勾选后UGUI 不会再做任何射线检测。4. 跳动动画Animator 参数驱动与物理感调出来的下落曲线4.1 动画状态机的三态设计桌面精灵的动画跳动不是简单的 y 轴位移做得好的看起来才有「活」的感觉。我的动画状态机有三个状态Idle待机、Jump跳动、Wave挥手中间用Animator的Trigger参数做切换。Idle 态我挂了 8 帧的呼吸动画幅度很小Sprite 的 y 轴在两个像素之间浮动外加一个 scale 的小幅呼吸。这个呼吸动画如果你用 Animator 自带的曲线调要注意曲线默认是线性的必须把曲线改成平滑的Smooth Step否则呼吸动画看起来是一顿一顿的不是柔和的起伏。Wave 态我做了一个 1.5 秒的一次性动画手臂部分单独分层。这里有个经验如果你只有整张 Sprite 序列帧没有分部位挥手很难做。后面第五章会说一个土办法——用同一张序列帧做旋转但效果一般。有条件的话还是建议把身体和手臂拆成两个 Sprite用两个 Image 组件挂在同一个节点下手臂的 Pivot 设在肩膀位置这样挥手只需要旋转手臂节点。Jump 态是重点我做的是 0.6 秒的循环跳动分成「起跳 → 上升 → 滞空 → 下落 → 落地压缩」五段每段用 Animation Curves 控制。最关键的是「下落」段的曲线我用的不是匀速直线而是一个带加速度的曲线如下图思路y 值从最高点开始随时间按抛物线下降落地前速度最快。这个在 Animation 窗口里就是把曲线调成先平缓后陡峭的形状。4.2 动画参数与切换逻辑代码控制而非时间轴切状态把动画挂上之后控制逻辑我用了一个很简单的脚本每隔几秒随机触发一次 Jump 或 Waveusing System.Collections; using UnityEngine; public class SpriteActionController : MonoBehaviour { private Animator animator; private float nextActionTime 3f; // 下一次动作的间隔 private readonly float minInterval 2f; // 间隔范围最小 2 秒 private readonly float maxInterval 6f; // 间隔范围最大 6 秒 void Start() { animator GetComponentAnimator(); if (animator null) { Debug.LogError(Animator 组件未找到); return; } nextActionTime Time.time 1f; // 开局延迟 1 秒再动作 } void Update() { if (Time.time nextActionTime) return; int action Random.Range(0, 3); if (action 0) { animator.SetTrigger(Jump); } else if (action 1) { animator.SetTrigger(Wave); } else { // 50% 概率什么都不做保持 Idle 更自然 animator.SetBool(IsIdle, true); } nextActionTime Time.time Random.Range(minInterval, maxInterval); } }这段代码的逻辑是用一个简单的时间判断来替代协程减少每帧的开销。animator.SetTrigger(Jump)会打断当前的 Idle 动画进入 Jump 状态Jump 播放完毕再通过Exit Time自动转回 Idle。参数说明Random.Range(minInterval, maxInterval)控制动作频率如果你希望精灵更活泼可以把 maxInterval 改到 3 或 4如果希望它安静一点改到 10 以上。这里有一个官方文档不会告诉你的坑Animator 的状态切换如果用的是Any State → Jump的 Transition要注意把Has Exit Time关掉否则动画会在上一次动作播完时才响应新的 Trigger精灵会看起来「卡壳」。我一开始没关 Has Exit Time跳跃动作经常消失了排查了半天发现就是 Transition 没设置对。4.3 用 Animation Clip 生成序列帧动画的最快路径最早我把序列帧一张张拖进 Animation 窗口60 帧素材拖了十分钟速度完全不可接受。后来我改用代码批处理生成 AnimationClip原理是查找到文件夹下所有 Sprite 资源按文件名排序后用一个循环把每张 Sprite 赋值到同一关键帧区间的不同时间点。这里给出批量生成动画的核心代码片段using UnityEditor; using UnityEngine; public static class SpriteAnimationGenerator { [MenuItem(Tools/Generate Idle Animation From Sprites)] public static void GenerateIdleAnimation() { // 假设序列帧放在 Assets/Sprites/Idle 下且为 Sprite 类型 string folderPath Assets/Sprites/Idle; string[] guids AssetDatabase.FindAssets(t:Sprite, new[] { folderPath }); Sprite[] sprites new Sprite[guids.Length]; for (int i 0; i guids.Length; i) { string path AssetDatabase.GUIDToAssetPath(guids[i]); sprites[i] AssetDatabase.LoadAssetAtPathSprite(path); } // 按文件名排序保证帧顺序正确 System.Array.Sort(sprites, (a, b) string.Compare(a.name, b.name)); AnimationClip clip new AnimationClip(); clip.frameRate 12; // 12 帧每秒 EditorCurveBinding binding new EditorCurveBinding { path Sprite, // 目标物体名 type typeof(UnityEngine.UI.Image), propertyName m_Sprite }; // 每秒一帧的关键帧分配 ObjectReferenceKeyframe[] keyframes new ObjectReferenceKeyframe[sprites.Length]; for (int i 0; i sprites.Length; i) { keyframes[i] new ObjectReferenceKeyframe { time i / 12f, value sprites[i] }; } AnimationUtility.SetObjectReferenceCurve(clip, binding, keyframes); // 保存为资产 AssetDatabase.CreateAsset(clip, Assets/Animations/Idle.anim); AssetDatabase.SaveAssets(); Debug.Log(生成成功: clip.name); } }逻辑说明EditorCurveBinding的 path 指的是目标 Image 组件的相对路径必须和场景中的物体名一致否则动画曲线找不到目标组件。ObjectReferenceKeyframe是 Sprite 交换动画的标准写法它把每个关键帧的 time 设为 1/12 秒的整数倍这样 12 帧每秒就是每帧 0.083 秒。参数说明frameRate设为 12 不是随便拍脑袋。桌面精灵的序列帧如果是像素风12 帧已经够流畅再高会显得动作过快。如果你素材是 30 帧的现代卡通风改成 24 或 30 帧 / 秒更合适。生成完 clip 后手动建 Animator Controller 把 Idle.anim 拖进去再加一个空状态做 Jump用 Trigger 连接两个状态即可。5. 避坑与常见问题透明失效、穿不过、动画卡住三处最容易翻车5.1 打包后窗口底色黑色不透明现象编辑器里 Game 视图看透明背景是对的但打包成 exe 后整个窗口是一块黑色矩形精灵像是嵌在黑色纸片里。原因多半是Player Settings里的Transparent Swapchain没有勾选或者勾了但被Use DX11 Flip Model Swapchain覆盖了。这两个开关是有依赖关系的后者开启时会强制走一张不透明的交换链。解决检查Edit → Project Settings → Player → Resolution and Presentation把Use DX11 Flip Model Swapchain勾掉将Transparent Swapchain勾上。然后重新打包。如果还是黑的检查 Camera 的 Clear Flags 是否真的是 Solid Color且颜色 Alpha 是否为 0。我发现很多模板工程的 Camera 会默认用 Skybox透明窗口直接失效。5.2 窗口穿透没有生效鼠标依然被挡住现象窗口是透明的了但鼠标在精灵区域点下去桌面文件还是选不中就像有一层隐形的玻璃罩。原因Win32 的WS_EX_TRANSPARENT没有成功加上。常见的情况是GetActiveWindow()返回的是 Unity 内部的一个子窗口句柄而不是主窗口句柄或者代码在 Start 后立即执行主窗口还没完成创建。解决我最后改用GetForegroundWindow()或者遍历进程的主窗口句柄而不是 GetActiveWindow。稳妥的做法是写一个循环向上查找父窗口直到找到顶层的那个。代码里我建议改成GetForegroundWindow()试试因为 Unity 打包后的主窗口通常是前台窗口。另外把修改窗口样式的时机放到协程等两帧之后不要 yield null 就调 API。5.3 Animator 切换动画时状态卡住尤其 Wave 播完不回到 Idle现象精灵播放完 Wave 后停在一个奇怪的姿势或者跳跃动作做到一半就没有了状态机显示状态已经不是 Jump但画面停在半空。原因Transition 设置问题。Wave → Idle 的 Transition 里Has Exit Time默认为开启这种情况下如果 Wave 动画本身没有设置 Loop Time播放完会停在最后一帧而不是自动退出。Jump → Idle 则是因为 Trigger 在跳跃结束后没有被清除下一轮 Trigger 到达时状态机以为重复触发。解决要么把所有一次性动画的 Loop Time 勾上副作用是动画会循环播不推荐要么在每个 Transition 上把Has Exit Time打开并且 Exit Time 设为 0.8即动画播放到 80% 就开始过渡回 Idle。对于 Trigger 残留的问题在SpriteActionController里每次触发新动作前调用animator.ResetTrigger(Jump)和animator.ResetTrigger(Wave)清一遍代价是可以忽略的收益是很明显不会再卡。5.4 置顶失效窗口被其他最大化窗口盖住现象精灵窗口偶尔置顶成功但在打开某些全屏程序比如视频播放器或之后精灵会消失需要重新运行才回到最上面。原因设置了HWND_TOPMOST后如果其他窗口也调用了 SetWindowPos 或者拥有更高的 Z 序置顶会被覆盖。加上部分播放器使用独占全屏模式会强制把其他窗口压到 Z 序底端。解决这不是达到 100% 的办法但能显著改善在Project Settings → Player → Resolution and Presentation里把Fullscreen Mode设成Windowed并取消勾选Allow Fullscreen选项。另外在代码里把SetWindowPos的 hWndInsertAfter 改成HWND_TOPMOST同时加上SWP_NOACTIVATE标志这样置顶不会抢夺其他窗口的键盘焦点配合播放器全屏时的冲突会小很多。6. 高级用法开机自启与托盘控制把桌宠变成一个常驻桌面系统桌面精灵做到这里已经能跑但要真正当桌宠用还得解决两件反人性的事开机后手动打开 exe 很麻烦想关闭它时窗口没有托盘图标只能到任务管理器里结束进程。这一章我把自己封装的托盘控制逻辑和开机自启注册方式放出来是最后一步落地。托盘控制我用了System.Windows.Forms和NotifyIcon但 Unity 打包的 exe 默认没有 WinForms 的引用需要在工程里加一个Assembly Definition或者用反射方式调用。简单做法是新建一个脚本放在Assets/Plugins目录并在代码里添加如下引用#if UNITY_STANDALONE_WIN using System.Windows.Forms; using System.Drawing; #endif然后写一个TrayIconManager.cs提供两个方法显示托盘图标和退出程序。托盘图标的菜单项最少要两个「显示/隐藏精灵」和「退出」。显示隐藏其实就是调用Application.runInBackground !Application.runInBackground但这里有个坑隐藏窗口应该用 Win32 的 ShowWindow 而不是 Unity 的 SetActive(false)因为窗口隐藏后再显示之前的透明和穿透属性会失效需要重新调用一遍 SetWindowLong 和 SetWindowPos。我最初用 SetActive(false)结果再显示时鼠标穿透没了折腾半天才发现是窗口句柄属性被重置了。退出功能用Application.Quit()即可但要在退出前把窗口指针释放干净不然偶尔会留下一个残影图标在任务栏要等几秒才消失。我在 OnApplicationQuit 里加了 ShowWindow(hWnd, 0) 强制隐藏窗口再调用 Quit顺序反过来会导致窗口闪现。开机自启我用注册表方式标准写法是写HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run值是指向 exe 完整路径。代码要小心处理 exe 路径含空格的情况注册表值必须用引号包住路径。另外要提供一个开关允许用户取消开机自启否则时间长了所有人都会讨厌这个精灵。放一个从网络检索里归纳出来的 Win32 关键 API 对照表方便你排查时快速定位功能API参数注意窗口透明SetLayeredWindowAttributes需先设置 WS_EX_LAYERED鼠标穿透SetWindowLong WS_EX_TRANSPARENT叠加到现有样式而非覆盖置顶SetWindowPos HWND_TOPMOST加上 SWP_NOACTIVATE 防抢焦点隐藏/显示ShowWindow SW_HIDE / SW_SHOW可在 OnApplicationQuit 中先隐藏再退出开机自启注册表 Run 键路径必须加引号从那以后我每次做类似的桌面工具都会把「运行时修改窗口属性」的能力单独封装到一个公共类里不直接散落在各个功能脚本中。这次的经历让我养成了一个习惯凡是涉及窗口句柄的调用先等两帧再调 API最后用 Debug.Log 验证句柄非零——这套流程走一遍基本不会再在这上面浪费时间。桌面精灵的工程结构不复杂但透明、穿透、动画这三个点单独拆出来每样都够折腾一阵子照着上面的步骤做你大概率两天内就能看到自己的精灵在桌面上活蹦乱跳希望帮到你。本文还有配套的精品资源点击获取