《计算机图形学》卷三《光线追踪与全局光照》附录 A · 渲染算法公式、参数表与数据集索引

《计算机图形学》卷三《光线追踪与全局光照》附录 A:全卷共用的定点常数表(EPSILON、TRI_STRIDE、BVH 叶子容量、SplitMix64 的四个常数)、光追中间量的实测位宽上界(为什么 TS 侧必须全程 BigInt)、十个渲染算法的整数形式速查、采样与收敛参数表、三卷基线 sha256 与误差汇总,以及各章产物的数据集索引与复现命令。所有数字来自本机实跑。

这是《计算机图形学》卷三《光线追踪与全局光照》的附录 A。它把全卷散落在各章的常数、公式、参数与产物集中到一处,方便在写代码时对照。

附录分两部分。前半是约定:卷三新增的定点常数、光追中间量的量级上界、十个渲染算法的整数形式。这些约定一旦违反,跨语言逐位一致立刻崩——它们不是「建议」,是硬约束。后半是索引:采样与收敛参数、三卷基线汇总、各章产物落在哪个文件、怎么一条命令复现。

卷三比前两卷多出来一个量级问题,本附录用一节专门讲:光追的中间量远超 32 位。前两卷的屏幕空间量级能塞进 JavaScript 的 Number,卷三不行——这不是估算,是在 896 个真实三角形上跑出来的实测值。

本卷所有数字都来自本机实跑,凡是引用具体数值的地方都注明了它出自哪一节。没有估算值,也没有从别处抄来的数字。

A.1 卷三的定点常数表

卷三沿用卷一卷二的 Q16.16 约定(ONE = 65536)与屏幕 Q6 约定(SUB1 = 64),另外新增八个常数。前四个属于几何与加速结构,后四个属于确定性 RNG。

常数值含义主要出处
ONE65536Q16.16 的 1.0(卷一引入,卷三沿用)全卷
EPSILON64沿法线的自交偏移,模长 1/1024第 2.3 节
TRI_STRIDE21三角形记录的整数个数:9 坐标 + 9 法线 + 3 albedo第 2.1 节
LEAF_SIZE4BVH 叶子容量第 3.2 节
RNG_GOLDEN11400714819323198485SplitMix64 的黄金比增量第 5.1 节
RNG_MIX113787848793156543929SplitMix64 第一轮混合乘数第 5.1 节
RNG_MIX210723151780598845931SplitMix64 第二轮混合乘数第 5.1 节
RNG_MASK1844674407370955161564 位全掩码,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–Trumboredet = e₁·(d×e₂);u = (t⃗·p)/det;v = (d·q)/det;t = (e₂·q)/detfmul + 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。

参数格式典型值含义
samplesPerPixelint1 / 16 / 64 / 256每像素的样本数
maxDepthint1 / 4 / 8递归深度上限(1 = 命中即着色)
stratifyGridint4(即 4×4)分层网格的边长
russianRouletteQ16.160(关)/ 32768(0.5)存活概率,0 表示不启用
stopRelPermilleint5停止准则:相对误差阈值(千分比)
minSamplesint16停止准则的最小样本数,防早停
maxSamplesint4096停止准则的最大样本数
seedQ16int20261015RNG 初值(与场景 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 节 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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