图形学数学基础:向量、矩阵、坐标变换与投影推导

从向量与矩阵出发,深入推导图形学核心变换链:Model-World-View-Clip-NDC-Screen、LookAt矩阵、正交与透视投影、四元数插值、齐次坐标及法线变换。附C++与GLSL代码实现。

图形学的本质是用数学描述三维世界并在二维屏幕上呈现。无论是 OpenGL、Vulkan 还是 DirectX,底层都依赖同一套线性代数体系。本文从向量与矩阵基础出发,完整推导坐标变换链、投影矩阵、四元数旋转以及法线变换等核心内容,并附带 C++ 与 GLSL 代码实现。

1. 向量运算

图形学中向量同时表示方向和有向线段。三维向量 $\mathbf{v} = (v_x, v_y, v_z)$ 是构建一切变换的基石。

点积(Dot Product)

$$\mathbf{a} \cdot \mathbf{b} = |\mathbf{a}||\mathbf{b}|\cos\theta = a_x b_x + a_y b_y + a_z b_z$$

几何意义:度量两个向量的方向相似程度。点积为正说明夹角小于 90°,为零则垂直,为负则方向相反。在图形学中广泛用于光照计算(如 Lambert 漫反射 $I = \mathbf{n} \cdot \mathbf{l}$)和背面剔除。

叉积(Cross Product)

$$\mathbf{a} \times \mathbf{b} = (a_y b_z - a_z b_y,; a_z b_x - a_x b_z,; a_x b_y - a_y b_x)$$

结果向量垂直于 $\mathbf{a}$ 和 $\mathbf{b}$ 张成的平面,方向遵循右手定则。模长 $|\mathbf{a} \times \mathbf{b}| = |\mathbf{a}||\mathbf{b}|\sin\theta$ 等于平行四边形面积。图形学中用于计算法向量、切线空间和面积加权法线。

归一化

将向量缩放到单位长度:$\hat{\mathbf{v}} = \frac{\mathbf{v}}{|\mathbf{v}|}$,其中 $|\mathbf{v}| = \sqrt{v_x^2 + v_y^2 + v_z^2}$。归一化法线是渲染管线中光照计算的前提。

C++ 实现:

struct Vec3 {
    float x, y, z;
    float dot(const Vec3& o) const {
        return x * o.x + y * o.y + z * o.z;
    }
    Vec3 cross(const Vec3& o) const {
        return {
            y * o.z - z * o.y,
            z * o.x - x * o.z,
            x * o.y - y * o.x
        };
    }
    float length() const { return std::sqrt(dot(*this)); }
    Vec3 normalized() const {
        float len = length();
        return { x / len, y / len, z / len };
    }
};

GLSL 原生支持:

float d = dot(a, b);
vec3 c = cross(a, b);
vec3 n = normalize(v);

2. 矩阵运算

变换矩阵统一了平移、旋转和缩放操作。3D 图形学中使用 4x4 矩阵配合齐次坐标。

矩阵乘法

设 $\mathbf{A}$ 为 $m \times n$ 矩阵,$\mathbf{B}$ 为 $n \times p$ 矩阵,则:

$$(\mathbf{A}\mathbf{B}){ij} = \sum{k=1}^{n} A_{ik} B_{kj}$$

矩阵乘法不满足交换律:$\mathbf{A}\mathbf{B} \neq \mathbf{B}\mathbf{A}$。变换的组合顺序从右向左阅读,如 $\mathbf{M} = \mathbf{T}\mathbf{R}\mathbf{S}$ 表示先缩放、再旋转、最后平移。

转置与逆

转置:$(\mathbf{A}^\mathsf{T}){ij} = A{ji}$。若 $\mathbf{A}^\mathsf{T} = \mathbf{A}^{-1}$,则 $\mathbf{A}$ 为正交矩阵。所有纯旋转矩阵都是正交矩阵,其转置即逆,计算代价远低于通用矩阵求逆。

逆矩阵满足 $\mathbf{A}\mathbf{A}^{-1} = \mathbf{I}$。对于非奇异方阵可用伴随矩阵法或 Gaussian-Jordan 消元求解。

C++ 实现 4x4 矩阵乘法:

