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_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。
- 返回加之前的旧值。
因此每个线程拿到的 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 的简单渐变上基本能工作,但不是可靠的工程写法。
学习时可以先理解“能跑起来”的最小版本,再逐步加上同步和双缓冲。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)