跳到主要内容

rAF离屏与双缓冲

1. 循环:可取消、可暂停、可追帧

export interface TickContext {
/** rAF 传入的 DOMHighResTimeStamp,相对 time origin */
now: number;
/** 相对上一帧的秒,已夹紧,后台恢复不会炸物理 */
dt: number;
}

export interface FrameLoopOptions {
maxDt: number;
onTick: (tick: TickContext) => void;
}

export function startFrameLoop(options: FrameLoopOptions): () => void {
let frameId = 0;
let last: number | undefined;
let running = true;

const frame = (now: number): void => {
if (!running) return;
if (document.visibilityState === "hidden") {
last = undefined;
frameId = requestAnimationFrame(frame);
return;
}
const rawDt = last === undefined ? 0 : (now - last) / 1000;
last = now;
const dt = Math.min(Math.max(rawDt, 0), options.maxDt);
options.onTick({ now, dt });
frameId = requestAnimationFrame(frame);
};

const onVisibility = (): void => {
if (document.visibilityState === "visible") {
last = undefined;
}
};
document.addEventListener("visibilitychange", onVisibility);
frameId = requestAnimationFrame(frame);

return () => {
running = false;
cancelAnimationFrame(frameId);
document.removeEventListener("visibilitychange", onVisibility);
};
}

maxDt 通常取 1 / 201 / 30。标签页从后台回来时,rAF 可能一次给出几百毫秒的间隙;不夹紧,积分器会把物体瞬移出屏幕。

document.hidden 时 Chrome 会把 rAF 降到约 1fps 或停。不要在 hidden 里继续算物理还往 canvas 画——白烧电,恢复时还要对齐。正确做法是 丢掉 last,恢复后从当前 now 重新积分

不要用 setInterval(16) 驱动绘制。它不跟 vsync,会和合成器抢,高刷屏上也对不齐。

2. 时间基准:rAF timestamp vs performance.now()

两者都是 DOMHighResTimeStamp,但:

  • rAF 的 timestamp这一帧被调度时 的时间,同一帧里多次 rAF 回调共享同一值(规范意图)。
  • performance.now() 是调用瞬间。在回调里测「这一帧画了多久」用它;做动画插值用 rAF 的 now

不要混:插值用 performance.now(),下一帧又用 rAF timestamp,会出现一帧的相位差,表现为微抖动。

Date.now() 会受系统校时、NTP 调整。动画禁用。

3. 立即模式意味着:每帧你得知道画什么

interface SceneNode {
id: string;
z: number;
dirty: boolean;
draw: (ctx: CanvasRenderingContext2D) => void;
}

export function renderScene(
ctx: CanvasRenderingContext2D,
nodes: readonly SceneNode[],
viewport: DOMRect,
): void {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.restore();

const ordered = [...nodes].sort((a, b) => a.z - b.z);
for (const node of ordered) {
node.draw(ctx);
}
void viewport;
}

全清 + 全绘是默认正确。脏区优化只有在「静态层很大、动态层很小」时才值得做:

  • clearRect(dirty) 之前,必须保证脏区外的像素 仍然是上一帧的最终结果。立即模式里一旦你漏画一个半透明叠层,残影就是产品 bug。
  • 半透明、阴影、滤镜的脏区必须膨胀(blur radius、shadow offset)。
  • 相交半透明节点不能只重绘脏的那一个。

没测过之前,不要上脏区。先分层。

4. 离屏不是双缓冲

浏览器对 <canvas> 的呈现本来就是:你画 backing store → 合成器拿去显示。你在 JS 里再搞一张离屏 canvas,解决的是另一件事:

目的做法
静态层不每帧重光栅离屏画一次,主 canvas drawImage
Worker 里算/画,主线程只提交OffscreenCanvas07
复杂合成(遮罩、滤镜链)工作表面 willReadFrequently 或独立离屏,结果 blit 到视图表面
避免绘制过程被用户看见不需要。backing store 不会在 fill 中途被合成

所谓「双缓冲」如果是指 canvas1 画、canvas2 显示再 swap DOM,通常是多余的,还会多一次布局/合成。例外:你要做 无缝替换整张位图(例如解码完一帧视频再贴),那是内容提交,不是对抗撕裂。

export function blitLayer(
view: CanvasRenderingContext2D,
layer: CanvasImageSource,
dpr: number,
): void {
view.save();
view.setTransform(dpr, 0, 0, dpr, 0, 0);
view.globalCompositeOperation = "copy";
view.drawImage(layer, 0, 0);
view.restore();
}

globalCompositeOperation = "copy" 比先 clearRectsource-over 更适合整层替换:少一次清屏 pass。

5. 一帧预算

60Hz 约 16.7ms,120Hz 约 8.3ms,而且要和 JS、样式、合成分。Canvas 2D 的 fill 是同步光栅。一帧里画爆了,不会自动拆到下一帧,只会掉帧。

策略:

  • 粒子/轨迹超过预算:降采样、合并 path、用 Path2D 复用。
  • 文本每帧 measureText:缓存 TextMetrics,font 变了再失效。
  • 动画节点和静态节点分 canvas,静态层只在 resize/主题切换时重绘。

6. 失败形态

症状原因
切回标签页物体瞬移dt 未夹紧
动画微抖混用 Date.now / performance.now / rAF timestamp
残影脏区没膨胀,或半透明没全量重绘
多一张 canvas 仍然掉帧DOM swap 假双缓冲,热路径仍在主线程光栅
后台仍然耗电hidden 时还在 rAF 里重物理

权威资料

核对日期:2026-08-26