模型、视图、投影(MVP)变换
需要预先了解:
1. 模型变换(Model Transformation)
模型变换(model transformation)把顶点从模型空间(model space,也称局部空间 local space)变换到世界空间(world space),对应的矩阵称为模型矩阵(model matrix),一般用符号 来表示。
计算机图形学中,我们经常会提到“空间”的概念,这里可以把空间理解为一个坐标系,用于描述物体的位置和方向。
具体地讲,通常一个模型在建模软件里被创造出来,里面需要存储顶点之类的模型的数据,建模的过程其实就是在模型空间中,原点通常落在模型的中心或脚底,轴向由建模习惯决定(Blender 默认 轴向上,glTF 和多数引擎默认 轴向上)。这些数字只有在"这个模型自己"的语境下才有意义。世界空间则是整个场景(scene)共享的坐标系,物体的摆放、光源与摄像机的位置都在这里描述,后续会讲到的视图变换与光照计算也都在这个空间里进行。
1.1 模型矩阵的组成
模型矩阵一般由平移(translation)、旋转(rotation)、缩放(scale)三种基本变换拼成,简称 TRS:
三种矩阵的具体形式见 变换。写成 也表示了我们进行处理的顺序:顶点会依次经历缩放、旋转、平移。这个顺序很直观,先在模型自己的坐标系里进行缩放旋转,设置好模型的位置,然后再将其移动到世界空间的具体的位置。
以单位立方体上的顶点 为例,取缩放 、绕 轴旋转 、平移 :
如果把顺序换成 (先平移),同一个顶点会先被搬到 ,再被缩放成 :平移量跟着被放大了 2 倍,物体落到了完全错误的位置。
1.2 旋转的顺序
缩放与平移各只有一个矩阵,而旋转 通常由绕三个坐标轴的旋转拼出来:、、 之间同样不可交换,必须先约定一个固定顺序,例如
用三个角度描述朝向的方式称为欧拉角(Euler angles)。欧拉角直观、便于手工编辑,但有两个毛病:万向节死锁(gimbal lock,某个角度下两个旋转轴重合,丢失一个自由度)和插值不连续。所以引擎内部通常用四元数(quaternion)保存朝向,只在需要矩阵时转成 ;而用户界面里编辑的仍是欧拉角,两者之间来回转换。
注:不同软件默认的顺序不同(Unity 的
Quaternion.Euler按 依次施加,即 ;许多离线渲染器默认 )。跨工具链路导入导出时,欧拉角顺序不一致会得到完全不同的朝向。
1.3 变换的中心
与 都是绕模型空间的原点作用。想让物体绕别的点旋转(比如绕它自己的某一角),就要先把那个点搬到原点,旋转后再搬回去:
其中 是旋转中心。缩放同理。这也是为什么建模时习惯把枢轴(pivot)放在模型的合理位置——模型空间的原点就是枢轴所在的位置。
1.4 场景图中的模型变换
带父子层级的场景里,每个物体只保存自己的本地矩阵(local matrix,即相对父物体的 TRS),世界矩阵由父到子依次相乘得到:
父物体一动,整棵子树的世界矩阵都跟着变,这是 变换 中结合律的直接应用。为了让深层级不必每帧全量重算,引擎会给世界矩阵加缓存和脏标记(dirty flag),只在父级或自身变化后才重算。
1.5 法线的变换
顶点位置乘 得到世界空间的位置,但法线不能这么变换。法线是垂直于表面的向量,只有 是刚体变换(旋转 + 平移)或等比缩放时,直接用左上 才不出错;一旦出现非等比缩放,垂直关系就会被破坏,法线跟着歪掉。
正确的做法是乘模型矩阵左上 的逆转置(inverse transpose),这个矩阵称为法线矩阵(normal matrix):
推导见 Phong模型与Blinn-Phong模型,完整的坐标空间链路见 坐标空间总览。
2. 视图变换(View Transformation)
在这里,我们要引入一个摄像机(camera)的概念,它并不是一个真实的用于摄影的物理设备,而是一个概念上的用于观察世界的点:我们约定可以确定摄像机的空间状态(位置与朝向,这里的朝向可以用欧拉角、四元数或两个垂直的向量来表示),以及摄像机的可视区域(就是摄像机能看到的空间范围)。我们最后实际渲染的画面就是摄像机可视区域到屏幕的投影。
三维场景中,物体、摄像机、光源都摆放在同一个世界坐标系(world space)下。渲染的画面取决于摄像机从当前位置、当前朝向看出去的样子。要把"摄像机看到的世界"描述出来,需要先把场景从世界坐标系变换到以摄像机为中心的坐标系中,这一步就是视图变换(View Transformation,也称摄像机变换)。
具体地说,视图变换的主要目的是将相机放置在原点上,并调整相机的朝向,使其看向 轴负半轴的方向,同时 轴指向上方, 轴指向右方。
此变换会对所有物体进行,因此,只要我们可以保证所有物体的相对位置不变(整个世界都朝相同的地方平移同样距离、都绕同一个轴旋转同样角度的操作,不会改变物体之间的相对位置),就可以实现将所有物体同步变换到摄像机空间中,确保摄像机看到的内容正确。
2.1 虚拟摄像机的设置
先定义一台虚拟摄像机,它由三个要素决定:
| 要素 | 含义 |
|---|---|
| 位置(position) | 摄像机在世界坐标系中的位置 |
| 看向方向(look-at direction) | 摄像机朝哪个方向看,f 取自 forward(前方) |
| 上方向(up direction) | 摄像机的"头顶"指向,用于固定画面不倾斜,u 取自 up(上方) |
只看位置和朝向,画面还可以随意翻转(比如倒立看),因此还需要上方向来固定摄像机的姿态。上方向通常取世界坐标系 轴的附近,不必与 轴完全重合。
- 可以通过调整 来实现滚动(roll)效果: 绕着 旋转,画面就会绕着中心点旋转。
- 事实上, 只要不与 共线即可,在实际的计算过程中, 沿着 的分量没有意义,只有垂直于 的分量才会影响最终结果。
2.2 摄像机空间(Camera Space)
为了方便计算,我们博客里约定摄像机永远处于一个标准位置:
- 位置在原点;
- 看向 轴方向(右手系, 轴正方向朝后);
- 上方向为 轴正方向。
以标准位置摄像机为原点建立的坐标系称为摄像机空间(camera space,也称视图空间、view space)。世界坐标系下的所有物体都要先变换到摄像机空间中,才能进入后续的投影、裁剪等阶段。
让摄像机看向 可以使深度值随距离单调变化,与后续 z 相关的计算保持一致
2.3 视图变换的原理
视图变换把世界坐标系下的物体变换到摄像机坐标系下。核心想法是:先移动摄像机到原点,再转动摄像机,让物体跟着一起动。因为物体与摄像机的相对位置始终不变,摄像机看到的内容也就保持不变。

