坐标变换与矩阵
这是图形学面试的「数学硬骨头」,能讲清楚一个顶点从模型到屏幕经历了哪些坐标空间变换,是高级 Web3D 工程师的核心素养。
坐标空间变换链(MVP)
一个顶点从模型定义到最终显示,会依次经过这些坐标空间:
模型空间 (Local/Object Space)
──[ 模型矩阵 Model ]──→ 世界空间 (World Space)
──[ 视图矩阵 View ]───→ 观察/相机空间 (View/Eye Space)
──[ 投影矩阵 Projection ]→ 裁剪空间 (Clip Space)
──[ 透视除法 /w ]────→ 归一化设备坐标 NDC ([-1,1]³)
──[ 视口变换 Viewport ]→ 屏幕空间 (Screen Space,像素)
着色器里那行经典代码就是这个过程:
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
// three 的 ShaderMaterial 中 viewMatrix * modelMatrix 合并为 modelViewMatrix
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
面试题:MVP 矩阵分别做什么?
- M(Model 模型矩阵):把模型从自身局部坐标摆放到世界中的正确位置(平移/旋转/缩放)。
- V(View 视图矩阵):把世界坐标转换到「以相机为原点」的观察空间,本质是相机世界矩阵的逆矩阵。
- P(Projection 投影矩阵):把 3D 观察空间投影到 2D 裁剪空间,决定透视/正交效果。
面试深挖:关于 MVP 矩阵的进阶连环问
1. 为什么一个坐标乘以矩阵就能完成平移、旋转、缩放这些空间变换? 答:矩阵乘法本质上是对空间的线性映射(基底变换)。这在不同矩阵中有不同的直观理解:
对于模型矩阵 (Model Matrix):我们可以把它看作是定义了一个新的世界坐标系。矩阵的前三列是新坐标系的 XYZ 轴方向向量(决定旋转和缩放),第四列是新原点位置(决定平移)。当局部坐标
(x, y, z)乘以它时,实际上是在做向量的线性组合($x \cdot 新X轴 + y \cdot 新Y轴 + z \cdot 新Z轴 + 原点偏移$),从而把点投射到了世界空间。// Three.js 示例:改变物体的 position/rotation/scale,底层会自动更新其 modelMatrix
mesh.position.set(10, 0, 0); // 第四列变了
mesh.scale.set(2, 2, 2); // 前三列的向量长度变了
mesh.updateMatrixWorld(); // 重新计算并应用模型矩阵对于视图矩阵 (View Matrix):这其实是一个逆向操作。相机的世界矩阵定义了相机在世界里的位置和朝向;而视图矩阵是相机世界矩阵的逆矩阵(Inverse Matrix)。它把整个世界“反向”拉拽过来,让相机回到宇宙的原点 $(0,0,0)$,并看向 $-Z$ 轴。换句话说,如果相机向右移动 5 米,视图矩阵的作用就是把世界上的所有物体向左移动 5 米。
// Three.js 示例:移动相机,本质上是重新计算了相机的逆矩阵(View Matrix)
camera.position.set(5, 0, 0); // 相机右移
camera.lookAt(0, 0, 0); // 更新视图矩阵,等价于把整个世界往左推了 5 米对于投影矩阵 (Projection Matrix):它的作用是定义一个视锥体(Frustum),并把这个锥体里的所有点“挤压”成一个 $2 \times 2 \times 2$ 的标准正方体(NDC)。它不仅会对 $x, y$ 进行缩放操作(处理视野角度 FOV 和屏幕宽高比),更关键的是它会把深度的信息塞进齐次坐标的 $w$ 分量里,为后续的透视除法做准备。
进阶探究:这个“视锥体矩阵”到底是个啥?它是怎么算出来的? 它本质上是一个利用三角函数构建的 4x4 矩阵。当你传入参数时,底层主要做了三件事:
- 视野缩放 ($X,Y$ 轴):底层计算了 $\cot(fov/2)$。FOV 越大,算出来的缩放系数越小,意味着进入 NDC 正方体里的东西越变小,从而能在屏幕上塞下更多的景物,这就实现了“广角镜头”效果。
- 深度映射 ($Z$ 轴):利用
near和far把实际的距离按比例映射到 $[-1, 1]$ 之间,用于 GPU 决定谁遮挡谁(深度测试 Z-Buffer)。- 移花接木 ($W$ 分量):矩阵的最后一行非常巧妙,它的参数专门设计成把真实的深度 $z$ 直接赋值给结果的 $w$ 分量,完美喂给 GPU 去做前面讲过的“透视除法”。
// Three.js 示例:配置相机的 fov, aspect, near, far 就是在直接构建投影矩阵
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / innerHeight,
0.1,
1000,
);
camera.updateProjectionMatrix(); // 参数改变后,必须重新“挤压”构建投影矩阵2. 为什么矩阵乘法顺序是 P V M,而不是 M V P? 答:因为在 GLSL 和 WebGL/OpenGL 中,向量通常表示为列向量(Column Vector)。当矩阵与列向量相乘时,数学上的规则是矩阵在左,向量在右(即
Matrix * Vector)。因此,先应用的变换矩阵必须放在最右边靠近向量的位置,即:P * V * M * vec4(position, 1.0)。2. 为什么顶点位置
position需要补一个 1.0 变成vec4?这个 1.0 有什么意义? 答:这就是齐次坐标(Homogeneous Coordinates)。在 3D 空间中,平移变换无法用简单的 3x3 矩阵乘法来实现(3x3 只能做旋转和缩放)。为了用统一的矩阵乘法同时完成平移、旋转和缩放,数学上引入了 4x4 的仿射变换矩阵,所以必须把 3D 坐标(x,y,z)补上一个分量w变成(x,y,z,w)。
- 当 $w = 1$ 时,它代表一个点,会受到平移矩阵的影响。
- 当 $w = 0$ 时,它代表一个方向向量(比如法线、光照方向),平移对方向毫无意义,所以 $w=0$ 会使平移矩阵失效。
4. 什么是透视除法?为什么除以深度值 $w$ 就能产生“近大远小”的效果? 答:投影矩阵(P)执行完后,顶点处于裁剪空间,此时它的 $w$ 分量不再是 1.0,而是存储了顶点在相机空间下的深度值(即距离相机的远近)。 随后,GPU 会自动执行“透视除法”:将坐标的 $x, y, z$ 分别除以 $w$ 分量,得到 $[-1, 1]$ 范围内的标准化设备坐标(NDC)。
背后的几何原理(相似三角形): 想象你在眼睛前面放一块半透明的玻璃(屏幕),你透过玻璃看远处的树。树的顶点到你眼睛的连线,穿过玻璃的那个点,就是它在屏幕上的投影点。 根据初中数学的相似三角形原理,物体在玻璃(屏幕)上的投影高度 $y'$,与物体的实际高度 $y$ 成正比,与物体到眼睛的距离 $z$ 成反比(即 $y' = y / z \times 焦距$)。 这就是为什么透视投影在底层只需要做一次简单的“除以深度($w$)”操作!因为物理世界的透视规律,在数学表达上恰好就是一个除以距离的分式。距离越远($w$ 越大),算出来的屏幕坐标 $x, y$ 就越小,从而完美还原了“近大远小”。
5. 既然矩阵运算都在 GPU 发生,那 CPU(Three.js)在这期间到底在算什么? 答:这是一个极其经典的架构分工问题。虽然针对每个顶点的 $P \times V \times M \times 坐标$ 乘法是在 GPU 的顶点着色器里并发执行的,但 CPU (Three.js) 绝不是什么都没干,它主要负责准备和组合这些矩阵:
- CPU 负责“局部矩阵”到“世界矩阵”的级联推导: 在 3D 引擎中,物体往往是层级嵌套的(比如:车轮属于汽车,汽车属于世界)。Three.js 在 CPU 端需要遍历整个 Scene Graph(场景图),执行
当前世界矩阵 = 父节点世界矩阵 \times 自己的局部矩阵。这个把关系树“拍平”算出每个物体最终 M 矩阵的过程,必须在 CPU 完成。- CPU 负责合并公共矩阵,减少 GPU 压力: 在着色器中我们看到
gl_Position = projectionMatrix * modelViewMatrix * ...。其实 $V$ 和 $M$ 矩阵在传给 GPU 之前,Three.js 已经在 CPU 端把它们预先乘在了一起,算出了一个modelViewMatrix(模型视图矩阵)。因为 $V$ 对于当前帧是不变的,提前在 CPU 乘好,就能让 GPU 在处理几百万个顶点时,每个顶点少做一次 4x4 的矩阵乘法,这极大提升了渲染性能。- 一句话总结:CPU 负责处理逻辑、层级关系并预先算好 4x4 矩阵的最终数值;然后把这些算好的矩阵当做 Uniform 变量传给 GPU;最后由 GPU 拿着这些矩阵,去成千上万个顶点身上疯狂做乘法。
为什么用 4×4 矩阵和齐次坐标
面试题:3D 点为什么要用 vec4(齐次坐标)?
- 平移无法用 3×3 矩阵表示(平移不是线性变换),引入第四维
w后,平移就能并入矩阵乘法,从而把平移、旋转、缩放统一成一次mat4 * vec4,便于链式连乘。- 点的
w = 1,方向向量的w = 0(这样平移对方向无效)。- 透视除法:投影后用
xyz / w得到 NDC,这一步制造了「近大远小」的透视效果。
投影:透视 vs 正交
| - | 透视投影 PerspectiveCamera | 正交投影 OrthographicCamera |
|---|---|---|
| 效果 | 近大远小,符合人眼 | 无近大远小,平行线保持平行 |
| 参数 | fov、aspect、near、far | left/right/top/bottom、near、far |
| 视体 | 截头椎体 Frustum | 长方体 |
| 场景 | 游戏、漫游、写实 | 工程制图、2.5D、UI、小地图 |
- 视椎体裁剪(Frustum Culling):在椎体外的物体不渲染,Three.js 默认开启。
- 近裁剪面不能设为 0:
near太小会导致深度精度不足(Z-fighting 闪烁),应尽量增大 near、减小 far。
旋转:欧拉角 vs 四元数
面试题:为什么动画/相机控制用四元数而不是欧拉角?什么是万向节死锁?
欧拉角(Euler):用绕 X、Y、Z 三个轴的角度和顺序(如
XYZ或YXZ)来表示旋转。它非常直观,但存在致命缺陷:万向节锁(Gimbal Lock)。万向节锁不是矩阵计算问题,而是“按固定顺序、分三次旋转”这种定义本身带来的数学拓扑缺陷。
举个飞机的例子(假设旋转顺序为 Y偏航 -> X俯仰 -> Z横滚):
- 飞机平飞,先绕 Y 轴偏航,机头转向左边。
- 接着绕 X 轴俯仰,机头向上拉起 90 度,此时飞机正垂直冲向天空。
- 问题来了:此时原本控制飞机横滚的 Z 轴,被刚才的俯仰操作带到了和初始 Y 轴(垂直地面)重合的位置!
- 结果:此时无论你是绕初始的 Y 轴转,还是绕现在的 Z 轴转,飞机都只是在绕着机身纵轴“自转”。你丢失了一个维度的自由度(无法再实现机腹朝向地面的水平偏航了)。
四元数(Quaternion):用 4 个分量 $(x, y, z, w)$ 表示旋转。它不依赖三次顺序旋转,而是直接定义绕 3D 空间中任意一根轴,旋转一个角度。
- 优势:完美避开万向节锁;支持球面线性插值(slerp),动画过渡极其平滑。
- 应用:Three.js 底层所有物体的旋转全部使用的是四元数(
object.quaternion),你在代码里写的object.rotation(欧拉角)只是为了方便人类阅读的上层封装,底层会被引擎自动转成四元数。
法线矩阵(进阶)
面试题:变换法线为什么不能直接用模型矩阵?
当模型矩阵包含非均匀缩放时,直接用它变换法线会导致法线不再垂直于表面。正确做法是用模型矩阵左上 3×3 的逆转置矩阵(normalMatrix)来变换法线。
为什么会这样?(直观理解) 想象一个等腰直角三角形,它的斜边是 45 度的,此时法线也是 45 度指向外部的。 现在我们进行非均匀缩放:只把 Y 轴拉长为原来的 2 倍。
- 对于几何体:三角形被拉高了,斜边变得更加“陡峭”。
- 对于法线(如果直接用模型矩阵):法线的 Y 分量也会被拉长 2 倍,变得更指向上方。 结果: 变陡峭的斜边和指向上方的法线,两者的夹角不再是 90 度了!法线“歪了”。
为了让法线继续保持垂直,图形学推导出的数学解法是:对模型矩阵的左上角 3x3 矩阵,先求逆矩阵,再求转置矩阵。
实际应用场景:什么时候必须用到法线矩阵?
- 自定义着色器中的光照计算(最常见):当你使用
ShaderMaterial自己写 GLSL 实现冯氏光照模型(Phong)或 PBR 渲染时。光照的强弱取决于光源方向与物体表面法线的点积(dot积)。如果法线“歪了”,光照计算就会全盘崩溃,导致物体表面明暗失真。 - 环境贴图反射/折射特效:当你想要实现金属反射、玻璃折射或者环境贴图(CubeMap)效果时,需要根据视线向量和法线计算出反射/折射向量。如果法线被非均匀缩放破坏,反射出来的画面就会像哈哈镜一样严重扭曲变形。
- 描边特效 (Outline/Toon Shading):很多二次元卡通渲染的描边效果,是通过在顶点着色器中将顶点“沿着法线方向向外挤出一点点”来实现的。如果不使用法线矩阵变换法线,在遇到模型被拉伸时,描边的粗细就会变得不均匀(有的地方粗有的地方细)。
代码示例:Three.js 与 GLSL 中的法线矩阵
在 Three.js 的 ShaderMaterial 中,引擎会自动为你传入 normalMatrix,你只需要在顶点着色器中使用它去乘顶点自带的法线即可:
// 顶点着色器 (Vertex Shader)
uniform mat3 normalMatrix; // Three.js 自动注入的法线矩阵 (逆转置矩阵)
attribute vec3 normal; // 几何体自带的法线数据
varying vec3 vNormal; // 传给片元着色器用于光照计算的法线
void main() {
// ❌ 错误做法:直接用 modelMatrix 乘法线(遇到非均匀缩放必错)
// vNormal = (modelMatrix * vec4(normal, 0.0)).xyz;
// ✅ 正确做法:使用 normalMatrix 乘法线
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
如果你需要在纯 JS 侧手动计算法线矩阵,Three.js 底层是这样做的:
// 提取模型视图矩阵左上角的 3x3 矩阵,并求逆转置
const normalMatrix = new THREE.Matrix3().getNormalMatrix(mesh.modelViewMatrix);
屏幕坐标 ↔ 3D 拾取
把鼠标 2D 坐标转成 NDC 再做射线拾取(Raycaster),就是 MVP 的「逆向」应用,详见 进阶应用。
// 屏幕坐标 → NDC([-1, 1])
const ndcX = (mouseX / canvasWidth) * 2 - 1;
const ndcY = -((mouseY / canvasHeight) * 2 - 1); // y 轴翻转
raycaster.setFromCamera({ x: ndcX, y: ndcY }, camera);
const intersects = raycaster.intersectObjects(scene.children, true);