【Canvas 2D】day38-transform-matrix

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

Canvas 变换矩阵 — setTransform 与"以鼠标为中心缩放"的数学本质

昨天的三件套 translate/rotate/scale,其实是三颗"快捷键"——它们底层都在修改同一个东西:变换矩阵(一个 2×3 的数字表格)。今天把这个底牌翻开:搞懂 setTransform/transform 的六个参数 a b c d e f,你就掌握了变换的"总开关"——可以一步设置任意复合变换,也可以随时"重置归零"。然后是本周重头戏实战:滚轮缩放 + 拖拽平移的画面导航,特别是"缩放时鼠标下的点纹丝不动"——这是组态编辑器、地图、CAD 软件共同的交互内核。


目录


一、矩阵:变换的统一语言

1.1 为什么需要矩阵?

昨天的三件套各管一件事,但组合起来时有局限:

// 局限 1:想知道"当前累计变换是什么"很别扭(没有 ctx.getTranslate() 这种 API)
// 局限 2:无法直接设置一个复合变换(只能从头 set 各分量)
// 局限 3:无法做"反推"——已知屏幕上的点,求它在原始场景里的位置(拾取要用!)

// 矩阵解决全部:一个 2×3 表格描述任意 translate+rotate+scale 的组合。

1.2 六参数矩阵的模样

Canvas 的变换矩阵(2×3):

  | a  c  e |        x' = a·x + c·y + e      新x = ...
  | b  d  f |        y' = b·x + d·y + f      新y = ...

  每个 Canvas 点 (x, y) 经过矩阵映射成 (x', y') —— 这就是"变换"的全部数学。

各参数的几何含义(先混个脸熟,不背公式,看直觉):

参数 单独设置时的效果 直觉
a x 坐标缩放 x 轴被拉伸多少
d y 坐标缩放 y 轴被拉伸多少
e x 方向平移 桌布右移多少
f y 方向平移 桌布下移多少
b y 影响 x(剪切/旋转成分) 坐标系"歪"的程度
c x 影响 y(剪切/旋转成分) 同上

1.3 三件套 = 矩阵的特例

// 昨天的每个 API 都是设置矩阵的快捷方式:

ctx.translate(tx, ty)  等价于矩阵参数:e = tx, f = ty(其余默认)
ctx.scale(sx, sy)      等价于:a = sx, d = sy
ctx.rotate(θ)          等价于:a = cosθ, b = sinθ, c = -sinθ, d = cosθ

// 恒等矩阵(什么变换都没有):
ctx.setTransform(1, 0, 0, 1, 0, 0);   // a=1 b=0 c=0 d=1 e=0 f=0

验证 rotate 的直觉:rotate(90°) → cos90°=0, sin90°=1 → a=0, b=1, c=-1, d=0 → 点 (1,0) 映射为 (0·1 + (-1)·0, 1·1 + 0·0) = (0, 1) → x 轴指向了 y 轴方向(旋转 90°)✅


二、setTransform 与 transform

2.1 两个 API 的区别(一句话)

ctx.setTransform(a, b, c, d, e, f);   // 【重置后设置】:清空所有累计变换,直接设这个矩阵
ctx.transform(a, b, c, d, e, f);      // 【叠加】:在当前矩阵基础上再乘一个新矩阵

// 类比:
// setTransform = 换一张新桌布(旧的扔掉)
// transform    = 在当前桌布上继续变形(叠加)

2.2 典型用法

// 用法 1:DPR 适配的重写(Day 29 的 ctx.scale(dpr, dpr) 本质是 transform 叠加)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // 一步到位,且不受之前变换污染

// 用法 2:绘制中途"回到世界坐标系"画 UI(不受场景变换影响)
ctx.save();
// ...一大堆场景变换 + 场景绘制...
ctx.restore();

// ⚠ 如果之前忘记 save(或栈被搞乱),救急手段:
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // 强制归位到"只有 DPR"的基准状态

// 用法 3:resetTransform(新 API,等价于 setTransform(1,0,0,1,0,0))
ctx.resetTransform();   // 完全归零(连 DPR 都去掉,慎用)

2.3 读取当前矩阵:ctx.getTransform()

const m = ctx.getTransform();
// m.a, m.b, m.c, m.d, m.e, m.f —— 六个分量
// 还有两个实用方法:

// ① 把"场景坐标"转成"屏幕坐标"(DOMMatrix 变换点):
const screenPt = m.transformPoint(new DOMPoint(sceneX, sceneY));

// ② 求逆矩阵:把"屏幕坐标"转回"场景坐标"(拾取的关键!):
const inv = m.inverse();
const scenePt = inv.transformPoint(new DOMPoint(screenX, screenY));

② 就是"画布拾取"的数学原理:鼠标给了屏幕坐标,经过逆矩阵变换,得到它在场景坐标系里的真实位置——不管画面被平移缩放旋转成什么样,命中检测都精确。


