需要预先了解:


1. 模型变换(Model Transformation)

模型变换(model transformation)把顶点从模型空间(model space,也称局部空间 local space)变换到世界空间(world space),对应的矩阵称为模型矩阵(model matrix),一般用符号 MM 来表示。

计算机图形学中,我们经常会提到“空间”的概念,这里可以把空间理解为一个坐标系,用于描述物体的位置和方向。

具体地讲,通常一个模型在建模软件里被创造出来,里面需要存储顶点之类的模型的数据,建模的过程其实就是在模型空间中,原点通常落在模型的中心或脚底,轴向由建模习惯决定(Blender 默认 zz 轴向上,glTF 和多数引擎默认 yy 轴向上)。这些数字只有在"这个模型自己"的语境下才有意义。世界空间则是整个场景(scene)共享的坐标系,物体的摆放、光源与摄像机的位置都在这里描述,后续会讲到的视图变换与光照计算也都在这个空间里进行。


1.1 模型矩阵的组成

模型矩阵一般由平移(translation)、旋转(rotation)、缩放(scale)三种基本变换拼成,简称 TRS:

M=T⋅R⋅SM = T \cdot R \cdot S

三种矩阵的具体形式见 变换。写成 T⋅R⋅ST \cdot R \cdot S 也表示了我们进行处理的顺序:顶点会依次经历缩放、旋转、平移。这个顺序很直观,先在模型自己的坐标系里进行缩放旋转,设置好模型的位置,然后再将其移动到世界空间的具体的位置。

以单位立方体上的顶点 v=(1,0,0)Tv = (1, 0, 0)^T 为例,取缩放 S(2,2,2)S(2, 2, 2)、绕 zz 轴旋转 90∘90^{\circ}、平移 T(3,0,0)T(3, 0, 0):

v→  S  (2,0,0)→  Rz  (0,2,0)→  T  (3,2,0)v \xrightarrow{\;S\;} (2, 0, 0) \xrightarrow{\;R_z\;} (0, 2, 0) \xrightarrow{\;T\;} (3, 2, 0)

如果把顺序换成 R⋅S⋅TR \cdot S \cdot T(先平移),同一个顶点会先被搬到 (4,0,0)(4, 0, 0),再被缩放成 (8,0,0)(8, 0, 0):平移量跟着被放大了 2 倍,物体落到了完全错误的位置。


1.2 旋转的顺序

缩放与平移各只有一个矩阵,而旋转 RR 通常由绕三个坐标轴的旋转拼出来:Rx(α)R_x(\alpha)、Ry(β)R_y(\beta)、Rz(γ)R_z(\gamma) 之间同样不可交换,必须先约定一个固定顺序,例如

R=Ry(β) Rx(α) Rz(γ)R = R_y(\beta)\, R_x(\alpha)\, R_z(\gamma)

用三个角度描述朝向的方式称为欧拉角(Euler angles)。欧拉角直观、便于手工编辑,但有两个毛病:万向节死锁(gimbal lock,某个角度下两个旋转轴重合,丢失一个自由度)和插值不连续。所以引擎内部通常用四元数(quaternion)保存朝向,只在需要矩阵时转成 RR;而用户界面里编辑的仍是欧拉角,两者之间来回转换。

注:不同软件默认的顺序不同(Unity 的 Quaternion.Euler 按 z→x→yz \to x \to y 依次施加,即 R=RyRxRzR = R_y R_x R_z;许多离线渲染器默认 R=RzRyRxR = R_z R_y R_x)。跨工具链路导入导出时,欧拉角顺序不一致会得到完全不同的朝向。


1.3 变换的中心

RR 与 SS 都是绕模型空间的原点作用。想让物体绕别的点旋转(比如绕它自己的某一角),就要先把那个点搬到原点,旋转后再搬回去:

M=T(c) R T(−c)M = T(c)\, R\, T(-c)

其中 cc 是旋转中心。缩放同理。这也是为什么建模时习惯把枢轴(pivot)放在模型的合理位置——模型空间的原点就是枢轴所在的位置。


1.4 场景图中的模型变换

带父子层级的场景里,每个物体只保存自己的本地矩阵(local matrix,即相对父物体的 TRS),世界矩阵由父到子依次相乘得到:

Mworld=Mparent⋅MlocalM_{world} = M_{parent} \cdot M_{local}

