MVP变换

在计算机图形学中MVP变换是模型变换(Model),视图变换(View),投影变换(Projection)三个矩阵的合称,它们将顶点从模型自身空间一步一步变换到裁剪空间,是渲染管线的核心环节。

一、整体流程

一个三维模型的顶点最初定义在局部空间内即模型空间,经过 MVP 变换后输出到裁剪空间,之后由 GPU 完成透视除法和视口变换,最终显示在屏幕上。

局部空间 --(M)--> 世界空间 --(V)--> 观察空间(摄像机空间) --(P)--> 裁剪空间
  • 模型矩阵 M:把物体从自身坐标系放入世界坐标系。

  • 视图矩阵 V:把整个世界放到摄像机坐标系下(摄像机为原点,看向特定方向)。

  • 投影矩阵 P:把摄像机看到的视锥体压缩到一个标准立方体(裁剪空间)。

三者相乘得到MVP = P * V * M,在顶点着色器中只用一句gl_Position = MVP * vec4(localPos, 1.0);即可完成所有几何变换(使用列主序矩阵左乘列向量)。

二、模型矩阵M(Model)

将物体的局部坐标转换到世界坐标,通常由缩放、旋转、平移组成。按列向量左乘的习惯,变换顺序为:先缩放、再旋转、再平移:

M=T⋅R⋅SM=T⋅R⋅S

  • 缩放 S:沿各轴缩放系数​,矩阵为对角阵。

  • 旋转 R:绕 X/Y/Z 轴的旋转矩阵,或由四元数生成的正交矩阵。

  • 平移 T:仅对第 4 列(平移分量)赋值,不影响方向和缩放。

例如先缩放再绕 Z 轴旋转,最后平移到 (tx,ty,tz),M 可将局部坐标 plocalplocal​ 变为世界坐标 Pworld=M*Plocalpworld​=M*Plocal​。

#include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> glm::mat4 createModelMatrix( const glm::vec3& position, // 平移量 (x, y, z) const glm::vec3& rotation, // 旋转角度 (pitch, yaw, roll) 单位:度 const glm::vec3& scale // 缩放倍数 (x, y, z) ) { // 1. 初始化一个4x4的单位矩阵 glm::mat4 model = glm::mat4(1.0f); // 2. 应用平移 model = glm::translate(model, position); // 3. 应用旋转 (按X, Y, Z轴顺序) model = glm::rotate(model, glm::radians(rotation.x), glm::vec3(1.0f, 0.0f, 0.0f)); model = glm::rotate(model, glm::radians(rotation.y), glm::vec3(0.0f, 1.0f, 0.0f)); model = glm::rotate(model, glm::radians(rotation.z), glm::vec3(0.0f, 0.0f, 1.0f)); // 4. 应用缩放 model = glm::scale(model, scale); return model; }

三、视图矩阵V(View)

视图变换将世界坐标转化为观察空间(摄像机空间),使摄像机位于原点,看向 -Z 方向,+Y 为上方(OpenGL 惯例)。通常用lookAt(eye, center, up)构建。

推导过程(左手/右手规则可按引擎调整,这里以 OpenGL 右手系为例):

  • 摄像机位置,看向目标,上方向

  • 计算摄像机坐标系的三根正交轴:
    (视线方向)
    (右轴)
    (正交化后的上轴)

  • 视图矩阵要将世界坐标转换到这个新坐标系,并使摄像机位于原点。它由旋转和平移组合而成:

其中第三行取负是因为摄像机看向 -Z,对应观察空间的 Z 轴方向与视线方向相反。
应用后,世界坐标转为观察空间坐标

使用GLM库实现:

#include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> glm::vec3 eyePos = glm::vec3(5.0f, 3.0f, 5.0f); glm::vec3 target = glm::vec3(0.0f, 0.0f, 0.0f); glm::vec3 upDir = glm::vec3(0.0f, 1.0f, 0.0f); glm::mat4 viewMatrix = glm::lookAt(eyePos, target, upDir);

