🎯 第五课:光线追踪算法——Möller-Trumbore + Slab Method
为什么排第五?
第四课学了 BVH 这棵”加速树”,但树只是跳过了不需要测试的三角形。真正判断”射线有没有打中三角形”的算法,在这一课。

BVH = 快速排除 → Möller-Trumbore = 精确判定 → Slab Method = 快速排除 AABB

三者协作,才完成了光线追踪。用你的话说:”看AI给的光追代码对不对——底层原理的应用”(理念 1.5)。

费曼学习法 Step 1:用大白话解释它是什么
想象你站在一个房间里,拿手电筒照墙壁:

手电筒的光线 = Ray(起点 + 方向)
墙上的一个三角形窗户 = Triangle
光线穿没穿过窗户?穿过的话在哪穿过去的? = Möller-Trumbore 要回答的问题
但先别急——房间里可能有几十万个窗户,你不可能逐个检查。所以你先看”这面墙”(AABB)有没有被照到 → Slab Method,只检查被照到的墙上的窗户 → Möller-Trumbore。

两个算法,两种精度:

Slab Method:粗筛,O(1),判断”可能命中”
Möller-Trumbore:精查,O(1),判断”确实命中 + 命中在哪”
费曼学习法 Step 2:逐个拆解核心知识点
📦 A. Möller-Trumbore 算法——射线与三角形求交
A1. 数学问题是什么?

已知:

射线:P(t) = O + tD(起点O,方向D,参数t)
三角形:顶点 V0, V1, V2
求:是否存在 t > 0,使得 P(t) 在三角形内部?

A2. 关键洞察:重心坐标

三角形内部任意一点可以表示为:

P = V0 + u·(V1 - V0) + v·(V2 - V0)
其中 u ≥ 0, v ≥ 0, u + v ≤ 1

u=0, v=0 → 就是 V0
u=1, v=0 → 就是 V1
u=0, v=1 → 就是 V2
u=0.3, v=0.5 → 内部某点
条件 u ≥ 0, v ≥ 0, u+v ≤ 1 就是”点在三角形内”的充要条件。

A3. 联立方程——核心推导

射线方程 = 重心坐标方程:

O + tD = V0 + u·E1 + v·E2

其中 E1 = V1 - V0, E2 = V2 - V0

移项:
-tD + u·E1 + v·E2 = O - V0
这是 3 个方程(x/y/z),3 个未知数(t/u/v),写成矩阵形式:

[-D, E1, E2] · [t, u, v]ᵀ = S

其中 S = O - V0
用克莱默法则解:

det = E1 · (D × E2) ← 行列式
= D · (E1 × E2) ← 交换叉积和点积(更高效)

t = (S · (E1 × E2)) / det
u = (D · (S × E2)) / det
v = (D · (E1 × S)) / det
A4. 完整算法流程

bool intersect_ray_triangle(Ray ray, Triangle tri, float& t_out) {
Vec3 E1 = tri.v1 - tri.v0; // 边1
Vec3 E2 = tri.v2 - tri.v0; // 边2
Vec3 S = ray.origin - tri.v0; // 起点偏移

Vec3 E1xE2 = cross(E1, E2);         // P向量(只算一次)
float det = dot(ray.direction, E1xE2);  // 行列式

// ① 行列式为0 → 射线平行于三角形平面,无交点
if (fabs(det) < 1e-8) return false;

float inv_det = 1.0f / det;         // 除法只做一次

float u = inv_det * dot(S, cross(ray.direction, E2));
float v = inv_det * dot(ray.direction, cross(S, E1));
float t = inv_det * dot(S, E1xE2);

// ② u, v 范围检查
if (u < 0.0f || v < 0.0f || u + v > 1.0f) return false;

// ③ t > 0(交点在射线正方向)
if (t < 0.0f) return false;

t_out = t;
return true;

}
A5. 每一步的物理意义

