一文推导 2D 旋转矩阵:从三角函数到工程实践

2D 旋转矩阵是怎么来的,顺便把变换顺序这事讲明白

最近在用 OpenLayers 做地图可视化,遇到一个需求:矩形要支持像截屏软件那样的拖拽操作------鼠标按住右下角的点拖,矩形跟着变形,但左上角那个点固定不动。

没旋转的时候很简单,直接 scale 就行。麻烦的是矩形被转过角度之后再拖拽缩放:这时候横向和纵向该放大多少倍,已经不是简单的比例关系了。

我最早的方案是:先算出对角线和水平线的夹角 α1,再用固定的旋转角 α2 减出对角线和矩形长边的夹角 α3,靠这个 α3 和对角线长度反推出旋转前的两条边长,最后再把坐标转回去。能用,但中间转换太多,精度一路掉,而且这套算法只认"左上角固定"这一种场景,换个固定点就得重推一遍。

后来发现,这些弯弯绕绕本质上就是一个旋转矩阵的事,所以干脆把旋转矩阵从头理一遍。

先说结论

平面上一个点 (x, y),绕原点逆时针转 θ,转完变成 (x', y')

arduino 复制代码
x' = x cosθ - y sinθ
y' = x sinθ + y cosθ

写成矩阵:

ini 复制代码
[ x' ]   [ cosθ  -sinθ ] [ x ]
[ y' ] = [ sinθ   cosθ ] [ y ]

也就是:

arduino 复制代码
R(θ) = [ cosθ  -sinθ ]
       [ sinθ   cosθ ]

下面推一下这东西怎么来的,用到的是高中学过的极坐标和三角函数和差公式(说实话我也是重新翻书才想起来)。

1. 从极坐标推

把点 P(x, y) 换成极坐标:r 是到原点的距离,α 是跟 X 轴正方向的夹角。

ini 复制代码
x = r cosα
y = r sinα

旋转在极坐标下特别直观:r 不变,角度直接加上 θ。转完的点:

scss 复制代码
x' = r cos(α + θ)
y' = r sin(α + θ)

把和角公式展开:

arduino 复制代码
cos(α + θ) = cosα cosθ - sinα sinθ
sin(α + θ) = sinα cosθ + cosα sinθ

代进去,因为 x = r cosαy = r sinα,所以:

arduino 复制代码
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 矩阵乘上点:

ini 复制代码
[ x' ]   [ a  b ] [ x ]
[ y' ] = [ c  d ] [ y ]

展开是 x' = ax + byy' = cx + dy。对照刚才推出来的:

arduino 复制代码
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:

scss 复制代码
R(θ) = [ cosθ  -sinθ   0 ]
       [ sinθ   cosθ   0 ]
       [   0      0    1 ]

平移矩阵:

scss 复制代码
T(tx, ty) = [ 1  0  tx ]
            [ 0  1  ty ]
            [ 0  0   1 ]

这样旋转和平移就能塞进同一套矩阵乘法里连乘了,Canvas、SVG、CSS transform 用的都是这套。(其实这里有个齐次矩阵的概念,感兴趣的可以去研究一下)。

4. 绕任意点旋转

实际场景基本都不是绕原点转,而是图形绕自己中心转、白板元素绕选区中心转。思路很简单:先把旋转中心搬到原点,转完再搬回去。

scss 复制代码
M = T(cx, cy) · R(θ) · T(-cx, -cy)

矩阵乘法是从右往左作用在点上的,所以实际顺序是先 T(-cx, -cy),再 R(θ),再 T(cx, cy)。展开后:

arduino 复制代码
M = [ cosθ  -sinθ  cx(1 - cosθ) + cy sinθ ]
    [ sinθ   cosθ  cy(1 - cosθ) - cx sinθ ]
    [   0      0              1           ]

看着复杂,其实就是多了两次平移而已。

5. 旋转、缩放、平移怎么组合

这块我项目里没实际用到,纯粹是自己好奇顺手推的,感兴趣可以看看,不感兴趣可以直接跳过。

工程里对象一般拆成 position / rotation / scale 三个属性,合成一个矩阵最常见的写法是:

scss 复制代码
M = T(tx, ty) · R(θ) · S(sx, sy)

矩阵从右往左作用,也就是点先被缩放,再被旋转,最后被平移。这个顺序不是随便定的,是因为 R · S ≠ S · R(缩放不是等比的话),谁在前谁在后,效果是不一样的。

比如同样是"旋转 + 平移",如果平移是跟着物体朝向走的局部偏移,就得写成 M = R · T(local);如果平移是不管物体朝向、永远沿页面方向走的世界偏移,就得写成 M = T(world) · R。位置换一下,结果就完全不同。

所以这块没什么万能公式可以直接套,遇到具体需求时,把动作拆成"先发生什么、再发生什么",然后照着顺序把矩阵一个个乘起来就行------顺序错了效果就是错的,这个只能靠当场推,背是背不出来的。

其实这里最完整的是必须让中心点是原点进行缩放和旋转

scss 复制代码
M = T(cx, cy) · T(tx, ty) · R(θ) · S(sx, sy) · T(-cx, -cy)

6. 落地成代码

绕原点转一个点:

arduino 复制代码
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,
  };
}

绕任意中心点转:

ini 复制代码
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,
  };
}

直接构造矩阵也行:

ini 复制代码
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.sinMath.cos 吃的是弧度:

ini 复制代码
const degree = 30;
const theta = (degree * Math.PI) / 180;

坐标系方向会影响视觉结果。 数学坐标系 Y 轴向上,屏幕坐标系 Y 轴向下,同一个公式在两种坐标系里视觉上的顺逆时针可能是反的。判断转向之前先确认自己在哪套坐标系里,ol是左下角原点,canvas是右上角。

总结

2D 旋转矩阵长这样:

arduino 复制代码
R(θ) = [ cosθ  -sinθ ]
       [ sinθ   cosθ ]

推导链路是:点看成极坐标 → 转动时半径不变、角度加 θ → 和角公式展开 → 整理成矩阵。工程里再加一维塞进平移,绕任意点转就是"搬到原点转完再搬回去",我开头矩形拖拽的问题也是靠这一步解决的。

至于旋转缩放平移怎么组合,没什么万能公式,动作拆开、顺序理清楚,矩阵按顺序乘就行------这块纯当扩展知识了解一下就行,实际项目按需再查也不迟,后期可以分享记录一下threejs中关于3d下的旋转缩放。

相关推荐
SmalBox19 小时前
【节点】[ComputeVertexPosition节点]原理解析与实际应用
unity3d·游戏开发·图形学
山河木马2 天前
GPU自动处理专题3-NDC怎么映射成屏幕像素坐标(视口变换)
javascript·webgl·图形学
SmalBox2 天前
【节点】[ScleraUVLocation节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox3 天前
【节点】[ScleraLimbalRing节点]原理解析与实际应用
unity3d·游戏开发·图形学
花褪残红青杏小4 天前
Rust图像处理第15节-鱼眼广角畸变:非线性几何变换
rust·webassembly·图形学
SmalBox4 天前
【节点】[ScleraIrisBlend节点]原理解析与实际应用
unity3d·游戏开发·图形学
花褪残红青杏小5 天前
Rust图像处理第14节-图片缩放与斜切畸变:齐次坐标 + 共享仿射变换
rust·webassembly·图形学
花褪残红青杏小5 天前
Rust图像处理第13节-任意角度旋转:旋转矩阵 + 双线性插值 + 自动补白边
rust·webassembly·图形学
SmalBox5 天前
【节点】[IrisUVLocation节点]原理解析与实际应用
unity3d·游戏开发·图形学