Canvas 变换矩阵 — setTransform 与"以鼠标为中心缩放"的数学本质
昨天的三件套 translate/rotate/scale,其实是三颗"快捷键"——它们底层都在修改同一个东西:变换矩阵(一个 2×3 的数字表格)。今天把这个底牌翻开:搞懂
setTransform/transform的六个参数a b c d e f,你就掌握了变换的"总开关"——可以一步设置任意复合变换,也可以随时"重置归零"。然后是本周重头戏实战:滚轮缩放 + 拖拽平移的画面导航,特别是"缩放时鼠标下的点纹丝不动"——这是组态编辑器、地图、CAD 软件共同的交互内核。
目录
- 一、矩阵:变换的统一语言
- 二、setTransform 与 transform
- 三、矩阵复合:从直觉到代码
- 四、场景变换模型:viewport 的数学
- 五、实战:滚轮缩放 + 拖拽平移画面
- 六、以鼠标为中心缩放:一步到位法
- 七、常见坑点与最佳实践
- 八、自测挑战
- 九、总结与知识图谱
一、矩阵:变换的统一语言
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 / zoom、font / 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 一个函数。这条纪律决定你的组态项目代码是否可维护。
八、自测挑战
- 参数题:
ctx.setTransform(2, 0, 0, 2, 100, 50)会把场景点 (30, 40) 映射到屏幕哪里?(手算) - 推导题:不看文档,从
screen = zoom·scene + pan出发,独立推导"以鼠标为中心缩放"的 pan 更新公式。 - 实现题:给第 5 节的实战代码加"缩放等级显示条"——右侧一列按钮 50% / 100% / 200%,点击后以画布中心为缩放中心跳转。写出关键代码。
- 思考题:为什么
drawGrid里要用1 / view.zoom补偿线宽,而 HUD 文字却不需要?(提示:HUD 画在哪个坐标系里?) - 进阶题:把 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 万个数字就是整幅画面的"基因"。取色器、反色、灰度……所有"每个像素怎么变"的效果,从明天起你都能自己写。