手写底层代码:

#include <cmath> #include <cstring> struct Vec3 { float x, y, z; }; struct Mat4 { float m[4][4]; }; // 向量运算辅助 Vec3 normalize(Vec3 v) { float len = sqrt(v.x*v.x + v.y*v.y + v.z*v.z); return {v.x/len, v.y/len, v.z/len}; } Vec3 cross(Vec3 a, Vec3 b) { return {a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x}; } float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; } Mat4 lookAt(Vec3 eye, Vec3 center, Vec3 up) { // 1. 计算三个轴(注意:OpenGL看向-Z,所以前向取反) Vec3 f = normalize({center.x - eye.x, center.y - eye.y, center.z - eye.z}); Vec3 r = normalize(cross(f, up)); Vec3 u = cross(r, f); // 重新校准上轴 Mat4 result; memset(&result, 0, sizeof(result)); // 初始化为0 // 2. 填充旋转部分(转置)和平移部分(点积取反) // 第一行:右轴 (R) result.m[0][0] = r.x; result.m[0][1] = r.y; result.m[0][2] = r.z; result.m[0][3] = -dot(r, eye); // 第二行:上轴 (U) result.m[1][0] = u.x; result.m[1][1] = u.y; result.m[1][2] = u.z; result.m[1][3] = -dot(u, eye); // 第三行:负前轴 (-F) result.m[2][0] = -f.x; result.m[2][1] = -f.y; result.m[2][2] = -f.z; result.m[2][3] = dot(f, eye); // 第四行:齐次坐标 result.m[3][3] = 1.0f; return result; }

四、投影矩阵P(Projection)

投影变换将观察空间的视锥体映射到裁剪空间,即一个 X、Y、Z 都在(OpenGL)或(Direct3D/Vulkan)之间的立方体(NDC,标准化设备坐标),超出部分会被裁剪。投影分为正交投影和透视投影。

4.1透视投影

模拟近大远小的效果,视锥体是一个金字塔截体,由近平面n、远平面f、垂直视场角(fovY)和宽高比a=w/h决定。

对称视锥体的参数:


透视投影矩阵(OpenGL 右手系,观察空间 +Y 朝上,-Z 朝前,深度映射到 [−1,1]):

由于对称是,可简化为:

推导逻辑

  • 将观察空间的点(其中​ 为负值,因为看向 -Z)转换到裁剪空间,其齐次坐标的分量设为,这样透视除法后产生近大远小效果。

  • X、Y 分量:利用相似三角形,投影平面上的坐标为,再线性映射到 [−1,1]。

  • Z 分量:保持深度非线性映射,使近平面映射为 -1,远平面映射为 1,同时保证深度值用于

GLM库实现:

#include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> float fov = 60.0f; // 视野角度(度) float aspect = 16.0f / 9.0f; // 宽高比 float near = 0.1f; // 近裁剪面 float far = 100.0f; // 远裁剪面 // 注意:glm::perspective 需要传入弧度 glm::mat4 projMatrix = glm::perspective(glm::radians(fov), aspect, near, far);

手写底层代码:

#include <cmath> #include <cstring> struct Mat4 { float m[4][4]; }; Mat4 perspectiveMatrix(float fovDegrees, float aspect, float near, float far) { float tanHalfFov = tan(fovDegrees * 3.1415926f / 360.0f); // 注意是半角 float f = 1.0f / tanHalfFov; // 即 cot(FOV/2) Mat4 result; memset(&result, 0, sizeof(result)); // 全部初始化为 0 // 第一行 result.m[0][0] = f / aspect; // 第二行 result.m[1][1] = f; // 第三行(关键深度映射) result.m[2][2] = -(far + near) / (far - near); result.m[2][3] = -(2.0f * far * near) / (far - near); // 第四行(触发透视除法) result.m[3][2] = -1.0f; // 注意:m[3][3] 保持为 0 return result; }

