09 二维旋转矩阵与绕任意点旋转

前置知识:第 3 章的局部坐标与 UV、第 7 章的比例修正。
本章示例:旋转箭头,完整源码。

学完本章能做什么

你将能写出二维旋转矩阵,判断角度正负、矩阵乘法顺序,并让图案绕指定点旋转。更重要的是,你会区分“旋转几何位置”和“反向旋转用于查询图案的坐标”。

先看现象

示例中,箭头原本朝右。调整角度,它绕蓝色圆点旋转;拖动圆点位置,旋转中心也随之改变。画面上的十字线固定在平面中心,可用来判断旋转中心是否真的移动。

1. 从单位圆得到旋转矩阵

逆时针旋转角度 θ 后,原来的 x 轴单位向量 (1, 0) 变成 (cos θ, sin θ),y 轴单位向量 (0, 1) 变成 (-sin θ, cos θ)。把这两个结果分别作为矩阵的两列:

R(θ) = | cos θ  -sin θ |
       | sin θ   cos θ |

对点 (x, y),结果是 (x cos θ - y sin θ, x sin θ + y cos θ)。长度和夹角保持不变;旋转矩阵不会把圆拉成椭圆。GLSL 的 mat2 构造参数按列排列,因此写成:

// 顶点或片元阶段;二维坐标所属空间由传入的 point 决定。
mat2 rotate2D( float angle ) {
  float c = cos( angle );
  float s = sin( angle );
  return mat2( c, s, -s, c );
}

rotate2D(angle) * vec2(1.0, 0.0) 得到 (cos(angle), sin(angle))。若误写成 mat2(c, -s, s, c),得到的是相反方向。GLSL 三角函数接收弧度:180° = π,90° = π/2。JavaScript 控件用度数时,可先乘 Math.PI / 180,再传入 uAngle。

2. 绕任意点:先平移,再旋转,最后移回

设旋转中心为 pivot,原点为 point。先求相对向量 point - pivot,旋转这个向量,再加回中心:

// 顶点阶段;point 和 pivot 必须处于同一个坐标空间。
vec2 rotated = pivot + rotate2D( uAngle ) * ( point - pivot );

代入 point == pivot,可得 rotated == pivot,因此中心不会移动。若把 pivot 加到旋转之前,中心本身也会被旋转,效果会变成绕别处公转。两个点必须使用相同单位:不能把 UV 的 (0.5, 0.5) 当作局部坐标中的 (0.5, 0.5)。

若确实要旋转网格顶点,可以在顶点着色器修改局部 position.xy,再用 projectionMatrix * modelViewMatrix 投影。此时网格边界、插值 UV 和后续光照都会跟着几何体变化。

3. 片元绘图为什么使用负角度

片元着色器不是把已经画好的像素推到新位置;它逐像素回答“此处应该是什么颜色”。假设未旋转的图案由 shape(localPoint) 定义,屏幕点 point 对应的旧图案坐标应满足:

point = pivot + R(θ) × localPoint
localPoint = R(-θ) × (point - pivot)

所以,要让图案看起来逆时针旋转 θ,就用负角度查询原图案:

// 片元阶段;point、pivot 均为按目标平面比例修正后的二维坐标。
vec2 localPoint = rotate2D( -uAngle ) * ( point - pivot );
float mask = arrowMask( localPoint );

示例中的箭头先定义为朝 +x,然后对每个片元反查 localPoint。例如目标像素恰好在旋转后的箭头尖端,反向旋转会把它带回未旋转时的尖端位置,于是 arrowMask 返回 1。把这里的 -uAngle 改为 uAngle,图案会朝控件所示角度的反方向旋转。

这也是纹理变形的常见思路:变换采样坐标,然后用该坐标读取固定图案或纹理。第 11 章会再次使用它。

4. 在非正方形平面上先统一长度单位

示例平面宽高比为 A = 宽 / 高。先把 UV 中心移到原点,并把 x 乘 A:

// 片元阶段;point 和 pivot 都以“平面高度”为单位。
vec2 point = ( vUv - 0.5 ) * vec2( uAspect, 1.0 );
vec2 pivot = uPivot; // JavaScript 已按相同规则提供该值。
vec2 localPoint = rotate2D( -uAngle ) * ( point - pivot );

若直接旋转原始 UV,在宽平面上 x、y 对应的屏幕长度不同。矩阵虽然在 UV 数值上保长,屏幕中的圆和旋转路径仍会拉伸。顺序应为:修正比例 → 平移到旋转中心 → 旋转 → 查询图案。如果需要回到 UV,则对 x 除以 A,再加 0.5。

画布宽高比、平面宽高比和图片原始宽高比是三个概念。这里使用的是正对相机的目标平面宽高比。若网格倾斜、受透视投影影响,“屏幕上的圆”还要在屏幕空间另行处理。

5. 可交换顺序吗?

通常不能。先旋转再平移的 R(point) + offset,与先平移再旋转的 R(point + offset) 不同;后者连 offset 一起转。两个不同中心的旋转也通常不能任意交换顺序。调试时把中心画成单独的颜色,并检查角度为 0、π/2 和 π 的位置,最容易发现顺序错误。

常见问题

本章小结

二维旋转矩阵保留长度;绕点旋转用 pivot + R(angle) * (point - pivot)。画顶点时通常正向移动点,画片元图案时则用 R(-angle) 反查图案。比例修正必须发生在旋转之前。

本章练习