三、矩阵复合:从直觉到代码

3.1 复合 = 效果叠加

// 先 translate(100, 0) 再 rotate(90°),等价于一次 transform 吗?
// 是的——复合变换可以用一次矩阵乘法表达:

ctx.translate(100, 0);
ctx.rotate(deg(90));
// 累计矩阵 = T(100,0) × R(90°)   (顺序:右边的先作用于图形)

// 用 transform 一步写(平时不用手算,但要看得懂):
ctx.transform(0, 1, -1, 0, 100, 0);   // = 先转90°再平移100 的复合矩阵

3.2 为什么"代码从后往前作用"?(呼应昨天的坑)

累计矩阵:M = T × R
点变换:p' = M·p = T × (R × p)

运算顺序:先 R 作用在 p 上(先旋转),再 T 作用(后平移)
代码顺序:先写 T 后写 R —— 正好反过来!
这就是"从后往前作用"的数学解释。

3.3 实用复合模板速查

// 模板:绕 (px,py) 旋转 θ 的等效单矩阵(平时仍推荐 translate/rotate 三步曲,可读性好)
// M = T(px,py) × R(θ) × T(-px,-py)

// 模板:缩放 s 且平移 (tx,ty)
ctx.setTransform(s, 0, 0, s, tx, ty);
// 场景坐标 (x,y) → 屏幕坐标 (s·x + tx, s·y + ty)
// ⭐ 记住这个式子——第五节的场景模型全靠它

四、场景变换模型:viewport 的数学

4.1 大屏/组态的画面导航模型

工业组态、地图、CAD 的画面缩放平移,本质都是一个场景矩阵

场景(世界坐标)                 屏幕(画布坐标)
  设备在 (500, 300)      ──→      画在画布哪里?

变换式:屏幕坐标 = 缩放 × 场景坐标 + 平移
        screen = zoom × scene + pan

矩阵形式(a=zoom, d=zoom, e=panX, f=panY):
  ctx.setTransform(zoom, 0, 0, zoom, panX, panY);

反向变换(屏幕 → 场景,拾取用):
  scene = (screen - pan) / zoom

4.2 为什么记住这个模型就够用?

// 缩放画面:改 zoom(保持 pan 不动 → 以画布左上角为中心缩放)
zoom *= 1.1;

// 平移画面:改 pan
panX += dx;

// 以画布中心 (cw/2, ch/2) 为中心缩放:希望缩放后该点屏幕位置不变
// 代入 screen = zoom·scene + pan,要求中心点 scene_c 映射不变:
//   newZoom·scene_c + newPan = oldZoom·scene_c + oldPan
// 解出 newPan = center - newZoom × (center - oldPan) / oldZoom

把"中心点"换成"鼠标点",就是大名鼎鼎的以鼠标为中心缩放


五、实战:滚轮缩放 + 拖拽平移画面

今天的实战目标:一个可无限缩放平移的"工厂场景"——滚轮缩放(以鼠标为中心)、按住拖拽平移、双击复位。场景内容用几个设备图元 + 网格背景演示。

5.1 完整代码

// day38-viewport.ts —— 滚轮缩放 + 拖拽平移的画面导航
import { bootCanvas } from "../day29/canvas-boot.js";

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

// ===== 场景状态:viewport 三元组(本文件的核心数据)=====
const view = {
  zoom: 1,      // 缩放倍数
  panX: 0,      // x 平移(屏幕像素)
  panY: 0,
};

const ZOOM_MIN = 0.1, ZOOM_MAX = 10;

/** 屏幕坐标 → 场景坐标(拾取、命中检测都用它) */
function screenToScene(sx: number, sy: number): { x: number; y: number } {
  return {
    x: (sx - view.panX) / view.zoom,
    y: (sy - view.panY) / view.zoom,
  };
}

// ===== 场景内容:网格 + 设备图元 =====

/** 绘制场景网格(跟随缩放,让"画面在动"有参照物) */
function drawGrid(): void {
  const step = 50;   // 场景坐标里每 50 一格
  ctx.strokeStyle = "rgba(58, 74, 106, 0.4)";
  ctx.lineWidth = 1 / view.zoom;   // ⭐ 线宽补偿:缩放后网格线视觉粗细不变

  // 只画屏幕覆盖范围内的格子(避免缩小时画几万条线卡死)
  const left = screenToScene(0, 0).x;
  const top = screenToScene(0, 0).y;
  const right = screenToScene(cssWidth, cssHeight).x;
  const bottom = screenToScene(cssWidth, cssHeight).y;

  ctx.beginPath();
  for (let x = Math.floor(left / step) * step; x <= right; x += step) {
    ctx.moveTo(x, top);
    ctx.lineTo(x, bottom);
  }
  for (let y = Math.floor(top / step) * step; y <= bottom; y += step) {
    ctx.moveTo(left, y);
    ctx.lineTo(right, y);
  }
  ctx.stroke();
}

