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 三个隐性成本
- 中间量 没有复用价值。它被算出来只为了做内测,而 本身已经是最终答案。
- 内测要重新算一遍边。 是三次减法加三次叉乘,和第一步毫无共享。
- 信息没利用干净。 全都在场, 里其实已经隐含了重心坐标的信息,但要先求出 才能把它「读」出来——多绕了一圈。
反过来想:既然 是三个未知数,而等式在三维空间中本来是三个标量方程,那它本来就该被一次性解出来。
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 几何意义:为什么是体积
- 的模长 = 张成的平行四边形底面积,方向垂直于该平行四边形;
- ;
- 而 是 在法向上的投影长度,即平行六面体的高 ;
- 于是 。
轮换的几何解释:三种写法只是换了底面。
| 写法 | 底面 | 高由谁提供 |
|---|---|---|
| 张成 | ||
| 张成 | ||
| 张成 |
盒子是同一个盒子。底面换了,高也随之改变,但「底面积 × 高」始终相同——这就是 §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 | S1 = cross(D, E2) |
顺序不是随意的: 在最前是因为它最便宜且拒绝率最高; 在最后是因为它最「不重要」——只在命中时才需要。
8. 参考实现
一份自包含的 C++ 版本,只依赖一个三维向量和点乘、叉乘:
1 |
|
几点说明:
- 背面剔除用 的符号实现。,所以「射线从正面打来」。哪一侧算正面取决于三角形绕序,改约定时只改这一处即可。
- 想做成单面/双面可切换,就是把 那一行换成上面那个分支。
- 最近命中(光线追踪的常规需求)不要在这个函数里维护全局最小 ,而应把
t返回给调用方比较——保持函数无状态,便于并行。
9. 边界情况与数值细节
9.1 的物理含义
线性相关,涵盖三种情况:
- 射线平行于三角形所在平面;
- 三角形退化(三点共线,或两顶点重合,);
- 射线方向退化()。
三种都该判「不命中」,代码里一句 |det| < eps 就能全部覆盖。这也是它相对两步法更省心的地方:两步法要分别处理 (退化)和 (平行)。
9.2 怎么取
- 判 :绝对值阈值在三角形尺度接近 1 时够用;更稳的做法是相对量级,例如与 相比。
- 判 :一般用
u < 0、u > 1;若要容忍共享边被两个三角形重复命中,可加微小容差。注意不要给 和 用互相矛盾的容差,否则会出现「判定通过但插值权重为负」的诡异结果。 - 判 :常用 量级,避免起点贴在三角形上时自相交。
9.3 不必归一化
式 (6.1) 对任意 成立。若 非单位向量, 的单位是「 的倍数」,不等于世界空间距离:
只比较 大小来选最近命中时完全不受影响;但要拿 做距离判断、介质吸收、或与别的系统交换数据时,必须记得这一点。
一个直观验证:把 乘以 2, 会减半,交点不变。
9.4 float 够不够
常规场景(三角形尺度 )用 float 足够。出现噪点状漏光时,优先怀疑两件事:
- 射线起点与表面重合(阴影射线的经典问题),应沿法线偏移一个 ;
- 世界坐标量级过大( 以上),
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. 公式速查
一句话记忆:一叉()定分母与 ,二叉()定 与 。
参考
- Tomas Möller, Ben Trumbore. Fast, Minimum Storage Ray/Triangle Intersection. Journal of Graphics Tools, 2(1), 1997.
- Sven Woop, Carsten Benthin, Ingo Wald. Watertight Ray/Triangle Intersection. Journal of Computer Graphics Techniques, 2(1), 2013.
- Ingo Wald, Sven Woop, Carsten Benthin, et al. Embree: A Kernel Framework for Efficient CPU Ray Tracing. ACM Transactions on Graphics, 33(4), 2014.
- Matt Pharr, Wenzel Jakob, Greg Humphreys. Physically Based Rendering: From Theory to Implementation(第 3 版 §3.4 对光线-三角形求交的讨论)。