矩阵

1 阅读15分钟

理解 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)
  1. 平移矩阵 translate(tx, ty)
[ 1  0  tx ]
[ 0  1  ty ]
[ 0  0  1  ]
  1. 缩放矩阵 scale(sx, sy)
[ sx  0   0 ]
[ 0  sy  0 ]
[ 0   0   1 ]
  1. 旋转矩阵 rotate(θ)
[ cosθ  -sinθ   0 ]
[ sinθ   cosθ   0 ]
[  0      0     1 ]
  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*b2
  • b = b1*a2 + d1*b2
  • c = a1*c2 + c1*d2
  • d = b1*c2 + d1*d2
  • e = a1*e2 + c1*f2 + e1
  • f = b1*e2 + d1*f2 + f1

5、Canvas 矩阵绘图案例

理论结合实践才能更好地理解。下面通过两个案例来展示矩阵的威力。

案例一:用 transform() 实现绕任意点旋转

我们之前用 translaterotate 组合实现过这个效果,现在用矩阵的方式来理解它。绕点 (px, py) 旋转角度 θ 的过程,本质上是三个变换的组合:

  1. 平移: 将旋转中心 (px, py) 移动到原点。矩阵为 T = [1, 0, 0, 1, -px, -py]
  2. 旋转: 绕原点旋转 θ。矩阵为 R = [cosθ, sinθ, -sinθ, cosθ, 0, 0]
  3. 缩放: 将画布缩放 (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] 中,bc 是控制图形变形的关键参数。

简单来说:

  • b 控制 垂直方向的倾斜(图形被“推”向垂直方向)。
  • c 控制 水平方向的倾斜(图形被“推”向水平方向)。

虽然它们常用于实现**斜切(Skew)效果,但正如你在表格中看到的,它们也是实现旋转(Rotate)**的核心参数。

下面我为你详细拆解这两个参数的具体含义和数学原理。


1. 核心公式:它们是如何改变坐标的?

Canvas 的变换是基于以下数学公式计算新坐标 (x', y') 的:

  • x' = a * x + c * y + e
  • y' = b * x + d * y + f

请注意加粗的部分:

  • b 的作用:它把原始的 x 坐标乘以一个系数,加到新的 y 坐标上。这意味着水平位置会影响垂直位置,导致图形在垂直方向上发生倾斜。
  • c 的作用:它把原始的 y 坐标乘以一个系数,加到新的 x 坐标上。这意味着垂直位置会影响水平位置,导致图形在水平方向上发生倾斜。

2. 场景一:实现斜切(Skew)

这是 bc 最直观的用法。当 ad 为 1(不缩放),且 bc 不为 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(θ)

为什么旋转需要 bc 为了让一个点绕原点转动,它必须在 X 轴和 Y 轴上同时发生位移。

  • b(正值)负责把 X 轴“推”向 Y 轴方向。
  • c(负值)负责把 Y 轴“拉”向 X 轴方向。 两者配合 ad 的缩放补偿,才能形成完美的圆周运动。

4. 总结对照表

为了方便记忆,你可以参考下表:

参数几何含义视觉效果典型值示例
b垂直倾斜矩形的垂直边保持平行,但水平边倾斜。图形看起来被上下拉扯变形。b = 0.5 (向下倾斜)b = -0.5 (向上倾斜)
c水平倾斜矩形的水平边保持平行,但垂直边倾斜。图形看起来被左右推倒变形。c = 0.5 (向右倾斜)c = -0.5 (向左倾斜)

一句话总结: bc相互关联的参数,它们打破了 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() 你需要先自己计算出最终的矩阵。

  1. 缩放矩阵 S 是 [2, 0, 0, 2, 0, 0]
  2. 平移矩阵 T 是 [1, 0, 0, 1, 100, 0]
  3. 最终矩阵 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 中,当你设置一个物体的 positionrotationscale 属性时,你不需要关心顺序,因为 Three.js 在内部调用 .updateMatrix() 时,会严格按照以下数学公式构建矩阵:

M=T×R×SM = T \times R \times S

这意味着在数学计算层面,变换是从右向左执行的:

  1. 先缩放 (S):物体在原点变大变小。
  2. 再旋转 (R):物体绕原点旋转。
  3. 最后平移 (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无论你先设置哪个属性,计算时永远是先缩放,再旋转,最后平移。
数学原理矩阵右乘累积:Mnew=Mold×McurrentM_{new} = M_{old} \times M_{current}组合矩阵构建:M=T×R×SM = T \times R \times S
灵活性极高,可以实现倾斜、自定义剪切等复杂效果。相对固定,专注于物体的位移、旋转和大小。
适用场景绘制 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)