struct Mat4 {
    float m[4][4];
    Mat4 operator*(const Mat4& o) const {
        Mat4 r{};
        for (int i = 0; i < 4; ++i)
            for (int j = 0; j < 4; ++j)
                for (int k = 0; k < 4; ++k)
                    r.m[i][j] += m[i][k] * o.m[k][j];
        return r;
    }
    Mat4 transpose() const {
        Mat4 r;
        for (int i = 0; i < 4; ++i)
            for (int j = 0; j < 4; ++j)
                r.m[i][j] = m[j][i];
        return r;
    }
};

3. 坐标系与左右手原则

三维坐标系分为左手系和右手系:

  • 右手系:右手四指从 X 轴向 Y 轴弯曲,拇指指向 Z 轴正方向。OpenGL 传统使用右手系。
  • 左手系:使用左手做同样操作,拇指指向 Z 轴正方向。DirectX 默认使用左手系,Vulkan 的 NDC 也是左手系。

叉积的隐含前提就是右手定则,因此不同坐标系下叉积结果方向可能相反。跨 API 开发时必须理解这一差异,否则法线、剔除面和旋转方向将全部出错。

4. 齐次坐标

三维点用四元组 $(x, y, z, w)$ 表示,其中 $w$ 为齐次分量:

  • 当 $w = 1$ 时表示点(可平移)
  • 当 $w = 0$ 时表示方向向量(不受平移影响)

透视除法将齐次坐标映射回三维:$(x’, y’, z’) = (\frac{x}{w}, \frac{y}{w}, \frac{z}{w})$。这一机制使得平移可以融入矩阵乘法,也让透视投影得以用线性变换表达。

5. 坐标变换链

三维顶点从模型空间到屏幕像素,经历完整的变换链:

$$\mathbf{v}{\text{screen}} = \mathbf{M}{\text{viewport}} \cdot \mathbf{M}{\text{proj}} \cdot \mathbf{M}{\text{view}} \cdot \mathbf{M}{\text{model}} \cdot \mathbf{v}{\text{local}}$$

Model 矩阵

将顶点从模型空间(Local Space)变换到世界空间(World Space)。由缩放 $\mathbf{S}$、旋转 $\mathbf{R}$、平移 $\mathbf{T}$ 组合而成:

$$\mathbf{M}_{\text{model}} = \mathbf{T} \cdot \mathbf{R} \cdot \mathbf{S}$$

View 矩阵

将世界空间变换到以摄像机为原点的观察空间(View Space)。摄像机位于世界坐标 $\mathbf{eye}$,注视 $\mathbf{at}$ 方向,上向量为 $\mathbf{up}$。

Projection 矩阵

将观察空间变换到裁剪空间(Clip Space)。随后 GPU 自动执行透视除法得到 NDC(归一化设备坐标),最后通过视口变换映射到屏幕像素。

各空间坐标范围

空间坐标范围
Local/Model由建模工具决定
World任意浮点范围
View摄像机前向为 -Z
Clip$[-w, w]$
NDC(OpenGL)$[-1, 1]^3$
NDC(Vulkan/DirectX)XY: $[-1,1]$,Z: $[0,1]$
Screen$[0, \text{width}] \times [0, \text{height}]$

6. LookAt 矩阵推导

View 矩阵的本质是将世界坐标系重新基化为以摄像机为中心的坐标系。

设摄像机位置 $\mathbf{e}$,目标点 $\mathbf{t}$:

  1. 前向轴(摄像机朝向的反方向):$\mathbf{f} = \text{normalize}(\mathbf{e} - \mathbf{t})$
  2. 右向轴:$\mathbf{s} = \text{normalize}(\mathbf{up} \times \mathbf{f})$
  3. 上向轴:$\mathbf{u} = \mathbf{f} \times \mathbf{s}$

View 矩阵由旋转矩阵 $\mathbf{R}$ 和平移矩阵 $\mathbf{T}$ 组成。旋转部分的三行就是新的基向量,平移部分将原点移到摄像机位置:

$$\mathbf{M}_{\text{view}} = \begin{bmatrix}
s_x & s_y & s_z & -\mathbf{s} \cdot \mathbf{e} \
u_x & u_y & u_z & -\mathbf{u} \cdot \mathbf{e} \
f_x & f_y & f_z & -\mathbf{f} \cdot \mathbf{e} \
0 & 0 & 0 & 1
\end{bmatrix}$$

