明明是三维,为什么用 4x4 矩阵
一句话总结
平移是加法,不是乘法,因此无法放入 3x3 矩阵。把坐标扩展成 (x, y, z, 1),平移也能表示为一次乘法,所以图形变换矩阵是 4x4。
为什么需要它
把一个物体放到屏幕上,需要连续缩放、旋转、移动到指定位置、转换到相机坐标,再进行投影。若把它们写成一连串函数调用,每个顶点要执行五种计算;十万个顶点就是五十万次。
如果这些计算都能表示为一次矩阵乘法,情况就不同了。矩阵乘法满足结合律,可以预先把五个矩阵合成一个,每个顶点只需做一次 4x4 乘法。这就是 GPU 只通过 uniform 接收一个 MVP 矩阵的原因。问题在于平移不是乘法:3x3 矩阵可以旋转与缩放,却无法表达平移。
如何工作
解决办法是增加一个维度。把点 (x, y, z) 写成 (x, y, z, 1) 并乘以 4x4 矩阵,最后一列会乘以 1 再加到结果中。把位移放入这一列,平移就进入了乘法。
| 1 0 0 tx | | x | | x + tx |
| 0 1 0 ty | * | y | = | y + ty |
| 0 0 1 tz | | z | | z + tz |
| 0 0 0 1 | | 1 | | 1 |
这种表示称为齐次坐标(homogeneous coordinates)。第四个分量 w 并非占位符:w 为 1 表示点,为 0 表示方向。方向不应受平移影响,北方无论移动到哪里仍是北方;设为 w=0 后,平移列乘以 0 会被自动忽略。因此,法线向量与光线方向会表示为 (x, y, z, 0)。
乘法顺序会改变结果。 矩阵乘法不满足交换律。T * R 表示“先旋转再平移”,R * T 表示“先平移再旋转”。采用列向量右乘的约定时,右侧矩阵先应用。 对点 (1,0,0) 先绕 z 轴旋转 90 度再沿 x 轴移动 2,前者得到 (2,1,0),后者得到 (0,3,0)。物体是在原地自转还是绕轨道公转,就取决于这里。
旋转矩阵还涉及手性。在右手坐标系中,绕 z 轴旋转会把 x 轴转向 y 轴。
rotate_z(t) = | cos t -sin t 0 0 |
| sin t cos t 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |
只写错一个符号,物体就会反向旋转,而且不会产生任何错误消息。
还存在行优先与列优先的约定差异。本实验把矩阵保存为“四个长度为 4 的列表”,点放在右侧相乘。相反,DirectX 系列文档与代码常把点放在左侧作为行向量,此时同一变换的矩阵看起来是转置的,两种约定中的乘法顺序也相反。移植他人代码后结果异常时,应首先怀疑这一点。内存中的存储顺序又是另一个维度,因此实际可以组合出四种约定。
旋转也不只能用矩阵表示。四元数用四个值保存旋转,而且比矩阵更适合在两个旋转之间平滑插值,因为逐元素混合两个矩阵,结果通常不再是旋转矩阵。但传给 GPU 时最终仍要转换为矩阵,因此常见结构是用四元数保存状态,绘制时再构造矩阵。
在实际项目中
在着色器中变换法线时,直接使用模型矩阵会导致照明错误。加入非均匀缩放(例如只把 x 缩小一半)后,原本垂直于表面的向量不再垂直。正确做法是使用模型矩阵逆矩阵的转置;只有亲手操作过矩阵,才容易真正理解这一事实。
另一个问题是浮点误差累积。每帧都把小旋转乘到当前旋转矩阵上,几百帧后正交性会遭破坏,物体逐渐变形。因此,引擎通常用角度或四元数保存状态,并每帧重新构造矩阵,而不把矩阵本身当状态。
还经常忽略逆矩阵的成本。通用 4x4 逆矩阵计算昂贵,但图形学中的大多数变换只有旋转与平移,可以更便宜地求逆。旋转部分是正交矩阵,转置就是逆矩阵;平移取反后再应用逆旋转即可。构造相机矩阵时正是利用了这一性质:前面看到的视图矩阵把相机三条轴作为行,最后一列放入负点积。
最后还要培养选择向量运算的直觉。点积用于角度与投影,叉积用于垂直方向与面积。想知道两个向量是否朝向同侧,看点积符号;想知道朝哪一侧旋转,看叉积符号。这两句话能解释相当一部分图形代码。
下一实验要做什么
亲手实现三维向量运算与 4x4 矩阵乘法,组装平移、缩放与旋转矩阵。通过数字比较乘法顺序变化带来的结果,再把三个变换后的四边形画到同一张 PNG 上。最后用叉积求三角形法线与面积。