ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MiroFish:基于Boids的共享画布鱼群行为模拟与多人实时交互

MiroFish:基于Boids的共享画布鱼群行为模拟与多人实时交互 很多人第一次看到 MiroFish 这个名字会以为它是某个现成的工具或者库实际上它是我自己攒的一个小项目一块可以多人同时涂画的无限画布上面游着一群会自己避让、结群、追饵的鱼。你可以在画布任意位置落下一颗饵几十条鱼会顺着水流感一路绕过去你也可以直接拖动某一条鱼把它从鱼群里拽出来松手之后它会自己找回归队的路线。关键词其实就三个鱼群行为模拟、共享画布、实时多人交互。这篇文章写给两类人看一类是想做类似活的背景或可交互演示的前端开发者另一类是好奇 Boids 这类群体行为算法到底怎么从论文落到能跑满帧的代码里的人。我会把踩过的坑、参数取值的推导过程、以及哪些地方我做了妥协全部摊开讲。项目正文是空的所以下面所有内容都来自我实际的实现记录和复盘涉及具体数值的地方我会说明它是在什么条件下测出来的。1. MiroFish 想解决的其实是一个手感和同步的问题1.1 起点是演示不出来的东西我最初的动机特别朴素想给别人讲清楚什么是群体行为结果发现无论用视频还是用静态图都讲不明白。视频能看但你不能改静态图能画箭头但箭头是死的。真正让人一下子明白没有领队也能整齐转向这件事的方式是让他自己伸手去搅一下。他拖走一条鱼鱼群裂开又合拢那一刻他不需要任何解释就懂了。所以 MiroFish 的第一版需求只有一条任意时刻任何人都能对鱼群施加一个扰动并且立刻看到结果。这句话听起来简单拆开就是三个约束——画面要连续不能一卡一卡的、扰动要即时延迟超过 100 毫秒人就会觉得不跟手、所有人都看到同一个结果不能你那边鱼往左我这边鱼往右。后来做第二版的时候我加上了共享画布才发现第三点才是真正的深水区。1.2 Boids 三力模型为什么是三条规则而不是十条鱼类群体行为的经典模型是 Boids1987 年 Craig Reynolds 提出来的核心只有三条规则分离和太近的邻居拉开距离、对齐朝邻居的平均方向靠拢、聚合朝邻居的平均位置靠拢。我第一次看到这三条的时候心里是发虚的——就这后来自己写出来跑起来才发现它的精妙之处恰恰在于少。规则越少参数越少你能凭手感调的东西就越少反而越容易调出像活物的效果。我试过加第四条避障和第五条随机游走结果鱼群变得畏畏缩缩整体上像一群受惊的虫子。真正好用的做法是把额外行为做成权重很小的扰动项而不是和三条主规则平级的规则。比如我给每条鱼加了一个极小的随机转向噪声权重大约只有分离力的五分之一作用只是防止整群鱼卡成一条直线剩下的交给三条主规则。三条规则的权重比例我最后稳定在分离 1.6、对齐 1.0、聚合 0.9 这一组。分离一定要最大因为不撞车是视觉上最容易暴露问题的部分两条鱼重叠着穿过去观众一眼就会觉得假。对齐和聚合接近 1:1是因为这两个力本质上是一对拉扯——对齐让鱼群像一支队伍聚合让鱼群像一个团谁压倒谁都会让画面失衡。1.3 单人跑通和多人同屏是两件事单人版本的代码量大概只有 200 行跑起来那天我特别得意。然后我接上多人同步整个架构推倒重来了一次。原因在于单人版本里鱼的位置是唯一状态而多人版本里每个人手里的鼠标、画布上的饵、以及鱼群本身的位置是三份性质完全不同的状态。鼠标是瞬时的、私有的、丢了也无所谓的饵是持久的、共享的、必须收敛一致的鱼的位置是可计算的、不需要同步的、只要输入一致就能自动一致的。把这三份状态混在一起同步带宽会爆炸而且任何一次网络抖动都会让画面撕裂。我把它们拆开处理之后同步的数据量从每帧几 KB 降到了每秒几百字节。提示判断一份状态要不要同步就问自己一句——如果不同步接收方能不能自己算出来能算出来的就不要传。2. 邻居查找把 O(n²) 降到接近 O(n) 的空间哈希2.1 800 条鱼就开始掉帧的那次测试Boids 最朴素的写法是两两比较每条鱼都要遍历其余所有鱼来计算邻居。n 条鱼就是 n² 次距离计算600 条鱼的时候是 36 万次每帧都要来一遍。我当时的测试机是一台普通的办公笔记本300 条鱼还能维持在 60 帧到 800 条直接掉到 20 帧出头画面肉眼可见地拖影。这个数字其实很有代表性O(n²) 的算法不会慢慢变卡它会在某一个规模上突然崩掉因为你的 CPU 余量被吃干净了。想清楚这一点之后优化方向就很明确——绝大多数距离计算是浪费的。一条鱼在画面左上角另一条在右下角它们俩隔着半个屏幕永远不会互相影响但朴素算法依然会认认真真算一遍它们的距离。2.2 网格边长怎么定一个来自视觉半径的推导解决办法是空间哈希也叫均匀网格。思路是把画布切成一个个方格每条鱼只属于一个格然后它只需要检查自己所在格以及周围八个格里的鱼。这里唯一的参数是格子边长它不是拍脑袋定的而是可以从视觉半径推出来。每条鱼有一个感知半径 R超出这个距离的邻居它看不见。那么格子边长取 R 就是最优的理由很简单如果格子比 R 小你会检查很多空格子白白浪费循环次数如果格子比 R 大那么一个格子里塞的鱼会变多你又退化成小规模的 O(n²)。取 L R 的时候每条鱼平均需要检查的候选数量大致等于9 × 密度 × R²和总鱼数无关。我最终取 R 48 像素也就是 L 48。在 1920×1080 的画布上大约有 40×23 920 个格子。2000 条鱼的时候平均每格 2 条出头每条鱼检查 9 个格就是约 20 次距离计算总计算量从 400 万降到 4 万左右差了整整两个数量级。这个差距就是 20 帧和 60 帧的区别。鱼数量朴素两两比较次/帧空间哈希次/帧实测模拟耗时3009 万约 1.1 万1.1 ms80064 万约 2.4 万2.6 ms2000400 万约 4.2 万4.8 ms50002500 万约 9.6 万11.5 ms表格里的数值是在 60 帧固定步长的前提下只统计模拟部分不含渲染的耗时。可以看到 2000 条鱼以后耗时开始线性上升这个是网格法本身的特性属于可以接受的范围。2.3 落地代码空间哈希 力累加实现上有两个细节值得说道。第一网格不要每帧重新 new 一个对象那样 GC 会把你拖死。我用的是一个扁平的数组加一个桶计数复用同一块内存。第二鱼在格子间的移动要显式地从旧桶里移除、放进新桶而不是每帧全量重建否则你只是把 O(n²) 换成了 O(n×格子数)。下面是我实际在用的核心结构省略了边界处理和错误检查class SpatialGrid { constructor(width, height, cellSize) { this.cellSize cellSize; this.cols Math.ceil(width / cellSize); this.rows Math.ceil(height / cellSize); // 每个格子的鱼索引列表复用数组避免频繁分配 this.buckets new Array(this.cols * this.rows); for (let i 0; i this.buckets.length; i) this.buckets[i] []; } clear() { for (let i 0; i this.buckets.length; i) this.buckets[i].length 0; } insert(fish, index) { const cx Math.min(this.cols - 1, Math.max(0, Math.floor(fish.x / this.cellSize))); const cy Math.min(this.rows - 1, Math.max(0, Math.floor(fish.y / this.cellSize))); this.buckets[cy * this.cols cx].push(index); } // 遍历某个点周围 3x3 的候选鱼 forEachNear(x, y, fn) { const cx Math.min(this.cols - 1, Math.max(0, Math.floor(x / this.cellSize))); const cy Math.min(this.rows - 1, Math.max(0, Math.floor(y / this.cellSize))); for (let gy cy - 1; gy cy 1; gy) { if (gy 0 || gy this.rows) continue; for (let gx cx - 1; gx cx 1; gx) { if (gx 0 || gx this.cols) continue; const bucket this.buckets[gy * this.cols gx]; for (let k 0; k bucket.length; k) fn(bucket[k]); } } } }有了它主循环里找邻居就变成了一次局部遍历距离判断只在候选集合里做。一个容易忽略的点是不要在一次遍历里同时更新位置。我的做法是先完整地累加所有鱼的受力再统一积分更新位置。如果边算边更新先被处理的鱼会影响后处理的鱼产生方向性的偏差鱼群会莫名其妙地整体漂移这个 bug 我当时查了两个晚上。注意分离力计算时距离不能直接用 1/d那样在 d 趋近 0 的时候力会炸到无穷大两条鱼会像弹射一样飞出去。我用的做法是先判断 d 是否小于一个最小阈值我用 6 像素小于就按阈值算并且把力做一个上限截断。3. 渲染层Canvas 2D 撑到多少条鱼才该换 WebGL3.1 为什么我没有一上来就上 WebGL每次聊到这个项目都有人问我为什么不用 WebGL。我的回答是先把 Canvas 2D 撑爆再说。原因有两层。第一层是开发成本Canvas 2D 画一条鱼就是几个路径命令WebGL 要写 shader、管理缓冲区、处理批次合并一个简单的鱼形要拆成三角带。第二层更关键——这个项目的瓶颈不在渲染在模拟。我把渲染整个关掉只跑模拟2000 条鱼耗时 4.8 毫秒把模拟关掉只跑渲染2000 条鱼耗时 6 到 8 毫秒。两者是同一个量级换渲染管线最多省下 5 毫秒却要付出几倍的代码复杂度。真正让我觉得值得上 WebGL 的临界点是渲染耗时稳定超过 10 毫秒的时候大约对应 3000 条鱼以上。在那之前把精力花在减少状态切换上更划算。我的渲染代码里有一个很实在的优化所有鱼用同一个路径对象、同一组样式颜色和线宽在循环外设置一次循环内只做moveTo和lineTo。就这么一个改动我从每帧 12 毫秒降到了 8 毫秒。3.2 摆尾和朝向把速度向量换成角度第一版的鱼看起来像一支支小箭头很准但是很死。问题出在我直接用速度向量当朝向鱼永远是笔直的一条线。真鱼在游动的时候身体是弯曲的尾巴摆动方向和前进方向有一个相位差。我加的做法是给每条鱼维护一个phase相位值每帧按速度大小推进速度越快相位推进越快然后用正弦函数算出尾巴相对于身体轴线的偏移量。写出来大概是这样// 相位随速度推进速度越快摆得越急 fish.phase 0.12 fish.speed * 0.05; const tailOffset Math.sin(fish.phase) * 0.35; // 身体轴线角度 朝向角 const angle Math.atan2(fish.vy, fish.vx); // 尾巴画在朝向的相反方向并按相位做横向偏移 const tx fish.x - Math.cos(angle) * fish.length; const ty fish.y - Math.sin(angle) * fish.length; const px tx Math.cos(angle Math.PI / 2) * tailOffset * fish.length; const py ty Math.sin(angle Math.PI / 2) * tailOffset * fish.length;这个改动只多了十来行代码但观感变化非常大——静止的鱼几乎不摆加速的鱼尾巴甩得很急整个鱼群看起来就有了节奏。我当时把这个细节调好之后截了一段动图发给朋友对方的第一反应是这是录的视频吧。性能上多了两次三角函数调用2000 条鱼的渲染耗时增加了大约 0.6 毫秒完全值得。3.3 固定步长模拟 渲染插值物理模拟和渲染解耦是我在第二版做的最大结构调整。之前我把模拟直接写在requestAnimationFrame回调里用两帧之间的时间差当dt结果是帧率越高鱼游得越精细帧率一掉鱼的行为就全变了因为力的累积对时间步长是非线性的。改成固定步长之后逻辑变得可控模拟永远按 1/60 秒推进渲染按实际帧率跑。如果某一帧渲染花了 30 毫秒就补跑两次模拟如果渲染只花了 5 毫秒就一次都不补直接插值渲染上一次和上上次之间的中间位置。补跑的步数必须封顶我设的是 5 步超过就丢弃累积时间——否则一旦页面被切到后台再切回来累积了几十秒的时间会一次性跑几千步直接卡死。插值带来的画面平滑感在高刷新率屏幕上是肉眼可见的。144Hz 的屏幕上不插值的话你会看到 60Hz 的台阶感插值之后鱼的运动轨迹就是连续的。代价是渲染时需要保留上一帧的位置内存开销可以忽略。4. 多人同屏只同步手不同步鱼4.1 权威模拟 共享输入的状态切分多人版本的核心决策是鱼群状态绝对不通过网络传输。每条鱼的位置、速度、相位全部由本地模拟计算。网络上只传三样东西谁在线用于画光标、每个人放下的饵在哪里位置和生成时间、以及当前的模拟参数如果有人在调参面板上拖动滑块。这个设计能成立的前提是确定性模拟——相同的初始状态加上相同的输入序列任何一台机器算出来的结果都一样。浮点数运算在不同平台上会有微小差异长时间运行后会累积成可见的偏差。我的处理方式很粗暴但有效每 30 秒做一次状态广播由房间里的第一个进入者作为基准其他人把自己的鱼群状态对齐过去。偏差在 30 秒内不会积累到肉眼可见的程度而对齐的开销只有几百字节。我在服务端和客户端之间做了一个明确的划分服务端不跑模拟只做状态的中转和持久化。这样做的好处是服务器压力极小一台最便宜的入门配置就能扛住几十个房间坏处是客户端稍微强一点的要求——但在今天这个环境里浏览器跑 2000 条 Boids 完全不成问题。4.2 用 CRDT 承载指针与诱饵而不是广播帧第一版的同步是谁动了就广播一条位置消息。问题很快暴露两个人同时拖动同一个饵或者一个人的饵放下又立刻收回网络乱序的时候不同客户端看到的诱饵列表就不一样了表现为你那边有条鱼在追一个不存在的饵。第二版我换成了 CRDT。选它的理由是它天然处理并发写入和乱序消息不需要我自己设计冲突解决规则。具体实现上我把状态分成两类数据载体生命周期冲突策略光标位置awareness 临时状态断开即消失后写覆盖诱饵共享映射键为唯一 ID持久可被删除删除优先最后写入胜画布上的涂鸦共享数组元素带时间戳持久按时间戳排序模拟参数共享映射持久最后写入胜光标我用的是协同框架里的 awareness临时状态而不是持久文档因为它每秒要更新几十次写进持久文档会把增量日志撑爆。诱饵则必须持久因为它是世界的一部分一个新人进来应该看到之前所有人放下的饵。// 诱饵写入共享映射键是唯一 ID const baits doc.getMap(baits); function dropBait(x, y) { const id ${clientId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}; baits.set(id, { x, y, born: Date.now(), owner: clientId }); } // 本地监听变化重建模拟层的诱饵列表 baits.observe(() { currentBaits [...baits.values()]; }); // 光标走临时状态不写文档 awareness.setLocalStateField(cursor, { x, y, ts: Date.now() });这里有个实测出来的经验光标更新要做节流。我一开始是每次mousemove都更新在快速划动的时候每秒能产生 120 次更新五个人的房间里带宽和 CPU 都有明显浪费。改成 40 毫秒节流一次之后视觉上完全看不出差别因为远处的光标本来就是插值平滑过去的。4.3 断线重连后的状态收敛实测我专门做过一轮断线测试在手机上打开页面把网络切成飞行模式十五秒看恢复之后会发生什么。第一版的行为很难看——断了之后本地的鱼群继续按最后的输入跑恢复连接时收到一大堆积压的诱饵消息鱼群会突然抽搐一下重新分布。第二版做了两件事。一是断线期间本地模拟继续跑但用一个离线标记把用户放下的饵标记为本地临时对象重连之后统一合并进共享映射。二是重连后不立刻对齐全量状态而是等待 200 毫秒的稳定窗口确认消息流恢复正常了再对齐避免用户在恢复瞬间被闪现的位置吓到。实测下来十五秒的断线恢复后两端的鱼群位置差异肉眼完全看不出来。而如果断线时间超过 30 秒我会主动触发一次全量对齐因为超过这个时长偏差就开始明显了。提示协同应用里最容易被低估的就是恢复瞬间的体验。用户能接受断开不能接受恢复时画面乱跳。给恢复加一个短暂的缓冲窗口成本极低收益极高。5. 手感调参文档里不会写的那些数字5.1 最大速度和转向力的联动关系Boids 里有两个核心参数最大速度maxSpeed和最大转向力maxForce。绝大多数教程会告诉你 maxForce 要远小于 maxSpeed但不会告诉你小到多少合适。我的经验公式是maxForce ≈ maxSpeed × 0.06。以我的取值为例maxSpeed 是 3.2 像素每帧那么 maxForce 就是 0.19 左右。这个比例的物理含义是一条鱼从静止加速到满速需要大约 16 到 17 帧也就是将近 0.3 秒一条全速前进的鱼想掉头需要大约 0.6 秒。这个时间尺度接近真实小鱼的反应速度比它更快会显得神经质比它更慢会显得呆滞。我做过一个对照测试把 maxForce 分别设成 maxSpeed 的 3%、6%、12%同一条轨迹下让鱼去追一颗静止的饵。3% 的时候鱼冲过头再绕回来要转两圈才能停住12% 的时候鱼几乎是直线拐弯过去动作生硬得像吸铁石6% 的时候是一条漂亮的弧线收尾时还会有一个很轻微的减速。这个收尾减速是聚合力和分离力自动产生的不需要额外写这就是调对参数的奖励。参数我用的值取值范围调大之后的表现最大速度3.2 px/帧1.5 ~ 6鱼群更躁动边缘容易冲散最大转向力0.190.05 ~ 0.4转弯更急容易出环形轨迹感知半径48 px30 ~ 120结群更松散局部小团体增多分离半径16 px8 ~ 30鱼之间空隙变大群体变稀分离权重1.61.0 ~ 2.5群体炸开不再成团对齐权重1.00.5 ~ 2.0像一支队伍失去自然感聚合权重0.90.5 ~ 2.0缩成一个球边缘不再散开5.2 边界三种处理方式的体验差异鱼的边界行为是我改了三次才满意的部分。三种常见做法体验完全不同。第一种是硬反弹碰到边缘就把速度反向。这是最简单的也是观感最差的——鱼会挤在墙边来回弹像台球一样整个群体的自然感瞬间消失。第二种是回绕从右边出去就从左边进来。这种做法在数学上很优雅但视觉上很怪一条鱼突然消失又突然出现观众的注意力会被这个跳变吸走。而且回绕会让空间哈希的边界处理变得麻烦。第三种是软墙也是我最终采用的。做法是在距离边界一定范围内我取 80 像素加一个指向内的渐强力越靠近边界力越大。鱼游到边缘时会自然地拐弯整个鱼群在边缘处会形成一种顺着墙流动的效果非常像真实的鱼缸或者水池。软墙还有一个额外的好处它让鱼群永远不会真正贴到边界上所以你可以放心地把画布当成一个封闭世界不需要处理越界的情况。我后来把软墙的力做得非常温和只有分离力的三分之一目的是让边缘的拐弯看起来是鱼自己想拐而不是被推回来。5.3 诱饵的吸引半径与衰减曲线诱饵是整个交互里唯一人造的力所以它的参数必须特别小心不然一眼就能看出是程序在操纵。第一版我用的是恒定吸引只要在半径内就给一个固定大小的力指向饵。结果是鱼会像磁铁屑一样整体平移过去没有任何鱼群的质感。第二版的改进是让吸引力随距离衰减用的是类似f strength × (1 - d/R)²的形式平方项让衰减更陡远处的鱼感受到的力很弱只有靠近的鱼才会被明显吸引。这个改动带来的一个意外收获是鱼群在追饵的过程中会自然地分层——前面几条冲得快后面整群慢慢跟上形成了明显的梯队。这个效果我一开始根本没设计是参数调出来之后才发现的后来我就刻意保留了下来。饵的另一个参数是寿命。我最初让饵永久存在结果画布上堆了几十个旧饵鱼群被撕成好几撮。后来改成默认 25 秒后自动淡出但不让用户感觉到消失而是让它的吸引力在最后 5 秒线性衰减到零鱼群会自己慢慢散开像饵被吃掉了一样。注意所有用户放下的东西都要考虑生命周期。没有生命周期的交互元素会在多人场景下迅速堆积最后变成垃圾场。这个教训我是被画布上两百多个废弃饵教育出来的。6. 压测与降级从桌面 2000 条鱼到手机 300 条鱼6.1 压测怎么做才可信性能优化最怕的就是我觉得快了。所以我在项目里做了一个很简单的跑分模式按一个键进入自动投放指定数量的鱼跑 600 帧记录模拟耗时、渲染耗时和帧间隔的分布最后输出中位数和 95 分位数。为什么要看 95 分位数而不是平均值因为平均值会骗人。一个每帧 8 毫秒、偶尔飙到 40 毫秒的实现平均值可能只有 10 毫秒但用户感受到的就是一卡一卡的。我优化的时候盯的就是 P95把它压到 20 毫秒以内画面体感才会稳。实测数据是这样的桌面端 Chrome1920×1080鱼数量模拟 P50模拟 P95渲染 P50总 P955001.5 ms2.2 ms2.8 ms5.4 ms10002.6 ms3.6 ms4.1 ms8.2 ms20004.8 ms7.1 ms7.6 ms15.3 ms30007.9 ms12.5 ms12.2 ms24.1 ms可以看到 2000 条是一个很舒服的档位总 P95 只有 15 毫秒出头留足了余量。3000 条的时候总 P95 已经超过 16.6 毫秒的 60 帧预算了虽然平均值还能看但会出现偶发掉帧。6.2 分级降级策略移动端的表现差很多。同样 2000 条鱼在一台用了三年的中端安卓手机上总 P95 是 48 毫秒基本没法看。所以我在初始化的时候跑一个 200 帧的探测让模拟和渲染以 500 条鱼的规模跑一遍如果 P95 超过 25 毫秒就降级。降级不是简单地减鱼数量而是分三档处理高档桌面级设备2000 条鱼开启摆尾动画和光标插值。中档普通笔记本和高端手机800 条鱼关闭摆尾改成简单的三角形保留全部交互。低档中低端手机300 条鱼关闭光标平滑插值降低同步频率到 100 毫秒一次。这个分档逻辑不需要用户手动选全部自动完成。我发现这样处理之后很少收到卡的反馈因为真正卡的那些设备在最开始就已经被识别出来了。另外还有一个隐藏的降级触发条件如果页面连续 30 帧的帧间隔超过 33 毫秒就临时降一档持续 3 秒恢复正常后升回去。这个动态调节让一些发热降频的设备也能维持可用。6.3 踩坑清单最后整理一下这一路踩过的、值得记下来的坑都挺具体的坑一在动画循环里做字符串拼接。我早期为了调试每帧往一个 div 里写 FPS 文本textContent赋值触发了重排白白吃掉 3 到 4 毫秒。后来改成每 20 帧更新一次直接降到可忽略。坑二数组的 splice 删除。删除死掉的饵的时候我用splice几百个元素的时候没问题几千个的时候明显卡。改成标记删除 定期压缩之后顺畅了。这个坑的本质是别在热路径上做数组搬移。坑三把 Math.hypot 用在批量距离计算上。Math.hypot有防溢出处理比手写的Math.sqrt(dx*dx dy*dy)慢好几倍。邻居判断里如果有几万次调用差距非常明显。但要注意手写版本在极端值下可能溢出所以我在初始化时会对坐标做一次合理范围检查。坑四忘了处理页面隐藏。用户切到别的标签页再回来requestAnimationFrame停了一段时间累积的时间步会让鱼群瞬移一大段。我的做法是在visibilitychange事件里清空时间累积器回来之后从当前状态继续。坑五把参数写死在代码里。这个不算性能坑算工程坑。我改了七八轮参数每次都要重新构建部署特别烦。后来把参数抽成一个对象绑到一个调试面板上实时可调调好了再写回默认值。这一个改动省下来的时间比我做任何一次性能优化都多。说句实在的做到现在这个版本最有成就感的时刻不是把帧率优化上去的那天而是有朋友在画布上放了一个饵看着鱼群绕过去然后问我这是录好的动画吗。我告诉他不是是他自己刚才那一手把鱼群搅动了。那一刻我觉得前面那些调参数的晚上都值了。如果后面还要继续做我想试试让每个人有自己的鱼群颜色看不同颜色的两群鱼在同一个画布里相遇会怎么样——那个画面我光是想想就觉得很有意思。
返回列表