2023-08-18
CSS Matrix3D 中的矩阵运算
CSS 中的 Transform 属性给大多数使用者留下了深刻的印象 —— 几个单词就能实现各种繁复的图形变换, 再配合 transition 属性或者 keyframe 即可又快又好的实现出关键帧动画, 如果再进阶一点学习一下贝塞尔曲线玩一波 timing-function 就可以做出非常生动华丽的动画效果了。
00#transform-example {
01  width: 2em;
02  height: 2em;
03  background: red;
04  transform: skewX(-15deg);
05}
后来进一步学习了解到, transform 底下走的是 css matrix3d, 其入参是一个 4x4 的矩阵, 浏览器会根据这个矩阵来计算并实现图形变换,其中涉及的线性代数原理当时我没有细究, 最近空了又深入研究了一下算是懂了, 故作此雄文将我对 transform 底层的矩阵原理的理解整理成博文,综合发表到这个破站。

# 矩阵的计算

矩阵的计算规则是重复而机械化的, 如果人肉去算会很蛋疼, 因此本文不会花太多篇幅去介绍矩阵乘法, 只需要看这个 demo 就行。 (点击启动即可)
注意观察其中轮播的行列高亮, 左边各行对应右边各列, 相乘相加计算化简得到最终结果 —— 这也意味着左右交换位置的话会导致不同结果, 矩阵乘法不满足交换律。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
=
1∙10 + 2∙12 + 3∙14 = 76
1∙11 + 2∙13 + 3∙15 = 82
4∙10 + 5∙12 + 6∙14 = 184
4∙11 + 5∙13 + 6∙15 = 199
7∙10 + 8∙12 + 9∙14 = 292
7∙11 + 8∙13 + 9∙15 = 316
启动轮播
我使用的矩阵表示很经典: 二维数组
Array<Array<number>>
—— 因为本文涉及的矩阵运算主要是加法 / 乘法, 不涉及其他高级操作, 因此可以简单表示, 即:
00// 为了写 demo 渲染 jsx 方便, 因此额外加了 string
01type Matrix = Array<Array<number | string>>
02
03// 例子: 手动构造一个 3x3 矩阵
04const oneMatrix: Matrix = [
05  [1, 2, 3],
06  [4, 5, 6],
07  [7, 8, 9],
08]
矩阵乘法完整实现如下, 已折叠, 点击展开
00import type { Matrix } from '.';
01
02export function matrixProduct(
03  matrix1: Matrix,
04  matrix2: Matrix
05): Matrix {
06  const rows1 = matrix1.length;
07  const columns1 = matrix1[0].length;
08  const rows2 = matrix2.length;
09  const columns2 = matrix2[0].length;
10
11  if (columns1 !== rows2) {
12    throw new Error('row col error')
13  }
14
15  const result: number[][] = [];
16
17  for (let i = 0; i < rows1; i++) {
18    result[i] = [];
19
20    for (let j = 0; j < columns2; j++) {
21      let sum = 0;
22
23      for (let k = 0; k < columns1; k++) {
24        //
25        // 这段比较乱, 你只需要记住下面这个是核心逻辑
26        // sum += matrix1[i][k] * matrix2[k][j]
27        // 
28        // 其他的部分是为了实现字符串的 Matrix 加法最后能得到像
29        // 1*10+2*12+3*14=76 这样的带过程的结果
30        // 
31        if (
32          typeof matrix1[i][k] === 'number'
33          && typeof matrix2[k][j] === 'number'
34        ) {
35          // @ts-ignore matrix 会用于 react 渲染
36          // 所以这里可能存在 string 计算, 可忽略
37          sum += matrix1[i][k] * matrix2[k][j];
38        } else {
39          const temp = `${matrix1[i][k]}${matrix2[k][j]}`;
40          if (sum === 0) {
41            // @ts-ignore 用于 react 渲染
42            sum = temp;
43          } else {
44            // @ts-ignore 用于 react 渲染
45            sum += ` + ${temp}`;
46          }
47        }
48      }
49
50      result[i][j] = sum;
51    }
52  }
53
54  return result;
55}
熟悉了矩阵的乘法计算规则, matrix3d 的矩阵原理就算懂了一半, 我们继续。

# 通过矩阵乘法表达位移变换

