Möller-Trumbore射线三角形求交算法

射线与三角形求交:Möller–Trumbore 算法详解与证明

前言

这篇文章是我和 AI 一来一回聊出来的。

起因只是一个很小的问题:射线与三角形求交,除了「先求射线与平面的交点、再判断点是否在三角形内」这个老办法,Möller–Trumbore 那个一口气解出 的做法究竟是怎么来的?结果问题越追越多——为什么这里冒出一个克莱姆法则?克莱姆法则本身为什么成立?混合积为什么能写成行列式?由于很多数学知识遗忘了,于是就这么一路问下去,把原本只会照抄公式的地方,逐个变成了能自己推出结论的地方。

收获比预期大。以前看这类实现,眼里只是一串叉乘和点乘;现在知道每一步在算什么、为什么可以那样重排,以及符号最容易在哪一步翻车。怕自己忘,也怕以后只记得结论不记得原因,就把这次讨论整理成了这篇。

全文按「问题 → 数学工具 → 推导 → 实现 → 工程细节」的顺序展开。想补推导的话,§4(克莱姆法则)和 §5(混合积)是关键;只想拿代码,直接跳到 §8 即可。


1. 问题定义与记号

三维空间里最常被问的一个问题:「这条射线打中了哪个三角形?」

教科书上的标准答案是分两步:先求射线与三角形所在平面的交点,再判断这个交点是否落在三角形内部。Möller 与 Trumbore 在 1997 年给出了另一条路径——只用一个 3×3 线性方程组,一次解出射线参数与重心坐标,把叉乘次数减半。下面先统一记号。

射线:

  • :起点
  • :方向向量。不要求是单位向量,这一点后面 §9.3 会用到

三角形:三个顶点 ,两条边向量

从射线起点指向 的向量:

1.1 重心坐标

三角形所在平面上的任意一点都可以写成三个顶点的仿射组合:

就是重心坐标(barycentric coordinates),三者恒和为 1。

几何上它们是面积比:

这也解释了为什么它天然适合做属性插值:给三个顶点各挂一个法线 / UV / 颜色,按 加权就能得到交点处的插值结果。

1.2 判定条件

相交 存在 使

且

前一个条件说交点在射线正方向,后三个说交点在三角形内部(含边界)。


2. 朴素做法:两步法

2.1 第一步,与平面求交

平面法向

过 的平面方程 。代入 :

表示射线平行于平面:无交点,或整条射线躺在平面内。

2.2 第二步,判断是否在三角形内

得到交点 后,两种常见写法。

边函数(三次叉乘同向):

若 同号,则 在内部。

面积比:直接按 §1.1 的定义算 ,代价是三次叉乘 + 三次取模 + 除法。

2.3 代价

项目 两步法 Möller–Trumbore
向量减法 6 3
叉乘 4 2
点乘 5 4
除法 1 1
是否构造交点 需要 不需要

2.4 三个隐性成本

  1. 中间量 没有复用价值。它被算出来只为了做内测,而 本身已经是最终答案。
  2. 内测要重新算一遍边。 是三次减法加三次叉乘,和第一步毫无共享。
  3. 信息没利用干净。 全都在场, 里其实已经隐含了重心坐标的信息,但要先求出 才能把它「读」出来——多绕了一圈。

反过来想:既然 是三个未知数,而等式在三维空间中本来是三个标量方程,那它本来就该被一次性解出来。


3. 换个思路:一个 3×3 线性方程组

从核心等式出发,把未知数归到左边。先展开右端:

代回并移项:

两边同乘 :

写成矩阵形式(三列依次是 ):

于是问题变成:解一个 3×3 线性方程组。

式 (3.1) 左端的负号来自移项,随后被矩阵的第一列 吸收,所以式 (3.2) 的右端是 而不是 。顺着移项走一遍,符号不会错。


4. 数学工具一:克莱姆法则

4.1 定理

设 是 方阵,。若 是

的解,把 的第 列替换为 得到 ,则

记忆:分母永远是原矩阵的行列式;分子是把「该未知数对应的那一列」换成常数列。

它的价值:不需要消元(无需处理主元为 0 的情形),每个未知数都有一个独立、闭式的表达式。当这些行列式能借助向量运算快速求出时,它就是最快的路径。

4.2 证明一:换列 + 行列式的列线性

以 为例:

由 ,即 、,代入:

关键性质:行列式对每一列/行是线性的(固定其余列,对当前列线性)。于是把第一列拆开:

第一项提出 就是 ;第二项两列相同,行列式为 。故

对 完全对称。推广到 维只是「拆成 项、其余 项因列重复而消失」,论证相同。

4.3 证明二:伴随矩阵

可逆时

