2D 旋转矩阵是怎么来的,顺便把变换顺序这事讲明白
最近在用 OpenLayers 做地图可视化,遇到一个需求:矩形要支持像截屏软件那样的拖拽操作——鼠标按住右下角的点拖,矩形跟着变形,但左上角那个点固定不动。
没旋转的时候很简单,直接 scale 就行。麻烦的是矩形被转过角度之后再拖拽缩放:这时候横向和纵向该放大多少倍,已经不是简单的比例关系了。
我最早的方案是:先算出对角线和水平线的夹角 α1,再用固定的旋转角 α2 减出对角线和矩形长边的夹角 α3,靠这个 α3 和对角线长度反推出旋转前的两条边长,最后再把坐标转回去。能用,但中间转换太多,精度一路掉,而且这套算法只认"左上角固定"这一种场景,换个固定点就得重推一遍。
后来发现,这些弯弯绕绕本质上就是一个旋转矩阵的事,所以干脆把旋转矩阵从头理一遍。
先说结论
平面上一个点 (x, y),绕原点逆时针转 θ,转完变成 (x', y'):
x' = x cosθ - y sinθ
y' = x sinθ + y cosθ
写成矩阵:
[ x' ] [ cosθ -sinθ ] [ x ]
[ y' ] = [ sinθ cosθ ] [ y ]
也就是:
R(θ) = [ cosθ -sinθ ]
[ sinθ cosθ ]
下面推一下这东西怎么来的,用到的是高中学过的极坐标和三角函数和差公式(说实话我也是重新翻书才想起来)。
1. 从极坐标推
把点 P(x, y) 换成极坐标:r 是到原点的距离,α 是跟 X 轴正方向的夹角。
x = r cosα
y = r sinα
旋转在极坐标下特别直观:r 不变,角度直接加上 θ。转完的点:
x' = r cos(α + θ)
y' = r sin(α + θ)
把和角公式展开:
cos(α + θ) = cosα cosθ - sinα sinθ
sin(α + θ) = sinα cosθ + cosα sinθ
代进去,因为 x = r cosα、y = r sinα,所以:
x' = r cosα cosθ - r sinα sinθ = x cosθ - y sinθ
y' = r sinα cosθ + r cosα sinθ = x sinθ + y cosθ
旋转公式就这么出来了。注意这里默认是数学坐标系(Y 轴向上)绕原点逆时针转,如果是屏幕坐标系(Y 轴向下),视觉上的转向会反过来。
2. 整理成矩阵
矩阵乘法本质就是线性组合。一个 2x2 矩阵乘上点:
[ x' ] [ a b ] [ x ]
[ y' ] = [ c d ] [ y ]
展开是 x' = ax + by,y' = cx + dy。对照刚才推出来的:
x' = x cosθ - y sinθ
y' = x sinθ + y cosθ
直接就能读出 a = cosθ,b = -sinθ,c = sinθ,d = cosθ。所以矩阵长这样不是巧合,是 cos(α+θ) 和 sin(α+θ) 展开后自然长出来的。
换个角度看也行:把 X 轴单位向量 [1, 0] 转 θ 变成 [cosθ, sinθ],Y 轴单位向量 [0, 1] 转 θ 变成 [-sinθ, cosθ],这两个转完的基向量拼成矩阵的两列,结果一样。矩阵其实就是在说:X 轴和 Y 轴各自被转了 θ。
到这里,开头那个矩形拖拽的问题其实已经解决了:左上角固定点、对角点坐标、旋转角 α1 都是已知的,用旋转矩阵把对角点转回水平位置,此时两个点坐标一减就是矩形没转之前的宽高,缩放完再用旋转矩阵转回去,另外两个点坐标也就顺带算出来了。全程只有一次旋转矩阵的正转和反转,不用再算什么 α2、α3,精度也不会一路损耗。
如果只是想解决旋转后的矩形拖拽,看到这里就够用了,下面是我自己顺手往下扒的东西。
3. 为什么工程里用 3x3
刚才的 2x2 矩阵只能表达绕原点转,但实际业务里还要平移:x' = x + tx。2x2 矩阵乘法根本没地方塞这个常数项。
解决办法是加一维,把点 (x, y) 写成 (x, y, 1),旋转矩阵扩成 3x3:
R(θ) = [ cosθ -sinθ 0 ]
[ sinθ cosθ 0 ]
[ 0 0 1 ]
平移矩阵:
T(tx, ty) = [ 1 0 tx ]
[ 0 1 ty ]
[ 0 0 1 ]
这样旋转和平移就能塞进同一套矩阵乘法里连乘了,Canvas、SVG、CSS transform 用的都是这套。(其实这里有个齐次矩阵的概念,感兴趣的可以去研究一下)。
4. 绕任意点旋转
实际场景基本都不是绕原点转,而是图形绕自己中心转、白板元素绕选区中心转。思路很简单:先把旋转中心搬到原点,转完再搬回去。
M = T(cx, cy) · R(θ) · T(-cx, -cy)
矩阵乘法是从右往左作用在点上的,所以实际顺序是先 T(-cx, -cy),再 R(θ),再 T(cx, cy)。展开后:
M = [ cosθ -sinθ cx(1 - cosθ) + cy sinθ ]
[ sinθ cosθ cy(1 - cosθ) - cx sinθ ]
[ 0 0 1 ]
看着复杂,其实就是多了两次平移而已。
5. 旋转、缩放、平移怎么组合
这块我项目里没实际用到,纯粹是自己好奇顺手推的,感兴趣可以看看,不感兴趣可以直接跳过。
工程里对象一般拆成 position / rotation / scale 三个属性,合成一个矩阵最常见的写法是:
M = T(tx, ty) · R(θ) · S(sx, sy)
矩阵从右往左作用,也就是点先被缩放,再被旋转,最后被平移。这个顺序不是随便定的,是因为 R · S ≠ S · R(缩放不是等比的话),谁在前谁在后,效果是不一样的。
比如同样是"旋转 + 平移",如果平移是跟着物体朝向走的局部偏移,就得写成 M = R · T(local);如果平移是不管物体朝向、永远沿页面方向走的世界偏移,就得写成 M = T(world) · R。位置换一下,结果就完全不同。
所以这块没什么万能公式可以直接套,遇到具体需求时,把动作拆成"先发生什么、再发生什么",然后照着顺序把矩阵一个个乘起来就行——顺序错了效果就是错的,这个只能靠当场推,背是背不出来的。
其实这里最完整的是必须让中心点是原点进行缩放和旋转
M = T(cx, cy) · T(tx, ty) · R(θ) · S(sx, sy) · T(-cx, -cy)
6. 落地成代码
绕原点转一个点:
function rotatePointAroundOrigin(x, y, theta) {
const cos = Math.cos(theta);
const sin = Math.sin(theta);
return {
x: x * cos - y * sin,
y: x * sin + y * cos,
};
}
绕任意中心点转:
function rotatePointAroundCenter(x, y, cx, cy, theta) {
const translatedX = x - cx;
const translatedY = y - cy;
const rotated = rotatePointAroundOrigin(translatedX, translatedY, theta);
return {
x: rotated.x + cx,
y: rotated.y + cy,
};
}
直接构造矩阵也行:
function createRotationAroundCenterMatrix(cx, cy, theta) {
const cos = Math.cos(theta);
const sin = Math.sin(theta);
return [
[cos, -sin, cx * (1 - cos) + cy * sin],
[sin, cos, cy * (1 - cos) - cx * sin],
[0, 0, 1],
];
}
function applyMatrixToPoint(matrix, x, y) {
return {
x: matrix[0][0] * x + matrix[0][1] * y + matrix[0][2],
y: matrix[1][0] * x + matrix[1][1] * y + matrix[1][2],
};
}
const matrix = createRotationAroundCenterMatrix(100, 100, Math.PI / 6);
const point = applyMatrixToPoint(matrix, 150, 100);
console.log(point);
7. 两个容易踩的坑
角度和弧度别搞混。 Math.sin、Math.cos 吃的是弧度:
const degree = 30;
const theta = (degree * Math.PI) / 180;
坐标系方向会影响视觉结果。 数学坐标系 Y 轴向上,屏幕坐标系 Y 轴向下,同一个公式在两种坐标系里视觉上的顺逆时针可能是反的。判断转向之前先确认自己在哪套坐标系里,ol是左下角原点,canvas是右上角。
总结
2D 旋转矩阵长这样:
R(θ) = [ cosθ -sinθ ]
[ sinθ cosθ ]
推导链路是:点看成极坐标 → 转动时半径不变、角度加 θ → 和角公式展开 → 整理成矩阵。工程里再加一维塞进平移,绕任意点转就是"搬到原点转完再搬回去",我开头矩形拖拽的问题也是靠这一步解决的。
至于旋转缩放平移怎么组合,没什么万能公式,动作拆开、顺序理清楚,矩阵按顺序乘就行——这块纯当扩展知识了解一下就行,实际项目按需再查也不迟,后期可以分享记录一下threejs中关于3d下的旋转缩放。