WebGL 中的矩阵变换有哪些?MVP 矩阵是什么?
WebGL 中的矩阵变换
3D 渲染的本质是将顶点从模型局部坐标一步步变换到屏幕像素坐标,而矩阵就是描述这些变换的数学工具。WebGL 中所有坐标变换都通过 4×4 齐次矩阵完成,理解这些矩阵的含义和组合方式是掌握 3D 图形编程的基础。
三种基本变换矩阵
平移矩阵
平移将物体沿 X、Y、Z 轴移动指定距离,是最直观的变换:
shell| 1 0 0 tx | | 0 1 0 ty | | 0 0 1 tz | | 0 0 0 1 |
由于 3×3 矩阵无法表示平移(线性变换不包含偏移),所以必须引入齐次坐标——用 4D 向量 (x, y, z, 1) 表示 3D 点,w=1 使平移分量 tx, ty, tz 参与运算。
javascriptfunction createTranslationMatrix(tx, ty, tz) { return new Float32Array([ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1 ]); }
注意 WebGL 使用列主序存储,所以矩阵在数组中的排列与数学书写不同——第一列先填,再填第二列。
缩放矩阵
沿各轴独立缩放物体大小:
shell| sx 0 0 0 | | 0 sy 0 0 | | 0 0 sz 0 | | 0 0 0 1 |
当 sx = sy = sz 时为等比缩放。如果某个分量为负值,会产生镜像翻转效果——这在法线计算时需要特别处理。
旋转矩阵
旋转矩阵比平移和缩放复杂,因为绕不同轴旋转的矩阵形式不同:
绕 X 轴旋转 θ:
shell| 1 0 0 0 | | 0 cosθ -sinθ 0 | | 0 sinθ cosθ 0 | | 0 0 0 1 |
绕 Y 轴旋转 θ:
shell| cosθ 0 sinθ 0 | | 0 1 0 0 | | -sinθ 0 cosθ 0 | | 0 0 0 1 |
绕 Z 轴旋转 θ:
shell| cosθ -sinθ 0 0 | | sinθ cosθ 0 0 | | 0 0 1 0 | | 0 0 0 1 |
任意轴的旋转可以通过绕三个基本轴旋转的组合实现,但更优雅的方式是使用四元数(Quaternion),它避免了万向锁(Gimbal Lock)问题,且插值更平滑。面试中如果被追问"如何避免万向锁",四元数是标准答案。
MVP 矩阵详解
MVP 矩阵是渲染管线中最核心的概念,它将顶点从模型空间一路变换到裁剪空间:
MVP = P × V × M
关键点在于乘法顺序——右侧的矩阵先作用于顶点。所以实际变换过程是:顶点先被 M 变换,再被 V 变换,最后被 P 变换。写成作用于顶点的形式就是 P × V × M × vertex。
M - 模型矩阵(Model Matrix)
模型矩阵将顶点从模型局部空间变换到世界空间。它描述的是物体在世界中的摆放方式——放在哪里、朝向哪个方向、多大尺寸。
javascriptconst modelMatrix = mat4.create(); mat4.translate(modelMatrix, modelMatrix, [x, y, z]); mat4.rotateX(modelMatrix, modelMatrix, angleX); mat4.rotateY(modelMatrix, modelMatrix, angleY); mat4.scale(modelMatrix, modelMatrix, [sx, sy, sz]);
模型矩阵通常是平移、旋转、缩放的组合。变换的顺序影响最终结果:先缩放再旋转再平移,和先平移再旋转再缩放,得到的是完全不同的效果。一般推荐 SRT 顺序(Scale → Rotate → Translate),即先缩放调整大小,再旋转调整朝向,最后平移到目标位置。
V - 视图矩阵(View Matrix)
视图矩阵将顶点从世界空间变换到相机空间(观察空间)。它的本质是设置观察者的视角——相机在哪里、看向哪里、哪个方向朝上。
javascriptconst viewMatrix = mat4.create(); mat4.lookAt( viewMatrix, [0, 0, 5], // 相机位置(eye) [0, 0, 0], // 观察目标点(center) [0, 1, 0] // 上方向向量(up) );
理解视图矩阵的一个思路:它并不移动相机,而是反向移动整个世界。将世界坐标系的原点搬到相机位置,并旋转坐标系使相机朝向 -Z 方向。这就是为什么 lookAt 矩阵是相机变换的逆矩阵。
P - 投影矩阵(Projection Matrix)
投影矩阵将顶点从相机空间变换到裁剪空间,是 3D 到 2D 映射的关键一步。
透视投影模拟人眼近大远小的效果,是 3D 游戏和可视化的默认选择:
javascriptmat4.perspective( projectionMatrix, Math.PI / 4, // 视野角度 FOV canvas.width / canvas.height, // 宽高比 0.1, // 近裁剪面 100.0 // 远裁剪面 );
FOV(Field of View)越大,看到的范围越广,但畸变越明显;近裁剪面和远裁剪面定义了可见深度范围,超出范围的片元会被剔除。注意近裁剪面不能设为 0,否则会导致深度缓冲精度问题。
正交投影没有近大远小的透视效果,物体大小不随距离变化,常用于 2D 游戏、UI 渲染和 CAD 软件:
javascriptmat4.ortho( projectionMatrix, -2, 2, // 左右边界 -2, 2, // 下上边界 0.1, 100 // 近远裁剪面 );
坐标空间变换全流程
一次完整的顶点变换经历以下坐标空间:
shell模型空间(局部空间) ↓ 模型矩阵 M 世界空间 ↓ 视图矩阵 V 相机空间(观察空间) ↓ 投影矩阵 P 裁剪空间 ↓ 透视除法(除以 w 分量) 标准化设备坐标 NDC(-1 到 1) ↓ 视口变换 gl.viewport 屏幕空间
其中 MVP 矩阵负责前三步变换,裁剪空间之后由 GPU 硬件自动完成透视除法和视口变换。透视除法是理解投影的关键:裁剪空间的齐次坐标 (x, y, z, w) 除以 w 后得到 NDC 坐标,正是这个除法产生了近大远小的透视效果。
顶点着色器中的 MVP 应用
glslattribute vec3 a_position; uniform mat4 u_mvpMatrix; void main() { gl_Position = u_mvpMatrix * vec4(a_position, 1.0); }
实际开发中推荐在 JavaScript 端预计算 MVP 矩阵再传入着色器,而不是在着色器中分别接收三个矩阵相乘。原因有两点:一是减少 GPU 端的计算量(顶点着色器每个顶点执行一次),二是减少 uniform 变量的数量和上传次数。
常见面试追问
Q: 为什么 MVP 的乘法顺序是 P × V × M,而不是 M × V × P?
因为矩阵作用于顶点的顺序是从右到左。P × V × M × vertex 等价于先 M 再 V 再 P,即先从模型空间到世界空间,再到相机空间,最后到裁剪空间。这是渲染管线的自然顺序。
Q: WebGL 中矩阵是行主序还是列主序?
WebGL 使用列主序。gl.uniformMatrix4fv 的 transpose 参数必须为 false(WebGL 1 不支持转置上传)。这意味着在 Float32Array 中,矩阵的第一列四个元素排在最前面。
Q: 齐次坐标中 w 分量的作用是什么?
w=1 表示点(受平移影响),w=0 表示方向向量(不受平移影响)。方向向量只需要旋转和缩放,如法线向量和光照方向。透视除法中 w 还承担了产生近大远小效果的关键角色。
Q: 法线变换为什么不能直接用 MVP 矩阵?
法线是方向向量不是位置向量,且存在非等比缩放时直接用模型矩阵会导致法线不再垂直于表面。正确做法是使用模型矩阵左上 3×3 子矩阵的逆转置矩阵,即 (M⁻¹)ᵀ。gl-matrix 中的 mat3.normalFromMat4 就是做这件事。