本节摘要:三维旋转要绕三根轴分别处理,欧拉角直观却有万向节锁隐患,四元数补上这块短板。本节先过三维变换的基本功,再推导观察(look-at)矩阵:用相机的位置、目标点、向上方向建立正交基,把世界"搬到"相机位于原点、看向负 z 的标准姿态。它是 1.2 节齐次坐标机器的三维首演,直接通向 4.3 的投影。
阅读完本节,你应当能够:

二维旋转绕一个点,三维旋转绕一根轴。绕三根坐标轴的旋转矩阵是所有旋转的积木:
绕x轴 Rx: 绕y轴 Ry: 绕z轴 Rz: 1 0 0 cos 0 sin cos -sin 0 0 cos -sin 0 1 0 sin cos 0 0 sin cos -sin 0 cos 0 0 1
任意朝向可以用三次基本旋转组合出来,这就是欧拉角(偏航 yaw、俯仰 pitch、滚转 roll),飞行与相机控制里最直观的表示。但它有个著名缺陷:第二次旋转 90 度时,第一次与第三次旋转轴重合,一个自由度凭空消失——万向节锁。瞄准正上方时相机突然"翻滚失控",就是这个坑在起作用。
四元数用四个数 (x, y, z, w) 表示旋转,绕过欧拉角的奇异性,还能在两个朝向之间走球面最短路径插值(slerp),动画中物体转动平滑自然。代价是不直观,工程上习惯"用欧拉角存与调,用四元数算与插":
import numpy as np def quat_from_axis_angle(axis, deg): # 轴角转四元数:绕单位轴旋转 deg 度 r = np.radians(deg) / 2 ax = np.array(axis, dtype=float); ax /= np.linalg.norm(ax) return np.concatenate([ax*np.sin(r), [np.cos(r)]]) def quat_mul(q1, q2): # 四元数乘法:组合两次旋转 x1,y1,z1,w1 = q1; x2,y2,z2,w2 = q2 return np.array([w1*x2+x1*w2+y1*z2-z1*y2, w1*y2-x1*z2+y1*w2+z1*x2, w1*z2+x1*y2-y1*x2+z1*w2, w1*w2-x1*x2-y1*y2-z1*z2]) q1 = quat_from_axis_angle((0,0,1), 90) # 先绕z转90度 q2 = quat_from_axis_angle((1,0,0), 90) # 再绕x转90度 print(np.round(quat_mul(q2, q1), 3)) # 组合旋转(右边的先作用) # 输出: [ 0.5 0.5 0.5 0.5] —— 一个等价的单位四元数,无奇异风险
投影公式都假设相机位于原点、看向某个标准方向。真实相机在场景里任意位置任意朝向,处理办法不是把投影公式复杂化,而是反向操作整个世界:给所有顶点乘一个观察矩阵,让相机在新的坐标系里恰好落在原点、看向负 z——世界动了,相对关系没动,画面等效。
构造观察矩阵只需要三样东西:相机位置 eye、看向哪里 target、哪边算上 up。步骤是建立相机的正交基:
def look_at(eye, target, up): # 构造观察矩阵:世界坐标 → 相机坐标 eye, target, up = map(np.array, (eye, target, up), ) f = target - eye # 前向 forward f = f / np.linalg.norm(f) r = np.cross(f, up) # 右向 right = forward × up r = r / np.linalg.norm(r) u = np.cross(r, f) # 修正后的上方向,保证正交 M = np.eye(4) M[0,:3], M[1,:3], M[2,:3] = r, u, -f # 旋转部分:基向量做行 M[0,3] = -r @ eye # 平移部分:把相机搬回原点 M[1,3] = -u @ eye M[2,3] = f @ eye return M V = look_at((0,0,5), (0,0,0), (0,1,0)) # 相机在 z=5 看向原点 p = np.array([0.0, 0.0, 0.0, 1.0]) # 世界原点处的一个顶点 print(np.round(V @ p, 3)) # 输出: [ 0. 0. -5. 1.] 顶点来到相机前方 5 单位(负z方向),符合"看向负z"约定
值得停下来体会一个细节:观察矩阵的旋转部分是基向量的转置摆放(做行不做列),平移部分还带负号——因为它本质是相机自身模型矩阵的逆矩阵。把相机当普通物体用模型矩阵摆进世界,再取逆,就得到观察矩阵;上面代码就是直接构造了这个逆。MVP 三连乘里的 V,来路原来如此。
再验一个斜向的例子,确认 up 的作用:
V2 = look_at((3,3,3), (0,0,0), (0,1,0)) print(np.round(V2 @ np.array([3.0,3.0,3.0,1.0]), 3)) # 输出: [ 0. 0. -5.196 1.] 相机自身点被映射到原点正后方位(距离 √27 ≈ 5.196)
⚠️ 常见坑:up 向量若与 forward 平行(相机垂直仰望正上方还把 up 设成世界 y 轴),叉积退化为零向量,基向量构建崩溃。实现里要检测并换一个备用 up。
至此可以完整串起顶点的旅程前半程:模型矩阵(1.2 节)把顶点从模型空间搬进世界空间,观察矩阵把世界搬进相机空间。两步合成一个矩阵,顶点只需乘一次。第 4.3 节在相机空间之后接上投影矩阵,三维世界即将被压平。
世界已站上相机脚下,4.3 节做那件最像魔法的事:除以 w,三维压成二维。