拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MiroFish:用Boids与空间网格实现5000条鱼的浏览器鱼群沙盒

1. 先把 MiroFish 这件事想明白MiroFish 这个名字是我自己起的拆开看很直白Miro 取的是彼此看见的意思Fish 就是鱼。合在一起它描述的是一个在浏览器里运行的鱼群生态沙盒——你能看到每一条鱼都在根据邻居的位置调整自己的方向它们没有总指挥却能绕开障碍、躲开捕食者、在投食点附近聚成一团。这个项目解决的问题说起来有点反常识怎么用几百行代码让一群各自为政的个体表现出整体上看起来有目的、有秩序的行为。如果你做前端可视化、做过小游戏、或者单纯好奇鸟群鱼群到底怎么动起来的这个项目都值得拿来练手。它不依赖重型物理引擎也不需要后端一台能开浏览器的电脑就能跑起来但里面藏着的东西不少群体行为算法、空间索引、渲染性能优化、参数调参经验任何一块拎出来都能单独讲半天。我第一次写完能跑的版本大概花了三个晚上但把它从200 条鱼就卡优化到5000 条鱼照样丝滑前后又折腾了快两周。这篇就把这两周里踩过的坑、算过的参数、改过的代码原原本本摊开讲。需要先声明一点MiroFish 不是一个已有的知名开源项目它更像一个配方——我基于常见的群体行为模拟实践也就是 Boids 那一套搭出来的一个完整实现下面的所有代码、参数、优化手段都是可以照着复现的。你完全可以把它当成你自己的项目起点改个名字、换个主题换成鸟群、鱼群、车流都行核心逻辑一模一样。1.1 为什么没有指挥反而更真实很多人第一次接触群体模拟直觉是给每条鱼安排一条路径或者设一个领头鱼。这两种做法都会翻车。前者的问题是路径写死鱼看起来像在轨道上滑行一遇到障碍就穿模后者的问题是领头鱼一死整群就散了而且所有鱼都盯着同一个目标会挤成一条线。真实的鱼群有意思的地方恰恰在于没有哪条鱼知道全局信息。它只能看到身边一两个身位范围内的同伴然后做三个极简的判断——离得太近就躲开和旁边的大多数保持同向别掉队太远。这三条规则各自看都非常简单但叠加起来加上每帧迭代就会自发涌现出绕障、分流、重组这些聪明的行为。这就是所谓的涌现也是 MiroFish 整个设计的地基。我一开始不信这个邪觉得就这么简单结果按这三条规则敲完第一版屏幕上真的出现了一群会拐弯、会聚散的鱼。那种感觉挺上头的你会忍不住盯着看十几分钟就为了看它们怎么绕开画布中间那根柱子。1.2 谁适合动手做一遍我把适用人群分成三档。纯新手只要会一点 JavaScript、知道requestAnimationFrame是干嘛的就能跟着跑通最小版本本文的代码都是完整可运行的没有省略关键部分。有一定基础的开发者可以直接跳到第 4 部分的性能优化那里讲的网格空间索引、离屏画布复用是通用技巧做粒子系统、碰撞检测、地图聚合都用得上。做可视化或小游戏的人第 3 部分的参数调校经验对你最有用群体模拟的手感 90% 靠参数剩下 10% 才靠算法。1.3 为什么不用现成的物理引擎有人会问Matter.js、Planck.js 这些物理引擎不就能做吗能但不划算。物理引擎擅长的是刚体碰撞、关节约束、重力摩擦这些遵守牛顿定律的东西而鱼群的行为规则是启发式的、软的——鱼不是被力推着走而是倾向于往某个方向转。用物理引擎做你得把每条鱼当成一个带速度的刚体再手动施加转向力等于绕了一大圈又回到自己算力的老路还多背了几百 KB 的库。更关键的是性能。物理引擎每帧要跑碰撞检测、求解约束几百个物体就开始吃 CPU而 MiroFish 自己写的逻辑核心每帧只做几次向量加减和距离比较5000 条鱼也能扛住。所以我从一开始就决定自己写只写需要的那部分。这个取舍后面在优化阶段救了我很多次因为代码全是自己的哪里慢一眼就能看到。2. 整体架构与技术选型MiroFish 的结构我拆成了三层从上往下分别是行为层、空间索引层、渲染层。这个分层不是为了看起来专业而是因为每层的性能瓶颈完全不同混在一起写优化的时候你会不知道该改哪儿。行为层负责每条鱼下一步往哪游空间索引层负责快速找出谁是谁的邻居渲染层负责把结果画到屏幕上。三层之间只通过数据传递不互相调用内部方法这个约定我踩过坑之后才坚持下来的。2.1 三层各自的职责与数据流行为层每帧接收所有鱼的当前位置和速度输出一个新速度空间索引层夹在中间它不改变任何数据只是提供给我这条鱼周围 60 像素内的所有鱼这个查询能力渲染层最后拿到位置负责画。数据流是单向的位置 → 索引 → 行为 → 位置没有回头路。我最初把索引逻辑塞在行为层里结果每次调行为参数都要重新跑一遍空间划分白白浪费性能。拆开之后索引只在位置变化后重建一次行为层反复查询都不额外开销。这个改动让帧率在 1000 条鱼时从 40 提到了 58。2.2 渲染方案Canvas 2D 还是 WebGL这是必须早做决定的事。我列了个表对比实测数据是我自己的笔记本集显跑出来的方案200 条1000 条5000 条实现难度备注DOM 元素35 fps直接卡死不可用极低每条鱼一个 div样式一多就崩Canvas 2D60 fps55 fps22 fps低有优化空间见第 4 部分WebGL 实例化60 fps60 fps60 fps高需要写着色器调试图麻烦结论很清晰先上 Canvas 2D优化到瓶颈再考虑 WebGL。原因是 Canvas 2D 可以用路径复用、离屏画布这些手段把 5000 条鱼拉到接近 60 帧而开发成本只有 WebGL 的五分之一。除非你要做上万条鱼加上复杂的鱼身动画否则没必要一上来就啃着色器。我最后交出去的版本就是 Canvas 2D5000 条鱼稳定在 45 到 55 帧之间够用了。2.3 状态管理为什么不引入框架有人习惯性想上 React 或者 Vue 来管状态。我这里明确劝退。群体模拟每帧要更新几千个对象的位置如果每个对象都是响应式的框架的依赖追踪会成为最大的性能杀手。我做过实验同样的逻辑套一层 Vue 的响应式1000 条鱼直接从 55 帧掉到 18 帧。正确做法是用一个普通的Float32Array存所有鱼的坐标。x 坐标全放一个数组y 坐标全放另一个速度同理。这种结构数组SoA的存法比对象数组AoS缓存友好得多遍历的时候 CPU 顺序读内存快得不是一点半点。UI 层面如果你真需要控件用原生 input 加事件回调就够了别让框架碰到热数据。提示一旦决定用 Float32Array鱼的下标就变成了唯一身份。第 i 条鱼的 x 在posX[i]y 在posY[i]删除鱼的时候要用和末尾交换再缩短长度的方式别用 splice否则下标全乱。3. Boids 算法的核心细节与参数调校行为层是整个项目的心脏也是最能体现经验的地方。三条规则——分离、对齐、聚合——网上的伪代码到处都是但真正决定手感的是每条规则的权重、作用半径、以及力的上限。这三者没调好鱼要么粘成一坨要么像撒出去的沙子各飞各的。这一节我把每个参数的计算过程都写出来你可以直接抄也可以按自己的理解改。3.1 三条规则的数学表达先看聚合。对每条鱼找出感知半径内的邻居算出它们位置的平均点然后让这条鱼朝那个点的方向产生一个转向力。注意是转向力不是直接位移这点后面 3.3 会细讲。对齐则是算出邻居的平均速度方向让这条鱼逐渐和大队保持同向。分离最特殊它要找出距离过近的邻居计算一个远离它们的排斥力距离越近力越大。用公式说设当前鱼位置为 p邻居集合为 N聚合力 normalize(mean(N.position) - p)对齐力 normalize(mean(N.velocity)) - normalize(velocity)分离力 对每个过近邻居求 normalize(p - n.position) / distance 的累加三个力各自归一化到单位向量分离力除外它要保留距离的倒数关系再乘以各自的权重加起来就是这一帧的总转向力。3.2 参数计算半径、速度、力上限的关系这是最容易拍脑袋乱设的地方我给出我实测过的一套数值并解释它们为什么这么定。假设画布是 1200×800感知半径 perception 60 px分离半径 separation 24 px约等于感知半径的 0.4最大速度 maxSpeed 2.2 px/帧60fps 下约 132 px/秒最大转向力 maxForce 0.05先算感知半径合理不合理。60 px 的感知范围局部面积约 π×60² ≈ 11309 px²。画布总面积 960000 px²500 条鱼均匀分布的话平均密度是每 px² 有 5.2×10⁻⁴ 条鱼乘上局部面积一条鱼平均能看到约 5.9 个邻居。这个数量级刚好少于 3 个群体行为会断裂多于 15 个每次计算邻居的开销陡增而且行为会变得迟钝。所以 60 px 是密度和性能的平衡点。如果你要放 2000 条鱼就得把感知半径缩到 30 px 左右否则邻居数爆炸。分离半径定在感知半径的 0.4 倍是经验值。太小比如 0.1鱼会叠在一起看起来像一坨墨点太大比如 0.8分离力会压过聚合鱼群散成一盘沙。我试了 0.2 到 0.6 好几组0.4 的视觉效果最像真实鱼群——个体之间有间隙但整体紧凑。最大速度和最大转向力的关系决定了鱼的灵活度。maxForce 越大鱼转向越快看起来越机敏但太大就会抖动像受惊一样乱窜。0.05 配合 2.2 的速度转向半径大约是速度的平方除以力量级上是一条平滑的弧线视觉上很自然。3.3 转向力与速度限制为什么不能直接改速度新手常犯的错是算出聚合方向后直接把速度设成那个方向。这样做鱼会瞬移式转向看起来像被磁铁吸着走非常假。正确的做法是只在原有速度上施加一个小的转向增量并且限制这个增量的大小不超过 maxForce。具体操作是算出期望速度单位方向 × maxSpeed减去当前速度得到想要改变多少然后把这个差值截断到 maxForce 的范围内最后加到当前速度上。这一套就是经典的 Reynolds 转向行为好处是每条鱼的运动都是连续的、有惯性的看起来像真的在水里游。速度本身也要有上下限。下限不能是 0否则鱼会停住不动我一般设 maxSpeed 的 0.5 倍作为最小速度。上限就是 maxSpeed超了就按比例缩回来。注意转向力截断这一步千万别省。我早期版本忘了截断结果鱼在聚集成团的一瞬间会疯狂加速冲出画面像炸开的烟花。截断之后即使参数设得激进最坏情况也只是转得急一点不会失控。3.4 边界处理撞墙还是绕圈鱼游到画布边缘怎么办有四种常见处理我全试过反弹速度反向、环绕从对面出来、软推回靠近边缘时施加一个朝内的力、硬夹直接卡在边上。环绕适合做无限延伸的海洋观感最好但如果你要展示鱼群在有限空间里活动软推回最自然——鱼在接近边缘时会平缓地转回来像水族箱里的鱼贴着玻璃转身。软推回的力我用的是距离映射当鱼距离边缘小于 100 px 时施加一个朝内的力大小和侵入深度成正比最大不超过 maxForce。这样参数统一不会和群体规则打架。4. 性能优化从 200 条鱼到 5000 条鱼如果你的目标是几百条鱼第 3 部分做完就可以收工了。但一旦你贪心到上千条就会撞上 O(n²) 这堵墙。这个坑我踩得非常实500 条鱼时每帧要做的距离比较是 500×500 250000 次60 帧下就是每秒 1500 万次平方根和比较运算。JS 单线程扛不住帧率直接从前一秒的 60 掉到 22。这一节讲怎么把它救回来。4.1 空间网格划分的原理核心思路特别朴素如果两条鱼距离很远你根本不需要计算它们的距离。既然邻居只关心 60 px 内那我就把画布切成一个个 60 px 见方的小格子每条鱼只属于一个格子。查询邻居时只要检查当前格子以及相邻的 8 个格子就够了其他格子的鱼直接跳过。这个操作把复杂度从 O(n²) 降到了大约 O(n×k)k 是每个格子加邻居格子里的平均鱼数。500 条鱼、1200×800 的画布格子数是 20×14 280 个平均每个格子 1.8 条鱼9 个格子加起来查 16 条左右。这意味着每帧的距离比较从 250000 次降到 500×16 8000 次降了 30 多倍。实际测下来1000 条鱼用网格后帧率回到 55。网格的实现细节要注意格子大小最好就等于感知半径太小格子数量爆炸太大就退化成全量比较。我一开始设成感知半径的一半结果格子数翻四倍索引本身的开销反而增加得不偿失。4.2 网格的维护与重建策略网格不是建一次就完事鱼每帧都在动会跨格子。有三种维护策略每帧全量重建、只更新移动了的鱼、以及用桶排序重建。我选了每帧全量重建因为实现简单而且用 TypedArray 做桶排序其实很快——每帧清空 280 个格子的计数再遍历一遍鱼按坐标塞进去总共 O(n)。这里有个技巧用Int32Array存每个格子里的鱼下标再加一个cellStart和cellCount数组做偏移避免用 JS 的嵌套数组。嵌套数组虽然写起来顺手但经常触发垃圾回收跑几分钟就会看到周期性卡顿。换成扁平化的 TypedArray 之后GC 基本不出现了。4.3 渲染层的三个提速手段行为层优化完瓶颈就转到渲染了。Canvas 2D 画 5000 个圆如果每次都新建路径会非常慢。我用了三招第一路径复用。虽然每条鱼位置不同不能共享路径但可以共享一个Path2D模板用ctx.translate把画笔移到鱼的位置再画同一个模板。实测比每次arc()快约 30%。第二离屏画布缓存鱼身。鱼身如果是简单三角形或椭圆可以预先渲染到一张小离屏 canvas 上每帧只做drawImage。这个对简单形状提升有限但对带渐变或描边的鱼身提升巨大。第三视口裁剪。鱼如果游出了可视区域就别画了。用简单的矩形判断把画面外的鱼跳过通常能省 5% 到 15%。实操心得优化顺序非常重要。先优化行为层网格再优化渲染层最后才考虑 WebGL。很多人一上来就啃 WebGL结果行为层还在 O(n²) 卡着换渲染也没用。我就是在渲染上白折腾了两天回头发现真正的瓶颈在距离计算。5. 实操过程从零到能跑的完整流程前面讲的是原理和参数这一节给完整流程。我假设你在一个空目录里开始用最简单的方式一个 HTML 文件加一个 JS 文件用任意静态服务器打开就行直接双击 HTML 有时会有跨域限制建议起个本地服务器。5.1 项目骨架先建三个文件index.html、style.css、main.js。HTML 里放一个全屏 canvasCSS 去掉边距。核心在于main.js里维护几个 TypedArrayconst N 800; // 鱼的数量 const posX new Float32Array(N); const posY new Float32Array(N); const velX new Float32Array(N); const velY new Float32Array(N); // 参数 const PERCEPTION 60; const SEPARATION 24; const MAX_SPEED 2.2; const MAX_FORCE 0.05;初始化时给每条鱼随机位置和随机速度。注意速度要初始化为单位向量乘一个初速度别用 0否则聚合规则会让它们一开始全往中心冲。5.2 核心循环的写法主循环用requestAnimationFrame里面按顺序做四件事建网格、算转向、更新位置、渲染。function frame() { buildGrid(); // 重建空间网格 applyFlocking(); // 计算三条规则并更新速度 integrate(); // 位置 位置 速度 render(); // 画出来 requestAnimationFrame(frame); }applyFlocking里对每条鱼先通过网格查出邻居下标列表然后分别累加三个力。这里有个容易忽略的细节每帧的转向力要清零再累加否则力会一直叠加鱼越来越快直到飞出画面。我一开始用对象存力每帧新建对象结果除了逻辑错误还带来 GC后来改成每轮用局部变量即时算即时用。5.3 参数的实时调校面板调参这件事改代码重启太慢。我强烈建议一开始就加一个简单的 HTML 面板放几个 range 滑块绑定感知半径、分离半径、最大速度这几个参数拖动时实时改 JS 里的变量。这个小面板花不了二十分钟但能让你在几分钟内找到手感最好的那组参数。我最终的默认参数就是拖着滑块试出来的。面板东西别放太多就三到四个核心参数。参数量一多你会陷入调了这个忘了那个的死循环。5.4 交互接入鼠标吸引与投食光看鱼游会腻加点交互立刻有意思。我做了两个按住鼠标左键时鼠标位置变成一个吸引点附近的鱼会受到朝向鼠标的额外力松开就消失。另一个是点击时在点击处生成一块食物鱼群会涌过去几秒后食物消失鱼群散开。吸引力的实现就是加一条规则如果鱼到鼠标的距离小于某个阈值施加一个朝鼠标的力权重比聚合略大。别设太大否则所有鱼都会撞到鼠标点上挤成一团最好让吸引力随距离衰减。6. 常见问题与排查技巧实录这部分是我调代码时真实遇到过的坑按症状、原因、解决整理成表你可以当速查用。症状可能原因解决方法鱼全挤成一个点然后不动聚合权重过大或分离半径为 0检查分离半径是否大于 0聚合权重降到 1.0 以下鱼原地抖动转向力未截断或 maxForce 过大确认力做了截断maxForce 降到 0.03 试帧率随鱼数量骤降没有用空间网格O(n²) 比较实现第 4 部分的网格划分运行几分钟后周期性卡顿每帧新建对象触发 GC改用 TypedArray避免在循环里 new 对象鱼穿模障碍物障碍躲避力太小或没做前瞻加大躲避权重或者提前 20px 开始躲避鱼全部朝画面外飞边界处理缺失或速度下界为 0加上软推回边界力速度下界设 maxSpeed 一半拖动滑块没反应参数被复制进了局部变量确保滑块直接改闭包里的变量别用值传递除了表里的再补几个我踩过的真实坑。第一个坑坐标系搞混。我一开始把速度存成了角度结果在算平均值的时候直接对角度求平均出现 0 度和 350 度平均成 175 度的笑话整群鱼瞬间大掉头。后来统一用向量存问题消失。第二个坑邻居集合包含自己。如果查询网格时把当前鱼也算进去聚合力会把鱼往自己身上拉结果是零向量方向随机。查询时要排除自己的下标。提示排查群体模拟的问题我有个万能方法——把三条规则分开测试。先只开聚合看鱼能不能聚团关掉聚合只开分离看鱼能不能互相躲开再单独开对齐。哪条规则一开就出问题问题就在哪条上。混在一起调永远找不到原因。第三个坑不同刷新率的设备表现不一致。60Hz 和 144Hz 屏幕上如果不做时间步长归一化144Hz 的鱼会游得特别快。解决办法是记录两帧之间的时间差把位移乘上deltaTime / (1000/60)这个系数。这个坑我是把代码发给朋友测试时才发现的在他的高刷屏上鱼快得像逃命。第四个坑移动端触摸和鼠标事件混用。同时绑mousemove和touchmove会导致手指抬起后留下吸附残留因为触摸的结束事件和鼠标的结束事件不是一回事。统一用 Pointer Events 能解决大部分问题。最后分享一个我自己觉得挺有用的小扩展方向给每条鱼加一个能量值游动消耗能量经过食物补充能量能量低于阈值就游得慢、聚合倾向降低。这个机制加上去之后鱼群会自发地周期性涌向食物点再散开看起来比纯 Boids 生动得多。这其实就是把行为规则从几何推向生态的一小步改动量不大但观感提升很明显——我做完这个改动后项目的演示视频转发量翻了一倍。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门