简介:面向希望复刻QQ宠物式桌面互动精灵的Unity开发者,这套完整工程包演示了从窗口模式设置、透明材质渲染、UI搭建到动画控制与用户交互的整套实现路径。压缩包共1267个文件,除大量bin编译资源外,还包含fbx模型、anim动画、mat材质、prefab预制体、cs脚本等关键工程文件,以及controller动画控制器和dll动态库,包体41.45MB,既能作为入门学习案例,也能作为二次开发的可运行模板。目前已有1593人学习浏览。通过工程代码与场景文件,读者可直接理解无边框透明窗口的开启方式、精灵跳动动画的状态机配置、基于Alpha通道的透明Shader设置,以及结合Windows API实现拖拽、置顶等桌面交互细节;描述中提到的跨平台注意事项和事件响应逻辑也均有对应的工程实现,便于逐项对照、动手验证和扩展自己的桌面精灵功能。
1. 桌面精灵的底层形态:Unity 透明悬浮窗不是改个背景色就行
很多人第一次听说“Unity 实现桌面精灵,动画跳动”时,会觉得这不就是把一个小窗口拖到桌面角落,再把背景调成透明就完事。实际做出来你会发现,Unity 默认会给你一整块不透明窗体,哪怕相机背景设成纯黑,画面也压住桌面图标。真正的桌面精灵要同时满足三个条件:相机渲染透明背景、窗口去掉边框与标题栏、透明区域不拦截鼠标点击。三件事只要有一件没对齐,桌面上就会出现一块矩形色斑或一块永远点不透的玻璃。下面把透明窗体和跳动动画的关键步骤拆开,参数和坑都写在对应位置,新手可以照着搭,熟手也能直接拿走透明窗体脚本。
2. 透明窗体不是改背景色:Win32 样式参数与相机设置步骤
窗口透明这件事的本质是:窗口本身依然是矩形,只是这个矩形内有一部分像素的 alpha 为 0,系统合成桌面时把它“挖空”。也就是说,桌面精灵的透明不是让窗口变成任意形状,而是让矩形区域内的背景像素消失,并且鼠标点击要能穿透到桌面。
2.1 为什么必须走相机透明而不是截图抠边
我最初尝试过“截图抠边”的方案:先抓取精灵四周的像素,再把同色区域设为透明。听起来能绕开 Win32 API,但实际跑起来有颜色偏移和闪烁问题,每换一张皮肤就要重调阈值。Unity 的正确做法是直接用相机渲染 alpha 为 0 的背景,包一层 Win32 的扩展样式告诉系统“这个窗体的背景允许透明”。这样精灵皮肤想换就换,不需要为每张图做二次抠色。
还有一个容易误解的点:透明窗体虽然视觉上是不规则形状,但它的鼠标命中区域仍然是整个矩形。如果不做任何处理,精灵周边一大块看不见的区域都会挡住桌面图标点击。所以点击穿透不是可选项,而是桌面精灵的必选项。
2.2 相机与 Player 参数:Alpha 归零、无边框、后台运行
在 Unity 工程里新建一个相机专门渲染精灵这一层:
- Camera Clear Flags 从 Skybox 改成 Solid Color;
- Background 颜色设置成 RGBA(0, 0, 0, 0),重点是把 Alpha 拉到 0;
- 精灵的 Sorting Layer 单独建一层,避免和 UI 混在一起;
- 项目里不需要默认灯光,桌面精灵一般只用 Sprite 自发光,最多加一个无阴影的 Directional Light。
接着在 Project Settings 的 Player 页面里调整窗口类型。我一般习惯先关掉 Fullscreen Mode,再把 Resizable Window 关掉,最后勾选 Run In Background。这样打包后窗口不会抢占焦点,也不会因为切出桌面就暂停渲染。
这些设置做完,编辑器里还是看不到透明效果。编辑器预览窗口会把 alpha 0 的区域渲染成棋盘格,看起来像花屏,这是正常现象,真正透明要等打包运行后才体现。
2.3 挂上 Win32 样式脚本:关键两个 Flag
把下面的脚本挂在相机或者其他启动时就在场景里的对象上,它负责把系统窗口设置成透明可穿透状态:
using System; using System.Runtime.InteropServices; using UnityEngine; public class WindowTransparent : MonoBehaviour { [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 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 = 0x00080000; private const int WS_EX_TRANSPARENT = 0x00000020; private const int WS_EX_TOOLWINDOW = 0x00000080; private const int WS_EX_NOACTIVATE = 0x08000000; private const uint SWP_NOMOVE = 0x0002; private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOZORDER = 0x0004; private const uint SWP_FRAMECHANGED = 0x0020; void Start() { // 打包后 Unity 主窗口就是当前激活窗口,编辑器下一样能取到句柄 IntPtr hwnd = GetActiveWindow(); int exStyle = GetWindowLong(hwnd, GWL_EXSTYLE); exStyle |= WS_EX_LAYERED | WS_EX_TRANSPARENT; exStyle |= WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE; SetWindowLong(hwnd, GWL_EXSTYLE, exStyle); SetWindowPos(hwnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); } }这段代码先取出窗口当前的扩展样式,然后通过位或运算追加四个 flag。WS_EX_LAYERED 告诉系统这个窗体支持逐像素透明,WS_EX_TRANSPARENT 是关键中的关键,它让鼠标点击直接穿透到下层窗口。WS_EX_TOOLWINDOW 让窗口不出现在任务栏,WS_EX_NOACTIVATE 防止点击精灵时抢走当前输入焦点。
最后一行 SetWindowPos 不是可选操作。只改扩展样式不刷新窗口,经常会出现“明明设置了透明却没有任何变化”的情况。这里带上 SWP_FRAMECHANGED 就是强制系统重新计算窗口边框和样式,改完立刻生效。
如果之后想临时取消穿透,保留 WS_EX_LAYERED,把 WS_EX_TRANSPARENT 从 exStyle 上移除再调用一次 SetWindowLong 即可。这个逻辑后面做悬停交互时会用到。
2.4 几种透明方案的取舍
有人会问为什么不用 RenderTexture 把精灵渲染到一张带 alpha 的贴图上,再把这张贴图挂到 UI 上做全屏透明层。这种方案在窗口内实现相对可控,但做穿透时更复杂,而且和 Unity UI 的 EventSystem 抢射线,多了一层不必要的黑匣子。还有色键抠图方案要求背景色单一,抗锯齿边缘会留一圈脏边。
我的选择是相机 Solid Color 加纯透明 alpha,理由就一条:它是 Unity 原生支持的路径,代码最少,后续换 Sprite、换序列帧动画都不用改底层。
3. 跳动动画的驱动方式:动画曲线、状态机与拖拽打断
动画跳动是这个项目的视觉核心,但实现方式不需要太复杂。很多桌面精灵用 Spine 骨骼动画,效果确实好,可文件体积和接入成本都会上去。我更推荐先用序列帧加程序化位移,做一个能自己控制振幅和频率的跳动循环。
3.1 序列帧还是曲线补间
序列帧适合角色本身有复杂动作,比如眨眼、挥手的逐帧动画。把一张精灵表拆成若干帧,用 Animator 的 Sprite 切换播放。缺点是一套动作要一堆图片,改幅度得重新出图。
另一种是曲线补间,适合上下跳动、左右摇晃这类机械感强的动作。只控制位置 Y 轴,用一个 AnimationCurve 定义“起跳-上升-回落”的位移曲线。这种方式参数全在 Inspector 里,改振幅和频率不需要换图。我的建议是两种结合:精灵的眨眼、表情用序列帧,上下跳动用曲线补间。这样项目既能出效果,又能省图片资源。
3.2 BounceController:一分钟搭出自循环跳动
直接写一个跳动控制器,挂在精灵根节点上:
using System.Collections; using UnityEngine; public class BounceController : MonoBehaviour { [Header("跳动参数")] public float bounceHeight = 1.2f; // 跳起来的最大高度,单位统一用世界单位 public float bounceDuration = 0.35f; // 单次跳动用时,越小越弹 public float restTime = 1.2f; // 两次跳动之间的休息时间 public AnimationCurve bounceCurve = AnimationCurve.EaseOutBack(); private Vector3 originalPos; private float restTimer; private bool bouncing; void Start() { originalPos = transform.localPosition; restTimer = restTime; } void Update() { if (bouncing) return; restTimer -= Time.deltaTime; if (restTimer <= 0f) { StartCoroutine(PlayBounce()); } } IEnumerator PlayBounce() { bouncing = true; float t = 0f; while (t < bounceDuration) { t += Time.deltaTime; float normalized = t / bounceDuration; float height = bounceHeight * bounceCurve.Evaluate(normalized); Vector3 pos = originalPos; pos.y += height; transform.localPosition = pos; yield return null; } transform.localPosition = originalPos; bouncing = false; restTimer = restTime; } }这段逻辑不复杂:空闲时倒数 restTimer,到时间就进入跳跃协程。协程里把 0 到 1 的归一化时间塞进 AnimationCurve,再把输出的高度叠加到原始 Y 坐标上。AnimationCurve.EaseOutBack 自带一点回弹,落到底部时会轻微越过原点再收回来,看起来就像真的有重力拉扯过。bounceHeight 建议先用世界单位调,比如 1 到 2 之间;如果精灵 Sprite 导入时 Pixels Per Unit 是 100,一个 512px 的精灵在世界里约 5.12 单位,1.2 的高度已经是很明显的一跳。restTime 不要设为零,否则精灵会像弹簧一样来回抖,看着累。
3.3 拖拽打断与动画状态衔接
跳动动画和拖拽打架是最常见的不满来源。精灵正在跳,鼠标一抓,它还在原地蹦,拖到哪跳哪。解决思路是加一个拖拽优先级状态:拖拽时暂停 BounceController,拖完再恢复。我不喜欢用 Animator 的复杂状态机做这件事,因为一个桌面精灵的状态切换逻辑很少,直接在脚本里用一个 bool 就能表达。
using UnityEngine; public class DragController : MonoBehaviour { private BounceController bounce; private Camera cam; private Vector3 offset; private float zDistance; private bool dragging; void Start() { bounce = GetComponent<BounceController>(); cam = Camera.main; } void OnMouseDown() { dragging = true; bounce.enabled = false; // 打断跳动 zDistance = cam.WorldToScreenPoint(transform.position).z; offset = transform.position - GetMouseWorldPosition(); } void OnMouseDrag() { transform.position = GetMouseWorldPosition() + offset; } void OnMouseUp() { dragging = false; bounce.enabled = true; // 恢复跳动 } Vector3 GetMouseWorldPosition() { Vector3 screenPoint = Input.mousePosition; screenPoint.z = zDistance; return cam.ScreenToWorldPoint(screenPoint); } }这里的关键是 zDistance 必须先固定。桌面精灵的相机我习惯用正交投影,精灵所在平面是 z=0,鼠标从屏幕坐标转世界坐标时如果不给深度,转出来永远是相机位置的那个平面,拖拽时精灵会突然贴到相机上去。把 zDistance 缓存下来,每次转换都用同一个深度,拖拽就不会乱跑。还有一点,如果后面做了“点击穿透”,OnMouseDown 根本收不到消息,需要配合第 5 章的悬停检测一起用。
4. 桌面精灵避坑记录:透明失败、穿透失效与动画卡顿的 5 个修复
这一步直接复习我踩过的坑。桌面精灵看起来是个小项目,实际上透明窗体、输入穿透、动画状态三套逻辑叠在一起,问题只多不少。
4.1 现象:打包运行后背景是一整块黑色矩形
原因是相机的 Clear Flags 没有真正改成 Solid Color,或者 Background 的 Alpha 还是 255。我在调试时经常遇到改完 Inspector 又被预设覆盖的情况。
解决:打开相机组件,把 Clear Flags 下拉框选到 Solid Color,点开 Background 颜色面板把 A 通道手动改成 0。改完注意看场景里的 Game 视图,如果显示棋盘格说明 alpha 生效了。另外不要开 HDR,HDR 合成阶段会把透明区域重新填成不透明。
4.2 现象:窗口透明了,但鼠标点不到桌面图标
明明设置了 WS_EX_TRANSPARENT,桌面图标还是点不了。原因是透明窗体本质上还是矩形窗口,鼠标会先命中窗口的“透明区域”,这个命中事件被 Unity 接住但没有显式返回,导致桌面收不到点击。
解决:除了 WS_EX_TRANSPARENT,还需要确认脚本调用过 SetWindowPos。只 SetWindowLong 不刷新时,旧样式可能还在生效。检查顺序是:先确认窗口句柄是否真的拿到了,再确认 SetWindowPos 没被编辑器报错,最后看桌面上是否能点击。这里有个小技巧:在 Start 里先输出一下 GetWindowLong 回来的值,如果 exStyle 里没有 0x00000020,说明窗口还没应用穿透。
4.3 现象:精灵边缘有半透明白边或脏色
透明区域本身没问题,但精灵皮肤边缘有一圈像水渍的边。这通常是图片压缩格式导致的,Sprite 的 Compression 选了默认的 Compressed,压缩算法会破坏透明边缘的 alpha 渐变。
解决:把精灵纹理的 Texture Type 设成 Sprite,Compression 改成 None,格式选 RGBA32。如果项目有图集,导入设置里也要同步关掉 Crunch Compression。这个坑在打包后比编辑器里明显得多,因为编辑器默认不压纹理。
4.4 现象:跳动动画掉帧,一顿一顿不流畅
跳动用的是 Time.deltaTime,逻辑上没有错,但窗口还是像幻灯片。帧率不稳是元凶。桌面精灵这种悬浮小工具不需要 60fps,但不锁帧会导致系统忙时帧率高、闲时反而低,视觉上就卡。
解决:在启动时设置 Application.targetFrameRate = 30,垂直同步保持关闭。30 帧对一组小尺寸 Sprite 足够流畅,CPU 占用也低很多。如果精灵带序列帧动画,每张图之间要保证帧间隔一致,不要用 Animator 的默认 Clip 让动画和 Update 抢节奏。
4.5 现象:拖拽时精灵突然飞到窗口外,找不回来
这个是 ScreenToWorldPoint 的 z 值没固定造成的。相机是透视投影时,鼠标的 z 每帧变化,精灵会在纵深方向乱跑,看起来就像瞬移出屏幕。
解决:拖拽开始前缓存 zDistance,后续每一帧都复用这个深度值。如果用的是正交相机,检查精灵的 z 坐标是否归零,并在转换时把 Input.mousePosition 的 z 固定到相机到精灵的平面距离。
5. 让精灵可交互且开机自启:悬停切换、双击拖拽与注册表项
透明窗体刷通,动画能跳了,下一步才是真正让桌面精灵“活”起来:鼠标移到精灵上时能触发交互,移开后又恢复穿透;双击暂停或锁定位置;开机自动启动。这些功能不复杂,但有个矛盾要先解开。
5.1 点击穿透与悬停交互怎样共存
如果窗口一直保持 WS_EX_TRANSPARENT,Unity 就永远收不到鼠标事件,OnMouseDown 全是摆设。如果去掉穿透,精灵周围大片透明区域又会挡住桌面。我用的方案是轮询鼠标位置:平时保持穿透状态,每隔 0.2 秒检测一次鼠标的世界坐标是否落在精灵的 Collider2D 范围内,落在范围内就临时关闭穿透,移出后立即恢复。
using UnityEngine; public class HoverDetector : MonoBehaviour { public float checkInterval = 0.2f; private Collider2D col; private Camera cam; void Start() { col = GetComponent<Collider2D>(); cam = Camera.main; InvokeRepeating(nameof(CheckHover), 0.1f, checkInterval); } void CheckHover() { if (col == null) return; // 把鼠标屏幕坐标转到精灵所在平面 Vector3 mousePos = Input.mousePosition; mousePos.z = Mathf.Abs(cam.transform.position.z); Vector3 worldMouse = cam.ScreenToWorldPoint(mousePos); bool hovering = col.OverlapPoint(worldMouse); // 悬停时关闭穿透,离开时恢复穿透 WindowToggler.SetClickThrough(!hovering); } }这个方案的优点是鼠标事件只在悬停时生效,桌面其他区域永远点得穿。缺点是判定不是像素级,如果精灵身上有镂空部位,会在镂空区域临时拦截鼠标。所以 Collider2D 最好用几个小 Box 拼出主要身体区域,不要为了省事套一个大包围盒,否则精灵身边会多出一块“不可穿透”的隐形区域。
5.2 双击切换锁定与右键菜单
桌面精灵常见的交互还有一个双击锁定位置。锁定后精灵不再响应拖拽,防止误碰把角色拖到角落。双击和拖拽的冲突在于点击计时,我一般用单击时间差区分:按下时记录时间,如果在 0.2 秒内再次按下,就切换锁定状态,否则进入拖拽。右键菜单可以用一个简单的 Unity UI 面板实现,面板平时隐藏,右键时移动到鼠标坐标再激活。
5.3 开机自启:注册表 Run 项
开机自启我优先用注册表 Run 项,比放快捷方式到启动目录少一层文件管理,遇到杀软误报的概率也低一些。脚本要放在 Windows 平台专属代码块里,避免在编辑器里误调注册表。
#if UNITY_STANDALONE_WIN using UnityEngine; using Microsoft.Win32; public class AutoStart : MonoBehaviour { private const string AppName = "DesktopSpirit"; public void EnableAutoStart() { string exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName; RegistryKey key = Registry.CurrentUser.OpenSubKey( @"Software\Microsoft\Windows\CurrentVersion\Run", true); key?.SetValue(AppName, $"\"{exePath}\""); } public void DisableAutoStart() { RegistryKey key = Registry.CurrentUser.OpenSubKey( @"Software\Microsoft\Windows\CurrentVersion\Run", true); key?.DeleteValue(AppName, false); } } #endif写入 CurrentUser 的 Run 键不需要管理员权限,也不会影响其他登录用户,对桌面精灵这种小工具最合适。AppName 不要用中文,部分系统对值名称编码处理不一致,统一用英文字符串更稳。如果之后做了快捷方式自启,记得卸载时反注册,注册表方式只要调用 DisableAutoStart 就能清干净。
5.4 多显示器边界
把精灵拖到副屏边缘时,窗口位置可能跑到屏幕外,出现一半在桌面里一半在桌面外的情况。我一般不做严格的多屏适配,简单做法是拖拽结束时读一次窗口位置,如果超出 Screen.width 或进入负坐标区域,就把精灵位置拉回主屏中心。够用,也避免引入原生虚拟屏幕坐标的复杂度。
6. 验证与性能:帧率、GC 和打包后透明表现
动画、穿透、自启都做完了,别急着发版本。桌面精灵是长期挂机的小工具,性能浪费会一直消耗 CPU 和内存,最需要验证的是透明效果和资源占用。
先做穿透自检:把精灵拖到一个桌面图标正上方,此时鼠标悬停在精灵身体上,图标应该点不动;鼠标移到精灵旁边的透明区域,图标应该能正常选中。如果透明区域也要点两下才有反应,基本是 WS_EX_TRANSPARENT 没有随悬停切换恢复。
再做性能检查,重点看 Profiler 的 CPU 耗时和 GC 分配量。两个脚本最容易拖后腿:HoverDetector 每 0.2 秒调用一次 ScreenToWorldPoint,这个接口会产生少量临时对象,频率不高可接受;如果改成 Update 逐帧调用,GC 就会明显上涨。把不需要物理反馈的动画对象上的 Rigidbody2D 删掉,默认自带的重力模拟是给物理游戏用的,桌面精灵用不到。
打包前我还会在构建配置里关掉不必要的包,把纹理 Mipmap 关掉。透明窗体不需要 mipmap,保留会多占一倍内存。帧率方面,Application.targetFrameRate = 30 已经设好,检查一下垂直同步是不是被 Player 设置强行打开,两个同时开启时 targetFrameRate 会失效。
这套桌面精灵做下来,我最深的教训是:透明窗体不是一道开关而是一条链路,相机 alpha、GetWindowLong、SetWindowPos、穿透切换环环相扣,哪一个状态没同步都会出诡异表现。从那以后我每次发版本前都强制跑一遍穿透自检流程,拖到图标上、拖到桌面空白处、双击锁定各来一次,确认无误才收工。这套流程能帮你省掉不少发布后的反馈沟通,希望帮到你。
本文还有配套的精品资源,点击获取