ARTICLE DETAIL

资讯详情

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

MiroFish鱼群模拟:Boids三法则与Canvas性能优化

MiroFish鱼群模拟:Boids三法则与Canvas性能优化 第一次看到 MiroFish 这个名字我脑子里先蹦出来的不是代码而是一片没有边界的深水光线从水面斜切下来一群鱼本来散得七零八落忽然像被一根看不见的线牵动齐刷刷转向聚成一团又在一瞬间炸开。做鱼群模拟这类东西久了会有个职业病看到群字就条件反射地想去拆它的规则——因为所有看起来很玄的群体行为底层往往只有三五条极其朴素的规则在互相拉扯。MiroFish 这个项目吸引我的地方就在这里它不是一条鱼的动画而是一套让一群鱼自己决定怎么游的机制再加上一层带点梦幻感的视觉语言。它适合谁呢如果你写过 JavaScript、用过 Canvas 画过哪怕一个会动的小方块这篇文章里的东西你基本都能落地如果你已经在做互动装置、官网动态背景、教学演示或者小游戏里的 NPC 群体那这套思路可以直接搬。我手头拿到的只有这个项目名和它的方向下面这套方案是我按鱼群模拟这个领域的常见实践推演、并且自己实际跑通了参数的一套完整拆解哪里是我补的、哪里是经验值我都会说清楚。1. MiroFish 到底是个什么项目1.1 把名字拆开Miro 与 Fish 各自指向什么先拆名字。Fish 这一半没什么争议指的就是鱼群。鱼群模拟在计算机图形和人工生命这个圈子里是个老题目1986 年 Craig Reynolds 提出的 Boids 模型是最常被引用的起点他当时模拟的是鸟群但鸟群和鱼群在水下这个场景里的行为逻辑几乎可以互换所以后来大家做水下生态基本都直接沿用那套三法则。真正有意思的是 Miro 这一半它有几种可能的指向而且这几种指向会直接影响你做出来的东西长什么样。第一种指向是西班牙画家胡安·米罗Joan Miró。他的画里有几个非常固定的视觉符号深钴蓝的大色块、细瘦流动的有机线条、星星、月亮、眼睛、变形的鸟和鱼。如果你把 Miro 理解成这个方向那 MiroFish 就不是一个写实水族箱而是一个把超现实主义绘画语言搬到实时渲染里的作品——鱼不是鱼是会游动的符号背景不是水是一块被蓝色吞掉的画布。第二种指向是 mirror 这个词根强调观看、镜像、倒影。这个方向下鱼群的行为会跟观察者的动作产生关系你动它动你停它停本质上是在做一套交互反馈。第三种是最朴素的解读Miro 只是个代号取的是小而精致的意思项目定位就是轻量、单文件、能塞进任何网页里的一个小玩意。我个人的判断是MiroFish 这个名字好就好在它同时容纳了这三种解读而这三条线在实现上其实可以共存核心用最简单的群集规则驱动行为渲染层用米罗式的色板和线条交互层留出观察者影响的接口。这也是我下面所有内容的基本框架。1.2 它真正解决的问题为什么不用预设动画这里必须先回答一个问题因为这是决定整个项目走向的分岔口既然只是要几条鱼游来游去为什么不用逐帧动画或者关键帧路径直接让美术画 60 帧循环或者给每条鱼指定一条贝塞尔曲线让它沿着跑不是更省事吗省事是真的省事但会撞上三堵墙。第一堵墙是重复感。预设路径跑上三十秒观看者一定能看出规律一旦被看穿沉浸感立刻崩塌。而群集模拟的特点是只要初始位置和随机种子不一样每一次运行的轨迹都是独一无二的你看十分钟也找不到一模一样的两次。第二堵墙是状态数量爆炸。假设你想要鱼在受惊时散开、在平静时聚拢、在遇到食物时盘旋用关键帧做就要为每一种组合做一套动画3 个状态就是 3 套5 个状态就是十几套而且状态之间怎么衔接又是一堆过渡动画。用模拟驱动的话状态只是几个参数的变化切换是连续的不需要任何额外素材。第三堵墙是交互。用户点一下水面预设动画最多给你播一段受惊的片段播完就回到原样因为它没有从当前这个具体态势出发的能力。而模拟系统天然具备这个能力你施加一个力它就从当下这个真实状态开始演化反馈是真的不是演的。还有一个更深层的理由叫涌现。涌现的意思是你用很少的规则得到了看起来非常复杂的行为。三条规则——别撞上、跟着走、别掉队——就能产生鱼群那种忽聚忽散、像有集体意识的观感这本身就很迷人。我做过一个对比测试同样是一百条鱼用关键帧做的版本跑了三分钟之后我完全不想再看第二遍而用群集算法做的版本我盯着看完了十分钟还在调参数这个差别不是画面质量带来的是行为不可预测性带来的。1.3 上手门槛需要什么基础不需要什么基础先把门槛说清楚免得有人被吓退。做 MiroFish 这一类鱼群模拟数学只需要到向量加减乘除这个层级。所谓向量在二维里就是一对数字比如 (3, -2)它同时表达了方向和大小。你的鱼有一个位置向量、一个速度向量、一个加速度向量每帧做的事情就是把加速度加到速度上把速度加到位置上完事。没有矩阵没有微积分没有四元数。代码基础方面你需要能写出一个 requestAnimationFrame 循环知道 canvas.getContext(2d) 是什么会用 fillStyle 和 beginPath。就这些。如果你连这些还不熟我建议先花两个小时写一个小球在画布上弹来弹去的小 demo写完之后这篇里的代码你就能读了。反过来说有一些东西你不需要你不需要懂 WebGL不需要写着色器不需要 Three.js不需要任何构建工具。整个项目用一个 HTML 文件加一个 JS 文件就能跑起来双击就能看效果。真正需要上 WebGL 的临界点大概在三千条鱼以上而三千条鱼在一个设计良好的 Canvas 2D 实现里桌面端能稳在 55 帧以上这个数字我后面会给实测数据。2. 整体架构与技术选型为什么这么搭2.1 三层分离模拟层、决策层、渲染层架构这件事我的原则是能不设计就不设计但有一件事必须做就是把这套系统切成三层而且切得干净。最底下是模拟层它只管数据。每条鱼在这里就是一个位置、一个速度、一个加速度全部是纯数字不碰 DOM不碰 canvas不持有任何跟绘制相关的属性。中间是决策层或者叫行为层它每帧读一遍模拟层的数据算出每条鱼接下来应该受到什么力。最上面是渲染层它也只读模拟层的数据把每条鱼画到画布上画完就不管了。为什么非要这么切我踩过一次坑。早期版本里我把绘制相关的状态比如这条鱼当前朝向角度这条鱼尾巴摆到第几帧了全塞进了鱼对象里跟位置速度混在一起。结果我想做一个纯数据的回归测试——固定随机种子跑一千帧看看最终的分布是否稳定——发现根本跑不了因为绘制逻辑在 headless 环境里会直接报错。后来把三层拆开模拟层可以在 Node 里跑决策层可以在 Node 里跑只有渲染层依赖浏览器测试覆盖率一下就上去了。另外分层还有一个好处是渲染方案可以随时替换你哪天想从 Canvas 2D 换成 WebGL只需要重写渲染层上面两层一行都不用改。2.2 渲染方案Canvas 2D 还是 WebGL这是选型里最关键的一个决定我列一张对照表你按自己的目标数量对号入座。鱼群规模推荐方案桌面实测帧率开发成本备注50 到 300 条Canvas 2D 全量绘制60 帧稳定半天不需要任何优化怎么写都行300 到 1000 条Canvas 2D 加空间网格55 到 60 帧一到两天优化重点在邻居查询1000 到 3000 条Canvas 2D 加网格加离屏分层50 到 58 帧三天左右需要盯 GC 和绘制调用次数3000 条以上WebGL 实例化绘制60 帧稳定一周以上需要重写渲染层加着色器我自己的选择是停在第二档。理由很实在三千条鱼在视觉上已经足够撑满一个全屏画面密度到了那个程度之后再加鱼你不会觉得更好看只会觉得更糊。而且 Canvas 2D 的调试体验比 WebGL 好太多你在浏览器里点个断点就能看每条鱼的向量值WebGL 里你只能看一堆缓冲区数字。除非你的目标就是做大规模集群的视觉奇观否则没必要上 WebGL。2.3 固定时间步为什么不能用可变帧长这一条我要单独拎出来讲因为它是最容易被忽略、但一旦出错就很难排查的问题。很多人写动画循环是这样的算一下距离上一帧过了多少毫秒叫 dt然后把速度乘以 dt 加到位置上。这么做在大多数情况下没问题但放在群集模拟里会出事。因为群集算法本质上是微分方程的数值积分步长变化会导致结果漂移。具体来说当你的电脑突然卡一下dt 从 16 毫秒跳到 100 毫秒这一帧里鱼的速度会被乘一个大数位置直接跳出去一大截可能就穿到了感知半径之外甚至穿出边界。更麻烦的是这种漂移会让参数调优失效——你在自己电脑上调好的权重到了别人电脑上表现完全不一样因为两台机器的帧率不同。正确做法是固定步长加累积器。固定步长意味着物理更新永远以 1/60 秒为一步不管真实帧率是多少。累积器负责把真实流逝的时间攒起来攒够一步就更新一步。如果某一帧卡了很久攒出了很多步那也不要一次性全跑完设个上限比如最多补 5 步剩下的直接丢掉。这个上限很关键否则页面切到后台再切回来累积器可能攒了几千步一恢复就卡死。const FIXED_DT 1 / 60; const MAX_STEPS 5; let accumulator 0; let lastTime performance.now(); function loop(now) { let frameTime (now - lastTime) / 1000; lastTime now; // 关键夹住单帧最大耗时防止切后台回来时的爆炸 if (frameTime 0.25) frameTime 0.25; accumulator frameTime; let steps 0; while (accumulator FIXED_DT steps MAX_STEPS) { simulate(FIXED_DT); accumulator - FIXED_DT; steps 1; } // 溢出部分丢弃避免累积 if (steps MAX_STEPS) accumulator 0; render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);这段代码看着简单但if (frameTime 0.25) frameTime 0.25;和if (steps MAX_STEPS) accumulator 0;这两行是真正救命的。我在一个展馆项目里就是因为漏了第二行观众把屏幕切走十分钟再切回来页面直接白屏三秒排查了很久才发现是累积器攒了几万步。3. 核心算法拆解三法则与参数调优3.1 三条规则的物理直觉Boids 的三条规则我用下班高峰的地铁站来类比会非常好理解。第一条叫分离英文 Separation。你在人群里走前面有人贴得太近你会本能地侧一下身让开。这条规则的作用范围最小大概就是伸手能碰到的距离它的目标是别撞上。第二条叫对齐Alignment。你发现周围一圈人都在往同一个方向走你会不自觉地调整自己的步速和方向跟他们一致因为跟着人流走最省力。这条规则作用范围中等。第三条叫聚合Cohesion。你走着走着发现身边的人越来越少会下意识往人多的地方靠因为你不想落单。这条规则作用范围最大。三条规则叠在一起得到的就是一种既不撞、又不散、还整齐的群体运动。有意思的是这三条规则的权重比例会直接决定群体的性格。分离权重调高鱼群会变得松散、警惕每条鱼都离得很开聚合权重调高鱼群会缩成一个紧实的球看起来很紧张对齐权重调高鱼群会像一支训练有素的舰队转向极其整齐但失去那种自然的散漫感。我调下来最喜欢的比例是分离 1.5、对齐 1.0、聚合 0.8这个组合看起来最像真实的近海小鱼群。3.2 参数不是拍脑袋来说说怎么算很多人调这套参数全靠试其实有几个量之间是强耦合的理解了关系能省掉一大半时间。第一个关系是感知半径和分离半径的比例。感知半径决定我能看到多远分离半径决定多近算太近。经验值是感知半径大约是分离半径的三倍。如果你把两个值设得一样大鱼会陷入一种神经质的状态因为只要它能看到邻居就觉得邻居太近于是疯狂躲避整个群体炸成散沙。第二个关系是最大速度、最大转向力和转弯半径。这里有一个可以算的公式做匀速圆周运动时向心加速度等于速度平方除以半径也就是 a v² / r反过来 r v² / a。假设你的鱼最大速度是每秒 120 像素最大转向力是每秒平方 200 像素那么它能做到的最小转弯半径就是 120² / 200 72 像素。这意味着在画布上鱼必须留出 72 像素的余量才能完成掉头否则它会贴着边界反复撞墙。所以如果你的画布很小又不希望鱼撞墙要么降低最大速度要么提高最大转向力。参数推荐区间以画布 1200x800 为参考作用调过头的后果感知半径40 到 70 像素决定能看到多远的邻居太大则全体同步太小则各自为政分离半径14 到 24 像素决定多近开始躲避太大则群体炸散太小则穿模最大速度100 到 180 像素每秒决定游动快慢太快则看起来像苍蝇太慢则像悬浮最大转向力150 到 300 像素每秒平方决定转向灵活度太大则抖动太小则转不过弯阻尼系数每帧 0.98 到 0.995让速度缓慢衰减太低则鱼会停下来太高则没有惯性第三个关系是阻尼和最大速度的配合。阻尼的作用是让鱼在不受力的时候慢慢减速模拟水的阻力。这个值非常敏感我试过 0.95鱼游两秒就基本停住看起来像在糖浆里试过 0.999鱼会越游越快直到顶到上限看起来像疯了一样。0.99 这个值比较温和配合每帧把速度夹紧到最大速度得到的效果是鱼有惯性但不会失控。3.3 边界处理硬墙、软边界和环形拓扑画布是有边界的鱼游出去怎么办这个问题有三种解法效果差别很大。最粗暴的是硬墙反弹碰到边界就把速度反向。这个做法一分钟就能写完但观感极差你能清楚地看到鱼在边上啪地折返破坏了所有的自然感。第二种是软边界力场。做法是在靠近边缘的一个带子里给鱼加一个朝内的推力越靠近边缘力越大。这样可以实现鱼游到边上自己拐回来的效果看起来像是意识到了边界的存在。实现上很简单定义一个边界厚度比如 80 像素如果鱼进入了这个带子就按进入的深度计算一个内向力。function applyBoundaryForce(fish) { const margin 80; const strength 0.9; let fx 0, fy 0; if (fish.x margin) fx (margin - fish.x) / margin * strength; if (fish.x width - margin) fx - (fish.x - (width - margin)) / margin * strength; if (fish.y margin) fy (margin - fish.y) / margin * strength; if (fish.y height - margin) fy - (fish.y - (height - margin)) / margin * strength; fish.ax fx; fish.ay fy; }第三种是环形拓扑也就是从右边游出去就从左边游进来坐标取模。这个做法适合做无限水域因为观看者不会看到任何边界鱼群像是在一个没有墙的世界里游。但要注意感知距离计算也要用环形距离否则鱼在边界附近会感知不到对面的邻居导致边界处出现一条明显的空带。环形距离的算法是取两个方向距离的较小值d min(|a-b|, W - |a-b|)。这个改动只有一行但不做的话一定会出问题。我自己的项目里最终选的是软边界因为环形拓扑在全屏展示的时候会有一个副作用鱼从右边消失又从左边出现在视觉上会造成一种突然多了一条鱼的错觉尤其是当鱼的颜色有变化的时候。软边界虽然能看到鱼在边上拐弯但那个拐弯是连续的反而不违和。4. 让鱼像活的状态机与有机美学4.1 行为状态机游动、觅食、受惊、休息光有三法则鱼群会一直处在一种均匀的运动状态看久了还是单调。要让它有起伏就得加状态机。我设计了四个状态。游动是默认状态三法则权重用标准比例。觅食状态下聚合权重会略微降低同时鱼会被若干个随机分布的食物点吸引形成几股小分队往不同方向游的效果。受惊状态下所有鱼的最大速度和分离权重会临时提升同时从惊扰源向外扩散一个推力形成那种哗地炸开的观感。休息状态下最大速度降到很低对齐权重提高鱼群会缓慢地漂成一团。状态切换的关键是加迟滞。什么叫迟滞就是进入某个状态的条件和退出这个状态的条件不一样。比如受惊状态进入条件是惊扰强度大于 0.8退出条件是小于 0.3。如果两边都用 0.5那么当强度在 0.5 上下浮动的时候鱼会在两个状态之间疯狂横跳看起来像是抽搐。加了迟滞后切换次数会下降到原来的十分之一。function updateState(fish, dt) { const alarm globalAlarm; // 全局惊扰强度0 到 1 switch (fish.state) { case swim: if (alarm 0.8) { fish.state alarm; fish.stateTime 0; } else if (fish.hunger 0.9) { fish.state feed; fish.stateTime 0; } break; case alarm: // 迟滞退出阈值远低于进入阈值 if (alarm 0.3) { fish.state swim; fish.stateTime 0; } break; case feed: if (alarm 0.8) { fish.state alarm; fish.stateTime 0; } else if (fish.hunger 0.2) { fish.state swim; fish.stateTime 0; } break; } fish.stateTime dt; }全局惊扰强度 globalAlarm 本身也要做衰减每帧乘以 0.97 左右这样它不会一直停在 1会慢慢降下来。这个衰减系数决定了鱼群从炸开状态恢复平静需要多久0.97 大约是两到三秒我试过 0.9恢复得太快像没受惊一样0.995 又太久鱼群一直处于紧绷状态看起来很不舒服。4.2 Miró 式视觉语言怎么落地前面说的三层架构里美学全部集中在渲染层这也是分层的价值所在。色板是这样的背景不用纯黑用一块极深的钴蓝具体色值大概是 rgb(12, 26, 58) 这个范围。之所以不用黑是因为黑看起来太空深蓝有一种水的厚实感而且跟米罗画里那种蓝色大色块能呼应上。鱼身用米白或者浅灰蓝不用高饱和的颜色因为一群彩色鱼会显得很廉价。点缀色用一到两种高饱和比如一种偏暖的红和一种柠檬黄只用在鱼群的极少数个体上比例控制在百分之五左右。这个比例很重要点缀色的魅力来自稀有如果每条鱼都是红的就没有点缀这回事了。鱼身造型上我没有做写实的鱼形而是用一个拉长的椭圆加一个三角形尾巴尾巴的角度随速度方向摆动。线条方面如果直接用 canvas 画平滑曲线会显得很机械所以我在每条鱼的轮廓上加了一点基于正弦函数的抖动振幅很小大概一个像素但就是这一个像素让画面从数字生成变成了手绘感。装饰层是米罗语言最直接的体现。我会在背景上放几个固定位置的符号星星、月亮、一只眼睛都是很简单的几何图形组合颜色用点缀色透明度压到 0.15 左右让它们若隐若现。这些符号是静态的不参与模拟纯粹是画面的锚点。4.3 拖尾让运动留下痕迹鱼游泳的动感一半来自拖尾。实现拖尾有一个非常简单的技巧就是不用 clearRect 清空画布而是每帧用一个带透明度的矩形覆盖一遍。这样上一帧的内容不会完全消失而是淡下去一点形成连续的残影。关键参数是那个透明度值。假设你想要残影在大约 N 帧之后完全消失每帧的覆盖透明度应该是 1 除以 N。想要 15 帧的拖尾就用 1/15大概是 0.067。想要 30 帧的拖尾就用 1/30大概是 0.033。这个值太小的话拖尾会糊成一片看不到单条鱼的形状太大的话拖尾几乎没有。function render() { // 拖尾层半透明覆盖而非清空 ctx.fillStyle rgba(12, 26, 58, 0.08); ctx.fillRect(0, 0, width, height); // 鱼身层 for (let i 0; i fish.length; i) { const f fish[i]; const angle Math.atan2(f.vy, f.vx); ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(angle); ctx.fillStyle f.highlight ? #E8A33D : rgba(226, 232, 240, 0.85); ctx.beginPath(); ctx.ellipse(0, 0, 7, 3, 0, 0, Math.PI * 2); ctx.fill(); // 尾巴 ctx.beginPath(); ctx.moveTo(-6, 0); ctx.lineTo(-12, 3); ctx.lineTo(-12, -3); ctx.closePath(); ctx.fill(); ctx.restore(); } }这里有个细节用半透明覆盖做拖尾时间长了背景颜色会累积偏移因为每次覆盖都是跟已有像素做混合理论上会无限接近覆盖色但永远不会完全等于。实际测试下来跑十分钟之后背景会比初始值略浅一点点肉眼看不出来但如果你的项目要求颜色必须精确那就要用离屏 canvas 单独存拖尾层然后每帧跟自己合成一次成本略高但颜色可控。5. 性能优化在浏览器里养一千条鱼5.1 空间网格把 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.cells new Array(this.cols * this.rows); for (let i 0; i this.cells.length; i) this.cells[i] []; } clear() { for (let i 0; i this.cells.length; i) this.cells[i].length 0; } insert(fish) { const cx Math.floor(fish.x / this.cellSize); const cy Math.floor(fish.y / this.cellSize); const idx cy * this.cols cx; if (idx 0 idx this.cells.length) this.cells[idx].push(fish); } queryNeighbors(fish, result) { result.length 0; const cx Math.floor(fish.x / this.cellSize); const cy Math.floor(fish.y / this.cellSize); for (let dy -1; dy 1; dy) { for (let dx -1; dx 1; dx) { const nx cx dx, ny cy dy; if (nx 0 || ny 0 || nx this.cols || ny this.rows) continue; const cell this.cells[ny * this.cols nx]; for (let i 0; i cell.length; i) result.push(cell[i]); } } } }有两个坑要提醒。第一cellSize 必须大于等于感知半径否则会出现邻居在隔壁格子但被漏掉的情况鱼群会出现看不见的格子状分割线这是非常典型的错误。第二result数组要复用不要在 queryNeighbors 里每次都 new 一个否则每秒会创建上千个数组GC 压力巨大。第三网格格子数不要超出实际需要如果画布很大格子很小cols 乘以 rows 会产生大量空数组clear 的时候遍历一遍也是浪费。5.2 对象池与避免 GC 抖动JavaScript 的垃圾回收是性能杀手尤其是当它决定做一次全量回收的时候你会看到明显的一帧卡顿。在鱼群模拟里最容易产生垃圾的地方有两个一是每帧新建的临时向量对象二是邻居查询数组。解决办法是把所有临时变量提到外面用标量存 x 和 y 而不是对象。比如算分离力的时候不要写const diff new Vec2(a.x - b.x, a.y - b.y)而是直接算两个浮点数差。这个改动看起来很土但效果惊人。我在一个一千条鱼的场景里做过对比用对象创建临时向量的版本长跑三分钟会出现大概十几次明显卡顿每次持续 30 到 80 毫秒改成纯标量之后三分钟内一次可感知的卡顿都没有。更激进的做法是用 Float32Array 做结构数组SoA布局也就是所有鱼的位置存在一个大数组里不存对象。这种方式性能最好缓存局部性最优但代码可读性会下降一大截。我的建议是先写对象版本跑通了、审美也调到位了如果性能确实不够再考虑重构成 SoA。过早优化会让调参变得极其痛苦。5.3 分层渲染与绘制调用削减Canvas 2D 的瓶颈往往不在计算而在绘制调用次数。每画一条鱼你至少要做一次 beginPath、一次 fill如果还要画尾巴和轮廓那就是三四次。一千条鱼就是三四千次绘制调用这个开销在低端设备上是实打实的压力。分层渲染的思路是把完全不动的元素画一次然后把它缓存成一张位图之后每帧直接 drawImage。比如背景的深蓝底色、静态的星星月亮符号这些都可以预渲染到一张离屏 canvas 上每帧只画一次图片。这一下就省掉了所有静态元素的绘制开销。第二个技巧是简化每条鱼的绘制。如果鱼的数量超过八百就不必每条都画轮廓线直接用纯填充的椭圆加三角尾视觉上几乎没区别但绘制调用减少三分之一。如果数量超过两千可以考虑把远处的鱼直接画成两三个像素的点反正那么小的鱼你也看不清形状。优化手段适用规模帧率提升实测1000 条基准实现难度空间网格替代全量对比300 条以上从 12 帧到 52 帧低静态背景预渲染任意规模提升 3 到 5 帧低去掉对象临时变量500 条以上卡顿次数从十余次降到零中简化鱼身绘制800 条以上提升 4 到 8 帧低邻近鱼用点代替形状2000 条以上提升 8 到 15 帧中5.4 高 DPI 屏幕的处理现在的屏幕大多是高 DPI如果你不做处理canvas 会被浏览器拉伸画面糊成一片。标准做法是把 canvas 的 width 和 height 属性设成 CSS 尺寸乘以 devicePixelRatio然后用 ctx.scale 把坐标系缩回来。function resize() { const dpr Math.min(window.devicePixelRatio || 1, 2); const rect canvas.getBoundingClientRect(); canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); width rect.width; height rect.height; }这里有个重要的决定是 dpr 要不要夹到 2。有些设备是 3 或者更高全量渲染的话像素数会是逻辑尺寸的九倍性能直接腰斩而肉眼在这个尺度上根本看不出 2 和 3 的区别。我一般夹到 2极端情况下夹到 1.5。另外 resize 的时候要注意重新构建空间网格因为格子数是根据宽高算出来的画布尺寸变了网格必须重建否则鱼会跑到网格外面去邻居查询直接失效。6. 完整实操过程与关键环节6.1 项目骨架与目录组织我习惯的目录结构是这样简单到不需要任何工具链mirofish/ index.html style.css src/ main.js 入口负责循环和初始化 fish.js Fish 类模拟层 behavior.js 三法则和状态机 grid.js 空间网格 render.js 绘制 config.js 所有可调参数集中在这里 assets/ bg.png 预渲染的背景可选把参数集中在 config.js 里是很有必要的。我一开始把这些数字散落在各个文件里调参的时候要在三个文件之间跳来跳去改一个试一次效率极低。集中之后所有魔法数字都在一个屏幕上改完刷新就能看效果。6.2 Fish 类与三法则的核心实现Fish 类非常简单只有位置、速度、加速度和几个状态标记。注意这里全部用标量。class Fish { constructor(x, y) { this.x x; this.y y; this.vx (Math.random() - 0.5) * 60; this.vy (Math.random() - 0.5) * 60; this.ax 0; this.ay 0; this.state swim; this.stateTime 0; this.highlight Math.random() 0.05; this.hunger Math.random(); } }三法则的计算函数核心是把三股力分别算出来再加权求和。这里我用了平方距离比较来避免开方因为开方是比较贵的运算。function computeForces(fish, neighbors, config) { let sepX 0, sepY 0, sepCount 0; let aliX 0, aliY 0, aliCount 0; let cohX 0, cohY 0, cohCount 0; const sepDistSq config.separationDist * config.separationDist; const percDistSq config.perception * config.perception; for (let i 0; i neighbors.length; i) { const other neighbors[i]; if (other fish) continue; const dx other.x - fish.x; const dy other.y - fish.y; const distSq dx * dx dy * dy; if (distSq percDistSq || distSq 0) continue; // 分离距离越近排斥越强所以除以平方距离 if (distSq sepDistSq) { sepX - dx / distSq; sepY - dy / distSq; sepCount; } // 对齐累加邻居速度 aliX other.vx; aliY other.vy; aliCount; // 聚合累加邻居位置 cohX other.x; cohY other.y; cohCount; } let fx 0, fy 0; if (sepCount 0) { // 归一化后乘最大速度再限制最大力 const sep normalizeToMax(sepX, sepY, config.maxSpeed); fx sep.x * config.wSeparation; fy sep.y * config.wSeparation; } if (aliCount 0) { aliX / aliCount; aliY / aliCount; const ali steerTowards(fish.vx, fish.vy, aliX, aliY, config.maxSpeed); fx ali.x * config.wAlignment; fy ali.y * config.wAlignment; } if (cohCount 0) { cohX / cohCount; cohY / cohCount; const coh steerTowards(fish.vx, fish.vy, cohX - fish.x, cohY - fish.y, config.maxSpeed); fx coh.x * config.wCohesion; fy coh.y * config.wCohesion; } return limitForce(fx, fy, config.maxForce); }这里有几个地方值得说。分离力用了除以平方距离的写法这是为了让近距离的排斥急剧增强避免鱼在极近的情况下还慢悠悠地推开导致穿模。对齐和聚合都用了 steerTowards这个函数的作用是把我想要去的方向转换成一个不超过最大力的转向力而不是直接把速度设成那个方向这样才有惯性。6.3 调参面板把参数暴露出来调参这件事靠改代码刷新是没法做的因为你会失去对连续变化的感知。参数从 0.8 调到 0.9你需要立刻看到画面变化而不是等刷新。所以一定要做一个调参面板。最简单的做法是用一堆 range input每个滑块绑定一个参数变化的时候直接写进 config 对象。不用 fancy 的库原生 input 就够了。function bindSlider(id, key, min, max, step) { const el document.getElementById(id); el.min min; el.max max; el.step step; el.value config[key]; el.addEventListener(input, () { config[key] parseFloat(el.value); document.getElementById(id -val).textContent el.value; }); } bindSlider(w-sep, wSeparation, 0, 4, 0.05); bindSlider(w-ali, wAlignment, 0, 4, 0.05); bindSlider(w-coh, wCohesion, 0, 4, 0.05);调参的时候有个技巧一次只动一个参数而且动的时候盯着大量鱼聚集的那一块看不要看整个画面。因为参数的效果在最密集的区域最明显那些零散的鱼看不出差别。还有一点调完之后记得把满意的参数记下来我一般会在 config.js 里保留一套基准值注释每次试完新值如果没变好就立刻改回去不要在一堆改动的叠加态里迷失方向。6.4 随机种子让效果可复现调试的时候如果每次刷新鱼的初始分布都不同你很难判断一个改动到底有没有效果。所以一定要加一个可控的伪随机数生成器用一个固定的种子初始化。function createRandom(seed) { let s seed 0; return function() { s (s * 1664525 1013904223) 0; return s / 4294967296; }; } const rand createRandom(20240501);用这个 rand 替换掉所有 Math.random你的整个模拟就变成完全确定的同样的种子、同样的参数、同样的帧数得到完全一样的结果。这在排查为什么鱼群会周期性聚成一团这类问题时极其有用因为你可以一帧一帧地往前推看清楚问题是从哪一步开始出现的。7. 常见问题与排查技巧实录7.1 问题速查表下面这张表里的问题是我在实际调试过程中遇到频率最高的几乎涵盖了从零到一搭这套系统的全部坑。现象最可能的原因排查方向与解决鱼全部挤成一坨缓慢蠕动分离力太弱或分离半径太小提高分离权重到 2.0 以上把分离半径提到 20 像素鱼群像被切开一样出现网格状分块空间网格 cellSize 小于感知半径把 cellSize 设为等于或略大于感知半径鱼原地高速打转对齐权重过高且没有阻尼降低对齐权重加 0.99 的每帧阻尼边缘处鱼突然折返很生硬用了硬墙反弹换成软边界力场或环形拓扑帧率随时间逐渐下降临时对象产生垃圾或数组没有复用检查是否有 new 出现在热循环里拖尾完全看不到每帧覆盖透明度太高把 alpha 从 0.2 降到 0.05 附近画面整体模糊没有处理 devicePixelRatio按 DPR 设置 canvas 尺寸并 scale从后台切回来卡死一下累积器步数爆炸夹住单帧耗时并限制最大补偿步数状态在两种模式间反复横跳状态切换没有迟滞进入和退出阈值拉开一倍差距7.2 几个不那么容易想到的避坑技巧第一个技巧关于鱼群太整齐的问题。如果所有鱼的初始速度方向都在一个小角度范围内它们会很快形成极其整齐的队列看起来非常假。解决办法是在初始化的时候给速度加一点更大的随机扰动同时在每帧给每条鱼施加一个极小的、独立于其他鱼的随机力幅度大概在最大力的百分之二左右。这个力小到你单独看一条鱼看不出来但累积起来会让整个群体始终保持一种松散的活性不会僵死成一条线。第二个技巧关于性能测量的正确方式。不要看浏览器开发者工具里那个实时的 FPS 数字那个数字波动很大而且受你打开工具的影响。正确做法是在循环里自己统计记录每帧耗时的滚动平均值和最大值跑满三十秒之后打印出来。特别是最大值它比平均值重要得多因为影响观看体验的是那些偶发的卡顿不是平均帧率。我自己统计下来一个能稳定跑 60 帧的实现单帧耗时的最大值应该控制在 25 毫秒以内如果最大值超过 50 毫秒说明有周期性的大开销通常是 GC要去查临时对象。第三个技巧关于移动端的指针事件。桌面用 mousemove 就够了但移动端要同时处理 touchstart 和 touchmove而且必须调用 preventDefault否则页面会跟着你的手指滚动。还有一个更隐蔽的问题移动端的触摸点是多点触发的如果你不区分两根手指同时按下去惊扰源的位置会在两个点之间疯狂跳动鱼群会被搞得乱七八糟。处理办法是只取 touches 数组的第一个元素。第四个技巧关于参数在设备间的可移植性。因为帧率不同适应不同设备的时候唯一需要跟着变的参数是最大速度和最大转向力其他权重类参数不用动。这是我踩过的坑我一开始想通过整体缩放所有参数来适配性能较弱的设备结果发现权重类的比例关系被打乱了效果完全变了。正确做法是保持权重不变只把每帧的物理步长按帧率比例微调或者干脆保持固定步长不变只是让弱设备每秒少更新几步。后者会显得有点卡但行为逻辑是完全一致的。7.3 关于视觉调优的一点补充最后说一个坑关于视觉调优的方向。我见过很多人在这个阶段走偏看到画面不够好看第一反应是加特效——加发光、加粒子、加后期滤镜。这些东西短期确实能让画面更满但它们掩盖了核心问题如果去掉所有特效光靠鱼的运动本身不够好看那说明行为参数还没调好。我的做法是先用最简单的白色圆点做鱼把群集行为调到让我愿意盯着看三分钟不腻然后再开始加颜色和造型。这个顺序反过来做你会在特效能遮盖的问题上浪费大量时间最后加完特效发现还是不对劲却已经找不到根源了。调到我个人最满意的那一版参数是这样的分离权重 1.6对齐权重 1.0聚合权重 0.7感知半径 55 像素分离半径 18 像素最大速度 130 像素每秒最大转向力 220 像素每秒平方阻尼 0.99。这个组合跑出来的效果是鱼群在大部分时间里分成三到五股小群体缓慢游动偶尔因为惊扰源炸开成一大片然后在四五秒内重新聚集成新的分组整个过程没有明显的重复感。这套参数在我的笔记本上跑一千条鱼能保持在 55 帧上下用鼠标划过画布的时候鱼群会沿着鼠标的路径让开一条缝鼠标离开之后又慢慢合拢这个细节是我自己最喜欢的一个地方。
返回列表