3D 数学基础

发布时间:2026/10/26 10:22:10
3D 数学基础

GLM 类型别名

using Vector2f = glm::vec2; using Vector3f = glm::vec3; using Vector4f = glm::vec4; using Vector2i = glm::ivec2; using Vector3i = glm::ivec3; using Vector4i = glm::ivec4; using Matrix3x3 = glm::mat3; using Matrix4x4 = glm::mat4; using Color = glm::vec3;
要点

Vector2f等是 GLM 类型的别名,不是新类型。Color本质上就是glm::vec3。

这样写的好处是代码语义更清楚,也方便以后替换数学库。

注意:Color和Vector3f是同一类型,不能写两个只差Color/Vector3f的重载函数,否则会冲突。

向量、点与齐次坐标

图形学中,Vector3f既可以表示方向/位移,也可以表示点。区分方式靠齐次坐标的第四个分量w:

含义齐次坐标平移矩阵作用
点(x, y, z, 1)会被平移
方向/向量(x, y, z, 0)不会被平移

为什么需要齐次坐标?

  1. 3×3 矩阵只能表示线性变换(旋转、缩放),无法表示平移
  2. 用 4×4 矩阵和w=1的点,可以把平移也写成矩阵乘法
  3. 方向向量的w=0会自动忽略平移,符合直觉
  4. 后续透视投影会用到w做透视除法

点积(Dot Product)

a·b= |a| |b| cosθ = ax*bx + ay*by + az*bz

几何意义:

  • 判断两个向量是否垂直:a · b = 0
  • 求夹角:cosθ = (a · b) / (|a||b|)
  • 求投影长度:a在b方向上的投影长度与a · b有关。

叉积(Cross Product)

a×b= |a| |b| sinθ · n

其中n是同时垂直于a和b的单位向量,方向由右手定则决定

几何意义:

  • 结果垂直于两个输入向量。
  • 模长等于两向量张成的平行四边形面积。
  • 后续求法线、构造切线空间也会用到。

右手坐标系中:

x × y = z

y × z = x

z × x = y

归一化(Normalize)

normalize(v) = v / |v|

注意:

零向量不能归一化,否则会除以 0 得到NaN

矩阵与列主序

GLM 默认使用列主序(Column-Major):矩阵按列存储,glm::mat4的构造函数参数也是一列一列填的

数学上我们习惯写成行×列:

列0 列1 列2 列3

[ m00 m01 m02 m03 ]

[ m10 m11 m12 m13 ]

[ m20 m21 m22 m23 ]

[ m30 m31 m32 m33 ]

但glm::mat4(...)的参数顺序是:

m00, m10, m20, m30, // 第 0 列

m01, m11, m21, m31, // 第 1 列

m02, m12, m22, m32, // 第 2 列

m03, m13, m23, m33 // 第 3 列

这就是最容易踩的坑:如果你按 “行” 去填 GLM 构造函数,得到的是转置矩阵

记忆方法:

GLM 构造按列填;向量右边乘;复合矩阵从右往左读

列向量右乘:

v' = M * v

复合变换:

M = A * B

表示先应用B,再应用A:

M * v = A * (B * v)

平移矩阵

inline Matrix4x4 MakeTranslation(const Vector3f& t) { return Matrix4x4( 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, t.x, t.y, t.z, 1.0f ); }

对应的数学矩阵:

T(t) = [ 1 0 0 tx ] [ 0 1 0 ty ] [ 0 0 1 tz ] [ 0 0 0 1 ]

对点(x, y, z, 1):

T * (x, y, z, 0) = (x, y, z, 0)

为什么平移量在第四列?

因为列向量右乘时,输出的每个分量是矩阵的行与输入向量的点积。

第四列参与的是输入向量的w分量;点的w=1,所以平移量被加上;方向的w=0,所以平移量不生效。

代码中t.x, t.y, t.z, 1.0f是第四列,正好对应数学矩阵的第四列。

缩放矩阵

inline Matrix4x4 MakeScale(float s) { return Matrix4x4( s, 0.0f, 0.0f, 0.0f, 0.0f, s, 0.0f, 0.0f, 0.0f, 0.0f, s, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f ); }

对应的数学矩阵:

S(s) = [ s 0 0 0 ] [ 0 s 0 0 ] [ 0 0 s 0 ] [ 0 0 0 1 ]
  • s > 1:放大
  • 0 < s < 1:缩小
  • -s < 0:翻转(镜像)
  • s = 0:退化,矩阵不可逆

现在我只实现了均匀缩放。如果需要非均匀缩放,可以把参数改成Vector3f,对角线分别填sx, sy, sz

