尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MiroFish:30KB Canvas 电子鱼缸的 Boids 群集与性能优化

MiroFish:30KB Canvas 电子鱼缸的 Boids 群集与性能优化 MiroFish 这个项目最初的起因是大屏角落里那块空着的地方。去年给一个做仓储调度的团队做大屏可视化整体布局定稿之后右下角剩了一个大约 480×260 的矩形。本来准备放一张汇总指标卡评审时对方的产品经理说了句这块地方太空了能不能放点活的东西但又不能是一眼假的循环动画。我第一反应是找个 GIF 或者切一段循环视频塞进去结果自己先否了——大屏是 7×24 常驻的一块 300KB 的 GIF 挂在那儿跑三天内存和 CPU 的账根本算不过来而且分辨率一放大就糊。于是就有了 MiroFish一个体积极小的、可以直接嵌进网页的电子鱼缸。它的核心不是画鱼而是三件事——用群集算法让鱼群看起来有社会性用精灵动画加行为状态机让每条鱼看起来有自己的脾气再用一套面向常驻场景的功耗控制让它挂在那儿不心疼。整套东西压缩后不到 30KB不含素材零运行时依赖Canvas 2D 实现在千元安卓机和四年前的轻薄本上都能守住 60fps。这篇文章不讲概念讲的是我在把它从 demo 做到能上生产的过程中真正花时间的那些细节参数怎么标定、一帧的时间怎么分配、哪些坑是必须踩一遍才知道的。前端、可视化、桌面小工具方向的同行可以直接抄作业想给自己主页或者文档站点加点生气的人也能照着改。1. MiroFish 的定位为什么现成的会动素材都不合适1.1 从大屏角落那块空白说起需求方要的活的东西翻译成工程语言其实是三个约束第一它必须持续变化但又不抢注意力不能像轮播广告那样每隔几秒甩一次大动作第二它必须在所有分辨率下都清晰大屏从 1080P 到 4K 拼接都可能遇到第三它必须便宜常驻三天不能把浏览器的内存吃到 1G 以上。这三条约束一摆出来大部分现成方案就直接出局了。GIF 是逐帧位图放大必糊而且它的循环周期是固定的看五分钟就能找到规律循环视频更糟一个 VP9 的 10 秒循环1080P 下解码器就要常驻GPU 占用虽然不高但内存是实打实的Lottie 的矢量动画清晰度没问题可它的本质还是按时间轴播放预设关键帧鱼的动作是死的鱼与鱼之间没有任何交互看一眼就知道是录好的。真正的分水岭在于你要的是一段动画还是一个模拟。动画是回放模拟是计算。回放的问题是观众迟早会看穿而模拟则每次都不一样——两条鱼擦肩而过的角度、鱼群散开又聚拢的时机全都是当场算出来的没有循环点可以被识破。1.2 四条技术路线的实测账我把当时能想到的方案都拉出来做了个小对比数据是在同一台 2019 款轻薄本1080P集显上挂 30 分钟测的同期只开一个 Chrome 标签页方案体积常驻内存增量放大清晰度可控性观感300KB GIF 循环300KB约 90MB差2 倍以上明显糊只能换图3 分钟后可被识破1080P 视频循环8MB约 260MB一般可调播放速率同上Lottie 矢量动画120KB约 60MB好可调速度与颜色动作机械个体间无交互Canvas 模拟MiroFish28KB约 12MB完美矢量路径参数全开放长时间看不出重复这里最反直觉的是内存那一列。很多人以为 Canvas 动画最吃内存实际上 20 到 40 条鱼的粒子系统每条鱼只存十几个浮点数加上一张 512×512 的精灵图集内存增量远低于一个视频解码器。真正的开销在素材上——GIF 和视频的素材是以解码后的位图形式常驻的而 MiroFish 的素材是一张压缩图集解一次就完事。提示如果你的页面本身已经有视频或大图在跑再叠加一个 GIF 鱼缸是很容易把移动端浏览器搞崩的尤其是 iOS Safari 对同时解码的媒体元素数量有隐形上限。1.3 体积、帧率、内存三条底线定了 Canvas 这条路之后我给自己划了三条底线后面所有的取舍都围绕它们做运行时体积不超过 10KBgzip 后。鱼缸本身是个装饰性功能它没有资格占掉主包的一大块。这条底线直接决定了我不能用任何渲染框架也不能引物理引擎所有向量运算手写。在集显设备上稳定维持 60fpsCPU 单核占用不超过 5%。大屏和笔记本都是长时间办公场景风扇一转用户就有意见。连续运行 72 小时内存增长不超过 5MB。这条最难它意味着不能有肉眼可见的内存泄漏也不能让短生命周期对象把 GC 拖成周期性卡顿。后来所有看起来过度设计的东西——对象池、TypedArray、离屏画布、固定步长——都是为了兑现这三条底线。如果你只是想给自己博客加个装饰不追求 72 小时常驻那很多优化可以砍掉我在第 6 节会标出哪些是可以先不管的。2. 名字里的 mirror一套素材怎么撑起一缸鱼2.1 镜像、缩放、调速同一条鱼的三种分身MiroFish 里的 Miro 取自 mirror。原因很实在整缸鱼用的其实是同一套朝右游的精灵图朝左游的时候用水平镜像翻转体型差异用 0.85 到 1.15 的缩放摆尾频率用 0.9 到 1.1 的倍率微调。三个维度组合出几十种组合肉眼就分辨不出是同一张图了。这件事的价值不只是省素材。维护一套帧序列意味着美术改动只需要改一处代码里的帧索引逻辑也只有一条分支。代价是镜像的时候必须处理锚点因为 Canvas 的scale(-1, 1)是绕原点翻转的如果不做坐标补偿鱼会瞬间跳到画面另一侧。我当时的写法是这样// 朝左的鱼先平移到鱼的锚点翻转再平移回去 ctx.save(); ctx.translate(fish.x, fish.y); if (fish.dir 0) { ctx.scale(-1, 1); // 镜像后精灵图的水平偏移方向要反过来算 ctx.drawImage(atlas, sx, sy, sw, sh, -w / 2, -h / 2, w, h); } else { ctx.drawImage(atlas, sx, sy, sw, sh, -w / 2, -h / 2, w, h); } ctx.restore();这段代码里有个容易忽略的点锚点必须设在鱼体的几何中心而不是精灵图的左上角。我一开始用的是左上角结果鱼转身的时候会画出一个圆弧看起来像在甩头。改成中心锚点之后转身动作立刻自然了。2.2 Boids 的三条规则最小实现鱼群的核心是 Boids1987 年 Craig Reynolds 提的那套东西三条规则分离别撞上邻居、对齐跟上邻居的平均朝向、聚合往邻居的中心靠。原理说出来平平无奇难的是参数和边界条件。我的单帧计算长这样邻域列表是外层传进来的避免在这里做全量遍历const PERCEPTION 64; // 感知半径单位是像素 const SEPARATION 22; // 分离半径 const W_SEP 1.6, W_ALI 0.9, W_COH 0.6; function accumulate(fish, neighbors) { let sepX 0, sepY 0, aliX 0, aliY 0, cohX 0, cohY 0; let nSep 0, nAli 0, nCoh 0; const p2 PERCEPTION * PERCEPTION; const s2 SEPARATION * SEPARATION; for (let i 0; i neighbors.length; i) { const o neighbors[i]; const dx fish.x - o.x, dy fish.y - o.y; const d2 dx * dx dy * dy; if (d2 p2 || d2 1e-6) continue; // 视野锥只响应前方约 240 度范围营造看不见背后的错觉 if (dx * fish.vx dy * fish.vy -0.3 * Math.sqrt(d2)) continue; if (d2 s2) { sepX dx / d2; sepY dy / d2; nSep; } aliX o.vx; aliY o.vy; nAli; cohX o.x; cohY o.y; nCoh; } // 三项归一化后乘权重具体合并逻辑见下一节 return { sepX, sepY, nSep, aliX, aliY, nAli, cohX, cohY, nCoh }; }这段代码里有三个刻意的优化。第一距离比较全部用平方只在必须开方的地方视野锥判断才开一次省下的Math.sqrt调用在 40 条鱼的规模下能占到总计算量的两成。第二分离力按距离平方反比离得越近推得越狠这样不需要额外做碰撞检测。第三d2 1e-6这个判断是为了防止两条鱼坐标完全重合时除零得到Infinity然后整个鱼群被弹飞到屏幕外——这个 bug 我在测试阶段遇到过两次都是因为在页面初始化时把所有鱼生成在了同一个点。2.3 视野锥与邻域查询别让每条鱼都遍历全场Boids 的朴素实现是 O(n²)40 条鱼就是每帧 1600 次距离计算。这个量级在桌面端还好但在低端手机上开始有压力。我做了两件事。第一是空间网格。把画布切成 64×64 的格子每帧只把鱼放进对应的格子查邻居时只遍历自己和相邻的 8 个格子。鱼在画面里分布相对均匀这一步能把距离计算量降到原来的三分之一左右。格子用一维数组加取模索引来存避免创建二维结构。第二是降频计算。这里有个反直觉的做法行为决策不需要每帧都做60Hz 下每两帧算一次决策中间帧用插值平滑肉眼完全看不出区别但 CPU 占用直接砍掉一半。人的视觉对连续位移敏感对决策速度并不敏感只要位置是连续变化的大脑就会认为它是平滑的。注意降频计算的前提是渲染仍然每帧执行。如果你把渲染也降到 30fps帧率变化本身会被感知到便宜变成了卡得不偿失。2.4 两条补充规则懒散权重与水层偏好纯 Boids 跑出来的鱼群看久了会觉得太整齐——像一群训练有素的无人机。真实鱼群是有个体差异的有的鱼爱扎堆有的爱单溜有的在水面附近晃有的贴着底。我加了两条自定义规则。懒散权重每条鱼初始化时随机一个 0.5 到 1.5 的socialFactor直接乘在聚合和对齐的权重上。socialFactor小的鱼几乎无视同伴会自己沿着画面边缘慢慢游形成明显的独行侠大的鱼则紧贴鱼群中心。这个参数一加社会结构立刻出来了。水层偏好每条鱼有一个preferredY初始在画面内随机同时叠一个缓慢的正弦漂移周期 20 到 60 秒不等。约束力很弱只在鱼偏离偏好水层超过一定距离时才起作用所以视觉上是这条鱼今天喜欢在上面待着而不是这条鱼被一根隐形的线拴住了。3. 一帧 16.6ms 的账怎么算3.1 分层画布与脏区重绘60fps 意味着每帧只有 16.6ms。我实测下来的时间分布大概是这样行为计算 2.5ms、参考鱼缸的物理计算 0.8ms、绘制 6ms、浏览器自身的合成与样式计算约 2ms剩下的时间是留给 GC 和系统抖动的余量。真正能优化的是绘制那 6ms。第一刀切在分层上。背景、水体渐变、水草这些几乎不变的内容画在一张离屏 Canvas 上只在尺寸变化时重绘每帧真正重绘的只有鱼和气泡。这一刀把绘制开销砍掉了将近一半因为水体的径向渐变填充在低端设备上很贵。第二刀是避免全屏 clear。有些实现习惯每帧clearRect(0, 0, w, h)在 4K 分辨率下这一步本身就要 1 到 2ms。正确做法是把静态背景铺在底层画布动态层只清理变化区域或者干脆用drawImage把背景贴回去——后者利用 GPU 合成通常比 clear 更快。3.2 精灵图集与帧相位帧动画最容易做错的地方是所有鱼同步摆尾。如果每条鱼的帧索引都从 0 开始按同一个节奏递增那么同一时刻画面上的鱼姿态完全一致看起来像贴图抖动。解决办法是给每条鱼一个phase偏移量0 到 1 的随机数计算帧索引时加上这个偏移// 每条鱼独立相位摆尾周期随体型微调 const cycle fish.tailPeriod; // 单位秒0.35 ~ 0.55 const t (elapsed / cycle fish.phase) % 1; const frame Math.floor(t * FRAME_COUNT) % FRAME_COUNT;再加上缩放带来的体型差异一缸鱼的摆尾就彻底错开了。这个改动代码量不到十行但对活物感的提升是我做过的所有改动里性价比最高的。3.3 固定步长加插值为什么不能直接用 dt很多人写动画习惯用dt now - last然后所有位移乘以 dt。对于匀速直线运动这没问题但对于 Boids 这种带反馈的系统dt的波动会直接变成行为的不一致——某一帧 dt 特别大比如浏览器在切标签页前的一帧鱼的位置会被推进一大步可能导致它直接穿过邻居触发剧烈的分离力然后整群鱼炸开。我的做法是固定步长物理固定按 1/60 秒推进渲染帧之间做线性插值。const FIXED_DT 1 / 60; let accumulator 0; function loop(now) { const frameTime Math.min((now - last) / 1000, 0.25); // 上限截断防止螺旋死亡 last now; accumulator frameTime; let steps 0; while (accumulator FIXED_DT steps 5) { simulate(FIXED_DT); accumulator - FIXED_DT; steps; } // 剩余时间用于插值让位置在小数帧上平滑 const alpha accumulator / FIXED_DT; render(alpha); requestAnimationFrame(loop); }这里有两个关键点。Math.min(..., 0.25)是防止长时间卡顿后累积出一个巨大的 frameTime导致 while 循环跑上千次把页面彻底卡死也就是常说的螺旋死亡。steps 5是第二道保险。alpha的存在让渲染可以画在物理状态的中间位置所以固定步长不会带来低帧率下画面一跳一跳的问题。3.4 高分屏与像素对齐高分屏的坑比较直接Canvas 的 CSS 尺寸和绘图缓冲尺寸是两回事。如果只设width属性不乘devicePixelRatio在 Retina 屏上就是糊的如果乘法做了但没处理坐标线条又会出现半像素的毛边。const dpr Math.min(window.devicePixelRatio || 1, 2); // 上限 23 倍屏收益极小、开销翻倍 canvas.width cssW * dpr; canvas.height cssH * dpr; canvas.style.width cssW px; canvas.style.height cssH px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);setTransform这一步很关键它让后续所有绘制代码都可以直接用 CSS 像素坐标不用到处乘 dpr。另外我把 dpr 上限压到了 2因为 3 倍屏上的像素填充开销是 2 倍的 2.25 倍而画质提升在那个尺寸下几乎看不出来。4. 从能动到像活的行为状态机怎么搭4.1 五个状态和它们的切换条件纯 Boids 的鱼只有一种行为跟着邻居游。加了状态机之后每条鱼成了一个有情绪的小个体。我的状态定义很简单状态触发条件表现退出条件巡游默认状态常速跟随群体被投喂或被点击冲刺长距离游动后随机触发速度 1.8 倍尾摆加快持续 0.6 到 1.2 秒悬停靠近边界或长时间无邻居速度衰减到接近 00.8 秒后自动退出受惊鼠标靠近或页面发生大范围变化4 倍速逃逸分离权重骤增2 秒无刺激趋食发生投喂事件朝食物点加速聚集食物消失或 5 秒超时状态切换用的是一个极简的权重混合每个状态对应一组 Boids 权重和速度参数切换时不硬切而是在 0.3 秒内做线性插值。硬切的后果是鱼会瞬间变性格非常出戏。4.2 转向延迟与尾摆相位这是活物感最核心的一条经验真实的鱼在转向时身体先弯然后头才转过去。如果代码里让速度方向和身体朝向完全一致动作就会像一枚导弹。我的做法是把朝向拆成两个量运动方向v和视觉朝向headingheading用一阶滞后跟随v// 视觉朝向滞后于运动方向滞后系数越小越懒 const lag 0.12; let diff Math.atan2(fish.vy, fish.vx) - fish.heading; while (diff Math.PI) diff - Math.PI * 2; while (diff -Math.PI) diff Math.PI * 2; fish.heading diff * lag;lag取 0.12 是我试了小半天定的再小就迟钝得像纸片再大就恢复成导弹。另外尾摆的相位应该和转向方向挂钩——向左转时左侧摆幅略大这个细节在慢速游动的鱼身上尤其明显。4.3 受惊、聚散与投喂受惊反应是我觉得最能让人停下来看的细节。把鼠标在画面上快速划一下附近 150 像素内的鱼会集体加速逃离同时分离权重从 1.6 拉到 4.0整群鱼会在一瞬间炸开然后在 2 秒内重新聚拢。这个过程完全由参数驱动没有任何预设动画。这里有个小技巧受惊的刺激源不要用鼠标的当前坐标而要用鼠标的移动速度。如果只是按坐标判定鼠标静止在画面中间时鱼会一直躲着那个点不敢靠近。改成移动速度超过阈值才产生刺激之后情况立刻合理了——鼠标不动的时候鱼会慢慢游回来甚至从光标旁边蹭过去。4.4 随机种子与可复现调试期间我崩溃过好几次某个 bug 只在特定随机序列下出现但我改完代码之后再也复现不了。后来引入了可控随机种子用 mulberry32 这类简单的 PRNG 替代Math.randomfunction mulberry32(seed) { return function () { seed | 0; seed (seed 0x6D2B79F5) | 0; let t Math.imul(seed ^ (seed 15), 1 | seed); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; }有了种子只要输入相同的参数和相同的初始种子跑出来的画面就是完全一样的。这对排查渲染问题帮助巨大——我可以精确地回到出问题的那一帧。生产环境默认用时间戳当种子每次刷新都是新的缸调试时传入固定值就行了。5. 常驻不打扰功耗与内存的硬约束5.1 三种该停下来的信号装饰性动画最没道理的地方就是它在用户看不见的时候还在烧电。我给它接了三个刹车。第一个是IntersectionObserver。鱼缸滚出视口之后完全停掉requestAnimationFrame不是降帧是完全停。用户滚回来的时候再恢复并且要重置时间基准避免它以为过了十秒而瞬间推进一堆物理步。第二个是document.visibilitychange。标签页切到后台时浏览器本来就会把 rAF 降到 1fps 甚至暂停但有些实现里的setInterval还在跑白白耗电。这个信号必须在第一帧就响应。第三个是页面前台但仍不可见的情况比如鱼缸被其他元素盖住了。这个我一开始漏掉了后来发现 SPA 页面里路由切换时旧组件还挂在 DOM 上鱼缸其实完全被遮住却在满速运行。解决方式是在路由变化时主动调用pause()而不是指望浏览器发现。提示暂停时一定要记录暂停时刻的时间戳恢复时用now - pausedAt重新对齐否则固定步长的累加器会把暂停期间的时间一次性补上画面会快进。5.2 低端机降级清单我不想维护两套代码所以降级是通过参数实现的。启动时探测一次设备能力navigator.hardwareConcurrency、屏幕尺寸、是否触屏然后按档位下发配置档位鱼数量帧率上限气泡粒子阴影/光效适用高3660开开桌面独显/大屏中2260开关普通笔记本/平板低1230关关千元安卓机极简830关关低端机/省电模式这里有个经验降级优先砍叠加效果最后才砍鱼的数量。因为鱼的数量直接影响鱼缸感剩 8 条鱼的鱼缸看起来很空旷而关掉一层淡淡的阴影几乎没人注意到。5.3 对象池与 TypedArray72 小时不涨内存这条底线主要靠两件事守住。一是不在循环里创建对象。Boids 的每一步都要算向量如果每帧return {x, y}几十次短时间内会产生大量小对象触发频繁的 Minor GC表现为周期性的微卡顿。我的做法是用一组预分配的Float32Array存位置、速度、朝向计算直接在数组上原地进行一个对象都不创建。// 预分配长度固定运行期不会扩容 const posX new Float32Array(MAX_FISH); const posY new Float32Array(MAX_FISH); const velX new Float32Array(MAX_FISH); const velY new Float32Array(MAX_FISH);二是事件监听和定时器全部登记在册destroy()时统一清理。装饰性组件最容易被遗忘页面上线半年之后没人记得它还挂着一个resize监听。5.4 桌面常驻窗口的额外麻烦除了网页嵌入我还把它塞进了桌面常驻场景——一个永远置顶在桌面右下角的小窗口。这部分踩的坑和网页完全不同。透明背景在网页里是一行 CSS在桌面容器里通常需要开启透明合成而且不同系统对透明窗口的点击穿透支持程度不一样。我的处理是把窗口做成点击穿透模式只有托盘图标可以交互这样用户点窗口后面的图标时不会被鱼缸挡住。另外多屏切换、屏幕缩放比例变化时窗口的逻辑尺寸和物理像素会不一致必须监听显示配置变化并重建画布否则从 4K 屏拖到 1080P 屏之后画面会糊成一团。还有一个容易被忽略的电池供电时要主动降档。笔记本离电之后一个常驻的 60fps 动画对续航的影响是能感知到的所以我在检测到电池模式时直接切到低档。6. 接入方式配置项、素材规范与最小示例6.1 十行代码跑起来MiroFish 的 API 我尽量压到了最小初始化只需要一个容器和一个配置对象import { MiroFish } from mirofish; const tank new MiroFish(document.querySelector(#tank), { count: 24, theme: freshwater, atlas: /assets/fish-atlas.png, frameCount: 8, interactive: true, maxFps: 60, seed: 20240501, }); tank.start(); // 页面卸载或路由离开时务必销毁 window.addEventListener(beforeunload, () tank.destroy());这里interactive控制的是鼠标刺激与投喂如果鱼缸只是页面装饰、不希望用户去逗它把它设成false可以省掉一部分事件绑定。6.2 配置项速查配置项类型默认值说明countnumber20鱼的数量超过 60 需谨慎maxFpsnumber60帧率上限移动端建议 30themestringfreshwater水色主题影响背景渐变与整体色温atlasstring内置精灵图集地址frameCountnumber8图集里每条鱼的帧数perceptionnumber64感知半径越大鱼群越抱团separationnumber22分离半径越大鱼越不容易靠近socialFactor[min, max][0.5, 1.5]懒散权重范围interactivebooleantrue是否响应鼠标seednumberDate.now()随机种子调参的时候建议一次只动一个。我见过有人把 perception 和 separation 一起调大结果鱼全挤在角落里互相推搡——因为感知半径变大之后想聚的鱼变多分离半径变大之后想散的力度也变大两个互相打架最后就锁死在平衡点上。6.3 素材替换的三条硬规范如果你想换成自己画的鱼有三条必须遵守否则渲染会出问题精灵图必须水平排列帧高一致帧宽一致。混合尺寸的图集需要额外的帧描述表为了省体积我没做支持。鱼头必须朝右且鱼体中心与帧中心重合。所有镜像、旋转、锚点都建立在这个假设上。背景必须全透明边缘不要有半透明描边。半透明边缘在缩放时会和背景色混合出脏边尤其在水体渐变上非常明显。图集导出时勾选紧贴像素边界和不插值两个选项能省掉后面一半的渲染问题。6.4 打包与按需加载MiroFish 的入口做了一个动态导入的分包。鱼缸这类装饰性功能不应该阻塞首屏正确做法是等页面load事件之后异步加载。if (requestIdleCallback in window) { requestIdleCallback(() import(mirofish).then(init)); } else { setTimeout(() import(mirofish).then(init), 1200); }用requestIdleCallback的好处是它会在浏览器空闲时才执行不会和首屏的关键渲染抢资源。素材图集建议单独走 CDN 并加上长缓存因为它基本不会变逻辑代码则要带 hash 短缓存方便修 bug 之后快速生效。7. 三个最难复现的问题完整排查链路7.1 鱼在抖亚像素与精灵图边界现象鱼在匀速游动时身体边缘有轻微的高频抖动截图看不出来但盯着看很难受。奇怪的是只有部分鱼抖靠左边的抖得更明显。排查过程第一步我怀疑是帧率波动把帧率锁死在 60 之后抖动还在排除。第二步怀疑是插值导致的来回震荡把alpha直接设为 0抖动减轻但没消失。第三步开始怀疑坐标精度——打日志输出某条鱼的x值发现它长期停留在123.99999这种接近整数的值上。根因精灵图的绘制坐标没有对齐到物理像素。在 dpr 为 2 的屏幕上x 123.4999会被映射到物理像素的边界上采样时取到了相邻的半像素边缘就出现了闪烁。修复在绘制前把坐标按物理像素对齐const snap (v) Math.round(v * dpr) / dpr; ctx.drawImage(atlas, sx, sy, sw, sh, snap(-w / 2), snap(-h / 2), w, h);注意对齐的是相对偏移量而不是绝对坐标因为鱼的绝对坐标需要保持小数精度用于物理计算只在最终绘制时对齐。7.2 鱼穿模闪烁深度排序与透明度现象两条鱼交叠的时候偶尔会出现闪烁——这一帧 A 在 B 前面下一帧突然反过来像是有一层在闪。排查过程先确认绘制顺序。我是按数组索引顺序绘制的没有做深度排序理论上顺序应该是固定的。但日志显示两条鱼的索引确实没变。接着检查透明度——没错我给每条鱼加了一个基于深度的透明度alpha 0.7 0.3 * depth。问题就在这里。根因两条深度接近的鱼透明度差异极小比如 0.001在 canvas 的 8 位 alpha 通道上被量化成同一个值。但因为浮点运算的微小差异两条鱼的depth会在某些帧里先后越过量化边界导致其中一条的 alpha 突然从 0.85 跳到 0.86视觉上就是闪了一下。修复把深度量化成固定的 16 档绘制前先算出档位同档位的鱼强制使用相同 alpha并且按depth排序后再绘制。修复之后闪烁彻底消失顺带还解决了另一个问题——远处的鱼现在统一画在后面了层次感更明显。7.3 切回标签页鱼瞬移时间步长累积现象切到别的标签页待一分钟再切回来鱼会在第一帧集体闪现到画面另一侧然后恢复正常。排查过程这个问题基本可以确定是时间步长的问题。我在暂停逻辑里记录了pausedAt恢复时用now - pausedAt计算经过时间。日志显示恢复后的第一帧frameTime是 60 秒被Math.min(..., 0.25)截断成了 0.25 秒——按理说应该只推进 15 个物理步。根因真正的问题是暂停时累加器没有清零。暂停前的最后一帧累加器里可能残留了 0.012 秒的余量。恢复时又累加了 0.25 秒然后 while 循环推进了 15 步——这本身没错。但我的render(alpha)用的是暂停前的alpha作为插值基准位置状态和渲染状态错位了视觉上就是突然跳一下。修复暂停时把累加器清零并在恢复时重新取一次performance.now()作为基准时间同时把上一帧的位置快照重置为当前位置插值从零开始。三行代码解决了一个困扰我两天的问题。7.4 我总结的排查顺序这三个问题折腾下来我形成了一个固定的排查顺序写在这里供参考先确认它是不是渲染问题。把行为计算完全冻结所有速度设为零如果画面还在动或者还在闪那问题一定在绘制层跟物理无关。再确认它是不是精度问题。把 dpr 强制设为 1如果问题消失基本就是亚像素级别的事情往像素对齐和量化方向查。最后查时间。所有和时序、暂停、恢复、卡顿相关的问题都去查累加器、基准时间和插值系数这三个值只要有一个没对齐就会出怪现象。这套顺序的价值在于它能快速排除掉一大半无关方向。我前两次排查都花了很多时间在行为参数上后来才意识到问题根本不在那里。最后分享一个我个人用下来受益最大的习惯给渲染层加一个调试开关打开之后画出感知半径、分离半径、速度向量和当前状态名。这个开关的代码量不到五十行但它让我第一次真正看见了鱼群在算什么很多参数标定的直觉都是从那张调试图上来的。装饰性的项目最容易做成黑盒而一旦你能量化它调优就变成了纯工程问题。
返回列表