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) | 不会被平移 |
为什么需要齐次坐标?
- 3×3 矩阵只能表示线性变换(旋转、缩放),无法表示平移
- 用 4×4 矩阵和w=1的点,可以把平移也写成矩阵乘法
- 方向向量的w=0会自动忽略平移,符合直觉
- 后续透视投影会用到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为列,表示“局部坐标系 → 世界坐标系”的旋转。
算法步骤
- 先选一个参考向量
u = (1, 0, 0)。 - 如果
w与u太平行,叉积会接近零,无法归一化,所以换一个参考向量u = (0, 1, 0)。 v = normalize(cross(w, u)):得到同时垂直于w和u的向量。u = normalize(cross(v, w)):用v和w重新得到与两者都垂直的u。- 返回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 | 列向量拼矩阵,逆=转置 |
| MVP | P * 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_tstd::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。
- 返回加之前的旧值。
因此每个线程拿到的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 的简单渐变上基本能工作,但不是可靠的工程写法。
学习时可以先理解“能跑起来”的最小版本,再逐步加上同步和双缓冲。