MiroFish这个项目名,拆开看就很有意思:Miro既可以联想到Mirror(镜像),也可以联想到Mirage(海市蜃楼),Fish就是鱼。我最初把它定义成一个“桌面虚拟水族箱”——一段代码在浏览器里画出一个不断游动的鱼群,水草摇曳,气泡上浮,你点击水面会泛起涟漪,鱼群会聚过来抢食。它可以作为网页小应用,也能打包成桌面屏保或PWA挂到手机主屏上。这篇文章就来聊聊我从零实现MiroFish的完整过程,包括鱼群模拟算法、实时渲染、交互反馈、跨平台发布,以及我踩过的一些坑。
1. 项目定位与设计思路
1.1 项目名背后想表达的东西
我起名的时候比较较真,MiroFish不是随便拼出来的。Miro这一层,想表达的核心是“镜像”——鱼在水里游,屏幕是水面的镜像,你在屏幕上看到的是一个微缩的、安静的水下世界。另一层意思来自Mirage,海市蜃楼,因为说到底它不是一个真的鱼缸,而是用代码营造出的视觉幻象,它看起来像真的,但它完全是数字的。
这个项目的核心需求,起初很朴素:我想在写代码的间隙,桌面上有一个不占资源、不吵不闹、还能互动的小东西陪着我。试过放视频,但视频是死的,不能让你点击之后产生任何反馈;也试过装第三方屏保,大部分要么是预渲染视频,要么行为逻辑太呆板,鱼只会直直地从左游到右。我要的是一群真正有“群感”的鱼:它们会聚堆,会躲开你的点击,会发现食物然后抢食,每条鱼的游动节奏都不太一样。
所以MiroFish要解决的核心问题,不是“画一条好看的鱼”,而是“构建一个让人愿意一直盯着看的水下生态”。这个生态里要有鱼群行为模拟、实时渲染、交互反馈,还要能在不同设备上跑起来。
如果你也想做类似的项目,我的建议是:先想清楚你要的是“看起来像鱼缸”,还是“鱼缸里的鱼真的在思考”。前者半天就能做出来,后者才是MiroFish真正花时间的地方。
1.2 为什么选择Web技术而不是Unity
技术上第一个问题就是选型。我考虑过Unity、Godot,也考虑过纯OpenGL或Three.js。最后选了Web技术栈,具体是Canvas 2D加requestAnimationFrame,模块用原生ES Module管理。
选择Canvas 2D的核心原因是:MiroFish的核心不在3D模型精度,而在鱼群行为模拟和环境氛围。Canvas 2D做2D绘制性能足够,而且完全没有复杂光照、材质、物理引擎的负担,单线程下也能轻松跑出60帧。
你可能要问:Three.js不是更炫吗?确实炫,但Three.js的体积和渲染开销会绑架后续优化方向。为了几条鱼的投影和鱼鳞反光去引入一套完整的3D管线,有点杀鸡用牛刀。Canvas 2D的绘制成本低,我可以把更多性能预算花在鱼群算法上。
市面上还有一种做法是纯CSS动画。我试过,用CSS画一条鱼的形状不难,但鱼群的转向、分离、聚合这些行为需要动态计算路径和速度,CSS的transition根本表达不了,只能靠硬编码路径,鱼多了就成了流水线作业,非常呆。最后还是老老实实用Canvas。
我把选型过程整理成一张表,给后面想做类似项目的人一个参考:
| 技术方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Canvas 2D + JS | 轻量、调试快、行为模拟方便 | 不适合复杂3D | MiroFish这类2D生态模拟 |
| Three.js / WebGL | 视觉效果上限高 | 开发复杂度高、包体大 | 需要真实光影水体的项目 |
| Unity / Godot | 生态成熟、动画系统强 | 发布和跨Web较麻烦 | 独立游戏或大型应用 |
| Pure CSS | 最简单 | 无法表达复杂行为逻辑 | 简单装饰动画 |
2. 鱼群模拟核心原理与实现
2.1 Boids三规则怎么变成可调参数
鱼群行为我用的是经典的Boids算法,这个算法1986年就提出了,核心就三条规则:
- 分离(Separation):别跟身边的鱼撞上,保持个人空间。
- 对齐(Alignment):跟周围的鱼朝大致相同的方向游。
- 聚合(Cohesion):往周围鱼群的中心靠拢。
听起来很简单,但如果只是把这三条规则加上去,你得到的就是一堆不断振荡的像素。真正麻烦的是参数调优。
每条鱼每一帧的逻辑是:先看周围有哪些邻居,然后分别计算三个力,乘以各自的权重系数,再加起来作为加速度,最后更新速度向量和位置向量。伪代码大概是这个样子:
function updateFish(fish, neighbors, dt) { let acceleration = { x: 0, y: 0 }; acceleration += separation(fish, neighbors) * config.separationWeight; acceleration += alignment(fish, neighbors) * config.alignmentWeight; acceleration += cohesion(fish, neighbors) * config.cohesionWeight; acceleration += wander(fish) * config.wanderWeight; fish.velocity.x += acceleration.x * dt; fish.velocity.y += acceleration.y * dt; const speed = Math.hypot(fish.velocity.x, fish.velocity.y); if (speed > config.maxSpeed) { fish.velocity.x *= config.maxSpeed / speed; fish.velocity.y *= config.maxSpeed / speed; } fish.position.x += fish.velocity.x * dt; fish.position.y += fish.velocity.y * dt; }工程化的时候,有个点必须处理:邻居查询。如果每条鱼都去遍历所有其他鱼,200条鱼就是4万次两两计算,浏览器还能扛住,但到了低端手机就悬了。我用的是空间网格(spatial hash grid),把整个画布划分成固定大小的格子,每条鱼只查自己附近九个格子里的邻居。这样查询复杂度从O(n²)降到了接近O(n),实测200条鱼在普通笔记本上CPU占用只有个位数。
这里有个细节容易被忽略:Boids算法的感知半径要大于分离半径。感知半径决定了鱼能“看到”多远,分离半径决定了它们认为多近才算挤。如果感知半径太小,聚合和对齐根本不会触发;如果分离半径太大,鱼群会变得稀疏,看起来像各游各的。我当时调了整整一个下午,最后确定感知半径为80像素、分离半径为24像素,感觉最自然。
2.2 让鱼群“活”起来,而不是“动”起来
算法跑起来之后,鱼是会动了,但看起来像一群体操队——所有鱼步调一致,转向整齐,完全不像真的。这个阶段我花的时间比写算法还多。
第一件事是给每条鱼加个性。鱼群的“个人参数”我定义了最大速度、基础游动频率、活跃度、偏好深度。每条鱼在初始化的时候,这些值都在一定范围内随机生成。有的鱼天生游得快,有的鱼喜欢贴着水底,有的鱼每隔几秒就突然加速窜一下。这些随机不是纯随机,而是用一个简单的伪随机正态分布,让大多数鱼落在中间值附近,少数鱼偏快或偏慢。
第二件事是处理鱼的转向方式。如果直接让速度向量改变方向,鱼会瞬间扭头,看起来像机器人。我的做法是:限制每帧最大转向角,比如每帧最多转2度,这样转弯就有弧度了。再配合尾鳍摆动频率,让鱼身体有一个左右轻微摇晃的效果。游得越快,尾鳍摆得越快,摇晃幅度也越大。
第三件事是受惊反应。这个机制是我后加的,但是整个项目里最出彩的部分。当鼠标或手指在鱼群附近点一下,会产生一个临时的斥力场,鱼群会像被石子惊到一样瞬间散开,然后又在几十帧内重新聚合。实现上就是Boids基础上加了一条“恐惧力”:离点击点越近的鱼,受到的排斥加速度越强,并且有一个指数衰减,让鱼先炸开再慢慢回来。
说实话,Boids算法本身不是难点,网上教程到处都是。难的是怎么把一堆数学公式调出一个“看起来不对劲,又说不出哪里不对劲”的体验。如果鱼群动起来非常整齐,优先怀疑对齐权重太高;如果鱼群太散、聚不到一起,优先怀疑聚合权重太低或者感知半径太小。
3. 交互玩法与水族箱氛围搭建
3.1 点击投喂:一条鱼从闻到食物到抢食的完整链路
一个虚拟水族箱只有鱼在游,看几分钟就会腻。MiroFish加入的第一个交互是点击投喂。
投喂的实现链路是这样的:用户点击画布,系统在点击坐标生成一颗食物粒子,食物粒子有一个很小的下沉速度,会缓缓沉到水底。与此同时,鱼群进入“觅食模式”,每条鱼会检测自己的感知范围内有没有食物粒子,如果有,就往最近的食物方向额外加一个加速度。食物被某条鱼吞掉之后消失,这条鱼会有一个短暂的“进食状态”,游动速度会暂时变慢,然后恢复正常。
这个机制写起来不复杂,但有几个细节会让手感差很多。
第一个是食物粒子的下落速度。太快了鱼根本来不及反应,太慢了看起来不像食物。我实测下来,每秒下沉15像素左右比较合适,还有一个轻微的随机横向漂移,让食物落得比较自然。
第二个是鱼的觅食优先级。如果觅食优先级太高,鱼群会对食物过度疯狂,聚成一个毫无美感的圆团;如果太低,鱼就像瞎了一样无视食物。我最后用了“距离衰减”的思路:只有感知半径内的食物才会被关注,而且离食物越近,觅食加速度越强。
第三个是点击位置的坐标换算。这个问题坑了我一会儿,后面常见问题部分会说。简单说就是Canvas的CSS尺寸和绘图尺寸不一致时,必须做一次坐标换算,否则你点左边鱼跑右边。
投喂状态下,反馈也要跟上。我加了一个小细节:鱼吃掉食物后会有一个很短的“张嘴”动画,持续两三帧,用一条略大的深色弧线覆盖在鱼嘴位置。视觉上很微妙,但就是这种小反馈让人觉得鱼是活的。
3.2 水族箱氛围的五个画层
鱼群是核心,但一个水族箱的氛围靠的是环境。MiroFish的渲染我拆成了五层,从底层到顶层依次是:
- 背景层:从深蓝到浅青的竖直渐变,模拟水体的透视感。这个渐变在初始化时创建一次并缓存,不每帧重绘。
- 植物层:水草、珊瑚、石头。水草用几段正弦曲线拼接,顶部会随着时间缓慢摆动,模拟水流。
- 鱼群层:所有鱼在这一层绘制。这层是每帧更新最多的地方。
- 前景层:气泡和悬浮的小颗粒。气泡从底部冒出,上升过程中轻微左右晃动,到水面附近消失。
- 交互层:涟漪、点击特效、漂落的食物粒子都画在这层,因为它们是临时效果,需要及时清除。
分层的核心好处是:不是每层都需要每帧重绘。比如背景层完全可以画一次,后续只复制过来。水草层的计算量也不大,气泡和颗粒的数量可以控制在一个固定上限。只有鱼群层需要每条鱼每帧更新坐标和绘制。
关于视觉风格,我特意避开了细腻写实的路线,选了偏扁平、偏手绘的配色。鱼的身体用两个椭圆叠加,一个深色一个浅色,尾巴用三角形加一个摆动角度,鱼鳍用半透明弧线。这种画法在Canvas 2D里非常省性能,不需要加载图片素材,同时看起来简洁干净。阴影我全部砍掉了,因为Canvas的shadowBlur是性能杀手,一多必然掉帧。
气泡的生成我用了定时器加随机概率:每200毫秒有30%概率在底部随机位置生成一个气泡。气泡上升速度和大小负相关,大气泡浮得快一点,小气泡慢一点。悬浮颗粒则是给水体增加质感的,数量控制在40个以内,不参与任何逻辑,只是以极慢的速度漂移。
音频部分我用的是Web Audio API生成的水声和气泡声,不是外放音频文件。其实主要是气泡破裂声,用一段带频率调制的噪声就能模拟。这个后面会提到,浏览器对音频的自动播放有严格限制,必须在用户首次点击之后才能初始化AudioContext。
4. 工程化落地与跨平台适配
4.1 模块划分和渲染循环
项目虽小,但代码结构如果不规划好,后面加功能会很难受。MiroFish的目录结构大致是这样的:
mirofish/ ├── index.html ├── src/ │ ├── main.js # 入口,初始化 │ ├── config.js # 所有可调参数 │ ├── engine/ │ │ ├── loop.js # requestAnimationFrame主循环 │ │ ├── renderer.js # 画布初始化、分层渲染 │ │ └── input.js # 鼠标/触摸事件 │ ├── entities/ │ │ ├── fish.js # 鱼实体,含Boids行为 │ │ ├── food.js # 食物粒子 │ │ ├── plant.js # 水草 │ │ └── bubble.js # 气泡和悬浮颗粒 │ └── utils/ │ ├── grid.js # 空间网格 │ └── random.js # 随机数工具config.js单独拎出来是我比较得意的决定。所有行为参数,包括Boids权重、鱼群数量上限、气泡生成概率、颜色配置,全部集中在这个文件里。调试的时候我直接用dat.GUI把参数面板弹出来,拖滑块看效果,调到一个舒服的值再写死。
渲染循环有一个点必须强调:物理更新和绘制必须分离,并且物理更新的步长要固定,或者至少用deltaTime做缩放。如果直接把速度乘以固定的每帧数值,刷新率不同的机器上,鱼群速度会完全不一样。60Hz的屏幕和120Hz的屏幕上,同一个配置跑出来是两种感觉。
我的做法是记录上一帧的时间戳,计算deltaTime(单位秒),然后所有速度相关的计算都乘以deltaTime。这样无论刷新率多少,鱼的绝对速度都一致。需要注意deltaTime不要小于0.1秒,否则页面切后台再切回来,鱼会瞬间瞬移,我加了一个上限保护。
4.2 从桌面到手机:跨平台适配的守恒原则
MiroFish最开始只在桌面浏览器调试,后来想挂到手机上,遇到了一堆适配问题。这里说的适配不只是尺寸变化,而是交互方式和性能预算的全面调整。
首先是Canvas的高DPI适配。手机上devicePixelRatio通常是2甚至3,如果不处理,Canvas画出来会发虚。正确做法是:把Canvas的buffer尺寸设置为CSS尺寸乘以devicePixelRatio,然后通过scale让绘制逻辑继续用CSS像素坐标。但这个也不能无限乘,否则高分辨率手机上渲染压力巨大。我限制最大DPR为2,再高就封顶,视觉上几乎看不出差别,性能却能节省三分之一。
其次是事件处理。桌面上用click和mousemove,手机上完全没有鼠标,必须用pointer事件统一处理电脑的鼠标和手机的触摸。pointer事件的好处是同时覆盖两者。我的input.js里只监听pointerdown、pointermove、pointerup,不做任何平台判断,代码干净很多。
第三是性能的动态调节。我写了一个简单的性能监控:每5秒统计一次平均帧率,如果低于45帧,就自动减少鱼群数量,每次减少10条,最少保留40条;如果帧率高于55帧,就慢慢恢复鱼群数量。这个动态调节逻辑虽然简单,但大幅提升了低端手机的体验。用户感知到的只是“鱼少了”,但整体依然流畅,不会卡成PPT。
跨平台发布的最后一公里是PWA。加了一个manifest.json和service worker之后,MiroFish可以直接“安装”到手机主屏,像原生App一样全屏运行。这一步成本很低,但让项目看起来完整了很多。
5. 常见问题排查与避坑实录
5.1 性能问题:先从渲染层找原因
我做性能排查时养成了一个习惯:先把Update(逻辑计算)和Draw(渲染)分开压测。如果去掉渲染只跑逻辑,帧率依然上不去,问题在Boids算法;如果逻辑很轻松但整体卡顿,问题一定在绘图。
MiroFish遇到的第一个性能问题是阴影。鱼的轮廓加了一层blur阴影想做出“水体朦胧感”,结果Canvas的shadowBlur是一个很贵的操作,每条鱼绘制时都会触发内部缓冲区的额外处理。200条鱼加阴影,帧率从60掉到30,删掉阴影后立刻恢复60帧。这类特效能不用就不用,如果一定要朦胧感,可以用一个半透明渐变覆盖层模拟,效果接近但开销小得多。
第二个性能问题是Canvas尺寸和绘制尺寸不一致。手机横竖屏切换时,Canvas buffer会被浏览器重置,如果没有及时重新设置宽高,绘制会变得很模糊,而且触发大量无关的重新布局。解决方法是在resize事件里重新设置width和height,同时重建背景渐变。
第三个性能问题来自移动端的省电模式。部分手机会限制requestAnimationFrame的频率,导致鱼群看起来“一卡一卡”的。这种问题代码层面很难完全解决,我的处理是检测到连续低帧率时,主动降低逻辑更新频率到30Hz,但让绘制插值补帧,观感上会平滑很多。
5.2 行为问题:鱼群看起来不对劲的排查方向
鱼群行为类bug的排查思路完全不一样。这里整理了几个我真实遇到的坑和解决办法,做成表格方便查阅:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 鱼群像军队一样整齐划一 | 对齐权重过高,或每条鱼参数太接近 | 降低对齐权重,给每条鱼加随机化个性参数 |
| 鱼群散成一团,聚不起来 | 聚合权重太低,或感知半径太小 | 加大聚合权重,把感知半径提高到80以上 |
| 鱼在边界区域反复绕圈 | 边界避让力方向计算错误,或力太强导致振荡 | 改用软边界,离边界越近力越强,但不要直接反转速度 |
| 点击和鱼群反应位置错位 | Canvas CSS尺寸和绘图buffer尺寸不一致,坐标没换算 | 用getBoundingClientRect()换算点击坐标 |
| 鱼“瞬移”到画面外 | deltaTime过大,页面切后台导致时间戳跳跃 | 给deltaTime设上限,超过0.1秒按0.1秒计算 |
| 部分鱼永远不动 | 初始化时速度向量为0,且周围没有邻居触发规则 | 初始化时给一个随机小速度和随机方向 |
鱼群绕圈这个bug我印象最深。当时边界处理用的是“硬反转”——鱼到了边界就把速度向量反过来。结果就是鱼在边界来回振荡或者在墙角绕圈。后来改成软边界:鱼越靠近边缘,就越有一个向内侧的加速度,力的大小和距离成反比,靠近到极限时会柔性地“推开”鱼。改完之后鱼群的分布自然了很多,没有那种硬邦邦碰壁的感觉。
还有一个很隐蔽的问题:Boids里的邻居查询经常把鱼自己算进去。如果不加判断把自身排除,分离力会瞬间变成0或无穷大,导致鱼群不时抽搐。排查了半小时才发现的,后来我在遍历邻居时直接跳过id相同的那条鱼。
5.3 音频和PWA运行时的细节坑
音频的自动播放限制是浏览器出名的坑。MiroFish刚加音效时,打开页面什么都正常,就是没有声音。查了一下发现是Chrome的输出规则:没有用户交互之前,Web Audio的context是挂起的。怎么解决呢?我第一次点击页面时,在pointerdown事件里统一初始化AudioContext并调用resume()。用户只要点过一次,之后就有声音了。这个逻辑放在input.js里再合适不过。
PWA的坑主要出现在service worker缓存。开发的时候我改了一版代码,但页面上跑的永远是旧版本,因为service worker把旧文件缓存了。光手动刷新还不行,必须在安装事件里更新缓存版本号,并启用新的service worker时调用skipWaiting。另外,开发过程中记得在浏览器DevTools里勾选“Update on reload”,不然被缓存坑到你怀疑人生。
6. 实测过程中的一些体会
最后聊聊实测数据和个人感受。
MiroFish在当前版本下,桌面Chrome浏览器里200条鱼稳定60帧,CPU单核占用大概10%到15%,内存占用控制在80MB以内。切换到低端Android手机,动态调节后的鱼群数量大概会降到60条左右,帧率维持在45帧上下。这个表现我觉得符合预期——毕竟它是个相对轻量的2D模拟项目,不该和3D大作拼资源。
从开发节奏上说,我最大的体会是:先让一条鱼动起来,再去复制成一万条。前期我花了很多时间在单条鱼的游动姿态上,包括尾鳍摆动、转向弧度、身体摇晃,等单条鱼看起来舒服了,再引入Boids算法。这样做的好处是,出问题时能明确知道是行为算法的问题,还是基础形态的问题。如果一开始就摆上200条鱼,任何bug都会被放大到让你找不到源头。
另一个心得是参数要集中管理,且一定要可视化调试。我推荐的组合是config.js加dat.GUI,把分离、对齐、聚合三个权重做成滑块,一边拖一边看效果。你会发现,光这三个权重系数的不同组合,就能产生完全不同的鱼群气质。同样一群鱼,可能表现出密集的沙丁鱼群风格,也可能是松散的各游各的调调。参数这个东西,靠代码里猜不如靠眼睛调,肉眼所见才是最终标准。
MiroFish做完之后,我反而更不爱用装饰性的屏保了。自己写的小鱼缸虽然没有商业作品那么精致,但我可以随时改鱼的颜色、数量、游动习惯,甚至加一条自定义鱼。这种“敲代码就能改变生态”的控制感,大概就是做这类小项目最大的乐趣。
如果你也想做一个类似的东西,我的建议是别直接抄代码。你可以参考Boids实现、参考分层渲染的思路,但最好设计一版你想要的鱼群气质,然后从零写一遍。踩坑的过程,才是这个项目真正的价值所在。