18 · Matrix3:组合变换与乘法顺序

返回系列目录 · 上一章 · 下一章

本章代码默认 import * as THREE from 'three';。我们使用列向量,因此 A B p 表示先对 p 应用 B,再应用 A。代码从左到右写出的矩阵,与对点执行的顺序恰好相反;原因是函数组合的嵌套关系 A(B(p))。

从一个可手算的点开始

让点 (1,1) 沿 X 放大两倍,再逆时针旋转 90°:先得到 (2,1),再得到 (-1,2)。反过来,先旋转得到 (-1,1),再缩放得到 (-2,1)。两个结果不同,因为缩放指定了坐标轴,而旋转改变了点相对于这些轴的关系。

const scale = new THREE.Matrix3().makeScale(2, 1);
const rotate = new THREE.Matrix3().makeRotation(Math.PI / 2);
const rotateAfterScale = new THREE.Matrix3().multiplyMatrices(rotate, scale);
const scaleAfterRotate = new THREE.Matrix3().multiplyMatrices(scale, rotate);
console.log(new THREE.Vector2(1, 1).applyMatrix3(rotateAfterScale).toArray()); // ≈ [-1, 2]
console.log(new THREE.Vector2(1, 1).applyMatrix3(scaleAfterRotate).toArray()); // ≈ [-2, 1]

这里 Vector2 的 applyMatrix3() 隐含输入 (x,y,1),适合二维仿射变换。使用 Vector3 时必须自己明确第三分量是空间 Z 还是齐次 1。

multiply(matrix)

参数为 Matrix3;执行 this = this * matrix,修改接收者并返回 this。参数通常不变,但若参数与接收者为同一对象,当然也会看到变化。

const result = rotate.clone().multiply(scale); // R S

结果先缩放后旋转。对于表示局部坐标到外部坐标的变换 M,右乘附加变换通常表示先在输入坐标中操作,再交给 M。这就是很多“绕自身局部轴旋转”操作的来源,但“局部”结论依赖你赋予 M 的坐标空间含义。

数值例子中 (1,1) 得到 (-1,2);不应把 multiply() 误解为逐元素相乘。若要保留 rotate,先 clone() 或写入单独的目标。

premultiply(matrix)

参数为 Matrix3;执行 this = matrix * this,修改接收者并返回 this。

const result = scale.clone().premultiply(rotate); // R S

仍得到 (-1,2)。左乘是在当前变换的输出侧再追加操作。因此对二维图形可以按实际执行顺序依次 premultiply():先缩放、后旋转、最后平移。不要只记“左乘就是世界变换”:如果输出空间是相机空间,左乘作用的就是那个空间。

multiplyMatrices(a, b)

接受两个 Matrix3,执行 this = a * b,返回 this。两个输入在与接收者不相同时都保持不变。每个结果元素由 A 的一行点乘 B 的一列得到:

(AB)ij=∑k=13AikBkj. (AB)_{ij}=\sum_{k=1}^{3} A_{ik}B_{kj}.

例如 diag(2,3,1) * diag(4,5,1) = diag(8,15,1)。这种对角矩阵刚好可以交换,不代表一般矩阵可交换。r185 实现先缓存输入分量,所以 a.multiplyMatrices(a,b) 也可以正确工作;可读性上通常直接用 a.multiply(b)。

multiplyScalar(scalar)

接受一个数字,对九个元素全部乘该数,修改接收者并返回 this。对于三维线性变换,(sM)p = s(Mp);单位矩阵乘 2 后,Vector3 (1,2,3) 变成 (2,4,6)。

它与二维 makeScale(2,2) 不同:后者保持右下角齐次分量为 1,而 multiplyScalar(2) 会将其也变成 2,并缩放平移项。在完整射影数学中,齐次矩阵整体乘非零常数对应相同射影映射,但 Vector2 的 applyMatrix3() 不会做透视除法,不可套用该结论解释其结果。

scalar = 0 会产生零矩阵,不能再求逆;方法不会限制无穷大或 NaN。

实践:围绕房间平面图的指定点旋转

设旋转中心 c=(3,2),我们先把中心搬到原点,旋转,再搬回去:

M=T(c)R(θ)T(−c). M=T(c)R(\theta)T(-c).
const center = new THREE.Vector2(3, 2);
const matrix = new THREE.Matrix3().makeTranslation(center.x, center.y);
matrix.multiply(new THREE.Matrix3().makeRotation(Math.PI / 2));
matrix.multiply(new THREE.Matrix3().makeTranslation(-center.x, -center.y));
const furnitureCorner = new THREE.Vector2(4, 2).applyMatrix3(matrix);
console.log(furnitureCorner.toArray()); // ≈ [3, 3]
console.log(center.clone().applyMatrix3(matrix).toArray()); // ≈ [3, 2]

第二个检查很有价值:旋转中心应该保持不动。真实渲染循环中把这些矩阵预先创建,交互参数改变时重新填充;不要在每个顶点、每帧重复创建三份对象。

练习

  1. 对 (2,0) 比较 T(1,0)R(90°) 与 R(90°)T(1,0),画出两条操作路径。
  2. 只使用 premultiply() 重写围绕指定点旋转的例子。
  3. 将例子扩展为先围绕中心缩放,再围绕同一中心旋转,验证中心仍不动。
  4. 比较单位矩阵 multiplyScalar(2) 与 makeScale(2,2) 的九个元素,并分别作用于 Vector2 和 Vector3。