注意前向轴使用的是 $\mathbf{e} - \mathbf{t}$ 而非 $\mathbf{t} - \mathbf{e}$。在右手系中,摄像机观察方向的前向为 -Z,因此需要取反。

C++ 实现:

Mat4 lookAt(Vec3 eye, Vec3 at, Vec3 up) {
    Vec3 f = (eye - at).normalized();
    Vec3 s = up.cross(f).normalized();
    Vec3 u = f.cross(s);
    Mat4 m = {
        s.x,  s.y,  s.z,  -s.dot(eye),
        u.x,  u.y,  u.z,  -u.dot(eye),
        f.x,  f.y,  f.z,  -f.dot(eye),
        0.0f, 0.0f, 0.0f, 1.0f
    };
    return m;
}

GLSL(GLM 风格)直接使用:

uniform mat4 view;
vec4 viewPos = view * worldPos;

7. 正交投影矩阵推导

正交投影保持平行线平行,无近大远小效果。定义视景体为一个轴对齐包围盒:

  • 左右边界:$l$ 和 $r$
  • 下上边界:$b$ 和 $t$
  • 近远裁剪面:$n$ 和 $f$

第一步将视景体中心移到原点并缩放为 $[-1,1]^3$:

$$x_{\text{ndc}} = \frac{2x}{r-l} - \frac{r+l}{r-l}, \quad y_{\text{ndc}} = \frac{2y}{t-b} - \frac{t+b}{t-b}, \quad z_{\text{ndc}} = \frac{2z}{f-n} - \frac{f+n}{f-n}$$

写成矩阵形式(OpenGL 右手系,NDC Z 在 $[-1,1]$):

$$\mathbf{M}_{\text{ortho}} = \begin{bmatrix}
\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}{f-n} & -\frac{f+n}{f-n} \
0 & 0 & 0 & 1
\end{bmatrix}$$

注意 Z 轴使用负号是因为右手系中观察方向为 -Z,需要反转 Z 的映射。

C++ 实现:

Mat4 ortho(float l, float r, float b, float t, float n, float f) {
    return {
        2/(r-l),   0,         0,         -(r+l)/(r-l),
        0,         2/(t-b),   0,         -(t+b)/(t-b),
        0,         0,        -2/(f-n),   -(f+n)/(f-n),
        0,         0,         0,         1
    };
}

8. 透视投影矩阵推导

透视投影模拟人眼近大远小的视觉效果,是 3D 渲染的核心。推导过程分为两步:首先将视景体内的点透视映射到近平面,然后归一化到 NDC。

相似三角形关系

对于观察空间中的点 $(x, y, z)$,设近裁剪面距离为 $n$,则透视投影到近平面的坐标为:

$$x’ = \frac{n \cdot x}{-z}, \quad y’ = \frac{n \cdot y}{-z}$$

注意 $z$ 为负值(右手系中相机前向为 -Z),因此用 $-z$ 保证除数为正。

归一化到 $[-1, 1]$

设垂直视场角为 FOV,宽高比为 aspect,近远面为 $n$ 和 $f$:

$$t = n \cdot \tan(\frac{\text{FOV}}{2}), \quad r = t \cdot \text{aspect}$$

透视投影矩阵必须同时完成映射并保持 $z$ 用于深度测试。关键是利用齐次坐标的 $w$ 分量存 $-z$,让 GPU 后续执行透视除法。

设矩阵乘法结果为 $(x_c, y_c, z_c, w_c) = \mathbf{M}_{\text{persp}} \cdot (x, y, z, 1)$,需要满足:

  • 透视除法后 $x_{\text{ndc}} = \frac{x_c}{w_c} = \frac{x}{-z} \cdot \frac{n}{r}$
  • 透视除法后 $y_{\text{ndc}} = \frac{y_c}{w_c} = \frac{y}{-z} \cdot \frac{n}{t}$
  • $w_c = -z$

对于 Z 的映射,需要满足:

  • $z = -n$ 时 $z_{\text{ndc}} = -1$
  • $z = -f$ 时 $z_{\text{ndc}} = 1$

由于透视除法后 $z_{\text{ndc}} = \frac{z_c}{w_c} = \frac{z_c}{-z}$,设 $z_c = A \cdot z + B$,代入边界条件:

