1. 为什么图形学需要专用数学库
三维图形渲染的每一帧,GPU 都要处理海量的线性代数运算:顶点变换、光照计算、投影、裁剪、光栅化。这些运算的对象是 固定维度的小向量(vec2/vec3/vec4) 和 小矩阵(mat3/mat4),而不是科学计算中动辄上万维的大矩阵。
通用线性代数库(如 Eigen、Armadillo)的强项是大规模矩阵分解、求解线性方程组、特征值计算等数值计算场景,它们为这些场景付出了额外的抽象与模板开销。而图形学数学库追求的是:
- 与着色器语言(GLSL/HLSL)一一对应:C++ 端的 glm::vec4 与 GLSL 的 vec4 语法几乎一致,便于 CPU/GPU 代码心智模型统一。
- 零抽象开销:所有运算在编译期展开,直接映射到 SSE/AVX 指令,-O2 下生成的汇编与手写 SIMD 相当。
- 严格的存储约定:图形 API(OpenGL/DirectX/Vulkan)对矩阵内存布局有硬性要求,库必须给出明确、可预测的内存排列。
- 即插即用:header-only、零依赖、无需链接,拷贝头文件即可使用,特别适合引擎、工具链、离线渲染器等场景。
GLM 就是这类库中事实上的标准------它被 Unreal Engine、Godot、很多开源渲染器以及大量教程项目直接或间接使用(Unreal 的 FVector/FMatrix 设计理念与之同源;Godot 的数学模块也从 GLSL 风格中汲取灵感)。
2. GLM 是什么:定位与设计哲学
GLM(OpenGL Mathematics) 是一个 header-only 的 C++ 模板数学库,由 Christophe Riccio 发起并维护,MIT 协议开源。它的核心目标用作者的话说是:"给 OpenGL 的着色器语言 GLSL 提供一套 C++ 实现"。
2.1 设计哲学一览
| 设计原则 | 具体表现 |
|---|---|
| GLSL 语法对齐 | glm::vec3 v = glm::vec3(1.0f, 2.0f, 3.0f); 与 GLSL 写法几乎相同 |
| Header-only 零依赖 | 只有 <glm/glm.hpp> 等头文件,不产编译产物,标准 C++11 起即可编译 |
| 模板驱动 | vec<4, float, defaultp> 是底层模板,vec4 是别名,精度与限定符可配 |
| 约定优于配置 | 默认列主序、右手坐标系、-1..1 深度范围,与 OpenGL 经典约定一致 |
| 宏开关控制行为 | GLM_FORCE_* 系列宏在包含头文件之前定义,可切换内存布局、精度、强制内联等 |
| 分层头文件 | 核心 <glm/glm.hpp> + 扩展 <glm/gtc/*.hpp> + 实验扩展 <glm/gtx/*.hpp> |
2.2 核心头文件分层
| 层级 | 头文件示例 | 内容 | 稳定性 |
|---|---|---|---|
| 核心 | glm/glm.hpp | vec/mat/qua 基础类型与运算符重载 | 稳定 |
| GTC(稳定扩展) | glm/gtc/matrix_transform.hpp | translate/rotate/scale/perspective/lookAt | 稳定 |
| GTC | glm/gtc/quaternion.hpp | 四元数运算、slerp、旋转矩阵转换 | 稳定 |
| GTC | glm/gtc/type_ptr.hpp | value_ptr 内存指针导出 | 稳定 |
| GTX(实验扩展) | glm/gtx/transform.hpp 等 | 组合变换、噪声、随机、投影增强 | 可能变化 |
经验法则:生产代码只用 glm.hpp + gtc/*;gtx/* 属于实验性质,接口可能在版本间变动,引入前需评估。
2.3 版本与许可证
- 当前稳定版 1.0.x(1.0.1 为 2024 年里程碑版本,修复了部分 UB 并调整了 CMake 集成)。
- MIT 许可:可自由商用、修改、分发,只需保留版权声明,不存在侵权风险,非常适合技术博客与开源项目引用。
3. 环境搭建与快速上手(完整可运行工程)
3.1 获取 GLM
方式一:包管理器
bash
# vcpkg
vcpkg install glm
# conan
conan install glm/1.0.1@
方式二:直接拷贝头文件(推荐用于学习)
bash
git clone --depth 1 https://github.com/g-truc/glm
# 只需 glm/ 目录下的头文件,复制进项目 include 路径即可
3.2 完整可运行工程:旋转的三角形与投影变换
下面给出一个可直接编译运行的最小工程。它不依赖 OpenGL/GLFW,纯控制台即可运行,用于验证 GLM 的核心数学能力:向量、矩阵变换、投影、四元数。
工程结构:
glm_demo/
├── CMakeLists.txt
└── main.cpp
CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(glm_demo CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 方式一:使用 vcpkg 安装的 glm
find_package(glm REQUIRED)
# 方式二:如果直接拷贝头文件,改用下面的 include 路径
# add_executable(glm_demo main.cpp)
# target_include_directories(glm_demo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/third_party")
add_executable(glm_demo main.cpp)
target_link_libraries(glm_demo PRIVATE glm::glm)
# 开启优化后 glm 才能生成最优汇编
target_compile_options(glm_demo PRIVATE /O2) # MSVC
# target_compile_options(glm_demo PRIVATE -O2) # GCC/Clang
main.cpp:
cpp
#include <glm/glm.hpp> // 核心类型
#include <glm/gtc/matrix_transform.hpp> // translate/rotate/perspective/lookAt
#include <glm/gtc/type_ptr.hpp> // value_ptr
#include <glm/gtc/quaternion.hpp> // 四元数
#include <glm/gtx/quaternion.hpp> // 四元数转矩阵(toMat4)
#include <iostream>
#include <iomanip>
// 打印 4x4 矩阵(按行打印,注意内存按列主序存储)
void printMat4(const glm::mat4& m, const char* name) {
std::cout << "=== " << name << " ===\n";
for (int r = 0; r < 4; ++r) {
for (int c = 0; c < 4; ++c) {
std::cout << std::setw(10) << std::fixed << std::setprecision(3)
<< m[c][r]; // m[列][行]:GLM 的列主序下标
}
std::cout << "\n";
}
std::cout << "\n";
}
int main() {
// ---------- 1. 向量基础运算 ----------
glm::vec3 a(1.0f, 2.0f, 3.0f);
glm::vec3 b(4.0f, 5.0f, 6.0f);
float dot = glm::dot(a, b); // 点积 = 32
glm::vec3 cross = glm::cross(a, b); // 叉积 = (-3, 6, -3)
glm::vec3 norm = glm::normalize(a); // 单位向量
std::cout << "dot(a,b) = " << dot << "\n";
std::cout << "cross(a,b) = " << cross.x << ", " << cross.y << ", " << cross.z << "\n";
std::cout << "length(a) = " << glm::length(a) << "\n\n";
// ---------- 2. 模型变换:先缩放,再旋转,最后平移 ----------
glm::mat4 model = glm::mat4(1.0f); // 单位矩阵
model = glm::translate(model, glm::vec3(1.0f, 0.0f, -2.0f)); // 平移
model = glm::rotate(model, glm::radians(45.0f), glm::vec3(0.0f, 1.0f, 0.0f)); // 绕 Y 轴旋转 45°
model = glm::scale(model, glm::vec3(2.0f)); // 均匀缩放 2 倍
printMat4(model, "Model Matrix (T * R * S)");
// 用矩阵变换一个顶点
glm::vec4 localPos(1.0f, 0.0f, 0.0f, 1.0f);
glm::vec4 worldPos = model * localPos;
std::cout << "local (1,0,0) -> world ("
<< worldPos.x << ", " << worldPos.y << ", " << worldPos.z << ")\n\n";
// ---------- 3. 视图矩阵:观察者位置与看向目标 ----------
glm::vec3 cameraPos(0.0f, 2.0f, 5.0f);
glm::vec3 target(0.0f, 0.0f, 0.0f);
glm::vec3 up(0.0f, 1.0f, 0.0f);
glm::mat4 view = glm::lookAt(cameraPos, target, up);
printMat4(view, "View Matrix (lookAt)");
// ---------- 4. 投影矩阵:透视投影 ----------
float fov = glm::radians(60.0f); // 60° 垂直视场角
float aspect = 16.0f / 9.0f; // 宽高比
float zNear = 0.1f, zFar = 100.0f; // 近/远裁剪面
glm::mat4 proj = glm::perspective(fov, aspect, zNear, zFar);
printMat4(proj, "Perspective Projection Matrix");
// ---------- 5. MVP 组合与视口坐标 ----------
glm::mat4 mvp = proj * view * model; // 注意顺序:先 model,再 view,最后 proj
glm::vec4 clip = mvp * glm::vec4(localPos, 1.0f);
glm::vec3 ndc = glm::vec3(clip) / clip.w; // 透视除法 -> NDC
std::cout << "clip = (" << clip.x << ", " << clip.y << ", " << clip.z << ", " << clip.w << ")\n";
std::cout << "ndc = (" << ndc.x << ", " << ndc.y << ", " << ndc.z << ")\n\n";
// ---------- 6. 四元数旋转与插值 ----------
glm::quat q = glm::angleAxis(glm::radians(90.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::vec3 rotated = q * glm::vec3(1.0f, 0.0f, 0.0f); // 旋转向量
std::cout << "quat rotate (1,0,0) -> ("
<< rotated.x << ", " << rotated.y << ", " << rotated.z << ")\n";
glm::quat q0 = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // 单位四元数(无旋转)
glm::quat q1 = glm::angleAxis(glm::radians(180.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::quat qSlerp = glm::slerp(q0, q1, 0.5f); // 球面插值 50%
glm::mat4 rotMat = glm::toMat4(qSlerp); // 四元数 -> 旋转矩阵
printMat4(rotMat, "Rotation Matrix from slerp(q0,q1,0.5)");
// ---------- 7. 导出内存指针交给 OpenGL ----------
const float* p = glm::value_ptr(mvp); // 列主序,可直接传给 glUniformMatrix4fv
std::cout << "mvp raw memory (column-major, 16 floats):\n";
for (int i = 0; i < 16; ++i) {
std::cout << std::setw(10) << std::fixed << std::setprecision(3) << p[i];
if ((i + 1) % 4 == 0) std::cout << "\n";
}
return 0;
}
编译运行:
bash
cmake -B build -S .
cmake --build build --config Release
./build/glm_demo
输出中的关键数值含义:
- dot(a,b) = 32:1*4 + 2*5 + 3*6 = 32,验证点积定义。
- cross(a,b) = (-3, 6, -3):叉积结果垂直于 a 与 b 所在平面。
- 模型矩阵 m3 = (1, 0, -2, 1):最后一列(列主序的第 4 列)即平移量,验证 T * R * S 的平移部分。
- ndc 结果在 -1, 1 范围内,说明顶点位于视锥体内,可被渲染。
3.3 与 OpenGL 集成的最小片段
// 上传矩阵到着色器 uniform(GLSL: uniform mat4 uMVP;)
GLint loc = glGetUniformLocation(program, "uMVP");
glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(mvp));
// ↑ 个数 ↑ transpose=GL_FALSE:GLM 已是列主序,无需转置
4. 核心类型系统:vec / mat / 精度前缀与限定符
4.1 模板是本体,别名是糖
GLM 所有类型都源于两个基础模板:
cpp
namespace glm {
template<length_t L, typename T, qualifier Q> struct vec; // 向量
template<length_t L, typename T, qualifier Q> struct mat; // 矩阵
}
常用类型只是模板别名:
cpp
using vec2 = vec<2, float, defaultp>;
using vec3 = vec<3, float, defaultp>;
using vec4 = vec<4, float, defaultp>;
using mat3 = mat<3, 3, float, defaultp>;
using mat4 = mat<4, 4, float, defaultp>;
4.2 精度前缀:lowp / mediump / highp
GLM 支持 GLSL 风格的精度限定符,通过类型名前缀表达:
| 类型 | 底层标量 | 典型用途 |
|---|---|---|
| glm::highp_vec3 | float(32 位) | 默认,几乎总是用它 |
| glm::mediump_vec3 | float 或 half(由 GLM_FORCE_MEDIUMP_* 决定) | 移动端低精度着色 |
| glm::lowp_vec3 | half/int 等低精度 | 极小众,一般不用 |
| glm::dvec3 | double(64 位) | 大世界坐标、科学可视化 |
注意:GLM_FORCE_DEFAULT_ALIGNED_GENTYPES 等宏只影响对齐,不影响精度。默认 defaultp 即 highp。
4.3 qualifier 限定符与宏开关
qualifier 参数控制类型的对齐与打包行为,核心宏开关:
| 宏(必须在 include 之前定义) | 效果 |
|---|---|
| GLM_FORCE_DEFAULT_ALIGNED_GENTYPES | 强制类型按 SIMD 宽度对齐(默认 16 字节对齐在部分平台未开启) |
| GLM_FORCE_INTRINSICS | 启用 SIMD 优化路径(见第 12 章) |
| GLM_FORCE_XYZW_ONLY | 关闭 swizzle 的 rgba/stpq 别名,只保留 xyzw |
| GLM_FORCE_LEFT_HANDED | 切换为左手坐标系(默认右手) |
| GLM_FORCE_DEPTH_ZERO_TO_ONE | 深度范围改为 0,1(Vulkan/DirectX 风格,默认 -1,1) |
| GLM_FORCE_SIZE_T_LENGTH | 让 size() 等返回 size_t 而非 int |
| GLM_FORCE_UNRESTRICTED_GENTYPE | 放宽模板约束,允许非浮点类型参与部分运算 |
命名建议:在 CMake 中集中定义,避免散落各处:
target_compile_definitions(glm_demo PRIVATE
GLM_FORCE_DEFAULT_ALIGNED_GENTYPES
GLM_FORCE_INTRINSICS
GLM_FORCE_XYZW_ONLY
)
4.4 构造与访问方式速查
cpp
glm::vec3 v1(1.0f); // 全部元素 = 1.0
glm::vec3 v2(1.0f, 2.0f, 3.0f); // 逐元素
glm::vec4 v3 = glm::vec4(v2, 1.0f); // 升维,第 4 分量 = 1(齐次坐标常用)
glm::vec2 v4 = glm::vec2(v3); // 降维截断
// 访问成员
v2.x; v2.y; v2.z; // xyzw 别名
v2.r; v2.g; v2.b; // rgba 别名(同位置)
v2.s; v2.t; v2.p; // stpq 别名(纹理坐标)
v2[0]; v2[1]; // 下标访问
5. 内存布局与存储约定:列主序的真相
5.1 什么是列主序
GLM 的 mat4 在内存中按 列优先(column-major) 存储:先存第 0 列的 4 个元素,再存第 1 列......以此类推。这是图形学约定(OpenGL 规范要求),与 C 语言天然的行优先(row-major)直觉相反。
cpp
glm::mat4 m = glm::mat4(1.0f);
// 内存中的 16 个 float 依次是:
// 第0列: m[0][0], m[1][0], m[2][0], m[3][0] <- 注意下标
// 第1列: m[0][1], m[1][1], m[2][1], m[3][1]
// ...
// 第3列: m[0][3], m[1][3], m[2][3], m[3][3]
关键结论:mcolrow------第一维是列,第二维是行。初学者最常见的错误就是把 m12 当成"第 1 行第 2 列",实际是"第 1 列第 2 行"。
5.2 与 glUniformMatrix4fv 的 transpose 参数
OpenGL 上传矩阵的函数签名:
cpp
void glUniformMatrix4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat *value);
- transpose = GL_FALSE:按列主序读取 value。GLM 默认就是列主序,直接传 value_ptr(m) 即可,transpose 必须传 GL_FALSE。
- 若误传 GL_TRUE,矩阵会被转置后上传,渲染结果会出现奇怪的镜像/错位。
- 若使用了 Eigen(默认行主序存储)等其他库,则需要 transpose = GL_TRUE 或先 .transpose() 再传。
5.3 行主序模式(不推荐但要知道)
如果非要用行主序,可以在 include 前定义:
cpp
#define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES
// 行主序没有直接的公开宏;历史上 GLM_FORCE_ROW_MAJOR 只是部分支持,
// 社区普遍做法是保持列主序 + transpose 参数配合。
GLM 官方明确不推荐行主序模式------整个 API 的运算语义(m * v 等)都围绕列向量与列主序设计。保持一致比"更符合直觉"重要得多。
5.4 与 GLSL 的对应关系验证
GLSL 中 mat4 m; m12 同样是第 1 列第 2 行。GLM 刻意保持与 GLSL 完全一致,因此在 CPU 端调试与 GPU 端着色器之间迁移代码时,下标语义不会产生任何歧义。这也是 GLM 相比其他数学库最核心的卖点。
6. 向量运算:点积、叉积、归一化与几何投影
6.1 基础运算总表
| 函数 | 数学含义 | 返回 | 典型用途 |
|---|---|---|---|
| dot(a, b) | 点积 a·b = Σ aᵢbᵢ | 标量 | 光照 Lambert 余弦、投影长度、夹角判断 |
| cross(a, b) | 叉积 a×b(仅 3D) | vec3 | 法线计算、构建正交基 |
| length(v) | 模长 ‖v‖ | 标量 | 距离、归一化 |
| distance(a, b) | 距离 ‖a-b‖ | 标量 | 碰撞、LOD |
| normalize(v) | 单位向量 v/‖v‖ | 同维向量 | 方向向量、法线 |
| reflect(I, N) | 反射 I - 2·dot(N,I)·N | 向量 | 镜面反射、菲涅尔 |
| refract(I, N, eta) | 折射(Snell 定律) | 向量 | 透明介质折射 |
| faceforward(N, I, Nref) | 若 dot(Nref, I) < 0 返回 N 否则 -N | 向量 | 法线朝向修正 |
| clamp(x, min, max) | 截断到区间 | 同类型 | 颜色/数值限幅 |
| mix(a, b, t) | 线性插值 a(1-t)+bt | 同类型 | 颜色混合、过渡 |
| smoothstep(e0, e1, x) | Hermite 平滑插值 | 同类型 | 渐变、抗锯齿 |
6.2 实战:三角形法线与光照方向
cpp
glm::vec3 p0(0.0f, 0.0f, 0.0f);
glm::vec3 p1(1.0f, 0.0f, 0.0f);
glm::vec3 p2(0.0f, 1.0f, 0.0f);
glm::vec3 edge1 = p1 - p0;
glm::vec3 edge2 = p2 - p0;
glm::vec3 normal = glm::normalize(glm::cross(edge1, edge2));
// normal = (0, 0, 1):XY 平面的三角形法线指向 +Z
glm::vec3 lightDir = glm::normalize(glm::vec3(1.0f, 1.0f, 1.0f));
float diffuse = glm::max(glm::dot(normal, lightDir), 0.0f);
// 注意:光线方向与法线夹角,Lambert 漫反射强度
6.3 向量投影与正交分解
cpp
glm::vec3 v(3.0f, 4.0f, 0.0f);
glm::vec3 axis(1.0f, 0.0f, 0.0f);
// v 在 axis 上的投影长度 = dot(v, normalize(axis))
float projLen = glm::dot(v, glm::normalize(axis)); // 3.0
// 投影向量 = axis 单位向量 * projLen
glm::vec3 projVec = glm::normalize(axis) * projLen; // (3, 0, 0)
// 正交分量(Gram-Schmidt 第一步)
glm::vec3 ortho = v - projVec; // (0, 4, 0)
7. 矩阵运算与三维变换:TRS 与组合顺序
7.1 四种基本变换矩阵
| 变换 | GLM 调用 | 说明 |
|---|---|---|
| 平移 | glm::translate(m, vec3(tx,ty,tz)) | 第 4 列为平移量 |
| 旋转 | glm::rotate(m, angle, axis) | 绕任意轴旋转 angle 弧度 |
| 缩放 | glm::scale(m, vec3(sx,sy,sz)) | 对角缩放,负数可镜像 |
| 切变/自定义 | 直接构造 mat4 | 一般不用 |
7.2 组合顺序:从右往左读
GLM 使用列向量约定:v' = M * v。多个变换组合时:
cpp
glm::mat4 mvp = proj * view * model; // 正确顺序
// 顶点流:local -> model -> view -> clip
// 等价于 mvp * vLocal = proj * (view * (model * vLocal))
顺序颠倒的后果 :model * view * proj 会先做投影再变换,得到完全错误的坐标。记法口诀:"先发生的变换写在右边,矩阵乘法从右往左作用于顶点"。顶点先经过最右边的矩阵,再依次向左。
7.3 TRS 分解的常见实现
模型矩阵的标准组合:Model = T * R * S(先缩放、再旋转、最后平移):
cpp
glm::mat4 BuildModel(const glm::vec3& pos, const glm::quat& rot, const glm::vec3& scale) {
glm::mat4 m = glm::mat4(1.0f);
m = glm::translate(m, pos);
m = m * glm::toMat4(rot); // 四元数 -> 矩阵,右乘
m = glm::scale(m, scale);
return m;
}
7.4 矩阵求逆与转置
cpp
glm::mat4 inv = glm::inverse(m); // 求逆(O(n³),帧循环中慎用)
glm::mat4 tr = glm::transpose(m); // 转置
glm::mat3 normalMat = glm::transpose(glm::inverse(glm::mat3(model))); // 法线矩阵
法线矩阵陷阱 :当模型矩阵包含非均匀缩放时,法线不能直接用 model 变换(法线会被拉伸偏离垂直方向),必须使用法线矩阵 transpose(inverse(mat3(model)))。若只有旋转与均匀缩放,直接用 mat3(model) 即可(此时逆转置等于原矩阵)。
7.5 逆矩阵的数值注意
glm::inverse 使用高斯消元/伴随矩阵实现,对退化矩阵(行列式为 0)会返回 NaN 或不可信结果。引擎实践中:
- 视图/投影矩阵的逆常用于屏幕坐标反投影(拾取、射线),此时矩阵通常可逆且良态。
- 对模型矩阵求逆开销大,若模型矩阵由 TRS 构成,可直接组合逆:inv(Model) = S⁻¹ * R⁻¹ * T⁻¹,避免通用求逆。
8. 投影矩阵原理:透视、正交与裁剪空间
8.1 为什么需要投影矩阵
三维场景最终要显示在二维屏幕上,投影矩阵完成三件事:
- 将视锥体内的顶点映射到裁剪空间(Clip Space),即 -w, w 范围;
- 编码深度信息供深度缓冲(Z-Buffer)使用;
- 为光栅化阶段的透视除法(除以 w)准备数据。
8.2 透视投影:glm::perspective
cpp
glm::mat4 proj = glm::perspective(glm::radians(fovY), aspect, zNear, zFar);
矩阵形式(右手系、OpenGL 深度范围 -1,1):
[ f/aspect 0 0 0 ]
[ 0 f 0 0 ]
[ 0 0 (zF+zN)/(zN-zF) (2·zF·zN)/(zN-zF) ]
[ 0 0 -1 0 ]
其中 f = 1/tan(fovY/2) = cot(fovY/2)。
关键推导结论:
- 第 3 行第 4 列为 -1:把 -z 写入 w,实现"离得远、w 大"的近大远小效果。
- 第 3 行第 3 列把 z 映射到 -1, 1 深度区间。
- zNear/zFar 必须是正数,GLM 会按"物体在 -z 方向"处理(右手坐标系观察方向为 -Z)。
8.3 正交投影:glm::ortho
cpp
// 形式一:指定左右上下远近
glm::mat4 ortho = glm::ortho(-10.0f, 10.0f, -5.0f, 5.0f, 0.1f, 100.0f);
// 形式二:以中心与尺寸定义(2D UI/文字常用)
float halfW = width * 0.5f, halfH = height * 0.5f;
glm::mat4 uiProj = glm::ortho(-halfW, halfW, -halfH, halfH, -1.0f, 1.0f);
正交投影不产生近大远小,w 恒为 1,常用于 2D 游戏、UI、阴影贴图等。
8.4 透视除法与 NDC
顶点经过 proj * view * model * vLocal 后得到裁剪坐标 (x, y, z, w),GPU 光栅化前执行透视除法:
ndc = (x/w, y/w, z/w) // NDC: Normalized Device Coordinates, 范围 [-1,1]
随后 NDC 映射到屏幕像素坐标(视口变换)。GLM 只负责到裁剪空间,除法由 GPU 固定管线完成;但在 CPU 侧做拾取、软渲染时,需要自己手动除以 w(见第 3 章示例)。
8.5 深度精度与 z-fighting
透视投影的深度映射在近处精度高、远处精度低(非线性)。深坑点:
- zNear 设得太小(如 0.001)会严重浪费深度精度,导致远处出现 z-fighting(深度冲突闪烁)。
- 经验法则:zNear 与场景尺度匹配,例如室内场景 0.11.0,室外大世界 1.010.0。
- 需要更高精度时可启用 GL_NV_depth_buffer_float 或使用 glDepthRangedNV/对数深度缓冲(Logarithmic Depth Buffer)技术。
8.6 左手系与 Vulkan/DirectX 适配
cpp
// Vulkan:左手坐标系 + 深度范围 [0,1]
#define GLM_FORCE_LEFT_HANDED
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
- DirectX/Metal/Vulkan 默认深度范围 0,1;OpenGL 默认 -1,1。
- 同一个 perspective 在两种深度约定下的矩阵不同,混用会导致深度测试异常(物体全被裁剪或全不裁剪)。
9. 四元数:旋转、插值与万向锁
9.1 为什么需要四元数
欧拉角(pitch/yaw/roll)直观但存在致命缺陷:万向锁(Gimbal Lock)------当两个旋转轴对齐时丢失一个自由度,导致旋转行为异常。此外,欧拉角的插值不平滑。四元数用 4 个分量表示旋转,无万向锁、插值平滑、计算效率高,是 3D 引擎旋转的标准表示。
9.2 四元数基础
cpp
// 构造:w + xi + yj + zk
glm::quat q(1.0f, 0.0f, 0.0f, 0.0f); // 单位四元数,无旋转
// 常用构造:绕轴旋转
glm::quat q = glm::angleAxis(glm::radians(90.0f), glm::vec3(0.0f, 1.0f, 0.0f));
// 从欧拉角(弧度)构造(注意旋转顺序约定)
glm::quat qEuler = glm::quat(glm::vec3(pitch, yaw, roll));
// 四元数旋转向量
glm::vec3 rotated = q * glm::vec3(1.0f, 0.0f, 0.0f);
// 四元数与矩阵互转
glm::mat4 rotMat = glm::toMat4(q);
glm::quat qBack = glm::quat(rotMat);
// 共轭(逆旋转):旋转 q 的逆 = 共轭(对单位四元数)
glm::quat qInv = glm::conjugate(q);
9.3 四元数旋转向量的数学原理
四元数旋转向量 v 的公式:v' = q * v * q⁻¹,其中 v 被提升为纯四元数 (0, vx, vy, vz)。GLM 重载了 quat * vec3 运算符,底层展开为:
cpp
// 简化伪代码:利用 quat * quat 与共轭
vec3 rotate(quat q, vec3 v) {
quat p(0.0f, v.x, v.y, v.z);
quat r = q * p * conjugate(q);
return vec3(r.x, r.y, r.z);
}
9.4 插值:slerp 与 nlerp
cpp
glm::quat q0 = /* 起始姿态 */;
glm::quat q1 = /* 结束姿态 */;
// 球面插值:匀速、几何正确,但开销较高(涉及 acos/sin)
glm::quat qS = glm::slerp(q0, q1, t);
// 归一化线性插值:近似球面路径,开销低,视觉差异小
glm::quat qN = glm::normalize(glm::mix(q0, q1, t)); // 即 nlerp
- slerp 在 q0 与 q1 夹角大于 90° 时会绕远路,应先检查 dot(q0,q1) < 0 则取 -q1(四元数 q 与 -q 表示同一旋转)。
- GLM 的 slerp 已内置最短路径处理,但自定义实现时需注意。
- 游戏引擎中动画混合常用 nlerp (性能优先),骨骼动画常用 slerp(质量优先)。
9.5 万向锁的直观理解
用欧拉角 (pitch, yaw, roll) 描述旋转时,若先绕 X 轴转 90°,则 Y 轴与 Z 轴重合,后续 yaw/roll 的旋转效果叠加在同一轴上------一个自由度丢失。表现为相机"卡死"、旋转方向突然反转。四元数因为直接描述"绕轴转角",不存在轴对齐问题,天然免疫万向锁。
9.6 四元数 vs 旋转矩阵 vs 欧拉角
| 维度 | 四元数 | 旋转矩阵 | 欧拉角 |
|---|---|---|---|
| 存储 | 4 float | 9~16 float | 3 float |
| 万向锁 | 无 | 无 | 有 |
| 插值 | slerp/nlerp 平滑 | 困难 | 不平滑 |
| 叠加旋转 | 乘法,快 | 乘法 | 复杂且易错 |
| 人类可读性 | 差 | 差 | 好 |
| 典型用途 | 动画、姿态、相机 | 最终变换、法线 | 参数配置、UI 输入 |
10. 坐标系统与 MVP:lookAt 与视图矩阵
10.1 五大坐标空间
局部空间(Local) -> 世界空间(World) -> 观察空间(View) -> 裁剪空间(Clip) -> 屏幕空间(Screen)
model矩阵 view矩阵 proj矩阵 透视除法/视口
每个阶段的矩阵在 3.2 节示例中均已出现,此处强调各空间切换的矩阵组合:
glm::mat4 model = /* 物体自身变换 */;
glm::mat4 view = glm::lookAt(cameraPos, cameraTarget, cameraUp);
glm::mat4 proj = glm::perspective(fov, aspect, near, far);
glm::mat4 mvp = proj * view * model;
10.2 lookAt 的原理
glm::lookAt(eye, center, up) 构造视图矩阵,其本质是构建以相机为原点的正交基:
cpp
// 数学推导(简化)
glm::vec3 zAxis = glm::normalize(eye - center); // 相机朝向(右手系看向 -Z)
glm::vec3 xAxis = glm::normalize(glm::cross(up, zAxis)); // 右轴
glm::vec3 yAxis = glm::cross(zAxis, xAxis); // 上轴(正交化后的 up)
视图矩阵 = 旋转部分(基向量转置)与平移部分(相机位置取负)的组合。up 向量不能与视线方向平行(如相机垂直向下看时 up 与视线重合),否则叉积为零向量,矩阵退化------此时应更换 up(如用 (0,0,1))。
10.3 第一人称相机(FPS Camera)实现
cpp
class FPSCamera {
public:
glm::vec3 position{0.0f, 1.0f, 5.0f};
float yaw = -90.0f; // 初始朝 -Z 方向
float pitch = 0.0f;
glm::mat4 GetViewMatrix() {
// 由欧拉角计算前向向量(注意顺序:pitch 绕 X 轴、yaw 绕 Y 轴)
glm::vec3 front;
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
front = glm::normalize(front);
return glm::lookAt(position, position + front, glm::vec3(0.0f, 1.0f, 0.0f));
}
void Rotate(float dyaw, float dpitch) {
yaw += dyaw;
pitch += dpitch;
// 限制俯仰角避免翻转
pitch = glm::clamp(pitch, -89.0f, 89.0f);
}
};
10.4 屏幕坐标反投影(射线拾取)
cpp
// 屏幕坐标 -> NDC -> 世界射线
glm::vec2 screenPos(mouseX, mouseY); // 像素坐标
glm::vec2 viewportSize(width, height);
glm::vec3 ndc(
(2.0f * screenPos.x) / viewportSize.x - 1.0f,
1.0f - (2.0f * screenPos.y) / viewportSize.y, // Y 轴翻转
1.0f
);
glm::vec4 clip(ndc, 1.0f);
glm::mat4 invVP = glm::inverse(proj * view);
glm::vec4 world = invVP * clip;
world /= world.w; // 反透视除法
glm::vec3 rayDir = glm::normalize(glm::vec3(world) - cameraPos);
11. 高级特性:GTX 扩展、随机、噪声与几何函数
11.1 常用 GTC 扩展速查
| 头文件 | 关键函数 | 用途 |
|---|---|---|
| gtc/matrix_transform.hpp | translate/rotate/scale/perspective/ortho/lookAt | 变换与投影 |
| gtc/quaternion.hpp | slerp/angleAxis/toMat4/toQuat | 四元数全套 |
| gtc/type_ptr.hpp | value_ptr/make_mat4/make_vec3 | 内存互操作 |
| gtc/matrix_inverse.hpp | inverseTranspose/affineInverse | 法线矩阵 |
| gtc/matrix_access.hpp | row/column | 取行列 |
| gtc/random.hpp | linearRand/gaussianRand/sphericalRand | 随机数 |
11.2 组合变换扩展:gtx/transform
cpp
#include <glm/gtx/transform.hpp>
// 一步到位:translate + rotate + scale 组合
glm::mat4 m = glm::translate(pos) * glm::rotate(angle, axis) * glm::scale(scaleVec);
// 注意 gtx 版本接受直接参数,返回新矩阵(不原地累乘)
11.3 随机数扩展
cpp
#include <glm/gtc/random.hpp>
float r1 = glm::linearRand(0.0f, 1.0f); // 均匀分布
glm::vec3 r2 = glm::sphericalRand(5.0f); // 球面上均匀采样(半径 5)
glm::vec3 r3 = glm::ballRand(2.0f); // 球体内均匀采样
float r4 = glm::gaussRand(0.0f, 1.0f); // 高斯分布
用途:粒子系统随机发射方向、环境光遮蔽采样、景深抖动等。
11.4 噪声扩展
cpp
#include <glm/gtc/noise.hpp>
float v = glm::perlin(glm::vec3(x, y, z)); // Perlin 噪声
float s = glm::simplex(glm::vec3(x, y, z)); // Simplex 噪声(更高效)
用途:程序化地形、纹理生成、云层动画。
11.5 几何查询函数(gtc/constants、gtx/intersect)
cpp
#include <glm/gtx/intersect.hpp>
// 射线与三角形求交(返回是否命中 + 重心坐标 uv + 距离)
float dist;
bool hit = glm::intersectRayTriangle(
rayOrigin, rayDir,
v0, v1, v2,
baryPosition, dist);
// 射线与球求交
bool hitSphere = glm::intersectRaySphere(rayOrigin, rayDir, sphereCenter, sphereRadius, pos, normal);
12. SIMD 与性能优化:GLM_FORCE_INTRINSICS 的底层机制
12.1 开与不开的差别
默认(非 SIMD 模式)下,glm::vec4 + glm::vec4 展开为 4 条标量浮点加法;开启 GLM_FORCE_INTRINSICS 后,编译器可将其编译为单条 addps(SSE)指令。对于矩阵乘法等热点,SIMD 路径可带来数倍收益。
cpp
#define GLM_FORCE_INTRINSICS // 必须在 include 之前
#include <glm/glm.hpp>
12.2 实现原理
- GLM 的 SIMD 路径通过 glm/simd/*.h 提供,使用编译器内置向量类型(如 GCC/Clang 的 __m128、MSVC 的 SSE intrinsics)与宏重载。
- 启用后 glm::mat4 的存储可能变为 4 个 __m128(每列一个 128 位寄存器),对齐到 16 字节。
- 建议同时开启 GLM_FORCE_DEFAULT_ALIGNED_GENTYPES 确保对齐,否则某些平台上 SIMD 加载会因未对齐地址触发性能惩罚甚至崩溃。
12.3 性能对比实测思路
用 Google Benchmark 做微基准(与已写系列呼应):
cpp
#include <benchmark/benchmark.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
static void BM_MatMul(benchmark::State& state) {
glm::mat4 a = glm::rotate(glm::mat4(1.0f), 0.5f, glm::vec3(1,0,0));
glm::mat4 b = glm::translate(glm::mat4(1.0f), glm::vec3(1,2,3));
for (auto _ : state) {
benchmark::DoNotOptimize(a * b);
}
}
BENCHMARK(BM_MatMul);
实测建议:同一代码分别开/关 GLM_FORCE_INTRINSICS 编译两个版本对比;Release + /O2 是前提,Debug 下 SIMD 收益会被掩盖。
12.4 手动 SIMD 与 GLM 的关系
glm::vec4 与 __m128 在 ABI 上对齐一致时,可以直接 reinterpret 交换:
cpp
__m128 sseVal = _mm_load_ps(glm::value_ptr(v));
// 或利用 glm 提供的 simd 接口
但绝大多数场景不需要手写 SIMD------GLM 已封装好且经过大量平台验证。手写 SIMD 的收益主要出现在自定义热路径(如千万级粒子的物理模拟)。
13. 底层实现窥探:swizzle 与模板元编程技巧
13.1 swizzle(分量重排)的实现
GLM 支持 v.zyx、v.rgb、v.xx 等语法糖,这是通过代理类型 + 模板递归实现的。简化版:
cpp
template<length_t L, typename T, qualifier Q, glm::swizzle... comps>
struct vecSwizzle {
// 根据 comps 参数包(编码的通道索引)在 operator vec<L,T,Q>() 中逐位提取
operator vec<L, T, Q>() const {
// 从 comps 中解码通道号,逐个拷贝分量
return vec<L, T, Q>(/* comp0, comp1, ... */);
}
};
// 在 vec4 内部:
struct data {
union {
struct { T x, y, z, w; };
struct { vecSwizzle<2, T, Q, X, Y> xy; }; // 代理对象
struct { vecSwizzle<3, T, Q, Z, Z, Z> zzz; };
// ...
};
};
注意 :swizzle 返回的是按值代理 (历史版本曾支持引用语义以支持 v.xy = ... 写操作,但因效率与 UB 争议,新版本对写操作支持有限制)。读操作是安全的;不建议在热循环中大量使用 swizzle(可能产生临时对象),显式构造更快。
13.2 模板递归展开
GLM 的向量逐分量运算大量使用模板递归/折叠表达式。以 C++17 视角,GLM 内部相当于对 L 个分量做编译期展开:
cpp
// 概念示意(GLM 实际实现更复杂,使用宏生成 2/3/4 维特化)
template<length_t L, typename T, qualifier Q>
vec<L,T,Q> operator+(const vec<L,T,Q>& a, const vec<L,T,Q>& b) {
vec<L,T,Q> r;
for (length_t i = 0; i < L; ++i) r[i] = a[i] + b[i]; // 编译器常量折叠后展开
return r;
}
配合 constexpr 与内联,循环在优化后完全消失,生成纯 SIMD 代码。这也是 GLM "零抽象开销"的根基。
13.3 为什么是宏而非纯模板
GLM 内部大量使用宏(如 #define GLM_SIMD_ENABLE、生成运算符的宏族)来减少重复代码,这是老牌 header-only 库的常见做法。代价是可读性差、IDE 跳转不友好,但换来的是极低的编译依赖与跨编译器兼容性。这也是社区对 GLM 源码"可读但不可爱"评价的来源。
14. 生态对比:GLM vs Eigen vs DirectXMath vs OpenCV
| 维度 | GLM | Eigen | DirectXMath | OpenCV(cv::Mat) |
|---|---|---|---|---|
| 定位 | 图形学/着色器数学 | 通用线性代数/数值计算 | DirectX 配套数学库 | 计算机视觉 |
| 核心类型 | vec24 / mat24 / quat | MatrixXd / VectorXd / 固定尺寸 | XMVECTOR/XMMATRIX | cv::Mat(多维数组) |
| 维度侧重 | 固定小维度(≤4) | 任意维度(动态/静态) | 固定小维度 | 图像与任意矩阵 |
| 存储约定 | 列主序(OpenGL) | 默认列主序但可配 | 行主序(XMMatrix) | 行主序 |
| 与 GLSL 对应 | 完全对应 | 无 | 与 HLSL 对应 | 无 |
| 求解器/特征值 | 无 | 丰富(LU/QR/SVD/特征值) | 无 | 部分(SVD 等) |
| 四元数 | 完善 | 支持 | 完善 | 支持(旋转向量) |
| SIMD | 宏开关,跨平台 | 表达式模板+向量化 | 专为 SSE/AVX 设计 | IPP/OpenCL 后端 |
| 学习成本 | 低(类 GLSL) | 中 | 中 | 中高 |
| 典型场景 | 渲染、游戏、相机 | 物理、优化、数据科学 | Windows/DirectX 渲染 | 图像处理、视觉 |
选型建议:
- 渲染/游戏/着色器开发 → GLM(或 DirectXMath,取决于图形 API)。
- 需要矩阵分解、求解方程组、特征值 → Eigen。
- 图像处理/视觉流水线 → OpenCV。
- 引擎中常见组合:GLM 负责渲染数学,Eigen 负责物理/优化计算,二者通过 value_ptr 无缝互转。
15. 常见坑点与避坑指南
| 坑点 | 现象 | 根因 | 解决 |
|---|---|---|---|
| 矩阵下标混淆 | 矩阵元素取错 | 列主序:mcolrow | 牢记 mcr,与 GLSL 对齐 |
| transpose 传错 | 画面镜像/旋转错乱 | glUniformMatrix4fv 第 3 参应为 GL_FALSE | GLM 列主序直接传 value_ptr |
| 变换顺序颠倒 | 物体绕错轴/位置偏移 | M*v 从右往左作用 | 统一用 proj * view * model |
| 角度单位错误 | 旋转角度异常 | rotate 接受弧度,误传度数 | 用 glm::radians(deg) 转换 |
| 非均匀缩放后法线错误 | 光照异常 | 法线不能直接用 model 变换 | 用法线矩阵 transpose(inverse(mat3(model))) |
| zNear 太小 | 远处深度闪烁 | 深度精度非线性分布 | 合理设置 zNear;对数深度缓冲 |
| perspective 负数 near | 画面全黑/全裁剪 | near/far 必须为正 | 检查传入值 |
| up 与视线平行 | 相机翻转/消失 | cross(up, z) 为零向量 | 更换 up 或检查 pitch 限制 |
| 忘记透视除法 | CPU 侧坐标巨大 | clip 坐标未除 w | ndc = vec3(clip)/clip.w |
| slerp 走远路 | 动画旋转绕大圈 | 两四元数点积 < 0 | 先取负再插值(GLM 内置处理) |
| GLM_FORCE_* 宏位置错误 | 行为未生效/报错 | 宏必须在 include 之前定义 | 统一放 CMake 的 compile_definitions |
| Debug 模式性能差 | 帧率骤降 | SIMD 未生效 | Release + /O2 + GLM_FORCE_INTRINSICS |
| 使用 gtx 扩展 | 接口随版本变化 | 实验性质 | 生产代码避免或锁定版本 |
| 在头文件中包含 glm 导致编译慢 | 编译时间膨胀 | header-only 模板库 | 用 PIMPL 隔离;减少重复包含 |
| 混用不同精度类型 | 编译错误 | vec3 与 dvec3 不能直接运算 | 统一用 float(dvec3 仅特殊场景) |
16. FAQ 速查表
Q1:GLM 是 header-only 吗?需要链接什么? 是。只需包含头文件,无需链接库。CMake 中 find_package(glm) + target_link_libraries(... glm::glm) 只是为了头文件路径与宏定义。
Q2:GLM 与 GLSL 的区别? GLM 是 C++ 库,运行在 CPU 端;GLSL 是 GPU 着色器语言。二者类型与函数命名一一对应,因此 CPU 端计算出的矩阵可直接上传给 GPU,无需语义转换。
Q3:glm::mat4(1.0f) 与 glm::mat4() 的区别? glm::mat4(1.0f) 构造单位矩阵 (对角线为 1);glm::mat4() 默认构造零矩阵(全 0)。把默认构造当单位矩阵是常见错误。
Q4:如何把 GLM 矩阵传给 OpenGL? glUniformMatrix4fv(loc, 1, GL_FALSE, glm::value_ptr(m))。DirectX 中则需转置或使用行主序约定(对应 XMMatrixTranspose)。
Q5:glm::radians 和 glm::degrees 做什么? 角度单位转换:radians(180.0f) = π ≈ 3.14159。rotate/perspective/fov 等参数必须用弧度。
Q6:为什么旋转方向和我预期相反? GLM 默认右手坐标系,正角度旋转遵循右手定则(逆时针)。若使用左手系需定义 GLM_FORCE_LEFT_HANDED;也可通过取反角度或翻转轴向修正。
Q7:GLM 支持 double 精度吗? 支持:glm::dvec3、glm::dmat4 等。大世界坐标(浮点精度不足导致抖动)常用 double 存储世界坐标,渲染时转回 float。
Q8:如何判断点是否在视锥体内? 将点变换到裁剪空间后检查 -w <= x,y,z <= w;或使用平面提取(Gribb-Hartmann 算法)对包围盒做测试。
Q9:GLM 与 Unreal/Unity 的数学库什么关系? Unreal 的 FVector/FMatrix 与 GLM 同为 GLSL 风格;Unity 的 Vector3/Matrix4x4 语义也类似,但 Unity 使用左手系、行主序(Matrix4x4 按行存储),与 GLM 不同,迁移时需转置与镜像。
Q10:GLM 能用于 CUDA 吗? 可以。GLM 支持 nvcc 编译,#include <glm/glm.hpp> 后可在设备端使用(建议开启 GLM_FORCE_CUDA 相关适配宏,具体见官方文档)。
Q11:为什么链接报 glm::xxx 未定义? GLM 是纯模板 header-only,不存在"未定义"。通常是头文件路径未配置或忘记 #include <glm/glm.hpp>;glm::perspective 等还需 #include <glm/gtc/matrix_transform.hpp>。
Q12:如何在 VS 中调试 GLM 矩阵内容? 在监视窗口输入 m0 查看第 0 列;或临时 printf 打印 glm::to_string(m)(需 #include <glm/gtx/string_cast.hpp>,仅调试用)。
Q13:GLM 与 Eigen 能互转吗? 能。GLM 是列主序、Eigen 默认列主序,直接逐元素拷贝即可;Eigen 的 Map 可零拷贝包装 GLM 的 value_ptr 指针。
Q14:编译报大量 GLM_FORCE_ 相关错误? 宏必须在第一次 include GLM 头文件之前定义,且各编译单元保持一致。建议统一在 CMake target_compile_definitions 或项目级预编译头中配置。
Q15:GLM 会改变我的 ABI 吗? GLM 是 header-only 模板库,编译进使用者代码,不参与二进制 ABI。但不同宏配置会改变类型布局,同一工程内宏配置必须全局一致,否则 ODR 违规。