这是《计算机图形学》卷三《光线追踪与全局光照》的附录 A。它把全卷散落在各章的常数、公式、参数与产物集中到一处,方便在写代码时对照。
附录分两部分。前半是约定:卷三新增的定点常数、光追中间量的量级上界、十个渲染算法的整数形式。这些约定一旦违反,跨语言逐位一致立刻崩——它们不是「建议」,是硬约束。后半是索引:采样与收敛参数、三卷基线汇总、各章产物落在哪个文件、怎么一条命令复现。
卷三比前两卷多出来一个量级问题,本附录用一节专门讲:光追的中间量远超 32 位。前两卷的屏幕空间量级能塞进 JavaScript 的 Number,卷三不行——这不是估算,是在 896 个真实三角形上跑出来的实测值。
本卷所有数字都来自本机实跑,凡是引用具体数值的地方都注明了它出自哪一节。没有估算值,也没有从别处抄来的数字。
A.1 卷三的定点常数表
卷三沿用卷一卷二的 Q16.16 约定(ONE = 65536)与屏幕 Q6 约定(SUB1 = 64),另外新增八个常数。前四个属于几何与加速结构,后四个属于确定性 RNG。
| 常数 | 值 | 含义 | 主要出处 |
|---|---|---|---|
ONE | 65536 | Q16.16 的 1.0(卷一引入,卷三沿用) | 全卷 |
EPSILON | 64 | 沿法线的自交偏移,模长 1/1024 | 第 2.3 节 |
TRI_STRIDE | 21 | 三角形记录的整数个数:9 坐标 + 9 法线 + 3 albedo | 第 2.1 节 |
LEAF_SIZE | 4 | BVH 叶子容量 | 第 3.2 节 |
RNG_GOLDEN | 11400714819323198485 | SplitMix64 的黄金比增量 | 第 5.1 节 |
RNG_MIX1 | 13787848793156543929 | SplitMix64 第一轮混合乘数 | 第 5.1 节 |
RNG_MIX2 | 10723151780598845931 | SplitMix64 第二轮混合乘数 | 第 5.1 节 |
RNG_MASK | 18446744073709551615 | 64 位全掩码,BigInt 必须显式截断 | 第 5.1 节 |
EPSILON 为什么取 64 而不是 1:第 2.3 节量化了这条边界。偏移量必须大于求交本身在命中点附近的不确定度,又必须小于任何可见的几何间隙。地形网格的三角形边长在 Q16.16 下是 45056(约 0.6875 世界单位),球体半径为 2.0 世界单位——64(即 1/1024 世界单位)落在两者之间,且是 2 的幂,乘起来只做一次移位。取 1 会漏掉自交,取 4096 会在球与地形的接触处撕出可见缝隙。
RNG_MASK 为什么必须显式出现:SplitMix64 的每一步乘法都会溢出 64 位。Python 的 int 是无限精度,不会自动截断;BigInt 也不会自动截断。两侧都必须显式 & RNG_MASK——这一条写进代码比写在注释里可靠,所以它是常数表的一员而不是一个说明。
A.2 光追中间量的量级上界
卷一的屏幕空间量级最大约 2^36(法线插值分子),能安全留在 Number 里。卷三不行。下面这个脚本是附录 A 的唯一数据源:它在 320×240 的网格上每 8 像素抽一条主光线(共 1200 条),对场景里全部 896 个三角形把 Möller–Trumbore 的每一步跑一遍,记录移位前的原始量位宽。之所以抽样而非全量,是因为 76800 条光线 × 896 个三角形在纯 Python 下要跑几分钟;之所以抽样而非取一条探针光线,是因为中间量随 d × e2 而变,单条光线会把上界算错。
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_appendix_a.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_appendix_a.py
"""附录 A 的数据源:光追中间量的量级上界实测。
在 320×240 的网格上每 8 像素抽一条主光线(共 1200 条),对场景里全部 896 个三角形
把 Möller–Trumbore 的每一步跑一遍,记录**移位前**的原始乘积与除法分子位宽。两个判据
必须分开看:**2^53** 是 `Number` 能精确表示整数的上界,**2^31** 是 `>>` 的位宽
(先 `ToInt32` 再移位),超过它算术右移**静默**给出错误答案。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cgtrace # noqa: E402
ONE = 65536
WIDTH, HEIGHT, STRIDE = 320, 240, 8 # 抽样:40 × 30 = 1200 条主光线
LIMIT_NUMBER, LIMIT_SHIFT = 1 << 53, 1 << 31
def primary_dirs(spec):
"""按 `cgtrace.render_trace` 的公式抽样生成主光线。
不能只取一条探针光线:中间量随 `d × e2` 而变,单条光线会把上界算错。
"""
cam = spec["camera"]
view = cglib.mat_look_at(tuple(cam["eyeQ16"]),
tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
# `mat_look_at` 是行主序:第 0 行 right、第 1 行 up、第 2 行 back(相机看向 -z)。
right, up = (view[0], view[1], view[2]), (view[4], view[5], view[6])
back = (view[8], view[9], view[10])
fi = cglib.f_from_int
tan_half = cam["tanHalfFovYQ16"]
tan_x = cglib.fmul(tan_half, cglib.fdiv(fi(WIDTH), fi(HEIGHT)))
hw, hh = WIDTH // 2, HEIGHT // 2
for py in range(0, HEIGHT, STRIDE):
ay = cglib.fmul(cglib.fdiv(fi(2 * (hh - py) - 1), fi(HEIGHT)), tan_half)
for px in range(0, WIDTH, STRIDE):
ax = cglib.fmul(cglib.fdiv(fi(2 * (px - hw) + 1), fi(WIDTH)), tan_x)
yield cglib.v_norm(tuple(cglib.fmul(ax, right[i])
+ cglib.fmul(ay, up[i]) - back[i] for i in range(3)))
def scan_magnitudes():
"""对抽样主光线跑一遍求交,记录每一步**移位前**的原始量位宽。"""
spec = cglib.load_spec()
tris, _ = cgtrace.build_scene_tris(spec)
orig = tuple(spec["camera"]["eyeQ16"])
coord = max(abs(t[i]) for t in tris for i in range(9))
edges = [((t[0], t[1], t[2]),
cglib.v_sub((t[3], t[4], t[5]), (t[0], t[1], t[2])),
cglib.v_sub((t[6], t[7], t[8]), (t[0], t[1], t[2]))) for t in tris]
max_mul = max_div = max_det = nray = 0
for dirv in primary_dirs(spec):
nray += 1
for v0, e1, e2 in edges:
for a, b in ((dirv[1], e2[2]), (dirv[2], e2[1]), (dirv[0], e2[2]),
(dirv[2], e2[0]), (dirv[0], e2[1]), (dirv[1], e2[0])):
max_mul = max(max_mul, abs(a * b))
p = cglib.v_cross(dirv, e2)
det = cglib.v_dot(e1, p)
max_det = max(max_det, abs(det))
if det == 0:
continue
max_mul = max([max_mul] + [abs(a * b) for a, b in zip(e1, p)])
max_div = max(max_div, abs(ONE) << 16)
num = cglib.v_dot(cglib.v_sub(orig, v0), p)
max_div = max(max_div, abs(num) << 16)
max_mul = max(max_mul, abs(num * cglib.fdiv(ONE, det)))
return nray, len(tris), coord, max_mul, max_div, max_det
def main():
nray, ntri, coord, max_mul, max_div, max_det = scan_magnitudes()
print(f"== 量级上界({nray} 条抽样主光线 × {ntri} 个真实三角形)==")
print(f" 抽样口径:{WIDTH}×{HEIGHT} 每 {STRIDE} 像素一条;单条探针会失真")
rows = [("世界坐标(Q16.16)", coord, "地形 extent 1441792,球心 ±8"),
("fmul 的原始乘积 a*b", max_mul, "Möller–Trumbore 的叉积与点积"),
("行列式 det", max_det, "e1 · (d × e2)"),
("fdiv 的分子 |a| << 16", max_div, "重心坐标与 t 的定点除法")]
for name, value, note in rows:
ok53 = "OK" if value < LIMIT_NUMBER else "OVERFLOW"
ok31 = "OK" if value < LIMIT_SHIFT else "SHIFT-BROKEN"
print(f"{name:<20} {value:>18} 2^{value.bit_length():<3} "
f"{ok53:<9} {ok31:<13} {note}")
ratio = LIMIT_NUMBER * 100 // max_mul / 100
print(f"\n2^53 = {LIMIT_NUMBER} 2^31 = {LIMIT_SHIFT}")
print(f"fmul 原始乘积 / 2^53 = {max_mul * 1000 // LIMIT_NUMBER}‰(余量只剩 {ratio:.2f} 倍)")
print(f"fdiv 分子 / 2^31 = {max_div // LIMIT_SHIFT} 倍")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(本机,exit 0):
== 量级上界(1200 条抽样主光线 × 896 个真实三角形)==
抽样口径:320×240 每 8 像素一条;单条探针会失真
世界坐标(Q16.16) 1441792 2^21 OK OK 地形 extent 1441792,球心 ±8
fmul 的原始乘积 a*b 5714018048147456 2^53 OK SHIFT-BROKEN Möller–Trumbore 的叉积与点积
行列式 det 671983 2^20 OK OK e1 · (d × e2)
fdiv 的分子 |a| << 16 549345492992 2^39 OK SHIFT-BROKEN 重心坐标与 t 的定点除法
2^53 = 9007199254740992 2^31 = 2147483648
fmul 原始乘积 / 2^53 = 634‰(余量只剩 1.57 倍)
fdiv 分子 / 2^31 = 255 倍
这张表要分两列读,而两列给出的理由并不相同。
看 2^53 那一列:全部「OK」,但余量只剩 1.57 倍。乘法原始乘积已经摸到 2^53——这正是 Number 能精确表示整数的上界,再大一档连加减法都开始丢位。这不是理论推演,是 1200 条真实主光线里实际出现过的最大值;场景稍微放大,它就会越界。
看 2^31 那一列:两行「SHIFT-BROKEN」,除法分子是 2^31 的 255 倍。Number 的 >> 是 32 位的——值超过 2^31 时引擎先做 ToInt32 把它截断再移位,结果静默错误:不抛异常、不报警告,只是答案不对。这也是本卷与前两卷的分水岭:卷一那些超 2^31 的量在 Python 侧走 numpy 的 int64、在 TS 侧走定点拆分,从没直接喂给 >>;卷三的求交链每一步都要移位降精度,「超 2^31」于是从理论风险变成了必然踩中的坑。
两条理由指向同一个做法:TS 侧光追全程用 BigInt。第 13 章会用一个 Number 版本的求交器做对照,演示它给出的错误答案长什么样。
A.3 渲染算法的整数形式
下表给出十个算法在 Q16.16 下的实现要点。「降精度」一列是重点:所有降精度必须走算术右移 >>,禁用 / 与 //——Python 的 // 是 floor,BigInt 的 / 是向零截断,两者在负数上差 1,而光线方向与法线分量恰好可正可负。
| 算法 | 整数形式 | 降精度 | 出处 |
|---|---|---|---|
| 光线-球求交 | 解 t² + 2bt + c = 0,b = d·(o−c),c = (o−c)·(o−c) − r² | fsqrt + >> | 第 2.1 节 |
| 光线-平面求交 | t = ((p₀−o)·n) / (d·n) | fdiv | 第 2.1 节 |
| Möller–Trumbore | det = e₁·(d×e₂);u = (t⃗·p)/det;v = (d·q)/det;t = (e₂·q)/det | fmul + fdiv | 第 2.2 节 |
| BVH slab 测试 | 逐轴 t₁ = (lo−o)/d、t₂ = (hi−o)/d,取 max(t_lo)、min(t_hi) | fdiv | 第 3.1 节 |
| 遍历收缩 | tmax = min(tmax, t_hit),剪掉 t_lo > tmax 的节点 | 无(比较) | 第 3.3 节 |
| 渲染方程 | L_o = L_e + ∫ f_r(x,ω,ω′) L_i(x,ω′) cosθ′ dω′ | fmul | 第 6.1 节 |
| 蒙特卡洛估计 | ⟨I⟩ = (1/N) Σ f(xᵢ)/p(xᵢ) | fdiv + >> | 第 5.1 节 |
| 余弦加权采样 | p(ω) = cosθ/π,权重退化为 f_r · π | fmul | 第 7.2 节 |
| MIS 权重 | wᵢ = pᵢ / Σⱼ pⱼ(平衡启发式) | fdiv | 第 7.3 节 |
| 体渲染方程 | L = ∫₀ˢ T(t) σ_s(t) L_i(t) dt + T(s) L_bg,T(t) = exp(−∫σ_t) | 累乘 + fmul | 第 9.1 节 |
幂次一律用平方链(沿用卷二):x^16 只需 4 次乘法,x^32 只需 5 次。第 4.2 节的菲涅尔项 (1−V·H)^5 用平方链展开成 4 次乘法,与逐次相乘的结果逐位相同。
RNG 的拒绝采样不能省:rng_below(state, n) 用 RNG_MASK - (RNG_MASK % n) 作上界反复取数,直到落进范围。取模 % n 会引入偏置——当 n 不整除 2^64 时,低位的概率比高位高。第 5.1 节用 3 个样本的直方图把这个偏置量出来:拒绝采样的卡方统计量落在临界值内,取模版本的偏离可观测。
A.4 采样与收敛参数表
卷三的随机部分由一组参数控制。它们全部落在 JSON 里,且没有任何浮点数——比例一律用千分比整数,概率用 Q16.16。
| 参数 | 格式 | 典型值 | 含义 |
|---|---|---|---|
samplesPerPixel | int | 1 / 16 / 64 / 256 | 每像素的样本数 |
maxDepth | int | 1 / 4 / 8 | 递归深度上限(1 = 命中即着色) |
stratifyGrid | int | 4(即 4×4) | 分层网格的边长 |
russianRoulette | Q16.16 | 0(关)/ 32768(0.5) | 存活概率,0 表示不启用 |
stopRelPermille | int | 5 | 停止准则:相对误差阈值(千分比) |
minSamples | int | 16 | 停止准则的最小样本数,防早停 |
maxSamples | int | 4096 | 停止准则的最大样本数 |
seedQ16 | int | 20261015 | RNG 初值(与场景 seed 同源) |
收敛判据的写法:误差必须按 1/√N 下降才说明估计量正确。第 5.2 节用双对数坐标拟合斜率,实测斜率落在 −0.5 ± 0.03 内;斜率明显偏离 −0.5 说明采样有偏或分层网格与样本数不匹配。第 12.3 节的停止准则同时看相对误差与局部方差——只看均值会漏掉「一片区域已经很准、另一片还在抖」的情况。
A.5 基线与误差汇总
三卷的基线锚在同一条链上:同一份 config/scene_spec.json、同一套定点约定、同一个 320×240 分辨率。三卷的 sha256 互不相同(可见性判定方式不同),但每一卷内部 Python 与 TypeScript 必须逐字节相同。
| 卷 | 基线 | sha256 | 跨语言 |
|---|---|---|---|
| 卷一 | 逐顶点光栅化 | bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced | 逐字节相同 |
| 卷二 | 逐像素着色 | 0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de | 逐字节相同 |
| 卷三 | 逐像素主光线 | 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71 | 逐字节相同 |
卷三基线的完整计数(S 层与 P 层逐条相同):
主光线 76800 阴影光线 77211 遮挡 1357
命中像素 43731(569.4141‰)—— 与卷二光栅化覆盖掩码只差 4 个像素
三角形 896 顶点 487 BVH 节点 511(叶子容量 4,树高 9)
BVH 访问节点 3238333(每条主光线 42.1658 个)
BVH 三角形测试 1119804(每条主光线 14.58 个;无 BVH 时是 896 个)
耗时 S 层约 6.9 s / P 层约 1.16 s(本机实测,随负载波动)
加速结构的效果是这张表里最直接的一个数:896 → 14.58,每条约 61.4 倍的三角形测试削减。这不是「性能提升 N 倍」式的绝对化断言,而是同一份场景、同一条代码路径、只切换 bvh_intersect 与朴素遍历两个版本的实测计数——两个计数都是整数,可逐条复现。
A.6 数据集索引
各章产物落在工作区 lantern/ 下。基准类产物是跨章共用的,章节类产物各章自建。
| 文件 | 类型 | 内容 | 出处 |
|---|---|---|---|
config/scene_spec.json | 规格 | 场景唯一真源(全整数) | 卷一第 1 章 |
out/cg_trace_baseline.json | 基准 | 卷三基线摘要(sha256、光线数、BVH 统计) | 卷三入口 |
out/trace_hollow.png / .rgb | 图像 | 基线的 PNG 与原始像素缓冲 | 卷三入口 |
out/cg_trace_<章>_<节>_*.json | 数据 | 各节的实测统计(计数、误差、收敛) | 各章 |
out/cg_trace_<章>_<节>_*.csv | 数据 | 收敛曲线、误差序列、代价表 | 各章 |
out/cg_trace_<章>_<节>_*.svg | 图表 | 纯 xml.etree 手绘的曲线图 | 各章 |
out/frames/ | 帧序列 | 第 15 章的动画帧(ffmpeg 合成 GIF) | 第 15.1 节 |
三条硬约束,违反其一跨语言逐位一致必然崩:① 降精度一律用算术右移,定点除法用显式向零截断的实现;② 光追的中间量超过 2^31,TS 侧必须全程 BigInt(见 A.2);③ 整数类可以写「逐字节相同」,感知类派生量按声明容差断言,正文不写「GPU 与 CPU 逐位相等」。
A.7 复现命令
全部命令在 lantern/ 目录下执行,Python 一律用 /opt/homebrew/bin/python3(系统自带的 3.9 没有 numpy)。
# snippet: 本卷的复现命令(全部在 lantern/ 目录下执行)
cd ~/.cgbook/lantern
# 卷三入口:基线 + BVH 统计
/opt/homebrew/bin/python3 sim/render_trace.py
node core/tools/render_trace_ts.ts # 断言与 Python 侧 sha256 逐字节相同
# 本附录的数据源
/opt/homebrew/bin/python3 sim/cg_trace_appendix_a.py
# P 层测试(本卷全部章节)
node --test 'core/test/cg_trace_*.test.ts'
想按顺序读,从目录篇 开始;想先看量级上界的结论,回到第 2.2 节 与第 13.3 节 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。