具体分两步。
第一步:平移,把摄像机移动到原点。
摄像机在世界坐标系中的位置为 ,把它平移到原点,平移量为 :
这一步必须对场景中所有物体同时进行,所有物体跟着一起平移,保持相对位置不变,画面才会正确。
第二步:旋转,把摄像机的朝向转到标准方向。
摄像机已经在原点,此时旋转它,让 指向 、 指向 。同时也会让物体绕着摄像机(原点)进行旋转,因此旋转摄像机和旋转物体用的是同一个矩阵。
实际计算中,往往需要用到右方向(right direction),它与 、 互相垂直,构成一个右手坐标系,因此只需要对 、 做叉乘就能得到 :
直接构造 有些绕,可以先构造它的逆:把 轴旋转到 (摄像机的右方向)、 轴旋转到 、 轴旋转到 :
具体的计算思路见 将已有坐标系旋转到一个新的坐标系。
旋转矩阵是正交矩阵(orthogonal matrix),它的逆等于转置,因此:
把两步合并,得到完整的视图变换矩阵:
世界坐标系下的任意点,左乘 就得到它在摄像机空间中的坐标。
3. 投影变换(Projection Transformation)
先明确一个前提条件,进行投影变换之前,所有的点已经经过了模型变换和视图变换,此时它们的位置处于摄像机空间中。
接下来的任务是对摄像机的可视区域做变换,把所有的点放进一个标准立方体内,我们未来的裁剪、光栅化等阶段都在这个立方体内进行。
再强调一遍,以下内容都在摄像机空间下进行,摄像机位于原点、看向 方向。
3.1 正交投影(Orthographic Projection)
正交投影不考虑近大远小:平行的线投影后仍然平行,物体的大小不随距离改变。它的可视区域是一个长方体,由六个平面围成:
| 方向 | 范围 |
|---|---|
| (left 到 right) | |
| (bottom 到 top) | |
| (far 到 near) |
注意 的范围写作 ,这是因为摄像机看向 ,近平面 在数值上大于远平面 。
把任意长方体变换到标准立方体分两步:
第一步:平移
把长方体的中心平移到原点,中心坐标为 :
第二步:缩放
把各轴长度缩放到 2。以 轴为例,长度从 缩到 2,缩放因子为 ,其余两轴同理:
合并两步,得到正交投影矩阵
根据先平移、后缩放的顺序去计算:
这里不可以调换顺序,先缩放再平移会导致平移的距离实际上乘以了一个缩放比例的倒数,虽然还可以正确把长方体变换到边长为 2 的正方体,但是其位置会偏离原点
3.2 透视投影(Perspective Projection)
透视投影模拟人眼的视觉效果:近大远小,平行的线在远处汇聚。它的可视区域不是长方体,而是视锥体(frustum,四棱锥被近平面和远平面截出的部分)。
透视投影的关键思路:先把视锥体"挤压"(squish)成长方体,再对长方体做正交投影。挤压变换 需要满足:
- 近平面上的点保持不变;
- 远平面上的点 z 坐标不变,x、y 按比例收缩;
- 中间平面上的点 x、y 按比例收缩,z 发生非线性变化。
利用相似三角形可以算出挤压后的 x、y 坐标。从侧面看,视锥体内的点 与原点连线在近平面 上的交点满足:
z 方向的变化暂时未知,记为 。在齐次坐标下,挤压变换写成:
(乘以 消去分母;齐次坐标下,点乘以非零常数表示同一个点。)
观察右侧向量:第一、二行是 、,第四行是 ,因此挤压矩阵形如:
、 待定,用"近平面 与远平面 上的点挤压后 z 坐标不变"来求解:
- 对 的点,挤压后第三行应为 :
- 对 的点,挤压后第三行应为 :
解得 、,于是:
完整的透视投影矩阵就是刚才所求的挤压矩阵左乘正交投影矩阵:
注:
- 挤压后的 z 坐标被非线性地压缩,离摄像机越远压缩得越厉害。这不会影响画面本身(透视效果已经由 x、y 的收缩体现),但会降低远处物体的深度精度,做深度测试(depth test)时需要注意
- 如果对 z 不进行缩放,会破坏透视投影的深度关系,使得原本共线的点在压缩后不再共线,造成仿射错觉,进行深度测试、片元插值时出现错误,光栅化后三角形变形。
4. 以上内容的 C++ 实现
4.1 模型变换
// ModelTransformation.hpp
#pragma once
#include "Vector.hpp"
#include "Matrix.hpp"
namespace MY::CG
{
// 模型变换参数:位置、欧拉角(单位为度)、缩放
struct ModelTransform
{
Vec3 position; // 平移
Vec3 rotation; // 欧拉角,固定按 y -> x -> z 的顺序施加
Vec3 scale; // 缩放,三个分量都为 1 时不缩放
};
/**
* 获取模型矩阵,即将模型坐标系中的点转换到世界坐标系中的矩阵
* @param transform 模型变换参数,包含位置、欧拉角和缩放
* @return 模型矩阵,类型为 Mat4,等于 T * R * S
*/
Mat4 get_model_matrix(const ModelTransform& transform);
} // namespace MY::CG
// ModelTransformation.cpp
#include <cmath>
#include <numbers>
#include "ModelTransformation.hpp"
namespace MY::CG
{
namespace
{
// 绕三个坐标轴的旋转矩阵按 Ry * Rx * Rz 的顺序合并
Mat4 get_rotate_matrix(const Vec3& degrees)
{
constexpr float kDegreeToRadian = std::numbers::pi_v<float> / 180.0f;
float rx = degrees[0] * kDegreeToRadian;
float ry = degrees[1] * kDegreeToRadian;
float rz = degrees[2] * kDegreeToRadian;
Mat4 rot_x = Mat4::get_identity();
rot_x[1][1] = std::cos(rx);
rot_x[1][2] = std::sin(rx);
rot_x[2][1] = -std::sin(rx);
rot_x[2][2] = std::cos(rx);
Mat4 rot_y = Mat4::get_identity();
rot_y[0][0] = std::cos(ry);
rot_y[0][2] = -std::sin(ry);
rot_y[2][0] = std::sin(ry);
rot_y[2][2] = std::cos(ry);
Mat4 rot_z = Mat4::get_identity();
rot_z[0][0] = std::cos(rz);
rot_z[0][1] = std::sin(rz);
rot_z[1][0] = -std::sin(rz);
rot_z[1][1] = std::cos(rz);
return rot_y * rot_x * rot_z;
}
} // namespace
Mat4 get_model_matrix(const ModelTransform& transform)
{
// 平移矩阵(Mat4 的索引为 [列][行],平移量位于最后一列)
Mat4 translate = Mat4::get_identity();
translate[3][0] = transform.position[0];
translate[3][1] = transform.position[1];
translate[3][2] = transform.position[2];
// 缩放矩阵
Mat4 scale = Mat4::get_identity();
scale[0][0] = transform.scale[0];
scale[1][1] = transform.scale[1];
scale[2][2] = transform.scale[2];
// 先缩放,再旋转,最后平移
return translate * get_rotate_matrix(transform.rotation) * scale;
}
} // namespace MY::CG
4.2 视图变换
// ViewTransformation.hpp
#pragma once
#include "Vector.hpp"
#include "Matrix.hpp"
namespace MY::CG
{
/**
* 获取视图矩阵,即将世界坐标系中的点转换到相机坐标系中的矩阵
* @param eye 相机位置
* @param center 相机的目标点
* @param up 相机的上方向
* @return 视图矩阵,类型为 Mat4
*/
Mat4 get_view_matrix(const Vec3& eye, const Vec3& center, const Vec3& up);
} // namespace MY::CG
// ViewTransformation.cpp
#include "ViewTransformation.hpp"
namespace MY::CG
{
Mat4 get_view_matrix(const Vec3& eye, const Vec3& center, const Vec3& up)
{
// 前向向量
Vec3 f = center - eye;
f.normalize();
// 右向量
Vec3 s = Math::cross(f, up);
s.normalize();
// 真正的上向量
Vec3 u = Math::cross(s, f);
Mat4 result = Mat4::get_identity();
result[0][0] = s[0];
result[1][0] = u[0];
result[2][0] = -f[0];
result[3][0] = 0.0f;
result[0][1] = s[1];
result[1][1] = u[1];
result[2][1] = -f[1];
result[3][1] = 0.0f;
result[0][2] = s[2];
result[1][2] = u[2];
result[2][2] = -f[2];
result[3][2] = 0.0f;
result[0][3] = -Math::dot(s, eye);
result[1][3] = -Math::dot(u, eye);
result[2][3] = Math::dot(f, eye);
result[3][3] = 1.0f;
return result;
}
} // namespace MY::CG
4.3 投影变换
// ProjectionTransformation.hpp
#pragma once
#include <cstdint>
#include "Matrix.hpp"
namespace MY::CG
{
// 投影类型枚举
enum class ProjectionType : uint8_t
{
Perspective, // 透视投影
Orthographic // 正交投影
};
// 透视投影参数结构体
struct PerspectiveParam
{
float fov; // 视野角度,单位为度
float aspect; // 宽高比
float near; // 近平面距离
float far; // 远平面距离
};
// 正交投影参数结构体
struct OrthographicParam
{
float left; // 左边界
float right; // 右边界
float bottom; // 下边界
float top; // 上边界
float near; // 近平面距离
float far; // 远平面距离
};
// 投影参数结构体,包含投影类型和对应的参数
struct ProjectionParam
{
ProjectionType type; // 投影类型
union
{
PerspectiveParam perspective; // 透视投影参数
OrthographicParam orthographic; // 正交投影参数
};
};
/**
* 获取投影矩阵
* @param param 投影参数,包含投影类型和对应的参数
* @return 投影矩阵,类型为 Mat4
*/
Mat4 get_projection_matrix(const ProjectionParam& param);
/**
* 获取透视投影矩阵
* @param fov 视野角度,单位为度
* @param aspect 宽高比
* @param near 近平面距离
* @param far 远平面距离
* @return 透视投影矩阵,类型为 Mat4
*/
Mat4 get_perspective_matrix(float fov, float aspect, float near, float far);
/**
* 获取透视投影矩阵
* @param param 透视投影参数,包含视野角度、宽高比、近平面距离和远平面距离
* @return 透视投影矩阵,类型为 Mat4
*/
Mat4 get_perspective_matrix(const PerspectiveParam& param);
/**
* 获取正交投影矩阵
* @param left 左边界
* @param right 右边界
* @param bottom 下边界
* @param top 上边界
* @param near 近平面距离
* @param far 远平面距离
* @return 正交投影矩阵,类型为 Mat4
*/
Mat4 get_orthographic_matrix(float left, float right, float bottom, float top, float near, float far);
/**
* 获取正交投影矩阵
* @param param 正交投影参数,包含左边界、右边界、下边界、上边界、近平面距离和远平面距离
* @return 正交投影矩阵,类型为 Mat4
*/
Mat4 get_orthographic_matrix(const OrthographicParam& param);
} // namespace MY::CG
// ProjectionTransformation.cpp
#include <cmath>
#include <numbers>
#include "ProjectionTransformation.hpp"
namespace MY::CG
{
Mat4 get_perspective_matrix(float fov, float aspect, float near, float far)
{
// 将角度转换为弧度
float fov_rad = fov * std::numbers::pi / 180.0f;
// 计算焦距
float f = 1.0f / std::tan(fov_rad / 2.0f);
Mat4 result;
result[0][0] = f / aspect;
result[1][1] = f;
result[2][2] = (far + near) / (near - far);
result[2][3] = -1.0f;
result[3][2] = (2.0f * far * near) / (near - far);
return result;
}
Mat4 get_orthographic_matrix(float left, float right, float bottom, float top, float near, float far)
{
Mat4 result;
result[0][0] = 2.0f / (right - left);
result[1][1] = 2.0f / (top - bottom);
result[2][2] = -2.0f / (far - near);
result[3][0] = -(right + left) / (right - left);
result[3][1] = -(top + bottom) / (top - bottom);
result[3][2] = -(far + near) / (far - near);
result[3][3] = 1.0f;
return result;
}
Mat4 get_projection_matrix(const ProjectionParam& param)
{
if (param.type == ProjectionType::Perspective)
{
return get_perspective_matrix(param.perspective);
}
else
{
return get_orthographic_matrix(param.orthographic);
}
}
Mat4 get_perspective_matrix(const PerspectiveParam& param)
{
return get_perspective_matrix(param.fov, param.aspect, param.near, param.far);
}
Mat4 get_orthographic_matrix(const OrthographicParam& param)
{
return get_orthographic_matrix(param.left, param.right, param.bottom, param.top, param.near, param.far);
}
} // namespace MY::CG
4.4 一个简单的摄像机例子
#pragma once
#include "Vector.hpp"
#include "Matrix.hpp"
#include "ViewTransformation.hpp"
namespace MY
{
/**
* @brief 一个简单的相机类
*/
class Camera
{
public:
Camera() = default;
~Camera() = default;
public:
// setters 和 getters
void set_position(const Vec3& position) { m_position = position; }
void set_forward(const Vec3& forward) { m_forward = forward; }
void set_up(const Vec3& up) { m_up = up; }
Vec3 get_position() const { return m_position; }
Vec3 get_forward() const { return m_forward; }
Vec3 get_up() const { return m_up; }
public:
/**
* 获取视图矩阵
*/
Mat4 get_view_matrix() const
{
return CG::get_view_matrix(m_position, m_position + m_forward, m_up);
}
private:
Vec3 m_position; // 相机位置
Vec3 m_forward; // 相机前向向量
Vec3 m_up; // 相机上向量
inline Vec3 right() const { return Math::cross(m_forward, m_up); } // 相机右向量
};
} // namespace MY