4.2正交投影

没有透视效果,视锥体是一个长方体。矩阵将长方体直接缩放平移到

Z 分量映射:近平面 −n→−1,远平面 −f→1,由于 n,f 为正且 f>n,第三行系数为负。

glm::mat4 orthoMatrix = glm::ortho(left, right, bottom, top, near, far);

五、MVP组合与后续步骤

在顶点着色器中,矩阵相乘顺序为:

输出的是裁剪空间中的齐次坐标

1.透视除法:坐标除以得到标准化设备坐标(NDC):

2、视口变换:将NDC映射到屏幕像素坐标,加上深度偏移,成为最终的窗口坐标。

// 顶点着色器核心代码 gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(localPos, 1.0);

六、例题

MVP 变换与屏幕投影

§ 1. 背景介绍

在三维图形系统中,一个模型的顶点并不是一开始就以屏幕坐标的形式存在。建模软件通常在模型自己的局部坐标系中描述顶点;场景管理系统再把模型放到世界坐标系中;相机决定观察场景的位置和方向;投影过程则把三维空间中的可见区域映射到二维屏幕上。
这套流程是实时渲染管线的基础。游戏引擎中的角色、CAD 软件中的零件、科学可视化中的三维数据,都需要经过类似的变换才能显示在屏幕上。理解 MVP 变换可以帮助学生解释很多常见现象,例如物体为什么会移动、旋转、缩放,为什么相机移动后画面会变化,以及为什么同一个物体在远处看起来更小。
本题把完整渲染系统中的顶点变换阶段抽象成一个纯数值问题。学生不需要调用图形 API,也不需要生成图片,只需要根据给定矩阵和屏幕参数,计算点最终对应的屏幕坐标和深度值。


§ 2. 题目描述

给定三维点、三个 4×4 变换矩阵和屏幕大小,输出每个点经过变换后的屏幕坐标和深度。
本题只要求输出指定数值结果,不要求生成图片或调用图形 API。输入数据规模较小,重点在于正确理解几何约定、坐标约定和输出格式。

§ 3. 输入格式

1 N 2 x0 y0 z0 3 x1 y1 z1 4 … 5 Model 矩阵 4 行,每行 4 个实数 6 View 矩阵 4 行,每行 4 个实数 7 Projection 矩阵 4 行,每行 4 个实数 8 width height

§ 4. 输入数据含义

  • N:整数,表示输入点的数量。
  • xi yi zi:三个实数,表示第 i 个点在模型 / 局部坐标系中的三维坐标。点的编号从 0 到 N-1,输出也应按照输入点的顺序逐行给出。
  • Model 矩阵:4×4 实数矩阵,输入时连续给出 4 行,每行 4 个实数。该矩阵描述模型坐标到世界坐标的变换。
  • View 矩阵:4×4 实数矩阵,输入时连续给出 4 行,每行 4 个实数。该矩阵描述世界坐标到相机 / 观察坐标的变换。
  • Projection 矩阵:4×4 实数矩阵,输入时连续给出 4 行,每行 4 个实数。该矩阵描述相机 / 观察坐标到裁剪空间的变换。
  • width height:两个正实数,分别表示屏幕宽度和屏幕高度。

§ 5. 输出格式

对每个输入点输出一行:

1 screen_x screen_y depth

其中:

  • screen_x:点在屏幕坐标系中的横坐标。
  • screen_y:点在屏幕坐标系中的纵坐标。
  • depth:点经过透视除法后得到的深度值。

浮点数保留 6 位小数。C++ 中可使用cout << fixed << setprecision(6);,Python 中可使用格式化字符串如f"{x:.6f}"。OJ 判题时会按题面约定检查输出格式和数值结果。

