我把一个只支持 Mac 的开源 3D 动态壁纸移植到了 Windows

我把一个只支持 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 版能复用全部场景。

喂食更有意思。我想让"双击桌面空白处"等于喂食,但双击图标得照常打开文件。难点是:壁纸进程怎么知道用户双击的是空白处还是图标?

我的做法分三步:

  1. 用 GetAsyncKeyState 轮询鼠标左键状态,只读这一个键,不碰键盘。用户在设置里交换过左右键的话,读右键。
  2. 按下时用 WindowFromPoint 看光标下是不是 SHELLDLL_DefView 里的 SysListView32,也就是桌面图标视图本身,而不是别的窗口。
  3. 松开后,向这个列表视图发 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 可能提示"已保护你的电脑",点"更多信息",再点"仍要运行"即可。下载后也可以用 Get-FileHash 和 Releases 里的 SHA256SUMS.txt 核对文件。

如果你在用的过程中遇到问题,%APPDATA%\Deskworlds\deskworlds.log 里记录了挂载方式、帧率变化和场景报错,附上它提 Issue 能更快定位。

相关推荐
加宝的起点雷达18 小时前
为什么我不看 Reddit 热榜,而是自己算了个"信号分"
产品
我命由我123451 天前
ReAct 循环
学习·ai·产品运营·学习方法·ai编程·ai写作·产品
SL_staff2 天前
销售知识库7天落地实践:面向业务侧的零代码嵌入方案
java·开源·产品
这个DBA有点耶3 天前
研发规模/投入最大的国产数据库厂商是谁?拆解五个衡量维度
数据库·程序员·产品
九五视界3 天前
17 位复合主键:不查表也知道记录属于哪个实体
产品
李剑一3 天前
AI翻译都这么强了,为什么程序员还是躲不开英语?假如你想用上GPT6,我劝你还是早点背单词吧!
面试·产品·创业
阿宸科普6 天前
液冷机组冷却设备,接触器怎么选?三大系列参数与工程校核要点
产品
洞察万事局6 天前
液冷机组冷却设备,接触器怎么选?一个电气设计的一线记录
产品
Mahut6 天前
我在 Mac 上做了个本地剪辑器
javascript·架构·产品