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 / 20~1 / 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 里算/画,主线程只提交 | OffscreenCanvas 见 07 |
| 复杂合成(遮罩、滤镜链) | 工作表面 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" 比先 clearRect 再 source-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