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 矩阵和w1的点可以把平移也写成矩阵乘法方向向量的w0会自动忽略平移符合直觉后续透视投影会用到w做透视除法点积Dot Producta·b |a| |b| cosθ ax*bx ay*by az*bz几何意义判断两个向量是否垂直a · b 0求夹角cosθ (a · b) / (|a||b|)求投影长度a在b方向上的投影长度与a · b有关。叉积Cross Producta×b |a| |b| sinθ · n其中n是同时垂直于a和b的单位向量方向由右手定则决定几何意义结果垂直于两个输入向量。模长等于两向量张成的平行四边形面积。后续求法线、构造切线空间也会用到。右手坐标系中x × y zy × z xz × x y归一化Normalizenormalize(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再应用AM * 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分量点的w1所以平移量被加上方向的w0所以平移量不生效。代码中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_localp_world T * R * S * p_local几何顺序局部坐标 → 缩放 S → 旋转 R绕物体原点 → 平移 T → 世界坐标为什么不是S * R * TS * R * T会先平移再旋转、缩放。平移后的点会绕世界原点旋转通常不是我们想要的效果。T * R * S是标准的“模型矩阵”顺序先塑造物体再摆放物体。这就是 MVP 中的MModel矩阵。后续完整变换通常是p_clip P * V * M * p_local其中M局部空间 → 世界空间。V世界空间 → 相机空间。P相机空间 → 裁剪空间。补充这里的旋转是绕物体原点旋转。如果想绕某个支点pivot旋转需要T(pivot) * R * T(-pivot)这里的缩放也是绕原点缩放。3.8 正交基构造MakeCoordinateSysteminline 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×uu 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但代码只写了 RGBAlpha 位是 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::atomicint 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::vectorstd::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()返回 0renderThreads为空但仍然有那 1 个额外线程在工作。更合理的写法unsigned int numThreads std::max(1u, std::thread::hardware_concurrency()); std::vectorstd::thread threads; for (unsigned int i 0; i numThreads; i) threads.emplace_back(Renderer::RunRenderThread, this); for (auto t : threads) t.join();本章多线程模型的问题问题说明改进多创建了一个线程总共N1个线程去掉额外的renderThread或循环N-1次detach后释放缓冲区主线程可能在渲染线程还在写的时候free(mBuffer)造成 use-after-free用join()等待线程结束或加停止标志首帧可能不完整线程刚启动主线程立刻mfb_update_ex()可能显示半张图加“帧完成”屏障/条件变量只渲染一帧计数器不重置所有线程完成后就退出每帧重置计数器并同步读写同一缓冲区渲染线程写、主线程读可能撕裂双缓冲渲染到后台缓冲完成后交换窗口 resize 未处理缓冲区大小固定窗口变大后仍按原尺寸显示监听 resize重新分配缓冲区这些问题在本章因为渲染很快、画面简单通常不会明显暴露但后续章节会逐渐放大。主线程与渲染线程的分工角色工作主线程创建窗口、分配缓冲区、启动线程、显示缓冲区、清理渲染线程领取像素、计算颜色、写入缓冲区本章的同步非常“朴素”没有join没有条件变量没有双缓冲。主线程假设渲染线程会在显示之前完成。这在 1920×1080 的简单渐变上基本能工作但不是可靠的工程写法。学习时可以先理解“能跑起来”的最小版本再逐步加上同步和双缓冲。