【Canvas 2D】day32-shadow-compositing

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

Canvas 阴影与合成模式 — 发光效果与"图层混合"体系

今天解锁 Canvas 的"高级感"双引擎:阴影让图形脱离平面(发光、投影、立体),合成模式(globalCompositeOperation)让新画的图形与已有内容产生 12 种化学反应——叠加提亮、挖洞、遮罩、异或……工业场景的报警灯光晕、雷达扫描余辉、橡皮擦(Day 35 画板的核心原理)全部建立在这两个 API 上。合成模式是本周抽象度最高的一课,我们用"源与目标"的统一心智模型一次讲清。产出:发光的设备报警灯 + 12 种合成模式实验卡。


目录


一、阴影四件套

// 阴影是画笔状态(影响后续所有 fill/stroke/drawImage):
ctx.shadowColor = "rgba(0, 255, 136, 0.8)";   // ① 阴影颜色(带透明度才有"光晕感")
ctx.shadowBlur = 20;                          // ② 模糊半径(0 = 硬边阴影)
ctx.shadowOffsetX = 0;                        // ③ 水平偏移
ctx.shadowOffsetY = 0;                        // ④ 垂直偏移

// 画一个"发光方块":
ctx.fillStyle = "#00ff88";
ctx.fillRect(100, 100, 80, 80);               // 这一笔自带绿色光晕

// ⚠ 用完必须关掉(阴影也是状态,会泄漏到后续绘制):
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
两组经典参数:
投影感:shadowColor 黑色半透明 + blur 10 + offsetX/Y 各 4(往右下投影)
发光感:shadowColor 同主色 + blur 20+ + offset 0(四周均匀光晕)
⚠ 偏移不受变换影响(第 6 周的伏笔:阴影偏移永远在画布坐标系)

二、发光效果:阴影的正向玩法

2.1 单层发光

function glowCircle(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void {
  ctx.shadowColor = color;
  ctx.shadowBlur = 25;
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fill();
  // 复位(防止污染后续绘制):
  ctx.shadowBlur = 0;
  ctx.shadowColor = "transparent";
}

2.2 多层发光(霓虹灯的浓烈感)

/**
 * 叠加多层模糊不同的阴影 → 更浓、更有层次的辉光
 * (一层 blur=40 很淡,四层 blur=40/30/20/10 叠起来非常"亮")
 */
function neonCircle(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
  ctx.fillStyle = "#fff";                        // 灯芯是白色
  for (const blur of [40, 25, 12, 6]) {
    ctx.shadowColor = "rgba(0, 200, 255, 0.9)";  // 同色多层
    ctx.shadowBlur = blur;
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fill();                                   // 同一个圆画 4 遍(每遍一层光)
  }
  ctx.shadowBlur = 0;
  ctx.shadowColor = "transparent";
}

2.3 呼吸动画的伏笔

// Day 34 学动画循环后,把 shadowBlur 随时间正弦脉动:
// shadowBlur = 15 + Math.sin(t / 300) * 10;   // 报警灯"呼吸"效果
// 今天先把静态版画出来,动画版是 Day 34 的回炉作业

三、globalAlpha:全局透明度

// globalAlpha:给后续所有绘制统一乘一个透明度(0-1)
ctx.globalAlpha = 0.3;
ctx.fillStyle = "#ff4d4f";
ctx.fillRect(50, 50, 100, 100);   // 30% 透明的红块

ctx.globalAlpha = 1;              // ⚠ 必须复位

// vs rgba 颜色自带透明度:
ctx.fillStyle = "rgba(255, 77, 79, 0.3)";   // 只影响这一种颜色
// globalAlpha 影响所有(包括图片、渐变、阴影)—— 批量半透明的总闸
// 典型场景:拖拽预览(0.5)、历史轨迹淡出(逐帧递减)、雷达余辉

四、合成模式:源与目标的心智模型

这是本周最重要的心智模型,先背下来再看模式

合成发生在每一次绘制时:

- 目标(destination)= 画布上【已经存在】的内容
- 源(source)       = 你【正在画】的这一笔

globalCompositeOperation 决定:
"这一笔画下去,源与目标如何结合?"

       源(新画的一笔)
      ┌─────────┐
      │  ╳╳╳    │      12 种模式 = 12 种"结合规则":
      │ ╳╳╳╳╳   │      重叠区怎么算?只留源?只留目标?
      └─────────┘      都留还是都删?
   ┌─────────┐
   │ ╳╳╳     │ ← 重叠区(合成规则作用的地方)
   │ ╳╳╳     │
   └─────────┘
  目标(画布已有内容)
// 使用方式(状态型 API,用完复位):
ctx.globalCompositeOperation = "lighter";
// ... 绘制 ...
ctx.globalCompositeOperation = "source-over";   // 复位成默认

五、12 种合成模式逐一拆解

5.1 基础三模式(必须精通)

// ===== source-over(默认)=====
// 源盖在目标上(正常画画)—— 平时一直在用的就是它
ctx.globalCompositeOperation = "source-over";

// ===== destination-out(橡皮擦原理 ⭐)=====
// 源像"腐蚀剂":源画到哪里,目标就被【擦掉】哪里
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0,1)";     // 颜色无所谓(只借形状)
ctx.fillRect(50, 50, 40, 40);        // 在已有画面上"抠"出 40×40 的透明洞
// Day 35 画板的橡皮 = 画笔换这个模式 —— 不是画白色,是真·擦除!