/** 场景里的设备(世界坐标) */
const devices = [
  { x: 100, y: 100, name: "泵机A" },
  { x: 300, y: 150, name: "泵机B" },
  { x: 200, y: 350, name: "阀门C" },
  { x: 450, y: 300, name: "电机D" },
];

function drawDevices(): void {
  for (const d of devices) {
    ctx.save();
    ctx.translate(d.x, d.y);    // 注意:当前矩阵已是场景矩阵,这里用【场景坐标】
    ctx.fillStyle = "#4a6fa5";
    ctx.fillRect(-18, -18, 36, 36);
    ctx.strokeStyle = "#00ff88";
    ctx.lineWidth = 2 / view.zoom;   // 线宽补偿
    ctx.strokeRect(-18, -18, 36, 36);
    ctx.fillStyle = "#e6f1ff";
    ctx.font = `${14 / view.zoom}px sans-serif`;   // ⭐ 文字也要补偿!否则缩小时糊成一团
    ctx.textAlign = "center";
    ctx.fillText(d.name, 0, 34 / view.zoom * 0.5 + 14 / view.zoom);
    ctx.restore();
  }
}

// ===== 渲染主函数:viewport 矩阵的唯一入口 =====
function render(): void {
  ctx.clearRect(0, 0, cssWidth, cssHeight);

  // ⭐ 核心两行:先重置回 DPR 基准,再叠加场景矩阵
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);            // 基准(bootCanvas 里定义的 dpr)
  ctx.transform(view.zoom, 0, 0, view.zoom, view.panX, view.panY);

  drawGrid();
  drawDevices();

  // 恢复基准,画不随场景缩放的 HUD 信息
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = "#8fa3c0";
  ctx.font = "12px sans-serif";
  ctx.fillText(`zoom: ${view.zoom.toFixed(2)}  pan: (${view.panX.toFixed(0)}, ${view.panY.toFixed(0)})`, 12, 20);
}
const dpr = window.devicePixelRatio || 1;

// ===== 交互 1:滚轮缩放(以鼠标为中心)=====
canvas.addEventListener("wheel", (e) => {
  e.preventDefault();   // 阻止页面滚动

  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;   // 鼠标屏幕坐标(CSS 像素)
  const my = e.clientY - rect.top;

  // 缩放因子:滚一格 ±10%
  const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
  const newZoom = Math.min(Math.max(view.zoom * factor, ZOOM_MIN), ZOOM_MAX);
  const realFactor = newZoom / view.zoom;   // 实际生效的因子(被 MIN/MAX 截断后)

  // ⭐ 以鼠标为中心的数学(第 4.2 节的公式直接落地):
  // 要求:场景点 scene_m(鼠标下)缩放后仍映射到 (mx, my)
  // screen = zoom·scene + pan  →  pan = screen - zoom·scene
  const scene = screenToScene(mx, my);         // 鼠标下的场景点(缩放前)
  view.panX = mx - newZoom * scene.x;
  view.panY = my - newZoom * scene.y;
  view.zoom = newZoom;
  // ⚠ 上面 realFactor 其实没直接用到——pan 公式里已隐含因子(更简洁的写法)

  render();
}, { passive: false });

// ===== 交互 2:拖拽平移 =====
let dragging = false;
let lastX = 0, lastY = 0;

canvas.addEventListener("mousedown", (e) => {
  dragging = true;
  const rect = canvas.getBoundingClientRect();
  lastX = e.clientX - rect.left;
  lastY = e.clientY - rect.top;
});
window.addEventListener("mousemove", (e) => {
  if (!dragging) return;
  const rect = canvas.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  view.panX += x - lastX;    // ⭐ 平移直接加屏幕像素差(pan 的单位就是屏幕像素)
  view.panY += y - lastY;
  lastX = x;
  lastY = y;
  render();
});
window.addEventListener("mouseup", () => { dragging = false; });

// ===== 交互 3:双击复位 =====
canvas.addEventListener("dblclick", () => {
  view.zoom = 1;
  view.panX = 0;
  view.panY = 0;
  render();
});

render();   // 首帧

5.2 "以鼠标为中心缩放"的数学推导(务必亲手推一遍)

目标:滚轮缩放后,鼠标指向的场景点不"跑偏"。

设:鼠标屏幕位置 (mx, my),缩放前的场景点 s = (sx, sy)。

缩放前:mx = zoom · sx + panX          ① 鼠标下的场景点满足的映射
缩放后要求不变:mx = zoom' · sx + panX'  ② 新参数也要满足同一条

