【Canvas 2D】day44-clipping

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

Canvas 裁剪 clip — 路径变身模板,非矩形可视区域的秘密

到今天为止,我们画的东西都是"画多大就显示多大"。但产品里到处是"只显示一部分"的需求:进度环只显示扇形部分、头像只显示圆形区域、雷达图只显示多边形内部。Canvas 的答案叫裁剪(clip):把当前路径变成一块"镂空模板",之后的所有绘制只有在模板内的部分才可见,画出去的自动隐形。今天回收 Day 36 的伏笔——裁剪区域也是被 save/restore 管理的状态,这也是 clip 使用中 90% 事故的答案。


目录


一、clip 的心智模型:镂空模板

1.1 一张纸的比喻

想象在画布上盖一张【中间镂空了形状的黑纸】:

┌──────────────────────┐
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│
│▓▓▓▓▓┌────────┐▓▓▓▓▓▓│      黑纸(什么都看不见)
│▓▓▓▓▓│        │▓▓▓▓▓▓│      中间镂空(唯一可见区)
│▓▓▓▓▓│  可见   │▓▓▓▓▓▓│
│▓▓▓▓▓│        │▓▓▓▓▓▓│      之后画的任何东西:
│▓▓▓▓▓└────────┘▓▓▓▓▓▓│      镂空区内的显示 ✅
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│      黑纸遮住的部分隐形 ❌
└──────────────────────┘

1.2 与"画小一点"的本质区别

// 需求:图片只显示中间 100×100 的正方形区域

// 方案 A:画小一点(drawImage 九参数裁剪,Day 33)
ctx.drawImage(img, sx, sy, 100, 100, dx, dy, 100, 100);
// 这是"源头裁剪":从图片上抠一块来画 —— 只适用于图片

// 方案 B:clip
ctx.save();
ctx.beginPath();
ctx.rect(dx, dy, 100, 100);
ctx.clip();
ctx.drawImage(img, dx, dy);        // 整张图随便画
ctx.restore();
// 这是"显示裁剪":随便画什么,出口处只放行指定区域 —— 适用于一切绘制

// clip 的独门能力:动画过渡(模板形状/位置随时间变)、
//                    多种绘制统一受裁剪(图片+文字+图形一起裁)

二、基本用法与三大铁律

2.1 标准三步

// clip 的使用永远是三步:
ctx.save();                     // ① 救状态(铁律一)

ctx.beginPath();                // ② 建路径(镂空模板的形状)
ctx.arc(200, 150, 80, 0, Math.PI * 2);
ctx.clip();                     //    激活!路径 → 模板

ctx.drawImage(img, 0, 0, 400, 300);   // ③ 随便画(只有圆内可见)

ctx.restore();                  // ④ 撤掉模板(铁律一的后半)

2.2 三大铁律

铁律一:clip 必须 save/restore 配对
  裁剪区域一旦激活,【后续所有绘制】都被裁——不 restore 的话
  下一帧的 clearRect 也会被裁掉 → 画面"越画越少"直至全黑。

铁律二:clip 用的是【当前路径】——先 build 路径再 clip
  clip() 没有参数!它吃的是 beginPath 家族攒出来的当前路径。
  (忘了 beginPath → 之前的路径全成了模板 → 裁剪区域莫名其妙巨大)

铁律三:clip 在路径的 fill/stroke 之前还是之后?
  ——clip 与 fill/stroke 是并列的"路径消费"操作,互不冲突:
  同一条路径可以既 clip 又 fill(先 clip 再 fill = 在模板内填充)

2.3 非 zero winding:clip 的填充规则

// 路径自相交时(比如两个反向的圆),裁剪区域怎么算?
ctx.clip("nonzero");   // 默认:非零环绕规则(和 fill 相同)
ctx.clip("evenodd");   // 奇偶规则:重叠区域会被"挖空"