$$\frac{A(-n) + B}{n} = -1, \quad \frac{A(-f) + B}{f} = 1$$

解得:$A = -\frac{f+n}{f-n}$,$B = -\frac{2fn}{f-n}$

最终矩阵(OpenGL 右手系):

$$\mathbf{M}_{\text{persp}} = \begin{bmatrix}
\frac{n}{r} & 0 & 0 & 0 \
0 & \frac{n}{t} & 0 & 0 \
0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n} \
0 & 0 & -1 & 0
\end{bmatrix}$$

C++ 实现:

Mat4 perspective(float fovY, float aspect, float n, float f) {
    float tanHalfFov = std::tan(fovY / 2.0f);
    float t = n * tanHalfFov;
    float r = t * aspect;
    return {
        n/r,  0,    0,              0,
        0,    n/t,  0,              0,
        0,    0,   -(f+n)/(f-n),  -2*f*n/(f-n),
        0,    0,   -1,              0
    };
}

Vulkan/DirectX 的 NDC Z 范围是 $[0,1]$,只需修改 Z 行:

// Vulkan 适配版本
0,    0,   -f/(f-n),  -f*n/(f-n),
0,    0,   -1,         0

9. MVP 矩阵在着色器中的应用

现代 GPU 管线中,顶点位变换通常在顶点着色器中完成:

#version 450

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inTexCoord;

layout(binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

layout(location = 0) out vec3 fragNormal;
layout(location = 1) out vec2 fragTexCoord;

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
    fragNormal = mat3(transpose(inverse(ubo.model))) * inNormal;
    fragTexCoord = inTexCoord;
}

注意法线变换使用 transpose(inverse(model)),这将在下一节解释。

10. 法线变换与逆转置矩阵

法向量不能像顶点那样直接用 Model 矩阵变换。当 Model 矩阵包含非均匀缩放时,直接变换会导致法线不再垂直于表面。

设表面切线 $\mathbf{t}$ 满足 $\mathbf{n} \cdot \mathbf{t} = 0$。变换后切线为 $\mathbf{t}’ = \mathbf{M}\mathbf{t}$,需要找到法线变换矩阵 $\mathbf{G}$ 使得变换后法线仍与变换后切线垂直:

$$(\mathbf{G}\mathbf{n}) \cdot (\mathbf{M}\mathbf{t}) = 0$$

展开:

$$(\mathbf{G}\mathbf{n})^\mathsf{T} (\mathbf{M}\mathbf{t}) = \mathbf{n}^\mathsf{T} \mathbf{G}^\mathsf{T} \mathbf{M} \mathbf{t} = 0$$

要使其对任意 $\mathbf{t}$ 成立,需要 $\mathbf{G}^\mathsf{T} \mathbf{M} = \mathbf{I}$,即:

$$\mathbf{G} = (\mathbf{M}^{-1})^\mathsf{T}$$

这就是逆转置矩阵(Inverse-Transpose)。对于仅含旋转的刚体变换,$\mathbf{M}^{-1} = \mathbf{M}^\mathsf{T}$,因此逆转置就是 $\mathbf{M}$ 本身,法线可以直接用模型矩阵变换。但当存在缩放时必须使用逆转置。

// 在 CPU 端预计算并传入(推荐)
mat3 normalMatrix = mat3(transpose(inverse(modelMatrix)));

// 顶点着色器中
vec3 worldNormal = normalize(normalMatrix * inNormal);

11. 四元数与三维旋转

旋转矩阵虽然直观,但存在万向锁(Gimbal Lock)问题,且插值困难。四元数提供了一种优雅的三维旋转表示。

定义

单位四元数 $\mathbf{q} = w + x\mathbf{i} + y\mathbf{j} + z\mathbf{k}$ 可表示为:

$$\mathbf{q} = \cos\frac{\theta}{2} + \sin\frac{\theta}{2}(u_x\mathbf{i} + u_y\mathbf{j} + u_z\mathbf{k})$$

其中 $(u_x, u_y, u_z)$ 是旋转轴单位向量,$\theta$ 是旋转角度。

四元数乘法

$$\mathbf{q}_1 \otimes \mathbf{q}_2 = (w_1 w_2 - \mathbf{v}_1 \cdot \mathbf{v}_2,; w_1\mathbf{v}_2 + w_2\mathbf{v}_1 + \mathbf{v}_1 \times \mathbf{v}_2)$$

