【Canvas 2D】day46-dirty-rect

作者:mario 发布时间: 2026-09-02 阅读量:5 评论数:0

Canvas 脏矩形优化 — 只重绘"脏了"的地方

目前为止我们所有动画都是"全屏重绘":每帧 clearRect 整个画布,再画所有内容。1000 个图元的画面拖动其中一个,另外 999 个没变——但它们每帧都被重画了 60 次,全是无效功。脏矩形(Dirty Rectangle)优化反其道而行:只有"变了的区域"才重绘,其余画面原封不动。拖一个 50×50 的节点,重绘面积从 1920×1080(207 万像素)降到约 100×100(1 万像素)——快 200 倍。这是日历应用、CAD、组态编辑器祖传的看家本领。


目录


一、为什么全屏重绘是浪费

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,
});

九、自测挑战

  1. 概念题:用自己的话说清"标脏 → 清脏区 → 重画相交图元"三步中,每一步为什么不可省略。
  2. 手算题:图元从 (100,100) 移动到 (180,120),尺寸 40×40。计算分开标脏的总面积与并集面积,判断哪种策略划算。
  3. 实现题:给第六节实战加"删除图元"功能:点击静态图元删除它。思考:删除时怎么标脏?(提示:被删除图元的旧位置 + 谁露出来了?)
  4. 改错题:下面的拖拽代码在脏矩形模式下有残影 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();
});
  1. 思考题:为什么说"碰撞检测(Day 45)和脏矩形在数学上是同一个问题"?它们的矩形相交测试分别服务于什么目的?
  2. 进阶题:把第六节改成"位移超过图元尺寸时分两块标脏,否则用并集",实现第三节的经验法则。

十、总结与知识图谱

Day 46 脏矩形优化
├── 前提认知
│   └── Canvas 像素保留 → 不动的不用重画
├── 核心流程
│   ├── 标脏(变化的区域)
│   ├── 清脏区(只 clearRect 这块)
│   └── 重画相交图元(保持 z 序)
├── 三个关键细节
│   ├── 移动 = 新旧两块(或并集)
│   ├── 重叠联动(相交重画自动覆盖)
│   └── 脏区扩边(阴影/发光的 padding)
├── 合并策略
│   ├── 面积权衡(包围盒 < 1.5×总面积)
│   └── 工程简化:一帧一合并
├── 决策口诀
│   └── 全屏都动全屏 / 一处动脏矩形 / 前景动背景静分层
└── 实战
    └── 千图元拖拽对比(10~40 倍提升)

明天预告(Day 47)多层画布架构——脏矩形解决了"重绘多少",多层画布从根上解决"谁和谁一起重绘":静态层(网格背景,只画一次)、动态层(数据内容,低频更新)、交互层(选中框/拖拽预览,高频更新)各自独立,事件在最上层监听再路由分发。大屏"内容又多又流畅"的架构级答案。

评论