2.2 Möller–Trumbore 与数值稳定性

Möller–Trumbore 的除法链在 Q16.16 定点下的量级实测(原始乘积到 2^49、除法分子到 2^39,故 TS 侧必须全程 BigInt);det 趋近 0 时相对误差从 0.061‰ 涨到 46.1426‰ 的扫描,以及真实命中里为什么不能按 det 大小做 epsilon 剔除。

2.2 Möller–Trumbore 与数值稳定性

站内 /graphics-raytracing-hybrid/ 那篇是实时混合光追,求交在硬件里做,开发者看不见 det 长什么样。本卷做离线光追的完整链路,用 Q16.16 定点数——一旦把浮点换成定点,Möller–Trumbore 的除法链就成了必须先算清的账。本节量三件事:除法链的位宽、det → 0 时相对误差的增长、以及原语为什么不做 epsilon 剔除。

为什么要算这条账

Q16.16 里一次乘法要右移 16 位才回到 Q16.16,移位之前的原始乘积决定了能不能用 32 位或 64 位承载。JavaScript 的 Number 做位运算时会被截成 32 位有符号整数,>> 越过 32 位会静默给出错值——不是抛异常,是安静地算错。所以卷三的 TS 侧全程 BigInt。这条账不是理论顾虑:本节与附录 A.2 用同一套抽样口径(320×240 每 8 像素一条主光线,1200 条 × 896 个三角形)把它量出来。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_02_2_stability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_02_2_stability.py
"""2.2 Möller–Trumbore 的数值稳定性:det 趋近 0 的相对误差与除法链量级。

1. 量级链:与附录 A.2 同口径——在 320×240 网格上每 8 像素抽一条主光线(1200 条),
   对全部 896 个三角形记录 `fmul` 移位前的原始乘积、`fdiv` 的分子、行列式 `det` 的
   位宽。原始乘积摸到 2^53(离 Number 的精确整数上界只剩 1.57 倍余量),除法分子是
   2^31 的 255 倍——两条独立理由都指向 TS 侧全程 BigInt。
2. det 扫描:命中点固定在三角形内部,方向逐渐共面使 `det -> 0`;用
   `fractions.Fraction` 做精确有理数参照,量出定点 (t, u, v) 的相对误差。
3. 为什么不做 epsilon 剔除:统计真实命中光线的 |det| 分布。这些小 det 的命中在
   几何上是真的,剔除它们等于凭空丢几何。

全整数/有理数,无浮点。产物:out/cg_trace_02_2_stability.json
"""

import json
import os
import sys
from fractions import Fraction as F

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cgtrace  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")
ONE = cglib.ONE
LIMIT_NUMBER, LIMIT_SHIFT = 1 << 53, 1 << 31


def sub(x, y):
    return tuple(x[i] - y[i] for i in range(3))


def cross(x, y):
    return (x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0])


def dot(x, y):
    return x[0] * y[0] + x[1] * y[1] + x[2] * y[2]


def world(p):
    """Q16.16 整数三元组 -> 精确有理数世界坐标。"""
    return (F(p[0], ONE), F(p[1], ONE), F(p[2], ONE))


def mt_exact(orig, dirv, v0, v1, v2):
    """Möller–Trumbore 的精确有理数版:输入 Q16.16 整数,输出 Fraction 的 (t, u, v)。"""
    o, d = world(orig), world(dirv)
    a, b, c = world(v0), world(v1), world(v2)
    e1, e2 = sub(b, a), sub(c, a)
    p = cross(d, e2)
    det = dot(e1, p)
    if det == 0:
        return None
    tv = sub(o, a)
    q = cross(tv, e1)
    return dot(e2, q) / det, dot(tv, p) / det, dot(d, q) / det


