23 mins
4595 words
Loading views
把一方池塘装进四千行代码:fishwallpaper 技术方案拆解与可借鉴的十个技巧

拆解开源项目「碧池观鱼 fishwallpaper」的完整技术方案:GPU 波动方程涟漪、Voronoi 焦散、程序化锦鲤与合成音频、一份网页适配五端宿主的架构设计,并提炼出十个可以直接搬回自己项目里的工程技巧。

最近在本地跑起了一个开源项目「碧池观鱼」(fishwallpaper):一方碧绿的池塘铺在桌面当壁纸,锦鲤结伴巡游、雨点打在荷叶上溅起涟漪、夜里有按真实月相倒映在水中的月亮。让我惊讶的不是效果——而是它的实现方式:全部效果加起来不到四千行代码,零 npm 依赖、零图片素材(除一张池底图)、零音频文件,却能同时跑在浏览器、Wallpaper Engine、macOS、Windows 和 Android 上。这篇文章把它从架构到细节完整拆一遍,最后提炼出十个我认为可以直接搬回自己项目里的技巧。


📑 目录h2


一、项目概况:四千行代码装着一整个池塘h2

项目地址:moli-xia/fishwallpaper

先看一组数字:

指标数据
Web 端代码6 个 JS 模块 + 1 个 HTML + 1 个 CSS,约 2700 行
三平台宿主Swift / C# / Java 共约 1100 行
npm 运行时依赖0 个,没有打包器,没有框架
图片素材仅 1 张池底图(其余全部程序化绘制)
音频文件0 个(全部 Web Audio 实时合成)
发行包体积网页版约 2 MB,macOS 应用约 4 MB

它做到的事情包括:GPU 波动方程涟漪、动态焦散光网、云影、鱼影、雨雪天气、真实月相、程序化绘制的七种锦鲤花色(逐片鳞)、Web Audio 合成的古琴与雨声、跨窗口存档同步、三平台桌面壁纸宿主。这个「效果 / 代码量」的比值,是它最值得研究的地方。


二、总体架构:六个模块各司其职h2

整个 Web 端就是 index.html 按顺序加载 6 个脚本,依赖关系即加载顺序,不需要任何构建工具:

模块行数职责
core.js176游动仿真、存档清洗。纯逻辑,不碰 DOM,可在 Node 里直接测试
art.js536程序化贴图:锦鲤、乌龟、蝴蝶、鳍,全部用 Canvas 2D 代码画出来
gl.js768WebGL2 渲染引擎:九步多 Pass 管线 + Canvas 2D 降级方案
scene.js568「导演」:天气、雨雪粒子、乌龟螃蟹蜻蜓萤火虫、浮水物标注
audio.js349全部声音的实时合成与调度
app.js186UI、面板、天气服务、主循环、多窗口存档同步

分层思想非常清晰:core 不知道画布的存在(所以 npm test 能直接测仿真行为),gl 不知道鱼为什么要转弯(只接收姿态数据),scene 负责把两者粘起来(每帧把天气状态翻译成渲染参数传给渲染器)。这是典型的「逻辑 / 表现分离」,但执行得比大多数大项目干净得多。


三、渲染管线:水面是如何动起来的h2

gl.js 是整个项目的技术核心:一个九步多 Pass 的 WebGL2 管线。每帧的执行顺序:

  1. 涟漪模拟——GPU 波动方程,双缓冲 ping-pong
  2. 焦散光网——Voronoi 边缘近似,半分辨率
  3. 云影——分形噪声 + 风场漂移,1/8 分辨率
  4. 池底阴影——半分辨率阴影缓冲,之后用 mipmap 做可变模糊
  5. 水下场景——池底图 + 焦散 + 阴影合成,再画水下的鱼
  6. 水面——坡度图(涟漪梯度 + 三层波浪纹理)+ 太阳耀斑
  7. 最终合成——折射、天空反射、色调分级、月亮与星星
  8. 浮水物——荷叶荷花石头的湿雪效果,画在水面上方
  9. 空中层——蜻蜓蝴蝶、萤火虫发光,加法混合

涟漪:GPU 上的波动方程h3

这是最经典的技巧。水面是一张高度场纹理,每个时间步做两件事:速度加上「与四邻平均高度的差」,高度加上速度——这正是波动方程的显式欧拉离散。用两张 RGBA16F 浮点纹理来回交换(ping-pong),固定每秒 90 步:

// FS_SIM 节选:avg 是四邻高度均值
s.g += (avg - s.r) * 1.9; // g 是速度,r 是高度
s.r += s.g;
// 一点黏度,让网格级噪声在干扰折射前先衰减掉
s.r = mix(s.r, avg, .012) * mix(.95, .9993, edge);

