4、2D坐标变换:平移、旋转、缩放,用矩阵说话。
说到2D坐标变换,我脑子里第一个蹦出来的词就是「矩阵」。
你想想看,一个点从位置A跑到位置B,背后其实是一堆数学运算。平移、旋转、缩放,这三个操作单独看都不难。但把它们组合起来,用手算?那可就头大了。
矩阵就是来解决这个问题的。它把复杂的变换,变成了一套统一的乘法规则。
4.1 为什么非要用矩阵?
我刚开始做UI框架的时候,也想过不用矩阵。直接加减乘除不就行了?
平移就是 x+dx, y+dy。缩放就是 x*sx, y*sy。旋转稍微麻烦点,但也能用三角函数硬算。
问题出在「组合」上。
比如我要先旋转45度,再平移(10,20),再缩放2倍。如果不用矩阵,你得写三个函数,调三次,中间还得传临时变量。代码又长又容易出错。
用矩阵就不一样了。三个变换各自对应一个矩阵,乘在一起,得到一个「复合矩阵」。然后所有点乘以这个矩阵,一步到位。
核心思想:矩阵让「变换的组合」变成了「矩阵的乘法」。这是线性代数给图形学最大的礼物。
4.2 齐次坐标:给矩阵加点料
这里有个坑。平移是加法,旋转和缩放是乘法。它们不是同一类运算。
如果只用2x2矩阵,你只能表示旋转和缩放。平移?没门。
解决方案是引入「齐次坐标」。说白了,就是把二维的点 (x, y) 变成三维的 (x, y, 1)。
多出来的那个1,就是给平移留的位置。
我的习惯:只要涉及坐标变换,我永远用齐次坐标。哪怕当前只有旋转,我也用3x3矩阵。因为后续加平移时,不用改代码结构。
4.3 三种基本变换的矩阵形式
直接上干货。假设我们有一个点 P = (x, y),齐次坐标下就是 (x, y, 1)。
4.3.1 平移矩阵
把点移动 (tx, ty):
| 1 0 tx | | x | | x + tx |
| 0 1 ty | * | y | = | y + ty |
| 0 0 1 | | 1 | | 1 |
嗯,很简单。左上角是单位矩阵,右下角是1,平移量放在第三列。
4.3.2 旋转矩阵
绕原点逆时针旋转 θ 角度:
| cosθ -sinθ 0 | | x | | x*cosθ - y*sinθ |
| sinθ cosθ 0 | * | y | = | x*sinθ + y*cosθ |
| 0 0 1 | | 1 | | 1 |
这里要注意,顺时针旋转的话,把 sinθ 的符号反过来就行。
我曾经踩过的坑:在 Canvas 和 SVG 里,Y轴是向下的。所以「逆时针」在屏幕上的表现,和数学课本里是反的。做UI的时候,一定要确认坐标系方向。
4.3.3 缩放矩阵
沿X轴缩放 sx 倍,沿Y轴缩放 sy 倍:
| sx 0 0 | | x | | x * sx |
| 0 sy 0 | * | y | = | y * sy |
| 0 0 1 | | 1 | | 1 |
如果 sx = sy,就是等比缩放。否则就是拉伸。
4.4 矩阵的组合:从左到右,还是从右到左?
这是初学者最容易搞混的地方。
假设我想先平移,再旋转。对应的矩阵是 T 和 R。那么复合矩阵 M 是 R * T 还是 T * R?
答案是:M = R * T。
为什么?因为矩阵乘法不满足交换律。你先做的变换,矩阵要放在右边。
我个人的记忆方法是:从右往左读。最右边的矩阵最先作用于点。
// 先平移,再旋转
M = R * T
// 应用时:P' = M * P = R * (T * P)
// 先算 T*P(平移),再算 R*(结果)(旋转)
实战经验:我在做手势识别时,需要把触摸点的坐标从屏幕空间变换到视图空间。顺序是:先缩放(适配分辨率),再平移(适配偏移),最后旋转(适配方向)。矩阵组合时,我总在纸上先写一遍顺序,再敲代码。省得 debug 到怀疑人生。
4.5 逆变换:怎么变回去?
有变换就有逆变换。平移的逆是反向平移,旋转的逆是反向旋转,缩放的逆是倒数缩放。
在矩阵里,逆变换就是求逆矩阵。
// 平移矩阵的逆
| 1 0 -tx |
| 0 1 -ty |
| 0 0 1 |
// 旋转矩阵的逆(其实就是转置,因为旋转矩阵是正交矩阵)
| cosθ sinθ 0 |
| -sinθ cosθ 0 |
| 0 0 1 |
// 缩放矩阵的逆
| 1/sx 0 0 |
| 0 1/sy 0 |
| 0 0 1 |
为什么要学逆变换?因为你在做「取消操作」或者「坐标反算」时,一定会用到。
4.6 用 SVG 画一张变换流程图
下面这张图,展示了从原始坐标到最终坐标的完整变换链路。
4.7 代码示例:用矩阵实现变换组合
下面是一个简单的 JavaScript 实现。我用数组表示3x3矩阵,用函数生成每种变换的矩阵,然后演示如何组合。
// 矩阵乘法:3x3 矩阵相乘
function multiplyMatrices(a, b) {
let result = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0]
];
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
result[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
}
}
return result;
}
// 平移矩阵
function translationMatrix(tx, ty) {
return [
[1, 0, tx],
[0, 1, ty],
[0, 0, 1]
];
}
// 旋转矩阵(角度制)
function rotationMatrix(angleDeg) {
let rad = angleDeg * Math.PI / 180;
let cos = Math.cos(rad);
let sin = Math.sin(rad);
return [
[cos, -sin, 0],
[sin, cos, 0],
[0, 0, 1]
];
}
// 缩放矩阵
function scaleMatrix(sx, sy) {
return [
[sx, 0, 0],
[0, sy, 0],
[0, 0, 1]
];
}
// 组合:先平移(10,20),再旋转45度,最后缩放2倍
let T = translationMatrix(10, 20);
let R = rotationMatrix(45);
let S = scaleMatrix(2, 2);
// 注意顺序:从右往左
let M = multiplyMatrices(S, multiplyMatrices(R, T));
// 应用变换到点 (5, 5)
let point = [5, 5, 1];
let result = [
M[0][0] * point[0] + M[0][1] * point[1] + M[0][2] * point[2],
M[1][0] * point[0] + M[1][1] * point[1] + M[1][2] * point[2],
1
];
console.log(`变换后的坐标: (${result[0].toFixed(2)}, ${result[1].toFixed(2)})`);
// 输出: 变换后的坐标: (-7.07, 24.14)
一个小技巧:在实际项目中,我不会每次都手动算矩阵乘法。我会用 gl-matrix 或者 DOMMatrix 这样的库。但理解原理很重要——至少 debug 时你知道问题出在哪。
4.8 总结一下
2D坐标变换用矩阵,说白了就是三个字:统一、组合、高效。
- 统一:平移、旋转、缩放都用3x3矩阵表示
- 组合:多个变换通过矩阵乘法合并成一个
- 高效:所有点乘以同一个矩阵,一次计算到位
嗯,这就是矩阵的魅力。它把一堆零散的数学运算,变成了一套优雅的代数系统。
下次你写 Canvas 或者 SVG 的变换代码时,不妨想想背后的矩阵。你会发现,那些 API 的调用顺序,其实就是在组装矩阵。
一句话记住:变换的顺序 = 矩阵乘法的顺序,从右往左读。
可可精品资料库