火次果
火次果

暂无菜单项

首页/文章/技术文章/web前端/HTML5/
打开 MD 链接

Canvas 动画帧循环与性能优化:rAF 时序、离屏渲染与 GC 控制的实战笔记

发布于 19小时前更新于 19小时前
2

Canvas 动画跑到 60fps 不难,难的是元素上到几千个、画布铺满高分屏之后还能稳住。多数项目掉到 30fps 时,第一反应是”要不要换 WebGL”,其实 2D 上下文里还有大量能抠出来的时间,问题在于帧循环写得不干净、状态切换太随意、每帧还在制造垃圾。

Canvas 动画帧循环与性能优化

⚙️ 帧循环:时间戳才是可信的时间源

用 setInterval(fn, 16) 驱动动画是最常见的起点问题。定时器不跟显示器刷新对齐,回调可能落在两帧之间,画面就会周期性抖一下,术语叫 judder。requestAnimationFrame 由浏览器在渲染前统一调度,天然对齐刷新率。

真正需要注意的是 rAF 回调收到的那个 DOMHighResTimeStamp 参数,而不是在回调里再调一次 performance.now():

let last = 0;
function frame(t) {
  if (!last) last = t;
  const dt = Math.min(t - last, 50) / 1000; // 切后台回来时 dt 会爆
  last = t;
  update(dt);
  render();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

那个 Math.min 不是可有可无的。标签页切到后台再回来,t - last 可能是几十秒,物理积分一步就能把粒子甩到画布外。钳到 50ms 相当于让模拟”慢放”一帧,比炸掉强得多。

累加器式固定步长适合有物理、碰撞的场景,逻辑稳定性比帧率重要:

const STEP = 1 / 60;
let acc = 0;
function frame(t) {
  const dt = Math.min(t - last, 50) / 1000;
  last = t;
  acc += dt;
  while (acc >= STEP) { update(STEP); acc -= STEP; }
  render(acc / STEP); // 余数可做插值
}
requestAnimationFrame(frame);

逻辑步固定,渲染按余数插值,画面在低刷设备(120Hz、60Hz 混用)上都不会变速。如果做的是纯视觉动效,不做插值也完全够用,别为了严谨把代码复杂度堆上去。

📉 一帧的预算到底花在哪

60fps 意味着 16.6ms 总预算,实际可用更少,浏览器还要做样式计算、合成、光栅化。让人困惑的地方在于:rAF 回调返回不等于画面已经画完。Canvas 2D 的绘制命令通常先入队,真正的光栅化发生在合成阶段,Performance 面板里看到的”长任务”往往只是脚本部分。

所以测的时候要分开记:

const t0 = performance.now();
render();
const cost = performance.now() - t0; // 只是脚本侧耗时

把每帧的 cost 塞进一个长度 120 的环形缓冲,看 p95 / p99​ 而不是平均值。平均 6ms 但 p99 25ms,用户感知到的就是每隔几秒卡一下。这个统计方式比肉眼看 FPS 数字靠谱得多。

🧱 状态切换:最容易被忽略的大头

Canvas 2D 的性能陷阱里,fillStyle、font 这类状态设置的开销排在很前面。每次赋字符串都要解析颜色、匹配字体,同一帧里反复设同一个值纯属浪费。

// 慢:500 次状态切换 + 500 次光栅化for (const p of points) {
  ctx.fillStyle = '#3b82f6';
  ctx.beginPath();
  ctx.arc(p.x, p.y, p.r, 0, TAU);
  ctx.fill();
}


// 快:一次切换,一次 fill
ctx.fillStyle = '#3b82f6';
ctx.beginPath();
for (const p of points) {
  ctx.moveTo(p.x, p.y);
  ctx.arc(p.x, p.y, p.r, 0, TAU);
}
ctx.fill();

同色、同线宽的元素合并成一条路径再一次性提交,实测在几千个粒子的场景下能差出好几倍。颜色必须不同的话,先按颜色分桶,桶内合并。

几个真实很贵的操作,尽量别出现在每帧的主循环里:

shadowBlur / shadowColor:模糊要额外走一遍卷积,粒子多的时候直接拖垮

ctx.filter = 'blur(4px)':比 shadow 还贵,静态内容预渲染到离屏画布上更划算

createLinearGradient / createRadialGradient:每帧 new 一次既是 GC 压力也是状态开销,缓存起来,尺寸变了再重建