检查 物理意义 不通过说明
det ≈ 0 射线几乎平行于三角形平面 光线贴着面飞过去,不可能穿透
u < 0 交点在 V0-V2 边的”外侧” 超出了三角形的一条边
v < 0 交点在 V0-V1 边的”外侧” 超出了另一条边
u+v > 1 交点在 V1-V2 边的”外侧” 超出了第三条边
t < 0 交点在射线反方向 光线往后打,不可能
A6. 为什么这个算法快?

传统方法 Möller-Trumbore
步骤 1. 算平面方程 2. 算射线-平面交点 3. 判断点是否在三角形内 一步到位,同时算出 t, u, v
除法 2-3 次 1 次
分支 需要3次”边测试” 同样3次,但更早拒绝
额外数据 需要预存平面法线 只需要3个顶点
核心优化:用 inv_det 把除法从 3 次减到 1 次,浮点除法是乘法耗时的 4-10 倍。

AI坑点: AI 写射线-三角形求交时经常用”先求平面交点,再判断点在不在三角形内”的两步法。代码更长、除法更多、还容易在退化三角形上出bug。Möller-Trumbore 一步到位,AI 不一定会用。

📦 B. Slab Method——射线与 AABB 求交
B1. 直觉理解

AABB 的 6 个面把空间切成了 3 对”平板”(slab):

X-slab: min.x ≤ x ≤ max.x (两面垂直于X轴的墙)
Y-slab: min.y ≤ y ≤ max.y (两面垂直于Y轴的墙)
Z-slab: min.z ≤ z ≤ max.z (两面垂直于Z轴的墙)

AABB = 三个 slab 的交集
射线穿过 AABB = 射线同时穿过三个 slab。

B2. 核心算法

对每个轴 i (x, y, z):
t_min_i = (bbox.min[i] - ray.origin[i]) / ray.direction[i] ← 进入这个slab的时间
t_max_i = (bbox.max[i] - ray.origin[i]) / ray.direction[i] ← 离开这个slab的时间

if (t_min_i > t_max_i) swap(t_min_i, t_max_i)  ← 方向为负时要交换

t_enter = max(t_min_x, t_min_y, t_min_z) ← 射线进入所有slab的时间 = 进入AABB的时间
t_exit = min(t_max_x, t_max_y, t_max_z) ← 射线离开任一slab的时间 = 离开AABB的时间

命中条件: t_enter < t_exit && t_exit > 0
B3. 逐步图解(2D 简化版)

     min.x           max.x
       │               │
───────┼───────────────┼─────── min.y
       │  ┌─────────┐  │
       │  │  AABB   │  │
 射线 →→→→→→→→→→→→→→→→→→→→
       │  │         │  │
       │  └─────────┘  │
───────┼───────────────┼─────── max.y
       │               │

X-slab: t_min_x = 2, t_max_x = 8
Y-slab: t_min_y = 3, t_max_y = 7

t_enter = max(2, 3) = 3    ← 同时在两个slab内
t_exit  = min(8, 7) = 7    ← 任一slab退出
3 < 7 ✓ 命中!

B4. t_enter > t_exit 是什么意思?

     min.x     max.x
       │         │
───────┼─────────┼─── min.y
       │  AABB   │
       │         │     射线从右边飞过
 ─ ─ ─ ─ ─ ─ ─ ─ ─ →→→   没进X-slab就出了Y-slab
       │         │
───────┼─────────┼─── max.y

X-slab: t_min_x = 8, t_max_x = 12
Y-slab: t_min_y = 3, t_max_y = 7

t_enter = max(8, 3) = 8    ← 要到t=8才进入X-slab
t_exit  = min(12, 7) = 7   ← 但t=7就已经出了Y-slab
8 > 7 ✗ 未命中!

进入一个 slab 之前就已经离开了另一个 slab → 永远不可能同时在三个 slab 里 → 不命中。

B5. 为什么要 swap?

如果 ray.direction.x < 0(射线朝X负方向):
t_min_x = (max.x - origin.x) / dir.x ← 注意:这时候min和max反了
t_max_x = (min.x - origin.x) / dir.x
因为方向为负时,”先碰到”的是 max 那一面,”后碰到”的是 min 那一面。swap 保证 t_min 始终是”进入时间”,t_max 始终是”离开时间”。