父物体一动,整棵子树的世界矩阵都跟着变,这是 变换 中结合律的直接应用。为了让深层级不必每帧全量重算,引擎会给世界矩阵加缓存和脏标记(dirty flag),只在父级或自身变化后才重算。


1.5 法线的变换

顶点位置乘 MM 得到世界空间的位置,但法线不能这么变换。法线是垂直于表面的向量,只有 MM 是刚体变换(旋转 + 平移)或等比缩放时,直接用左上 3×33 \times 3 才不出错;一旦出现非等比缩放,垂直关系就会被破坏,法线跟着歪掉。

正确的做法是乘模型矩阵左上 3×33 \times 3 的逆转置(inverse transpose),这个矩阵称为法线矩阵(normal matrix):

N=(M−1)TN = \left(M^{-1}\right)^{T}

推导见 Phong模型与Blinn-Phong模型,完整的坐标空间链路见 坐标空间总览。


2. 视图变换(View Transformation)

在这里,我们要引入一个摄像机(camera)的概念,它并不是一个真实的用于摄影的物理设备,而是一个概念上的用于观察世界的点:我们约定可以确定摄像机的空间状态(位置与朝向,这里的朝向可以用欧拉角、四元数或两个垂直的向量来表示),以及摄像机的可视区域(就是摄像机能看到的空间范围)。我们最后实际渲染的画面就是摄像机可视区域到屏幕的投影。

三维场景中,物体、摄像机、光源都摆放在同一个世界坐标系(world space)下。渲染的画面取决于摄像机从当前位置、当前朝向看出去的样子。要把"摄像机看到的世界"描述出来,需要先把场景从世界坐标系变换到以摄像机为中心的坐标系中,这一步就是视图变换(View Transformation,也称摄像机变换)。

具体地说,视图变换的主要目的是将相机放置在原点上,并调整相机的朝向,使其看向 zz 轴负半轴的方向,同时 yy 轴指向上方, xx 轴指向右方。

此变换会对所有物体进行,因此,只要我们可以保证所有物体的相对位置不变(整个世界都朝相同的地方平移同样距离、都绕同一个轴旋转同样角度的操作,不会改变物体之间的相对位置),就可以实现将所有物体同步变换到摄像机空间中,确保摄像机看到的内容正确。


2.1 虚拟摄像机的设置

先定义一台虚拟摄像机,它由三个要素决定:

要素 含义
位置(position)e→\overrightarrow{e} 摄像机在世界坐标系中的位置
看向方向(look-at direction)f^\hat{f} 摄像机朝哪个方向看,f 取自 forward(前方)
上方向(up direction)u^\hat{u} 摄像机的"头顶"指向,用于固定画面不倾斜,u 取自 up(上方)

只看位置和朝向,画面还可以随意翻转(比如倒立看),因此还需要上方向来固定摄像机的姿态。上方向通常取世界坐标系 yy 轴的附近,不必与 yy 轴完全重合。

  • 可以通过调整 u^\hat{u} 来实现滚动(roll)效果:u^\hat{u} 绕着 f^\hat{f} 旋转,画面就会绕着中心点旋转。
  • 事实上,u^\hat{u} 只要不与 f^\hat{f} 共线即可,在实际的计算过程中,u^\hat{u} 沿着 f^\hat{f} 的分量没有意义,只有垂直于 f^\hat{f} 的分量才会影响最终结果。

2.2 摄像机空间(Camera Space)

为了方便计算,我们博客里约定摄像机永远处于一个标准位置:

  • 位置在原点;
  • 看向 −z-z 轴方向(右手系,zz 轴正方向朝后);
  • 上方向为 yy 轴正方向。

以标准位置摄像机为原点建立的坐标系称为摄像机空间(camera space,也称视图空间、view space)。世界坐标系下的所有物体都要先变换到摄像机空间中,才能进入后续的投影、裁剪等阶段。

让摄像机看向 −z-z 可以使深度值随距离单调变化,与后续 z 相关的计算保持一致


2.3 视图变换的原理

视图变换把世界坐标系下的物体变换到摄像机坐标系下。核心想法是:先移动摄像机到原点,再转动摄像机,让物体跟着一起动。因为物体与摄像机的相对位置始终不变,摄像机看到的内容也就保持不变。

ViewTranformation

具体分两步。

第一步:平移,把摄像机移动到原点。

