13.1 求交的容差边界

把 Möller–Trumbore 的误差写成一条闭式不等式,在 43731 条命中主光线上逐条核对:界恒成立,最紧处实测/界 = 0.746916。改一处舍入规则,41254 条命中的 t 就会分叉。真正决定自交的是垂直误差 Δt·sinθ,最大 241.47 LSB,而非沿光线的 Δt。

13.1 求交的容差边界

站内 /posts/books/渲染引擎/ 那套讲的是 TypeScript 引擎架构:RHI 抽象、帧图调度、可见性剔除,关心的是同一门语言里结构怎么摆、每帧的代价怎么摊。本节换一个尺度——算法本身一个字不改,只换实现的宿主,问的是逐位结果还是不是同一个。这是本卷「跨语言逐位一致」这条线的收口章的第一节,落点选在最底层的那个动作:求交。先把它的误差写成一条不等式,再回答一个可断言的问题:什么条件下两个宿主会给出不同的 t。

把误差写成一条不等式

Möller–Trumbore 的每一步都是一次降精度:fmul(a, b) = (a * b) >> 16 向下取整,fdiv 向零截断。每次丢掉不到 1 个 LSB,但链路上有四次这样的操作——叉积、点积、倒数、最终乘——误差会叠。把它们按链路加起来,就得到本节的主结果:

|x_fx - x_exact| <= 1 + 2^16·δN/|det| + 2^16·|N_fx|·δdet/det² + |N_fx|·δinv/2^16

四项各有出处,缺一项界就漏:

项出处
1最后一次 fmul 的向下取整(< 1 LSB)
`2^16·δN/det
`2^16·N_fx
`N_fx

其中 δdet = 3 + 2·Σ|e1_k|/2^16、δN = 3 + 2·Σ|·_k|/2^16、δinv = 1 + 2^32·δdet/det²。前两个式子里的 3 是三个分量的求和项数,2 是每个分量在叉积与点积里各出现两次。

参照值不能用浮点算,否则「界」和「参照」各自带误差,比出来的比值没有意义。这里用整数有理数:同一套 Q16.16 整数输入,raw_cross / raw_dot 不做任何截断(叉积自然落在 Q32.32),t = 2^16·N/det 的分子与分母全程留在整数上,只在最后一步做一次除法。

在全部 43731 条命中主光线上逐条核对:界恒成立,实测误差 / 界 的最大比值 0.746916。最紧的那条是 det = -464222:实测 271.06 LSB,界 362.90 LSB。界不松,但也没松到失去意义——它是一条能用的判据,而不是一个装饰。

舍入变体:t 什么时候会变

输入全是整数时,运算序列完全确定,唯一的自由度是每一步的降精度规则。所以「两个宿主会不会给出不同的 t」可以变成一个可控实验:只改一处规则,数一数多少条命中的 t 会变。

  • 倒数改 floor 除法((ONE * ONE) // det,模拟 Python 的 //):41254 / 43731 条 t 改变。
  • 末步改四舍五入((nt * inv + ONE/2) >> 16):22043 / 43731 条 t 改变。

第一个数字有结构:它与「命中里 det < 0 的条数」逐条相同——两个集合实测完全相等,都是 41254。这不是巧合。fdiv 向零截断,// 向下取整,两者只在结果为负且除不尽时不同;而 43731 条命中里没有一个负 det 能整除 2^32(能整除的只可能是 ±2 的幂,而最小的 |det| 是 1102)。所以在这份场景里,「倒数规则写成 floor」精确等价于「所有背面命中的 t 都偏了 1 个 LSB」。

第二个数字更能说明问题:22043 条里有 20828 条落在 det < 0 里,另外 1215 条不在。也就是说四舍五入与向下取整的分歧不挑正负,正面命中同样会分叉。

结论一句话:两个宿主只要有一处舍入规则不同,t 就会在这些命中上分叉;而且分叉不需要浮点——全整数定点照样分叉。 这正是第 4.2 节「代数上等价不等于逐位相同」在求交链路上的同一件事,只是那里坏掉的是结合律,这里坏掉的是除法语义。

分箱:误差按 1/|det| 缩放

把 43731 条命中按 |det| 的二进制量级分箱,每箱给出实测最大 |Δt| 与对应的界:

2^n命中数最小 |det|实测最大 |Δt|误差界
11411021442.726812053.8014
12822661392.14568864.6804
13674764748.15579066.0073
142998860413.35253981.1202
1587416395289.51642808.4543
16152332808239.17351558.8413
17315365536153.1055971.2141
1821353131106178.2077545.0533
1916381262146317.5658438.1063
2069524393310.8266434.1625

实测误差不随 |det| 单调:2^19 箱的 317.57 比 2^18 箱的 178.21 还大,因为分子 N 也随光线变,不只看 det。但界是单调按 1/|det| 缩放的:从 2^11 箱的 12053.80 一路降到 2^20 箱的 434.16,十个箱单调不增。这说明界抓住了主导项,而实测误差还受分子那一项支配——界是保守的,保守的方向是安全的。顺带一提,|det| 最小 1102(2^11),这个数来自第 2.2 节量过的那份真实分布,不是造出来的探针光线。

容差的真正尺度:垂直误差,不是 Δt

Δt 是沿光线的误差,它本身不决定自交。真正决定「这条光线会不会穿到自己刚离开的那个面上」的,是 Δt 投影到面法线上的分量:垂直误差 = Δt · sinθ,其中 sinθ = |det| / |e1×e2|。

掠射时 |det| 小、sinθ 也小,两个因子相乘把误差压回去。实测:

  • |Δt| 最大 1442.726772 LSB(= 0.022014 世界单位 = 22.54 × EPSILON);超过 EPSILON(64) 的命中 4582 / 43731(10.5%),超过 256 的只有 100 条。
  • 垂直误差最大 241.471057 LSB(= 0.0036846 世界单位 = 3.77 × EPSILON);超过 EPSILON 的命中 909 / 43731(2.1%)。

两条具体命中能把这件事说透。一条 det = 10313 的命中,|Δt| = 295.82 LSB(4.6 倍 EPSILON),垂直误差只有 45.99 LSB——小于 EPSILON。反过来,2^11 箱那 4 条最掠射的命中 Δt 最大,垂直误差却被 sinθ 压到很小。所以「Δt 大」和「会自交」是两件事,中间隔着一个 sinθ。

这正好接上第 2.3 节:eps = 64 时球面上仍残留 717 个自交点,与本节的「垂直误差 > 64 的 909 条」是同一档量级。2.3 节把残留归因于插值法线而非位置误差,本节给出了那条位置误差的上界——最大 241.47 LSB,即 3.77 倍 EPSILON。所以 eps 再往上加,吃掉的已经不是 Δt 了,收益必须来自别处(法线插值本身),这也是 2.3 节的结论在本节有了量化上界。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_13_1_tolerance_bound.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_13_1_tolerance_bound.py
"""13.1 求交的容差边界:Möller–Trumbore 的闭式误差界与「两宿主何时给出不同 t」。

第 2.2 节量了 det -> 0 时 (t, u, v) 的相对误差,第 2.3 节量了 EPSILON 被哪两条边界夹住。
本节把两件事接起来:给出一条**闭式的绝对误差界**,并回答**什么条件下两个宿主会给出不同
的 t**。MT 每一步都是一次 `fmul`(`(a*b) >> 16`,向下取整)或一次 `fdiv`(向零截断),
每次降精度丢掉不到 1 个 LSB;沿「叉积 -> 点积 -> 倒数 -> 最终乘」把损失加起来:

    |x_fx - x_exact| <= 1 + 2^16·δN/|det| + 2^16·|N_fx|·δdet/det² + |N_fx|·δinv/2^16

精确参照用**整数有理数**(不做任何截断的原始叉积/点积)算,无浮点、无 Fraction。在全部
43731 条命中主光线上核对「界 >= 实测」。输入全整数时运算序列完全确定,唯一的自由度是每
一步的**降精度规则**——这就是「两个宿主给出不同 t」的充要条件,用两个舍入变体量出来。
最后把 Δt 投影到**面法线**(`Δt·sinθ`,`sinθ = |det|/|e1×e2|`),接上第 2.3 节的 EPSILON。
"""

import json
import os
import sys

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
FMUL, FDIV = cglib.fmul, cglib.fdiv
BASELINE_SHA = "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71"


def raw_cross(a, b):                    # 无截断的叉积:整数 × 整数,保留全部位(Q32.32)
    return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])


def raw_dot(a, b):
    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]


def err_bound(det_fx, n_fx, d_n, d_det):
    """闭式误差界(Q16.16 的 LSB):1(末次 fmul 的向下取整)+ 2^16·δN/|det|(分子截断)
    + 2^16·|N|·δdet/det²(det 截断被 1/det 放大)+ |N|·δinv/2^16(倒数本身的截断)。"""
    ad = abs(det_fx)
    return (1.0 + (ONE * d_n) / ad + (ONE * abs(n_fx) * d_det) / (ad * ad)
            + (abs(n_fx) * (1.0 + (1 << 32) * d_det / (ad * ad))) / ONE)


def analyze(orig, dirv, v0, v1, v2):
    """一次 MT:定点结果、精确有理参照、误差界,以及两个舍入变体的 t。"""
    e1 = (v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2])
    e2 = (v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2])
    tvec = cglib.v_sub(orig, v0)
    p = cglib.v_cross(dirv, e2)                 # 定点链,与 cgtrace.intersect_triangle 同式
    det = cglib.v_dot(e1, p)
    if det == 0:
        return None
    inv = FDIV(ONE, det)
    q = cglib.v_cross(tvec, e1)
    nu_fx, nv_fx, nt_fx = (cglib.v_dot(tvec, p), cglib.v_dot(dirv, q), cglib.v_dot(e2, q))
    P, Q = raw_cross(dirv, e2), raw_cross(tvec, e1)   # 精确参照:同一套整数输入,全无截断
    det_r, nu_r, nv_r, nt_r = (raw_dot(e1, P), raw_dot(tvec, P),
                               raw_dot(dirv, Q), raw_dot(e2, Q))
    s = lambda v: 3.0 + 2.0 * (abs(v[0]) + abs(v[1]) + abs(v[2])) / ONE
    d_det = s(e1)

    def abs_err(x_fx, n_r):
        return abs(x_fx * det_r - (n_r << 16)) / abs(det_r)   # 整数分子,最后一步才除

    area2 = cglib.v_len(cglib.v_cross(e1, e2))     # |e1×e2|(Q16.16,2 倍面积)
    t_err = abs_err(FMUL(nt_fx, inv), nt_r)
    return {
        "det": det, "tErr": t_err, "uErr": abs_err(FMUL(nu_fx, inv), nu_r),
        "vErr": abs_err(FMUL(nv_fx, inv), nv_r),
        "tBound": err_bound(det, nt_fx, s(e2), d_det),
        "uBound": err_bound(det, nu_fx, s(tvec), d_det),
        "vBound": err_bound(det, nv_fx, s(dirv), d_det),
        "perpErr": t_err * abs(det) / area2 if area2 else 0.0,
        "tFx": FMUL(nt_fx, inv),
        "tFloor": FMUL(nt_fx, (ONE * ONE) // det),          # 倒数改 floor(Python `//`)
        "tRound": (nt_fx * inv + (ONE >> 1)) >> 16,         # 末步四舍五入
    }