其中 是代数余子式。取第 个分量:

而右端这个求和正是把第 列换成 后按第 列展开的公式:

两个证明的分工:证明一让你看见为什么是「换列」;证明二让你理解它在矩阵语言里的位置。写代码时两者都不需要,但想清楚「分母为什么总是同一个」,靠的是它们。

5. 数学工具二:混合积

5.1 定义

对三个三维向量 :

先叉乘得向量,再点乘得标量,所以叫标量三重积(scalar triple product);三个向量「混」在一起,也叫混合积。

5.2 定理:以三个向量为列的行列式 = 混合积

按第一列展开:

而叉乘的分量是

逐项对照:

中间那个负号正好与展开式里的 抵消:

这里之所以选列来证,是因为后面要用的都是列:§6 的 就是列拼,直接套用即可。若改以三个向量为行拼成矩阵,结论同样成立——,且按第一行展开与按第一列展开逐项对应。

5.3 性质

性质 表达式
轮换不变
交换变号
有重复为零 任意两个向量相同或共线
共面判据 三向量共面
体积 平行六面体体积
点叉可换

轮换性的证明:把 看成以三向量为列的行列式 。

行列式有一条基本性质:交换任意两行(或两列),行列式变号。 于是把 轮换成 只需两步——

两次交换,变了两次号,负负得正,行列式不变。同理,交换任意两个向量只需一次交换,符号翻转。

两种情形合起来,就是「6 种排列里 3 种相等、3 种互为相反数」的全部理由:交换次数为偶则保号,为奇则变号。

与点叉互换的关系:轮换不变还有一条等价说法,即「叉号可以往前挪一位」:

这一条很容易被误当成轮换性的「原因」,其实它是轮换性的另一种写法:由轮换式直接代入可得它;反过来用它配合点积的交换律,也能推出轮换式。所以它不提供任何新的信息,只是一条更方便的记账规则。

一行证明:左边是 ,右边是 ,而后者正是前者的三列轮换。

这条性质属于谁:既不属于点积,也不属于叉积。

  • 点积是对称的(),它只负责「投影求值」,不产生符号;
  • 叉积是反对称的(),符号从它这里来;
  • 但符号随排列如何变化,由行列式的「对换变号」规定,与两个算子各自的对称性都无关。

一个反例说明它不能归给叉积:三重叉积 轮换之后并不相等。取 、、:

三者互不相等——只有「一叉一点」的嵌套才有轮换不变性。

工程上的用处:这条性质让你可以自由调整顺序、挑最省力的写法。§6.1 里那一步

中间的等号就是轮换性。改写成最后的形式,是为了复用 (算 时还要用),从而省掉一次叉乘——这正是「叉乘从 4 次降到 2 次」的来源。

5.4 几何意义:为什么是体积

  1. 的模长 = 张成的平行四边形底面积,方向垂直于该平行四边形;
  2. ;
  3. 而 是 在法向上的投影长度,即平行六面体的高 ;
  4. 于是 。

轮换的几何解释:三种写法只是换了底面。

写法 底面 高由谁提供
张成
张成
张成

盒子是同一个盒子。底面换了,高也随之改变,但「底面积 × 高」始终相同——这就是 §5.3 的轮换性在几何上的说法。

注意:这个几何解释只直接说明绝对值不变。体积一样大,不等于定向一样;符号不变还得靠 §5.3 的「变两次号」。只用「体积相等」去论证轮换不变,其实只证了一半。

符号的意义:正 ⟹ 是右手系;负 ⟹ 左手系;零 ⟹ 共面(体积为 0)。

在图形学里,法线朝向、三角形绕序(winding order)、背面剔除、光线打的是正面还是背面——判据全是混合积的符号。


6. 合成:解出

6.1 分母

用轮换性整理成最好算的形式。定义

则

6.2 分子

未知数 替换的列 分子行列式 化简结果
第 1 列
第 2 列
第 3 列

其中

6.3 结果

即

6.4 正确性证明(直接验证)

已知 ,即 线性无关。要验证式 (3.1):

技巧:用叉乘去消项。拿 点乘两端,含 和 的项自动消失。

验证 :

右端 ,故 。

验证 :两端点乘 (垂直于 与 ):

右端 ,故 。

验证 :两端点乘 (垂直于 与 ):

右端 ,故 。

三式全部验证通过; 时解唯一,故这就是唯一解。

这个证明比「由克莱姆法则,显然」更有价值:它顺便解释了为什么偏偏是这三个叉乘方向——因为用 点乘恰好能消掉另外两个未知数的项。克莱姆法则是「怎么想到的」,这个证明是「为什么必然对」。


7. 它到底快在哪