// ===== lighter(叠加提亮 ⭐)=====
// 源与目标颜色【相加】(物理光学:红+绿=黄)
ctx.globalCompositeOperation = "lighter";
// 粒子叠加的火苗、雷达扫描的热点、数据碰撞的高亮区
// ⚠ 多层 lighter 叠加会快速"烧白"(255 封顶)—— 恰好是火光的质感

5.2 保留源族(source-in / source-atop)

// ===== source-in =====
// 只保留【重叠区】的源(源被目标"裁剪"了)
ctx.globalCompositeOperation = "source-in";
// 用途:给图片/图形做形状遮罩 ——
// 先画一个圆形目标 → 切 source-in → 画图片 → 得到圆形裁剪的图片

// ===== source-out =====
// 只保留【不重叠】的源(重叠处被目标挡掉)
ctx.globalCompositeOperation = "source-out";
// 用途:月牙效果(大圆画完,用重叠小圆"咬"掉一块)

// ===== source-atop =====
// 源只画在目标【存在的地方】(不重叠处不画,目标保留)
ctx.globalCompositeOperation = "source-atop";
// 用途:给已有图形"上贴纸"——只在图形区域内显示新内容

5.3 保留目标族(destination-over / destination-in / destination-out / destination-atop)

// ===== destination-over =====
// 源垫在目标【下面】(后画的在底层)
ctx.globalCompositeOperation = "destination-over";
// 用途:先画前景、后补背景(不需要打乱绘制顺序)

// ===== destination-in =====
// 只保留【重叠区】的目标(目标被源裁剪)
ctx.globalCompositeOperation = "destination-in";
// 用途:聚光灯效果 —— 画一个亮圆,全画面只保留圆内内容

// ===== destination-atop =====
// 目标只保留在源存在的地方,且源画在目标外区域
ctx.globalCompositeOperation = "destination-atop";

5.4 异或族

// ===== xor =====
// 重叠区【互斥删除】(只留不重叠部分)
ctx.globalCompositeOperation = "xor";
// 用途:维恩图的"不相交"部分、抽象图案

// ===== copy =====
// 只留源(目标全部清空)
ctx.globalCompositeOperation = "copy";
// 用途:谨慎使用 —— 相当于带形状的 clearRect

// ===== darker(已废弃)=====
// 与 lighter 相反的减色 —— 规范已移除,浏览器可能有兼容实现,不学

5.5 模式速查表(打印贴墙版)

模式 效果记忆 工业用途
source-over 新盖旧(默认) 正常绘制
destination-out 新笔=橡皮 画板橡皮 ⭐
lighter 颜色相加变亮 火焰/雷达热点 ⭐
source-in 新的被旧的裁剪 圆形头像裁剪
source-out 新的露在旧的外的部分 月牙/缺口
source-atop 新的只贴在旧的上面 图形内贴纹理
destination-over 新的垫底 先前景后补背景
destination-in 旧的被新的裁剪 聚光灯
destination-atop 旧的贴到新的上 纹理混合
xor 重叠互删 维恩图
copy 只要新的 带形状清屏
darker (废弃) 不用

六、实战:发光报警灯

// day32-alarm-lamp.ts —— 三盏状态灯:运行(绿)、待机(黄)、报警(红呼吸)
import { bootCanvas } from "./canvas-boot.js";

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

interface LampSpec {
  x: number; y: number;
  color: string;         // 主色
  label: string;
  blinking: boolean;     // 是否闪烁
}

