我把一个只支持 Mac 的开源 3D 动态壁纸移植到了 Windows
Oct 15, 2026 · @Someone
起因:一个只能在 Mac 上用的好项目
Deskworlds 是 Chase Lean 开源的动态壁纸项目,MIT 许可。它在桌面图标后面放了六个会动的 3D 小世界:河床、珊瑚礁、斗鱼、等离子球、锦鲤池和篝火。
它不是循环播放的视频。每条鱼都由一套行为模拟驱动:会成群游动、给鼠标让路、被快速靠近时受惊逃开,喂食时还会过来抢。整个项目用 Three.js 和 WebGL2 写成,场景本身就是网页。
可惜它只支持 Mac。负责把网页放到桌面上的那层外壳是用 Swift 和 WebKit 写的,Windows 用户只能在浏览器里看看。
所以我给它写了一个 Windows 版。最终效果是:
- 下载安装包双击安装,不需要管理员权限,也不需要装任何运行环境。
- 画面在桌面图标后面,图标照常显示、照常点击。
- 鱼会躲开鼠标;双击桌面空白处可以喂食,双击图标照常打开。
- 桌面被窗口挡住、锁屏或息屏时,自动降帧或停止渲染。
- 可以把鱼换成你喜欢的图片,也可以用自己的图片、视频或网页做壁纸。
原项目的场景代码几乎没改,只加了几个很小的挂接点。下面按开发顺序,讲讲遇到的几个核心问题。
问题一:怎么把窗口放到桌面图标后面
答案是把自己的窗口变成资源管理器桌面窗口的子窗口,挂在图标层下面。普通的置底窗口做不到,因为桌面本身就在最底层,再往下就看不见了。
Windows 的桌面由一个叫 Progman 的窗口负责,图标在其中的 SHELLDLL_DefView 里。向 Progman 发送一个未公开的消息 0x052C,资源管理器会在图标后面创建一个 WorkerW 窗口,原本是给换壁纸时的淡入动画用的。把我们的窗口挂到它下面,就正好夹在壁纸和图标之间。
麻烦在于 Windows 11 24H2 改了这套结构:
text
Windows 10 和 24H2 之前的 Windows 11
├─ WorkerW ← 图标被移到这里
│ └─ SHELLDLL_DefView
├─ WorkerW ← 图标后面那层,挂在这里
└─ Progman
Windows 11 24H2 及以后
└─ Progman
├─ SHELLDLL_DefView ← 图标留在 Progman 里
└─ WorkerW ← 图标后面那层,变成了 Progman 的子窗口
所以代码要两种都认:先在 Progman 里找同时存在的 SHELLDLL_DefView 和 WorkerW;找不到,再枚举顶层窗口,找到装着图标的那个 WorkerW,取它后面的兄弟窗口。
这里有个容易踩的细节:只认装着图标的 WorkerW。如果把 Progman 也当成候选,在 24H2 上可能挂到一个看不见的窗口上,画面就消失了。
Win32 调用用的是 koffi,一个带预编译二进制的 Node FFI 库,安装时不需要 Visual Studio:
js
const user32 = koffi.load('user32.dll');
const FindWindowExW = user32.func(
'uintptr_t __stdcall FindWindowExW(uintptr_t parent, uintptr_t after, const char16_t* cls, const char16_t* title)');
const progman = FindWindowW('Progman', null);
// lParam = 1 是新版系统期望的请求方式;在 24H2 上 lParam = 0 会让资源管理器很快把这层收回去
SendMessageTimeoutW(progman, 0x052C, 0xD, 0x1, 0, 1000, result);
const icons = FindWindowExW(progman, 0, 'SHELLDLL_DefView', null);
const inner = FindWindowExW(progman, 0, 'WorkerW', null);
const host = icons && inner ? inner : findClassicWorkerW(); // 两种结构都处理
SetParent(myWindow, host);
最后一个坑是坐标。子窗口的位置要用父窗口的客户区坐标,而 WorkerW 覆盖整个虚拟屏幕,多显示器时原点可能是负数。我用 ScreenToClient 把每块屏幕的物理像素坐标换算过去,每块屏幕各放一个窗口,各跑一个世界。
问题二:收不到点击,鱼怎么知道鼠标在哪
办法是不等事件,主动去读。壁纸在图标层下面,所有鼠标事件都被图标层拿走了,网页里的 pointermove 永远不会触发。
主进程用定时器读取全局光标位置,换算成那块屏幕里的坐标,再通过 preload 脚本,在场景的画布上派发一个合成的 pointermove 事件:
js
// 主进程:最多每秒 30 次,且不超过场景当前的帧率
const point = screen.getCursorScreenPoint();
wallpaper.webContents.send('deskworlds:pointer', point.x - bounds.x, point.y - bounds.y);
// preload:DOM 事件可以从隔离环境派发给页面自己的监听器
ipcRenderer.on('deskworlds:pointer', (_event, x, y) => {
document.querySelector('#scene')
?.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y, bubbles: true }));
});
场景代码完全不用改,它以为自己收到的是真实的鼠标事件。原项目的 Mac 版也是这个思路,这正好让 Windows 版能复用全部场景。
喂食更有意思。我想让"双击桌面空白处"等于喂食,但双击图标得照常打开文件。难点是:壁纸进程怎么知道用户双击的是空白处还是图标?
我的做法分三步:
- 用
GetAsyncKeyState轮询鼠标左键状态,只读这一个键,不碰键盘。用户在设置里交换过左右键的话,读右键。 - 按下时用
WindowFromPoint看光标下是不是SHELLDLL_DefView里的SysListView32,也就是桌面图标视图本身,而不是别的窗口。 - 松开后,向这个列表视图发
LVM_GETSELECTEDCOUNT,问它现在选中了几个图标。点在图标上会选中它,点在空白处会清空选择,所以结果是 0 就是空白处。
第 3 步的关键是:这条消息不带任何指针参数,所以可以跨进程直接发给资源管理器,不需要往对方进程里写内存。
问题三:动态壁纸怎么才能不费电
结论是:看不见就不画。壁纸大部分时间被窗口挡着,没必要一直按 60 帧渲染。
第一件事反而是先关掉 Chromium 自己的遮挡判断。Chromium 发现窗口被挡住时会停止绘制,而挂在图标后面的窗口在它看来永远是"被挡住"的,场景根本不会启动。所以要加上 --disable-features=CalculateNativeWinOcclusion 等几个开关,再由自己来判断。
自己判断的方法是:每秒用 EnumWindows 遍历一次顶层窗口,过滤掉不可见、最小化、被 DWM 隐藏(其他虚拟桌面上的窗口就是这种状态)、工具窗口和点击穿透的覆盖层,用 DWMWA_EXTENDED_FRAME_BOUNDS 取到不含阴影的真实边框。然后在每块屏幕上取 16×10 个采样点,算出有多少比例没被盖住。
| 情况 | 帧率 |
|---|---|
| 桌面露出 40% 以上,插着电源 | 60 帧 |
| 桌面露出 40% 以上,用电池 | 30 帧 |
| 桌面露出 15% 到 40% | 20 帧 |
| 桌面露出不到 15% | 0(停止) |
| 暂停、锁屏、息屏、系统休眠 | 0(停止) |
| 开启了节能模式 | 0(停止) |
电池和节能模式用 GetSystemPowerStatus 一次读到。息屏要麻烦些:没有现成的事件,需要用一个隐藏的顶层窗口调用 RegisterPowerSettingNotification,订阅 GUID_CONSOLE_DISPLAY_STATE,再从 WM_POWERBROADCAST 消息的 lParam 指针里读出显示器状态。
帧率决定之后,通过原项目已有的 sceneRate(fps) 接口告诉场景。鼠标的读取频率也跟着走:场景停了,读鼠标的定时器也一起停。
踩过的坑
每 3 秒闪一次屏
第一次在真机上跑,画面成功挂到了图标后面,但每隔 3 秒就闪一下。日志里每 3 秒出现一行"桌面结构变了,重新挂载"。
原因出在我加的一个安全检查上。它每 3 秒确认一次窗口还挂在原位,用的是 GetParent(hwnd) === host。问题是 Electron 的无边框窗口带着 WS_POPUP 样式,而文档里写得很清楚:对 WS_POPUP 窗口,GetParent 返回的是所有者窗口,不是父窗口。我们的窗口没有所有者,所以永远返回 0。
检查因此每次都判定窗口"脱落"了,于是先把它隐藏,再重新挂上,这一藏一显就是闪屏。正确的写法是 GetAncestor(hwnd, GA_PARENT),它只看窗口树里真正的父窗口。
修复之后我还加了一道保险:一分钟内需要重新挂载超过 3 次,就不再自动重试,只写日志。判断错误最多导致不挂载,不会再变成无限闪烁。
资源管理器重启
资源管理器一重启,它的桌面窗口全部销毁,作为子窗口的壁纸也会被一起销毁。解决办法是用隐藏窗口监听 RegisterWindowMessage("TaskbarCreated") 广播,任务栏重建时等两秒,重新创建所有壁纸窗口。
退出后桌面停在最后一帧
壁纸窗口关掉后,WorkerW 不一定会重绘原来的壁纸,桌面就停在最后一帧。退出时我用 SPI_GETDESKWALLPAPER 读出当前壁纸路径,再用 SPI_SETDESKWALLPAPER 设回同一张。注意只带 SPIF_SENDCHANGE,不带 SPIF_UPDATEINIFILE,这样不会改写用户的幻灯片或 Windows 聚焦设置。
安装和卸载要先"礼貌地"退出
安装包升级前如果直接结束进程,就会触发上面那个"最后一帧"的问题。所以程序支持 Deskworlds.exe --quit:它借助单实例锁通知正在运行的那份程序正常退出,安装和卸载都先调用它,两秒后才强制结束。
把鱼换成你喜欢的图片
这是用户呼声最高的功能。思路是模拟一点不动,只换外观:鱼怎么游、怎么躲鼠标、怎么抢食,全都照旧由原来的代码计算,我只是在每条鱼的位置上画一张图片。
这样做的好处是,换成猫、鸭子还是任何角色图片,都自动拥有和鱼一模一样的行为,不需要为每种图片重写一遍互动逻辑。
实现上是一个共享模块 scenes/shared/skins.js,每个世界只加几行挂接代码:
js
// 河床:创建图片,隐藏原来的鱼,每帧按鱼的状态摆放图片
const skins = createSkins(scene, { count: fish.fish.length, view: 'side' });
if (skins) for (const mesh of fish.meshes) mesh.visible = false;
const readFish = (i) => {
const f = fish.fish[i];
return { position: f.position, direction: f.heading, length: STANDARD_LENGTH * f.scale * 1.25 };
};
// 在每帧的鱼群模拟之后
skins?.update(readFish, camera, time);
模块内部有几个细节:
- 每张图片一个
InstancedMesh,所有同图片的鱼一次绘制。多张图片轮流分给每条鱼,混成一群。 - 侧视的世界(河床、珊瑚礁)里,图片始终正对镜头,往左游时把 x 方向的缩放取负,图片自动镜像;再按游动方向的上下分量轻微倾斜。
- 顶点着色器里沿身体加一道从头传到尾的正弦波,越靠近尾巴摆幅越大。任何一张静态图片都会"游"起来。
- 俯视的锦鲤池里,图片平躺在水中,随朝向旋转,摆尾变成水平方向的摆动。
锦鲤池还有一个隐藏的坑。它的水下图层渲染到一个专用缓冲里,片元的 alpha 通道存的不是透明度,而是"水面以下的深度",后面的浑浊模糊效果靠它判断模糊多少。如果图片照常写入 alpha = 1,就会被当成沉在最深处,糊成一团。所以锦鲤池用的着色器把 -worldPosition.y 写进 alpha,和原来的锦鲤保持一致。
等离子球没有生物,可替换的是球心的电极球:换成一张始终正对镜头的图片,电弧照常从中心发出。
做成一个正常的产品
能跑起来只是一半,另一半是让普通用户下载就能用。
为什么选 Electron。 场景依赖 WebGL2 和 ES 模块,Electron 自带的 Chromium 能保证在所有 Windows 机器上表现一致。场景通过自定义协议 deskworlds:// 提供,注册为 standard、secure 和 stream,这样模块导入、fetch 和视频的分段读取都能正常工作。
安装包。 用 NSIS 生成,只为当前用户安装到 %LOCALAPPDATA%\Programs\Deskworlds,不需要管理员权限。它会添加开始菜单项,可选桌面快捷方式,注册"应用和功能"里的卸载项,并设置开机启动。升级时保留用户关掉自启的选择,界面根据系统语言显示中文或英文。
体积。 Electron 应用的大头是 Chromium 本身,没法省。能省的我都省了:
| 部分 | 处理 | 效果 |
|---|---|---|
| Chromium 界面语言包 | 55 种只留英文、简体中文、繁体中文 | 48 MB → 2 MB |
| koffi | 删掉 C++ 源码、头文件和文档,只留 JS 和二进制 | 1.6 MB → 0.1 MB |
| NSIS 压缩 | LZMA 字典从 8 MB 加大到 64 MB | 安装包再小 3.5 MB |
| 许可文件 | 保留,许可证要求随程序分发 | 约 20 MB |
最终安装包约 109 MB,和 VS Code、Slack 这类 Electron 应用差不多。
其他细节。 托盘菜单和设置窗口支持英文、简体中文和繁体中文,默认跟随系统。设置窗口可以调整亮度、色相和生物数量,也可以用自己的图片、视频或网页代替世界。视频没有声音,网页在被挡住时会被隐藏,不在后台耗电。
写在最后
回头看,整个移植最核心的只有两件事:把窗口挂到正确的位置,以及在收不到事件的情况下,把鼠标和电源状态"喂"给场景。剩下的大部分工作,是把它从"能跑"打磨成"敢给别人用":处理异常情况、控制功耗、正规的安装和发布流程。
项目完全开源,欢迎试用、提 Issue 和 PR:
- Windows 版仓库:github.com/Yuan-PengJu...,安装包在 Releases 页面
- 原项目:github.com/chaseleantj...,作者 Chase Lean
- 许可:MIT。场景、Mac 版和浏览器版都是原作者的作品,Windows 版同样以 MIT 许可发布
安装包没有做代码签名,第一次运行时 Windows 可能提示"已保护你的电脑",点"更多信息",再点"仍要运行"即可。下载后也可以用 Get-FileHash 和 Releases 里的 SHA256SUMS.txt 核对文件。
如果你在用的过程中遇到问题,%APPDATA%\Deskworlds\deskworlds.log 里记录了挂载方式、帧率变化和场景报错,附上它提 Issue 能更快定位。