B6. 除以零怎么办?

dir.x = 0 时,射线平行于 X 轴的 slab:

如果 origin.x 在 [min.x, max.x] 内 → 永远在这个 slab 里 → t_min = -∞, t_max = +∞
如果不在 → 永远不在 → t_min = +∞, t_max = -∞
代码中用浮点数的特殊值处理:1.0f / 0.0f = +inf, 1.0f / -0.0f = -inf,IEEE 754 标准保证这一切自动正确,不需要特殊处理。

AI坑点: AI 经常加一个 if (fabs(dir.x) < epsilon) 的特殊分支来处理平行情况。这不但不必要(IEEE 754 已经处理了),还会引入分支预测失败的惩罚。项目代码直接算,零分支。

📦 C. 两个算法在 BVH 遍历中的协作
// 伪代码:完整的BVH光线追踪
Intersection trace_ray(Ray ray, BVH& bvh) {
Intersection closest; // 最近的命中
closest.distance = FLT_MAX;

uint32_t stack[64];
int sp = 0;
stack[0] = 0;  // 根节点

while (sp >= 0) {
    uint32_t idx = stack[sp--];
    BVHNode& node = bvh.nodes[idx];
    
    // ──── 第一步:Slab Method 粗筛 ────
    if (!intersect_ray_aabb(ray, node.bounds))
        continue;  // 射线没碰到AABB → 整棵子树跳过
    
    if (node.is_leaf) {
        // ──── 第二步:Möller-Trumbore 精查 ────
        for (int i = 0; i < node.tri_count; i++) {
            float t;
            if (intersect_ray_triangle(ray, *bvh.leaf_tris[node.tri_start + i], t)) {
                if (t < closest.distance) {  // 更近?
                    closest.distance = t;
                    closest.tri = bvh.leaf_tris[node.tri_start + i];
                    closest.point = ray.origin + t * ray.direction;
                }
            }
        }
    } else {
        stack[++sp] = node.right;
        stack[++sp] = node.left;
    }
}

return closest;

}
C1. 为什么 Slab Method 必须在 Möller-Trumbore 之前?

只用 Möller-Trumbore Slab + Möller
每个节点 测试叶子中所有三角形 先测AABB,不过就跳
AABB测试代价 — 6次除法 + 6次比较 ≈ 20ns
三角形测试代价 每个三角形 ≈ 50ns 同
百万三角形场景 测试 ~1000 个三角形 ≈ 50μs 测试 ~10 个AABB + ~5 个三角形 ≈ 0.5μs
AABB 测试比三角形测试便宜,而且一次通过就能跳过整棵子树。粗筛的成本远低于精查,所以先粗后精。

📦 D. 自交问题与 Epsilon 偏移——光线追踪的”幽灵bug”
D1. 问题是什么?

从三角形表面发射射线,射线起点就在这个三角形上。由于浮点误差,t 可能算出一个极小的正数(比如 1e-8),射线”命中”了自己所在的三角形。

从三角形A表面发射线 → t = 1e-8 → “命中A自己” → 错误!
D2. 解决方案:epsilon 偏移

float self_intersection_eps = 1e-4f;

// 方案1:偏移射线起点
ray.origin = ray.origin + ray.direction * self_intersection_eps;

// 方案2:忽略过近的命中
if (t < self_intersection_eps) continue; // 太近了,视为自交
项目使用方案2——忽略过近的命中。这样更简单,而且不需要修改射线数据。

D3. epsilon 选多大?

值 风险
1e-8 太小,浮点误差可能比这个大,自交仍然出现
1e-4 合适,对于 mm 级别的模型足以排除自交
1e-2 太大,可能错过真正很近的对面壁(薄壁模型)
1e-4 在 3D 打印场景下是安全的——0.0001mm 的壁厚在现实中不存在。

