最近在本地跑起了一个开源项目「碧池观鱼」(fishwallpaper):一方碧绿的池塘铺在桌面当壁纸,锦鲤结伴巡游、雨点打在荷叶上溅起涟漪、夜里有按真实月相倒映在水中的月亮。让我惊讶的不是效果——而是它的实现方式:全部效果加起来不到四千行代码,零 npm 依赖、零图片素材(除一张池底图)、零音频文件,却能同时跑在浏览器、Wallpaper Engine、macOS、Windows 和 Android 上。这篇文章把它从架构到细节完整拆一遍,最后提炼出十个我认为可以直接搬回自己项目里的技巧。
📑 目录h2
- 一、项目概况:四千行代码装着一整个池塘
- 二、总体架构:六个模块各司其职
- 三、渲染管线:水面是如何动起来的
- 四、游动仿真:每条鱼都是一台小状态机
- 五、程序化美术与合成音频:零素材的极致
- 六、数据层:存档清洗、多端同步与真实天气
- 七、多端宿主:同一份网页的五种壳
- 八、可借鉴的十个技巧
- 九、本地运行与构建
- 十、结语
- 参考与引用
一、项目概况:四千行代码装着一整个池塘h2
先看一组数字:
| 指标 | 数据 |
|---|---|
| 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.js | 176 | 游动仿真、存档清洗。纯逻辑,不碰 DOM,可在 Node 里直接测试 |
art.js | 536 | 程序化贴图:锦鲤、乌龟、蝴蝶、鳍,全部用 Canvas 2D 代码画出来 |
gl.js | 768 | WebGL2 渲染引擎:九步多 Pass 管线 + Canvas 2D 降级方案 |
scene.js | 568 | 「导演」:天气、雨雪粒子、乌龟螃蟹蜻蜓萤火虫、浮水物标注 |
audio.js | 349 | 全部声音的实时合成与调度 |
app.js | 186 | UI、面板、天气服务、主循环、多窗口存档同步 |
分层思想非常清晰:core 不知道画布的存在(所以 npm test 能直接测仿真行为),gl 不知道鱼为什么要转弯(只接收姿态数据),scene 负责把两者粘起来(每帧把天气状态翻译成渲染参数传给渲染器)。这是典型的「逻辑 / 表现分离」,但执行得比大多数大项目干净得多。
三、渲染管线:水面是如何动起来的h2
gl.js 是整个项目的技术核心:一个九步多 Pass 的 WebGL2 管线。每帧的执行顺序:
- 涟漪模拟——GPU 波动方程,双缓冲 ping-pong
- 焦散光网——Voronoi 边缘近似,半分辨率
- 云影——分形噪声 + 风场漂移,1/8 分辨率
- 池底阴影——半分辨率阴影缓冲,之后用 mipmap 做可变模糊
- 水下场景——池底图 + 焦散 + 阴影合成,再画水下的鱼
- 水面——坡度图(涟漪梯度 + 三层波浪纹理)+ 太阳耀斑
- 最终合成——折射、天空反射、色调分级、月亮与星星
- 浮水物——荷叶荷花石头的湿雪效果,画在水面上方
- 空中层——蜻蜓蝴蝶、萤火虫发光,加法混合
涟漪: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 Engine | project.json | wallpaperPropertyListener 接管可编辑属性 |
| macOS | Swift + WKWebView | 每屏一个窗口置于壁纸层;透明「点击层」转发鼠标 |
| Windows | C# + WebView2 | 窗口挂到桌面图标层后;低级鼠标钩子「旁听」点击 |
| Android | Java 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
git clone https://github.com/moli-xia/fishwallpaper.gitcd fishwallpapernpm run dev # 即 node scripts/serve.mjs,无任何依赖要装打开 http://localhost<5173>5173> 即可。其实双击 index.html 也能跑,走本地服务才有稳定的存档和城市天气查询。
npm test # Node 内置 test runner,直接测仿真逻辑npm run build # 产出 dist/,可导入 Wallpaper Engine本文封面图就是我在本机运行时的实况截图——雨天有雨天的雾,月夜有月夜的碎光,晴天的焦散光网随涟漪晃动,点击水面还有锦鲤围过来抢食。
十、结语h2
fishwallpaper 最打动我的不是某项黑科技——波动方程、Voronoi 焦散、拨弦合成都是图形学和音频里的经典手法——而是用极少的代码把经典手法组合到极高完成度的工程能力:每个效果都有一个廉价的实现路径,每个平台差异都有一个薄薄的壳,每一行代码都知道自己为什么存在。下次想给项目加「一点点氛围感」的时候,这个仓库值得翻开来抄。
参考与引用h2
- moli-xia, fishwallpaper(碧池观鱼), GitHub, 2026. github.com/moli-xia/fishwallpaper
- fishwallpaper 项目 README:「本地运行与开发」「已实现」「素材与服务」等章节,及
core.js、gl.js、art.js、audio.js、scene.js、app.js源码(本文代码节选均出自该项目,引用仅作技术评述)。 - Open-Meteo 免费天气与地理编码 API:open-meteo.com
- Wallpaper Engine 网页壁纸属性接口文档:docs.wallpaperengine.io
- 本项目灵感来源的原视频(作者声明为独立复刻,非官方作品):douyin.com/video/7688973095907232741
Comments