三个细节体现了作者的功力:

  • 边缘吸收:池边把波速衰减系数调低,涟漪到岸边自然消失,不会反弹出方框伪影;
  • 黏度项:mix(s.r, avg, .012) 混掉高频网格噪声,否则折射画面会出锯齿;
  • 荷叶遮罩:浮水物的遮罩图直接把波场清零,涟漪遇到荷叶「撞回去」形成反射——一个物理特性,零额外成本。

而点击、雨点、鱼食入水全部是同一种输入:一个往高度场里注入余弦形扰动的「投石」shader,每批最多 24 个投石点打包成 uniform 数组,一次 draw call 注入。

焦散:Voronoi 边缘近似h3

阳光透过波纹聚焦在池底的光网,用的是图形学里常见的手法:在每个 Voronoi 细胞里算最近点和次近点的距离差 sqrt(d2) - sqrt(d1),差值小的地方就是细胞边界,也就是光线汇聚的亮线。两层不同频率、不同速度叠加,再用噪声图调制亮度,半分辨率渲染——完全看不出是「假」的。

云影、鱼影与「免费的模糊」h3

云影是三层分形值噪声加阈值,随风漂移并用噪声域扭曲让云缓慢变形,只需要 1/8 分辨率。所有向阳的东西(池底、鱼身、影子)都去读这张低分辨率图,全池明暗自然统一。

鱼影的做法更巧:鱼画进一张半分辨率阴影缓冲,池底采样时不直接采样,而是按 mipmap LOD 偏移采样——贴池底的鱼用低 LOD(锐利影子),浮在水面的鱼用高 LOD(大而柔的影子)。等于拿 mipmap 当免费的可变高斯模糊,一无所费:

// lod 越大影子越糊:由鱼的深度和云影厚度决定
vec2 t = uShadowTexel * exp2(lod) * .7;
return .25 * (textureLod(uShadow, uv + t, lod).a + ... );

锦鲤:沿脊柱的三角条带 + 片元内重新受光h3

每条锦鲤不是一张会转的贴图,而是沿 16 段脊柱拉出的三角条带,贴图顺着身体弯曲。更妙的是受光:片元着色器里用屏幕空间导数 dFdx / dFdy 重构出条带的横向法线,把鱼身当圆柱体实时打光——向阳一侧变亮,高光随转身在鱼背上滑动,鳞片的金属感(metal / gloss 两个通道)参与高光指数计算。一颗精灵,有了体积感。

月亮是纯 Shader,连月相都对h3

「月下观鱼」模式里的月亮没有一张图片:按当天真实月相计算参数,终止线是一个半椭圆,五块「月海」是手摆的椭圆斑,水面倒影按波纹法线偏移采样所以会随涟漪碎开,波峰朝向月亮的地方会聚出月光碎金,还有星星在波纹间闪烁。一段 40 行的 shader,把「月映池塘」做全了。

性能预算:处处分级,自动降档h3

资源分辨率策略
涟漪模拟纹理不超过 900×900
焦散 / 水面屏幕的 1/2(节能模式 1/3)
云影屏幕的 1/8
阴影缓冲屏幕的 1/2
画布总像素上限 360 万(节能 160 万),超出按比例缩

运行时每 5 秒测一次帧率,跟不上就自动降一级渲染精度(降两次为止),还有 30 / 60 帧模式、页面不可见即暂停、webglcontextlost 自动重建。WebGL2 不可用时整个 Pond 退回 Canvas 2D:没有水效果,但鱼照游。


四、游动仿真:每条鱼都是一台小状态机h2

core.js 只有 176 行,却撑起了「鱼像鱼」的全部观感。几个关键设计:

归一化坐标。所有鱼的状态用 0 到 1 的相对坐标存储,与分辨率解耦,窗口怎么缩放都不影响行为。

三态状态机。巡游(随机选目标 + 双正弦扰动让路径不呆板 + 随机停下来歇)、追食(减速对准食物;食物落在身下够不到时,先游开再绕回来——这个细节直接来自对真锦鲤的观察)、受惊(C 形急转 + 冲刺逃离)。

摆尾—滑行节奏(beat-and-glide)。真鱼不是匀速前进的:几次尾部击水加速,然后收鳍滑行。仿真里就是一个「是否击水」的开关加指数加减速,配合沿脊柱向后传播的行波:

// 头部前 5 段保持刚性,后面每段朝前一段的方向限幅跟随
const a = prev + clamp(wrap(Math.atan2(py - y, px - x) - prev), -lim, lim);
p[i*2] = px - Math.cos(a) * seg;