// 典型应用:圆环(大圆 + 反向小圆)用 nonzero → 中间被挖空的环
ctx.beginPath();
ctx.arc(cx, cy, 80, 0, Math.PI * 2);           // 大圆(顺时针)
ctx.arc(cx, cy, 50, 0, Math.PI * 2, true);     // 小圆(逆时针 → 方向相反)
ctx.clip();
// 结果:只有 50~80 之间的圆环可见(甜甜圈模板)

三、clip 与 save/restore:伏笔回收

3.1 Day 36 的清单里写过它

会 save/restore 管理的状态:……裁剪区域 clip() 设置的区域(第 7 周)

今天正式展开:

// 嵌套 clip 的行为:交集!
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, 200, 200);
ctx.clip();                       // 模板 1:左上角 200×200

ctx.save();
ctx.beginPath();
ctx.rect(100, 100, 200, 200);
ctx.clip();                       // 模板 2:右下角 200×200
// ⚠ 现在的有效可见区 = 模板1 ∩ 模板2 = (100,100)-(200,200) 的 100×100

ctx.fillRect(0, 0, 500, 500);     // 只有交集区域显示
ctx.restore();                    // 撤掉模板 2 → 回到只有模板 1

ctx.fillRect(250, 50, 50, 50);    // 模板 1 外 → 隐形!(250 > 200)
ctx.restore();                    // 撤掉模板 1 → 回到自由

3.2 一个反直觉实验

// 实验:clip 后 restore,再画东西——还裁吗?
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, 100, 100);
ctx.clip();
ctx.restore();                    // ← 立刻 restore,什么都没画

ctx.fillRect(200, 200, 50, 50);   // 显示!✅(restore 已撤掉模板)

// 结论:clip 的作用域就是 save...restore 之间的代码块
// —— 和"变换的作用域"完全一致(Day 36/37 的知识直接迁移)

四、经典应用一:扇形进度环

4.1 需求分析

扇形进度环(工业大屏常客):
  ┌─────────┐
  │  ⬤ 68%  │    背景:整圆灰色
  │ ╲▔▔▔╱   │    前景:68% 的扇形亮色
  │  ╲   ╱   │    实现路径 A:arc 直接画扇形(Day 30 就会)
  └─────────┘    实现路径 B:clip —— 为什么还要 B?

clip 版的杀手锏:进度扇形里要画渐变 + 图片 + 文字的复合内容时,用 arc 画扇形只是"底色",内容还是矩形的——只有 clip 能把所有东西统一裁进扇形。

4.2 完整实现

// day44-progress-ring.ts —— 扇形进度环(clip 版)
import { bootCanvas } from "../day29/canvas-boot.js";

const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);

const CX = cssWidth / 2, CY = cssHeight / 2, R = 110;

/**
 * 绘制扇形进度环
 * @param progress 0~1 的进度
 */
function drawRing(progress: number): void {
  // ===== 1. 背景环(整圆,暗色)=====
  ctx.strokeStyle = "#1a2740";
  ctx.lineWidth = 22;
  ctx.beginPath();
  ctx.arc(CX, CY, R, 0, Math.PI * 2);
  ctx.stroke();

  // ===== 2. 进度环(clip 大法)=====
  ctx.save();
  // 2a. 建扇形路径(从 -90° 顶部开始,顺时针 progress 圈)
  ctx.beginPath();
  ctx.moveTo(CX, CY);                                    // 圆心(扇形的尖)
  ctx.arc(CX, CY, R + ctx.lineWidth, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * progress);
  ctx.closePath();                                       // 闭合回圆心 → 完整扇形
  ctx.clip();                                            // 激活模板

  // 2b. 模板内随便画 —— 这里画整圆的渐变前景(clip 自动裁成扇形)
  const grad = ctx.createLinearGradient(CX - R, CY - R, CX + R, CY + R);
  grad.addColorStop(0, "#00c6ff");
  grad.addColorStop(1, "#00ff88");
  ctx.strokeStyle = grad;
  ctx.lineWidth = 22;
  ctx.beginPath();
  ctx.arc(CX, CY, R, 0, Math.PI * 2);
  ctx.stroke();                                          // 整圆渐变 → 只露扇形 ✅

  ctx.restore();                                         // 撤模板

  // ===== 3. 中心文字(不裁剪!在 restore 之后画)=====
  ctx.fillStyle = "#e6f1ff";
  ctx.font = "bold 42px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText(`${Math.round(progress * 100)}%`, CX, CY - 8);
  ctx.font = "14px sans-serif";
  ctx.fillStyle = "#8fa3c0";
  ctx.fillText("设备负载", CX, CY + 28);
}

