详解 CSS 中的 matrix 和 matrix3d 变换原理
背景简介
网上有很多介绍 matrix 和 matrix3d 原理的文章,但很多只介绍了“何为矩阵”、“matrix和其他‘单一变换’的换算关系”(很多还不包含 3d 变换的换算规则)。看完还是有很多疑惑:
- 为什么这里会使用矩阵
- 为什么矩阵的维度比坐标多一维(2d 变换是三维矩阵,3d 变换是四维矩阵)
- matrix() 和 matrix3d() 每个参数的意义是什么
- 如何通过 matrix 和 matrix3d 实现变换,也就是如何确定这两个方法的参数是多少
因此经过自己的学习和尝试后,简单地回答一下这些问题。
参考链接:
理解CSS3 transform中的Matrix(矩阵):
https://www.zhangxinxu.com/wordpress/2012/06/css3-transform-matrix-矩阵/
预备知识
- 矩阵、矩阵的乘法
- 方程
- 三角函数
- css 3D 坐标系
关于矩阵、方程和三角函数的基本理论不是本文的重点,有需要可以自行搜索。这里只简单提一下矩阵的概念和矩阵相乘的计算规则。
在数学中,矩阵(Matrix)是一个按照长方阵列排列的复数或实数集合,最早来自于方程组的系数及常数所构成的方阵。(概念摘自百度百科)
矩阵与列向量/其他矩阵相乘的计算规则:左侧矩阵的每一行的每个值分别与右侧矩阵的每一列的每个值相乘并求和,简单点就是“行×列”。比如:
3D 坐标系
注意: 网页渲染时,坐标轴与一般的坐标轴不同,Y 轴正向是朝下的,X 轴倒是一样朝右为正向;而 Z 轴是以面向用于的一侧为正向。一开始忘记了Y 轴方向,思考了很久为什么斜拉的渲染结果与矩阵或方程的计算结果相反。
这点在使用定位属性时会比较明显:
{
position: relative;
/* 指定 top 属性时,正数会往下偏移 */
top: xxx;
}
matrix() / matrix3d() 详解
matrix()
先看结论:matrix 参数顺序 scaleX,skewY,skewX,scaleY,translateX,translateY
,分别对应各自的单一类型(缩放、旋转、斜拉、平移)、单一维度变换方法。下面的详解会用两种参数命名形式:
- 以上的完整参数命名,至于为什么这些参数会代表特定的变换方法后面会提到
- 简单的字母 a b c d e f
在尚未完全理解时,可以先忽略完整命名所代表的意义,只看用简单字母表示的部分;理解了原理之后,完整的命名可以作为速查手册。
用矩阵表示参数:
将各参数对应名称替换为简单字母:
matrix 计算公式
用矩阵表示
等号左侧有两个矩阵:
- 第一个 3*3 矩阵是使用 matrix() 时提供的参数(第一行和第二行的6个位置,第三行3个数用于辅助矩阵计算)
- [x y 1] 表示原坐标 (x, y),1 用于辅助矩阵计算
注意此处将参数用矩阵表示时的顺序:先从上到下,然后从左到右。也就先写满第一列(不包括最后一行),然后写第二列(不包括最后一行),以此类推。
完整的参数命名
简单字母
用方程组表示计算公式
此时,矩阵中用于辅助的数字都不需要再写了。
完整的参数命名
简单字母,并掉转顺序:
由方程可以看出,为了表示新坐标与原坐标有一定关系,每个坐标都会带上原坐标的两个值;且为了增加可变程度,给两个坐标分别加上了系数 a c / b d,同时各增加一个常数项 e / f,以表示不受原坐标影响的独立系数。
matrix() 与单一 2d 变换方法的参数转换规则
参数转换规则是指如何通过 matrix() 的参数表示其他四种变换。
这里的转换规则是根据上面的矩阵公式或方程,并结合坐标在坐标系中的变换效果分析得到的。
下面各项结论中的斜杠是区分对新坐标有影响的系数,格式是 newX / newY 。
注意给 matrix() 传参时将矩阵绕对角线翻转。
可以对照下面表格的 matrix 参数序列。
1.平移
平移使得元素的所有像素点沿X/Y轴整体移动,移动距离与原坐标的两个值具体是多少无关,所以是方程的两个常数项 e / f。
2.缩放
缩放是影响元素在两条轴向的像素点分布区间,只与各自对应的 x/y 坐标相关,所以是方程中的 a / d 两个系数,且位于矩阵对角线上。
3.斜拉
斜拉相当于将元素的水平/垂直轴线向着Y/X轴方向倾斜特定角度,使其轴线不再平行于坐标系的X/Y轴。通过图示来理解会比较容易,比如:
使垂直轴沿 X 轴正向倾斜50度(也就是skewX(50deg)),新的 x 坐标需要加上—— y 乘以 tan(50°) ,即约等于 1.912。此时计算过程为(示例为坐标 (1,1),其他坐标同理):
oldPos(1,1) => newPos(?,1):
(oldPos.x + tan(angleX) * oldPos.y + 0) = (1+1.912*1,1) = (2.912,1)
所以是方程中的 c b 两个系数,位于对角线两侧。
注意顺序与 skew() 的参数相反:因为斜拉变换是基于其他坐标轴方向上的值作变换,当前轴的原坐标不会影响新坐标,所以系数是与其他坐标相乘的。
至于为什么是往这个方向倾斜(比如示例 skew(50deg)
是往左倾斜),可以根据矩阵或方程的计算公式判断出:本质上,这些变换方法在渲染引擎内部都是调用 matrix() 方法。如果往相反方向倾斜,那么结果会与 matrix() 的效果相反。
4.旋转
旋转理解起来比较简单,就是将元素整体绕中心点旋转,相当于元素的水平和垂直轴同时旋转,且角度相同,因此前四个参数都跟旋转有关,也就是系数 a c / b d 。且由于 2d 旋转只有一个自变量 angle,因此可以用以下矩阵表示:
总结
变换类型 | 变换方法 | matrix 写法 |
---|---|---|
平移 | translate(translateX, translateY) | matrix(1, 0, 0, 1, translateX, translateY) |
缩放 | scale(scaleX, scaleY) | matrix(scaleX, 0, 0, scaleY, 0, 0) |
斜拉 | skew(angleX, angleY) | matrix(1, tan(angleY), tan(angleX), 1, 0, 0) |
旋转 | rotate(angle) | matrix(cos(angle), sin(angle), -sin(angle), cos(angle), 0, 0) |
注意:斜拉和旋转需要将角度换算成特定三角函数值再传参。
由此可以看出,执行单一变换时,禁止其他变换的规则如下:
- 禁止平移,e / f 为 0
- 禁止缩放,a / d 为 1
- 禁止斜拉,b / c 为 0
- 旋转由于需要同时控制前四个参数,只要有一个参数不符合上述三角函数值,最终效果就不是旋转。
暂不清楚图片分辨率在缩放时是否会影响清晰度,比如高清图原本只显示了一部分,但放大后使得空间变大,此时是否会显示原本被隐藏的像素点或是取已显示的像素点放大(肉眼验证不实际,但不清楚如何用脚本验证)。
计算 matrix 参数
计算 matrix 参数大致分为两类情况:组合单一变换过程,或者执行不属于单一变换排列组合成的特殊变换过程
按顺序组合单一变换
根据上面的计算公式(方程)以及 matrix() 与单一变换方法的参数转换规则,在已知变换过程和各自参数的情况下,换算出 matrix() 方法的参数。
计算时,根据换算规则先将每个变换方法转换成对应的 matrix 表示法,然后按照矩阵相乘规则依次计算并得到最终坐标。
注意:矩阵乘法满足结合律而不满足交换律。因此换算后的参数矩阵在计算公式中的顺序很重要。
比如计算:transform: translate(100px) rotate(45deg);
根据浏览器渲染效果可以确定,上述代码会先执行 rotate(45deg) ,后执行 translate(100px),也就是从右到左执行多个 transform。而为了符合矩阵乘法定律,rotate 转换后的参数矩阵需要靠近原坐标矩阵,如下:
从结果上看,方法的排列顺序与其参数矩阵在计算中的排列顺序相同。
参数矩阵相乘后得到唯一的参数矩阵:
因此,上述变换通过 matrix 表示:transform: matrix(0.707, 0.707, -0.707, 0.707, 100, 0);
不同顺序的连续变换示例(图中被遮挡的文字是 content2):
content2 相当于box2 先平移后旋转,而 content3 相对于 box3 先旋转后平移。
上图对应的示例代码:
<div class="box box2">box2
<div class="content">content2</div>
</div>
<div class="box box3">box3
<div class="content">content3</div>
</div>
.box2 .content {
transform: rotate(45deg) translate(100px);
}
.box3 .content {
transform: translate(100px) rotate(45deg);
}
特殊变换
已知需要实现的目标效果,但无法简单组合单一变换方法实现效果。此时需要根据目标效果分析出新坐标与原坐标的计算关系,关系式的各个系数直接代入以上的方程,再按顺序作为参数传给 matrix()。
举个例子:实现绕X轴作镜像翻转。此时,X坐标无变化,Y轴坐标变为原来的负数。可得关系式: newX = x, newY = -y。也就是新坐标的 newX 只与 原坐标的 x 相关,新坐标的 newY 只与 原坐标的 y 相关
将关系式反代入方程,已知x坐标不变,因此 a 是 1,c 和 e 都是 0;而y坐标变为原来的负数,因此 b 是 -1,d 和 f 都是 0。
最终得到的参数(排成一行)就是 (1,0,0,-1,0,0),下面的例子是保持矩阵行列的写法:
/* 绕X轴翻转 */
transform: matrix(
1, 0, 0,
-1, 0, 0
/* 0,0,1 这三个值不会传入 matrix 方法,但在方法的内部实现中是已知且固定的*/
);
上面给到的参考链接中有一个更为复杂的例子(第七点),实现绕非特定直线 y=kx 的镜面翻转,如下图所示,将坐标 (x, y) 变换为 (x', y'):
matrix3d()
看完 matrix() 再来理解 matrix3d() 就简单多了。
matrix3d 有16个参数,表示成矩阵为:
利用前面 2d 变换中的一些结论可以得到以下的换算规则:
-
最后一列表示平移:a30,a31,a32。即:
\[ \begin{pmatrix} 1 & 0 & 0 & translateX \\ 0 & 1 & 0 & translateY \\ 0 & 0 & 1 & translateZ \\ 0 & 0 & 0 & 1 \end{pmatrix} \] -
对角线表示缩放:a00,a11,a22。即:
\[ \begin{pmatrix} scaleX & 0 & 0 & 0 \\ 0 & scaleY & 0 & 0 \\ 0 & 0 & scaleZ & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \] -
对角线两侧表示斜拉:a10,a20 / a01,a21 / a02,a12。即:
\[ \begin{pmatrix} 1 & scale & scale & 0 \\ scale & 1 & scale & 0 \\ scale & scale & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \]一共有6个参数,分别表示沿不同坐标轴方向斜拉元素的三条轴(水平/垂直/初始与Z轴同方向的轴),可以根据需要选择对应的参数。
-
旋转影响左上角9个参数,分别是:
- 绕 Z 轴旋转(相当于 2d rotate):
\[ \begin{pmatrix} cos(angle) & -sin(angle) & 0 & 0 \\ sin(angle) & cos(angle) & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \]-
绕 X 轴旋转:
\[\begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & cos(angle) & -sin(angle) & 0 \\ 0 & sin(angle) & cos(angle) & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \] -
绕 Y 轴旋转:
\[ \begin{pmatrix} cos(angle) & 0 & sin(angle) & 0 \\ 0 & 1 & 0 & 0 \\ -sin(angle) & 0 & cos(angle) & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \]
matrix3d 连续变换的规则与 2D 相同,也是从右到左依次执行变换。
3D 变换矩阵最后一行4个数的意义
看过了网上很多文章,都没有分析最后一行4个参数对 3D 变换的影响。
在 2D 变换中,matrix() 本身就不支持修改最后一行的3个数(只有6个参数),所以不需要考虑这3个数是否存在变化的可能(当然实际上也没有变化的可能);但是 3D 变换的 matrix3d() 方法是支持修改最后一行4个数的,说明对于 3D 变换,这4个数是有意义的。
分析 3D 变换与 2D 变换,一个区别是 3D 变换支持设置 perspective 和 perspective-origin,也就是透视相关属性。大胆猜测这4个数是与透视相关的。尝试寻找相关资料,还真被我发现了。
在 MDN 上 matrix3d 页面底下的规范文件(Specification)部分,有一个相关文件的链接:
CSS Transforms Module Level 2(地址已带#定位,可以直接定位到对应位置):https://drafts.csswg.org/css-transforms-2/#interpolation-of-3d-matrices
在文件的 "13.1.1. Decomposing a 3D matrix" 这一条中,大致说明了 3D 变换矩阵计算的实现,其中有这么一段伪代码:
// First, isolate perspective.
if (matrix[0][3] != 0 || matrix[1][3] != 0 || matrix[2][3] != 0)
// rightHandSide is the right hand side of the equation.
rightHandSide[0] = matrix[0][3]
rightHandSide[1] = matrix[1][3]
rightHandSide[2] = matrix[2][3]
rightHandSide[3] = matrix[3][3]
// Solve the equation by inverting perspectiveMatrix and multiplying
// rightHandSide by the inverse.
inversePerspectiveMatrix = inverse(perspectiveMatrix)
transposedInversePerspectiveMatrix = transposeMatrix4(inversePerspectiveMatrix)
perspective = multVecMatrix(rightHandSide, transposedInversePerspectiveMatrix)
else
// No perspective.
perspective[0] = perspective[1] = perspective[2] = 0
perspective[3] = 1
由此,证明了我的猜想是对的,最后一行的4个数确实是与透视相关。
通过简单的测试发现,最后一个数相当于设置 css perspective 属性。将该值从1变为2,元素的所有长度都会变为 1/2,相当于将视点与投影平面的距离增加了一倍而导致元素的投影长度缩小为一半。
另外三个数虽然能知道是设置 perspective-origin,也就是视点的相对位置,但并不能确定具体的关系式。由于伪代码并未包含具体的实现,其中有很多方法只有名称,而本人对于矩阵和计算机图形学相关的高级理论尚不了解,因此尚无法验证这三个数的具体影响和使用的规则。
解答
现在可以来回答开头提出的几个问题了:
- 为什么这里会使用矩阵
答:熟练后,矩阵可以用一种统一的标准,方便地表示方程的多个系数而不需要写上自变量。因为对于开发者而言,不需要知道元素所有点的具体坐标,只需要给出元素的变换过程,浏览器会自动处理各个坐标的对应变换。 - 为什么矩阵的维度比坐标多一维(2d 变换是三维矩阵,3d 变换是四维矩阵)
答:2D 变换是为了方便计算时可以加入一个常数项。3D 变换同理,一般使用默认的 0 0 0 1 就够了;不过3D 变换是允许修改矩阵最后一行的4个数的,而且是与透视相关的值。 - matrix() 和 matrix3d() 每个参数的意义是什么
答:将参数表示成矩阵形式后:- 最后一列表示平移
- 对角线表示缩放
- 对角线两侧表示斜拉
- 除开最后一列和最后一行,左上角的所有参数在特定条件下表示旋转。对于 2D 变换是4个参数,3D 变换是9个参数
- 同上 2. 的回答,3D 变换矩阵的最后一行4个数会影响透视相关属性,进而影响元素的投影
- 如何通过 matrix 和 matrix3d 实现变换,也就是如何确定这两个方法的参数是多少
答:简单的单一变换根据3的规则代入系数即可;特殊变换需要先求得新坐标与原坐标的关系式,并将系数代入矩阵,然后将矩阵的值依次作为参数传入 matrix() / matrix3d()。