理解 Canvas 的矩阵乘法,是掌握其高级绘图能力的钥匙。它揭示了所有变换(平移、旋转、缩放)背后的统一数学原理,让你能够以编程的方式精确控制坐标系。
🧮 Canvas 矩阵乘法详解
在 Canvas 中,我们使用的不是普通的 2x2 矩阵,而是一个 3x3 的仿射变换矩阵,用于在二维平面上进行各种操作。
1、变换矩阵的结构
Canvas两个核心 API:
① transform(a, b, c, d, e, f)
在当前变换矩阵基础上,叠加新矩阵(累积变换)
ctx.transform(a,b,c,d,e,f);
② setTransform(a, b, c, d, e, f)
重置矩阵为单位矩阵,再应用新变换(覆盖式,不累积)
ctx.setTransform(a,b,c,d,e,f);
Canvas API 通过 transform(a, b, c, d, e, f) 方法的六个参数来定义这个 3x3 矩阵:
[ a c e ]
[ b d f ]
[ 0 0 1 ]
这个矩阵的作用是将一个原始坐标点 (x, y) 变换到一个新的坐标点 (x', y')。其计算过程是矩阵乘法:
[x'] [ a c e ] [x]
[y'] = [ b d f ] * [y]
[1 ] [ 0 0 1 ] [1]
展开后,我们得到两个核心公式:
- x' = a * x + c * y + e
- y' = b * x + d * y + f
2、矩阵参数的几何意义
每个参数都对应着一种具体的几何变换效果:
| 参数 | 几何意义 | 说明 |
|---|---|---|
| a | 水平缩放 | a > 1 拉伸,0 < a < 1 压缩,a < 0 水平翻转。 |
| d | 垂直缩放 | d > 1 拉伸,0 < d < 1 压缩,d < 0 垂直翻转。 |
| e | 水平平移 | 将整个坐标系沿 x 轴移动 e 个单位。 |
| f | 垂直平移 | 将整个坐标系沿 y 轴移动 f 个单位。 |
| b, c | 斜切/旋转(skew、rotate) | 使坐标系发生倾斜或剪切变形,将矩形变为平行四边形或者旋转。 |
3、基本变换的矩阵表示
我们之前学习的 translate, rotate, scale 等方法,本质上都是对这个矩阵的便捷操作。它们对应的矩阵参数如下:
- 平移 (Translate):
ctx.translate(e, f)等价于ctx.transform(1, 0, 0, 1, e, f) - 缩放 (Scale):
ctx.scale(sx, sy)等价于ctx.transform(sx, 0, 0, sy, 0, 0) - 旋转 (Rotate):
ctx.rotate(θ)等价于ctx.transform(cosθ, sinθ, -sinθ, cosθ, 0, 0)
- 平移矩阵 translate(tx, ty)
[ 1 0 tx ]
[ 0 1 ty ]
[ 0 0 1 ]
- 缩放矩阵 scale(sx, sy)
[ sx 0 0 ]
[ 0 sy 0 ]
[ 0 0 1 ]
- 旋转矩阵 rotate(θ)
[ cosθ -sinθ 0 ]
[ sinθ cosθ 0 ]
[ 0 0 1 ]
- 斜切矩阵 skew(ax, ay)
[ 1 tan(ay) 0 ]
[ tan(ax) 1 0 ]
[ 0 0 1 ]
旋转矩阵旋转方向和旋转点解释:
rotate(angle):参数 angle 表示弧度,顺时针旋转。
transform(a, b, c, d, e, f):其中的旋转参数(结合 a, b, c, d)同样遵循顺时针规则
- θ>0 时,图形会顺时针旋转。
- 默认:绕左上角 (0,0) 旋转 配合translate() 可以实现实现绕任意点旋转
4、矩阵乘法的本质:变换的组合
矩阵乘法的核心在于组合变换。当你连续调用多个变换方法时,Canvas 内部会将它们对应的矩阵相乘,最终合并成一个总的变换矩阵。
1.关键点:顺序至关重要!
矩阵乘法不满足交换律,即 A × B ≠ B × A。这意味着变换的顺序不同,最终结果也完全不同。
- 先平移后旋转:图形会先移动到新位置,然后绕着新位置的原点旋转。
- 先旋转后平移:图形会先绕着原始原点旋转,然后沿着旋转后的坐标轴方向移动。
2.Canvas 矩阵乘法遵循 右乘规则
什么是右乘:
矩阵相乘选定一个矩阵,如果你要把这个矩阵放到左边,就可以说这个矩阵左乘于其它矩阵。如果你要把这个矩阵放到右边,就可以说这个矩阵右乘于其它矩阵
案例:假设有矩阵:m,m1,m2,m3,m4....
1)矩阵m要左乘m1,m2,m3
则是:m X m1 X m2 ...
2)矩阵m要右乘m1,m2,m3
则是:.. m3 X m2 X m1 X m
Canvas 矩阵右乘规则:新变换 × 当前变换 = 最终变换
这就是为什么: Canvas 里写代码的变换顺序,和实际执行顺序是相反的!
例子:
// 代码顺序
ctx.scale(2)
ctx.translate(100,0)
ctx.rotate(Math.PI/4)
代码写的顺序 = 矩阵右乘的顺序 = 实际执行倒序
实际矩阵运算:
最终矩阵 = rotate × translate × scale
实际执行顺序: 先缩放 → 再平移 → 最后旋转
3.矩阵乘法怎么算?(超简单公式)
两个 3×3 矩阵 M × N,结果矩阵每个位置 = M 行 × N 列 对应相乘再相加。
最终结果:
[ a1*a2 + c1*b2 , a1*c2 + c1*d2 , a1*e2 + c1*f2 + e1 ]
[ b1*a2 + d1*b2 , b1*c2 + d1*d2 , b1*e2 + d1*f2 + f1 ]
[ 0 , 0 , 1 ]
对应 Canvas 6 个参数:
a = a1*a2 + c1*b2b = b1*a2 + d1*b2c = a1*c2 + c1*d2d = b1*c2 + d1*d2e = a1*e2 + c1*f2 + e1f = b1*e2 + d1*f2 + f1
5、Canvas 矩阵绘图案例
理论结合实践才能更好地理解。下面通过两个案例来展示矩阵的威力。
案例一:用 transform() 实现绕任意点旋转
我们之前用 translate 和 rotate 组合实现过这个效果,现在用矩阵的方式来理解它。绕点 (px, py) 旋转角度 θ 的过程,本质上是三个变换的组合:
- 平移: 将旋转中心
(px, py)移动到原点。矩阵为T = [1, 0, 0, 1, -px, -py]。 - 旋转: 绕原点旋转
θ。矩阵为R = [cosθ, sinθ, -sinθ, cosθ, 0, 0]。 - 缩放: 将画布缩放
(sx, sy)。矩阵为S = [sx, 0, 0, sy, 0, 0]。
总的变换矩阵 M 就是这三个矩阵的乘积:M = S × R × T。
const canvas = document.getElementById('myCanvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext('2d');
const {width, height} = canvas;
const px = width/2, py = height/2; // 旋转中心点
const angle = -Math.PI/2; // 逆旋转90度
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const sx = 2, sy = 2; // 放大2倍
// 1. 保存当前状态
ctx.save();
// 2. 手动计算组合矩阵并应用
/*
根据 WHATWG 规范,transform() 将参数矩阵右乘到当前变换矩阵(CTM)上:
ctx.translate(px, py) // CTM = CTM × T = I × T = T
ctx.rotate(r) // CTM = CTM × R = T × R
ctx.scale(sx, sy) // CTM = CTM × S = T × R × S
最终 CTM = T × R × S
作用于点
v' = CTM × v = T × R × S × v
点先被缩放,再被旋转,最后被平移。
*/
// 先执行的后乘
ctx.translate(px, py);// T: 平移
ctx.rotate(angle);// R: 旋转
ctx.scale(sx, sy);// S: 缩放
// 等价下面的叠加变换
// ctx.transform(1, 0, 0, 1, px, py ); // T: 平移
// ctx.transform(cos, sin, -sin, cos, 0, 0); // R: 旋转
// ctx.transform(sx, 0, 0, sy, 0, 0); // S: 缩放
// 3. 在原点绘制一个矩形,它会绕 (px, py) 旋转 且放大
ctx.fillStyle = 'skyblue';
ctx.fillRect(0, 0, 100, 50);
ctx.restore();
ctx.save();
案例二:使用 setTransform() 实现倾斜(Skew)效果
Canvas 没有原生的 skew() 方法,但我们可以用 setTransform() 轻松实现。倾斜会使矩形变成平行四边形。
- 水平倾斜:
x' = x + k * y,对应矩阵[1, 0, k, 1, 0, 0] - 垂直倾斜:
y' = y + k * x,对应矩阵[1, k, 0, 1, 0, 0]
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.save();
// 设置一个水平倾斜的矩阵
// 这会让图形向右倾斜,y 值越大,x 方向偏移越多
const skewFactor = 0.5; // 倾斜因子
ctx.setTransform(1, 0, skewFactor, 1, 0, 0);
// 绘制一个矩形,它会变成一个平行四边形
ctx.fillStyle = 'coral';
ctx.fillRect(50, 50, 100, 60);
ctx.restore();
在 Canvas 的变换矩阵 [a, b, c, d, e, f] 中,b 和 c 是控制图形变形的关键参数。
简单来说:
b控制 垂直方向的倾斜(图形被“推”向垂直方向)。c控制 水平方向的倾斜(图形被“推”向水平方向)。
虽然它们常用于实现**斜切(Skew)效果,但正如你在表格中看到的,它们也是实现旋转(Rotate)**的核心参数。
下面我为你详细拆解这两个参数的具体含义和数学原理。
1. 核心公式:它们是如何改变坐标的?
Canvas 的变换是基于以下数学公式计算新坐标 (x', y') 的:
x' = a * x +c * y+ ey' =b * x+ d * y + f
请注意加粗的部分:
b的作用:它把原始的x坐标乘以一个系数,加到新的y坐标上。这意味着水平位置会影响垂直位置,导致图形在垂直方向上发生倾斜。c的作用:它把原始的y坐标乘以一个系数,加到新的x坐标上。这意味着垂直位置会影响水平位置,导致图形在水平方向上发生倾斜。
2. 场景一:实现斜切(Skew)
这是 b 和 c 最直观的用法。当 a 和 d 为 1(不缩放),且 b 或 c 不为 0 时,矩形会变成平行四边形。
参数 c:水平斜切
- 效果:图形像被风吹过的扑克牌一样,向左右歪斜。
- 原理:
y值越大(越靠下),x方向偏移越多。 - 代码:
ctx.transform(1, 0, 0.5, 1, 0, 0)(c=0.5)
参数 b:垂直斜切
- 效果:图形向上下歪斜。
- 原理:
x值越大(越靠右),y方向偏移越多。 - 代码:
ctx.transform(1, 0.5, 0, 1, 0, 0)(b=0.5)
3. 场景二:实现旋转(Rotate)
这是很多初学者容易忽略的。旋转其实是水平斜切和垂直斜切以及缩放的精密组合。
当你调用 ctx.rotate(angle) 时,Canvas 内部设置的矩阵参数如下:
a = cos(θ)b = sin(θ)c = -sin(θ)d = cos(θ)
为什么旋转需要 b 和 c?
为了让一个点绕原点转动,它必须在 X 轴和 Y 轴上同时发生位移。
b(正值)负责把 X 轴“推”向 Y 轴方向。c(负值)负责把 Y 轴“拉”向 X 轴方向。 两者配合a和d的缩放补偿,才能形成完美的圆周运动。
4. 总结对照表
为了方便记忆,你可以参考下表:
| 参数 | 几何含义 | 视觉效果 | 典型值示例 |
|---|---|---|---|
b | 垂直倾斜 | 矩形的垂直边保持平行,但水平边倾斜。图形看起来被上下拉扯变形。 | b = 0.5 (向下倾斜)b = -0.5 (向上倾斜) |
c | 水平倾斜 | 矩形的水平边保持平行,但垂直边倾斜。图形看起来被左右推倒变形。 | c = 0.5 (向右倾斜)c = -0.5 (向左倾斜) |
一句话总结:
b 和 c 是相互关联的参数,它们打破了 X 轴和 Y 轴的正交性(垂直关系),从而实现了图形的歪斜或旋转。
6、Canvas 复合矩阵与three.js区别
6.1 Canvas 复合矩阵
transform(a, b, c, d, e, f) 并不是一个固定了“先缩放、再平移、后旋转”顺序的操作。
恰恰相反,它是一个非常底层的工具,允许你通过直接设置变换矩阵的六个参数,来一次性定义一个包含了缩放、旋转、平移和倾斜的最终复合变换效果。
🤔 为什么会有这个疑问?
这个疑问很常见,因为我们在学习 Canvas 时,通常是按照 scale()、rotate()、translate() 的顺序来理解组合变换的。这种分步调用的方式,在数学上等价于将几个独立的矩阵按顺序相乘。
例如,先缩放(S),再旋转(R),最后平移(T),其总矩阵 M 的计算方式是:
M = T × R × S
💡 transform() 的本质
transform(a, b, c, d, e, f) 方法的作用是,让你直接提供这个最终计算出来的矩阵 M。你传入的 a, b, c, d, e, f 就是矩阵 M 的六个分量,它代表了所有变换叠加后的最终结果。
你可以把它理解为:
- 分步变换 (
scale,rotate,translate):像是给你一堆乐高积木(基础变换),让你自己动手拼成一个模型(复合变换)。拼装的顺序很重要。 transform()方法:像是直接给你一个已经拼好的、完整的乐高模型(最终变换矩阵)。你不需要关心它是怎么拼起来的,你只知道它的最终形态。
📝 举例说明
假设你想实现“先放大2倍,再向右平移100像素”的效果。
方法一:使用分步方法
ctx.save();
ctx.scale(2, 2); // 1. 放大2倍
ctx.translate(100, 0); // 2. 向右平移100
ctx.fillRect(0, 0, 50, 50);
ctx.restore();
方法二:使用 transform()
因为transform()会将你提供的新矩阵累加(右乘)到当前已有的变换矩阵上,所以你可以使用使用transform,进行分步
ctx.save();
// 直接应用最终计算出的矩阵
ctx.transform(2, 0, 0, 2, 0, 0);
ctx.transform(1, 0, 0, 1, 100, 0);
ctx.fillRect(0, 0, 50, 50);
ctx.restore();
方法三:使用 setTransform()
你需要先自己计算出最终的矩阵。
- 缩放矩阵 S 是
[2, 0, 0, 2, 0, 0] - 平移矩阵 T 是
[1, 0, 0, 1, 100, 0] - 最终矩阵 M = T × S =
[2, 0, 0, 2, 100, 0]然后你就可以直接用setTransform()实现同样的效果:
ctx.save();
// 直接应用最终计算出的矩阵
ctx.setTransform(2, 0, 0, 2, 100, 0);
ctx.fillRect(0, 0, 50, 50);
ctx.restore();
⚠️ 重要区别:transform() vs setTransform()
transform(a, b, c, d, e, f): 会将你提供的新矩阵累加(右乘)到当前已有的变换矩阵上。变换效果会叠加。setTransform(a, b, c, d, e, f): 会先用一个单位矩阵(相当于无任何变换)重置当前所有变换,然后再应用你提供的新矩阵。它的作用是“覆盖”而非“叠加”。
总结来说,transform() 的参数 a, b, c, d, e, f 并不对应一个固定的变换顺序,而是定义了所有变换完成后的最终状态。
在 Three.js(以及大多数 3D 引擎,如 Unity)中,确实存在一个约定俗成且强制执行的变换顺序。
这与 Canvas 2D 的底层逻辑不同:
- Canvas 2D (
transform):是底层工具。它给你一块“空白画布”,你可以按任意顺序堆叠变换。 - Three.js (
Object3D):是高层封装。为了方便开发者,它内部写死了逻辑,强制按照 缩放 (S) -> 旋转 (R) -> 平移 (T) 的顺序来计算最终矩阵。
下面我详细解释一下 Three.js 中的这个机制,以及为什么它要这么做。
6.2 Three.js 的复合矩阵
1. Three.js 的“SRT”铁律
在 Three.js 中,当你设置一个物体的 position、rotation 和 scale 属性时,你不需要关心顺序,因为 Three.js 在内部调用 .updateMatrix() 时,会严格按照以下数学公式构建矩阵:
这意味着在数学计算层面,变换是从右向左执行的:
- 先缩放 (S):物体在原点变大变小。
- 再旋转 (R):物体绕原点旋转。
- 最后平移 (T):物体移动到目标位置。
代码体现: Three.js 的源码逻辑大致如下(简化版):
// 内部逻辑示意
matrix.compose(position, quaternion, scale);
// 或者数学表达:
// matrix.multiplyMatrices(translationMatrix, rotationMatrix);
// matrix.multiplyMatrices(result, scaleMatrix);
2. 为什么要强制这个顺序?
Three.js 之所以“剥夺”你手动选择顺序的权利,是因为 SRT (Scale-Rotate-Translate) 是 3D 图形学中最符合直觉、最能避免“灾难性后果”的顺序。
如果 Three.js 允许你像 Canvas 那样随意改变顺序(比如先平移再旋转),会出现很多反直觉的 Bug:
-
如果先平移,再缩放: 假设你把物体移到
(10, 0, 0),然后放大 2 倍。- 结果:物体会被移到
(20, 0, 0)。 - 后果:物体的位置不再受你控制,缩放会带着位置一起放大。这在 3D 编辑中是噩梦。
- Three.js 的做法:强制先缩放(在原点),再平移。这样无论怎么缩放,物体的位置坐标永远是准确的。
- 结果:物体会被移到
-
如果先平移,再旋转: 假设你把物体移到
(10, 0, 0),然后绕 Z 轴旋转。- 结果:物体会像钟表指针一样绕着世界中心
(0,0)公转。 - Three.js 的做法:强制先旋转(在原点),再平移。这样物体是绕着自身中心旋转,然后移动到新位置。
- 结果:物体会像钟表指针一样绕着世界中心
3. Three.js 与 Canvas 的对比
为了帮你理清这两个概念,我做了一个对比表:
| 特性 | Canvas 2D (ctx.transform) | Three.js (Object3D) |
|---|---|---|
| 设计理念 | 底层绘图指令 | 场景图管理 |
| 变换顺序 | 完全取决于你调用的顺序先写 translate 就先平移。 | 固定为 S -> R -> T无论你先设置哪个属性,计算时永远是先缩放,再旋转,最后平移。 |
| 数学原理 | 矩阵右乘累积: | 组合矩阵构建: |
| 灵活性 | 极高,可以实现倾斜、自定义剪切等复杂效果。 | 相对固定,专注于物体的位移、旋转和大小。 |
| 适用场景 | 绘制 2D 图形、UI、复杂的矢量路径。 | 3D 模型、相机控制、物理世界模拟。 |
4. 如果我在 Three.js 中真的需要改变顺序怎么办?
虽然 position, rotation, scale 是锁死的 SRT 顺序,但 Three.js 允许你直接操作矩阵,从而打破这个规则(但这属于高级用法,容易出错)。
你可以直接操作 matrix 属性:
// 警告:手动操作矩阵需要非常小心
const matrix = new THREE.Matrix4();
const translation = new THREE.Matrix4().makeTranslation(10, 0, 0);
const rotation = new THREE.Matrix4().makeRotationZ(Math.PI / 4);
// 如果你想“先平移再旋转”(公转效果)
// 注意矩阵乘法顺序:R * T
matrix.multiplyMatrices(rotation, translation);
mesh.matrix = matrix;
mesh.matrixAutoUpdate = false; // 必须关闭自动更新,否则你的矩阵会被覆盖
总结
- Canvas 像是在搭积木:你先放哪块,后放哪块,完全由你决定,顺序不同形状不同。
- Three.js 像是组装家具:为了保证家具(物体)不散架、位置不跑偏,厂家(引擎)规定了必须先定大小(S),再定朝向(R),最后摆放位置(T)。