def primary_hits(spec, tris, nodes, order):
    """跑一遍主光线,对每条命中重做一次带误差界的 MT 分析。"""
    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"]
    th, tmax = cam["tanHalfFovYQ16"], cam["farQ16"]
    tx = FMUL(th, FDIV(cglib.f_from_int(w), cglib.f_from_int(h)))
    rows = []
    for py in range(h):
        ay = FMUL(FDIV(cglib.f_from_int(2 * (h // 2 - py) - 1), cglib.f_from_int(h)), th)
        for px in range(w):
            ax = FMUL(FDIV(cglib.f_from_int(2 * (px - w // 2) + 1), cglib.f_from_int(w)), tx)
            dirv = cglib.v_norm((
                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]))
            hit = cgtrace.bvh_intersect(nodes, order, tris, eye, dirv, tmax)
            if hit is None:
                continue
            rec = tris[hit[1]]
            r = analyze(eye, dirv, (rec[0], rec[1], rec[2]), (rec[3], rec[4], rec[5]),
                        (rec[6], rec[7], rec[8]))
            if r is not None:
                rows.append(r)
    return rows


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

    n = len(rows)
    max_t = max(r["tErr"] for r in rows)
    hold = all(r["tErr"] <= r["tBound"] and r["uErr"] <= r["uBound"] and r["vErr"] <= r["vBound"]
               for r in rows)
    worst = max(max(r["tErr"] / r["tBound"], r["uErr"] / r["uBound"], r["vErr"] / r["vBound"])
                for r in rows)
    min_det = min(abs(r["det"]) for r in rows)
    n_floor = sum(1 for r in rows if r["tFloor"] != r["tFx"])     # 舍入变体:t 会变的命中数
    n_round = sum(1 for r in rows if r["tRound"] != r["tFx"])
    n_negdet = sum(1 for r in rows if r["det"] < 0)
    max_perp = max(r["perpErr"] for r in rows)                   # Δt vs 垂直误差 Δt·sinθ
    n_t_eps = sum(1 for r in rows if r["tErr"] > cgtrace.EPSILON)
    n_perp_eps = sum(1 for r in rows if r["perpErr"] > cgtrace.EPSILON)

    buckets = {}                                                 # |det| 分箱
    for r in rows:
        cur = buckets.setdefault(abs(r["det"]).bit_length(),
                                 {"count": 0, "maxErr": 0.0, "maxBound": 0.0, "minDet": 1 << 62})
        cur["count"] += 1
        cur["maxErr"] = max(cur["maxErr"], r["tErr"])
        cur["maxBound"] = max(cur["maxBound"], r["tBound"])
        cur["minDet"] = min(cur["minDet"], abs(r["det"]))
    bucket_rows = [(b, buckets[b]) for b in sorted(buckets)]

    print(f"== 13.1 求交的容差边界({n} 条命中主光线)==")
    print(f"命中主光线        : {n}")
    print(f"最小 |det|        : {min_det}(2^{min_det.bit_length()})")
    print(f"|Δt| 最大         : {max_t:.6f} LSB")
    print(f"闭式界是否恒成立   : {hold}(实测误差 / 界 的最大比值 {worst:.6f})")
    print(f"\n== 舍入变体:改一处规则,t 会变的命中数 ==\n命中里 det < 0 的  : {n_negdet} / {n}")
    print(f"倒数改 floor 除法  : {n_floor} 条 t 改变\n末步改四舍五入     : {n_round} 条 t 改变")
    print("\n== 容差的真正尺度:沿光线的 Δt vs 垂直的 Δt·sinθ ==")
    print(f"Δt > EPSILON(64)   : {n_t_eps} / {n}\n垂直误差 最大      : {max_perp:.6f} LSB")
    print(f"垂直误差 > 64      : {n_perp_eps} / {n}")
    print(f"\n|det| 分箱表({len(bucket_rows)} 个箱)落盘 out/cg_trace_13_1_det_buckets.csv")

    os.makedirs(OUT, exist_ok=True)
    cglib.write_csv(
        os.path.join(OUT, "cg_trace_13_1_det_buckets.csv"),
        ["det_bits", "hits", "min_abs_det", "max_abs_dt_lsb", "bound_lsb"],
        [[b, cur["count"], cur["minDet"], round(cur["maxErr"], 6), round(cur["maxBound"], 6)]
         for b, cur in bucket_rows])
    payload = {
        "hits": n, "minAbsDet": min_det, "maxAbsDtLsb": round(max_t, 6),
        "boundHolds": hold, "maxErrOverBound": round(worst, 6),
        "detNegative": n_negdet, "tChangedByFloorDiv": n_floor, "tChangedByRoundHalfUp": n_round,
        "epsilon": cgtrace.EPSILON, "tErrGtEpsilon": n_t_eps,
        "tErrGt256": sum(1 for r in rows if r["tErr"] > 256),
        "maxPerpErrLsb": round(max_perp, 6), "perpErrGtEpsilon": n_perp_eps,
        "baselineSha": BASELINE_SHA,
        "buckets": [{"detBits": b, "hits": c["count"], "minAbsDet": c["minDet"],
                     "maxAbsDtLsb": round(c["maxErr"], 6), "boundLsb": round(c["maxBound"], 6)}
                    for b, c in bucket_rows],
    }
    with open(os.path.join(OUT, "cg_trace_13_1_tolerance.json"), "w", encoding="utf-8") as fh:
        json.dump(payload, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("\n落盘 out/cg_trace_13_1_tolerance.json、out/cg_trace_13_1_det_buckets.csv")
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_13_1_tolerance_bound.py
== 13.1 求交的容差边界(43731 条命中主光线)==
命中主光线        : 43731
最小 |det|        : 1102(2^11)
|Δt| 最大         : 1442.726772 LSB
闭式界是否恒成立   : True(实测误差 / 界 的最大比值 0.746916)

== 舍入变体:改一处规则,t 会变的命中数 ==
命中里 det < 0 的  : 41254 / 43731
倒数改 floor 除法  : 41254 条 t 改变
末步改四舍五入     : 22043 条 t 改变

== 容差的真正尺度:沿光线的 Δt vs 垂直的 Δt·sinθ ==
Δt > EPSILON(64)   : 4582 / 43731
垂直误差 最大      : 241.471057 LSB
垂直误差 > 64      : 909 / 43731

|det| 分箱表(10 个箱)落盘 out/cg_trace_13_1_det_buckets.csv

落盘 out/cg_trace_13_1_tolerance.json、out/cg_trace_13_1_det_buckets.csv

P 层

// snippet: 节选自 lantern/core/test/cg_trace_13_1_tolerance_bound.test.ts(BigInt 重实现)
// verify: node --test 'core/test/cg_trace_13_1_tolerance_bound.test.ts'
  assert.equal(rows.length, golden.hits);
  assert.equal(rows.filter((r) => r.det < 0n).length, golden.detNegative);
  assert.equal(rows.filter((r) => r.tFloor !== r.tFx).length, golden.tChangedByFloorDiv);
  assert.equal(rows.filter((r) => r.tRound !== r.tFx).length, golden.tChangedByRoundHalfUp);

P 层把 analyze 用 BigInt 重写一遍,断言的是计数与集合,不是浮点误差界本身:命中数、det < 0 的条数、两个舍入变体的分叉条数、|det| 分箱的每箱条数,以及「界恒成立」这个布尔量。误差界那一项只断言 bool,因为把同一条不等式在 Number 上再算一遍,末几位会因浮点尾数差异抖动——界是不是成立是结构问题,界等于几是浮点问题,两者要分开守。

tFloor 那一行在 TypeScript 里必须写成 floorDiv(ONE * ONE, det):BigInt 的 / 是向零截断,直接写 / 会得到与 Python 不同的集合。这条断言守的正是本节的核心结论——换语言时,先换的是除法语义。

小结

  • 闭式界 |x_fx − x_exact| ≤ 1 + 2^16·δN/|det| + 2^16·|N_fx|·δdet/det² + |N_fx|·δinv/2^16 在 43731 条命中主光线上恒成立,最紧处比值 0.746916。
  • 改一处舍入规则,t 就会分叉:倒数改 floor 影响 41254 条(与 det < 0 集合逐条相同),末步改四舍五入影响 22043 条(其中 1215 条是正面命中)。
  • 误差界随 1/|det| 单调缩放(12053.80 → 434.16,十箱单调不增),实测误差不单调——界保守,但保守在安全的一侧。
  • 决定自交的是垂直误差 Δt·sinθ:最大 241.471057 LSB(3.77 × EPSILON),超 EPSILON 的 909 条;而 |Δt| 最大 1442.726772 LSB(22.54 × EPSILON),其中大部分落在掠射上,被 sinθ 压了回去。

容差边界量清楚了,下一个问题就不是「一条光线算得准不准」,而是「几万条光线按不同顺序累加,结果还是不是同一张图」——那是下一节的事。

阅读导航:上一节:12.3 采样的停止准则 · 下一节:13.2 累积的确定性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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