邻域交互。每条鱼对邻居做分离(保持个人空间)、对头让路(侧向绕开)、同种对齐(结伴同游),青鲢额外做松散聚群;荷叶和池边作为障碍物做前瞻转向。

深度模拟。每条鱼有一个 0 到 1 的深度值驱动上浮下沉,深度同时影响画面的两个地方:越深越模糊(贴图 mipmap 偏移)、「影子越贴身」(池底影子偏移量)。一个变量同时驱动两处表现,便宜又统一。


五、程序化美术与合成音频:零素材的极致h2

art.js:七种花色,逐片鳞h3

锦鲤贴图没有一张来自图片文件。红白、大正三色、黄金、白写、丹顶、墨鲤、纯红,每种花色都是 Canvas 2D 逐片鳞画出来的:每片鳞有被前一片压住的暗色鳞袋、靠近游离缘的亮带、一道细鳞缘线,位置错落、色调各异——所以纯色的红鲤和墨鲤也能看出一片片鳞。花纹由 seed 驱动确定性随机,同一尾鱼在任何设备上重开都长一样;用户手绘的花纹存成坐标数组,参与精灵缓存 key 的哈希。

精灵按 palette:seed:marksHash 为 key 做 LRU 缓存(上限 120 张),画好的贴图上传进一张 2048×2048 的图集,鱼和杂项(龟壳、蝴蝶、荷叶水珠)共用。

audio.js:所有声音都是代码合成的h3

没有音频文件。溪流潺潺是噪声过一遍一阶滤波(跑两遍让循环点无缝衔接);雨声密度随雨量变密;古琴是拨弦物理模型加五声音阶,带滑音和泛音;颂钵、风铃是正弦叠加;投喂的「噗通」、吞食的「咕噜」、点水声全部实时合成。缓冲区在第一次用到时才离线渲染,页面加载零音频开销。


六、数据层:存档清洗、多端同步与真实天气h2

白名单清洗。sanitizeSave 对读入的存档逐字段校验:名字限长、花色限枚举、大小限范围、手绘笔迹限数量并校验十六进制颜色正则,最多 60 尾。存档文件因此永远不可能把页面弄崩——这是所有接受用户数据的前端都该有的习惯。

多窗口同步。桌面壁纸可能每块屏幕一个实例,加上交互窗口、手机 App,多个页面共享一池鱼。方案是 rev 编辑戳合并:每次人工改动打上时间戳,同步时设置和鱼群取「编辑更新的一方」,进食数量永远取两边的最大值。通道包括浏览器 storage 事件和三个宿主桥(macOS 的 webkit.messageHandlers、Windows 的 chrome.webview、Android 的 PondHost)。

真实天气。接入 Open-Meteo 免费接口(无需 API 密钥):先地理编码查城市坐标,再取实时天气;天气码映射到池塘的晴 / 多云 / 雨 / 雪,降水量线性映射雨量强度;每 15 分钟轮询,失败时保留当前天气并提示,绝不伪造温度。


七、多端宿主:同一份网页的五种壳h2

同一个池塘页面,五种宿主方式,平台差异全部隔离在宿主侧:

宿主技术关键做法
浏览器纯静态页双击即用;本地服务获得稳定存档
Wallpaper Engineproject.jsonwallpaperPropertyListener 接管可编辑属性
macOSSwift + WKWebView每屏一个窗口置于壁纸层;透明「点击层」转发鼠标
WindowsC# + WebView2窗口挂到桌面图标层后;低级鼠标钩子「旁听」点击
AndroidJava WallpaperService虚拟显示器渲染 + 逐帧拷贝到壁纸表面

三个宿主方案各有值得一记的巧思:

  • macOS:Finder 的桌面窗口会截走所有点击,壁纸收不到。作者的解法是在普通窗口之下、桌面图标之上再放一层透明点击层,鼠标悬停在池塘控件上时它才接住事件转发给壁纸,移开就放行给桌面图标——按需截获,两不耽误。
  • Windows:桌面点击同样到不了壁纸窗口,于是装低级鼠标钩子旁听(不拦截)桌面空白处的点击,转成投喂事件。资源管理器重启、显示器热插拔都会自动重建壁纸窗口,锁屏自动停画。
  • Android:系统只给壁纸一块 Surface,WebView 画不进去。方案是让网页跑在私有虚拟显示器的 Presentation 里,GPU 正常渲染,再用 ImageReader 收帧、lockHardwareCanvas 逐帧拷到壁纸表面(不用「虚拟显示器直写表面」的取巧方案,因为那在部分厂商系统上会黑屏);10 秒无帧就兜底显示静态图,绝不黑屏。

