MiroFish 鱼群模拟实战:群体智能、空间分区与性能优化
发布时间:2026/9/18 11:59:05 锦皓数字建站

第一次在仓库列表里看到 MiroFish 这个名字我的第一反应是又一个拿无限画布当噱头的玩具。直到把它拖进浏览器跑起来——几百条鱼在画布上散开、聚拢、贴着障碍物绕行鼠标随便一划整群鱼像被一股暗流带偏了方向集体改向、短暂混乱、又慢慢恢复队形——我才改口。MiroFish 是一套面向群体智能swarm intelligence的鱼群模拟与交互式可视化项目核心是最经典的一类局部规则每条鱼只感知自己周围一小圈同伴没有总指挥却能自发形成全局秩序。它适合三类人想找一个能直接改参数、立刻看到效果的群体行为教学工具的人想在网页里塞一个会呼吸的动态背景的人以及想拿它练手空间分区、渲染管线和多线程计算的工程师。先把话说在前面不同版本的 MiroFish 在文件命名和默认参数上会有出入下面这套目录结构、参数取值和优化顺序是我按同类项目的通用做法整理出来的你对着自己仓库里的名字改一下就能对上。真正值钱的部分不是跑起来而是每个参数背后的取舍逻辑以及从五百条鱼堆到五万条鱼这一路上会撞到的东西。1. MiroFish 究竟在解决什么问题以及它不该被期待成什么1.1 从一段看起来很聪明的鱼群说起群体行为最容易让人误判的地方是把结果当成了原因。看到鱼群整齐转向人脑会本能地假设有一条领头的鱼在发号施令或者整群鱼共享了某个全局目标点。MiroFish 的内部恰恰相反不存在领头鱼也不存在全局目标。每条个体每一帧只做三件事——离我太近的同伴要躲开分离separation我看得见的同伴朝哪游我就大致朝哪游对齐alignment以及朝同伴的几何中心靠拢一点聚合cohesion。三股力加起来得到一个期望速度再用当前速度去逼近它就完成了转向。有意思的是这三条规则单独看都很傻只做分离鱼群炸成一团均匀的气体只做对齐整群鱼会像一条僵硬的线只做聚合所有鱼立刻塌缩成一个像素点。只有当三者以合适的权重叠加才会出现那种有生命感的流动。这跟做饭一个道理盐、糖、醋单独吃都难以下咽比例对了才是菜。理解了这一点你在调参时的心态就会变。不是去修复鱼群乱跑而是去调整三种倾向的相对强弱。乱跑说明聚合太弱或分离太强僵成一条线说明对齐过重、分离不足一团糊在一起就是分离半径太小。所有现象都能映射回这三条规则的配比这是 MiroFish 最核心的心智模型。1.2 三类典型使用者和三类被误会的期待我观察下来真正会把 MiroFish 用起来的人大致分三类。第一类是教学和科普场景需要把涌现这个概念从论文里的公式变成屏幕上肉眼可见的现象学生自己拖动滑块看权重变化如何彻底改变群体形态比讲二十分钟 PPT 有效得多。第二类是前端和数字艺术方向的人需要一个体积不大、不依赖后端、能嵌进已有页面的动态背景或视觉主体MiroFish 的渲染层天然适配这种用法。第三类是想练手底层优化的工程师这个项目把空间查询、数据布局、多线程通信这些硬骨头集中在一个可玩性很高的壳子里比刷算法题有意思。同时我也见过不少误会。有人拿它当物理引擎用想模拟刚体碰撞、绳索、流体压力这完全跑偏了——MiroFish 里没有碰撞求解器个体之间是柔性避让允许轻微重叠追求的是整体观感而不是物理正确。有人期待它是某种 AI 训练框架其实它没有任何学习过程权重全靠人手调这也是它上手快的原因真要引入学习那是另一个项目的规模。还有人把它当动效曲线工具想用它做精确的入场动画这更不合适因为它的每一帧都是迭代出来的无法反向求解第 3 秒应该是什么姿势。提示如果你需要的是完全可预测、可回放、每一帧都能倒推的动画MiroFish 这类迭代式模拟是错误工具。它的魅力恰恰来自不确定性。1.3 和普通粒子特效库的本质差别在哪里普通的粒子系统每个粒子的运动通常互不相关给定初始速度、重力、生命周期然后各跑各的。这种模型的复杂度是线性的一万个粒子也只是把同一段计算重复一万次GPU 轻轻松松。MiroFish 完全不是这个量级。每个个体的下一帧状态取决于它周围有哪些邻居、邻居此刻的速度和位置。计算量不再只随个体数增长还随邻居密度增长。在均匀分布下如果把感知半径固定个体翻倍等于计算量翻四倍——这就是经典的 O(n²) 陷阱。我第一次把数量从 500 调到 2000帧率直接从 60 掉到 11原因就在这里。这个差别决定了后续所有工程决策一旦你要处理的个体数量上了四位数就必须放弃遍历所有鱼的朴素写法转向空间分区。所以 MiroFish 真正的技术含量不在那三条规则它们一共不到二十行代码而在于如何让这些规则在一秒钟内被执行几百万次还不卡。后面第 3、4 章基本都在讲这件事。2. 把项目跑起来技术栈取舍与第一次渲染的坑2.1 渲染层为什么先选 Canvas 2D 而不是 WebGL这是个很典型的正确性 vs 潜力选择题。WebGL尤其配合实例化绘制在十万个体量级上能把 Canvas 2D 按在地上摩擦但它带来的复杂度也是实打实的着色器要自己写邻居查询得搬到 GPU 上用纹理或计算着色器实现调试靠猜。WebGPU 的实例化计算能力更强可浏览器支持面仍在推进中把它当默认选择会让一部分用户直接白屏。我的建议是按体量分档别一上来就上重武器个体数量级推荐渲染方式理由500 以下Canvas 2D逐条绘制代码最短方便调试每条鱼的状态500 - 5000Canvas 2D 批量变换 精灵图集减少状态切换就能撑住改动成本低5000 - 50000WebGL 实例化绘制 Worker 计算主线程扛不住必须双管齐下50000 以上WebGPU 或移步原生/离线渲染涉及显存与调度属于另一个项目MiroFish 的默认实现走的是第二档这个位置很聪明绝大多数网页动态背景教学演示的需求都落在 2000 到 3000 这个区间Canvas 2D 优化到位完全够用同时还保留了把渲染层替换成 WebGL 的接口。我个人实测同等数量下先把 Canvas 2D 的绘制调用从每条鱼一次 save/rotate/restore改成一次变换批量绘制帧率提升接近两倍比换渲染器省事多了。2.2 目录结构和每个文件的职责一个组织得比较清楚的 MiroFish 通常长这样你对照自己仓库看mirofish/ ├─ src/ │ ├─ core/ │ │ ├─ agent.js # 单个个体的数据与状态 │ │ ├─ flock.js # 群体容器负责每帧推进 │ │ ├─ rules.js # 分离/对齐/聚合三条规则的实现 │ │ └─ spatialGrid.js # 空间分区与邻居查询 │ ├─ render/ │ │ ├─ renderer2d.js # Canvas 2D 渲染后端 │ │ └─ sprites.js # 鱼形精灵图集与朝向映射 │ ├─ interact/ │ │ ├─ pointer.js # 鼠标/触摸产生的吸引力与排斥力 │ │ └─ boundary.js # 边界策略环绕、反弹、软推回 │ ├─ workers/ │ │ └─ sim.worker.js # 把计算搬离主线程 │ └─ config.js # 所有可调参数的默认值 └─ index.html这个划分的关键在于把计算和绘制彻底分开。core目录里没有任何一行绘制代码它只负责把位置和速度推进到下一帧render目录里没有任何一行物理代码它只读数据。这个边界一旦模糊后面想换渲染器或者把计算丢进 Worker 就会痛不欲生。我在早期版本里偷懒在规则计算里顺手改了颜色和透明度结果迁移 Worker 时改了两天才理干净——教训就是边界要一开始就划。2.3 启动失败时先检查的四件事第一次跑 MiroFish 报错基本都是四个原因按这个顺序查能省不少时间模块加载方式不匹配。源码用 ES Module 写的话script标签必须带typemodule本地直接双击 HTML 打开会踩到文件协议的限制起一个本地静态服务是最省事的做法比如python -m http.server 8080或npx serve。画布尺寸为 0。父容器用了flex或者没设高度时canvas 的clientWidth可能是 0渲染逻辑照常执行但什么都看不见控制台也不报错。加一行尺寸日志问题立刻现形。高清屏没处理。只设了 CSS 宽高、没设canvas.width cssWidth * devicePixelRatio结果就是画面发虚、鱼变成糊团。这个坑很隐蔽因为能跑只是难看。帧循环被重复启动。热更新或者组件重复挂载时requestAnimationFrame被注册了多次表现是速度莫名其妙变快、越跑越卡。加一个是否已在运行的标记位就能规避。// 高清屏适配注意 CSS 宽高与位图宽高是两回事 function resize(canvas, ctx) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width Math.max(1, Math.floor(rect.width * dpr)); canvas.height Math.max(1, Math.floor(rect.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按 CSS 像素坐标绘制即可 }3. 三条规则落到代码感知、转向与力的叠加3.1 邻居查询所有优化里回报最高的一步朴素实现长这样对每条鱼遍历整个数组算一次距离判断是否在感知半径内。写起来五行跑起来灾难。做个算术就直观了2000 条鱼每帧两两比较一次是 4×10⁶ 次距离计算60 帧就是一秒 2.4×10⁸ 次。JavaScript 主线程每秒能做的浮点运算是有上限的光距离计算就能把预算吃光。第一个立竿见影的改动是用距离平方比较省掉开方。我们只需要知道是否小于半径不需要真实距离所以比较dx*dx dy*dy r*r就够了。别小看这一步Math.sqrt在热点循环里省掉之后实测有百分之十几到二十的提升而且改动风险几乎为零。第二个改动才是质变空间网格spatial hash。把画布切成边长为感知半径的方格每条鱼按坐标丢进对应格子。查询邻居时只需要看自己所在格子以及相邻八个格子因为更远的格子里的鱼在几何上不可能落在感知半径内。这一步把复杂度从 O(n²) 压到接近 O(n·k)k 是单个格子内的平均个体数。同样 2000 条鱼实测从 11 帧回到了满帧。方案复杂度2000 个体实测帧率实现成本全量遍历 开方O(n²)常数大约 8 帧极低全量遍历 平方比较O(n²)常数小约 13 帧极低空间网格 平方比较约 O(n·k)稳定 60 帧中等注意格子边长不要随意取。取成感知半径的整数倍查 3×3 邻域才能保证不漏取小了会漏邻居导致鱼群行为诡异取大了等于没分区。如果分离半径和感知半径不一致按两者中的较大值来定格子边长。3.2 转向力为什么不是直接改速度新手最容易写错的地方是把三条规则的加权和直接加到速度上。这样做的后果是转向没有惯性——鱼会瞬间改向看起来像受惊的跳蚤完全没有水生生物那种延迟和拖尾感。正确做法是引入转向力这个概念把加权和算出的方向当成期望速度然后用它减去当前速度得到一个需要改变多少的向量再把这个向量截断到最大转向力以内最后才加到速度上。这里有两层限幅非常关键**最大速度maxSpeed**决定鱼能游多快限制速度向量长度。没有它对齐力会让整群鱼不断互相加速最后飞出屏幕。**最大转向力maxForce**决定鱼能多快地改变方向限制的是速度的增量。没有它鱼会在障碍物边缘瞬间甩头。这两个参数的手感差别很大。把 maxSpeed 调高、maxForce 调低得到的是高速但笨重的鱼转向半径大像金枪鱼反过来是低速但灵活的小鱼能在石缝里钻来钻去。做视觉作品时这个组合比三条规则的权重更能决定气质值得单独花时间调。3.3 边界、障碍与鼠标力的叠加顺序力的叠加有个隐含的顺序问题顺序错了会出现鱼贴着墙抖成筛子或者鼠标一靠近就炸开这类现象。我的经验顺序是先算三条群体规则再叠加障碍物排斥再叠加鼠标交互力最后处理边界然后统一截断。边界策略三种各有适用场景。**环绕wrap**是从左边出去从右边进来数学上最干净不会在边界堆积缺点是在鱼群横跨边界时会看到两个半群视觉上有点出戏。**反弹bounce**最直观但容易在角落形成死角。**软推回soft push back**是我最推荐的进入边界缓冲区后施加一个指向内侧的力越靠近墙越强鱼会自然地在边沿减速转弯整个画面像被一圈看不见的水墙兜住观感最自然。鼠标交互力我一般做成有限半径 距离衰减的形式离指针越近的鱼受到越强的作用力超出半径就完全不受影响。这样不会出现划一下全屏鱼都被吸走的廉价感。想让指针像捕食者就设成排斥想让指针像鱼食就设成吸引。有意思的是指针吸引配合较强的对齐权重会形成一条明显的鱼流非常出片。// 转向力的标准写法期望速度 → 差值 → 限幅 → 累积 function steer(current, desiredDir, maxSpeed, maxForce) { const desired { x: desiredDir.x, y: desiredDir.y }; const len Math.hypot(desired.x, desired.y) || 1; desired.x (desired.x / len) * maxSpeed; desired.y (desired.y / len) * maxSpeed; const steerVec { x: desired.x - current.vx, y: desired.y - current.vy }; const slen Math.hypot(steerVec.x, steerVec.y); if (slen maxForce) { steerVec.x (steerVec.x / slen) * maxForce; steerVec.y (steerVec.y / slen) * maxForce; } return steerVec; }4. 从 500 条到 5 万条一次完整的性能排查链路4.1 帧率断崖是怎么一步步定位的我不喜欢一上来就凭直觉猜优化点浪费的时间太多。真实的排查顺序应该是一条有据可依的链路我把当时的过程完整记下来你可以直接复用这个思路。第一步确认瓶颈在计算还是渲染。做法很粗暴把渲染调用全部注释掉只跑计算循环看帧率有没有变化。当时的结果是注释掉渲染后只从 11 帧涨到 14 帧说明主要瓶颈在计算侧优化重心立刻明确。第二步用 Performance 面板看调用栈。展开火焰图dist相关的函数占了接近六成采样。这就锁定了两两距离比较。于是先做平方比较再做空间网格两步做完从 11 帧回到 55 到 60 帧之间。第三步观察帧时间曲线是否呈锯齿状。我发现即使平均帧率上去了曲线还是有周期性尖刺这种形态几乎一定是垃圾回收造成的。排查后发现每帧都在创建临时向量对象一秒钟几百万个小对象GC 频繁触发。改法是把临时向量提升为模块级复用的对象数量固定的情况下一个都不新建。第四步检查渲染层的状态切换。剩下的小尖刺来自fillStyle和save/restore的频繁切换。改成按颜色分桶、批量绘制并用精灵图集替代路径绘制曲线才真正平了。4.2 数据布局从对象数组到类型化数组到这一步如果还想继续往上推数量级就要动数据结构了。用对象数组存鱼的状态每条鱼是一个{x, y, vx, vy, ...}可读性极好但内存不连续、字段访问要跳指针在数百万次迭代下开销很可观。替换方案是结构体数组SoA用几个Float32Array分别存 x、y、vx、vy索引 i 对应的就是第 i 条鱼。这样做的好处有三个内存连续、缓存友好可以整体转移transfer给 Worker零拷贝GC 几乎不参与因为缓冲区一次分配、反复复用。// SoA 布局四个数组共用一个索引 const N 20000; const px new Float32Array(N); const py new Float32Array(N); const vx new Float32Array(N); const vy new Float32Array(N); // 推进一帧注意用 dt 归一化避免高刷屏和低刷屏行为不一致 function integrate(dt, ax, ay) { const drag 0.99; for (let i 0; i N; i) { vx[i] (vx[i] ax[i] * dt) * drag; vy[i] (vy[i] ay[i] * dt) * drag; px[i] vx[i] * dt; py[i] vy[i] * dt; } }这里顺便说一个容易被忽略的细节所有速度、加速度都应该用每秒为单位再乘以 dt而不是写成每帧加多少。我最早就是按帧写的结果在 144Hz 的显示器上整群鱼比 60Hz 快了将近两倍用户反馈你的页面在我电脑上像快进。改成基于时间的积分之后不同刷新率下行为完全一致。4.3 把计算搬进 Worker双缓冲与数据流的坑上到五万这个量级主线程要同时干计算和绘制怎么优化都不够。这时必须把邻居查询和规则计算搬到 Worker 里主线程只负责渲染。通信有两种路子。一种是每次发送消息传数据简单但序列化开销大另一种是用SharedArrayBuffer让主线程和 Worker 共享同一块内存配合双缓冲一份正在算、一份正在画切换时只传一个索引开销几乎为零。第二种性能明显更好但有两个前提页面需要跨源隔离相关的响应头配置而且并发访问必须靠Atomics之类的手段保证不出错。我个人的取舍是只有当个体数稳定超过两万才值得上共享内存这条路。理由是它带来的部署复杂度响应头、调试难度、兼容性排查不低如果只是想做一个漂亮的动态背景把计算量压在几千这个量级、老老实实待在主线程性价比高得多。注意一旦数据跨线程所有直接改对象属性的写法都会失效必须显式同步。我第一次迁移时忘了同步指针交互的位置结果鼠标在画布上划了半天鱼群毫无反应排查了半小时才想起来 Worker 里那份坐标是旧的。5. 把它从 Demo 变成可用组件5.1 参数接口怎么设计才不别扭一个模拟项目最容易失控的地方就是参数爆炸。我的做法是把参数分成三层物理层感知半径、分离半径、最大速度、最大转向力、行为层三个权重、边界策略、指针作用力、表现层颜色、尺寸、尾迹长度、背景。物理层和行为层的变化会触发布局重算或者空间网格重建表现层则可以随时热改不影响模拟结果。这个分层的好处是运行时好判断改表现层不用重建网格改物理层必须重建。所以接口上我会把是否需要重建作为配置对象的一个内部推导结果而不是让使用者自己声明——让人去记这种琐事迟早出错。参数典型取值调大之后会发生什么感知半径40 - 80 px群体更整齐、更同步但计算量上升分离半径12 - 24 px个体间距变大群体显得松散最大速度60 - 160 px/s整体节奏加快转向半径变大最大转向力30 - 120 px/s²转向更急过高会显得神经质聚合权重0.6 - 1.4越高越容易抱团成球对齐权重0.8 - 1.8越高越容易出现长条状鱼流分离权重1.0 - 2.0越高越像均匀扩散的气体5.2 嵌入已有页面时最容易忽略的三件事把 MiroFish 当背景组件嵌进已有页面代码量不大但有几个点不注意就会影响真实体验。第一是尺寸变化。用ResizeObserver监听容器而不是只监听窗口resize因为很多布局变化不涉及窗口尺寸变化比如侧边栏折叠。画布尺寸变了要同步更新位图尺寸、变换矩阵以及重算空间网格的列数行数少一样都会出问题。第二是可见性。页面切到后台、或者画布被滚动出视口时requestAnimationFrame依然会被调用频率可能被浏览器降低但不会停止。这时候应该主动暂停模拟用visibilitychange加IntersectionObserver双保险。我见过一个页面因为没做这个处理用户开着它过夜第二天回来标签页占了将近 2GB 内存。第三是画布层的层级与事件穿透。背景画布要设pointer-events: none否则会把页面上真正的按钮点击全吃掉。如果同时又想要鼠标交互效果就在上层的透明容器上监听指针移动把坐标转换后传给模拟层而不是让画布自己去接事件。5.3 一个很实用的延展把鱼群当仪表盘纯装饰之外MiroFish 有一个我觉得被低估的用法——数据可视化。思路是让每条鱼对应一个数据点位置代表两个维度的指标速度代表变化率鱼与鱼之间的聚集程度自然反映出指标的相似性。给某些阈值区间放上吸引子或排斥子指标越界时对应的鱼会明显被推离主群异常一眼就能看出来。我拿它做过一次服务健康度的展示几十个实例的状态映射成鱼群正常时是一团缓慢流动的鱼某个实例响应时间飙升时对应的那条鱼会明显脱离队伍、在外围打转运维同事扫一眼就知道该看谁。这种先看到异常再去看数字的顺序比盯着一排折线图更符合人的直觉。6. 长时间运行的坑以及几个我反复踩到的细节6.1 数值爆炸与 NaN 的三大来源模拟类项目最怕的不是慢是跑到半夜突然整屏鱼消失——基本可以断定出现了NaN因为任何坐标一旦变成非数后续所有计算都会被污染。我统计过自己遇到的三种主要来源。最典型的是除零。计算单位向量时如果两个个体位置完全重合距离为 0归一化就炸了。写法上要么加一个极小值兜底要么在距离小于阈值时直接跳过这条邻居。第二种是参数被外部改成了非法值比如从界面读到的空字符串被转成NaN然后污染了整条链路所以配置入口一定要做校验和取值范围的收口。第三种是时间步长异常。标签页切回来时两帧之间的时间差可能是好几秒如果直接拿这个巨大的 dt 去积分速度会瞬间飙到天文数字位置直接飞出有效范围。解决第三种的标准做法是给 dt 设上限比如超过 1/30 秒就按 1/30 秒算。这在游戏引擎里是常识但在网页小项目里经常被忽略。let last performance.now(); function frame(now) { let dt (now - last) / 1000; last now; if (!Number.isFinite(dt) || dt 0) dt 1 / 60; dt Math.min(dt, 1 / 30); // 关键上限兜底 step(dt); requestAnimationFrame(frame); }6.2 首屏体积和部署这件小事MiroFish 的计算部分纯逻辑没有重依赖压缩之后通常只有几十 KB 这个量级放到任意静态托管上都毫无压力。真正值得花心思的是首屏策略不要在页面加载时就创建两万条鱼先按容器面积和devicePixelRatio折算出一个合理的初始数量等第一批帧跑顺了再按需增加。用户对打开就卡一下的容忍度比对慢慢变多的容忍度低得多。另外如果项目里用到了 Worker 或者共享内存相关的特性部署时的响应头配置、文件路径大小写、构建产物的目录层级都容易出问题。我的习惯是每次部署后先在无痕窗口里跑一遍重点看控制台有没有加载失败因为这类问题在本地开发环境里往往被缓存掩盖了。6.3 我个人最看重的三个调参习惯踩了这么多次坑我现在调 MiroFish 只保留三个习惯分享给你。第一个是先定气质再调参数。开始之前先想清楚要的是珊瑚礁里悠闲的小鱼还是被追捕的沙丁鱼群前者对应低速度、高分离、中等聚合后者对应高速度、高对齐、低分离。有了目标再动手比无头苍蝇一样来回拖滑块快十倍。第二个是一次只改一个参数并且记录。群体行为是非线性的两个参数同时动你永远说不清是哪个起的作用。我会在配置里留一行注释把每次有效的调整记下来比如对齐 1.2 → 1.5鱼流形态明显。这份记录过一个月回头看比任何文档都有用。第三个是用固定随机种子做对比。初始位置和初始速度如果有随机成分两次运行的群体形态完全不同你根本分不清差异是参数带来的还是随机性带来的。给随机数生成器固定种子让每次实验起点一致才能做出可信的对比。这个小技巧让我少走了太多弯路也是我从这个项目里带走的最实用的一条经验。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。