十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Boids 鱼群仿真实战:Canvas 2D、空间哈希与 60 帧性能优化

Boids 鱼群仿真实战:Canvas 2D、空间哈希与 60 帧性能优化 第一次把 MiroFish 这个想法落到代码里是在一个再普通不过的周末。起因很简单我在看一段水下纪录片的时候突然意识到鱼群那种没有指挥却整齐划一的运动其实是一套可以完全用规则描述出来的系统。MiroFish 就是我在那之后陆续迭代了几个月的一个项目——它把鱼群行为从纪录片里拽出来放进浏览器变成一堆可以实时调节的参数和一个能跑满 60 帧的沙盒。Miro 取的是凝视、观察的意思Fish 就是鱼。合起来它想做的事情就是让你能观察一群鱼而且这群鱼是你自己养出来的。它能做的事不复杂在一个画布里生成几百到几千条虚拟鱼每条鱼只遵守极少数几条局部规则但整体会自发地涌现出绕障、避敌、追食、惊群、洄游这些看起来相当聪明的行为。它解决的问题是过去想研究或演示群体行为要么得装一整套仿真环境要么得写一堆看不懂的数学公式而 MiroFish 把它压缩成了一个打开就能玩的网页。适合谁看如果你是前端工程师这里有性能优化和空间数据结构的实战如果你是做可视化或者游戏的同学这里有 steering behavior 的完整落地如果你只是单纯觉得鱼群好看那也可以直接拿走参数面板和预设调出你自己喜欢的那缸鱼。1. MiroFish 的整体设计与三次推翻的选型过程我在动第一行代码之前花了大概两周时间做取舍。这两周基本没写东西全在纸上画框、在草稿里推翻自己。事后回头看这两周是值得的因为鱼群仿真这个题目看起来小实际上每一条技术路线都通向一个不同的坑。1.1 从看起来像倒推技术需求我给自己定了一个非常朴素的验收标准随便截图一帧发给一个没接触过这个项目的人他看完之后第一反应应该是这是鱼群而不是这是一堆点在乱飘。就这一条直接砍掉了当时我脑子里一半的方案。要把像拆开其实有三个层次。第一个层次是运动像也就是每条鱼的转向是连续的、有惯性的不能是瞬移和直角拐弯这就意味着位置和速度必须是分开的两个状态量靠加速度去驱动速度、靠速度去驱动物位置。第二个层次是结构像也就是一群鱼必须看起来像一个整体有密度、有边界、有内部空隙偶尔还会分成两个小群再合回去这就要求存在一个把所有个体耦合起来的局部交互机制。第三个层次是质感像包括鱼身的朝向跟随速度方向、尾巴摆动的频率和速度相关、鱼群在障碍物附近会贴着走而不是撞上去弹开这一层纯粹是渲染和细节调参的活。我后来把这三个层次直接映射成了项目的三层结构行为层决定加速度、空间层决定谁能看到谁、表现层决定怎么画出来。这个映射一确定后面所有决策都变得有据可依——任何新功能先问它是哪一层的跨层的功能一律拆开再做。提示新手最容易犯的错是把行为和渲染混在一起写比如在渲染循环里直接改速度方向。这会让后面的调试变得极其痛苦因为你没法单独关掉渲染去验证行为。1.2 为什么最终落在 Canvas 2D 空间哈希选型阶段我一共认真评估过四条路线这里把结论和理由摊开讲因为我觉得这个过程比结论更值得参考。路线优势致命问题结论朴素 Canvas 2D 双重循环实现最快半天能跑起来500 条鱼 O(n²) 邻居查询帧率直接崩到个位数只做原型验证WebGL 实例化渲染上万个体毫无压力行为计算仍要回 CPU瓶颈没解决GPU 计算要另外写 shader列为后期方向后端仿真 前端播放计算能力不受限交互延迟高拖动参数要等一个来回调试体验极差直接放弃Canvas 2D 空间哈希分桶邻居查询降到近似 O(n)调试直观无需额外依赖需要自己管理桶的生命周期最终采用放弃 WebGL 这个决定当时被朋友吐槽了很久。理由其实很实际MiroFish 的瓶颈从来不在画那一万条鱼而在每条鱼要问周围一圈谁在我附近。GPU 渲染解决的是画的问题解决不了问的问题。而当我把邻居查询从 O(n²) 降到接近 O(n) 之后实测 1500 条鱼在普通笔记本上就能稳定跑满 60 帧Canvas 2D 的绘制开销完全不是瓶颈。既然如此为什么要为了一个不存在的瓶颈去引入一整套 shader 工程至于后端仿真我在纸上推了五分钟就否掉了。这个项目的核心价值在于实时调参、立刻看到变化一次网络往返哪怕只有 30 毫秒调参的手感也会碎掉。仿真这种东西手感就是一切。1.3 MiroFish 的分层架构与数据流敲定路线之后整个架构就变得很清楚了。一个固定的帧循环顺序执行四步绝不交叉空间重建清空所有网格桶把每条鱼按当前位置重新塞进对应的桶里。这一步是空间层的入口。邻居查询与力累加每条鱼只在自己所在的桶和相邻的八个桶里找邻居累加分离力、对齐力、聚合力和环境力得到这一帧的总加速度。积分更新用加速度更新速度用速度更新位置同时做边界处理和速度钳制。这里有两个可以切换的积分器显式欧拉和半隐式欧拉后者在高速下的能量漂移明显更小。渲染根据速度方向决定精灵的旋转角根据速度大小决定尾巴摆动速度然后一次性画到画布上。这四步的顺序不能乱。我踩过一次坑曾经把空间重建放在更新位置之前结果所有鱼的位置都还是上一帧的邻居查询拿到的是一份过期的地图表现就是鱼群在高速运动时会出现间歇性的撕裂——一部分鱼按新位置算邻居一部分按旧位置算整群鱼看起来像卡了一下。还有一个我比较坚持的设计所有行为力都是可加性的不允许某个力直接覆盖另一个力的结果。这条约束让调参变得可预测——你调大分离力的权重效果就是鱼之间距离变大不会突然出现完全相反的行为。如果哪天发现某个行为必须要覆盖才能实现那说明这个行为应该被建模成一个独立的状态机分支而不是一个力。2. 核心算法拆解Boids 三定律在 MiroFish 里被改成了什么样鱼群仿真绕不开 Boids这是 1980 年代就提出的经典模型。但如果你直接把教科书上那三行公式抄进项目里得到的会是一群像粒子一样抱团抖动的点完全不像鱼。MiroFish 在原始 Boids 上做了不少改造这一节把数学部分和工程部分一起讲清楚。2.1 分离、对齐、聚合的物理含义与参数取值原始 Boids 的三条规则可以写得非常紧凑。设第 i 条鱼的位置为 p_i速度为 v_i它的邻居集合为 N_i那么分离力让每条鱼远离过近的邻居。对每个距离小于分离半径的邻居 j累加一条从 j 指向 i 的方向向量并按距离做倒数加权——越近排斥越强。对齐力让每条鱼的速度方向趋近邻居的平均速度方向。取 N_i 内所有邻居速度的平均值减去自己的速度。聚合力让每条鱼朝邻居的几何中心靠拢。取 N_i 内所有邻居位置的平均值减去自己的位置。这三个力各自归一化之后乘以权重再加起来就得到这一帧的期望加速度。我在实际调参时发现权重的数量级关系比具体数值重要得多。经过大量试玩MiroFish 默认预设下的权重区间大致是这样力默认权重合理区间调过头的表现分离力1.61.0 ~ 2.5鱼群炸开成一盘散沙永不聚拢对齐力0.90.4 ~ 1.4整群鱼变成一块刚性钢板转向生硬聚合力0.70.3 ~ 1.2挤成一个致密小球边缘疯狂抖动环境力0.5 ~ 1.2视场景而定鱼群被水流压成一排失去自主性这里必须强调一个细节三个力在相加之前一定要归一化。我早期偷懒没归一化结果分离力因为按距离倒数加权在极近距离下数值会飙到几十甚至上百把对齐和聚合完全压制住。表现就是鱼群看起来永远在互相躲避毫无集体感。归一化之后权重才真正代表这三种倾向的相对强弱调参才有意义。注意归一化的时候要处理零向量。当一条鱼的邻居集合为空或者平均速度恰好为零时直接做除法会得到 NaN。NaN 一旦进入位置变量那条鱼就会从画面上彻底消失而且不会报任何错排查起来非常费时间。2.2 感知半径与视锥为什么圆形邻域不够用教科书上的 Boids 用圆形邻域也就是距离我 R 以内的都算邻居。这个模型在很多场景下够用但鱼不行。原因在于鱼有明确的前方。真实的鱼只能看到身体两侧和前方一定角度范围内的东西正后方基本是盲区。而圆形邻域意味着一条鱼会去感知它身后的同类这会带来两个明显的失真一是鱼群在转向时会出现一种黏糊糊的迟滞感后面的鱼明明已经绕开了前面的鱼还在被后面的鱼拽着走二是拥挤时鱼群会形成奇怪的对称结构因为前后左右的信息是对称的而真实的鱼群几乎从来不对称。MiroFish 的做法是引入视锥概念。每条鱼有一个朝向也就是归一化后的速度方向。判定邻居时先算距离距离合格之后再算夹角把从自己指向邻居的向量归一化和自身朝向做点积点积小于某个阈值对应半视锥角的直接排除。默认的半角大约是 140 度接近一个比较宽的视野再做参数面板暴露出去。视锥带来的额外计算量很小一次点积而已但视觉上的差异非常大。开了视锥之后鱼群在急转弯时的波浪传递效果会自然出现——前面的鱼先转后面的鱼隔一小段延迟才跟上整群鱼像一条活的绸带。这个效果我一开始以为是某种高级算法才能做出来的后来发现纯粹是视野限制带来的副产品。还有一个容易忽略的点感知半径应该和个体大小挂钩而不是一个全局常数。MiroFish 里感知半径是体长的 6 到 10 倍小鱼的感知范围小大鱼的范围大。这样在混合鱼群里小鱼会更紧密地抱团大鱼会更松散画面层次立刻丰富起来。2.3 从群到个体给每条鱼一点性格纯 Boids 出来的鱼群有个很大的问题太完美了。所有鱼的行为完全一致看上去像一群被同一根线牵着的木偶。真实鱼群的魅力恰恰在于那种整齐中带着参差。MiroFish 里给每条鱼加了三组个性化参数都在出生时随机生成之后基本不变性格权重偏移每条鱼的分离、对齐、聚合力权重在全局权重基础上有一个 ±20% 的个人偏移。这意味着同一个预设下每条鱼对要不要合群的态度都略有不同。反应延迟每条鱼对邻居状态变化的响应有一个 0 到 3 帧的随机延迟。实现方式很简单维护一个长度为 3 的环形缓冲记录过去几帧的邻居平均速度每条鱼取缓冲里不同位置的样本。这个改动很便宜但效果出奇地好鱼群里会出现明显的层次感。体力与疲劳每条鱼有一个体力值高速游动时缓慢下降低速时缓慢恢复。体力低于阈值时这条鱼的最大速度会被压低它会从队伍前方慢慢掉到后方休整一会儿再追上来。这一条让整个鱼群有了节奏感不再是匀速平移。这三组参数里我认为反应延迟的性价比最高。它只增加了一个很小的环形缓冲却几乎消灭了鱼群同步抖动这个最扎眼的问题。如果你只做一件事我建议就做这个。2.4 环境力场水流、障碍与捕食者个体层面做完之后环境层面才是让画面真正有故事的地方。MiroFish 里环境力被统一抽象成一个向量场每条鱼在当前位置采样一次得到一个方向向量乘以权重加到总加速度上。水流用的是最常见的一维噪声场。在一个长条形的域上铺一层噪声每个点取噪声的梯度方向作为水流方向再乘一个强度系数。这样出来的水流是连续的、有旋涡的不是一块一块的方格。用噪声而不是解析函数好处是你可以只调一个频率参数就切换水流尺度——频率低就是大缓流频率高就是湍流。障碍物用的是势场法。每个障碍物向外辐射一个斥力大小和到边界的距离成反比。这个方法实现极简但有个经典缺陷当障碍物挨得比较近时两个斥力场之间会形成局部极小值点鱼会卡在那里来回抖动出不去。我的处理办法是加一个切向分量——把纯径向的斥力旋转 90 度取一部分混进去鱼就会顺着障碍物表面滑走而不是直愣愣地撞。这个技巧在很多机器人避障的文章里都有但在鱼群仿真里很少有人提实测效果非常好。捕食者则是一个特殊个体它不参与鱼群的邻居计算但它会作为邻居出现在所有鱼的邻居查询里只是贡献的是逃离力而不是普通的分离力。逃离力的权重会被额外放大同时触发一个惊群状态进入惊群状态后整群鱼的对齐力权重会临时提高、聚合力权重临时降低表现就是鱼群先炸开再迅速收成一团高速远离。这个状态有持续时间会自己衰减回正常。3. 性能工程怎么让两千条鱼在浏览器里跑满帧前面所有算法如果不做性能处理在 500 条鱼的时候就会开始卡。这一节是 MiroFish 里我花时间最多、也最有分享价值的部分。3.1 均匀网格空间哈希的实现细节邻居查询是绝对的性能大头必须优先处理。MiroFish 用的是最朴素的均匀网格把整个画布切成边长等于感知半径的方格每帧把所有鱼按位置放进对应的格子。为什么格子边长要等于感知半径因为这样一来任何一条鱼的潜在邻居只可能出现在它自己所在的格子以及相邻的八个格子里——再远的格子光靠水平或垂直距离就已经超出感知半径了。这个九个格子的固定开销把邻居查询从遍历全体变成了遍历附近的一小撮。实现上有几个细节值得单独说。第一不要每帧 new 一个二维数组。我一开始用Array.from({length: cols}, () [])每帧重建结果 GC 频繁触发帧时间曲线上一堆尖刺。后来改成一次性分配一个扁平的对象池每个桶用一个可复用的数组清空时只把长度置为 0尖刺立刻消失。第二桶内数组用 push 而不是预分配因为大部分桶里的鱼数量很少预分配反而浪费。第三做一点越界保护当鱼被水流推到画布外时索引可能是负数或者超过列数如果不做钳制访问到不存在的桶会静默返回 undefined然后整条鱼的邻居集合变空行为立刻异常。实测下来这个改动带来的提升是数量级的鱼数量朴素双重循环均匀网格空间哈希200约 58 帧约 60 帧满帧500约 22 帧约 60 帧满帧1000约 6 帧约 58 帧2000基本卡死约 42 帧2000 条那一行掉帧主要是绘制开销开始显现了跟邻居查询已经关系不大。3.2 结构体数组与零分配原则第二个大坑是垃圾回收。JavaScript 里随手写{x: 0, y: 0}是很自然的但如果这个对象在每帧每鱼都要新建若干个比如位置向量、速度向量、临时方向向量瞬间就是每秒几十万次小对象分配。V8 的分代回收扛得住一阵但扛不住持续压力表现就是每隔几秒卡一下。我的做法是把所有状态量都拍平到几个Float32Array里posX、posY、velX、velY、accX、accY、heading、stamina各一个数组第 i 条鱼的所有数据就是这些数组下标 i 上的值。这就是所谓的结构体数组SoA布局。这么改之后还有一个额外的好处缓存友好。遍历velX数组算平均速度内存是连续的CPU 预取器能吃到满而如果是一堆对象散在堆里每次访问都可能是一次缓存未命中。在 2000 条鱼的规模下这个差异我用性能面板实测过大约有 15% 到 20% 的整体提升。配套的还有一条纪律在帧循环内部不允许出现任何对象字面量、数组字面量和闭包创建。我甚至把这条写进了代码注释里。所有临时量都用模块级的标量变量或者复用的小数组。这条纪律执行起来有点别扭但它把 GC 问题从根上掐死了。3.3 渲染合批把鱼身预渲染成精灵当邻居查询和 GC 都处理掉之后绘制就成了下一个瓶颈。最初的实现是每条鱼调用一次ctx.save()、一次旋转、一次beginPath、描几条线当鱼身、一次restore()。这套操作在 500 条鱼的时候就已经很吃力了。MiroFish 的做法是离屏预渲染。在初始化阶段把鱼身的形状按若干个朝向角度比如 36 个每 10 度一个分别画到一个小离屏画布上形成一个精灵图集。运行时只需要根据鱼的速度方向找到最近的离散角度用drawImage把对应的精灵画上去。drawImage是浏览器里最快的绘制路径之一比路径描边快一个数量级。代价是角度被离散化了。10 度一格在慢速游动时肉眼看不出差别但快速转向时会有一点点跳帧感。我试过加到 72 格内存占用翻倍但视觉提升很小最后折中在 48 格。还有一个优化方向是保留两个相邻角度的精灵做混合效果会非常平滑但成本翻倍我暂时没做。另外尾巴摆动我一开始也是逐帧重绘的后来意识到完全没必要——尾巴摆动的本质是周期性的形变可以直接预渲染出摆动周期的若干关键帧运行时按速度查表选帧。这样一来整条鱼的绘制就退化成了一次drawImage加一次旋转变换成本极低。3.4 帧时间预算的分配思路做完上面的优化之后我养成了一个习惯把每帧 16.6 毫秒的预算拆开来看心里有个数。MiroFish 在 1500 条鱼、普通笔记本上的大致分配是这样阶段耗时占比空间重建约 0.8 ms5%邻居查询与力累加约 7.5 ms45%积分与边界处理约 1.2 ms7%渲染约 5.5 ms33%参数面板与统计约 0.6 ms4%余量约 1.0 ms6%这个表最大的价值不是数字本身而是让你知道优化该往哪儿使劲。看到邻居查询占了将近一半你就不会跑去优化渲染看到渲染占了三分之一你就知道精灵图集这条路是对的。很多性能优化做无用功就是因为没先量一遍。4. 交互与参数调优把手感变成可以拧的旋钮算法跑通、性能达标之后MiroFish 才真正开始变得有意思。因为它的定位不是一个仿真库而是一个可以玩的东西。这一节讲讲我在交互层做的取舍以及一次完整的调参记录。4.1 哪些参数该暴露哪些必须锁死参数面板是我改了最多遍的模块。最初的版本把所有内部参数全暴露出来三十多个滑块结果自己和朋友用起来都是一脸懵——不知道该拧哪个拧了也不知道发生了什么。后来我做了一个筛选原则只暴露那些拧了之后三秒内能看出画面变化的参数。按这个原则砍下来常驻面板只保留了九个鱼群数量100 ~ 3000分离力权重对齐力权重聚合力权重感知半径倍率最大速度视锥半角水流强度捕食者数量其余的比如网格边长、精灵角度分辨率、体力衰减系数、惊群衰减速率全部收进一个折叠的高级面板里默认收起。这些参数要么影响太大不适合随手改要么改了根本看不出来。还有一个必须锁死的参数时间步长。我试过做变速播放让用户可以快放和慢放结果一放开就出问题——时间步长变大时鱼的运动会穿过障碍物甚至速度累加到爆炸。所以 MiroFish 里时间步长是固定的快放慢放是通过每帧执行多次或跳过多次积分实现的而不是改步长。这个区别听起来小但它是数值稳定的关键。提示如果你也在做类似的实时仿真固定时间步长 累加器是标准做法。累加器里攒够一个步长就执行一次逻辑更新渲染每帧都做。这样在不同的刷新率屏幕上表现一致也不会有数值爆炸。4.2 四个预设场景的设计意图为了让新用户开箱就有感觉我做了四个预设。每个预设不只改权重而是改一整套参数乃至环境设置因为它们要营造的是完全不同的场景感。珊瑚礁是默认预设。缓流、少量障碍物、中等密度、普通的力权重。它的目标是好看且稳定适合第一次打开的人放着当屏保看。这个预设里我把反应延迟调得比较大鱼群的层次感最强。急流是水流强度拉满的版本。这个预设里鱼群会被水流压成一个顺着流向的长条形看起来像河里的鱼在顶水。要让这个形态稳定聚合力必须适当提高否则鱼会被水流彻底冲散。同时最大速度要提上去不然鱼游不过水流会全部堆在画布边缘。深海是低密度、低光照、慢速的版本。鱼数量少个体大感知半径大游动缓慢。这个预设其实是最难调的因为数量少意味着个体行为很容易被看穿任何不自然的转向都会很明显。我在这个预设上花了最长时间最后发现关键是把反应延迟调到最小、给个体差异调到最大让每条鱼的性格都足够鲜明。惊群是一个专门的演示预设开局就放了三只捕食者。它的目的是让用户直观看到群体行为的爆发——鱼群会先炸成一个壳然后迅速收缩成高速移动的一团捕食者走后又慢慢散开恢复。这个预设里我把惊群状态的衰减速率调得很慢让整个恢复过程能被完整观察到。4.3 一次完整的调参记录从抖动团到活绸带这里我把一次真实调参的过程完整记录下来因为我觉得这个过程比参数表更有参考价值。初始状态是分离力 1.0、对齐力 0.5、聚合 1.0、视锥关闭、反应延迟为 0。画面表现是一团致密的、边缘不停抖动的球鱼的朝向杂乱无章。第一步先处理抖动。抖动来自两个地方一个是分离力在近距离下的数值爆炸一个是所有鱼完全同步。我先把三个力都做了归一化抖动幅度明显减小但没有完全消失。然后加上了 0 到 3 帧的反应延迟抖动基本消失了。这一步之后鱼群已经能看但还是一团死球。第二步把球松开。松开的方法不是降聚合而是开视锥。视锥一开每条鱼看到的邻居少了一大半聚合的合力自然变小而且因为视野的不对称球的边缘开始出现结构。这一步是整次调参里视觉提升最大的一步。第三步给运动加节奏。此时鱼群能动了转向也顺滑了但整体像一块匀速平移的板。我加入了体力系统让疲劳的鱼降速。画面立刻活了队形开始有前后层次有的鱼冲到前面有的掉到后面然后追上来。第四步压住对齐力。加入体力之后鱼群出现了新的问题——掉队的鱼追不上因为对齐力让它们被大部队的速度吸住。我把对齐力从 0.9 降到 0.6同时把最大速度提高了 15%掉队问题解决。第五步加水流微调。最后加入一个很弱的水流让整个鱼群不是笔直地转圈而是有一个缓慢漂移的方向。这一步是纯审美需求不涉及算法。整个过程从开始到定稿用了大概三个晚上。我最大的体会是调参是有顺序的不能乱拧。先解决稳定性抖动、爆炸再解决形态松紧、结构再解决节奏速度层次最后才考虑氛围环境力。顺序对了每一步的效果都立竿见影顺序错了你会觉得所有参数都在互相干扰怎么调都不对。5. 常见问题与排查技巧实录项目做到现在我自己和几个试用的朋友踩过的坑不少。这一节把典型问题和排查路径整理出来希望能省下你一些时间。5.1 鱼群炸开或抱团抖动的排查顺序这两个是最常见的症状而且往往是同一个根因的两种表现。我的排查顺序固定是这样先看数值是否合法。在每帧结束后检查所有鱼的位置和速度如果有 NaN 或者 Infinity直接锁定问题在归一化那里。最快的办法是在控制台里过滤isFinite一条鱼一条鱼地查。这个排查我建议做成一个开关只在开发模式下跑因为全量检查本身开销不小。再看权重比例。如果数值都正常那大概率是权重搭配失衡。判据很简单炸开就说明分离力相对聚合和对齐太强抱团抖动则相反。但注意不要直接去改权重先试着关掉视锥和反应延迟看看。因为很多时候看起来像权重问题其实是视野问题——视锥关掉之后整群鱼能看到的邻居变多聚合的判断立刻变化。最后看时间步长和积分器。如果只有高速时才出问题低速正常那基本就是积分器能量漂移了。切到半隐式欧拉或者在高速时给速度做一个硬钳制通常能解决。5.2 帧率突然掉到 20 的三种典型原因掉帧这件事我在不同阶段遇到过三次每次原因都不一样很有代表性。第一次是内存分配导致的 GC 抖动。表现是帧率不是稳定低而是一段时间正常然后突然卡半秒。用性能面板看内存曲线是标准的锯齿形。这类问题的排查方法很直接打开内存面板如果看到频繁的尖峰回收就去帧循环里找对象字面量。第二次是网格参数配错导致的桶退化。我给一个朋友调参数的时候把感知半径倍率拉到了很小结果网格边长也跟着变得很小桶的数量暴增每个桶里可能只有零到一条鱼。邻居查询虽然还是九宫格但九宫格的遍历开销加上桶的管理开销反而超过了朴素循环。教训是网格边长有下限不能无限小。MiroFish 里我加了一条钳制网格边长不会小于画布短边的 1/200。第三次是精灵数量爆炸导致的内存压力。我把角度分辨率从 48 调到 192 的时候帧率直接砍半。原因是精灵图集的总像素数涨了四倍超出了纹理缓存每帧都在做重复的解码和上传。这种情况的解法是回到合理的分辨率或者把图集改成单个大画布而不是几十个小画布。5.3 常见问题速查表症状最可能的原因快速验证方法处理方式鱼群整体静止不动权重全部为零或速度初值为零且无力打印一帧的加速度总和检查预设加载是否成功个别鱼消失不见位置变量变成 NaN遍历isFinite检查给所有归一化加零向量保护鱼群贴边不回来边界处理只做了钳制没做回转把边界画出来看加边界排斥力或做环绕转向生硬有直角直接改位置而不是改速度打印单帧速度变化量保证所有运动都通过加速度驱动惊群后无法恢复惊群状态没有衰减计时打印惊群标志位加一个随时间衰减的权重系数逆流时全堆在边缘最大速度低于水流强度对比两者数值提高最大速度或降低水流强度高密度下鱼重叠分离半径小于体长测量重叠鱼的距离分离半径至少设为体长的 1.2 倍这张表里我特别想强调转向生硬有直角那一条。这是新手最容易犯的错误因为直接改位置在代码上更简单、效果更精确。但鱼群的美感恰恰来自转向的迟滞和惯性一旦你开始直接操纵位置那种独特的生物感立刻消失。我到现在都没有找到任何一种直接改位置还能保留生物感的做法。注意排查这类实时仿真问题最有效的工具是单步模式。MiroFish 里我留了一个按键按下后每帧只更新一次并且暂停可以一条一条鱼地看它的邻居集合和受力。图形界面里做调试能画出来永远比打印日志快。6. 后面还可以怎么接着玩这个项目我没有把它做成一个完成品的打算因为它本质上是一个沙盒留了很多口子。这里说说我自己接下来想做的几个方向也许对你也有参考。多鱼种混养是这个项目最自然的下一步。现在的鱼群里所有个体共享同一套权重只是有 ±20% 的偏移。如果把权重分成几个簇比如小鱼簇分离力高、聚合力高大鱼簇对齐力高、分离力低那么混在一起的时候会自然出现小鱼抱团、大鱼领队的结构。这个改动在算法上只是把全局权重换成按种类查表但在视觉上会是一次质变。障碍物的形状编辑也是我一直在犹豫的。现在障碍物只能放圆形因为势场法对圆形最友好。如果要做任意多边形就得换成距离场的做法性能和实现复杂度都会上一个台阶。但可以预见的收益是用户可以自己画出礁石、沉船、桥墩这些形状整个场景的可玩性会完全不同。行为录制与回放是一个偏工具向的方向。我现在的调参过程是拧一下看一帧但如果能录下一段参数变化的时间线然后回放对比不同参数下的表现调参效率会高很多。实现上其实不难只要把每帧的参数快照和随机种子存下来回放时按固定时间步长喂进去就行。难点在于随机种子要用可复现的伪随机数不能用Math.random。移动端适配是我自己觉得最实用的一个方向。现在这个项目在触屏设备上体验一般主要是参数面板太密手指点不准。如果把常用的四个参数做成滑块放在底部其他都收起来其实完全可以在平板上当个放松工具用。我试过用鼠标在手机上连选一条鱼看它的邻居连线那种感觉其实挺好的。最后分享一个我自己在调试时的小习惯。我在画布上留了一个隐藏的调试层按 Tab 键切换开启后会画出所有鱼的感知半径圆、邻居连线、当前受力方向的箭头以及网格的分割线。这个层完全不参与性能预算的考量因为它只在开发时开。但它的价值极高——很多我绞尽脑汁想不明白的行为异常打开这个层看一眼就明白了比如某个区域鱼特别密集一看网格线就知道是桶的分布不均导致的。可视化调试这件事我个人的经验是能画出来的东西绝对不要靠打印。
返回列表