1. 变换不是公式堆砌,而是坐标的“搬运工”
我最早接触图形学中的缩放、平移、旋转时,以为就是背几个矩阵,考试能写出来就完事了。后来在实际项目里写图片缩放、做3D模型旋转、调动画过渡才发现,变换是整个图形学里最基础也最容易搞出“灵异现象”的地方:物体飞了、位置对了但旋转中心不对、法线高光歪了——追根溯源,全是变换的理解出了偏差。
先建立一个最朴素的心智模型:图形学里的任何变换,本质都是“给物体上每个顶点找一个新的位置”。一个点原来在 (p=(x,y,z)),经过变换后出现在 (p'),之后的渲染管线只认 (p'),不再管它之前在哪。缩放、平移、旋转、剪切、镜像,都是这同一框架下的特例,只是“找新位置”的规则不同。
那为什么不能单纯用坐标代数去算,非要引入矩阵?原因很简单:一个变换往往不是只作用在一个点上,同一个模型少说也有几千个顶点。与其对每个顶点写一遍 if-else 规则,不如把规则统一成“一个矩阵乘上坐标向量”, CPU/GPU 的向量化指令和并行单元天生就擅长这活儿。
这套思维方式放之四海皆准。CSS 里的transform: scale/rotate/translate、Qt 的QPainter::scale/rotate、Canvas 的setTransform、OpenGL 的glm::translate/rotate/scale,底层全是同一个数学结构。换句话说,你如果真把这里面的矩阵搞明白了,跨平台看什么图形API都不虚。
更关键的是,变换直接决定了坐标系的切换。一个模型在建模软件里是在自己的“局部坐标系”下定义的,把它放进场景需要“模型变换”;接着从世界坐标看相机,需要“视图变换”;最后把三维世界压到二维屏幕,需要“投影变换”。这套 MVP 矩阵链路,每一步都是我们马上就要讨论的基本变换的组合。所以这一步不是“图形学的一个小章节”,而是后续一切的地基。
2. 齐次坐标:为什么平移“不配”用普通矩阵
2.1 一次失败的联系:2×2矩阵表达不了平移
先看二维情况。如果只用 2×2 矩阵,缩放、旋转这类“线性变换”都能写出来:
- 缩放:(\begin{bmatrix} s_x & 0 \ 0 & s_y \end{bmatrix})
- 旋转:(\begin{bmatrix} \cos\theta & -\sin\theta \ \sin\theta & \cos\theta \end{bmatrix})
可平移是 (p' = p + t),也就是加了一个常数向量。仔细想想,2×2 矩阵只能表示“坐标的线性组合”,乘出来每一项都还是 (x)、(y) 的一次项,不可能凭空多出一个常数项。这就是数学上说的“平移不是线性变换”,它是仿射变换。
当时我第一次意识到这件事的反应是:那我不写矩阵,平移直接加向量不就行了?确实可以,但问题随之而来——如果一个变换序列里既有平移又有旋转,代码就得分开处理:
p = (x, y) p = rotate(p, angle) p = (p[0] + tx, p[1] + ty) p = scale(p, sx, sy)这样写两三个变换尚可忍受,但实际渲染里一个物体可能经过局部变换、父节点变换、世界变换、相机变换等五六层叠加,每层都可能混着缩放旋转平移。如果每层都拆分处理,代码会迅速失控,而且无法把“一整条变换链”打包成单个矩阵交给 GPU。
2.2 解决方案:多给一个维度,让平移转正
齐次坐标的思路很巧妙:在原本的 ((x, y)) 后面强行加一个分量 (w),平时固定为 1,即 ((x, y, 1))。这样一来,2D 变换矩阵统一升到 3×3:
[ T(t_x,t_y) = \begin{bmatrix} 1 & 0 & t_x \ 0 & 1 & t_y \ 0 & 0 & 1 \end{bmatrix} ]
把 ((x, y, 1)) 乘上这个矩阵,结果就是 ((x + t_x, y + t_y, 1))。平移从此也能用矩阵乘法表达了。三维情况同理,坐标变成 ((x, y, z, 1)),变换矩阵变成 4×4。
从工程上看,这个“多出来的维度”不是白给的。它为后续透视投影预留了位置——当 (w \neq 1) 时,最终显示坐标是 ((x/w, y/w)),这正是透视除法。不过在基础变换这一章里,你只需要记住:(w=1) 代表纯几何点,(w=0) 代表方向向量。方向向量不随平移变化,如果你把某个法线方向的 (w) 设成 1,平移会产生错误结果,这也是新手经常踩的坑。
2.3 行向量与列向量:一个影响所有公式的“约定”
很多人学变换矩阵时被绕晕,不是因为矩阵本身难,而是没搞清数学书和图形库用的“约定”可能相反。
- 列向量约定:点写成一列,变换矩阵在左边,即 (p' = M p)。先应用的变换在最右侧。OpenGL、glm、Three.js 里很多教程默认这一种。
- 行向量约定:点写成一行,变换矩阵在右边,即 (p' = p M)。先应用的变换在最左侧。DirectX、部分数学库使用这一种。
两种写法在数学上完全等价,矩阵互为转置,但组合顺序恰好相反。我见过无数人在自己代码里做矩阵连乘时,把translate * rotate写成rotate * translate,然后发现物体跑到了莫名其妙的位置。这不是数学错误,是约定没对齐。
我的建议是:选定一种约定后,在项目文档里直接写死,并且所有代码注释按同一套来。不要“感觉不对就翻转一下试试”,那样运气好能过,运气不好会留下极其隐蔽的 bug。
3. 五种基本变换的矩阵推导:从直觉到数字
3.1 缩放:绕原点缩小放大,以及“绕任意点缩放”的两个陷阱
最简单的缩放,是把坐标按比例拉伸或压缩:
[ S(s_x,s_y) = \begin{bmatrix} s_x & 0 & 0 \ 0 & s_y & 0 \ 0 & 0 & 1 \end{bmatrix} ]
当 (s_x, s_y > 0) 时,图形被拉宽或压扁;当 (s_x, s_y) 为负时,实际上产生了“镜像+缩放”的混合效果。这也是镜像变换为什么可以从缩放里面推出来的原因——关于 y 轴对称无非是 (S(-1, 1))。
但实际开发时,大家更常用的是“绕任意点缩放”。比如你有一个 UI 元素,希望它围绕自己的中心点缩放,而不是围绕原点缩放。直接套用上面的矩阵,元素会向原点方向飘走。正确做法是三步走:
- 把参考点平移到原点:(T(-c_x, -c_y))
- 缩放
- 平移回去:(T(c_x, c_y))
组合矩阵写出来就是:
[ M = T(c_x, c_y) \cdot S(s_x, s_y) \cdot T(-c_x, -c_y) ]
你可以在 Base64 里搜“图片缩放”的缓存:
这里有个很实用的经验:CSS 的transform-origin就是干这个事的;Qt 里QPainter::translate(center); scale(...); translate(-center);也是同一套路。如果你发现图像缩放后位置偏了,不用怀疑算法,一定是中心点还原没做好。
另外,缩放还有个不起眼但重要的特性:它会把原本垂直于表面的法向量变得不垂直。这个问题我在第 6 章单独展开,因为它的坑远比你想象的大。
3.2 旋转:矩阵很简单,方向问题很闹心
二维旋转的标准矩阵是:
[ R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \ 0 & 0 \ \sin\theta & \cos\theta & 0 \ 0 & 0 & 1 \end{bmatrix} ]
写规范一点:
[ R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \ \sin\theta & \cos\theta & 0 \ 0 & 0 & 1 \end{bmatrix} ]
这个矩阵的作用是:把点 ((x,y)) 绕原点逆时针旋转 (\theta)。注意“逆时针”是针对标准右手坐标系前提的;如果屏幕坐标 y 轴向下,视觉上可能变成顺时针。实际项目里不要靠记忆判断旋转方向,直接在纸上画一个点,用 90 度代入验证一下,又快又不会错。
二维旋转还有一个很常见的“来源不明”困惑:为什么 (\sin) 的位置有负号?你可以把 ((1,0)) 代入 90 度的情况验证:旋转后应该是 ((0,1)),写出来就能看出负号放的位置决定了旋转方向。矩阵公式背不熟可以,推导一定得会。
3.3 平移:朴素但绝不能省
平移矩阵是:
[ T(t_x,t_y) = \begin{bmatrix} 1 & 0 & t_x \ 0 & 1 & t_y \ 0 & 0 & 1 \end{bmatrix} ]
它做的事情非常直接。但在实际编码里,平移往往是组合变换中“最后乘上去”的那一个,也是最容易被忽略中心点的那一个。上面说到的绕任意点旋转/缩放,本质都是先用一个负平移把参考点搬到原点,变换完再补一个正平移。
3.4 剪切(Shear):图像“歪斜”背后的线性关系
剪切变换稍微冷门一点,但 CSS 里的skew()、图像处理里的斜切效果、游戏里的底盘形变都用得到。沿 x 方向的剪切矩阵长这样:
[ H(s) = \begin{bmatrix} 1 & s & 0 \ 0 & 1 & 0 \ 0 & 0 & 1 \end{bmatrix} ]
它做的事情是:y 坐标不变,x 坐标根据 y 线性偏移。原来一个竖直的矩形,会变成一个平行四边形。同理,沿 y 方向的剪切是 x 不动,y 根据 x 偏移。
它的物理意义可以用一个很生活化的例子理解:一摞纸从侧面推一下,最上面的纸偏移最大,最下面的纸不动,中间就形成线性渐变——这就是剪切。如果你把 s 设得过大,图形会被拉得特别歪,图像可能跑到可视范围之外,这是调试时经常遇到的状况。
剪切在基础变换里看起来不如旋转“高级”,但它是研究仿射变换性质时的重要工具,也是许多矩阵分解算法里的基本元素。理解它之后,你对“矩阵每一列代表基向量变换后的去向”会更有直觉。
3.5 镜像:行列式为负到底说明什么
二维关于 y 轴的镜像矩阵:
[ M_y = \begin{bmatrix} -1 & 0 & 0 \ 0 & 1 & 0 \ 0 & 0 & 1 \end{bmatrix} ]
它可以把物体的 x 坐标取反,让左右颠倒。关于 x 轴的镜像就是把 y 取反。如果对坐标原点做中心对称镜像,则相当于 (S(-1,-1)),这在数值上等价于 180 度旋转加一个整体翻转。
镜像与其他变换有一个显著区别:它的行列式为负。线性代数里,行列式的绝对值代表面积缩放倍数,符号代表空间方向是否被“翻面”。因此,如果你把变换矩阵乘到二维向量集合上,发现三角形顶点顺序从逆时针变成顺时针,就说明矩阵里含镜像成分。这在渲染里会影响面片剔除方向,直接导致模型“消失”或者只有反面可见。
另外值得留意的是,镜像往往不是单独出现的。实际项目里要判断用户有没有对对象做镜像,不能只检查是否存在负数缩放因子,还要看整个矩阵的行列式是否为负——组合变换中即使所有缩放因子都为正,旋转叠加旋转也可能产生负的行列式(比如先镜像再旋转还是镜像)。
4. 组合变换的乘法顺序:先平移再旋转,不等于先旋转再平移
4.1 一个反直觉的实验
很多新手第一次被组合变换坑,是在做“让图片以中心旋转”时。假设一个点在 ((1,0)),你要让它在原来位置基础上旋转 90 度,再平移到 ((3,0))。按照“先旋转再平移”的顺序,矩阵是:
[ M_1 = T(3,0) \cdot R(90^\circ) ]
计算结果是 ((3,1)) 附近。如果调换顺序变成“先平移再旋转”:
[ M_2 = R(90^\circ) \cdot T(3,0) ]
结果会变成 ((0,4))。同样两个操作,顺序不同,结果天差地远。旋转和平移不交换,这是矩阵乘法不满足交换律的直接体现。
理解这件事,你就能明白为什么很多图形框架里设置变换的顺序必须讲究:
- CSS 里
transform: translate(...) rotate(...)是自右向左执行的,和列向量约定一致。 - Canvas 的
translate、rotate是“追乘”当前变换矩阵,每调用一次,作用在已有变换的右边还是左边取决于实现,需要看文档或实测。
我自己的习惯是:拿到需求先问“是绕哪个坐标系动”,再约定矩阵连乘顺序,最后用一条形状简单的测试图(比如一个三角形)验证,而不是直接对真实模型下手。
4.2 固定坐标系旋转与移动坐标系旋转的两套解释
群里经常有人问“绕移动坐标系和固定坐标系旋转”到底怎么理解。其实这是同一个矩阵乘法的两种解释方式。
- 固定坐标系视角:点按世界坐标系的轴旋转,变换矩阵从右往左乘。你已经写好的变换矩阵,最右边的离点最近。
- 移动坐标系视角:物体以自身的局部坐标系旋转,变换矩阵从左往右乘。每一次新的旋转都是基于物体当前的朝向。
一个经典例子:想让飞机先抬头 30 度,再左转 60 度。如果你把“抬头”和“左转”的矩阵以固定世界坐标系的顺序写,可能结果与预想完全不同;但如果你用移动坐标系解释,就需要把“左转”矩阵乘在“抬头”矩阵的左边。这个区别在我刚入门三维引擎时困扰了很久,直到自己动手写了一个小 demo 反复切换顺序,才彻底转过弯来。
工程上我的建议是:不要混用两种理解方式。大多数引擎(如 Three.js、Unity)默认采用“移动坐标系、矩阵从左到右”或“局部坐标按顺序左乘”的思路,所以你在设置旋转时,先做的操作通常放在左边。具体到某一个 API,一定要写一个最小测试验证。
4.3 绕任意点的标准组合公式
绕任意点旋转是上面顺序问题的直接应用:
[ M = T(p) \cdot R(\theta) \cdot T(-p) ]
也就是:把中心点平移到原点,旋转,再把中心点平移回来。绕任意点缩放也是同一套路。
我见过很多人把这三个矩阵分开存储、逐顶点循环乘三次。其实没必要:矩阵乘法满足结合律,你完全可以先把这三个矩阵乘成一个矩阵,再对顶点做一次乘法。这样每个顶点从三次矩阵乘变成一次矩阵乘,在模型有几十万顶点的时候,性能差距非常明显。
5. 从二维到三维:矩阵升维,思维也要升维
5.1 三维基本变换长什么样
二维的 3×3 矩阵升到三维 4×4,规则一模一样:坐标变成 ((x,y,z,1)),平移矩阵在最右列放位移,缩放矩阵在对角线放比例,旋转矩阵则是把二维旋转“嵌入”某个平面。
绕 z 轴旋转的矩阵,本质就是在 xy 平面内做二维旋转,z 分量不动:
[ R_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 & 0 \ \sin\theta & \cos\theta & 0 & 0 \ 0 & 0 & 1 & 0 \ 0 & 0 & 0 & 1 \end{bmatrix} ]
绕 x 轴和绕 y 轴的矩阵,只要把 2×2 旋转块放到对应平面上。注意绕 y 轴时,由于坐标轴排列顺序不同,矩阵里 (\sin) 的符号位置会和绕 x/z 轴略有差异。网上很多人喜欢抄公式,结果抄错了符号,导致模型旋转方向反向。建议自己也用单位向量代入验证一次。
三维平移:
[ T(t_x,t_y,t_z) = \begin{bmatrix} 1 & 0 & 0 & t_x \ 0 & 1 & 0 & t_y \ 0 & 0 & 1 & t_z \ 0 & 0 & 0 & 1 \end{bmatrix} ]
三维缩放、剪切、镜像也都可以类推。实际渲染中,模型变换和视图变换就是这些矩阵的组合。写代码时,可以先用glm::translate等现成库把矩阵建好,再手动打印出矩阵数值来核对,能省去很多挠头时间。
5.2 绕任意轴旋转:七矩阵连乘还是 Rodrigues 公式
三维里最让人头皮发麻的是“绕任意轴旋转”。热搜词里有“绕任意轴旋转后坐标形式(七矩阵连乘)”,说的就是这种经典做法:
- 把旋转轴平移到过原点;
- 旋转坐标系,让旋转轴与某个坐标轴(比如 z 轴)重合;
- 绕 z 轴执行目标旋转;
- 把坐标系旋转回去;
- 把轴平移回原位。
整个过程大致是:
[ M = T(p) \cdot R^{-1}{align} \cdot R_z(\theta) \cdot R{align} \cdot T(-p) ]
算起来要五六个矩阵相乘,所以会给人一种“绕任意轴很复杂”的印象。实际上工程上更推荐使用 Rodrigues 旋转公式,直接根据旋转轴单位向量 (\mathbf{n}) 和角度 (\theta) 生成 3×3 旋转矩阵,一步到位。它的推导依赖向量分解成平行和垂直于旋转轴两部分,代码实现也比连乘直观得多。
实战里反复用到绕任意轴的场景不多,但“物体的朝向插值”会用四元数来实现,那又是另一个大坑了。基础变换阶段,能理解七矩阵连乘到底在做什么、知道为什么能这样拼,就够了。
5.3 欧拉角与旋转顺序:万向锁是怎么来的
三维建模软件里最常用的旋转表达是欧拉角:分别绕 x、y、z 轴旋转 pitch、yaw、roll。但同一个姿态,可以有无数种“三个角的组合”,取决于旋转顺序。
热搜词里也出现过“旋转顺序”“机械手旋转顺序”“jaka机械臂的旋转顺序”,这类问题在生产中非常现实。比如机械臂的末端姿态,如果按 XYZ 还是 ZYX 顺序解算,结果可能差出几十度。
欧拉角最著名的坑是万向锁:当中间轴旋转到 90 度时,另外两个轴的旋转效果变得“重合”,丢失了一个自由度,表现为模型抖动、无法平滑旋转。这是欧拉角本身的结构缺陷,不是 bug。
所以做动画、相机飞行的实际项目里,我几乎不用欧拉角做插值,而是转四元数。基础变换阶段,你只需理解欧拉角对应“绕固定/移动坐标系的多次旋转”,明白旋转顺序不同结果不同,就够你应付大部分面试和调试了。
6. 逆变换与法向量变换:渲染中两个容易被忽视的细节
6.1 逆矩阵:怎么把“已做过的变换”撤销
变换可逆,意味着你可以把空间里所有东西再变回去。平移矩阵的逆是反方向平移;旋转矩阵是正交矩阵,逆等于转置;缩放矩阵的逆是对应分量取倒数。
在实际项目里,撤销变换最常见的使用场景是把世界坐标转回局部坐标。比如点击屏幕选择模型时,需要从屏幕坐标反推到模型坐标,就得对模型变换矩阵求逆。另一个常见场景是层级变换中父子节点之间的坐标互转。
求逆在数学上通用方法是伴随矩阵除以行列式,但工程上几乎不会对 4×4 大矩阵直接这样干,除非你在写教学 demo。性能优化上,可以利用上述特殊矩阵的逆的简单表达式:
- 平移:(T^{-1}(t) = T(-t))
- 旋转:(R^{-1}(\theta) = R(-\theta) = R^T(\theta))
- 缩放:(S^{-1}(s) = S(1/s))
组合变换的逆等于“各个矩阵逆矩阵,顺序反过来”,即 ((A \cdot B)^{-1} = B^{-1} \cdot A^{-1})。你要是没记住这个,写代码时很容易把“取逆后的顺序”也调错,结果就是撤销变换叠加之后,物体飞到更远。
6.2 法向量不能用模型矩阵直接变换,这是很多人翻车的地方
我见过最典型的渲染 bug 是:给模型加了一个非均匀缩放(比如 x 方向拉长),结果表面的高光跟着错,甚至法线贴图出现奇怪的接缝。问题出在有人把顶点位置变换矩阵直接乘到了法向量上。
法向量是方向向量,平移对它的影响可以忽略,但缩放和剪切会影响它的方向。一个简单直观的例子:一个表面原本法线朝上,如果整体 x 方向被压缩一半,表面的实际倾斜都会变化,法向量也应该跟着倾斜。但如果你用同一个矩阵去乘法向量,得到的新向量可能不再垂直于表面。
数学上的正确做法是用“逆转置矩阵”,即模型矩阵的逆的转置:(N' = (M^{-1})^T \cdot N),而不是 (M \cdot N)。逆转置矩阵能保证变换后的法向量与切线方向保持垂直。
这个知识点在基础变换阶段可能用不上,但只要你开始做光照渲染,就一定会碰到。提前知道,能省下一次稀里糊涂的 Debug 时间。
7. 实际应用观察:从图片缩放卡顿到旋转卡壳算法
7.1 图片缩放为什么会卡
有人问“图片平移会卡帧么”“图片缩放会卡帧么”。先说结论:如果只是改矩阵参数,几乎不会卡;卡顿往往出在重采样、重绘和布局计算上。
- 图片缩放本质是变换像素坐标,然后按某个插值算法重新采样。双线性、双三次插值在高倍缩放时开销很大。
- 如果缩放的 UI 元素触发了布局重排,那整个页面都要重新计算尺寸,这才是卡顿主因。
- 频繁创建对象导致 GC 压力,也可能造成帧率抖动。
所以我的优化顺序是:能用 GPU 缩放就不要用 CPU 逐像素处理;尽量只更新 transform 矩阵,不触发布局;缩放过程用缓存帧或低分辨率预采样,等停止变化再高清重绘。矩阵本身的乘法开销少到可以忽略,别一卡顿就去优化矩阵计算,那是白白浪费时间。
7.2 旋转卡壳算法与“旋转”并无直接关系
热搜词里出现“旋转卡壳算法”,这里必须澄清一下:旋转卡壳(Rotating Calipers)是计算几何里用来求凸多边形直径、最近点对等问题的算法,核心思想是绕着凸包转两根平行的“卡尺”,逐步遍历对踵点。它和图形学里的旋转矩阵没有直接关系,只是在思路上有一点“旋转扫描”的影子。
很多初学者被这个词骗了,以为学旋转变换就能顺带学会旋转卡壳。它属于计算几何的专题,和“绕任意轴旋转”完全是两个方向。如果你只是学图形学基础变换,先不用管它;如果你是准备算法面试,也建议单独整理它的凸包与对踵点知识体系。
7.3 层级变换:3D动画里的父子关系,本质是矩阵连乘
3D 相册、模型旋转、机械臂模拟这些应用,背后都会用到层级变换。一个典型结构是:
- 世界场景节点
- 父节点(比如飞机机身)
- 子节点(比如机翼上的炮台)
- 父节点(比如飞机机身)
渲染时,子节点坐标要先乘自己的局部变换,再乘父节点的世界变换。整棵结构树的矩阵关系就是一级一级连乘出来的:
[ M_{sub_world} = M_{parent_world} \cdot M_{sub_local} ]
你在做机械臂旋转顺序标定时,也是这个思路:关节 1 旋转一个角度,关节 2 在关节 1 的坐标系里旋转,末端在世界空间的位姿要一次一次把父级矩阵乘下来。理解这一点,比背多少公式都重要。
我在实际项目里的经验是:给每个节点维护单独的局部矩阵,渲染时再通过遍历计算世界矩阵,不要在局部矩阵里直接写世界坐标。否则一旦父节点动了,子节点全部要手动同步,代码很快就会变成一团乱麻。
写在最后的一点经验
基本变换这套东西,听起来简单,实际用起来处处是约定和顺序的坑。我个人最大的体会是:不要相信自己能在脑子里把矩阵顺序理清,一定要写出测试代码,打印中间矩阵和顶点结果,亲眼看到数值才放心。另一个是工具选择,能用现成数学库(glm/Three.js/OpenCV 等)就别自己造轮子,但造轮子前也得能读懂库源码在做什么。
最后分享一个小技巧:调试变换问题时,不要用真实模型,先画一个带方向的三角形或坐标轴,分别绕 x、y、z 旋转 90 度,看看变换结果是否和预期一致。这个方法的效率远高于盯着矩阵数字硬想,因为三角形的朝向和形状能非常直观地暴露“负号写反”“顺序反了”“中心点没对齐”等问题。