八、可借鉴的十个技巧h2

以上是「它做了什么」,这一节是「我能抄走什么」:

1. 纯逻辑层与平台层分离,让仿真直接跑单测。 core.js 不碰 DOM、用 module.exports 导出,于是 npm test 可以在 Node 里验证追食记账、受惊逃离、存档清洗。想给自己的动画 / 游戏逻辑上测试,先做这一步。

2. 双缓冲 ping-pong:在 GPU 上解偏微分方程。 两张浮点纹理来回交换,一个 pass 解一步波动方程。这套模式通用于所有「状态随时间演化」的 GPU 模拟:流体、烟雾、布料、生长模型。

3. 把 mipmap 当免费的可变模糊。 想要「距离越远越糊 / 越深越糊」时,不需要第二张模糊纹理,给 textureLod 一个动态 LOD 偏移即可。本项目用它做鱼影的深度模糊,同样适用于接触阴影、景深近似。

4. 低频信息降分辨率渲染。 云影 1/8、焦散 1/2、阴影 1/2——变化缓慢的东西不需要全分辨率,省下的填充率是实打实的帧时间。线性上采样补回来即可。

5. 单图集 + 分层批处理。 全池生物共用一张 2048 图集、七个图层队列,每帧只十几次 draw call。精灵渲染想快,先合并纹理再合并 draw call。

6. 程序化生成代替素材。 美术是代码画的、声音是代码合的、月亮是 shader 算的——换来 2 MB 的发行包和「每个用户看到的数据完全一致」的可复现性。小体量项目里,程序化生成的性价比经常高过找素材。

7. 存档白名单清洗 + 固定种子。 对任何读入的数据逐字段校验;首次访问用固定随机种子,让「24 尾初始锦鲤」在多窗口、多设备上长得一样。这是把「数据决定观感」的随机性变成可测试、可同步的前提。

8. 编辑戳合并解决多端并发写。 每次人工修改打时间戳,同步时「设置取最新、计数取最大」。没有后端、没有 CRDT,一行时间戳就解决了 80% 的冲突。

9. 平台差异隔离在壳里,网页只留小接口。 网页侧只暴露 pondControl(宿主控制天气暂停)、pondTap(宿主转发点击)、wallpaperPropertyListener(WE 属性)三个接口。macOS 的点击层、Windows 的钩子、Android 的虚拟显示器全在壳里。想让一份 Web 代码多端发布,照这个切。

10. 性能预算自动化。 分级渲染精度、帧率实测自动降档、按页面可见性暂停、上下文丢失重建。性能不靠用户设置,靠程序自己量——这对任何长驻桌面 / 后台运行的应用都是必修课。


九、本地运行与构建h2

Terminal window
git clone https://github.com/moli-xia/fishwallpaper.git
cd fishwallpaper
npm run dev # 即 node scripts/serve.mjs,无任何依赖要装

打开 http://localhost<5173> 即可。其实双击 index.html 也能跑,走本地服务才有稳定的存档和城市天气查询。

Terminal window
npm test # Node 内置 test runner,直接测仿真逻辑
npm run build # 产出 dist/,可导入 Wallpaper Engine

本文封面图就是我在本机运行时的实况截图——雨天有雨天的雾,月夜有月夜的碎光,晴天的焦散光网随涟漪晃动,点击水面还有锦鲤围过来抢食。


十、结语h2

fishwallpaper 最打动我的不是某项黑科技——波动方程、Voronoi 焦散、拨弦合成都是图形学和音频里的经典手法——而是用极少的代码把经典手法组合到极高完成度的工程能力:每个效果都有一个廉价的实现路径,每个平台差异都有一个薄薄的壳,每一行代码都知道自己为什么存在。下次想给项目加「一点点氛围感」的时候,这个仓库值得翻开来抄。


参考与引用h2

  1. moli-xia, fishwallpaper(碧池观鱼), GitHub, 2026. github.com/moli-xia/fishwallpaper
  2. fishwallpaper 项目 README:「本地运行与开发」「已实现」「素材与服务」等章节,及 core.js、gl.js、art.js、audio.js、scene.js、app.js 源码(本文代码节选均出自该项目,引用仅作技术评述)。
  3. Open-Meteo 免费天气与地理编码 API:open-meteo.com
  4. Wallpaper Engine 网页壁纸属性接口文档:docs.wallpaperengine.io
  5. 本项目灵感来源的原视频(作者声明为独立复刻,非官方作品):douyin.com/video/7688973095907232741

Author: 夜猫子Ai手记
Post: 把一方池塘装进四千行代码:fishwallpaper 技术方案拆解与可借鉴的十个技巧

Comments