摄像机在世界坐标系中的位置为 e→=(xe,ye,ze)\overrightarrow{e} = (x_e, y_e, z_e),把它平移到原点,平移量为 −e→-\overrightarrow{e}:

Tview=(100−xe010−ye001−ze0001)T_{view} = \begin{pmatrix} 1 & 0 & 0 & -x_e \\ 0 & 1 & 0 & -y_e \\ 0 & 0 & 1 & -z_e \\ 0 & 0 & 0 & 1 \end{pmatrix}

这一步必须对场景中所有物体同时进行,所有物体跟着一起平移,保持相对位置不变,画面才会正确。

第二步:旋转,把摄像机的朝向转到标准方向。

摄像机已经在原点,此时旋转它,让 f^\hat{f} 指向 −z-z、u^\hat{u} 指向 yy。同时也会让物体绕着摄像机(原点)进行旋转,因此旋转摄像机和旋转物体用的是同一个矩阵。

实际计算中,往往需要用到右方向(right direction)r^\hat{r},它与 f^\hat{f}、u^\hat{u} 互相垂直,构成一个右手坐标系,因此只需要对 f^\hat{f}、u^\hat{u} 做叉乘就能得到 r^\hat{r}:

r^=f^×u^\hat{r} = \hat{f} \times \hat{u}

直接构造 RviewR_{view} 有些绕,可以先构造它的逆:把 xx 轴旋转到 r^\hat{r}(摄像机的右方向)、yy 轴旋转到 u^\hat{u}、zz 轴旋转到 −f^-\hat{f}:

Rview−1=(xr^xu^x−f^0yr^yu^y−f^0zr^zu^z−f^00001)R_{view}^{-1} = \begin{pmatrix} x_{\hat{r}} & x_{\hat{u}} & x_{-\hat{f}} & 0 \\ y_{\hat{r}} & y_{\hat{u}} & y_{-\hat{f}} & 0 \\ z_{\hat{r}} & z_{\hat{u}} & z_{-\hat{f}} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

具体的计算思路见 将已有坐标系旋转到一个新的坐标系。

旋转矩阵是正交矩阵(orthogonal matrix),它的逆等于转置,因此:

Rview=(Rview−1)T=(xr^yr^zr^0xu^yu^zu^0x−f^y−f^z−f^00001)R_{view} = (R_{view}^{-1})^{T} = \begin{pmatrix} x_{\hat{r}} & y_{\hat{r}} & z_{\hat{r}} & 0 \\ x_{\hat{u}} & y_{\hat{u}} & z_{\hat{u}} & 0 \\ x_{-\hat{f}} & y_{-\hat{f}} & z_{-\hat{f}} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

把两步合并,得到完整的视图变换矩阵:

Mview=Rview⋅TviewM_{view} = R_{view} \cdot T_{view}

世界坐标系下的任意点,左乘 MviewM_{view} 就得到它在摄像机空间中的坐标。


3. 投影变换(Projection Transformation)

先明确一个前提条件,进行投影变换之前,所有的点已经经过了模型变换和视图变换,此时它们的位置处于摄像机空间中。

接下来的任务是对摄像机的可视区域做变换,把所有的点放进一个标准立方体内,我们未来的裁剪、光栅化等阶段都在这个立方体内进行。

再强调一遍,以下内容都在摄像机空间下进行,摄像机位于原点、看向 −z-z 方向。


3.1 正交投影(Orthographic Projection)

正交投影不考虑近大远小:平行的线投影后仍然平行,物体的大小不随距离改变。它的可视区域是一个长方体,由六个平面围成:

方向 范围
xx [l,r][l, r](left 到 right)
yy [b,t][b, t](bottom 到 top)
zz [f,n][f, n](far 到 near)

注意 zz 的范围写作 [f,n][f, n],这是因为摄像机看向 −z-z,近平面 z=nz = n 在数值上大于远平面 z=fz = f。

把任意长方体变换到标准立方体分两步:

第一步:平移

把长方体的中心平移到原点,中心坐标为 (r+l2,t+b2,n+f2)\left(\frac{r+l}{2}, \frac{t+b}{2}, \frac{n+f}{2}\right):

T=(100−r+l2010−t+b2001−n+f20001)T = \begin{pmatrix} 1 & 0 & 0 & -\frac{r+l}{2} \\ 0 & 1 & 0 & -\frac{t+b}{2} \\ 0 & 0 & 1 & -\frac{n+f}{2} \\ 0 & 0 & 0 & 1 \end{pmatrix}