def each_ray(spec):
    """逐像素产出 (px, py, dirv),供两次全场扫描共用。"""
    cam = spec["camera"]
    eye = tuple(cam["eyeQ16"])
    view = cglib.mat_look_at(eye, tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    right, up, back = ((view[0], view[1], view[2]), (view[4], view[5], view[6]),
                       (view[8], view[9], view[10]))
    w, h = spec["width"], spec["height"]
    tan_half = cam["tanHalfFovYQ16"]
    tan_x = cglib.fmul(tan_half, cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h)))
    for py in range(h):
        sy = cglib.fdiv(cglib.f_from_int(2 * (h // 2 - py) - 1), cglib.f_from_int(h))
        ay = cglib.fmul(sy, tan_half)
        for px in range(w):
            sx = cglib.fdiv(cglib.f_from_int(2 * (px - w // 2) + 1), cglib.f_from_int(w))
            ax = cglib.fmul(sx, tan_x)
            yield px, py, cglib.v_norm((
                cglib.fmul(ax, right[0]) + cglib.fmul(ay, up[0]) - back[0],
                cglib.fmul(ax, right[1]) + cglib.fmul(ay, up[1]) - back[1],
                cglib.fmul(ax, right[2]) + cglib.fmul(ay, up[2]) - back[2]))


def magnitude_chain():
    """除法链的位宽上界:**复用附录 A.2 的扫描函数**,保证两边口径完全一致。

    上界**取决于光线集合**:中间量随 `d × e2` 而变,只取一条探针光线会把上界算错。
    故这里不自己造探针,直接调用附录 A 的 `scan_magnitudes()`——它按 `render_trace`
    的公式在 320×240 上每 8 像素抽一条主光线(1200 条),对全部 896 个三角形扫描,
    相机基取**行**。
    """
    import cg_trace_appendix_a as appendix_a
    nray, _ntri, _coord, max_mul, max_div, max_det = appendix_a.scan_magnitudes()
    return nray, max_mul, max_div, max_det


def det_sweep():
    """命中点固定在三角形内部,方向逐渐共面:量 (t, u, v) 相对误差随 |det| 变化。"""
    v0, v1, v2 = (0, 0, 0), (ONE, 0, 0), (0, ONE, 0)
    center = (ONE // 4, ONE // 4, 0)
    rows = []
    for n, dn in [(1, 1), (1, 2), (1, 4), (1, 10), (1, 20), (1, 50), (1, 100), (1, 200),
                  (1, 500), (1, 1000)]:
        dz = (n << 16) // dn
        d = cglib.v_norm((int(0.3 * ONE), int(0.4 * ONE), dz))
        orig = (center[0] - d[0], center[1] - d[1], center[2] - d[2])
        e1, e2 = cglib.v_sub(v1, v0), cglib.v_sub(v2, v0)
        det = cglib.v_dot(e1, cglib.v_cross(d, e2))
        got = cgtrace.intersect_triangle(orig, d, v0, v1, v2)
        exact = mt_exact(orig, d, v0, v1, v2)
        if got is None or exact is None:
            rows.append({"dzQ16": dz, "det": det, "hit": False})
            continue
        t_fx, u_fx, v_fx = got
        t_ex, u_ex, v_ex = exact
        errs = [abs(F(fx, ONE) - ex) / abs(ex)
                for fx, ex in ((u_fx, u_ex), (v_fx, v_ex), (t_fx, t_ex)) if ex != 0]
        rows.append({"dzQ16": dz, "det": det, "absDet": abs(det), "hit": True, "tQ16": t_fx,
                     "uQ16": u_fx, "vQ16": v_fx,
                     "relErrPermille": round(float(max(errs)) * 1000.0, 4)})
    return rows


def real_det_distribution(spec, tris, nodes, order):
    """真实命中光线的 |det| 分布:最小 det 与几个阈值以下的命中数。"""
    eye = tuple(spec["camera"]["eyeQ16"])
    tmax = spec["camera"]["farQ16"]
    min_det, hits = 1 << 62, 0
    below = {1024: 0, 4096: 0, 16384: 0, 65536: 0}
    for _px, _py, dirv in each_ray(spec):
        hit = cgtrace.bvh_intersect(nodes, order, tris, eye, dirv, tmax)
        if hit is None:
            continue
        hits += 1
        t = tris[hit[1]]
        e1 = cglib.v_sub((t[3], t[4], t[5]), (t[0], t[1], t[2]))
        e2 = cglib.v_sub((t[6], t[7], t[8]), (t[0], t[1], t[2]))
        det = abs(cglib.v_dot(e1, cglib.v_cross(dirv, e2)))
        min_det = min(min_det, det)
        for thr in below:
            if det < thr:
                below[thr] += 1
    return {"hits": hits, "minAbsDet": min_det, "below": below}


def main():
    spec = cglib.load_spec()
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)

    nray, max_mul, max_div, max_det = magnitude_chain()
    print(f"== 2.2 除法链量级({nray} 条抽样主光线 × {len(tris)} 个真实三角形)==")
    print("   口径与附录 A.2 相同:320×240 每 8 像素一条;相机基取行")
    for name, value, note in (("fmul 原始乘积", max_mul, "上界来自 num × fdiv(ONE, det)"),
                              ("fdiv 分子 |a| << 16", max_div, "重心坐标与 t 的定点除法"),
                              ("行列式 det", max_det, "e1 · (d × e2)")):
        v31 = "SHIFT-BROKEN" if value >= LIMIT_SHIFT else "OK"
        v53 = "OVERFLOW" if value >= LIMIT_NUMBER else "OK"
        print(f"{name:<20} {value:>20}  2^{value.bit_length():<3} 2^53={v53:<9} "
              f"2^31={v31:<13} {note}")
    print(f"2^31 = {LIMIT_SHIFT}  2^53 = {LIMIT_NUMBER}")
    print(f"fmul 原始乘积 / 2^53 = {max_mul * 1000 // LIMIT_NUMBER}‰"
          f"(余量只剩 {LIMIT_NUMBER * 100 // max_mul / 100:.2f} 倍)")
    print(f"fdiv 分子 / 2^31     = {max_div // LIMIT_SHIFT} 倍")
    print()

    sweep = det_sweep()
    print("== 2.2 det 扫描:方向逐渐共面,|det| 与相对误差 ==")
    print(f"{'dz(Q16.16)':>10} {'|det|':>10} {'2^n':>6} {'u(Q16)':>10} {'v(Q16)':>10} "
          f"{'相对误差‰':>12}")
    for r in sweep:
        if not r["hit"]:
            print(f"{r['dzQ16']:>10} {r['det']:>10} {'-':>6} {'-':>10} {'-':>10} "
                  f"{'无交(det=0)':>12}")
            continue
        print(f"{r['dzQ16']:>10} {r['absDet']:>10} {('2^' + str(r['absDet'].bit_length())):>6} "
              f"{r['uQ16']:>10} {r['vQ16']:>10} {r['relErrPermille']:>12}")
    print()

    dist = real_det_distribution(spec, tris, nodes, order)
    print("== 2.2 真实命中光线的 |det| 分布(为什么不做 epsilon 剔除)==")
    print(f"命中光线            : {dist['hits']}")
    print(f"最小 |det|          : {dist['minAbsDet']}(2^{dist['minAbsDet'].bit_length()})")
    for thr in sorted(dist["below"]):
        print(f"|det| < {thr:<8} 的命中 : {dist['below'][thr]}")
    print()

    os.makedirs(OUT, exist_ok=True)
    payload = {"magnitude": {"fmulRaw": max_mul, "fdivNum": max_div, "det": max_det},
               "detSweep": sweep, "realDet": dist}
    with open(os.path.join(OUT, "cg_trace_02_2_stability.json"), "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print("落盘 out/cg_trace_02_2_stability.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一次(本机约 6 秒):

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_02_2_stability.py
== 2.2 除法链量级(1200 条抽样主光线 × 896 个真实三角形)==
   口径与附录 A.2 相同:320×240 每 8 像素一条;相机基取行
fmul 原始乘积                5714018048147456  2^53  2^53=OK        2^31=SHIFT-BROKEN  上界来自 num × fdiv(ONE, det)
fdiv 分子 |a| << 16            549345492992  2^39  2^53=OK        2^31=SHIFT-BROKEN  重心坐标与 t 的定点除法
行列式 det                            671983  2^20  2^53=OK        2^31=OK            e1 · (d × e2)
2^31 = 2147483648  2^53 = 9007199254740992
fmul 原始乘积 / 2^53 = 634‰(余量只剩 1.57 倍)
fdiv 分子 / 2^31     = 255 倍

== 2.2 det 扫描:方向逐渐共面,|det| 与相对误差 ==
dz(Q16.16)      |det|    2^n     u(Q16)     v(Q16)        相对误差‰
     65536      58618   2^16      16384      16385        0.061
     32768      46342   2^16      16384      16385        0.061
     16384      29310   2^15      16385      16385        0.061
      6553      12852   2^14      16389      16389       0.3052
      3276       6520   2^13      16394      16394       0.6104
      1310       2618   2^12      16396      16396       0.7324
       655       1309   2^11      16421      16471       5.3101
       327        654   2^10      16534      16534       9.1553
       131        262    2^9      16759      16509      22.8882
        65        130    2^8      17140      17140      46.1426

== 2.2 真实命中光线的 |det| 分布(为什么不做 epsilon 剔除)==
命中光线            : 43731
最小 |det|          : 1102(2^11)
|det| < 1024     的命中 : 0
|det| < 4096     的命中 : 12
|det| < 16384    的命中 : 378
|det| < 65536    的命中 : 2775

落盘 out/cg_trace_02_2_stability.json

量级链:原始乘积已摸到 2^53

fmul 的原始乘积(叉积与点积的逐分量积,移位之前)最大到 5714018048147456,即 2^53——离 Number 能精确表示整数的上界 9007199254740992 只剩 1.57 倍余量(占比 634‰)。fdiv 的分子(|a| << 16)最大到 549345492992,即 2^39,是 2^31 的 255 倍;行列式 det 本身只到 671983(2^20)——问题全在乘法的中间量上,不在 det 本身。两条独立理由指向同一个结论:Number 的 >> 先 ToInt32 再移位,越过 2^31 会静默出错;原始乘积又已逼近 2^53,任何用浮点承载中间量的写法都站在悬崖边上。

上界取决于光线集合:中间量随 d × e2 而变,只取一条探针光线会把上界算错。所以本节不自己造探针,而是直接复用附录 A.2 的扫描函数——320×240 每 8 像素一条主光线(1200 条),对全部 896 个三角形扫描,相机基取行。

det → 0:相对误差从 0.061‰ 涨到 46.1426‰

扫描把命中点固定在三角形内部(重心 (0.25, 0.25)),让方向逐渐与三角形平面共面,det 从 2^16 掉到 2^8。用 fractions.Fraction 做精确有理数参照,量定点 (t, u, v) 的相对误差:2^16 时 0.061‰,2^12 时 0.7324‰,2^9 时 22.8882‰,2^8 时 46.1426‰——误差随 |det| 单调增长:定点除法的分子被左移 16 位后是固定的,分母越小,商的绝对误差越大。det = 0 时原语直接返回 None,不会给出被 0 除的噪声值。

为什么原语不做 epsilon 剔除

真实分布否掉了一个自然的「防御」——在 det 太小时直接判无交:43731 条命中光线里最小 |det| 是 1102(2^11),|det| < 1024 的命中是 0 条,< 4096 有 12 条,< 16384 有 378 条,< 65536 有 2775 条。这些小 det 的命中在几何上是真的——光线确实以掠射角打到了那个三角形;按 det < ε 剔除,这 12 条(乃至 378 条、2775 条)会凭空消失,画面出现漏光的洞。原语的正确做法是如实返回,把「要不要信这条命中」交给调用方:调用方知道自己能容忍多大误差,原语不知道。这也是 2.3 的伏笔——容差属于调用方,不属于求交本身。

跨语言核对

P 层在 TS 侧用 BigInt 重跑同一套 1200 条抽样主光线与同一次 det 扫描,逐位比对:

// snippet: 节选自 lantern/core/test/cg_trace_02_2_stability.test.ts
// 与附录 A.2 同口径:320×240 每 8 像素一条主光线(1200 条),相机基取**行**
for (let py = 0; py < height; py += 8) {
  const ay = fmul(fdiv(fFromInt(2 * (halfH - py) - 1), fFromInt(height)), tanHalf);
  for (let px = 0; px < width; px += 8) {
    const ax = fmul(fdiv(fFromInt(2 * (px - halfW) + 1), fFromInt(width)), tanX);
    const dirv = vNorm([
      fmul(ax, right[0]) + fmul(ay, up[0]) - back[0],
      fmul(ax, right[1]) + fmul(ay, up[1]) - back[1],
      fmul(ax, right[2]) + fmul(ay, up[2]) - back[2],
    ]);
    nray += 1;
    for (const t of tris) {
      const e1 = vSub([t[3], t[4], t[5]], [t[0], t[1], t[2]]);
      const e2 = vSub([t[6], t[7], t[8]], [t[0], t[1], t[2]]);
      const p = vCross(dirv, e2);
      for (let i = 0; i < 3; i += 1) {
        const m = e1[i] * p[i];                // 移位前的原始乘积(还有叉积的六个)
        const am = m < 0n ? -m : m;
        if (am > maxMul) maxMul = am;
      }
      const det = vDot(e1, p);                 // vDot 走 fmul,已 >>16
      const ad = det < 0n ? -det : det;
      if (ad > maxDet) maxDet = ad;
      if (det === 0n) continue;
      const num = vDot(vSub(eye, v0), p);
      const div = (num < 0n ? -num : num) << 16n;   // fdiv 的分子,是 2^31 的 255 倍
      if (div > maxDiv) maxDiv = div;
      const mm = num * fdiv(ONE, det);              // 上界正出在这一项:2^53
      const amm = mm < 0n ? -mm : mm;
      if (amm > maxMul) maxMul = amm;
    }
  }
}
assert.equal(nray, 1200, "抽样主光线数应为 1200");
assert.equal(Number(maxMul), s.magnitude.fmulRaw, "fmul 原始乘积上界不符");
assert.ok(maxMul < (1n << 53n) && maxMul > (1n << 52n), "原始乘积应逼近 2^53");

三组断言都过:1200 条抽样光线的 fmulRaw / fdivNum / det 与 S 侧相同,且前两者都 > 2^31、原始乘积落在 (2^52, 2^53) 内(证明「必须 BigInt」不是空话);det 扫描每一行的 (det, u, v, t) 与 S 侧逐位相同,且相对误差随 |det| 单调不减;真实命中的最小 |det| 与四个阈值计数逐条相同,并断言「真实命中里应存在 |det| < 4096 的光线」——这条断言直接钉住「不能剔除」。

阅读导航:上一节:2.1 球、平面与三角形 · 下一节:2.3 容差与自交 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配