先把话说明白:混合积本身并不比行列式快。展开一个 3×3 行列式是 9 乘 5 加;用一次叉乘加一次点乘算同一个混合积是 9 乘 + 3 加 + 3 加,并没有更省。

真正的收益在下面四点。

7.1 复用

四个行列式(分母 + 三个分子)共享同一批 。按定义展开是 次乘法;复用后是 2 次叉乘 + 4 次点乘 次。叉乘从 4 次降到 2 次是主要收益,而叉乘是这套运算里最贵的单目(每次 6 乘 3 减)。

7.2 只有一次除法

只算一次,三个未知数各乘一次——而不是三次除法。

7.3 提前退出(early-out)

只依赖 ,所以它可以在 之前就判定。 落在 外时直接返回,此时只花了 1 次叉乘 + 2 次点乘。在光线追踪里,绝大多数射线是「未命中」,走的就是这条最便宜的路径。

7.4 延迟计算

放到最后。未命中的射线根本不需要 ,省下来的不只是那一次点乘,还有一次结果写回。

于是推荐的计算顺序是:

1
2
3
4
5
6
7
8
9
10
11
12
S1   = cross(D, E2)
det = dot(S1, E1)
if |det| < eps: return miss // 平行或退化三角形
inv = 1 / det
u = dot(S1, S) * inv
if u < 0 || u > 1: return miss // 拒绝率最高的关卡
S2 = cross(S, E1)
v = dot(S2, D) * inv
if v < 0 || u + v > 1: return miss
t = dot(S2, E2) * inv
if t <= eps: return miss // 交点在射线背后
return hit(t, u, v)

顺序不是随意的: 在最前是因为它最便宜且拒绝率最高; 在最后是因为它最「不重要」——只在命中时才需要。


8. 参考实现

一份自包含的 C++ 版本,只依赖一个三维向量和点乘、叉乘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <cmath>

struct Vec3 {
float x, y, z;
Vec3 operator-(const Vec3& o) const { return {x - o.x, y - o.y, z - o.z}; }
};

inline Vec3 cross(const Vec3& a, const Vec3& b) {
return {a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x};
}

inline float dot(const Vec3& a, const Vec3& b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}

// 命中返回 true,并写出 t(射线参数)、u/v(重心坐标,对应 P1/P2 的权重)
// 未命中时不修改输出参数
bool rayTriangle(const Vec3& p0, const Vec3& p1, const Vec3& p2,
const Vec3& orig, const Vec3& dir,
float& t, float& u, float& v,
bool cullBackface = false)
{
const Vec3 e1 = p1 - p0;
const Vec3 e2 = p2 - p0;
const Vec3 s = orig - p0; // 注意方向:orig - p0

const Vec3 s1 = cross(dir, e2);
const float det = dot(s1, e1);

if (cullBackface) { if (det < 1e-9f) return false; }
else { if (std::fabs(det) < 1e-9f) return false; }

const float inv = 1.0f / det;

const float uu = dot(s1, s) * inv;
if (uu < 0.0f || uu > 1.0f) return false;

const Vec3 s2 = cross(s, e1);
const float vv = dot(s2, dir) * inv;
if (vv < 0.0f || uu + vv > 1.0f) return false;

const float tt = dot(s2, e2) * inv;
if (tt <= 1e-6f) return false;

t = tt; u = uu; v = vv;
return true;
}

几点说明:

  • 背面剔除用 的符号实现。,所以「射线从正面打来」。哪一侧算正面取决于三角形绕序,改约定时只改这一处即可。
  • 想做成单面/双面可切换,就是把 那一行换成上面那个分支。
  • 最近命中(光线追踪的常规需求)不要在这个函数里维护全局最小 ,而应把 t 返回给调用方比较——保持函数无状态,便于并行。

9. 边界情况与数值细节

9.1 的物理含义

线性相关,涵盖三种情况:

  • 射线平行于三角形所在平面;
  • 三角形退化(三点共线,或两顶点重合,);
  • 射线方向退化()。

三种都该判「不命中」,代码里一句 |det| < eps 就能全部覆盖。这也是它相对两步法更省心的地方:两步法要分别处理 (退化)和 (平行)。

9.2 怎么取

  • 判 :绝对值阈值在三角形尺度接近 1 时够用;更稳的做法是相对量级,例如与 相比。
  • 判 :一般用 u < 0、u > 1;若要容忍共享边被两个三角形重复命中,可加微小容差。注意不要给 和 用互相矛盾的容差,否则会出现「判定通过但插值权重为负」的诡异结果。
  • 判 :常用 量级,避免起点贴在三角形上时自相交。

9.3 不必归一化

式 (6.1) 对任意 成立。若 非单位向量, 的单位是「 的倍数」,不等于世界空间距离:

只比较 大小来选最近命中时完全不受影响;但要拿 做距离判断、介质吸收、或与别的系统交换数据时,必须记得这一点。

一个直观验证:把 乘以 2, 会减半,交点不变。

9.4 float 够不够

常规场景(三角形尺度 )用 float 足够。出现噪点状漏光时,优先怀疑两件事:

  1. 射线起点与表面重合(阴影射线的经典问题),应沿法线偏移一个 ;
  2. 世界坐标量级过大( 以上),det 的有效位被吃掉。

后者的对症做法是先把顶点平移到以射线起点为原点——本算法已经这么做了( 正是这个平移),能显著改善数值表现。


10. 变体与演进

10.1 两个方向的等价表述

§2 的两步法与 §3 的一步法在数学上等价,只是解同一个方程组的不同路径。工程里还有第三种流行写法:先把射线变换到三角形的局部坐标系(用平面法向和两条边构造基),在二维里做边函数判定——它与 Möller–Trumbore 的差别只在于「在哪一步除以 」。

10.2 水密求交(watertight)

经典实现在三角形共享边上会遇到浮点误差问题:同一条边被相邻两个三角形用不同的顶点组合计算,舍入结果不一致,于是出现亚像素级裂缝,光线从缝里穿过去,画面呈现为稀疏噪点(漏光)。

Woop、Benthin、Wald 在 2013 年给出的解法,核心是让共享边两侧的判定使用完全相同的浮点运算:

  • 先按 的最大分量选择主轴 ,并交换轴,使 方向最陡(这样 不会爆炸);
  • 把三个顶点平移到以射线起点为原点,再用 缩放,投影到二维;
  • 于是求交退化为二维的边函数符号判定,共享边两侧算出的是同一组数,符号必然一致。

代价是多几次轴交换和一次除法,收益是彻底消除裂缝。渲染器通常只在主光线或大型网格上开启水密模式。

10.3 SIMD 与批量求交

在 BVH 遍历中,一次要拿一条射线测 4 条或 8 条三角形。此时标量实现会被改写为:

  • 结构体数组(SoA)布局,把多个三角形的 分量分别存成连续数组;
  • 用 的符号做掩码,把 if 全换成 blend / select;
  • 「提前退出」变成「掩码更新」,因为 SIMD 里不能真的提前退出。

这违背了 §7.3 的单射线最优策略——所以标量场景与 SIMD 场景的最优计算顺序恰好不同,是两套写法。


11. 常见错误清单

# 错误 症状 如何定位
1 写成 全部未命中,或只有背面命中 手算 §12 的例子,看 是否为
2 移项后左右两端的负号不匹配 、 反号,全部未命中 按 §3 的方程组逐项核对
3 漏判 三角形外侧(同一平面)也被命中,轮廓「变胖」 用两个三角形拼一个正方形测试
4 漏判 射线背后的三角形也被命中 把相机放进物体内部
5 把 当纹理坐标用反 几何正确但贴图错位 用带格子纹理的四边形
6 顺序反了,却没同步改 互换,插值错但形状对 打印 与手算值对比
7 用 det == 0 判断 偶发 NaN,或 爆炸到 改成 fabs(det) < eps
8 先算 再判 结果对但慢 2–3 倍 按 §7.4 重排

第 6 条最隐蔽:形状看起来完全正常,只有贴图或法线插值时才露馅——交换 不改变「是否命中」,只改变「命中点的属性从哪来」。


12. 一个可以手算的例子

取

则 ,,。

结果 :交点正是 。

调试用测试用例

# 期望结果 覆盖的行为
1 命中 顶点命中
2 同上,但 互换 命中 绕序翻转仍命中
3 命中 内部点,权重正确
4 未命中
5 未命中 平行,
6 三点共线 任意 未命中 退化三角形
7 未命中 射线背离平面

第 3 个用例值得手算: 正好对应面积比。


13. 公式速查

一句话记忆:一叉()定分母与 ,二叉()定 与 。


参考

  1. Tomas Möller, Ben Trumbore. Fast, Minimum Storage Ray/Triangle Intersection. Journal of Graphics Tools, 2(1), 1997.
  2. Sven Woop, Carsten Benthin, Ingo Wald. Watertight Ray/Triangle Intersection. Journal of Computer Graphics Techniques, 2(1), 2013.
  3. Ingo Wald, Sven Woop, Carsten Benthin, et al. Embree: A Kernel Framework for Efficient CPU Ray Tracing. ACM Transactions on Graphics, 33(4), 2014.
  4. Matt Pharr, Wenzel Jakob, Greg Humphreys. Physically Based Rendering: From Theory to Implementation(第 3 版 §3.4 对光线-三角形求交的讨论)。