注意:非均匀缩放会改变法线方向,后续计算光照时不能直接用原法线,需要用逆转置矩阵处理。

旋转矩阵与欧拉角

inline Matrix4x4 MakeRotation(const Vector3f& euler) { // 先绕 X 轴旋转,再绕 Y 轴旋转,最后绕 Z 轴旋转: float cx = cosf(euler.x); float sx = sinf(euler.x); float cy = cosf(euler.y); float sy = sinf(euler.y); float cz = cosf(euler.z); float sz = sinf(euler.z); Matrix4x4 rx( 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, cx, sx, 0.0f, 0.0f, -sx, cx, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f ); Matrix4x4 ry( cy, 0.0f, -sy, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, sy, 0.0f, cy, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f ); Matrix4x4 rz( cz, sz, 0.0f, 0.0f, -sz, cz, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f ); return rz * ry * rx; // 注意矩阵乘法的顺序 }

从 2D 旋转说起

在xy平面内,绕原点逆时针旋转θ

R(θ) = [ cosθ -sinθ ] [ sinθ cosθ ]

把它扩展到 3D,分别绕三个坐标轴旋转:

Rx(θ) = [ 1 0 0 0 ] [ 0 cosθ -sinθ 0 ] [ 0 sinθ cosθ 0 ] [ 0 0 0 1 ] Ry(θ) = [ cosθ 0 sinθ 0 ] [ 0 1 0 0 ] [ -sinθ 0 cosθ 0 ] [ 0 0 0 1 ] Rz(θ) = [ cosθ -sinθ 0 0 ] [ sinθ cosθ 0 0 ] [ 0 0 1 0 ] [ 0 0 0 1 ]

把代码里的构造函数按列还原,可以得到上面的数学矩阵。例如rx的参数:

第 0 列: 1, 0, 0, 0 第 1 列: 0, cx, sx, 0 第 2 列: 0, -sx, cx, 0 第 3 列: 0, 0, 0, 1

还原成行×列正是Rx

欧拉角顺序

代码注释写的是:

先绕 X 轴旋转,再绕 Y 轴旋转,最后绕 Z 轴旋转

对列向量来说,先应用Rx,再应用Ry,最后应用Rz,对应的复合矩阵是:

R = Rz * Ry * Rx

因为:

R * v = Rz * (Ry * (Rx * v))

右乘的矩阵先作用,所以最右边的Rx最先作用

如果写成Rx * Ry * Rz,实际顺序就变成先Rz、再Ry、最后Rx。

为什么旋转顺序不能随便换?

3D 旋转一般不满足交换律

Rx * Ry ≠ Ry * Rx

例如“先绕 X 转 90°,再绕 Y 转 90°”和“先绕 Y 转 90°,再绕 X 转 90°”得到的姿态不同。

欧拉角的问题

  • 顺序必须约定清楚:XYZ、ZYX、YXZ 等不同顺序结果不同。
  • 存在万向节死锁(Gimbal Lock):某些角度下两个旋转轴重合,会丢失一个自由度。
  • 后续做相机/物体旋转时,常用lookAt或四元数来避免欧拉角的这些问题。

弧度制

cosf() / sinf()接收的是弧度,不是角度。如果输入是角度,需要先转换:

float rad = glm::radians(45.0f);

复合变换与世界矩阵

inline Matrix4x4 MakeWorldTransform(const Vector3f& position, const Vector3f& rotation, float s) { Matrix4x4 T = MakeTranslation(position); Matrix4x4 R = MakeRotation(rotation); Matrix4x4 S = MakeScale(s); return T * R * S; // 注意矩阵乘法的顺序 }

对局部空间中的点p_local:

p_world = T * R * S * p_local

几何顺序:

局部坐标 → 缩放 S → 旋转 R(绕物体原点) → 平移 T → 世界坐标

为什么不是S * R * T?

  • S * R * T会先平移,再旋转、缩放。平移后的点会绕世界原点旋转,通常不是我们想要的效果。
  • T * R * S是标准的“模型矩阵”顺序:先塑造物体,再摆放物体。

这就是 MVP 中的M(Model)矩阵。后续完整变换通常是:

p_clip = P * V * M * p_local

其中:

  • M:局部空间 → 世界空间。
  • V:世界空间 → 相机空间。
  • P:相机空间 → 裁剪空间。

补充:

  • 这里的旋转是绕物体原点旋转。如果想绕某个支点pivot旋转,需要:
T(pivot) * R * T(-pivot)
  • 这里的缩放也是绕原点缩放。

3.8 正交基构造:MakeCoordinateSystem

inline Matrix3x3 MakeCoordinateSystem(const Vector3f& w) { // u, v Vector3f u(1.0f, 0.0f, 0.0f); // 这个向量与 w 平行,则无法构造右手坐标系 if (glm::dot(w, u) > 0.99f) { u = Vector3f(0.0f, 1.0f, 0.0f); } Vector3f v = glm::cross(w, u); u = glm::cross(v, w); u = glm::normalize(u); v = glm::normalize(v); return Matrix3x3(u, v, w); // 列主序 }
作用

给定一个单位向量w(例如表面法线、观察方向),构造一组正交基(u, v, w),使得:

  • u、v、w两两垂直。
  • 三者都是单位向量。
  • 满足右手系:u × v = w。
  • 返回的mat3以u, v, w为列,表示“局部坐标系 → 世界坐标系”的旋转。
算法步骤
  1. 先选一个参考向量u = (1, 0, 0)。
  2. 如果w与u太平行,叉积会接近零,无法归一化,所以换一个参考向量u = (0, 1, 0)。
  3. v = normalize(cross(w, u)):得到同时垂直于w和u的向量。
  4. u = normalize(cross(v, w)):用v和w重新得到与两者都垂直的u。
  5. 返回s,按列拼成矩阵。
验证

取w = (0, 0, 1):

u = (1, 0, 0) v = cross(w, u) = (0, 1, 0) u = cross(v, w) = (1, 0, 0) M = [u v w] = 单位矩阵

说明当w就是局部 +Z 轴时,这个函数返回单位矩阵,符合直觉。

为什么正交矩阵的逆等于转置?

如果矩阵M的列向量是两两正交的单位向量,那么:

Mᵀ M = I

所以:

M⁻¹ = Mᵀ

这就是后续代码中常见的写法:

Matrix3x3 localToWorld = MakeCoordinateSystem(isect.normal); Matrix3x3 worldToLocal = glm::transpose(localToWorld);

把世界空间方向转换到局部着色坐标系时,不需要求逆,直接转置即可。

代码中的边界问题

当前判断:

if (glm::dot(w, u) > 0.99f)

只处理了w接近+X的情况。

如果w接近-X,例如w = (-1, 0, 0):

dot(w, u) = -1

条件不成立,不会切换到(0, 1, 0),于是:

cross(w, u) ≈ 0 normalize(0) → NaN

更稳妥的写法是判断绝对值:

if (std::fabs(glm::dot(w, u)) > 0.99f)

另外:

  • 函数假设w已经是单位向量。如果w不是单位向量,返回的基就不是正交归一基。
  • 如果w是零向量,归一化同样会得到NaN。
  • 调用者应保证w非零且已归一化,或者在函数内部做检查。

公式速查表

名称公式 / 代码记忆点
点积a·b = ax*bx + ay*by + az*bz垂直为 0,求夹角/投影
叉积a×b,右手定则结果垂直两者,模长=平行四边形面积
归一化v / length(v)零向量不能归一化
平移第四列填(tx, ty, tz, 1)点受平移,方向不受
缩放对角线填s均匀缩放
绕 X 旋转Rx(θ)y/z分量参与
绕 Y 旋转Ry(θ)x/z分量参与
绕 Z 旋转Rz(θ)x/y分量参与
欧拉角复合R = Rz * Ry * Rx先 X,再 Y,最后 Z
世界变换T * R * S先缩放,再旋转,最后平移
正交基v = w×u,u = v×w列向量拼矩阵,逆=转置
MVPP * V * M * p列向量右乘,从右往左读

渲染器框架

乘,从右往左读

渲染器框架

总体架构

main() └─ Renderer(1920, 1080).Run() ├─ mfb_open_ex("Fortune Renderer", w, h, MFB_WF_RESIZABLE) ├─ malloc(w * h * 4) // 32 位/像素 ├─ 启动渲染线程 │ └─ RunRenderThread() │ ├─ mCurrentPixelIndex.fetch_add(1) // 原子领取像素 │ ├─ RenderPixel(x, y) // 计算颜色 │ ├─ 颜色量化 + 打包 │ └─ mBuffer[y * w + x] = packed └─ present 循环 ├─ mfb_update_ex(window, mBuffer, w, h) └─ mfb_wait_sync(window)

分工:

  • 主线程:创建窗口、分配缓冲区、启动渲染线程、显示缓冲区、清理资源。
  • 渲染线程:领取像素任务、计算颜色、写入帧缓冲。

本章没有复杂的同步机制,依赖“渲染很快完成”这一事实。后续RenderPixel()变成光线追踪后会变慢,同步问题会更明显。

MiniFB 窗口

struct mfb_window* window = mfb_open_ex( "Fortune Renderer", mViewportWidth, mViewportHeight, MFB_WF_RESIZABLE ); if (window == NULL) return;
  • mfb_open_ex()创建一个窗口,返回mfb_window*。
  • 第四个参数MFB_WF_RESIZABLE表示窗口可调整大小。
  • 返回NULL表示创建失败,直接返回。

显示循环 :

mfb_update_state state; do { state = mfb_update_ex(window, mBuffer, mViewportWidth, mViewportHeight); if (state != MFB_STATE_OK) break; } while (mfb_wait_sync(window));
  • mfb_update_ex()把 CPU 缓冲区的内容显示到窗口。
  • MFB_STATE_OK表示显示正常;其他状态表示窗口关闭或出错。
  • mfb_wait_sync()等待下一帧/垂直同步;返回false时退出循环。

MiniFB 不是 GPU 渲染器,它只负责“窗口 + 显示一块 CPU 内存”。真正的像素计算全部由我们的代码完成

帧缓冲与像素格式

// 屏幕/窗口/视口上每个像素点的颜色,以32位整数表示,格式为0xAARRGGBB mBuffer = (uint32_t*)malloc(mViewportWidth * mViewportHeight * 4);
  • 每个像素 32 位(4 字节)。
  • 总大小 =width * height * 4字节。
  • 缓冲区按行优先排列:第y行、第x列的元素是mBuffer[y * width + x]。
  • MiniFB 的第 0 行在窗口顶部,所以y向下增大。

像素打包:

uint32_t r = glm::clamp((uint32_t)std::round(color.r * 255.0f), 0u, 255u); uint32_t g = glm::clamp((uint32_t)std::round(color.g * 255.0f), 0u, 255u); uint32_t b = glm::clamp((uint32_t)std::round(color.b * 255.0f), 0u, 255u); mBuffer[y * mViewportWidth + x] = (r << 16) | (g << 8) | (b);

打包结果:

0x00RRGGBB ││││││││ │││└┴┴┴─ B: bits 0-7 │└┴┴──── G: bits 8-15 └─────── R: bits 16-23 Alpha: bits 24-31(这里为 0)

为什么用位运算:

  • 一个uint32_t正好装下 4 个 8 位通道。
  • 没有结构体对齐/填充问题。
  • 写入和读取都很快。

关于 Alpha:

  • 注释写的是0xAARRGGBB,但代码只写了 RGB,Alpha 位是 0。
  • MiniFB 在桌面端通常忽略 Alpha,所以画面能正常显示。
  • 更稳妥的写法是显式设置不透明:
mBuffer[y * mViewportWidth + x] = 0xFF000000u | (r << 16) | (g << 8) | b;

关于内存字节序:

  • 0x00RRGGBB是一个 32 位整数的值,与字节序无关。
  • 在 little-endian 桌面上,它在内存中的字节顺序是B, G, R, A,正好是 MiniFB 桌面端期望的 BGRA 布局。
  • 如果从外部图片加载器拿到的是 RGBA 字节,可能需要交换 R/B。

颜色映射

Color Renderer::RenderPixel(int x, int y) { Color color; color.r = (float)x / mViewportWidth; color.g = (float)y / mViewportHeight; color.b = 0.0f; // std::this_thread::sleep_for(std::chrono::milliseconds(1)); return color; }

要点:

  • x、y是整数,必须先把其中一个转成float,否则会做整数除法,结果几乎全是 0。
  • x ∈ [0, width-1],所以r ∈ [0, 1);y同理。
  • Color使用线性 RGB,范围[0, 1]。后续 HDR 渲染可能超过 1,显示前再 clamp。
  • 蓝色分量为 0,所以画面是红绿渐变,右下角是黄色。
  • 被注释掉的sleep_for可以用来模拟“每个像素渲染很慢”,方便观察多线程效果。

颜色量化:

float [0, 1] → 乘以 255 → 四舍五入 → clamp 到 [0, 255] → uint32_t
  • std::round()四舍五入到最近的整数。
  • glm::clamp()防止越界。
  • 8 位通道最大值是 255。

潜在问题:

(uint32_t)std::round(color.r * 255.0f)

如果color.r是负数,先转成uint32_t会发生回绕,变成一个很大的无符号数,再 clamp 就变成 255,而不是 0。更稳妥的顺序是先把浮点值 clamp 到[0, 1]:

float r = glm::clamp(color.r, 0.0f, 1.0f); uint32_t r8 = (uint32_t)std::round(r * 255.0f);

本章颜色非负,所以不会出现这个问题,但后续 HDR 颜色可能为负或大于 1,需要特别注意。

关于 Gamma:

  • 直接把线性颜色映射到 8 位显示,严格来说缺少 Gamma 编码,画面会偏暗。
  • 本章只是演示框架,暂不处理颜色管理。
  • 后续做真实感渲染时,需要区分线性空间和显示空间

多线程渲染

std::atomic<int> mCurrentPixelIndex = 0;
void Renderer::RunRenderThread() { while (true) { // 读取当前屏幕的下一个像素 int pixelIndex = mCurrentPixelIndex.fetch_add(1); if (pixelIndex >= mViewportWidth * mViewportHeight) break; int x = pixelIndex % mViewportWidth; int y = pixelIndex / mViewportWidth; Color color = RenderPixel(x, y); uint32_t r = glm::clamp((uint32_t)std::round(color.r * 255.0f), 0u, 255u); uint32_t g = glm::clamp((uint32_t)std::round(color.g * 255.0f), 0u, 255u); uint32_t b = glm::clamp((uint32_t)std::round(color.b * 255.0f), 0u, 255u); mBuffer[y * mViewportWidth + x] = (r << 16) | (g << 8) | (b); } }
原子计数器如何工作

fetch_add(1)的作用:

  1. 原子地读取当前值。
  2. 把当前值加 1。
  3. 返回加之前的旧值。

因此每个线程拿到的pixelIndex都是唯一的,不会重复处理同一个像素,也不需要互斥锁。

线程 A: fetch_add(1) -> 0 线程 B: fetch_add(1) -> 1 线程 C: fetch_add(1) -> 2 ...
动态任务分配

这种模式叫动态调度:

  • 哪个线程先完成当前像素,就继续领取下一个。
  • 处理速度快的线程会自动多做,速度慢的少做。
  • 比“每个线程固定分一块区域”更适合每个像素耗时不一致的场景(例如后续光线追踪)。

代价:

  • 每次领取任务都要访问共享原子变量,有一定开销。
  • 默认fetch_add使用std::memory_order_seq_cst,比较严格;这里只需要计数,可以用std::memory_order_relaxed降低开销:
int pixelIndex = mCurrentPixelIndex.fetch_add(1, std::memory_order_relaxed);
线程数量
std::thread renderThread(&Renderer::RunRenderThread, this); renderThread.detach(); int numThreads = std::thread::hardware_concurrency(); std::vector<std::thread> renderThreads(numThreads); for (int i = 0; i < numThreads; i++) { renderThreads[i] = std::thread(&Renderer::RunRenderThread, this); renderThreads[i].detach(); }
  • std::thread::hardware_concurrency()返回硬件支持的逻辑线程数,可能是 0(无法获取)。
  • 代码先创建了1 个renderThread,然后又创建了numThreads个线程。
  • 所以实际线程数是numThreads + 1,而不是numThreads。这很可能是一个疏漏。
  • 如果hardware_concurrency()返回 0,renderThreads为空,但仍然有那 1 个额外线程在工作。

更合理的写法:

unsigned int numThreads = std::max(1u, std::thread::hardware_concurrency()); std::vector<std::thread> threads; for (unsigned int i = 0; i < numThreads; ++i) threads.emplace_back(&Renderer::RunRenderThread, this); for (auto& t : threads) t.join();
本章多线程模型的问题
问题说明改进
多创建了一个线程总共N+1个线程去掉额外的renderThread,或循环N-1次
detach后释放缓冲区主线程可能在渲染线程还在写的时候free(mBuffer),造成 use-after-free用join()等待线程结束,或加停止标志
首帧可能不完整线程刚启动,主线程立刻mfb_update_ex(),可能显示半张图加“帧完成”屏障/条件变量
只渲染一帧计数器不重置,所有线程完成后就退出每帧重置计数器,并同步
读写同一缓冲区渲染线程写、主线程读,可能撕裂双缓冲:渲染到后台缓冲,完成后交换
窗口 resize 未处理缓冲区大小固定,窗口变大后仍按原尺寸显示监听 resize,重新分配缓冲区

这些问题在本章因为渲染很快、画面简单,通常不会明显暴露,但后续章节会逐渐放大。

主线程与渲染线程的分工

角色工作
主线程创建窗口、分配缓冲区、启动线程、显示缓冲区、清理
渲染线程领取像素、计算颜色、写入缓冲区

本章的同步非常“朴素”:

  • 没有join,没有条件变量,没有双缓冲。
  • 主线程假设渲染线程会在显示之前完成。
  • 这在 1920×1080 的简单渐变上基本能工作,但不是可靠的工程写法。

学习时可以先理解“能跑起来”的最小版本,再逐步加上同步和双缓冲。