由 ①:sx = (mx - panX) / zoom          (screenToScene 做的事)
代入 ②:panX' = mx - zoom' · sx
              = mx - zoom' · (mx - panX) / zoom

代码里就是这三行:
  const scene = screenToScene(mx, my);
  view.panX = mx - newZoom * scene.x;
  view.panY = my - newZoom * scene.y;

面试高频题:为什么不能简单地 panX *= factor?——因为那是以画布原点 (0,0) 为中心缩放,鼠标位置会飘走。中心点的选择决定了 pan 的补偿公式。


六、以鼠标为中心缩放:一步到位法

第 5 节的写法是"先算场景点、再回代",还有一种等价的"三步曲直觉法",某些代码库常见,看得懂即可:

// 直觉法:把"以 (mx,my) 为中心缩放"理解为三步变换的复合
//   ① 把中心搬到原点:translate(-mx, -my)
//   ② 缩放:scale(factor)
//   ③ 搬回去:translate(mx, my)

// 用矩阵乘法展开后(过程略,结果与第 5 节公式完全一致):
//   panX' = mx - factor · (mx - panX)
//   panY' = my - factor · (my - panY)
//   zoom' = zoom · factor

// 两种写法结果相同 —— 推荐第 5 节版(screenToScene 语义清晰、复用性强)。

七、常见坑点与最佳实践

# 症状 解法
1 wheel 不 preventDefault 画布缩放时整个页面滚动 { passive: false } + preventDefault
2 缩放中心用画布原点 缩放时内容"飞向"左上角 以鼠标为中心:pan = mouse - zoom’·scene
3 忘了线宽/字号补偿 缩小后线糊成面、字看不清 lineWidth / zoomfont / zoom
4 缩小时网格画满全屏 帧率暴跌 只遍历屏幕可见范围(代码里的 left/top/right/bottom)
5 setTransform 忘了 DPR 高分屏上画面变模糊/错位 setTransform(dpr,...) 再叠加场景矩阵
6 zoom 无限大/小 精度崩坏、内容消失 MIN/MAX 钳制(0.1 ~ 10)
7 用 clientX 直接当画布坐标 画面位置偏移 getBoundingClientRect 换算(Day 34 惯例)
8 拖拽把 pan 单位搞混 拖得比手快/慢 pan 是屏幕像素单位,直接加屏幕像素差

架构建议:viewport(zoom/panX/panY)作为独立的状态对象,渲染函数只在开头 setTransform 一次——所有场景绘制统一在场景坐标系里写,屏幕/场景的换算收敛到 screenToScene 一个函数。这条纪律决定你的组态项目代码是否可维护。


八、自测挑战

  1. 参数题ctx.setTransform(2, 0, 0, 2, 100, 50) 会把场景点 (30, 40) 映射到屏幕哪里?(手算)
  2. 推导题:不看文档,从 screen = zoom·scene + pan 出发,独立推导"以鼠标为中心缩放"的 pan 更新公式。
  3. 实现题:给第 5 节的实战代码加"缩放等级显示条"——右侧一列按钮 50% / 100% / 200%,点击后以画布中心为缩放中心跳转。写出关键代码。
  4. 思考题:为什么 drawGrid 里要用 1 / view.zoom 补偿线宽,而 HUD 文字却不需要?(提示:HUD 画在哪个坐标系里?)
  5. 进阶题:把 viewport 扩展成支持旋转的四元组(zoom/panX/panY/rotation),想想:①矩阵怎么设?②screenToScene 的逆变换怎么求?(提示:DOMMatrix.inverse() 可以偷懒,但手推一遍收获巨大)

九、总结与知识图谱

Day 38 变换矩阵
├── 矩阵基础
│   ├── 2×3 六参数:a b c d e f
│   ├── 映射式:x' = a·x + c·y + e
│   └── 三件套都是矩阵特例
├── API
│   ├── setTransform:重置后设置(归位/一步到位)
│   ├── transform:叠加复合
│   └── getTransform:读矩阵 + inverse() 反推(拾取)
├── 场景模型 ⭐
│   ├── screen = zoom·scene + pan
│   ├── setTransform(dpr) → transform(zoom,0,0,zoom,panX,panY)
│   └── screenToScene(命中检测的钥匙)
├── 以鼠标为中心缩放
│   └── pan' = mouse - zoom'·scene(mouse)
└── 实战:viewport 导航
    ├── 滚轮缩放(MIN/MAX 钳制)
    ├── 拖拽平移(pan += 屏幕像素差)
    └── 线宽/字号补偿 1/zoom

本周进度:变换篇(Day 36-38)完成。明天切到第二条主线——像素操作:getImageData 返回的那个 Uint8ClampedArray,40 万个数字就是整幅画面的"基因"。取色器、反色、灰度……所有"每个像素怎么变"的效果,从明天起你都能自己写。

评论