ARTICLE DETAIL

资讯详情

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

从Boids到WebGL:千级鱼群互动仿真与空间网格性能优化

从Boids到WebGL:千级鱼群互动仿真与空间网格性能优化 上个月接了个水下场景的互动装置项目甲方要一群鱼在超宽屏上自己游起来不是循环播放的序列帧而是真的会绕着鼠标位置聚集、被快速划过的手势惊散、过一会儿又重新成团。我第一反应是把收藏夹里躺了很久的 MiroFish 翻出来重做了一遍。MiroFish 本质上是一套轻量级的鱼群行为仿真方案核心是让每条鱼只关心自己视野内的一小撮邻居靠分离、对齐、聚合三条规则自下而上长出群体感而不是给整群鱼写一条预设轨迹。它适合三类人想给游戏或互动装置加活体氛围的前端和 TA做生态科普、水族馆展陈的可视化开发者以及单纯想搞明白群体智能到底怎么落地、又不想啃论文的人。我把这套东西从算法推导、参数计算、代码落地到性能优化完整走了一遍下面全是实操里攒下来的东西包括几个把我卡了两天的坑。1. 项目定位与整体设计思路1.1 MiroFish 到底解决的是什么问题鱼群仿真这个需求比想象中普遍。游戏里的水下关卡需要背景鱼群撑场面互动装置需要观众一动就有反馈科普展馆要演示为什么鱼能成群影视预演阶段经常要快速出一版动态分镜。这几类需求的共同点是个体数量多几百到上万、单体不需要精密动画、但整体必须有活的感觉——会聚、会散、遇到障碍会绕、被惊扰会炸开再重组。传统做法无非三条路各有各的死穴。第一条是序列帧或预渲染视频。优点是好看美术成本一次性投入缺点是完全没有交互性观众伸手过去鱼照样按原路走穿帮得非常明显而且换个视角、换个屏幕比例就得重做。第二条是骨骼动画加状态机每条鱼独立播游动动画、独立做简单的寻路。能做交互但做不出群体涌现——你会看到一群鱼像上班打卡一样整齐地朝一个方向平移丝毫没有个体之间互相影响的痕迹。第三条是完整物理引擎每条鱼一个刚体加碰撞。真实感确实上去了但开销大得离谱我实测过某主流 2D 物理引擎300 个刚体加邻域碰撞检测桌面端就已经掉到 40 帧以下还要处理一堆穿模和抖动。MiroFish 的思路是绕开这三条路用多智能体集群行为仿真不给群体写剧本只给每条鱼写看邻居的本能规则群体形态自然涌现。它把问题拆成三层——仿真内核负责算每条鱼下一帧该往哪走渲染层负责把状态画出来交互层负责把外部的鼠标、触摸、传感器信号转换成力。三层之间只通过一块状态数组通信互不依赖。这个解耦非常关键后面讲性能优化和跨端复用时你会感谢它。1.2 为什么选 Boids 而不是寻路网格市面上做群体行为的方案主要有四种我把它们在鱼群场景下的表现整理成了一张表选型的时候直接对着看就行。方案核心机制群体涌现感交互响应千级个体开销适合场景A* 寻路个体独立规划到目标点的路径无个体互不感知改目标点即可高路径重算昂贵少量单位、有明确目标流场 Flow Field预计算一张方向网格个体采样弱像粒子沿河漂需重算流场低大量个体、单向流动Boids 集群邻居规则驱动力累加强动态成团加一个力场即可中可通过空间划分降低鱼群、鸟群、人群物理刚体碰撞约束求解中容易被卡住直接施加冲量极高少量、需精确碰撞A* 的死穴在于拥堵。个体越多路径越容易互相打架最后你会看到一堆鱼卡在同一个拐角互相推挤这在鱼群里是灾难性的画面。流场的问题相反个体之间毫无影响——每条鱼都只是读同一张网格最后呈现的是一堆沙子沿同一方向流缺少鱼群那种忽聚忽散的呼吸感。物理刚体前面说了开销劝退。Boids 的妙处在于它的涌现是双向的个体影响群体群体也反过来约束个体。你给某条鱼加一个被鼠标吓到的力它会躲开然后它躲开的动作会影响视野内的邻居邻居再影响邻居几秒钟内整个鱼群就会呈现出一个从扰动点扩散出去的波。这种传播效应是流场和 A* 都给不了的也是鱼群看起来活的根本原因。代价是 Boids 的朴素实现是 O(n²) 的——每条鱼都要和所有其他鱼比一遍距离。1 万条鱼就是 1 亿次比较每帧跑一次直接爆炸。所以 MiroFish 真正的工程量不在那三条规则上而在如何把邻居查询从 O(n²) 压到接近 O(n)。这部分我放在第 4 章详细说。1.3 三层架构的分工与边界MiroFish 被我拆成了三块各自的职责边界卡得很死。仿真内核是纯数学不碰任何渲染 API。它维护两组数组位置数组和速度数组每帧根据邻居状态算出加速度更新速度和位置。这一步完全可以用 Float32Array 表达跑在 Worker 里也毫无压力。内核的接口只有两个step(dt)推进一步getState()返回状态数组。它不知道鱼长什么样也不知道画在哪个 canvas 上。渲染层只做一件事读状态画出来。它不参与任何决策。这样做的好处是渲染和后端可以完全解耦——你可以在 Canvas 2D 上画三角形也可以切到 WebGL 用实例化渲染画贴图精灵甚至可以把状态数组通过 WebSocket 发到另一台机器上去画内核一行都不用改。交互层负责把外部刺激翻译成力。鼠标位置变成一个排斥力场触摸点、摄像头识别的骨骼点、音频频谱、陀螺仪数据全都可以接进来当力源。MiroFish 里我留了一个统一的addForceField()接口任何东西只要能输出位置 强度 半径就能往里塞。这个设计在互动装置场景下救了我——甲方临时要求加一个雷达传感器感知观众距离我只写了一个 30 行的适配器就接上了没动内核。顺带说一句这个分层还有个隐性好处可测试性。内核不依赖 DOM我可以用纯 Node 跑 1000 步把位置快照导出来做回归对比。调参的时候最怕的就是改了一个权重整体行为全变了但不知道变在哪有了快照对比参数改动的影响一目了然。2. 核心算法拆解三条规则如何长出鱼群2.1 分离、对齐、聚合的数学表达Boids 的三条规则名字很直白但真写起来每个的向量计算方向和归一化处理都有讲究处理错了鱼群要么瘫成一团要么炸成烟花。分离Separation对视野内距离小于分离半径的邻居产生一个远离它们的力。注意这里要做反距离加权——离得越近排斥越强。我见过很多实现是每个近邻给一个等大的反向单位向量然后求平均结果就是两条鱼贴脸的时候排斥力不足会纠缠好几帧。正确的做法是累加(self.pos - other.pos) / distance这样距离趋近 0 时力会急剧增大实际效果类似于给鱼一个贴身软碰撞既不穿模也不僵硬。对齐Alignment求出视野内所有邻居的平均速度方向然后朝这个方向修正自己的速度。这里有个细节——求的是速度的平均不是平均速度的方向的归一化。如果你先归一化每个邻居的速度再平均那速度快慢的信息就丢了鱼群会变得没有集体冲刺的感觉。直接对速度向量求算术平均得到的是一个既包含方向也包含快慢信息的向量用它去减自己的速度得到的就是转向力。聚合Cohesion求出邻居的质心朝质心方向产生一个力。质心的计算要注意是位置的平均不是加权平均。有实现会给近邻更高权重效果是鱼群会形成更紧密的小核心视觉上像一坨一坨的球我个人不太喜欢还是均匀平均更自然。三条力算完之后按权重叠加再限制一下总的转向力上限maxForce加到速度上速度再限制上限maxSpeed最后更新位置。整个流程用伪代码写出来大概是这样// 每条鱼每帧执行 let accel { x: 0, y: 0 }; // 视野内邻居集合neighbors // 分离半径内邻居closeNeighbors // 1. 分离 for (const other of closeNeighbors) { const dx self.x - other.x; const dy self.y - other.y; const d2 dx * dx dy * dy; if (d2 0.0001) { accel.x dx / d2; accel.y dy / d2; } } accel normalize(accel) * maxSpeed - self.v; // 2. 对齐 const avgVel average(neighbors.map(n n.v)); steer normalize(avgVel) * maxSpeed - self.v; // 3. 聚合 const centroid average(neighbors.map(n n.pos)); steer normalize(centroid - self.pos) * maxSpeed - self.v; // 汇总并限幅 total sep * wSep ali * wAli coh * wCoh; total limit(total, maxForce); self.v limit(self.v total, maxSpeed); self.pos self.v * dt;权重我最后定下来的是分离 1.6、对齐 1.0、聚合 0.9。分离权重必须明显高于其他两个不然鱼群会挤成一团。具体的比例和 maxSpeed 有关下面单独说。2.2 视野锥与邻域搜索的边界条件视野内这三个字实现了才知道有多少坑。最粗暴的做法是圆形半径距离小于感知半径就算邻居。这个做法简单但鱼的视野其实是有前后方向的——鱼看不见身后的东西。所以 MiroFish 用的是视野锥既要距离够近又要方向在角度范围内。方向判断用点积不需要算角度const dx other.x - self.x; const dy other.y - self.y; const dist Math.hypot(dx, dy); if (dist perceptionRadius || dist 0) continue; // 单位化方向向量 const nx dx / dist, ny dy / dist; // 自身速度方向 const speed Math.hypot(self.vx, self.vy); if (speed 0.0001) continue; const fx self.vx / speed, fy self.vy / speed; // cos(半角) 与点积比较省掉 acos const cosHalfFov Math.cos(fovHalfAngle); if (nx * fx ny * fy cosHalfFov) continue;这里的关键是用Math.cos预计算阈值避免每次调用Math.acos。acos是超越函数1 万条鱼每帧调 1 万次性能损耗相当可观。把cos(fovHalfAngle)在初始化时算好存成常量每帧只做一次乘加和比较快得不是一点半点。视野半角我用的 110 度也就是fovHalfAngle 110°。小于 90 度鱼群反应太迟钝转头要转半天大于 140 度就接近全向视野背后的鱼也会影响它视觉上会很怪——你能看到一条鱼被身后的看不见的手推了一下。还有个容易忽略的边界条件当自身速度接近 0 时视野锥方向无法定义。归一化会除以 0 得到 NaN一个 NaN 混进数组几帧之内整个鱼群全变 NaN屏幕瞬间空白。我第一版就栽在这上面排查了半小时才发现是初始化时把所有鱼速度设成 0 导致的。解决办法是在速度方向计算里加一个下界保护速度低于阈值时直接跳过对齐和视野锥判断或者给一个随机的初始速度。注意所有除法都要防零。dx / d2、vx / speed、normalize()内部的取模运算这三个位置是 NaN 的高发区。写完内核第一件事就是全量扫一遍除法。2.3 让鱼群摆脱机械感的四个补丁严格按三条规则跑出来的鱼群能看但看久了会觉得像一群训练有素的无人机——太整齐、太同步。真实的鱼群有几个特征个体有差异、动作有延迟、边缘比中心松散、整体有缓慢的漂移。我加了四个补丁来解决。第一个是参数随机化。每条鱼的 maxSpeed、感知半径、三个权重都乘一个 0.85 到 1.15 之间的随机系数初始化时固定下来。这样跑起来你会发现鱼群会自然分层——快的鱼在外圈领游慢的鱼在内圈跟着视觉上的层次感立刻就出来了。这一步成本极低效果提升却最明显强烈建议第一个就加。第二个是反应延迟。给每条鱼的加速度加一个一阶滞后滤波accel accel * 0.7 prevAccel * 0.3。鱼是有惯性的生物不可能一帧之间完成转向。这个滤波会让鱼群的转向出现拖尾看起来柔软很多。第三个是噪声注入。每帧给加速度叠一个很小的随机扰动幅度大概是 maxForce 的 3% 到 5%。幅度太大会让鱼群看起来在抽搐太小又没效果。我试了七八组值最后定在 4%。除了白噪声用 Perlin 噪声做低频、空间连续的扰动效果更好——同一条鱼在连续几帧里受到方向相近的扰动看起来像是被水流推着走而不是像喝多了。第四个是边界处理。这块单独拎出来讲因为它决定了鱼群的第一观感。三种常见处理方式硬反弹、环绕wrap、软边界。硬反弹的问题是鱼会啪地一下撞墙反弹非常生硬环绕的问题是鱼从左边出去右边进来如果屏幕不是无缝重复的观众一眼就看出穿帮。我最后用的是软边界在距边缘一定距离内施加一个向内逐渐增强的力同时把视觉区域做得比逻辑边界小一圈鱼实际游到视觉边缘之前就已经开始转向了看起来就是游到边上自然而然拐了个弯。软边界的力曲线用二次函数force k * ((margin - dist) / margin)²二次曲线在边界处力为零、在边缘处力最大过渡非常平滑。3. 从零搭建实操流程与关键配置3.1 环境准备与项目结构技术栈我选了 Vite TypeScript。Vite 的冷启动和 HMR 快到让人忘记等编译TypeScript 在这种大量数值运算的项目里价值极高——把Vector2定义成{x: number, y: number}之后写错字段名立刻飘红省下大量调试时间。Node 版本建议 18 以上主要是为了 Worker 里的现代 API 支持。目录结构我这么分的mirofish/ src/ core/ simulation.ts // 仿真内核纯逻辑 spatialGrid.ts // 空间网格加速 boids.ts // 三条规则的具体实现 vector.ts // 向量运算工具 render/ canvas2d.ts // Canvas 2D 渲染器先用它跑通 webgl.ts // WebGL 实例化渲染器性能版 interaction/ forceField.ts // 力场统一接口 mouse.ts // 鼠标适配器 params.ts // 所有可调参数集中管理 main.ts index.html把参数全部集中到params.ts是一个刻意为之的决定。调 Boids 参数的时候你会疯狂改数值散落在各个文件里会让人崩溃。集中管理之后我甚至做了一个简单的 GUI 面板实时拖滑块调参效率翻倍。3.2 仿真内核的落地实现内核用的数据结构是结构数组SoAStructure of Arrays不是对象数组。这是性能的关键我单独在第 4 章展开这里先说接口。export class Simulation { private px: Float32Array; // 位置 x private py: Float32Array; // 位置 y private vx: Float32Array; // 速度 x private vy: Float32Array; // 速度 y private ax: Float32Array; // 加速度缓存 private ay: Float32Array; private speedScale: Float32Array; // 个体速度差异系数 private count: number; constructor(count: number, width: number, height: number) { this.count count; this.px new Float32Array(count); this.py new Float32Array(count); this.vx new Float32Array(count); this.vy new Float32Array(count); this.ax new Float32Array(count); this.ay new Float32Array(count); this.speedScale new Float32Array(count); for (let i 0; i count; i) { this.px[i] Math.random() * width; this.py[i] Math.random() * height; // 初始速度必须非零避免视野锥方向 NaN const angle Math.random() * Math.PI * 2; const speed 1 Math.random(); this.vx[i] Math.cos(angle) * speed; this.vy[i] Math.sin(angle) * speed; this.speedScale[i] 0.85 Math.random() * 0.3; } } step(dt: number, grid: SpatialGrid): void { const { px, py, vx, vy, ax, ay, speedScale } this; ax.fill(0); ay.fill(0); for (let i 0; i this.count; i) { const neighbors grid.query(px[i], py[i]); // 三条规则的力累加到 ax[i], ay[i] applyBoids(i, neighbors, this, grid); } for (let i 0; i this.count; i) { const maxSpeed BASE_SPEED * speedScale[i]; vx[i] ax[i] * dt; vy[i] ay[i] * dt; const sp Math.hypot(vx[i], vy[i]); if (sp maxSpeed) { vx[i] (vx[i] / sp) * maxSpeed; vy[i] (vy[i] / sp) * maxSpeed; } px[i] vx[i] * dt; py[i] vy[i] * dt; } } }这里有个设计选择值得说明固定时间步长。我没有直接把dt设为帧间隔而是把仿真按固定 1/60 秒推进累积时间超过一步就推一步最多连续推三步防卡顿。为什么这么做因为 Boids 是迭代系统参数是在特定步长下调出来的如果 dt 随帧率浮动60Hz 屏幕和 144Hz 屏幕上跑出来的鱼群行为会明显不同——高刷屏上鱼群更松散、反应更快。固定步长保证了跨设备一致性这在装置项目里非常重要因为你没法保证部署机器的刷新率。3.3 渲染层从 Canvas 2D 到 WebGL先用 Canvas 2D 把逻辑跑通绝对不要一上来就写 WebGL。原因很现实调试成本。Canvas 2D 里你可以随手画个圆圈、画条速度线肉眼就能看出鱼是不是在往正确的方向转WebGL 里出一张黑屏你得先排查是着色器编译失败、缓冲区没上传、还是坐标变换写错了一小时就过去了。Canvas 2D 阶段的渲染代码简单到不值得贴核心就是遍历位置数组画三角形三角形的朝向由速度方向决定for (let i 0; i count; i) { const angle Math.atan2(vy[i], vx[i]); ctx.save(); ctx.translate(px[i], py[i]); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(-5, 4); ctx.lineTo(-5, -4); ctx.closePath(); ctx.fill(); ctx.restore(); }但这套东西到 2000 条鱼就撑不住了save/restore/rotate每次都要操作变换矩阵栈开销巨大。我实测 2D 渲染 3000 条鱼不含仿真就只能跑到 25 帧。上千条鱼必须换 WebGL。WebGL 版本的核心思路是实例化渲染instanced rendering一次 draw call 画出所有鱼。每个实例传一个位置、一个朝向、一个缩放顶点着色器里根据实例属性做变换片元着色器采样同一张鱼形贴图。这样无论多少条鱼draw call 永远是 1。实例属性用一个大的 Float32Array 装着每帧更新位置和朝向两个字段然后调用bufferSubData整块上传。这里要注意的是缓冲区要用DYNAMIC_DRAW提示驱动会把它放到适合频繁更新的显存区域实测能快 15% 到 20%。朝向的表达我又踩了一个坑一开始我传的是角度值顶点着色器里做sin/cos变换。后来改成直接传速度向量的两个分量在着色器里用向量构造旋转矩阵省掉了每条鱼每帧两次三角函数。3000 条鱼的情况下这个优化省了大概 1.5 毫秒在 16.6 毫秒的总预算里不算小数目。3.4 参数调优把机械蜂群调成活鱼调参这件事没有标准答案但有一套可复用的流程。我的做法是先把所有参数拉到一个极端观察现象然后往回调这样比盲目微调快得多。第一步先调速度上限。这个参数决定了整体的节奏感。鱼缸里的小型鱼每秒大概游 2 到 3 个体长大型鱼慢一些。视觉上屏幕宽度是 1920 像素的情况下我最后定的是每秒 90 到 160 像素的基准速度配合个体差异系数实际速度在 76 到 184 之间。速度再快会有慌张感再慢就像在飘。第二步调感知半径。这个决定了鱼群的团块大小。半径太小鱼群会碎成很多小团各自为政半径太大整屏的鱼会抱成一坨。1920 宽的屏幕上60 到 90 像素的感知半径效果最好大约相当于屏幕宽度的 3% 到 5%。同时注意感知半径和空间网格的格子大小是绑定的第 4 章会说改半径的时候别忘了同步改格子。第三步调三条规则的权重比。我用的起点是分离 1.5、对齐 1.0、聚合 1.0然后微调到 1.6 / 1.0 / 0.9。调节的时候有个技巧一次只动一个且每次改动不超过 0.2。因为这三个权重是耦合的分离权重提上去鱼群会散开聚合显得弱了你会想加聚合一加又挤了来回震荡。稳住一个动一个才看得清。第四步调转向力上限maxForce。这个参数控制的是转向的敏捷度。太小了鱼转弯像航母掉头太大了鱼会在原地抽搐。经验值是和 maxSpeed 挂钩的大概取maxSpeed * 0.05到maxSpeed * 0.1之间。我最后用的是 0.07。下面这张表是我最后锁定的参数配置可以直接抄作业但记得根据你的画布尺寸和目标数量做比例缩放。参数数值说明调整方向maxSpeed160 px/s基准速度上限大屏按宽度等比例放大maxForce11 px/s²转向力上限约为 maxSpeed 的 0.07perceptionRadius75 px感知/视野半径屏幕宽度的 3% 到 5%separationRadius28 px分离半径约为感知半径的 0.37fovHalfAngle110°视野半角小于 90 迟钝大于 140 诡异wSeparation1.6分离权重不能低于对齐权重wAlignment1.0对齐权重基准wCohesion0.9聚合权重略低于对齐noiseAmplitude4%噪声幅度超过 8% 会抽搐boundaryMargin120 px软边界宽度屏幕短边的 10% 到 15%4. 性能优化让千级个体稳住 60 帧4.1 空间网格把 O(n²) 砍成 O(n·k)朴素的邻居查询是双重循环n 条鱼比较 n 次3000 条鱼就是 900 万次距离计算。我的实测数据是纯 JS 双重循环加完整的点积判断3000 条鱼单是邻居查询就要 8 毫秒左右加上后续的力计算和渲染直接超帧预算。空间网格的思路很朴素把画布切成大小等于感知半径的方格每条鱼只塞进自己所在的格子。查询邻居的时候只看自己和相邻的 8 个格子。因为感知半径等于格子边长超出相邻一圈的鱼距离必然大于感知半径不可能成为邻居直接跳过是安全的。格子边长有个讲究必须大于等于感知半径。如果格子比感知半径小一条鱼的邻居可能分布在两圈以外的格子里你就得扩大查询范围收益迅速下降。如果格子远大于感知半径每个格子里的鱼太多又退化成暴力搜索。所以cellSize perceptionRadius是最优解刚好一圈。用一张表对比一下不同格子大小下的表现3000 条鱼、感知半径 75格子大小平均每格鱼数单帧查询耗时是否命中邻居37 px半径一半约 2.66.2 ms需要查 5x5 范围75 px等于半径约 10.42.8 ms查 3x3 范围刚好覆盖150 px半径两倍约 41.65.1 ms查 3x3内部冗余多实现上网格我用的是计数排序式的结构先遍历一遍统计每个格子里有多少鱼算出每个格子的起始偏移再遍历一遍把索引填进去。整个过程只用两个 Int32Array没有任何对象分配。这一点很重要——如果每个格子用一个数组存每帧要创建和销毁上千个数组GC 压力会让帧率出现周期性抖动肉眼能看出来的那种一顿一顿。class SpatialGrid { private cellStart: Int32Array; // 每个格子的起始下标 private cellCount: Int32Array; // 每个格子的鱼数 private indices: Int32Array; // 按格子排好序的鱼索引 private cols: number; private rows: number; private cellSize: number; build(px: Float32Array, py: Float32Array, count: number) { this.cellCount.fill(0); // 第一遍统计 for (let i 0; i count; i) { const c this.cellIndex(px[i], py[i]); this.cellCount[c]; } // 前缀和算出每个格子的起始位置 let sum 0; for (let c 0; c this.cellCount.length; c) { this.cellStart[c] sum; sum this.cellCount[c]; } // 第二遍填充 const cursor this.cellStart.slice(); for (let i 0; i count; i) { const c this.cellIndex(px[i], py[i]); this.indices[cursor[c]] i; } } }这套结构每帧重建一次3000 条鱼重建耗时约 0.4 毫秒几乎可以忽略换来的是查询阶段的巨大提速。总体从 8 毫秒降到 2.8 毫秒接近 3 倍。4.2 TypedArray 与内存布局前面提到我用的是 SoA结构数组而不是 AoS数组的结构。这两种布局的差别在小数据量下看不出来到千级规模就是生死线。AoS 是对象数组[{x, y, vx, vy}, ...]。每个对象在堆上是散落的遍历的时候 CPU 缓存命中率很低而且 V8 需要维护对象的形状信息属性访问有额外开销。SoA 是每个字段一个Float32Array位置连续、大小固定、无装箱、无 GC 压力。我做过对比测试3000 条鱼的情况纯遍历更新的耗时 SoA 比 AoS 快了大约 40%。还有个容易被忽略的点是数组大小的预分配。不要在循环里push一开始就new Float32Array(maxCount)分配好用实际数量count控制有效范围。这样即使鱼的数量动态变化比如被吃掉、繁殖也不会触发数组扩容和内存搬迁。另外Float32Array和Float64Array的选择也有取舍。位置和速度用 Float32 精度完全够用——大约 7 位有效数字在世界坐标范围 10000 像素以内误差远小于 1 像素。但累加器比如求质心的中间变量建议用普通 number因为 JS 里Float32Array读取会先转成双精度再运算频繁读写反而有转换开销用局部变量累加然后一次性写回更快。4.3 Worker 分流与渲染批处理当鱼的数量超过 5000单线程已经很难在 16.6 毫秒里同时完成仿真和渲染。这时候把仿真挪到 Worker 里是唯一出路。这里的关键是用 Transferable ArrayBuffer 传输状态而不是结构化克隆。位置和速度数组每帧都要传给主线程如果走默认的结构化克隆每帧要复制几十 KB 数据光这一项就是几毫秒。用 transferable 是把所有权直接移交零拷贝。// Worker 侧 self.postMessage( { px: pxBuffer, py: pyBuffer, vx: vxBuffer, vy: vyBuffer }, [pxBuffer, pyBuffer, vxBuffer, vyBuffer] // 第二个参数是 transfer list );但 transfer 之后 Worker 侧就失去这块内存了下一帧得再传回来。所以标准做法是双缓冲准备两套数组Worker 算完 A 组传出去主线程渲染 A 组的同时 Worker 在算 B 组算完主线程把 A 组传回去如此循环。这样两边永远在不同步地干活没有等待。渲染侧还有一个优化是减少状态切换。WebGL 里切换到不同贴图、不同混合模式都要重新配置管线开销不小。鱼群的渲染应该做到一张图集装下所有鱼的不同朝向帧如果用序列帧的话一次绑定一次 draw call 全画完。深度排序也不要每帧做——鱼群的深度感用初始化的随机 z 值就够了排序一次管一辈子真的需要动态排序再说。5. 常见问题与排查技巧实录5.1 鱼群抖动、炸群和贴边堆积现象一鱼群整体高频抖动像通电一样。这是最常见的症状八成是力没有做归一化就直接累加。分离规则的原始输出是1/distance的形式距离很小的时候这个值会很大几条鱼叠加一下就爆了。解决办法是先归一化成单位向量再乘 maxSpeed或者直接在累加后做整体限幅。另一个可能原因是噪声幅度过大检查一下是不是超过了 maxForce 的 8%。现象二运行几秒后鱼群突然炸成一条直线飞出屏幕。这是经典的 NaN 污染。定位方法是每步之后扫一遍位置数组用Number.isFinite找第一个非有限值然后回溯它的索引看那条鱼的邻居查询里有没有除以零的地方。我遇到过的三个触发点分别是最初速度为零、两条鱼位置完全重合、软边界计算里边界宽度设成了 0。现象三鱼全挤在屏幕边缘贴着不动。这是边界力和内聚力的拉锯。边缘的排斥力够强把鱼推回来了但鱼又不停地被群体拉向边缘最后卡在力的平衡点上。解决办法是让软边界的作用范围别太窄boundaryMargin至少要能放下两三个感知半径的宽度另外可以把边界力的权重设得比聚合权重稍大一点保证它在边界区域占主导。现象四鱼群看起来像一坨在平移没有个体感。这几乎肯定是参数随机化没做所有鱼参数完全一致行为自然整齐划一。补上 0.85 到 1.15 的差异系数立刻改善。5.2 帧率骤降的定位套路帧率问题最忌讳瞎猜。我的定位流程固定是三步。第一步用 Performance 面板看火焰图先确定瓶颈在脚本、在渲染还是在合成。如果Scripting那一条超了 10 毫秒那就是仿真的问题如果Rendering超了就是 draw call 或片元着色器的问题。第二步如果确定是脚本分别打点测量仿真和邻居查询。我给内核加了几个performance.now()采样点去掉所有注释后跑一帧看数据。经验值3000 条鱼网格重建 0.4 毫秒、邻居查询加力计算 3 毫秒以内是正常的超过 6 毫秒说明有优化空间。第三步如果数值都正常但帧率还是低检查 GC。Memory 面板里勾选记录分配如果看到每帧都有新的小对象被分配就是有地方在偷偷创建对象。常见元凶是Array.prototype.map、解构赋值、以及返回新对象的向量工具函数。把这些全部改成原地修改和预分配帧率曲线会立刻变得平滑。5.3 常见问题速查表症状最可能的原因快速验证处理方式高频抖动分离力未归一化把分离权重设为 0 看是否消失归一化后乘 maxSpeed瞬间炸群NaN 污染每步扫描 Number.isFinite排查除法零值贴边堆积软边界范围太窄加大 margin 看是否改善margin 设为短边 10%整体平移无个体感参数完全一致检查是否所有鱼同参加 0.85~1.15 随机系数帧率周期性掉帧GC 抖动Memory 面板看分配消除每帧对象分配高刷屏行为不同dt 不固定分别在 60/144Hz 对比改固定时间步长鱼群碎成小团感知半径太小逐次调大观察提到屏幕宽 3%~5%转向生硬maxForce 过大调到一半观察设为 maxSpeed 的 0.076. 拓展玩法从这个内核还能长出什么6.1 捕食者与猎物给鱼群加一个天敌MiroFish 的力场接口天然支持加入捕食者。捕食者本质上就是一个特殊个体它自己没有分离对齐聚合但它进入普通鱼的感知范围时会对这些鱼施加一个数量级更高的排斥力同时降低了它们对内聚力的权重——被追的鱼会四散而不是还想抱团。这个改动带来的视觉效果远超预期。原本均匀的鱼群在有捕食者之后会形成缺口捕食者游过的地方会留下一条明显的空通道几秒钟后才慢慢弥合。这就是鱼群在真实纪录片里的那种表现。参数上我做了一个细节被惊吓的鱼会有 1 到 2 秒的警戒状态在这个状态下它的 maxSpeed 提高 30%、聚合权重降低一半警戒状态结束后再恢复。用一个简单的计时器实现效果比永久性的状态改变真实得多。再往前一步可以加能量模型让鱼有饥饿度、疲劳度捕食者捕到鱼之后能量恢复鱼群数量动态变化。这时候整个系统就更接近一个生态模拟而不是视觉特效了适合做科普展项。6.2 生成艺术与音频可视化联动鱼群的位置和速度是天然的生成艺术素材。我做过一个实验版本把每条鱼的轨迹用半透明的短线画出来不清屏让画面自然累积成一层一层的纹理跑十分钟之后得到的画面像流体画。这里面有个技巧是用globalCompositeOperation lighter做加法混合颜色会互相叠加变亮比默认的覆盖模式层次感强得多。音频联动也很直接把频谱的低中高频分别映射到三个权重上。低频鼓点映射到分离权重鼓点一响鱼群就炸开中频映射到速度上限旋律起来鱼群游得快一些高频映射到噪声幅度高频密集的时候鱼群会有细碎的抖动。映射曲线建议用对数而不是线性因为人耳对音量的感知本身是对数的线性映射会出现小音量时完全没反应、大音量时直接爆掉的情况。6.3 游戏 NPC 与集群决策如果把每条鱼看作一个 NPC这套内核可以直接复用到人群、鸟群、虫群甚至车辆集群上。做游戏的话我会在三条规则之外加一条目标吸引——给每个个体一个目标点让它朝目标走同时其他三条规则负责避让和保持队形。这就是经典的寻路 局部避障组合比纯 A* 加物理碰撞的方案轻量得多。有意思的是这套东西还能用在数据可视化上。把每条鱼看作一个数据点两条鱼之间的排斥力定义为两个向量的相似度相似度越高排斥越强那跑一段时间之后相似的数据点会自动聚成团。这其实是一种力导向图布局的变体比起传统的弹簧模型Boids 版本收敛更快、过程更平滑视觉上还能直接看到聚类形成的过程做汇报演示的时候效果很好。最后分享一个我踩过的坑别在项目一开始就追求万级个体。我第一版把目标定在 1 万条鱼结果花了大量时间在优化上反而把交互和视觉效果忽略了。后来把数量降到 3000配合更精细的个体差异和渲染效果观感反而更好——观众根本数不清屏幕上有多少条鱼他们在意的是看起来像不像活的。先把 500 条调到好看再逐步加数量是我个人在实际操作中体会最深的一条经验。
返回列表