§ 6. 重要约定

  • 矩阵输入为普通的 4×4 数表,每行对应矩阵的一行。
  • 几何变换采用列向量约定,即三维点先补齐为齐次坐标点,再依次作用 Model、View、Projection。
  • 屏幕坐标系约定为左上角为原点,x 向右增大,y 向下增大。
  • 测试数据保证透视除法所需的 w 不为 0。
  • 输出时不要添加额外说明文字。

§ 7. 数据范围

  • 1 ≤ N ≤ 200
  • 矩阵元素和点坐标为有限实数
  • width、height 为正数

§ 8. 测试数据说明

输入:

1 3 2 0 0 0 3 1 1 0 4 -1 -1 0 5 1 0 0 0 6 0 1 0 0 7 0 0 1 0 8 0 0 0 1 9 1 0 0 0 10 0 1 0 0 11 0 0 1 0 12 0 0 0 1 13 1 0 0 0 14 0 1 0 0 15 0 0 1 0 16 0 0 0 1 17 800 600

输出:

1 400.000000 300.000000 0.000000 2 800.000000 0.000000 0.000000 3 0.000000 600.000000 0.000000

代码实现:

#include <iostream> #include <vector> #include <array> #include <iomanip> using namespace std; using mat4 = array<array<double, 4>, 4>; using vet4 = array<double, 4>; #矩阵与矩阵的乘法 mat4 mul(const mat4& a, const mat4& b) { mat4 res{}; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { res[i][j] = 0.0; for (int k = 0; k < 4; ++k) { res[i][j] += a[i][k] * b[k][j]; } } } return res; } #矩阵与向量的乘法,与上边的函数构成重载 vet4 mul(const mat4& m, const vet4& v) { vet4 res{}; for (int i = 0; i < 4; ++i) { res[i] = 0.0; for (int k = 0; k < 4; ++k) { res[i] += m[i][k] * v[k]; } } return res; } int main() { int N; cin >> N; vector<array<double, 3>> points(N); for (int i = 0; i < N; ++i) { cin >> points[i][0] >> points[i][1] >> points[i][2]; } mat4 m; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { cin >> m[i][j]; } } mat4 v; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { cin >> v[i][j]; } } mat4 p; for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { cin >> p[i][j]; } } double width, height; cin >> width >> height; mat4 mvp = mul(p, mul(v,m)); cout << fixed << setprecision(6); for (const auto& i : points) { vet4 t = { i[0], i[1], i[2], 1.0 }; vet4 clip = mul(mvp, t); double wc = clip[3]; double xn = clip[0] / wc; double yn = clip[1] / wc; double zn = clip[2] / wc; double s_x = (xn + 1.0) * 0.5 * width; double s_y = (1.0 - yn) * 0.5 * height; cout << s_x << " " << s_y << " " << zn << endl; } return 0; }

为什么s_x = (xn + 1.0) * 0.5 * width

  • 原因:NDC 的 X 范围是[-1, 1],而屏幕像素范围是[0, Width]

  • 解读(xn + 1) * 0.5[-1,1]完美映射到[0,1],再乘以宽度,就得到了该顶点在屏幕上的水平像素列号

为什么s_y = (1.0 - yn) * 0.5 * height?(这是最关键的区别)

  • 原因坐标系 Y 轴方向相反

    • OpenGL 的 NDC 是Y 轴向上(底部 -1,顶部 +1)。

    • 屏幕/窗口坐标系是Y 轴向下(顶部 0,底部 Height)。

  • 解读:如果直接写(yn + 1) * 0.5 * height,物体是颠倒的。代码用(1.0 - yn)代替(yn + 1.0),本质上就是把 Y 轴翻转了,确保物体的顶部显示在屏幕的上方。

为什么最后输出zn

  • zn是映射到[-1, 1](或[0,1],取决于投影矩阵)的深度值。输出它是为了模拟深度缓冲(Z-buffer),在后续画像素时,可以用它来判断哪个物体在前面。