Canvas 脏矩形优化 — 只重绘"脏了"的地方
目前为止我们所有动画都是"全屏重绘":每帧 clearRect 整个画布,再画所有内容。1000 个图元的画面拖动其中一个,另外 999 个没变——但它们每帧都被重画了 60 次,全是无效功。脏矩形(Dirty Rectangle)优化反其道而行:只有"变了的区域"才重绘,其余画面原封不动。拖一个 50×50 的节点,重绘面积从 1920×1080(207 万像素)降到约 100×100(1 万像素)——快 200 倍。这是日历应用、CAD、组态编辑器祖传的看家本领。
目录
- 一、为什么全屏重绘是浪费
- 二、脏矩形的核心思想
- 三、关键细节:移动图元的"前后两块"
- 四、重叠联动:谁被"掀了盖子"
- 五、多个脏矩形的合并策略
- 六、实战:千图元流畅拖拽
- 七、脏矩形 vs 全屏重绘:决策表
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、为什么全屏重绘是浪费
1.1 一笔账
场景:1920×1080 大屏,800 个设备图元,用户拖拽其中 1 个
全屏重绘(现状):
每帧 clearRect 全屏(207 万像素清零)
+ 800 个图元全部重画(约 12ms)
= 拖一个节点的代价 = 整个画面的代价
脏矩形重绘(今天的目标):
只处理被拖图元"扫过"的区域(约 120×120 = 1.4 万像素)
+ 该区域内受影响的图元(可能就 2~3 个)
= 约 0.3ms
1.2 为什么"画面不动的地方不用重画"
关键认知:Canvas 是【保留像素】的!
不像 DOM(浏览器管理重绘),Canvas 的像素画上去就一直留着——
直到你主动 clearRect 或被新绘制覆盖。
所以"不动的内容"留在画布上完全没问题,
问题只有一个:动的内容怎么"擦旧画新"而不伤到周围?
—— 这就是脏矩形要解的全部问题。
二、脏矩形的核心思想
2.1 三步流程
① 标脏(Mark):
任何东西发生变化 → 把"变化的区域"标记为脏矩形
② 重绘脏区(Redraw):
对每个脏矩形:
ctx.clearRect(脏区) ← 只清这一块
重画"与脏区相交的所有图元" ← 按原来的 z 序!
③ 画布其余部分:什么都不做(像素还是上一帧的,天然正确)
2.2 最小实现
interface Rect {
x: number; y: number; w: number; h: number;
}
/** 脏矩形收集器 */
class DirtyTracker {
private rects: Rect[] = [];
/** 标脏:一个区域变了 */
mark(rect: Rect): void {
this.rects.push(rect);
}
/** 取出并清空本轮所有脏区 */
drain(): Rect[] {
const out = this.rects;
this.rects = [];
return out;
}
}
/**
* 脏矩形渲染主流程
* @param items 全部图元(含 z 序)
*/
function renderDirty(items: { box: Rect; draw: () => void }[], dirty: DirtyTracker): void {
const rects = dirty.drain();
if (rects.length === 0) return; // 没有任何变化 → 一行绘制代码都不执行
for (const r of rects) {
ctx.clearRect(r.x, r.y, r.w, r.h); // ① 只清脏区
for (const item of items) { // ② 重画与脏区相交的图元
if (rectsIntersect(item.box, r)) {
item.draw(); // (保持原 z 序遍历)
}
}
}
}
/** AABB 相交判定(Day 45 直接复用!) */
function rectsIntersect(a: Rect, b: Rect): boolean {
return !(a.x + a.w < b.x || b.x + b.w < a.x || a.y + a.h < b.y || b.y + b.h < a.y);
}
注意:Day 45 的 AABB 判定在这里零成本复用——碰撞检测和脏矩形在数学上是同一个问题(矩形相交测试)。
三、关键细节:移动图元的"前后两块"
3.1 新手必错:只标新位置
// ❌ 错误:图元从 A 移到 B,只把 B 标脏
dirty.mark(newBox);
// 结果:A 位置的"残影"永远留在画布上!
// (旧像素没人清,新位置又画上了 → 屏幕上出现两个)
// ✅ 正确:新旧两块都脏
// 旧块要清掉残影,新块要画上新图
dirty.mark(oldBox);
dirty.mark(newBox);
// 更优:如果位移不大,两块的"并集"是一个矩形,标一次就行
const union = unionRect(oldBox, newBox);
dirty.mark(union);
3.2 并集的实现
/**
* 两个矩形的并集包围盒(能同时盖住两者的最小矩形)
*/
function unionRect(a: Rect, b: Rect): Rect {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x, y,
w: Math.max(a.x + a.w, b.x + b.w) - x,
h: Math.max(a.y + a.h, b.y + b.h) - y,
};
}
3.3 什么时候并集不划算
移动距离很远(比如瞬移 500px):
并集 = 500×50 的长条 → 清晰/重绘一大片
分开 = 两个 50×50 → 总面积小得多
经验法则:位移 < 图元尺寸 → 用并集;否则分两块标脏
四、重叠联动:谁被"掀了盖子"
4.1 问题场景
拖走的图元 A 原来压在图元 B 的上面:
┌────────┐
│ A(拖走) │
│ ┌───┴────┐
└────┤ B │ A 走了 → B 被遮住的部分露出来了
└────────┘ → B 也必须重画!但 B 自己没动!
只标 A 的旧位置够吗?
✓ A 旧位置的 clearRect 会把该区域清空
✓ "与该区域相交的图元重画"——B 与之相交 → B 会重画 ✅
结论:第 2.2 节的标准流程已经覆盖了这种情况!
"重画与脏区相交的所有图元" 这半句就是联动处理的全部。
4.2 验证 z 序的重要性
// 脏区内重画时必须按【全局 z 序】遍历,不能只画"相交的"随便排序:
// 场景:脏区内 B 在下、C 在上(C 半透明盖着 B)
// ✅ 按 items 数组序(z 序)画:先 B 后 C → 层次正确
// ❌ 按"相交检测的先后"画:可能先 C 后 B → C 被盖错
// 第 2.2 节的 for (const item of items) 已经是 z 序遍历(数组序 = 绘制序)
// 这条纪律从今天起升级为铁律:**items 数组的顺序 = 画面的层次**
五、多个脏矩形的合并策略
5.1 为什么要合并
一帧里 3 个图元分别动了 → 3 个脏矩形:
情形 A(分散): 情形 B(聚集):
┌──┐ ┌──────┐
│① │ │① ② │
└──┘ → 不合并, │ │
┌──┐ 各清各的 ✅ │ ③ │
│② │ └──────┘
└──┘ → 合并成一个更省
┌──────┐ (3 次 clearRect + 3 轮相交筛选
│ ③ │ vs 1 次 + 1 轮)
└──────┘
5.2 合并的判断标准:面积权衡
/**
* 简单合并策略:
* 合并后的总面积 < 各脏区面积之和 × 阈值(如 1.5 倍)→ 合并
* 否则分开处理(合并引入的"白清面积"太多)
*/
function maybeMerge(rects: Rect[]): Rect[] {
if (rects.length < 2) return rects;
const totalArea = rects.reduce((s, r) => s + r.w * r.h, 0);
const bound = boundingBox(rects); // 所有矩形的总包围盒
const boundArea = bound.w * bound.h;
// 包围盒只比原面积大 50% 以内 → 合并划算
return boundArea < totalArea * 1.5 ? [bound] : rects;
}
/** 多矩形的总包围盒 */
function boundingBox(rects: Rect[]): Rect {
return rects.reduce((acc, r) => unionRect(acc, r));
}
5.3 工程简化:一帧一脏区
// 更朴素的策略(组态编辑器的常见选择):
// 一帧内所有标脏都合并成一个总包围盒,无论划不划算
// 为什么可以接受:
// ① 编辑器场景的"每帧变化"通常集中(拖一个节点/框选一块区域)
// ② 实现简单,无边界情况
// ③ 极端分散的场景(多粒子)本来就该用全屏重绘(见第七节)
// 结论:先做"一帧一合并",测量后发现亏了再上精细策略
六、实战:千图元流畅拖拽
今天的实战目标:画布上撒 1000 个静态图元 + 1 个可拖拽图元,分别用全屏重绘和脏矩形两种模式拖拽,对比帧耗时——亲眼看 20 倍差距。
6.1 完整代码
// day46-dirty-rect.ts —— 脏矩形 vs 全屏重绘对比
import { bootCanvas } from "../day29/canvas-boot.js";
const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);
interface Item {
x: number; y: number; w: number; h: number;
color: string;
}
// ===== 1. 生成 1000 个静态图元 + 1 个红色拖拽图元 =====
const items: Item[] = Array.from({ length: 1000 }, () => ({
x: Math.random() * (cssWidth - 16),
y: Math.random() * (cssHeight - 16),
w: 12, h: 12,
color: "#4a6fa5",
}));
const hero: Item = {
x: cssWidth / 2 - 20, y: cssHeight / 2 - 20, w: 40, h: 40, color: "#ff4d4f",
};
/** 画一个图元 */
function drawItem(it: Item): void {
ctx.fillStyle = it.color;
ctx.fillRect(it.x, it.y, it.w, it.h);
}
/** 全量渲染(全屏重绘模式用) */
function renderAll(): void {
ctx.clearRect(0, 0, cssWidth, cssHeight);
for (const it of items) drawItem(it);
drawItem(hero);
}
// ===== 2. 脏矩形机器 =====
const dirty = new DirtyTracker(); // 第二节的实现
let oldHeroBox: Rect = { x: hero.x, y: hero.y, w: hero.w, h: hero.h };
/** 脏矩形渲染(含 hero 本体) */
function renderDirtyFrame(): void {
const rects = maybeMerge(dirty.drain()); // 一帧一合并(第五节简化策略)
if (rects.length === 0) return;
for (const r of rects) {
ctx.clearRect(r.x, r.y, r.w, r.h);
for (const it of items) { // 与脏区相交的静态图元重画
if (rectsIntersect(it, r)) drawItem(it);
}
if (rectsIntersect(hero, r)) drawItem(hero);
}
}
// ===== 3. 两种模式切换 + 帧耗时统计 =====
let useDirty = true;
let frameCost = 0;
function onHeroMoved(): void {
const t0 = performance.now();
const newBox: Rect = { x: hero.x, y: hero.y, w: hero.w, h: hero.h };
if (useDirty) {
dirty.mark(oldHeroBox); // 旧位置(清残影)
dirty.mark(newBox); // 新位置(画新图)
renderDirtyFrame();
} else {
renderAll();
}
oldHeroBox = newBox;
frameCost = performance.now() - t0;
// HUD
ctx.fillStyle = "#ffffff";
ctx.font = "14px monospace";
ctx.fillText(`模式: ${useDirty ? "脏矩形" : "全屏重绘"} 本帧: ${frameCost.toFixed(2)}ms (按 C 切换)`, 12, 24);
}
// ===== 4. 拖拽(老套路)=====
let dragging = false, offX = 0, offY = 0;
canvas.addEventListener("mousedown", (e) => {
const r = canvas.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
if (px >= hero.x && px <= hero.x + hero.w && py >= hero.y && py <= hero.y + hero.h) {
dragging = true;
offX = px - hero.x; offY = py - hero.y;
}
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const r = canvas.getBoundingClientRect();
hero.x = e.clientX - r.left - offX;
hero.y = e.clientY - r.top - offY;
onHeroMoved();
});
window.addEventListener("mouseup", () => { dragging = false; });
window.addEventListener("keydown", (e) => {
if (e.key.toLowerCase() === "c") {
useDirty = !useDirty;
renderAll(); // 切模式时全量重绘一次打底
oldHeroBox = { x: hero.x, y: hero.y, w: hero.w, h: hero.h };
}
});
// 启动:全量画一遍打底(脏矩形模式的前提:画布上有完整的初始画面)
renderAll();
6.3 预期结果
典型测量值(800×600,1000 静态图元,普通笔记本):
全屏重绘模式: 本帧 8~14ms (1000 次 fillRect + 全屏 clearRect)
脏矩形模式: 本帧 0.3~1ms (清 ~100×100 + 相交的 3~5 个图元)
差距 10~40 倍,且静态图元越多差距越大。
⚠ 注意观察:脏矩形模式下拖拽经过的区域,被"掀开"的静态图元
正确地重新画了出来(第四节的联动生效)。
6.4 一个隐藏知识点:HUD 文字也在"弄脏"画面
// 上面代码里 HUD 文字每帧重画——它的旧像素没清 → 拖快点会拖出文字残影!
// 正确做法:HUD 区域也标脏
function onHeroMoved(): void {
// ... 标记 hero 新旧位置 ...
dirty.mark({ x: 0, y: 0, w: 400, h: 30 }); // HUD 条也标脏
renderDirtyFrame();
drawHUD(); // 脏区清掉后重画 HUD
}
// 这个细节的价值:体会"画面上任何变化都是脏"的全局意识
七、脏矩形 vs 全屏重绘:决策表
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 大量图元持续变化(粒子、流体) | 全屏重绘 | 脏区覆盖率接近 100%,脏矩形白费管理成本 |
| 少量图元 + 大量静态背景(编辑器/组态) | 脏矩形 | 收益最大(今天的场景) |
| 静态背景 + 动态前景(大屏) | 多层画布(明天) | 背景干脆放另一层,永不重绘 |
| 变化区域中等且集中 | 脏矩形(合并成一个) | 简单有效 |
| 变化区域分散多处 | 脏矩形(分开多个)或全屏 | 合并面积亏太多就别合 |
决策口诀:
全屏都动 → 全屏重绘
一处动 → 脏矩形
前景动背景静 → 分层(明天)
八、常见坑点与最佳实践
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | 移动图元只标新位置 | 旧位置留残影 | 新旧两块都标脏 |
| 2 | 忘了"初始全量绘制" | 脏矩形模式下画布空白 | 启动时 renderAll 打底 |
| 3 | 脏区内重画不按 z 序 | 被遮图元"浮上来" | items 数组序 = 绘制序 = z 序 |
| 4 | 相交筛选用精确多边形 | 性能反而下降 | AABB 相交筛选(图元的包围盒) |
| 5 | 图元带阴影/发光却只标本体框 | 阴影残影 | 脏区扩边:四周加 shadowBlur + lineWidth |
| 6 | resize 后继续脏矩形 | 画布错乱 | resize = 全屏标脏(或全量重绘) |
| 7 | 每帧极多小脏区不合并 | clearRect 调用开销堆积 | 一帧一合并的简化策略 |
| 8 | DPR 下脏区用 CSS 坐标 clearRect | 清的位置不对 | clearRect 走绘制管线用 CSS 坐标(注意与 putImageData 的物理坐标区分) |
| 9 | 拖拽飞快时残影 | 一帧内位移超过图元尺寸 | 标记"上一帧位置→当前位置"的并集 |
坑 5 展开(高频翻车点):
// 图元有 shadowBlur = 20 的发光效果:
dirty.mark({ x: node.x, y: node.y, w: node.w, h: node.h });
// ❌ 只清本体 → 周围 20px 的光晕残影
// ✅ 脏区扩边(padding = 阴影模糊半径 + 线宽/2 + 安全余量)
const PAD = 24; // shadowBlur 20 + lineWidth 2 + 余量
dirty.mark({
x: node.x - PAD, y: node.y - PAD,
w: node.w + PAD * 2, h: node.h + PAD * 2,
});
九、自测挑战
- 概念题:用自己的话说清"标脏 → 清脏区 → 重画相交图元"三步中,每一步为什么不可省略。
- 手算题:图元从 (100,100) 移动到 (180,120),尺寸 40×40。计算分开标脏的总面积与并集面积,判断哪种策略划算。
- 实现题:给第六节实战加"删除图元"功能:点击静态图元删除它。思考:删除时怎么标脏?(提示:被删除图元的旧位置 + 谁露出来了?)
- 改错题:下面的拖拽代码在脏矩形模式下有残影 bug,找出来:
window.addEventListener("mousemove", (e) => {
hero.x = e.clientX - r.left - offX;
hero.y = e.clientY - r.top - offY;
dirty.mark({ x: hero.x, y: hero.y, w: hero.w, h: hero.h });
renderDirtyFrame();
});
- 思考题:为什么说"碰撞检测(Day 45)和脏矩形在数学上是同一个问题"?它们的矩形相交测试分别服务于什么目的?
- 进阶题:把第六节改成"位移超过图元尺寸时分两块标脏,否则用并集",实现第三节的经验法则。
十、总结与知识图谱
Day 46 脏矩形优化
├── 前提认知
│ └── Canvas 像素保留 → 不动的不用重画
├── 核心流程
│ ├── 标脏(变化的区域)
│ ├── 清脏区(只 clearRect 这块)
│ └── 重画相交图元(保持 z 序)
├── 三个关键细节
│ ├── 移动 = 新旧两块(或并集)
│ ├── 重叠联动(相交重画自动覆盖)
│ └── 脏区扩边(阴影/发光的 padding)
├── 合并策略
│ ├── 面积权衡(包围盒 < 1.5×总面积)
│ └── 工程简化:一帧一合并
├── 决策口诀
│ └── 全屏都动全屏 / 一处动脏矩形 / 前景动背景静分层
└── 实战
└── 千图元拖拽对比(10~40 倍提升)
明天预告(Day 47):多层画布架构——脏矩形解决了"重绘多少",多层画布从根上解决"谁和谁一起重绘":静态层(网格背景,只画一次)、动态层(数据内容,低频更新)、交互层(选中框/拖拽预览,高频更新)各自独立,事件在最上层监听再路由分发。大屏"内容又多又流畅"的架构级答案。