图形学的本质是用数学描述三维世界并在二维屏幕上呈现。无论是 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}$:
- 前向轴(摄像机朝向的反方向):$\mathbf{f} = \text{normalize}(\mathbf{e} - \mathbf{t})$
- 右向轴:$\mathbf{s} = \text{normalize}(\mathbf{up} \times \mathbf{f})$
- 上向轴:$\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 错乱、法线闪烁、视锥裁剪异常等疑难问题。图形学的每一行矩阵乘法背后,都是严格的线性代数原理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。