transform 图形计算就是将图像中的每一个像素点根据某种变换放到新的点上, 比如从原点 (0,0) 位移 (3,4) 其实就是朝着右边移动 3 再向上移动 4 实现。
显然, 这个平移可以向量去表达 —— 即每个点都应用到向量 OP(3,4) 上, 而向量可以视作为 2x1 矩阵, 这是否说明可以构造一种特殊的矩阵使得下列计算成立呢 ?
?
x
y
=
x + a
y + b
琢磨了一下, 当对角线全是 1 的情况 (即单位矩阵) 很显然计算结果等于自身:
1
0
0
1
x
y
=
1∙x + 0∙y
0∙x + 1∙y
=
x
y
这里很有意思,通过 0 消除了 x y 使其不耦合,如果弄成 3x3 的单位矩阵,也就是更进一步:
1
0
?
0
1
?
?
?
1
x
y
?
=
1∙x + 0∙y + Q
0∙x + 1∙y + W
?∙x + ?∙y + 1∙?
=
x + Q
y + W
1
答案呼之欲出,需要想办法让 Q 等于 a, W 等于 b 即可, 并且第三行的 ?∙x 和 ?∙y 中的问号为 0 即可,代入一下计算,就能得到我们想要的那个特殊矩阵了:
1
0
a
0
1
b
0
0
1
x
y
1
=
1∙x + 0∙y + a∙1
0∙x + 1∙y + b∙1
0∙x + 0∙y + 1∙1
=
x + a
y + b
1
启动轮播

translate3d(dx, dy, dz) 矩阵的一般形式

经过前面的讨论我们很容易就能得到在三维坐标系下, translate3d 平移变换矩阵的一般形式, 其中 dx dy dz 分别是各个坐标轴的位移:
translate3d(dx, dy, dz)
=
1
0
0
dx
0
1
0
dy
0
0
1
dz
0
0
0
1

scale3d(ma, mb, mc) 矩阵的一般形式

即各个轴的放大, 显然易得, 其中 ma mb mc 指的是放大倍数:
ma
0
0
0
0
mb
0
0
0
0
mc
0
0
0
0
1
x
y
z
1
=
ma∙x + 0∙y + 0∙z + 0∙1
0∙x + mb∙y + 0∙z + 0∙1
0∙x + 0∙y + mc∙z + 0∙1
0∙x + 0∙y + 0∙z + 1∙1
=
ma ∙ x
mb ∙ y
mc ∙ z
1
scale3d(ma, mb, mc)
=
ma
0
0
0
0
mb
0
0
0
0
mc
0
0
0
0
1

# 那么旋转呢 ?

旋转首先要定一个旋转的中心点, 这里我构造了一个 10x10 的方块, 并将其放在坐标系中心, 并以原点 (0,0) 为中心顺时针旋转 30deg, 旋转叠在一起得到下面左图, 其中 P 经过旋转后移动到了 P'
P (5, 5)
P' (?, ?)
O (x0, y0)
A (xa, ya)
B (xb, yb)
这个计算是显而易见的,根据勾股定理计算就能得到 P' 的坐标约为: (6.83,1.83)
根据此例我们可以构造一个更一般的情况, 即右图所示: A 以 O 为中心顺时针旋转 θ 至 B, 根据已有的信息, 可以得出下面 6 条方程, 其中 r 指的是从 A 到 O 点的距离, 而 sinA sinB 中的 A 和 B 指的是 AO 和 BO 跟 X 轴的角度。
A = θ + B
ya - y0 = r∙sinA
yb - y0 = r∙sinB
xa - x0 = r∙cosA
xb - x0 = r∙cosB
联立这五个方程配合三角函数完全展开并化简可以得到 xb yb 解析解:
xb = (1-cosθ)∙x0 + sinθ∙y0 + cosθ∙xa + sinθ∙ya
yb = (cosθ-1)∙y0 + sinθ∙x0 - sinθ∙xa + cosθ∙ya
上述表达比较长, 不妨将其中的常数项剥离为 CX 和 CY, 得到:
CX = (1-cosθ)∙x0 + sinθ∙y0
xb = cosθ∙xa + sinθ∙ya + CX
CY = (cosθ-1)∙y0 + sinθ∙x0
yb = -sinθ∙xa + cosθ∙ya + CY
根据其中的 ➋ 和 ➍ 的这两个方程即可构造出特征矩阵:
cosθ
sinθ
CX
-sinθ
cosθ
CY
0
0
1
xa
ya
1
=
cosθ∙xa + sinθ∙ya + CX∙1
-sinθ∙xa + cosθ∙ya + CY∙1
0∙xa + 0∙ya + 1∙1
=
xb
yb
1
根据 ➊ ➌ 我们也可以知道,如果旋转的中心恰好在原点的话, 上述 CX 和 CY 的值为 0, 特征矩阵会变的相当简单:
M
=
cosθ
sinθ
0
-sinθ
cosθ
0
0
0
1
可能你会问我你为什么不挑原点做中心 —— 因为 CSS 有 transform-origin 的特性可以自行决定中心点, 因此要考虑一个更 general 的情况。

