
把几百条鱼放进浏览器里让它们自己决定往哪游这件事我前后折腾了小半年。MiroFish 这个项目最初只是我想给个人主页做一个能一直跑在角落里的动态背景画一条鱼用正弦曲线就够了但一旦把数量拉到几十条以上每条鱼都开始参考邻居的位置做决定整个画面立刻变成另外一个量级的问题它不是一段可以提前烘焙好的动画而是一个每帧都在重新求解的耦合系统。每条鱼的行为都会影响邻居邻居又反过来影响它任何一处参数改错画面要么像标本一样僵住要么像爆炸一样散开。我把这段时间里关于群体行为算法、渲染性能、参数调优和交互手感的东西整理一下适合想做一个动态视觉小项目、或者第一次接触群体行为模拟的朋友参考。不要求你会图形学但至少得能看懂 for 循环和向量加减剩下的都可以边做边补。1. MiroFish 的定位为什么一群鱼和一条鱼是两个问题1.1 单鱼动画与群体行为的本质差别单条鱼的游动是一条曲线加一段摆动。你可以用一条路径动画让它在屏幕里沿着贝塞尔曲线走尾巴用sin(t * f)左右摆基本就是能看的水平。这种做法我在最早的版本里用了一下午就写完了甚至不需要任何物理概念只要把t喂给路径采样函数就行。问题出在数量上。当屏幕里有三十条鱼你不可能为每一条手写一条路径——就算写了它们之间也没有任何关系看起来就像三十个互不认识的独立动画叠在一起观感非常假。真实的鱼群有一种这东西是一整块的感觉转弯时边缘的鱼会滞后中间密集、外围稀疏遇到刺激会像涟漪一样扩散开。这种整体感不是设计出来的而是从个体之间的局部规则里长出来的这就是 MiroFish 里最核心的那部分逻辑。我把这叫作涌现。它的麻烦之处在于你没法直接控制结果。你想要的是鱼群优雅地绕开石头而你能改的只是每条鱼感受到的排斥力系数中间隔着一层非线性的相互作用。所以做这类项目心态上要从我在画动画切换到我在设计一套规则然后观察规则长成什么样。这个心态转变很关键我见过不少人卡在这里反复调参数却始终觉得画面不对劲本质上是因为他们还在用做动画的思路去调一个动力学系统。1.2 三种用法对应的不同取舍同样是鱼群做背景装饰和做算法演示技术选择差得很远。我在做 MiroFish 的时候一开始想让它同时满足所有场景结果是每个场景都做得不够好。后来我按用途拆成三类反而清楚了很多。用途合理数量级帧率底线是否需要确定性关键诉求页面背景装饰80 到 300 条稳定 60 帧不需要低功耗、不抢注意力演示/展示页300 到 800 条稳定 50 帧以上最好可复现视觉冲击、可交互行为算法实验500 到 3000 条能跑就行必须可复现参数可控、可导出数据做背景装饰的时候最重要的其实是别让风扇转起来。浏览器里的一个持续运行的动画如果一直占着 30% 以上的 CPU用户很快会关掉它。所以我给背景模式加了两个开关页面不可见时直接暂停鼠标不在区域内时降低更新频率。这两条加进去之后同一个页面在笔记本上的续航表现差别非常明显。演示模式反过来要的是一眼看上去很厉害这时候牺牲一点确定性完全可以接受允许用随机数、允许位置有微小抖动甚至可以让鱼的个体之间有肉眼可见的差异看起来更生动。而做算法实验的时候随机种子必须固定同一个种子跑出来必须是同一条轨迹不然你改了参数之后根本分不清是自己的改动生效了还是随机波动。2. Boids 三条规则的拆解为什么必须分开算再合并2.1 分离、对齐、聚集的向量化实现群体行为这块最经典的框架是三条局部规则MiroFish 用的就是这一套分离不要撞上邻居、对齐和邻居的朝向保持一致、聚集往邻居的中心靠。这三条规则的妙处在于它们都只用邻居信息每条鱼不需要知道全局状况也不需要有个导演来指挥。写成向量代码大概是这样function steer(boid, neighbors) { const sep { x: 0, y: 0 }; const ali { x: 0, y: 0 }; const coh { x: 0, y: 0 }; let nSep 0, nAli 0, nCoh 0; for (const other of neighbors) { const dx boid.x - other.x; const dy boid.y - other.y; const d2 dx * dx dy * dy; if (d2 0) continue; // 重合时跳过见 5.2 if (d2 R_SEP * R_SEP) { // 距离越近排斥越强除以 d2 是一种常见做法 sep.x dx / d2; sep.y dy / d2; nSep; } if (d2 R_ALI * R_ALI) { ali.x other.vx; ali.y other.vy; nAli; } if (d2 R_COH * R_COH) { coh.x other.x; coh.y other.y; nCoh; } } const a { x: 0, y: 0 }; if (nSep) { normalize(sep); a.x sep.x * W_SEP; a.y sep.y * W_SEP; } if (nAli) { normalize(ali); // 关键先归一化再乘权重 a.x ali.x * W_ALI; a.y ali.y * W_ALI; } if (nCoh) { coh.x coh.x / nCoh - boid.x; coh.y coh.y / nCoh - boid.y; normalize(coh); a.x coh.x * W_COH; a.y coh.y * W_COH; } return a; }这段代码里有两个地方特别容易写错我第一次写的时候两个都踩了。第一个是对齐规则必须先归一化再乘权重。如果你直接把邻居的速度加起来ali累加的是速度而不是方向那么邻居越多这个累加值就越大右边的鱼群密度稍高一点对齐力就会被放大好几倍鱼会像被磁铁吸住一样猛甩。正确做法是先把累加值归一化成单位向量只保留方向信息再乘权重。聚集规则也是同理先算平均位置、再减去自身位置、再归一化。第二个是分离规则里我用的是1 / d2而不是1 / d。除以距离的平方意味着距离减半排斥力变成四倍近处的排斥会非常陡峭。实测下来这个陡峭程度是合适的否则鱼会贴在一起叠罗汉如果换成1 / d你需要把分离权重调得很大才能达到同样的效果而权重一大远处的拉力又会被影响参数就变得很难调。这里不是说1 / d不行而是它的可调空间比较窄——如果你的画面里鱼群总是糊成一团可以先检查一下这里用的是几次方。2.2 感知半径决定了权重能不能调得动三个权重W_SEP、W_ALI、W_COH是最容易被拿来乱调的但真正决定手感的其实是三个感知半径。我的经验是先把半径定死再调权重。原因很简单半径决定了每条鱼能看见多少个邻居。半径一大邻居数量从平均 3 个变成 30 个就算权重完全不变实际施加的力也会大一个数量级这时候你去调权重等于在做无用功。我的起步配置是这样坐标单位是像素速度单位是像素每秒参数含义起步值调整方向R_SEP分离感知半径22 px鱼群糊成一团就加大R_ALI对齐感知半径48 px队形散乱就加大R_COH聚集感知半径60 px整体过于松散就加大W_SEP分离权重1.6抖动/规避太生硬就减小W_ALI对齐权重1.0队形乱就加大W_COH聚集权重0.8整体散开就加大MAX_SPEED最大速度90 px/s视觉节奏的快慢MAX_FORCE最大转向加速度140 px/s²转弯是否急这张表最需要注意的是最后一行的量纲。因为我把速度定义成了像素每秒加速度就是像素每平方秒转向力的上限必须和这两个量纲对得上否则会出现很诡异的现象力很大但速度上限很小鱼会像贴在墙上的磁铁一样疯狂抖动力很小但速度上限很大鱼会笔直冲出去撞墙。我一般会保证MAX_FORCE大约是MAX_SPEED的 1.5 到 2 倍这个比例调出来的手感最像鱼。另外还有一个隐含参数每条鱼的最大转向角速度。它和最大加速度不一样后者管的是力有多大前者管的是每帧最多转多少度。在鱼身上限制转向角速度比限制力更有效因为鱼是身体转向而不是横向平移物理上也更合理。我给的单帧转向上限大概在每秒 4 到 6 弧度之间太小会让鱼像轮船一样转大弯太大又会显得神经质。2.3 边界处理三种做法的实际观感差异边界这块我先后试了三种方案结论是软推力最像鱼但成本也最高。第一种是硬反弹碰到边界就把速度分量反向。实现最简单但观感最差因为鱼会在边界附近来回弹形成一条密度很高的墙边带一看就知道是程序画的。第二种是环形绕回从右边出去的鱼从左边进来。它的问题不是实现难而是接缝——如果你不额外在边界外绘制一份镜像副本鱼会在边缘凭空出现和消失就算画了镜像相邻两屏之间的关联也会让整个群体在竖直方向拉出一条明显的缝。第三种是软推力在距离边界一定范围内施加一个指向内部的力越靠近边界力越大同时配合一个速度衰减。这套做法的好处是鱼群会自然地绕着边界走转弯发生在离边界还有一段距离的地方看起来就像鱼知道前面是缸壁。代价是你要留出足够的边界留白不然力还没起作用鱼就冲出去了。我最后用的是软推力加一个兜底的位置钳制正常运行时钳制永远不触发只在极端参数下防止鱼跑丢。3. 渲染层能扛多少条鱼Canvas 2D 的三次压测与迁移时机3.1 分组压测数据与瓶颈定位MiroFish 最早是用 Canvas 2D 实现的每条鱼一次drawImage从精灵图集里取一帧。这个方案写起来极快但数量一上去就顶不住。我在一台几年前的轻薄本上做了三组对照每次持续 10 秒取平均帧率画布分辨率固定在 1600×900物理更新和渲染分开计时鱼的数量Canvas 2D 帧率物理部分耗时占比渲染部分耗时占比150 条58 到 60 帧约 15%约 25%500 条40 到 46 帧约 35%约 45%1200 条20 帧左右约 45%约 50%这张表里最有价值的不是帧率数字本身而是物理和渲染大概从什么时候开始互相拖累。150 条的时候两者都很轻松到 500 条物理已经开始占三分之一到 1200 条两边都不够用了。这说明一个很容易被忽略的事迁移渲染方案只解决一半问题邻居查询的复杂度如果还是 O(n²)就算渲染做到零成本整体也顶不住。测量的方法我建议用performance.now()分别在物理更新前后和渲染前后打点把两段耗时存到一个环形缓冲区里界面上画一条简单的曲线。不要只看总帧率总帧率只能告诉你慢了不能告诉你哪里慢。这个打点工具我写了大概四十行代码但它省掉的时间远不止四十行。3.2 从 Canvas 2D 迁到 WebGL 到底要改哪几处迁移的时候我以为要重写整个项目实际上真正需要改的只有三处。第一处是状态存储。Canvas 2D 版本里每条鱼是一个对象属性散落各处。迁到 WebGL 之后我把所有鱼的状态拍平成几个Float32Array位置一个、速度一个、朝向和缩放各一个。拍平的好处是上传到显卡的时候一次bufferSubData就搞定不用遍历对象拼数组。这里有个细节坑Float32Array一旦分配就不能动态扩容所以要么一开始按最大数量分配要么用两倍扩容的策略我选的是后者因为背景模式下鱼的数量是可以在界面上调的。第二处是绘制调用。Canvas 2D 版本是每条鱼一次save/translate/rotate/restore这是最慢的部分因为每次状态切换都有开销。WebGL 版本改成一次绘制调用画完所有鱼靠顶点着色器传入每条鱼的位置和朝向。我一开始想上实例化渲染后来发现这个场景里鱼的图集帧数很少直接在一张大纹理上做批处理反而更简单。第三处是朝向计算。2D 版本里我用Math.atan2求角度然后旋转画布搬到着色器之后不能再用这个思路得改成一个二维旋转矩阵或者干脆把朝向编码成一个单位向量传进去在着色器里做基向量变换。这一步是迁移里唯一需要动脑子的地方但也就半天的事。3.3 精灵图集与绘制顺序里的隐形成本有一个性能问题困扰了我挺久明明已经批处理了帧率还是上不去。后来发现是图集切换。我的鱼有不同的颜色和大小一开始给每种颜色单独做了一张纹理结果渲染时在几张纹理之间反复切换驱动层的状态切换把好处全吃掉了。改成一张大图集、用纹理坐标偏移取帧之后同样的画面直接快了将近四成。还有绘制顺序。鱼的遮挡关系如果按数组顺序画会出现后面的鱼盖住前面的鱼这种轻微违和感但如果你按y坐标排序再画每帧多一次排序的开销。1200 条鱼的排序在 JavaScript 里大概是零点几毫秒到一两毫秒看实现方式。我的建议是如果鱼的数量在 500 以内排序完全值得超过 1000 条可以考虑把整个画布按纵坐标分几条带带内不排序、带间按顺序画能省下大部分排序成本视觉上的违和感也基本看不出来。4. 手感是怎么调出来的交互、抖动与像活物的细节4.1 把光标当作一个会呼吸的障碍物MiroFish 的交互只有一条鼠标或者手指进入画布之后鱼群会避开它。听起来简单但直接让鱼躲开光标位置的效果非常生硬因为鼠标在屏幕上移动是跳跃式的一帧之内可能移动几十上百像素鱼来不及反应看起来像是被瞬移的东西吓到。我的做法是给光标加一层平滑跟随维护一个虚拟光标位置每帧用插值往真实光标位置靠时间常数大概 0.12 秒。这样鱼面对的是一个有质量的、会呼吸的障碍物避开的过程就自然多了。同时排斥半径不是固定的而是随光标的移动速度轻微放大——鼠标快速划过的时候鱼的躲避范围会大一些像被水波推开的鱼群。还有一个反直觉的点排斥力不应该太强。我一开始把排斥力调得很猛结果是鱼被推开之后立刻又被聚集力拉回来形成一圈一直在抖的甜甜圈。后来把排斥力降到刚好能让鱼绕开同时给被排斥的鱼一个短暂的惊慌状态——接下来半秒内它的聚集权重降低、速度上限提高鱼群就会先散开再慢慢聚拢。这个短暂的惊慌状态是让交互变得高级的关键成本只有几行代码。4.2 抖动几乎都来自转向没有限幅如果你做出来的鱼群一直在小幅高频抖动那基本可以确定是转向没有限幅。没有限幅的情况下期望速度和当前速度之间的差可以直接变成很大的加速度下一帧速度冲过头再下一帧又冲回来就是典型的振荡。正确的做法是分两步先算出期望速度也就是当前朝向乘以最大速度然后算一个转向向量放在一个环形缓冲区里做平均让力有几个帧的记忆再钳制这个平均之后的力。这个力的平均不是玄学它模拟的是水的阻尼和鱼自身的惯性——真实的水生动物不会在一帧之内把方向改过来。调整顺序我建议这样先只开分离规则把鱼调成互相不重叠的状态然后加对齐调到队形大致一致最后加聚集让它们收成团。每加一条规则都重新看一遍帧率如果某条规则加上去帧率明显掉了那说明邻居查询的范围或者数据结构有问题先别急着调权重。4.3 让鱼群不像阅兵的三个小改动群体行为算法有一个很常见的审美问题调得好的时候鱼群会整齐得像个方阵看起来不像生物。我用了三个小改动来解决。第一个是个体差异。每条鱼的最大速度在上浮下调百分之十最大转向加速度也有差异摆尾频率更是每条鱼一个随机相位。这三处差异加起来就能把方阵拆散成有层次的群体。第二个是转弯时速度衰减。鱼在急转弯的时候会降速这个可以通过速度方向变化越大速度上限越低来实现。转弯降速会让整个群体的动作有一个重心看起来是有质量的。第三个是尾摆与速度绑定。尾巴的摆动频率和前进速度挂钩游得快摆得快停下时几乎不摆。这一条对观感的提升比我想象中大得多静止的尾巴会让画面立刻变成静止图片上有位移而真实感几乎全部来自尾巴那一小块的运动。5. 踩坑复盘邻居查询、NaN 扩散与帧率依赖5.1 从 O(n²) 到空间哈希网格最开始的邻居查询是双层循环每条鱼遍历所有其他鱼。300 条的时候没什么感觉1200 条的时候每帧要算 144 万次距离比较物理耗时直接从几毫秒涨到二十多毫秒。改成空间哈希网格之后同样 1200 条鱼物理耗时降到了原来的三分之一左右。网格的实现思路很简单把画布划分成边长等于最大感知半径的方格每条鱼放进它所在格子的桶里查询邻居时只检查自己所在格和周围八格。这里有个坑如果格子的边长小于感知半径你就要检查两圈邻居性能会明显变差。所以格子边长要取所有感知半径里的最大值这样一圈就够了。另一个坑是每帧重建网格的开销。用数组套数组去建桶会在每帧产生大量临时对象垃圾回收的停顿会让帧率出现周期性的卡顿。我最后的做法是预先分配两个定长数组用一个计数数组 偏移数组实现紧凑的桶结构桶里存的是鱼的索引而不是对象。这套东西写起来大概七八十行是整个项目里我最推荐认真写的一块。5.2 一次 NaN 让整缸鱼在 0.2 秒内消失这个 bug 我调了很久值得单独说。现象是鱼群跑着跑着某一帧开始整缸鱼全部黏在一个点上然后画面空了。加日志之后发现某个鱼的位置变成了NaN。原因在分离规则里。当两条鱼的位置完全重合时dx和dy都是 0距离也是 0然后做归一化就会除以 0得到NaN。只要有一条鱼变成NaN它接下来会传播其他鱼计算邻居中心时把NaN加进去整个群体在极短时间里全部污染。修复其实很简单两个位置一是距离平方为 0 时直接跳过这个邻居二是在所有归一化函数里加一个下限比如长度小于1e-6时直接返回零向量而不是做除法。我第二个改法是在归一化函数内部统一加的这样以后任何地方调用都不会出问题。这件事之后我加了一个开发模式下的断言每帧结束后遍历一遍位置数组发现非有限数就立刻暂停并打印是哪条鱼、哪一帧出的问题。这个断言只在开发环境启用但它帮我抓到了后面好几个同类问题。5.3 固定时间步为什么比 deltaTime 更稳一开始我用的是deltaTime也就是每帧把物理更新量乘以这一帧的实际耗时。在帧率稳定的时候没问题但只要帧率一波动就会出现诡异的现象某一帧特别长鱼的转向就特别大一下子甩出去把整个群体的结构打断。改成固定时间步之后问题就消失了。做法是维护一个累加器每帧把实际耗时加进去只要累加值超过固定步长我用的 1/60 秒就执行一次物理更新直到累加值不足为止。这样做的好处是物理的表现和帧率完全解耦30 帧和 120 帧屏幕上看到的轨迹是同一条。代价是极端情况下需要限制单帧内的最大更新次数比如最多 3 次避免卡顿之后出现追帧雪崩。这个改动对调试的价值更大。参数调好之后我可以把这个配置连同随机种子一起存下来别人打开同一个链接看到的是完全相同的鱼群轨迹这在分享和对比的时候特别有用。6. 把调参结果变成可复现的东西6.1 参数快照与链接序列化调参最痛苦的事情是你调出了一个很满意的状态然后手滑改了两个数字再也回不去了。我给 MiroFish 加了一个参数快照功能本质就是把当前所有参数序列化成一个紧凑的字符串然后拼到 URL 的 hash 里。序列化的方式我比较粗暴把所有参数按固定顺序排好取小数点后三位用短横线连起来。这样一条链接大概一百多个字符既不会长到影响分享又包含了全部信息。为了让链接可读我把参数名和值成对拼进去而不是纯数字串——多几十个字符但出问题的时候一眼能看出是哪个参数不对劲。还有一个细节是随机数种子也要进链接。因为个体差异是用伪随机数生成的种子不同同样的参数也会长出不同的鱼群。种子进链接之后这条链接就成了一个真正意义上的可复现实验。6.2 一次完整的调参记录从四散到成群我把最近一次重新调参的过程记下来供参考。初始状态是鱼四散乱游几乎不成群。第一步把聚集权重从 0.8 提到 1.2聚集半径从 60 提到 80。结果是鱼开始聚了但聚成几个互不相干的团团与团之间空得厉害。这说明聚集在起效但对齐没有把团连起来。第二步把对齐权重从 1.0 提到 1.5对齐半径从 48 提到 70。团开始融合形成一条比较长的流线。但问题出现了整群鱼像被一根线牵着队形非常窄转弯时外侧的鱼会掉队。这是典型的对齐过强、分离不足。第三步把分离权重从 1.6 提到 2.2分离半径从 22 提到 28。队形变宽了掉队现象基本消失但整体开始有点松散。第四步把聚集权重再提到 1.4同时把最大转向加速度从 140 提到 160。这一步是最后的关键鱼群终于形成了一个有边界、能整体转向、遇到障碍会像一块软东西绕过去的形态。整个过程大概花了四十分钟绝大部分时间花在第二和第三步的来回上。我的经验是聚集和对齐这两个参数是一对改一个必须跟着看另一个而分离基本是独立的先把分离定死再动另外两个会省很多事。6.3 后面还能往上加什么MiroFish 现在只是一群鱼在水里游往上有不少可以加的东西我按性价比排个序。最值得加的是障碍物比如几块石头。实现上就是给每条鱼加一条对障碍物的排斥规则规则本身和分离完全一样只是障碍物是静态的可以预先放进空间网格里。加完之后整个画面立刻有环境感。其次是捕食者。一条体型更大、速度更快的鱼其他鱼对它有额外的排斥。这会让鱼群出现炸开再合拢的动态观感提升很大代码量也就几十行。再往上就是多物种和深度分层。多物种需要给每条鱼加一个种类标记让同种之间的对齐权重大于异种。分层则是把画布分成前后两层后层慢一点、模糊一点、颜色淡一点做出水深的错觉。这两块我目前只做了分层效果不错成本主要是多一次绘制。最后提醒一句加功能之前先把性能打点工具留好。这个项目里我改动最频繁的其实不是渲染也不是物理而是各种参数和规则没有打点工具的话你会分不清变慢了是因为鱼多了还是因为新规则写得不高效。这个工具花的时间一定会还回来而且还得比预期多。