第二步:缩放

把各轴长度缩放到 2。以 xx 轴为例,长度从 r−lr - l 缩到 2,缩放因子为 2r−l\frac{2}{r-l},其余两轴同理:

S=(2r−l00002t−b00002n−f00001)S = \begin{pmatrix} \frac{2}{r-l} & 0 & 0 & 0 \\ 0 & \frac{2}{t-b} & 0 & 0 \\ 0 & 0 & \frac{2}{n-f} & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}

合并两步,得到正交投影矩阵

根据先平移、后缩放的顺序去计算:

Mortho=S⋅T=(2r−l00−r+lr−l02t−b0−t+bt−b002n−f−n+fn−f0001)M_{ortho} = S \cdot T = \begin{pmatrix} \frac{2}{r-l} & 0 & 0 & -\frac{r+l}{r-l} \\ 0 & \frac{2}{t-b} & 0 & -\frac{t+b}{t-b} \\ 0 & 0 & \frac{2}{n-f} & -\frac{n+f}{n-f} \\ 0 & 0 & 0 & 1 \end{pmatrix}

这里不可以调换顺序,先缩放再平移会导致平移的距离实际上乘以了一个缩放比例的倒数,虽然还可以正确把长方体变换到边长为 2 的正方体,但是其位置会偏离原点


3.2 透视投影(Perspective Projection)

透视投影模拟人眼的视觉效果:近大远小,平行的线在远处汇聚。它的可视区域不是长方体,而是视锥体(frustum,四棱锥被近平面和远平面截出的部分)。

透视投影的关键思路:先把视锥体"挤压"(squish)成长方体,再对长方体做正交投影。挤压变换 Mpersp→orthoM_{persp \to ortho} 需要满足:

  • 近平面上的点保持不变;
  • 远平面上的点 z 坐标不变,x、y 按比例收缩;
  • 中间平面上的点 x、y 按比例收缩,z 发生非线性变化。

利用相似三角形可以算出挤压后的 x、y 坐标。从侧面看,视锥体内的点 (x,y,z)(x, y, z) 与原点连线在近平面 z=nz = n 上的交点满足:

x′=nzx,y′=nzyx' = \frac{n}{z} x, \quad y' = \frac{n}{z} y

z 方向的变化暂时未知,记为 z′=?z' = ?。在齐次坐标下,挤压变换写成:

(xyz1)→(nxznyz?1)∼(nxny?z)\begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} \to \begin{pmatrix} \frac{nx}{z} \\ \frac{ny}{z} \\ ? \\ 1 \end{pmatrix} \sim \begin{pmatrix} nx \\ ny \\ ? \\ z \end{pmatrix}

(乘以 zz 消去分母;齐次坐标下,点乘以非零常数表示同一个点。)

观察右侧向量:第一、二行是 nxnx、nyny,第四行是 zz,因此挤压矩阵形如:

Mpersp→ortho=(n0000n0000AB0010)M_{persp \to ortho} = \begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & A & B \\ 0 & 0 & 1 & 0 \end{pmatrix}

AA、BB 待定,用"近平面 z=nz = n 与远平面 z=fz = f 上的点挤压后 z 坐标不变"来求解:

  • 对 z=nz = n 的点,挤压后第三行应为 n2n^2:An+B=n2An + B = n^2
  • 对 z=fz = f 的点,挤压后第三行应为 f2f^2:Af+B=f2Af + B = f^2

解得 A=n+fA = n + f、B=−nfB = -nf,于是:

Mpersp→ortho=(n0000n0000n+f−nf0010)M_{persp \to ortho} = \begin{pmatrix} n & 0 & 0 & 0 \\ 0 & n & 0 & 0 \\ 0 & 0 & n+f & -nf \\ 0 & 0 & 1 & 0 \end{pmatrix}

完整的透视投影矩阵就是刚才所求的挤压矩阵左乘正交投影矩阵:

Mpersp=Mortho⋅Mpersp→orthoM_{persp} = M_{ortho} \cdot M_{persp \to ortho}

注:

  1. 挤压后的 z 坐标被非线性地压缩,离摄像机越远压缩得越厉害。这不会影响画面本身(透视效果已经由 x、y 的收缩体现),但会降低远处物体的深度精度,做深度测试(depth test)时需要注意
  2. 如果对 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