
周末把盯了很久的一个小项目重新翻出来打磨了一遍取名叫 MiroFish。名字不必深究你可以把它理解成“一个能被观察的小鱼缸”——它没有服务端、没有依赖包、不需要联网打开一个 HTML 文件水里有二三十条鱼各自游各自的。它们会自发聚成松散的鱼群遇到太近的同伴会侧身让开看到沉底的食物会集体冲过去抢被鼠标快速划过会四散惊逃过一会儿又慢悠悠地聚回来。整个项目大概两千行不到的 JavaScript纯 Canvas 2D没有任何游戏引擎。我做这个东西的初衷很朴素市面上大部分“鱼缸屏保”本质上是一张贴图在循环播放看两分钟就腻了因为里面的鱼没有“意图”。而真正的鱼缸好看的地方在于不可预测——同样是喂食今天的鱼是慢吞吞地晃过去明天可能是抢成一团。这种不可预测性恰恰是可以用很轻量的行为算法模拟出来的。MiroFish 就是这个思路的落地它不追求画质追求的是行为上的“活”。这篇文章适合三类人看。第一类是想入门群体行为模拟、但被各种论文里的公式劝退的人我会把 Boids 的三个力用最直白的语言和可直接跑的代码讲清楚第二类是想做一个能放进个人主页或者副屏常驻的小玩意儿的人性能优化、移动端适配、配置文件设计这几个坑我都踩过第三类是已经在写类似模拟、但遇到了“所有鱼像复制粘贴”“帧率一崩行为就乱”这类问题的同学第六节的排查表应该能直接抄。全篇不讲虚的代码和参数都按我实际跑通的版本给。1. 项目整体设计从需求倒推出来的技术选型1.1 我到底想要一个什么样的鱼缸动手写代码之前我先花了一个晚上把需求写死在纸上因为这类“氛围型项目”最容易失控——写着写着就想加水草、加气泡、加光影、加小虾最后变成一个四不像。我给自己定了五条硬约束后面所有的技术选型都是被这五条倒逼出来的。第一条零依赖、零构建。不要 webpack不要 npm install双击 index.html 就能跑。理由很实际这东西我是想放在个人站点里当背景的如果引入构建流程半年后我自己都懒得更新它。第二条300 条鱼在同屏时稳定 60 帧低于这个数字鱼群就会出现明显的“黏滞感”。第三条行为必须与帧率解耦60Hz 屏和 144Hz 屏上鱼群的游动速度、群体密度必须一致否则换个显示器就变了个样。第四条物种、颜色、体型、性格倾向全部走配置文件代码里不许出现任何写死的美术参数。第五条手机竖屏能用手指点一下就是喂食。这五条里第二条和第三条是最要命的它们直接决定了后面渲染层和模拟层的所有结构。我可以先把结论摆在这里真正让这个项目从“能跑”变成“好跑”的不是 Boids 算法本身而是空间哈希和固定时间步这两个基础设施。前者把邻居查询从 O(n²) 拉到接近 O(n)后者把行为逻辑从渲染循环里彻底剥离出来。1.2 渲染方案选型为什么最后是 Canvas 2D一开始我认真评估过四种方案做了一个横向对比。这个表格不是事后编的是我当时真的列在笔记里、用来做决策的依据。方案300 个动态实体实测帧率上手成本灵活度主要痛点DOM CSS transform约 20 帧极低低每个实体一个节点重排开销爆炸Canvas 2D稳定 60 帧低高单线程绘制调用多时 CPU 压力大WebGL 手写稳定 60 帧可上 5000 条高极高着色器、批处理、坐标系转换门槛高现成 2D 引擎55 到 60 帧中中包体积大和“零依赖”约束冲突DOM 方案我是第一个淘汰的。很多人第一反应是“一条鱼就是一个 div改 transform 就行浏览器不是有 GPU 合成吗”理论没错但 300 个绝对定位元素在每一帧都改 transform 和 z-index样式重算和图层合成的代价远超预期。我实测到 40 条鱼的时候就已经掉到 45 帧左右了而且风扇开始转。WebGL 方案性能上是最优解但它的成本不在渲染在“为了一次性能提升我要写多少胶水代码”。鱼的形状如果要用程序化绘制尾巴摆动在 WebGL 里要么预烘焙精灵图集要么写 SDF 着色器两条路都会让项目从“周末项目”变成“两周项目”。而 Canvas 2D 里一条鱼就是一个quadraticCurveTo加一个三角形尾巴参数改了立刻见效调参体验好得多。最后的决定是用 Canvas 2D 打底把架构预留出切换到 WebGL 的空间。所谓预留就是渲染层只暴露一个drawFish(x, y, angle, params)接口模拟层完全不知道上层用什么画。这个决定在第六节讲的性能优化里救了我一次——当 300 条鱼在低端安卓机上还是掉帧时我只需要改渲染层模拟层一行没动。注意如果你的目标场景是 1000 条以上别学我直接上 WebGL并且在模拟层就用类型化数组Float32Array存位置和速度别用对象数组。对象数组在千级别时 GC 压力会非常明显。1.3 五层架构模拟层不碰渲染层项目文件结构上我刻意做了分层每层只允许单向依赖下面这层不知道上面这层存在。这样做的好处是每一层都可以单独测试我可以开一个没有渲染的world.step()循环跑一万帧看鱼群会不会发散、会不会有 NaN完全不需要打开浏览器。core 层向量数学、对象池、随机数发生器、空间哈希。这一层纯粹是工具和“鱼”没有半点关系。simulation 层Boids 三力计算、状态机、个体参数、食物系统、饥饿度。这一层只输出“位置 朝向 状态”绝不调用ctx。render 层Canvas 上下文、离屏精灵、绘制插值。这一层只读模拟层的数据绝不修改。input 层鼠标、触摸、键盘把原始事件翻译成“在 (x, y) 撒了一把食物”这类领域事件。config 层物种定义、主题配色、初始数量。所有可调参数集中在这一个文件里。这个分层看着有点“过度设计”但实际写下来最大的收益是调试效率。有一次我发现鱼群会周期性地集体抽搐如果是耦合代码我得一边怀疑渲染一边怀疑物理分层之后我直接跑纯模拟发现是空间哈希在鱼越过右边界时算出了负索引取到了错误的桶三分钟定位。还有一个细节模拟层的所有方法都禁止返回值用于渲染。也就是说world.step()不返回任何东西渲染层自己去读fish.x、fish.y。这避免了“每一步都要创建临时对象来传数据”的隐性开销下面讲性能优化时会看到这个约定有多重要。2. 鱼群行为的骨架Boids 三力怎么落地才不飘2.1 分离、对齐、聚合的数学表达与权重标定Boids 模型的核心就三条规则但真正决定鱼群“像不像鱼”的是权重的比例和每个力的归一化方式。先说我踩过的第一个大坑几乎所有入门教程里写的都是“把三个力直接相加”这样写出来的鱼群会瞬间塌缩成一团密集的球然后停在原地。原因很简单聚合力的原始公式是“指向邻居质心的向量”它的模长等于平均距离可能高达 70而分离力如果按 1/d 加权在近距离时又可能爆到几百。两个数量级不同的力相加等于只有一个力在起作用。我的做法是除了分离力其余全部先归一化到单位长度再乘权重。分离力则保留 1/d² 的衰减特性但额外做一次钳制防止两条鱼完全重叠时产生无穷大的力。下面是我线上跑的版本你可以直接抄参数。const BOIDS { perception: 70, // 感知半径像素决定鱼群松紧 sepRadius: 26, // 分离半径大约是鱼长的 1.2 倍 wSep: 1.6, // 分离权重 wAli: 0.9, // 对齐权重 wCoh: 0.7, // 聚合权重 maxSpeed: 1.8, // 每步最大位移固定步长 1/60 秒下约合 108 px/s maxForce: 0.08 // 每步最大速度增量控制转向柔和度 };这些数字是怎么定下来的先说sepRadius。我把它锚定在鱼体长度上成年鱼体长约 22 像素分离半径取 26也就是比一条鱼略长一点点。这个取法的直觉是——只有“快撞上了”才需要让路如果分离半径设到 50鱼群会变得极其松散像一群互相嫌弃的陌生人。perception取 70大约是分离半径的 2.7 倍这个比例让鱼能感知到“附近这一小撮同伴”的整体走向而不是只盯着身边两条。经验值是perception / sepRadius落在 2.5 到 3.5 之间最自然。权重方面wSep明显高于另外两个是刻意的。人的视觉对“碰撞”极度敏感两条鱼穿插而过的画面会立刻破坏真实感而对齐和聚合稍微弱一点反而显得自然——真实鱼群里本来就不是所有鱼都朝同一个方向。我试过把wSep提到 3.0结果鱼群变得非常分散每条鱼都在躲像一锅沸水降到 0.8 时鱼会成群地糊在一起像墨点。1.6 是我对着屏幕调了两个小时的最终值。maxForce这个参数特别容易被忽略但它决定了“鱼的味道”。它其实是每步允许的最大速度变化量本质上是加加速度限制。设成 0.08 时一条全速前进的鱼想掉头大概需要 40 步也就是 0.66 秒这个时间配合一个平滑的转向插值就能得到那种身体先摆、方向后到位的观感。设成 0.3 的话鱼会像是被无形的绳子猛地拽走非常廉价。2.2 空间哈希把邻居查询从 O(n²) 降下来300 条鱼如果每条鱼都遍历其余 299 条算距离每帧就是 9 万次距离计算乘以 60 帧就是每秒 540 万次。这个量级在现代 CPU 上其实勉强能扛但留给渲染的预算就没剩多少了而且一旦鱼数上到 800 就直接崩。所以空间哈希不是优化是必需品。原理不复杂但有几个细节特别关键我在代码里都标出来了。核心是格子边长必须等于或略大于感知半径这样查询邻居时只需要检查自己所在的格子加上周围八格3×3 的覆盖范围一定能包含所有在感知半径内的鱼。如果你把格子设得比感知半径小就得扩大查询范围到 5×5性能反而更差。class SpatialHash { constructor(cell, width, height) { this.cell cell; this.cols Math.ceil(width / cell) 1; this.rows Math.ceil(height / cell) 1; this.buckets new Array(this.cols * this.rows); for (let i 0; i this.buckets.length; i) this.buckets[i] []; } // 复用数组而不是每帧新建这一条能省掉大量 GC clear() { for (let i 0; i this.buckets.length; i) this.buckets[i].length 0; } // 用整数位运算取格坐标比 Math.floor 快很多 key(x, y) { let cx (x / this.cell) | 0; let cy (y / this.cell) | 0; // 关键钳制防止鱼越界时算出负索引取到错误的桶 if (cx 0) cx 0; else if (cx this.cols) cx this.cols - 1; if (cy 0) cy 0; else if (cy this.rows) cy this.rows - 1; return cy * this.cols cx; } }那个key()里的钳制就是我在 1.3 节提到的抽搐 bug 的根因。当时我的边界处理是在模拟结束后才把鱼位置夹回画布内但空间哈希是在模拟开始前建的于是越界的鱼带着负坐标进了哈希函数负数除以格子边长再取整会得到 -1 甚至 -3算出来的桶索引完全错位导致这一帧它周围的邻居全错产生一个巨大的修正力下一帧又被拉回来于是看起来就像在边界处反复抽搐。这个 bug 的教训是任何“世界坐标到某种索引”的映射都必须无条件钳制。2.3 边界处理贴缸壁这个坑的三种解法鱼缸边界看起来是小事实际上是整个项目里最影响观感的细节之一。我一共试过三种处理方式。第一种是“反弹”撞到边界就把速度分量取反。结果是最糟糕的鱼会在边界处反复弹跳像弹球游戏完全不是生物该有的行为。第二种是“穿越”从左边出去就从右边回来。这个对于横向卷轴来说没问题但在一个封顶的鱼缸里很怪鱼会凭空消失再凭空出现。第三种也是我最终采用的在边界内缩出一个软边界区进入这个区域的鱼会受到一个与深度成正比的向内力同时最大速度被压低。function applyBoundary(fish, w, h, margin) { const push 0.06; // 软边界的推力系数 if (fish.x margin) fish.ax push * (1 - fish.x / margin); else if (fish.x w - margin) fish.ax - push * (1 - (w - fish.x) / margin); if (fish.y margin) fish.ay push * (1 - fish.y / margin); else if (fish.y h - margin) fish.ay - push * (1 - (h - fish.y) / margin); // 兜底硬钳制防止极端情况下真的跑出去 if (fish.x 0) { fish.x 0; fish.vx Math.abs(fish.vx); } if (fish.x w) { fish.x w; fish.vx -Math.abs(fish.vx); } if (fish.y 0) { fish.y 0; fish.vy Math.abs(fish.vy); } if (fish.y h) { fish.y h; fish.vy -Math.abs(fish.vy); } }margin我取的是 90 像素大约是感知半径的 1.3 倍。这个取法有讲究如果 margin 小于感知半径鱼会先被聚合力和对齐力推向同伴然后才感受到边界推力两者会打架看起来就是“贴着墙犹豫”。margin 大于感知半径时鱼在边界处的行为由边界主导会自然地带着一点弧度拐回来观感上就像真的碰到了缸壁。另外我在软边界区加了一个小细节进入这个区域后鱼的转向抖动会被抑制也就是降低游荡力的强度。真实鱼在靠近障碍物时动作会变得更稳、更精确这个细节加上之后边界处那种“慌乱感”就消失了。3. 个体差异与状态机怎么让鱼不像复制粘贴3.1 四状态有限状态机只跑 Boids 的鱼群有个通病所有鱼都处在同一个“群体模式”于是整个画面永远是一种节奏看久了会腻。要打破这种单调必须有“不在群体模式”的鱼。我给每条鱼加了一个极简的四状态机。状态触发条件行为特征退出条件CRUISE默认状态全权重跑 Boids跟群检测到食物或受惊SEEK感知半径内有食物关闭对齐与聚合直线奔向最近食物吃到食物或食物消失FLEE惊吓事件在响应距离内分离权重拉到 4 倍忽略聚合计时器到期约 1.2 秒LINGER随机计时器到期且附近无同类最大速度降到 30%游荡力增强随机计时器到期2 到 6 秒LINGER这个状态是我觉得整个项目里最值的一个设计。它让一部分鱼会脱离群体在水草边慢慢晃悠、原地转小圈然后过几秒又回到队伍里。真实的鱼群从来不是铁板一块总有那么几条在“走神”。加上这个状态之后整个画面的层次立刻出来了——远看是一群鱼近看有主角有配角。状态切换的判定我用的是时间驱动而不是帧驱动每个状态都有一个stateTimer在固定步长里每次减去 1/60。这一点非常重要否则在 144Hz 的显示器上同样的LINGER状态维持的帧数不变但实际持续时间会缩短到原来的 40%鱼会变得异常焦躁。3.2 参数随机化消除复制粘贴感的三层做法这是我最想重点讲的一节因为“所有实体看起来一样”是这类项目最常见的失败原因而大部分教程为了简化会让所有个体共用同一套参数。我用了三层随机化来打破这个问题。第一层是速度与感知的个体差异。每条鱼出生时maxSpeed会在基准值的 0.8 到 1.2 倍之间随机perception在 0.6 到 1.3 倍之间随机。这个区间是有讲究的如果maxSpeed差异超过 ±30%快的鱼会甩开慢的鱼鱼群会解体成小组差异太小时又看不出层次。±20% 是我实测下来既能分出“领头鱼”和“跟班鱼”、又不会散架的甜点。第二层是行为权重的偏移。每条鱼的wCoh会有一个 ±25% 的乘性扰动。这意味着有些鱼天生更爱扎堆有些鱼更独。这个偏移不要太大因为它直接关系到群的稳定性超过 ±40% 之后会出现“社交型”和“孤僻型”两极分化画面会显得割裂。第三层也是效果最明显的一层尾摆相位与频率偏移。每条鱼有一个固定的随机相位0 到 2π和一个 0.9 到 1.4 倍于基准的尾摆频率。相位决定了尾巴摆动的起始时刻如果所有鱼相位相同它们会像仪仗队一样整齐划一地摆尾那种诡异感看一眼就出戏。加上随机相位之后即便是并排游动的两条鱼尾巴也是错开的视觉上立刻“活”了。function createFish(x, y, species) { return { x, y, vx: Math.cos(Math.random() * Math.PI * 2), vy: Math.sin(Math.random() * Math.PI * 2), ax: 0, ay: 0, speedMul: 0.8 Math.random() * 0.4, // 速度个体差异 perceiveMul: 0.6 Math.random() * 0.7, // 感知个体差异 cohMul: 0.75 Math.random() * 0.5, // 群体性个体差异 tailPhase: Math.random() * Math.PI * 2, // 尾摆相位 tailFreq: 0.9 Math.random() * 0.5, // 尾摆频率 state: CRUISE, stateTimer: 0 }; }提示随机化的关键是“出生时随机一次之后固定”。千万不要每帧对参数做随机扰动那会让鱼的行为退化成布朗运动看起来像是癫痫发作。差异要来自个体属性不是来自逐帧噪声。逐帧噪声只用在“游荡”这一个力上而且要用平滑噪声不能用Math.random()。3.3 转向平滑为什么不要直接 atan2拿到速度向量之后很多人会直接Math.atan2(vy, vx)得到朝向然后立刻用这个角度绘制。结果就是鱼会在转向时“瞬间平移”身体朝向跳变非常廉价。我的做法是给每条鱼维护一个独立的heading角度它不等于速度方向而是以有限角速度追向速度方向。每步最多转过maxTurnRate × dt弧度我取的是 4.5 弧度每秒也就是大约 0.72 秒转一整圈。这样在急转弯时鱼会先甩头、再换向有一个非常自然的“身体跟不上意图”的滞后感。const MAX_TURN 4.5; // 弧度/秒 function updateHeading(fish, dt) { const target Math.atan2(fish.vy, fish.vx); let diff target - fish.heading; // 归一化到 [-π, π]否则会绕远路 while (diff Math.PI) diff - Math.PI * 2; while (diff -Math.PI) diff Math.PI * 2; const maxStep MAX_TURN * dt; if (diff maxStep) diff maxStep; else if (diff -maxStep) diff -maxStep; fish.heading diff; }那个角度归一化的while循环是必须的。少了它当目标角度从 -179 度变到 179 度时diff会是 358 度鱼会绕一大圈反向转过去看起来就像被什么东西拽了一下。这类角度环绕的错误在第一次写的时候几乎一定会遇到而且因为只在跨越 ±π 的瞬间出现很容易被忽略。排查方法很简单把朝向画成一条短线盯着它转圈如果在某个固定角度附近突然抽搐就是这个问题。4. 渲染与性能稳定 60 帧的实测优化路径4.1 固定时间步加插值渲染这是我认为全篇最重要的一个技术点也是决定项目“专业感”的分水岭。最开始的写法是最朴素的requestAnimationFrame里算出两帧之间的时间差dt直接传给物理更新。在 60Hz 屏幕上没问题但一换到 144Hz 屏幕dt变成 1/144每条鱼每帧的位移变小但帧数变多总体位移不变——听起来是等价的实际上不是。问题出在 Boids 的力是“非线性”的。分离力用了 1/d² 的衰减聚合力和对齐力涉及归一化这些运算都不满足“小步长多次”等于“大步长一次”的性质。结果就是在 144Hz 上鱼群更松散、转向更灵敏在 30Hz 的低端机上鱼群会糊成一团。更糟的是当帧率剧烈波动时比如你突然切了一下标签页dt会突然变成一个很大的值一次物理更新会让鱼瞬移出屏幕然后被边界钳制拉回来画面直接崩坏。解决方案是经典的三件套固定步长累加器、最大步数守卫、插值渲染。const STEP 1 / 60; // 物理固定步长 const MAX_STEPS 5; // 单帧最多追赶 5 步防止死亡螺旋 let accumulator 0; let lastTime performance.now(); function loop(now) { // 关键一把 dt 钳制在 100ms 以内 const raw (now - lastTime) / 1000; lastTime now; const dt Math.min(raw, 0.1); accumulator dt; // 关键二守卫计数防止低端机上无限追赶 let steps 0; while (accumulator STEP steps MAX_STEPS) { world.step(STEP); accumulator - STEP; steps; } // 追不上就丢弃剩余时间宁可掉帧也不要卡死 if (steps MAX_STEPS) accumulator 0; // 关键三把剩余的时间比例传给渲染层做插值 renderer.draw(accumulator / STEP); requestAnimationFrame(loop); }为了让插值有意义模拟层需要保存上一帧的位置。渲染时不直接用fish.x而是用lerp(prevX, x, alpha)其中alpha是累加器里剩余的时间比例。这一步看起来只多了一行代码但它让 60 帧的物理输出在 144Hz 屏幕上呈现出 144 帧的顺滑度鱼在慢速游动时完全没有阶梯感。那三个“关键”注释里的每一句都是我用血换来的。dt钳制是因为我曾经切出去看了一篇文章再切回来画面上的鱼全部飞到了角落堆积成一块MAX_STEPS守卫是因为在低端安卓机上某一帧卡了 200ms累加器要求追赶 12 步追赶本身又要花时间下一帧又要追赶 14 步直接进入死亡螺旋页面无响应。4.2 程序化绘制与对象池画鱼的部分我没有用图片全部是 Canvas 路径程序化绘制。一是因为零依赖约束里不想带图片资源二是因为程序化绘制让physicalSize、身体宽窄、尾摆幅度全都可以按个体参数走每条鱼都略有不同这是图片序列帧做不到的。function drawFish(ctx, fish, alpha) { const x lerp(fish.prevX, fish.x, alpha); const y lerp(fish.prevY, fish.y, alpha); ctx.save(); ctx.translate(x, y); ctx.rotate(fish.heading); const len fish.bodyLen; const t world.time * fish.tailFreq fish.tailPhase; const swing Math.sin(t) * len * 0.16; // 尾部摆动幅度 // 身体两个二次曲线拼成的纺锤形 ctx.beginPath(); ctx.moveTo(len * 0.5, 0); ctx.quadraticCurveTo(0, -fish.bodyW, -len * 0.42, swing * 0.5); ctx.quadraticCurveTo(0, fish.bodyW, len * 0.5, 0); ctx.fillStyle fish.color; ctx.fill(); // 尾巴一个随摆动角变化的三角形 ctx.beginPath(); ctx.moveTo(-len * 0.42, swing * 0.5); ctx.lineTo(-len * 0.62, swing * 0.5 - fish.bodyW * 0.85); ctx.lineTo(-len * 0.62, swing * 0.5 fish.bodyW * 0.85); ctx.closePath(); ctx.fillStyle fish.finColor; ctx.fill(); ctx.restore(); }程序化绘制的代价是每帧要发起 2 次beginPath和 2 次fill300 条鱼就是 1200 次路径操作。实测下来这部分的 CPU 开销大约占单帧的 35%比物理计算还高。这里能做的最有效优化是离屏画布缓存为每个物种预渲染出 8 个尾部摆动相位的关键帧运行时按当前相位选最接近的一帧做drawImage。我把这个优化加上之后绘制耗时从 6.2ms 掉到 1.8ms。但注意缓存有个前提旋转必须用setTransform而不是逐个rotate加save/restore。save/restore会记录和恢复整个上下文状态栈300 次调用很贵。改成一次性ctx.setTransform(cos, sin, -sin, cos, x, y)直接设置变换矩阵能再省下来 1ms 左右。对象池则是解决另一个问题的物理计算里需要大量的临时向量。最开始的代码里我每帧每条鱼都new Vector2()好几次300 条鱼乘以 60 帧就是每秒 5 万多次对象分配GC 每隔几秒就会触发一次表现为画面周期性地卡一下。改成预分配的对象池之后这个卡顿完全消失。// 极简对象池只适合生命周期短的临时向量 const pool []; let poolIndex 0; function tmpVec() { if (poolIndex pool.length) { pool.push({ x: 0, y: 0 }); } const v pool[poolIndex]; v.x 0; v.y 0; return v; } // 每一帧开始时重置索引池里的对象可以无限复用 function resetPool() { poolIndex 0; }这个池的用法有一个强约束池里的对象只在当前这一帧内有效绝对不能存起来跨帧引用。因为下一帧resetPool()之后同一个对象可能已经被别的计算覆写了。我在第一次实现时就把一个池对象存进了鱼的属性里当“上一帧位置”结果鱼的插值轨迹变成了一团乱麻排查了半天。4.3 优化前后的实测数据为了给这些优化一个明确的交代我在一台四年前的笔记本集成显卡和一台中端安卓手机上分别跑了 60 秒取平均帧率和 1% Low 帧率。1% Low 比平均帧率更能反映卡顿感所以我把两个指标都列了出来。优化阶段鱼数量桌面平均帧率桌面 1% Low手机平均帧率关键改动初始版本120583141朴素循环每帧 new 向量加空间哈希300604738O(n²) 降到近 O(n)加对象池300605849消除 GC 抖动加离屏缓存300605955绘制耗时降到 1.8ms加 DPR 限制300606059高分屏渲染分辨率封顶 2 倍注意第二行到第三行平均帧率没变都是 60因为已经封顶了但 1% Low 从 47 涨到 58。这意味着体感从“大部分时候流畅偶尔顿一下”变成了“一直很稳”。很多人优化只盯平均帧率这在这类模拟项目里是会误判的因为卡顿感几乎完全由最差的那几帧决定。DPR 限制这一条单独说。高分屏手机上devicePixelRatio可能是 3意味着画布的实际像素数是逻辑尺寸的 9 倍。我一开始按 DPR 全量放大画布结果渲染面积暴涨鱼是清晰了但帧率掉到 40 以下。最后的方案是把 DPR 封顶在 2并且把画布尺寸取整到偶数避免亚像素模糊。对于这种以动态模糊和色彩为主、没有文字和细线条的画面2 倍 DPR 和 3 倍 DPR 的肉眼差异几乎看不出来但性能差异有 30% 以上。5. 交互设计与配置化让它真的能被玩起来5.1 喂食、惊扰、呼唤三种交互交互是我后期才补上的但它直接决定了这个项目是“屏保”还是“玩具”。我做了三种每种的行为反应都不一样这也是检验状态机是否好用的试金石。第一种是点击喂食。点击位置生成 5 到 8 颗食物颗粒它们会缓慢下沉并带一点水平漂移。食物颗粒进入某条鱼的感知半径后这条鱼进入SEEK状态此时对齐和聚合被完全关闭——因为抢食的鱼不会排队一定是各奔各的。吃到食物后鱼的satiety属性加一satiety高于阈值的鱼在接下来一段时间内会主动降低对食物的敏感度表现为“吃饱了懒得动”。这个设计让连续喂食不会变成所有鱼永久冲锋画面有节奏。第二种是快速划动惊扰。鼠标快速移动时我按移动速度计算一个“惊扰强度”在移动路径附近一定距离内的鱼进入FLEE状态分离权重临时拉到 4 倍同时最大速度提升 40%。这里有个细节我没有让鱼直接朝远离鼠标的方向跑而是保留 Boids 的分离力只是把它放大。这样鱼群会像一团被搅动的沙子一样整体散开再重组而不是像射线一样四散飞走观感真实得多。第三种是长按呼唤。长按超过 400ms 后以鼠标位置为中心在半径 200 像素内的鱼会进入一个弱吸引状态聚拢过来但不会挤成一团。这个交互对拍摄或者录屏很有用——你可以把鱼群“赶”到画面的一角构图就出来了。注意鼠标坐标转画布坐标必须用getBoundingClientRect()换算不能直接用event.offsetX。因为画布可能被 CSS 缩放响应式布局里几乎一定会offsetX是缩放后的 CSS 像素而画布内部的坐标是逻辑像素直接用会导致交互位置偏移屏幕越大偏得越多。function toWorldCoords(canvas, evt) { const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; // 注意用 CSS 尺寸做分母 const scaleY canvas.height / rect.height; const raw evt.touches ? evt.touches[0] : evt; return { x: (raw.clientX - rect.left) * scaleX, y: (raw.clientY - rect.top) * scaleY }; }5.2 配置驱动的物种系统所有的美术和性格参数都集中在一份配置里这是我一开始就定下的硬约束。好处是我可以在一分钟内换掉整个鱼缸的性格把perception全线调低、cohMul全线调高就得到一缸胆小的群居鱼反过来就是几条散漫的独行鱼。配置项类型说明常见取值idstring物种标识tiny / normal / bigcountRationumber初始数量占比会自动归一化0.5 / 0.3 / 0.2bodyLen[min, max]体长范围像素[12, 16] / [20, 26]bodyWnumber身体半宽决定胖瘦0.28 到 0.36 倍体长colorstring主体色建议用 HSL 便于批量调色相hsl(28, 72%, 58%)finColorstring鳍与尾部颜色略深于主体色speedScalenumber相对基准速度的倍率大鱼 0.85小鱼 1.15cohesionBiasnumber群体性倾向的整体偏移0.7 到 1.4tailFreqBasenumber尾摆基准频率1.0 到 1.6这里有一个很容易被忽略的设计点颜色用 HSL 而不是十六进制。因为我希望同一缸鱼的体色有细微的色相抖动用 HSL 的时候只需要给h加一个 ±8 的随机偏移、给l加 ±5 的偏移就能得到一组和谐又有变化的颜色。用十六进制就得先转 RGB 再插值代码量和心智负担都更大。这条经验同样适用于任何需要“同色系生成”的场景。另外countRatio我用了比例而不是绝对数量。因为鱼缸的总容量是浮动的取决于屏幕大小如果写死“20 条大鱼 30 条小鱼”在小屏手机上就会过度拥挤。改成比例之后总数由屏幕面积除以每条鱼的平均占地反推出来代码里只需要一个totalFish clamp(area / 5200, 30, 320)就够了。5.3 移动端适配的两个必需动作移动端我踩的坑主要在两个地方。第一个是触摸事件必须处理passive和preventDefault的组合。为了让喂食和滚屏不冲突我给画布的touchstart加了{ passive: false }并且只在落点位于画布内时才preventDefault否则页面就没法滚动了——这个体验非常伤用户会以为页面卡住了。第二个是移动端的devicePixelRatio变化。从竖屏切横屏时DPR 有时会变尤其是折叠屏和部分安卓机如果只在初始化时读一次 DPR转动屏幕后整个画面的缩放就全错了。我的做法是监听resize在回调里重新计算画布尺寸、DPR 和空间哈希的网格维度然后保留所有鱼的位置但按比例缩放而不是重建世界。重建世界会让鱼群突然全部重新随机分布观感上像换了一缸鱼。空间哈希重建这一步尤其要注意网格的cols和rows变了所有旧的桶索引都失效必须clear()之后重新插入但不能替换 buckets 数组本身否则新数组需要重新分配在 resize 频繁触发时会产生明显的抖动。我现在的实现是保留 buckets 数组只重新计算cols、rows和cell然后按新的维度重算索引——如果新的总桶数比旧的多就补足空数组比旧的少就只用到前 N 个。6. 常见问题与踩坑实录6.1 症状到根因的排查速查表这张表是我把整个开发过程中遇到的所有问题整理出来的按“症状描述”“最可能的根因”“排查动作”“修复方式”四列组织。遇到问题的第一反应应该是查表而不是改参数试。症状最可能的根因排查动作修复方式鱼群塌缩成一坨不动聚合力和分离力的量级不匹配单独把三个力画成向量看模长除分离力外全部归一化后再乘权重边界处反复抽搐空间哈希索引未钳制越界坐标取到错误桶在 key() 里打日志看是否出现负索引对格坐标做无条件 clamp换个显示器鱼群行为变了物理更新直接用了渲染 dt打印每帧 dt看是否随刷新率变化固定时间步 累加器 插值所有鱼尾摆完全同步尾摆相位统一检查 tailPhase 是否被随机化出生时随机相位逐帧不改周期性卡顿每隔几秒每帧新建向量对象触发 GC用性能分析器看 GC 频率引入对象池帧内复用切标签页回来后鱼全在角落dt 未钳制一次物理更新过大在 loop 开头打印 raw dtdt 钳制到 0.1 秒以内页面在低端机上无响应累加器无上限进入追赶死亡螺旋打印每帧的 steps 次数加 MAX_STEPS 守卫超出则丢弃剩余时间点击位置和鱼的实际反应位置有偏移画布被 CSS 缩放坐标未换算对比 clientX 与 canvas.width 的比例用 getBoundingClientRect 换算高分屏手机上帧率腰斩DPR 全量渲染像素数平方级增长打印 canvas.width 与逻辑宽度之比DPR 封顶在 2尺寸取偶旋转屏幕后画面缩放全错DPR 只在初始化时读取打印 resize 前后的 DPR监听 resize 并重算保留鱼的位置鱼在两帧之间出现阶梯感渲染用了模拟的离散位置在慢速游动时观察保存上一帧位置按 alpha 做线性插值吃食的鱼排队一样整齐抢食时仍在跑聚合与对齐检查 SEEK 状态的权重开关SEEK 下关闭对齐和聚合6.2 一些不太常规但很管用的调参心得第一先调速度再调力最后调权重。很多人一上来就调三个权重越调越乱。正确的顺序是把maxSpeed定在一个“看起来像鱼”的基准上我的经验值是每秒 100 到 130 像素鱼长 20 像素左右也就是每秒游过 5 到 6 个身位然后调maxForce让转向柔和度对了最后再微调三个权重去控制群体的松紧。顺序错了的话速度和力的不匹配会掩盖权重的效果。第二关掉渲染调行为。我专门留了一个调试开关按住某个键时渲染层直接跳过绘制只跑物理。这时候帧率会飙到几百好处是能快速判断问题是在模拟层还是在渲染层。如果关掉渲染后鱼群还是抽搐那 100% 是模拟的问题如果关掉之后一切正常那问题在绘制或者上下文状态上。第三给随机化留一个种子开关。调试的时候我用固定种子每次刷新出来的鱼群布局和性格分布完全一样这样才能复现问题、对比改动效果。上线时切成时间种子每次打开都是新的一缸。这个开关加进来只花了十行代码但把我调试边界 bug 的时间从两小时压到了二十分钟。第四警惕“参数联动”。我改perception的时候sepRadius其实也应该跟着动因为空间哈希的格子边长必须 perception。有一次我把perception从 70 调到 120 忘了改格子边长结果邻居查询范围不够鱼群会随机地漏掉一半邻居表现为“忽聚忽散”像有隐形的手在搅动。后来我把cell perception × 1.05写成了一行自动推导的代码这个类问题就再也没出现过。第五不要追求物理正确。这个项目里我做过好几次“更严谨”的尝试比如给鱼加速度阻尼、加流体阻力模型、算真实的转向半径约束结果观感反而变差。真实的物理会让鱼看起来很“重”而观众对水下生物的心理预期是轻盈的。所以最终的参数里我保留了几个明显不物理的东西比如无阻力匀速巡航、瞬时速度钳制。这类模拟项目的目标是“看起来对”不是“算得对”。6.3 后续可以往外扩的几个方向我在收尾的时候列了一张扩展清单都是低成本但有明显观感收益的如果你也打算做类似的东西可以直接拿去用。优先级最高的三个是阴影层在每条鱼下方偏移几个像素画一个低透明度的深色椭圆鱼群立刻就有了“离水底的高度”这个维度感折射光斑用一层可平铺的径向渐变以极低透明度缓慢横移模拟水面透光成本几乎为零桶链改造把空间哈希的数组桶换成带next索引的链表用两个Int32Array实现可以在不产生任何数组操作的前提下完成插入和查询我实测在 800 条鱼时能再省 2ms。再往后如果需要更进一步那就是把模拟层整体迁到Worker里主线程只做渲染通过SharedArrayBuffer共享位置数据。这条路收益很大但也有代价调试会变麻烦而且需要处理跨线程的时序问题。对于一个周末项目来说我觉得已经过度了但如果你打算把它做成一个正经的可视化演示这是个值得投入的方向。我个人在反复折腾这个项目的体会是行为模拟里真正难的部分从来不是算法本身——Boids 的三条规则看十分钟就能懂——难的是那一堆看起来不重要的小数分离半径到底是 24 还是 26尾摆相位差到底是随机到 2π 还是只到 πDPR 封在 2 还是 1.5。这些数字没有标准答案只能靠一次次盯着屏幕改、改完刷新、再盯着看。但恰恰是这些数字决定了你做的是一缸“会动的贴图”还是一缸让人愿意盯着看五分钟的鱼。