AI坑点: AI 通常不知道要加 epsilon 偏移,或者随便写个 1e-6——在某些模型上就会出”幽灵命中”,而且这种 bug 极难调试(只在特定角度、特定模型上出现)。

费曼学习法 Step 3:两个算法的对比与协作全景
射线射出


┌─────────────────────────────────────────────┐
│ Slab Method (粗筛) │
│ │
│ 输入: Ray + AABB │
│ 计算: 3对t_min/t_max → t_enter/t_exit │
│ 判断: t_enter < t_exit && t_exit > 0 ? │
│ 输出: Yes/No (O(1), ~20ns) │
│ 特点: 零分支处理平行情况(IEEE 754) │
└──────────────┬──────────────────────────────┘
│ No → 跳过整棵子树
│ Yes

┌─────────────────────────────────────────────┐
│ Möller-Trumbore (精查) │
│ │
│ 输入: Ray + Triangle │
│ 计算: 1次叉积 + 3次点积 + 1次除法 → t, u, v │
│ 判断: u≥0 && v≥0 && u+v≤1 && t>ε ? │
│ 输出: t值 + 命中点 (O(1), ~50ns) │
│ 特点: 重心坐标一步到位, 1次除法 │
└──────────────┬──────────────────────────────┘


Intersection(point, tri*, distance)


曲率计算(第6课) → 聚类(第7课)
费曼学习法 Step 4:识别你的知识缺口
# 自检问题 如果你答不出
1 Möller-Trumbore 中 u+v>1 的几何意义是什么?画个图试试 重新理解重心坐标
2 为什么 det ≈ 0 时射线平行于三角形?行列式的几何意义是什么? 重新理解叉积+点积的几何含义
3 Slab Method 中 1.0f / 0.0f 在 C++ 中结果是什么?为什么不需要特殊处理? 去查 IEEE 754 浮点标准
4 为什么要 swap t_min 和 t_max?不 swap 会怎样? 重新理解方向为负时的情况
5 epsilon = 1e-4,如果模型单位是米而不是毫米,这个值还合适吗? 重新理解 epsilon 与模型尺度的关系
6 为什么 Slab Method 只需 6 次比较就能判断3D空间中射线是否穿过一个盒子? 重新理解 slab 分解的数学本质
7 Möller-Trumbore 用 inv_det 只做1次除法,传统方法做几次?除法比乘法慢多少? 重新理解除法优化的价值
费曼学习法 Step 5:用你的话复述
试着这样解释:

“光线追踪分两步:先粗筛再精查。粗筛用 Slab Method——把 AABB 想象成3对平行墙,射线穿过所有墙的时间取最晚的(t_enter),离开任意一面墙的时间取最早的(t_exit),如果进去比出来早,就命中了。方向为负时 swap 一下就行,除以零靠 IEEE 754 自动处理,零分支。精查用 Möller-Trumbore——把射线方程和三角形的重心坐标方程联立,一步解出 t、u、v 三个参数。t 告诉你在哪命中,u 和 v 告诉你命中在三角形内的什么位置。只做1次除法,比传统的两步法快。还要加个 epsilon 偏移防止自交——从表面发射的射线不能命中自己,浮点误差会让这个幽灵bug时隐时现,极难调试。”

📌 本课与你的 AI 时代理念的映射
你的理念 本课体现
1.5 AI代码有坑 AI 不会用 Möller-Trumbore(用两步法更慢)、不知道 epsilon 偏移(幽灵bug)、加不必要的分支处理平行情况
1.4 架构能力 Slab + Möller 的”先粗后精”不是算法细节,是系统架构——每一层该用多高精度的查询,是架构师的决策
1.2 技术是切入点 重心坐标、行列式、IEEE 754 是技术,但”为什么需要光线追踪”是业务——3D 打印需要检测壁厚
1.1 出题人 epsilon 取 1e-4 而不是 1e-8——这是你定义”什么是自交”,出题人定义边界条件
1.6 软实力 自交 bug 时隐时现极难复现——如果你能用清晰的语言向团队描述这个问题的本质,调试效率翻倍