getImageData:GPU 到 CPU 的回读,同步阻塞,频繁调用时显式传 { willReadFrequently: true } 让浏览器走 CPU 后端

save() / restore() 单次很便宜,但循环里几千次地调就是浪费,能用 setTransform(1,0,0,1,0,0) 显式复位的地方就别压栈。

🖼️ 离屏画布、分层与脏矩形

预渲染是 2D 上下文最实用的手段:复杂的图标、带阴影的粒子贴图、文字标签,先画到一张离屏 canvas 上,主循环里只做 drawImage。一次位图拷贝比重新走一遍路径 + 光栅化便宜得多,而且离屏画布要复用,不要每帧 createElement('canvas')。

分层的思路是按变化频率拆画布:

背景层:网格、渐变底、静态装饰,尺寸变化时重画一次

通过对动态的层面化的呈现,如以粒子、连线、波形等为代表的各类动态的视觉元素的每一帧的实时重画,从而使其呈现出更加生动的视觉效果

交互层:tooltip、图例、按钮,直接放 DOM,事件处理和无障碍都省事

UI 用 DOM 覆盖在 canvas 上,比在 canvas 里手搓命中检测省一大截工作量。

脏矩形只清理变化区域,适合元素稀疏的场景:

ctx.clearRect(dirty.x, dirty.y, dirty.w, dirty.h);

元素铺满全屏时,维护多个脏区的合并、相交计算反而白搭,这时候整屏 fillRect 覆盖反而更直白。不透明画布用 fillRect 铺底,需要透明背景才用 clearRect。

📱 高分屏与像素填充率

高 DPI 处理写错,性能直接砍半:

const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width  = cssW * dpr;
canvas.height = cssH * dpr;
canvas.style.width  = cssW + 'px';
canvas.style.height = cssH + 'px';
ctx.scale(dpr, dpr);

那个 Math.min(..., 2) 是刻意加的。3x 屏上像素量是 1x 的 9 倍,填充率压力暴涨,视觉收益却很小。移动端全屏粒子动画在 3x 下掉帧,把 dpr 钳到 2 通常立刻回血。

填充率相关的还有几处:大范围半透明矩形叠加、globalCompositeOperation = 'lighter' 铺满全屏、整屏渐变遮罩。背景那类不需要锐利细节的内容,可以按 0.5 倍分辨率画到独立层再放大合成,观感损失小,收益明显。

🧹 GC 抖动:每帧别造垃圾

动画卡顿里有一类是周期性微卡,原因常在垃圾回收。每帧 new 几百个粒子对象、数组反复 push/splice,新生代 GC 就会隔一会儿来一次。

// 粒子数据用 TypedArray 预分配const N = 5000;
const px = new Float32Array(N), py = new Float32Array(N);
const vx = new Float32Array(N), vy = new Float32Array(N);

结构数组(SoA)比对象数组在这个量级上明显更稳:连续内存、无隐藏类变化、不产生临时对象。数量不定就用对象池,回收时把对象塞回池子而不是丢掉。另外注意闭包——每帧在循环里创建箭头函数、每次 filter/map 产生新数组,都是隐形分配。

🔀 把绘制搬到 Worker

OffscreenCanvas 能把整条绘制链挪出主线程,滚动和交互不再被绘制阻塞:

const off = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: off }, [off]);

适合绘制重、交互轻的场景(数据可视化大屏、波形流)。要注意的是 DOM 事件得手动转发进 worker,文本测量能力也有限,Safari 的兼容情况按项目支持范围评估。比它更省事的折中方案:只把粒子位置计算放进 worker,用 SharedArrayBuffer 或 postMessage 传坐标数组,绘制仍在主线程,改动量小很多。

支持作者
如果这篇内容对你有帮助,可以请作者喝杯咖啡
0 点赞
0 收藏
分享
0 讨论
反馈
0 / 600
细中粗
0 讨论
热门最新
总结
暂无总结
嗨,下午好!
所有的成功,都源自一个勇敢的开始
创作
社区
购物
会员
近期热门

暂无数据

火次果
火次果
首页
资迅中心
小店
AI导航
社区
所有的成功,都源自一个勇敢的开始
不辜负每一个勇敢的开始
关于FAQ协议
火次果 © 2026鲁ICP备2025164830号-1