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| | 误差界 |
|---|---|---|---|---|
| 11 | 4 | 1102 | 1442.7268 | 12053.8014 |
| 12 | 8 | 2266 | 1392.1456 | 8864.6804 |
| 13 | 67 | 4764 | 748.1557 | 9066.0073 |
| 14 | 299 | 8860 | 413.3525 | 3981.1202 |
| 15 | 874 | 16395 | 289.5164 | 2808.4543 |
| 16 | 1523 | 32808 | 239.1735 | 1558.8413 |
| 17 | 3153 | 65536 | 153.1055 | 971.2141 |
| 18 | 21353 | 131106 | 178.2077 | 545.0533 |
| 19 | 16381 | 262146 | 317.5658 | 438.1063 |
| 20 | 69 | 524393 | 310.8266 | 434.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 累积的确定性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。