/** 画一盏带光晕和灯芯的状态灯 */
function drawLamp(spec: LampSpec, alpha = 1): void {
  ctx.save();   // 第 6 周正式课,今天先用:保存画笔状态
  ctx.globalAlpha = alpha;

  // ===== 外层光晕(径向渐变,透明到主色) =====
  const glow = ctx.createRadialGradient(spec.x, spec.y, 5, spec.x, spec.y, 45);
  glow.addColorStop(0, spec.color);
  glow.addColorStop(1, "rgba(0,0,0,0)");        // 边缘透明(光晕淡出)
  ctx.fillStyle = glow;
  ctx.beginPath();
  ctx.arc(spec.x, spec.y, 45, 0, Math.PI * 2);
  ctx.fill();

  // ===== 灯体(多层阴影发光) =====
  ctx.shadowColor = spec.color;
  ctx.shadowBlur = 18;
  ctx.fillStyle = spec.color;
  ctx.beginPath();
  ctx.arc(spec.x, spec.y, 16, 0, Math.PI * 2);
  ctx.fill();
  ctx.shadowBlur = 0;

  // ===== 高光点(左上的白色小椭圆 → 立体玻璃感) =====
  ctx.fillStyle = "rgba(255,255,255,0.85)";
  ctx.beginPath();
  ctx.arc(spec.x - 5, spec.y - 6, 4, 0, Math.PI * 2);
  ctx.fill();

  // ===== 标签 =====
  ctx.fillStyle = "#e6f1ff";
  ctx.font = "13px sans-serif";
  ctx.textAlign = "center";
  ctx.fillText(spec.label, spec.x, spec.y + 40);
  ctx.restore();
}

// ===== 底色 + 三盏灯 =====
ctx.fillStyle = "#0a1628";
ctx.fillRect(0, 0, width, height);

drawLamp({ x: width * 0.25, y: height / 2, color: "#00ff88", label: "运行", blinking: false });
drawLamp({ x: width * 0.5,  y: height / 2, color: "#ffc53d", label: "待机", blinking: false });
drawLamp({ x: width * 0.75, y: height / 2, color: "#ff4d4f", label: "报警", blinking: true }, 0.75);
// 报警灯的"呼吸"(alpha 随时间变化)是 Day 34 rAF 循环的回炉作业

七、实战:合成模式实验卡

// day32-composite-lab.ts —— 12 种模式做成一张可视化对照卡(学习神器)
import { bootCanvas } from "./canvas-boot.js";

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

const MODES = [
  "source-over", "destination-out", "lighter", "source-in",
  "source-out", "source-atop", "destination-over", "destination-in",
  "destination-atop", "xor", "copy", "source-over",
] as const;

const cols = 4, rows = 3;
const cellW = width / cols, cellH = height / rows;

MODES.forEach((mode, i) => {
  const cx = (i % cols) * cellW + cellW / 2;
  const cy = Math.floor(i / cols) * cellH + cellH / 2;

  // ===== 1. 画目标(蓝色方块) =====
  ctx.globalCompositeOperation = "source-over";   // 复位再画目标!
  ctx.fillStyle = "rgba(0,150,255,0.8)";
  ctx.fillRect(cx - 35, cy - 35, 70, 70);

  // ===== 2. 切换合成模式,画源(红色圆) =====
  ctx.globalCompositeOperation = mode;
  ctx.fillStyle = "rgba(255,80,80,0.8)";
  ctx.beginPath();
  ctx.arc(cx + 15, cy + 15, 32, 0, Math.PI * 2);
  ctx.fill();

  ctx.globalCompositeOperation = "source-over";   // 复位

  // ===== 3. 模式名标注 =====
  ctx.fillStyle = "#e6f1ff";
  ctx.font = "12px monospace";
  ctx.textAlign = "center";
  ctx.fillText(mode, cx, cy + cellH / 2 - 10);
});

// ⭐ 每个格子里:蓝方块是"目标",红圆是"源",重叠区的效果 = 该模式的语义
// 这张卡拍下来存进笔记 —— 比任何文字描述都直观

八、类比记忆:暗房叠片

合成模式 = 暗房里两张底片的叠放规则

- 目标 = 先放进放大机的底片(画布已有内容)
- 源   = 正要压上去的新底片(这一笔)

- source-over      = 新底片直接压上(最常见)
- lighter          = 两次曝光(亮度叠加 → 烧白)
- destination-out  = 新底片是"漂白剂":压到哪,旧片哪变透明
- source-in        = 剪刀:新底片被旧底片的形状剪出来
- destination-in   = 反向剪刀:旧底片被新底片的形状剪出来
- destination-over = 新底片垫在下面(后放反而做底)
- xor              = 重叠处两张一起消失(奇妙的双负片)

阴影 = 底片下的衬纸:
- blur 是衬纸的厚度(模糊扩散)
- offset 是衬纸的错位(投影方向)
- "发光" = 衬纸比底片还亮(阴影颜色的创造性误用)

九、常见坑点与最佳实践

坑点 1:阴影状态泄漏(本周最高频事故)