// 动画:进度从 0 滑到目标值(Day 37 的指数逼近)
let current = 0;
const target = 0.68;

function frame(): void {
  current += (target - current) * 0.06;
  ctx.clearRect(0, 0, cssWidth, cssHeight);
  drawRing(current);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

4.3 结构复盘

背景环(不受裁剪)─┐
                   ├─ 顺序很关键:restore 之后画文字,文字才不被裁
进度扇形(clip 内)─┘

五、经典应用二:圆形头像与图片裁剪

5.1 圆形头像

/**
 * 圆形头像:图片裁进圆里
 * ⚠ 高频坑:drawImage 会使 canvas tainted(Day 33/39)
 *    → 图片必须同源或 crossOrigin="anonymous" + CORS
 */
function drawAvatar(img: CanvasImageSource, cx: number, cy: number, r: number): void {
  ctx.save();
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.clip();

  // cover 模式:图片等比缩放至"铺满圆",居中裁剪(CSS object-fit: cover 的手写版)
  const iw = (img as HTMLImageElement).naturalWidth;
  const ih = (img as HTMLImageElement).naturalHeight;
  const scale = Math.max((r * 2) / iw, (r * 2) / ih);   // 取更大的缩放 → 铺满
  const dw = iw * scale, dh = ih * scale;
  ctx.drawImage(img, cx - dw / 2, cy - dh / 2, dw, dh);  // 居中绘制

  ctx.restore();

  // 描边要在 restore 后(不然描边也被裁掉一半)
  ctx.strokeStyle = "#00ff88";
  ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.stroke();
}

5.2 任意多边形裁剪图片

/**
 * 把图片裁进任意多边形(组态里的"建筑轮廓贴图")
 * points: 多边形顶点数组
 */
function clipPolygon(img: CanvasImageSource, points: { x: number; y: number }[]): void {
  ctx.save();
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
  ctx.closePath();
  ctx.clip();

  // 裁剪包围盒铺图
  const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
  const minX = Math.min(...xs), maxX = Math.max(...xs);
  const minY = Math.min(...ys), maxY = Math.max(...ys);
  ctx.drawImage(img, minX, minY, maxX - minX, maxY - minY);

  ctx.restore();

  // 多边形描边
  ctx.strokeStyle = "rgba(0,255,136,0.8)";
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
  ctx.closePath();
  ctx.stroke();
}

六、经典应用三:雷达图

6.1 需求与实现

雷达图 = 多边形 clip + 多边形描边 + 同心网格:

// day44-radar.ts —— 雷达图(clip 版数据面)
/**
 * 绘制雷达图
 * @param values  各维度数值(0~1),如 [0.8, 0.6, 0.9, 0.7, 0.5]
 * @param labels  各维度名称
 */
function drawRadar(values: number[], labels: string[]): void {
  const CX = 300, CY = 220, R = 140, N = values.length;

  // 顶点计算:第 i 维的方向角(从顶部开始均分)
  const vertex = (i: number, scale: number): { x: number; y: number } => {
    const a = -Math.PI / 2 + (Math.PI * 2 * i) / N;
    return { x: CX + Math.cos(a) * R * scale, y: CY + Math.sin(a) * R * scale };
  };

  // ===== 1. 背景网格(三层同心多边形 + 轴线)=====
  for (const s of [1 / 3, 2 / 3, 1]) {
    ctx.strokeStyle = "rgba(58,74,106,0.5)";
    ctx.lineWidth = 1;
    ctx.beginPath();
    values.forEach((_, i) => {
      const p = vertex(i, s);
      i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
    });
    ctx.closePath();
    ctx.stroke();
  }
  values.forEach((_, i) => {
    const p = vertex(i, 1);
    ctx.beginPath();
    ctx.moveTo(CX, CY);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();
  });

  // ===== 2. 数据面(clip 大法:渐变填充自动被裁成多边形)=====
  ctx.save();
  ctx.beginPath();
  values.forEach((v, i) => {
    const p = vertex(i, v);                       // 每维一个顶点
    i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
  });
  ctx.closePath();
  ctx.clip();                                     // 数据多边形 → 模板

  // 模板内画一个大的径向渐变矩形(中心亮边缘暗)
  const grad = ctx.createRadialGradient(CX, CY, 0, CX, CY, R);
  grad.addColorStop(0, "rgba(0,198,255,0.5)");
  grad.addColorStop(1, "rgba(0,255,136,0.25)");
  ctx.fillStyle = grad;
  ctx.fillRect(CX - R, CY - R, R * 2, R * 2);     // 矩形被裁成雷达形 ✅

  ctx.restore();

  // ===== 3. 数据面描边 + 顶点 =====
  ctx.strokeStyle = "#00c6ff";
  ctx.lineWidth = 2;
  ctx.beginPath();
  values.forEach((v, i) => {
    const p = vertex(i, v);
    i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
  });
  ctx.closePath();
  ctx.stroke();

  values.forEach((v, i) => {
    const p = vertex(i, v);
    ctx.fillStyle = "#e6f1ff";
    ctx.beginPath();
    ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
    ctx.fill();
  });

  // ===== 4. 维度标签 =====
  ctx.fillStyle = "#8fa3c0";
  ctx.font = "13px sans-serif";
  ctx.textAlign = "center";
  labels.forEach((label, i) => {
    const p = vertex(i, 1.18);                    // 标签放顶点外侧一点
    ctx.fillText(label, p.x, p.y);
  });
}

drawRadar([0.85, 0.6, 0.9, 0.7, 0.45, 0.75],
           ["效率", "能耗", "良率", "稼动", "维保", "安全"]);

6.2 为什么数据面用 clip 而不是直接 fill

// 直接 fill:纯色可行,但【渐变按整块填充】
ctx.fillStyle = grad;
ctx.fill();          // 渐变覆盖数据面 —— 效果其实一样?

// 区别在"复杂填充"场景:
// ① 数据面内要叠加纹理(createPattern)→ fill 也行
// ② 数据面内要画"多个图层"(渐变 + 高光 + 图片)→ 只有 clip 能统一裁
// ③ 动画:数据值变化时,clip 路径重建即可,内部图层不用重算
// 经验:简单场景 fill,复合内容 clip。今天用雷达图练 clip 的"复合填充"肌肉。

七、clip 的性能真相

7.1 clip 不便宜

clip 的实现代价:浏览器要把裁剪区域做成"蒙版"应用到后续每次绘制

测量参考(普通笔记本,800×600 画布):
  无 clip 的 fillRect × 100:        ~0.5ms
  clip(复杂路径)后的 fillRect × 100:~2-4ms   ← 慢 4~8 倍

结论:
  ✅ 静态/低频内容用 clip:随便用,无感
  ⚠ 每帧热循环里用 clip:掂量(尤其是复杂路径的 clip)
  ❌ 嵌套多层 clip 的动画:性能重灾区,重构吧

7.2 替代方案速查

场景 clip 之外的选择
图片裁矩形 drawImage 九参数(源头裁剪,零开销)
圆形头像(静态) 离屏画布裁一次,之后 drawImage 贴(Day 41 思想)
每帧动画里的非矩形显示 数学画法(arc 扇形本身就是非矩形的)

原则:clip 是"显示层裁剪",能用"源头裁剪/数学画法"替代时优先替代;clip 留给"多内容统一裁剪"的刚需场景。


八、常见坑点与最佳实践

# 症状 解法
1 clip 后不 restore 下一帧 clearRect 也被裁 → 画面逐渐消失 铁律:save…clip…restore 三件套
2 clip 前忘了 beginPath 旧路径变成模板,裁剪区莫名巨大 clip 前必 beginPath
3 描边画在 clip 内 圆形头像的边框只剩一半 描边放 restore 之后
4 clip 内画了透明度低的底色 想给模板区域加底色,结果叠了好几层 底色也是"被裁内容",注意叠加顺序
5 热循环里复杂 clip 帧率骤降 离屏预裁剪 / 数学画法替代
6 跨域图 + clip + 后续 getImageData SecurityError crossOrigin + CORS(Day 33/39 老坑)
7 嵌套 clip 想要并集 只能得到交集 clip 天生交集;并集要在【同一条路径】里加两个子路径
8 clip 的路径用了变换 模板形状"歪了" 路径构建时就考虑变换(Day 37:先变换再建路径)

坑 7 展开并集

// 想要"两个圆的并集"作为模板:
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.arc(220, 100, 50, 0, Math.PI * 2);   // ⚠ 不 moveTo 直接 arc?会有连接线
// 正确:每个子路径前 moveTo 到圆的起点
ctx.beginPath();
ctx.moveTo(150, 100);   ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.moveTo(270, 100);   ctx.arc(220, 100, 50, 0, Math.PI * 2);
ctx.clip();             // 两个圆的并集模板 ✅

九、自测挑战

  1. 默写题:写出 clip 的标准三步(含铁律一的完整配对),并说明为什么每步不可省。
  2. 预测题:下面代码执行后画面上有什么?
ctx.save();
ctx.beginPath();
ctx.rect(50, 50, 100, 100);
ctx.clip();
ctx.fillRect(0, 0, 300, 300);       // ①
ctx.restore();
ctx.fillRect(200, 200, 50, 50);     // ②
  1. 实现题:把第 4 节的进度环改成"环形"(中间挖空):进度环外径 110 内径 88,用 nonzero 规则的 clip 实现(提示:大圆顺时针 + 小圆逆时针)。
  2. 改错题:下面的圆形头像代码有两个 bug,找出来:
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.strokeStyle = "#fff";
ctx.stroke();
  1. 设计题:大屏的"视频监控窗格"要实现圆形放大镜效果(鼠标到哪,哪里的画面以圆形放大 2 倍显示)。设计实现方案:clip 圆 + drawImage 放大重绘的流程,写出伪代码。
  2. 思考题:为什么"clip 后 restore 再画东西就自由了"能证明"裁剪区域是被 save/restore 管理的状态"?(对照 Day 36 的状态清单,说说这个设计的一致性价值。)

十、总结与知识图谱

Day 44 裁剪 clip
├── 心智模型
│   └── 镂空模板:模板内可见、模板外隐形
├── 三大铁律
│   ├── save/restore 必配对(否则 clearRect 都被裁)
│   ├── 吃当前路径(先 beginPath)
│   └── 与 fill/stroke 并列的路径消费
├── 嵌套规则
│   ├── 多层 clip = 交集
│   ├── 并集 = 同一路径多子路径(moveTo 断开)
│   └── nonzero/evenodd 填充规则
├── 三大应用
│   ├── 扇形进度环(渐变裁成扇形)
│   ├── 圆形头像(cover 模式 + 描边在 restore 后)
│   └── 雷达图(复合填充裁成多边形)
├── 性能
│   ├── clip 慢 4~8 倍 → 热循环慎用
│   └── 替代:源头裁剪 / 离屏预裁 / 数学画法
└── 架构位置
    └── 裁剪 = 显示层过滤(与 Day 33 源头裁剪区分)

明天预告(Day 45)碰撞检测——图元世界的物理规则:AABB 包围盒、圆-圆、圆-矩形,以及碰撞后的"推离响应"。拖拽设备图元时与相邻图元重叠 → 警示变色 + 自动推开的"磁悬浮排座"效果,是明天实战的主角。它也是编辑器(Day 49)防节点重叠的基础。

评论