旋转的组合通过四元数乘法实现,结果仍为单位四元数。向量 $\mathbf{p}$ 的旋转通过:$\mathbf{p}’ = \mathbf{q} \otimes \mathbf{p} \otimes \mathbf{q}^{-1}$,其中 $\mathbf{p}$ 以纯虚四元数 $(0, \mathbf{p})$ 表示。

SLERP 插值

球面线性插值(Spherical Linear Interpolation)在旋转之间产生恒角速度的最短路径:

$$\text{slerp}(\mathbf{q}_1, \mathbf{q}_2, t) = \frac{\sin((1-t)\theta)}{\sin\theta}\mathbf{q}_1 + \frac{\sin(t\theta)}{\sin\theta}\mathbf{q}_2$$

其中 $\cos\theta = \mathbf{q}_1 \cdot \mathbf{q}_2$。当夹角接近 0 时退化为线性插值(LERP)避免除零。

struct Quat {
    float w, x, y, z;
    float dot(const Quat& o) const {
        return w*o.w + x*o.x + y*o.y + z*o.z;
    }
    Quat operator*(const Quat& o) const {
        return {
            w*o.w - x*o.x - y*o.y - z*o.z,
            w*o.x + x*o.w + y*o.z - z*o.y,
            w*o.y - x*o.z + y*o.w + z*o.x,
            w*o.z + x*o.y - y*o.x + z*o.w
        };
    }
    Quat normalized() const {
        float len = std::sqrt(dot(*this));
        return { w/len, x/len, y/len, z/len };
    }
};

Quat slerp(Quat a, Quat b, float t) {
    float cosTheta = a.dot(b);
    if (cosTheta < 0) { b = {-b.w, -b.x, -b.y, -b.z}; cosTheta = -cosTheta; }
    if (cosTheta > 0.9995f) { // 近似线性
        return (Quat{a.w + t*(b.w-a.w), a.x + t*(b.x-a.x),
                     a.y + t*(b.y-a.y), a.z + t*(b.z-a.z)}).normalized();
    }
    float theta = std::acos(cosTheta);
    float sinTheta = std::sin(theta);
    float w1 = std::sin((1-t)*theta) / sinTheta;
    float w2 = std::sin(t*theta) / sinTheta;
    return { a.w*w1 + b.w*w2, a.x*w1 + b.x*w2,
             a.y*w1 + b.y*w2, a.z*w1 + b.z*w2 };
}

四元数转旋转矩阵

Mat4 quatToMat4(Quat q) {
    q = q.normalized();
    float x2 = q.x*q.x, y2 = q.y*q.y, z2 = q.z*q.z;
    float xy = q.x*q.y, xz = q.x*q.z, yz = q.y*q.z;
    float wx = q.w*q.x, wy = q.w*q.y, wz = q.w*q.z;
    return {
        1-2*(y2+z2), 2*(xy-wz),   2*(xz+wy),   0,
        2*(xy+wz),   1-2*(x2+z2), 2*(yz-wx),   0,
        2*(xz-wy),   2*(yz+wx),   1-2*(x2+y2), 0,
        0,           0,           0,           1
    };
}

12. 总结与最佳实践

技术点关键公式 / 要点
变换顺序$\mathbf{M} = \mathbf{T}\mathbf{R}\mathbf{S}$,先缩放、再旋转、最后平移
LookAt基向量为行,前向取负,平移分量是逆平移在基上的投影
透视投影利用 $w_c = -z$ 实现透视除法;Z 行满足 $-n \to -1$,$-f \to 1$
法线变换非均匀缩放必须使用 $(\mathbf{M}^{-1})^\mathsf{T}$
四元数无万向锁、易插值;单位四元数的逆即共轭(Negate xyz)
NDC 差异OpenGL Z: $[-1,1]$,Vulkan/DirectX Z: $[0,1]$

理解这些数学推导不仅能写出正确的渲染代码,更能 Debug UV 错乱、法线闪烁、视锥裁剪异常等疑难问题。图形学的每一行矩阵乘法背后,都是严格的线性代数原理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. GPU 架构与并行计算:从 SIMT 到 GPGPU 编程