rotateZ(x0, y0, deg) 矩阵的一般形式

在上述例子里其实是固定 Z 轴旋转 XY 平面实现的旋转, 在旋转的过程中, z 是不变的, 因此其一般形式如下, 其中 x0 y0 是旋转中心点, deg 为旋转角度:
rotateZ(x0, y0, deg)
=
cosθ
sinθ
0
CX
-sinθ
cosθ
0
CY
0
0
1
0
0
0
0
1
CX = (1-cosθ)∙x0 + sinθ∙y0
CY = (cosθ-1)∙y0 + sinθ∙x0
特别地,当旋转中心在原点 (0, 0) 的时候 CX 和 CY 的值为 0

# 为什么是矩阵乘法 ?

矩阵的魔力在于它的乘法操作对应了向量空间的变换 —— 应用到图形学就是图像变换
任意复杂的变换都可以拆为若干个子变换, 而每一个变换意味着一次矩阵乘法 —— 那么我们可以通过多次应用矩阵变换的方式来实现对任意复杂的变换或运动的绘制,更具体一点来说每次乘上一次特征矩阵就是做一次变换,比方旋转 45 度后向右走 5px 那就分两个矩阵依次乘上去

# さあ、ゲームを始めよう

CSS Matrix
(0.0, 0.0)
Z 轴旋转
-180
0.0
180
负数代表旋转方向为逆时针
旋转中心 x0
-200
0.0
200
负数代表向左移动
旋转中心 y0
-200
0.0
200
负数代表向左移动
X 轴位移
-200
0.0
200
负数代表向左移动
Y 轴位移
-200
0.0
200
负数代表向上移动
Z 轴位移
-200
0.0
200
负数代表向上移动
DemoMatrix
=
1.00
0.00
0.00
0.00
0.00
1.00
0.00
0.00
0.00
0.00
1.00
0.00
0.00
0.00
0.00
1.00
矩阵复位
中心复位
placeholder

# 意犹未尽 ...

玩了上面的 demo 你可能会发现我使用的位移矩阵并不是像下面的 ➊ 这样, 而是 ➋ 这样在最后一行添加分量:
translate3d(dx, dy, dz)
=
1
0
0
dx
0
1
0
dy
0
0
1
dz
0
0
0
1
translate3d(dx, dy, dz)
=
1
0
0
0
0
1
0
0
0
0
1
0
dx
dy
dz
1
具体原因是浏览器实现用的矩阵计算中坐标是放在左边而不是在右边, 因此矩阵按对角线对称变换了 (写 DEMO 被坑到了 233):
x
y
z
1
1
0
0
0
0
1
0
0
0
0
1
0
dx
dy
dz
1
=
x+dx
y+dy
z+dz
1
当本文写到这里的时候我依然对这套变换还存有困惑,比如你一定会注意到我每一个的矩阵变换里最后一个是 1, 而且都是 4x4 的,如果说第一二三行对应 x y z 三个轴, 那么第四行又对应什么呢? 而这一切都会指向最后一个线索 「透视 (perspective)」
到这里能明显感到我已站在 3D 世界大门前, 只差临门一脚, 意犹未尽 ...
0%
XhrUnsent
期间还算了半天算错了, 我太菜了.jpg

# Links

撰写此文参考了大量资料, 感谢互联网, 也感谢过程中跟我讨论过的朋友们