ctx.shadowBlur = 20;
ctx.fillRect(0, 0, 50, 50);     // 这块带光晕(故意的)
ctx.fillRect(100, 0, 50, 50);   // ⚠ 这块也带光晕(忘记关了!)
ctx.fillText("标题", 10, 100);  // ⚠ 文字也带光晕(越污染越乱)

// ✅ 两条纪律:
// 1. 用完立刻复位四件套(shadowColor=transparent, blur/offset=0)
// 2. 今天先用 ctx.save()/restore() 包裹(第 6 周正式学"状态栈")

坑点 2:合成模式忘记复位

ctx.globalCompositeOperation = "destination-out";
// ... 擦除 ...
// 后续所有绘制都在"擦除模式"下进行 —— 画啥啥消失,画面越来越空
// ✅ 每次用完立刻切回 "source-over"

坑点 3:lighter 叠加成一片白

// lighter 是加色混合,RGB 各通道相加、255 封顶
// 60 个粒子互相叠加 → 画面迅速"烧白"失去层次
// 缓解:给源色降初始值(如 rgb(80,20,10) 的暗红火苗)或降低 alpha

坑点 4:用 destination-out 时源颜色影响透明度

ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0,0.5)";    // 半透明的"橡皮"
ctx.fillRect(50, 50, 40, 40);         // 擦掉的是 50% 透明度(半擦除!)
// 完全擦除需要 alpha=1 的颜色 —— 但"半擦除"恰好可以做羽化边缘(高级技巧)

坑点 5:shadowBlur 性能税

shadowBlur 是 Canvas 最贵的操作之一(高斯模糊逐帧计算):
- 大 blur 半径 + 每帧重绘 = 掉帧元凶
- 优化:光晕改为"径向渐变预渲染到离屏 canvas,再 drawImage"
  (第 6 周 OffscreenCanvas 的正式话题,今天留个钩子)

十、自测挑战

挑战 1(基础):模式对号入座

以下需求分别用哪个合成模式?
a. 画板里"擦掉"画错的部分
b. 把矩形照片裁成圆形头像
c. 粒子火焰越叠越亮的炽热感
d. 先画了前景后想垫一张背景图
e. 只显示鼠标圈选区域内的画面(聚光灯)

挑战 2(进阶):月牙绘制

// 用 source-out 画一弯月牙:先切换模式,再画一个与"已有满月"部分重叠的圆
// 挑战升级:不用合成模式,用非零环绕规则(Day 30)画同样的月牙 → 对比两种思路

挑战 3(进阶):羽化橡皮

// destination-out + 径向渐变(中心 alpha=1、边缘 alpha=0)做"软边橡皮"
// 这是 Photoshop 羽化选区的原理 —— Day 35 画板的高配橡皮

挑战 4(论文级):讲清"源与目标"

向橡皮鸭解释(拿今天的实验卡当教具):
1. 为什么"先画的叫目标、后画的叫源"?这个命名是怎么对应的?
2. source-in 和 destination-in 都是"保留重叠区",区别到底在哪一端?
3. 橡皮擦为什么"颜色无所谓,形状才重要"?

十一、总结与知识图谱

阴影与合成(Day 32)
│
├── 阴影四件套(状态型,用完复位 ⚠)
│   ├── shadowColor / shadowBlur / shadowOffsetX / shadowOffsetY
│   ├── 投影感:黑半透明 + blur 10 + 偏移
│   ├── 发光感:同主色 + blur 20+ + 零偏移
│   └── 多层发光 = 霓虹(性能税高,第 6 周优化)
│
├── globalAlpha
│   └── 全局透明总闸(拖拽预览/轨迹淡出)
│
├── 合成模式(源 = 新一笔 / 目标 = 已有内容)⭐
│   ├── source-over —— 默认(新盖旧)
│   ├── destination-out —— 橡皮擦 ⭐
│   ├── lighter —— 加色提亮 ⭐
│   ├── source-in/out/atop —— 源的三种保留方式
│   ├── destination-over/in/atop —— 目标的三种保留方式
│   └── xor / copy —— 互斥 / 只留源
│
└── 实战产出
    ├── 发光报警灯(渐变光晕 + 多层 shadow + 高光)
    └── 12 模式实验卡(学习教具,存进笔记)

一句话总结:合成模式的全部复杂度被"源与目标"四个字降维——新画的是源、已存的是目标,12 种模式只是"重叠了怎么办"的 12 个答案;而阴影是"发光效果"的平民方案(记得用完关)——这两把钥匙合起来,你就能画出会呼吸、会发光、能被擦除的活界面。


明日预告:Day 33 文本与图片——fillText 的字体简写与锚点体系、measureText 的宽度测量、drawImage 九参数裁剪大法,以及"画布被污染"(tainted canvas)的跨域大坑。

评论