计算机图形学里有一件事,无论你是做引擎渲染、写 OpenGL,还是在前端用 Canvas/CSS 摆弄动效,甚至是在做图像处理、机械臂运动规划,都绕不开,那就是基本变换。缩放、平移、旋转、剪切、镜像这五个变换,几乎是所有复杂图形操作的地基。我见过很多新手一上来就去啃着色器、光照模型,结果被一个模型矩阵绕得晕头转向,问题往往就出在没把变换矩阵这套基本功打牢。这篇文章我就把这五个基本变换从头到尾捋一遍:它们各自的矩阵长什么样、为什么长这样、放在实际工程里怎么用、常见的坑又在哪里。适合刚接触图形学的人打基础,也适合已经写了几年代码但一直对矩阵变换“会用但讲不清”的朋友查漏补缺。
1. 变换矩阵的数学地基:为什么非用矩阵不可
1.1 从一个点说起:点的表示与齐次坐标
要聊变换,先从最基础的点说起。在二维平面上,一个点通常写作 (x, y),这是大家都熟悉的东西。可一旦要把缩放、旋转、平移这些操作统一成一套计算体系,只靠 (x, y) 就有点不够用了。
问题出在平移上。缩放和旋转都可以写成 x' = ax + by 这种“原坐标的线性组合”的形式,也就是用一个 2x2 的矩阵去乘一个二维向量。但平移不一样,平移是 x' = x + tx,那个 tx 是凭空加出来的常数,没法塞进 2x2 矩阵里。如果用矩阵乘法表示,就应该是 [x'; y'] = [1 0; 0 1] * [x; y] + [tx; ty],矩阵乘法后面还拖着一个小尾巴。
这在实际计算里非常难受。一个复杂的变换往往要连续做很多次:先缩放,再旋转,再平移,再投影。如果每一步都要先做矩阵乘法再加一次向量,代码会变得非常啰嗦,而且也不方便把多次变换“合并”成一个矩阵。
解决办法是引入齐次坐标。简单说,就是把二维点 (x, y) 表示成三维向量 (x, y, 1),相应地用 3x3 矩阵来表示二维变换。这样一来,平移也能写成矩阵乘法的样子。加上那一行 [0, 0, 1] 看起来只是纯粹凑数,但它的意义是把“点的位置”和“方向向量”区分开来了。点 (x, y, 1) 中的 w 为 1,而方向向量 (dx, dy, 0) 中的 w 为 0,方向的平移结果就该是它自己。
1.2 矩阵乘法的几何直觉:列向量就是变换后的基向量
很多人学矩阵变换时,第一个反应是“这公式怎么背”,第二个反应是“这公式怎么用”。但我觉得最该建立的是一个几何直觉:矩阵的列向量,就是原来的基向量经过变换后到了哪里。
拿二维 2x2 矩阵举例,标准基向量是 e1 = (1, 0) 和 e2 = (0, 1)。任意向量 (x, y) 都可以写成 xe1 + ye2。矩阵 M 是一个线性变换,那么 M*(x, y) = x*(Me1) + y(M*e2)。换句话说,M 的第一列就是 e1 变换后的位置,第二列就是 e2 变换后的位置。
这个视角极其好用。旋转矩阵为什么是 [cosθ, -sinθ; sinθ, cosθ]?因为 e1=(1,0) 旋转 θ 后变成 (cosθ, sinθ),这就是第一列;e2=(0,1) 旋转 θ 后变成 (-sinθ, cosθ),这就是第二列。剪切矩阵为什么长成 [1, tanθ; 0, 1]?因为 e1 没动,e2 则沿 x 方向被压斜了。后面拆解五个变换时,我会反复用这个视角,因为它比死记硬背靠谱得多。
1.3 从线性变换到仿射变换:为什么平移被区别看待
矩阵这个工具本身只能表达线性变换,线性变换必须满足两个条件:直线映射后还是直线,以及原点必须保持不动。旋转、缩放、剪切、镜像都满足这两条,所以它们能用 2x2 矩阵搞定。但平移会把原点挪走,它不是线性变换,而是仿射变换。
仿射变换可以理解为“先做一次线性变换,再做一次平移”。有了齐次坐标之后,平移就被统一进了 3x3 矩阵里。这个从 2x2 到 3x3 的扩维看起来多了一行一列,实际工程里带来的收益是巨大的。它让所有变换都变成了同一个操作:矩阵乘法。而多个矩阵相乘还能合并成一个矩阵,这就意味着一个复杂的变换序列可以预先算好,最后只对顶点做一次矩阵乘法。GPU 能高效处理顶点,靠的就是这一套“合并”思想。
注意:很多图形学教材喜欢区分行向量和列向量两种约定。这篇文章默认使用列向量,也就是 p' = M * p,矩阵左乘向量,坐标从上往下写。如果你用行向量,矩阵转置一下就行,但千万别混用,一旦混用,算出来的结果往往就是错的。
2. 五个基本变换逐个拆:从公式到直觉
2.1 缩放变换:不是只有“变大变小”这么简单
缩放矩阵在二维里长这样:
假设 Sx 是 x 方向的缩放因子,Sy 是 y 方向的缩放因子,那么:
S = | Sx 0 0 | | 0 Sy 0 | | 0 0 1 |应用到一个点 (x, y, 1) 上,得到 (Sxx, Syy, 1)。这太好理解了,每个坐标分量各自乘一个系数。
但缩放有三个细节经常被忽略。
第一,缩放因子可以是负的。Sx 为 -1 时,x 坐标会取反,图形会沿 y 轴翻到另一侧,这就变成了镜像。所以镜像本质上可以看成“某个方向缩放因子为 -1 的特殊缩放”。
第二,不等比缩放。Sx ≠ Sy 时,图形会在两个方向被拉伸得不一样。如果你对一个圆形做不等比缩放,它会变成椭圆;一个球会变成椭球。这在建模工具里很常见,但也意味着原本垂直于表面的法线方向会被扭曲,如果直接用法线做光照,会出现失真。
第三,缩放的中心问题。矩阵乘法默认是“以原点为中心”进行缩放的。你写一个 scale(2, 2),图形会朝远离原点的方向放大,而不是原地保持中心点不动。这在实际交互中非常反直觉。很多人第一次实现“点击按钮放大图片”时,发现图片放大后位置飞了,就是因为所有缩放都绕原点进行。要解决这个问题,必须先把物体的中心平移到原点,缩放完再平移回去。这个“先平移、再变换、再平移回来”的模式,在图形学里几乎是无处不在的标准套路。
2.2 平移变换:最容易和坐标系混淆的变换
平移矩阵的齐次形式很简单:
T = | 1 0 tx | | 0 1 ty | | 0 0 1 |应用后得到 (x + tx, y + ty, 1)。就这么简单。
但平移在实际工程里的坑,往往不是矩阵本身,而是它和旋转、缩放复合时的顺序。我见得太多次了:一个物体想在屏幕上旋转,写完代码发现它一边转一边胡乱飘,检查了半天才发现旋转中心和期望不一致。
先平移再旋转,和先旋转再平移,效果完全不同。前者是先挪位置再绕原点转,后者是先绕原点转完再带着角度去挪位置。如果你要的是“物体绕自己的中心旋转”,那应该是先平移到中心,旋转,再平移回去;如果你要的是“物体绕世界原点旋转,但每帧都在原位附近”,那顺序是反过来。这两种需求在游戏、CAD、图像编辑里都有出现,没有绝对的对错,只有是否贴合场景。
平移还有一个容易踩的地方:复合矩阵里的平移分量会被前面的旋转或缩放矩阵影响。假设你先构造了一个旋转矩阵 R,再乘一个平移矩阵 T,得到 R * T,那么最后这个矩阵的最后一列,并不是简单的 (tx, ty) 本身。它实际上是 R * (tx, ty) 的结果,也就是平移向量先被旋转了一次。这个“平移向量跟着前面矩阵一起变”的现象,会让不熟悉矩阵乘法的人觉得“为什么我的平移方向不对”。原因就是两个矩阵乘起来时,前面矩阵的旋转部分作用到了后面矩阵的平移分量上。
2.3 旋转变换:旋转矩阵为什么是那个样子
二维旋转矩阵:
R(θ) = | cosθ -sinθ 0 | | sinθ cosθ 0 | | 0 0 1 |用基向量来理解就非常直接。e1=(1,0) 旋转 θ 后变成 (cosθ, sinθ) 作为第一列;e2=(0,1) 旋转 θ 后变成 (-sinθ, cosθ) 作为第二列。这里的 θ 通常约定为逆时针方向为正,前提是坐标系是右手系。如果你在左手系或者某些 Y 轴朝下的屏幕坐标系里写代码,正方向可能就变成顺时针了。这也是新手容易困惑的地方:在 Canvas 或很多图像坐标里,Y 轴向下,同样是正角度,旋转方向看起来是顺时针。实际上不是数学变了,而是坐标系反了。
三维旋转要复杂一些,但本质上也是同一个套路。绕 x 轴旋转时,x 坐标不变,y 和 z 构成一个二维旋转;绕 y 轴、z 轴同理。三个矩阵分别是:
Rx(θ) = | 1 0 0 0 | | 0 cosθ -sinθ 0 | | 0 sinθ cosθ 0 | | 0 0 0 1 | Ry(θ) = | cosθ 0 sinθ 0 | | 0 1 0 0 | | -sinθ 0 cosθ 0 | | 0 0 0 1 | Rz(θ) = | cosθ -sinθ 0 0 | | sinθ cosθ 0 0 | | 0 0 1 0 | | 0 0 0 1 |三个矩阵合在一起,加上乘法顺序,就引出了欧拉角的概念。欧拉角用三个角度(roll、pitch、yaw)描述一个旋转,听起来方便,但用它做连续旋转有一个著名的问题:万向节锁。当中间那根轴旋转到一定角度后,另外两个轴会变得平行,导致丢失一个自由度,物体出现无法控制的抖动。实际工程里如果要长期连续旋转物体,我更推荐用四元数。不过这是后话了,这篇文章先把矩阵吃透。
2.4 剪切变换:最容易被忽视的变换
剪切变换比前面几个冷门,但在实际工程里出场频率并不低。二维剪切矩阵的典型形式是沿 x 方向剪切:
Hx(a) = | 1 a 0 | | 0 1 0 | | 0 0 1 |作用到点 (x, y) 上,得到 (x + a*y, y)。意思是:y 坐标不变,x 坐标在原来的基础上额外加了 a 乘以 y。如果你把 a 看作一个斜率,那么图形里 y 越大的点,被推得越远。原来竖直的线条会变成斜线,整个图形像一个矩形被推成了平行四边形。
沿 y 方向剪切也类似:
Hy(b) = | 1 0 0 | | b 1 0 | | 0 0 1 |应用后得到 (x, y + b*x)。
剪切变换的核心价值在于理解矩阵的列向量:标准基 e2 变成了 (a, 1),也就是说 y 轴本身不再竖直,而是偏了一个角度。这个变换的行列式为 1(|1a - a1| 的关系,其实 |H| = 1),所以它不改变图形的面积。这跟缩放不一样,缩放行列式为 Sx*Sy,面积会跟着变;旋转行列式为 1,面积不变;镜像行列式为 -1,面积绝对值不变但方向反了。这些行列式的符号和大小,在图形学里直接关系到面积、法线方向和手性,后面会再提。
剪切的实际应用很广泛。字体斜体效果、图像矫正、高斯消元里的初等行变换,本质上都是一回事。在投影变换里,透视投影矩阵也可以理解为包含剪切成分的矩阵,它把视锥体“推”成长方体,方便后续计算。所以别看剪切平时不显眼,关键时刻它是很有用的工具。
2.5 镜像变换:行列式为负带来的“手性翻转”
镜像变换,也叫反射变换,用矩阵表示最常见的是关于 x 轴镜像:
ReflectX = | 1 0 0 | | 0 -1 0 | | 0 0 1 |点 (x, y) 变成 (x, -y)。关于 y 轴镜像就是把第一列负一下,关于任意直线镜像则需要先旋转、再镜像、再旋转回去。
从矩阵的角度看,镜像一个最重要的特征是行列式为 -1。我刚开始学图形学时一直不理解“行列式为负到底意味着什么”,后来在三维渲染里被法线翻转的问题折磨过几次,才真正明白:行列式为负的变换会把坐标系的“手性”翻转。也就是说,原本右手系经过镜像后会变成左手系。最直观的体验是,在三维编辑器里对一个模型做镜像后,模型的正面会变背面,三角形绕序反过来,法线方向全部反了,光照一下子就错乱。
所以镜像变换不能随便和其他变换混在一起。实际工程里如果需要用镜像,通常要额外处理三角面的绕序,或者检查法线是否朝向正确。此外,在矩阵复合中,如果一个矩阵包含奇数个镜像,那么这个复合矩阵的行列式就是负的;如果包含偶数个镜像,行列式为正。这个性质可以用来快速判断整个变换序列是否包含镜像。
3. 矩阵顺序的坑:旋转、平移到底谁先谁后
3.1 矩阵乘法不满足交换律:一个生活化类比
矩阵乘法不满足交换律,AB 在大多数情况下不等于 BA。这个数学事实在图形学里带来的后果是:你写变换的顺序,决定了最终图形的呈现方式。
有一个很经典的生活化类比:先穿袜子再穿鞋,和先穿鞋再穿袜子,结果完全不同。矩阵复合也是这个道理。对一个物体同时施加旋转和平移,“先旋转后平移”和“先平移后旋转”得到的是两种不同的位置关系。前者是物体先绕原点转好,再移到目标位置,物体的朝向和它在世界里的位置是解耦的;后者是先挪到目标位置,再绕世界原点转,物体会一边转一边改变位置。
在实际调试时,遇到“物体旋转后不在预期位置”的情况,第一反应就应该是检查矩阵乘法的顺序。可以先把代码里的矩阵拆开,用简单的点 (1, 0, 0) 一步步手算验证,哪个环节出了问题一目了然。
3.2 先缩放、再旋转、再平移:模型矩阵的标准顺序
在绝大多数游戏引擎和三维渲染管线里,把一个模型从自身的局部坐标系放到世界坐标系,用的模型矩阵顺序是:
Model = T * R * S也就是先缩放,再旋转,最后平移。这个顺序不是拍脑袋定的,它符合建模和装配的实际需要。缩放放在最里层,可以让模型在自己的局部坐标系里调整大小;旋转在中间,指定这个模型朝哪个方向;平移在最外层,确定模型放在世界里的哪个位置。
如果换成 M = S * R * T,模型会先被移动,然后再被旋转和缩放,最后的结果往往是一团乱麻,物体的位置会因为旋转半径被放大而变得不可控。我建议在初始化每帧的变换矩阵时,都固定成先缩放、再旋转、再平移这个习惯。等到你对矩阵足够熟悉,再去有意识地突破这个顺序满足特殊需求。
3.3 固定坐标系和移动坐标系,以及绕任意轴旋转的七步连乘
围绕“旋转坐标系”有一个非常经典的概念:内旋和外旋。同样是三个欧拉角,固定在世界坐标系的轴上旋转叫外旋,固定在被旋转物体自身的轴上旋转叫内旋。矩阵表示上,外旋可以理解为每次旋转都是继续左乘新的旋转矩阵,而内旋则是在右乘。两者最终的数值并不一样,这也是很多人看资料看到怀疑人生的地方。
面对这种情况,我通常建议绕开欧拉角,直接用“绕任意轴的旋转”。绕任意轴旋转的三维矩阵可以用罗德里格斯公式直接得到。如果不用公式,也可以拆成七个步骤:
- 第一步,把旋转轴平移,让它通过原点;
- 第二步,把轴旋转到与某一坐标轴(比如 z 轴)对齐;
- 第三步,绕这个坐标轴旋转目标角度 θ;
- 第四步,把轴旋转回原来方向;
- 第五步,把轴平移回原来的位置。
翻译成矩阵连乘就是 T * Rz(-α) * Ry(-β) * Rz(θ) * Ry(β) * Rz(α) * T⁻¹。注意这里每一步都是在变换整个坐标系,所以中间会夹着好几次“基准变换和逆向基准变换”。这个“七矩阵连乘”的思路虽然啰嗦,但非常直观,也容易实现。罗德里格斯公式本质上就是把这个过程压缩成了一个更紧凑的公式。实际工程里如果对数值效率和稳定性有要求,我通常直接用四元数或罗德里格斯公式,而不会真的连乘七个矩阵,但理解七个矩阵的过程对于排查问题非常有帮助。
4. 手写 2D 变换库与常见坑排查
4.1 从零写一个二维变换矩阵库
理论讲再多,不落到代码上总是差点意思。这里我用一小段 TypeScript 实现一个最精简的 2D 变换矩阵库,你可以直接拿去做 Canvas 项目或小工具:
type Mat3 = [number, number, number, number, number, number, number, number, number]; function multiply(a: Mat3, b: Mat3): Mat3 { return [ a[0]*b[0] + a[1]*b[3] + a[2]*b[6], a[0]*b[1] + a[1]*b[4] + a[2]*b[7], a[0]*b[2] + a[1]*b[5] + a[2]*b[8], a[3]*b[0] + a[4]*b[3] + a[5]*b[6], a[3]*b[1] + a[4]*b[4] + a[5]*b[7], a[3]*b[2] + a[4]*b[5] + a[5]*b[8], a[6]*b[0] + a[7]*b[3] + a[8]*b[6], a[6]*b[1] + a[7]*b[4] + a[8]*b[7], a[6]*b[2] + a[7]*b[5] + a[8]*b[8], ]; } function identity(): Mat3 { return [1,0,0, 0,1,0, 0,0,1]; } function translate(tx: number, ty: number): Mat3 { return [1,0,tx, 0,1,ty, 0,0,1]; } function scale(sx: number, sy: number): Mat3 { return [sx,0,0, 0,sy,0, 0,0,1]; } function rotate(rad: number): Mat3 { const c = Math.cos(rad), s = Math.sin(rad); return [c,-s,0, s,c,0, 0,0,1]; } function shear(a: number, b: number): Mat3 { return [1,a,0, b,1,0, 0,0,1]; } function reflectX(): Mat3 { return [1,0,0, 0,-1,0, 0,0,1]; } function apply(m: Mat3, x: number, y: number): [number, number] { return [ m[0]*x + m[1]*y + m[2], m[3]*x + m[4]*y + m[5], ]; }这段代码的第一坑我已经替你先踩了:矩阵元素的下标顺序问题。上面我用的是一个扁平数组,前三个元素是矩阵第一行,中间三个是第二行,最后三个是第三行。在 TypeScript/JavaScript 里这很直观。但 OpenGL 用的是列主序,也就是一组 16 个 float 里,前四个元素是第一列。如果你做 WebGL 渲染,这段代码里的数据布局需要转置。这个坑非常经典,我查过好多次,每次都是表面显示不对,结果发现是行主序和列主序搞反了。
4.2 典型应用场景:图片缩放与平移卡顿问题
我们平时在前端做图片缩放或地图拖拽时,看起来是在操作 div 或者 canvas 的 transform 属性,但底层依然是变换矩阵。很多人遇到“图片平移卡帧”的问题,第一时间会想是不是循环效率不够,但真正的原因往往是事件回调里做了太多 DOM 读写,或者直接修改了 left、top 这些属性,导致浏览器不断重新触发布局。
正确做法是,把平移量累积进一个 transform 矩阵里,然后在每一帧结束时一次性设置 CSS 的 transform 属性为 matrix(a, b, c, d, e, f)。浏览器对 transform 的更新走的是合成器,比修改 left/top 快得多,实测差距能有几倍。对于 Canvas 项目,也是同样的思路:把变换累积到矩阵里,每次绘制时调用 ctx.setTransform 而不是反复使用 translate、scale。
另一个典型场景是“以鼠标为中心缩放”。如果你做图片查看器,想实现类似地图应用那种“鼠标放在哪,就往哪放大”的效果,可以直接调整矩阵的平移分量。简单可行的两步法是:
- 先算出鼠标当前对应的世界坐标点。
- 对矩阵乘以一个新的缩放矩阵和平移矩阵,让那个世界坐标点经过变换后仍然落在鼠标位置下。
纯靠矩阵操作可以推出来,但新手更容易理解的做法是:先把目标点平移到原点,缩放,再平移回鼠标位置。等于构造一个 T(鼠标) * S(scale) * T(-鼠标) 的复合矩阵。这种情况下,如果平移和缩放顺序写反,图标就会在缩放时乱跳。
4.3 常见问题与调试速查表
这里整理一张我在各种项目里反复遇到的“症状 - 原因 - 方案”对照表,建议收藏。
| 症状 | 原因 | 解决方案 |
|---|---|---|
| 物体旋转后位置飞了 | 平移和旋转矩阵顺序反了 | 确认是 TR 还是 RT,按需要调整 |
| 缩放时物体不是围绕中心缩放 | 缺少“平移到原点-缩放-平移回来” | 构造 T(cx,cy) * S * T(-cx,-cy) |
| 图片镜像后光照变暗或背面朝外 | 镜像行列式为负,三角形绕序反转 | 单独处理绕序或手动翻转法线 |
| 屏幕坐标系里旋转方向看着不对 | 旋转正方向受坐标系影响 | 确认坐标系 Y 轴方向,必要时角度取负 |
| 连续旋转出现卡顿 / 万向节锁 | 用欧拉角做连续旋转 | 改用四元数表示旋转 |
| 变换累积了几百次后图形变形 | 浮点精度误差累积,矩阵不再是严格正交 | 定期对旋转矩阵做正交化或单位化处理 |
| WebGL 里变换结果错乱 | 行主序 / 列主序传数组格式用错 | 确定 API 的矩阵布局约定,统一处理 |
这些坑里,我最想单独强调的是最后两条。浮点精度问题在长期运行的编辑器或地图应用里非常常见。比如你不停地在当前矩阵上再叠加一个小旋转,一百帧后旋转矩阵渐渐不再是正交矩阵,图形会轻微扭曲。干净的解决方式是保存一个独立的旋转角度和一个独立的平移向量,每次从零重新计算矩阵,而不再让矩阵自己累积误差。这个习惯能让你省掉大量排查浮点误差的时间。
4.4 一个调试矩阵的好习惯
最后分享一个我自己的调试习惯。无论用什么语言、什么框架,我都会在初始化时定义一个单位矩阵,然后单独用一个变量存放当前矩阵。遇到结果不对时,我会先把复合矩阵拆成最初的几个基础矩阵,再用三个已知的点手动跑一遍矩阵乘法。这三个点我一般选 (0,0)、(1,0) 和 (0,1),因为它们在单位基向量上,乘完之后的结果正好能看出来每一列变成了什么。只要三个点对得上,矩阵本身基本没有问题,剩下要怀疑的就是数据传递和顺序了。
比如你想验证 rotate(90°) 的结果,用点 (1,0) 乘一下,应该得到约 (0,1) 或 (0, -1),取决于坐标系方向。如果得到的是别的数,多半是角度单位没用对,或者矩阵元素的三角值写错了位置。这个方法看起来笨,但实际排查速度非常快,比对着矩阵瞎猜靠谱多了。
这套变换知识,往浅了说是几个公式,往深了说直接关系到你看待整个图形学的坐标系体系。我自己的体会是,矩阵变换这块急不得,一定要亲手算几遍、写几遍代码才能建立直觉。你现在要是能把这五个变换的矩阵各自写出来,并且能说清楚为什么列向量是那个样子,那后面学 MVP 矩阵、法线变换、UV 变换都会轻松很多。