3.1 包围盒与 BVH
把 896 个三角形收进一棵扁平整数数组的 BVH:叶子 9 个整数、内部节点 10 个,left 等于负一是整棵树唯一的类型标签。本节立起两条不变量——父盒包含两个子盒、叶盒包含它引用的每个三角形,再把 slab 判据逐轴展开到方向分量为 0 的退化分支,全部落到实跑输出。
tag
把 896 个三角形收进一棵扁平整数数组的 BVH:叶子 9 个整数、内部节点 10 个,left 等于负一是整棵树唯一的类型标签。本节立起两条不变量——父盒包含两个子盒、叶盒包含它引用的每个三角形,再把 slab 判据逐轴展开到方向分量为 0 的退化分支,全部落到实跑输出。
自交与容差的边界分析:EPSILON 被两条可测量的边界夹住——下界是地形自交在 eps>=16 清零,上界是场景里最小的可见几何间隙 174 Q16.16;实测丢遮挡在 eps=256 起出现,并解释为什么偏移要沿法线而不是沿光线,以及球自交为何清不干净。
Möller–Trumbore 的除法链在 Q16.16 定点下的量级实测(原始乘积到 2^49、除法分子到 2^39,故 TS 侧必须全程 BigInt);det 趋近 0 时相对误差从 0.061‰ 涨到 46.1426‰ 的扫描,以及真实命中里为什么不能按 det 大小做 epsilon 剔除。
三条解析求交原语——光线-球、光线-平面、光线-三角形的闭式解,连同方向与平面平行、交点在光线背后、方向与三角形共面三个退化情形一律返回 None 的口径;再用真实场景量出解析球与八面体细分网格之间的面化误差,看清网格近似在离线光追里的真实代价。
卷三的交付账本:三卷基线 .rgb 的 sha256 逐个复核,六条计数恒等式(主光线 76800、BVH 访问 3238333 = 主光线 + 阴影光线、掩码对称差 4)逐条对账,六档可运行性各写清证明了什么;另给出已证明 4 条与未证明 5 条(真实 GPU、生产级材质、硬件光追 API 等),以及三条扰动轴的软告警。
把三卷基线的 sha256、计数与容差判据并成一张表:卷一顶点光栅化 bac65977…、卷二逐像素 0d997343…、卷三主光线 213b4130…,三者命中或覆盖都是 43731 像素;再引第 13.3 与第 14.3 节的实测锚给出反直觉结论——列口径真 bug 下两个语言实现仍逐字节相同,只有掩码对称差(4 对 22212)抓得出来。
把 config/scene_spec.json 到像素缓冲的八级链路逐级重放:规格摘要、相机基行口径、76800 条主光线方向摘要、BVH 511 节点高 9、命中 43731 像素、阴影光线 77211(遮挡 1357)、遍历 3238333 节点,末级 sha256 与卷三基线逐字节相同,P 层在 BigInt 里复现每一级摘要。
把分辨率、每像素样本数、最大弹射深度、BVH 叶子容量四条旋钮各转一遍,用「相对参考解的 MAE」对「本机墙钟」给出取舍表;实测样本数在 9 spp 之后饱和(MAE 只再降 1.5463 倍),叶子容量的遍历代价在 leaf=4 处取到最小值,未达预期的组合以软告警列出。
一帧 6.7 秒,时间到底花在哪?本节把 320x240 基线的单帧耗时拆成主光线求交、阴影光线求交、着色算术、光线生成、命中处理五块,用计数而不是直觉解释它;实测阴影光线求交独占 53.68%,比主光线求交还贵,因为阴影光线大多要遍历到底去证明这一点没被挡住。
离线渲染的成片是一串图,不是一张图。本节把共用相机参数接上一条纯整数相机路径,16 帧逐帧渲染并落盘 PNG 序列,用 ffmpeg 合成 GIF/MP4;实测单帧约 1.6-2.0 s、总耗时 29.1 s,逐帧重跑 16/16 帧 sha256 相同。
把三类判据摆在同一帧上:跨语言逐字节一致(sha256 相等,0 容忍)、跨路径像素掩码一致(光追命中掩码对光栅化覆盖掩码的对称差等于 4 个像素)、跨宿主声明容差(MAE ≤ 1.0 / PSNR ≥ 30 dB / 覆盖差 ≤ 20‰)。再给出反直觉的结论:判据一抓不到「两侧同错」。
把 CPU 定点与浏览器 WebGL2 之间的差异拆成三类可归因的来源:光栅化规则(顶左规则与子像素精度)、插值方式(屏幕空间仿射对透视校正)、浮点精度(Q6/Q16.16 对 float32)。每类配一个只动一个变量的实验,并用 MAE ≤ 1.0、PSNR ≥ 30 dB、覆盖归属差异 ≤ 20‰ 三条声明容差加一个必须被拒绝的负例来收口。
光栅化与光追是两条完全独立的成像路径:一个在屏幕 Q6 空间用整数边函数判覆盖,一个在世界空间用 Möller–Trumbore 求交。同一份场景下两条路径的覆盖掩码各 43731 像素、逐像素对称差 4 个,且这 4 个全落在到最近正向边距离为 0 Q6 的轮廓边上;采样点挪半个像素,同一判据立刻变差到 160。
同一份 scene_spec.json 交给 Python 与 TypeScript,像素缓冲 sha256 同为 213b4130…、五个计数逐条相同。把相机基从行换成列,命中像素从 43731 掉到 21519,两侧却依旧逐字节相同——跨语言一致抓不到「两边一起错」。再把 BigInt 换成 Number,右移只有 32 位,命中像素静默变成 8484。
一张图是几万次累加拼出来的:整数加法三序零差异,破坏结合律的是 fmul 的截断(结合律反例 1005/4096)。重排三角形重建 BVH 后图像摘要一字不变而访问节点数变了;全局 RNG 流下换遍历顺序 3049/3072 像素改变,逐像素流则逐字节相同。
把 Möller–Trumbore 的误差写成一条闭式不等式,在 43731 条命中主光线上逐条核对:界恒成立,最紧处实测/界 = 0.746916。改一处舍入规则,41254 条命中的 t 就会分叉。真正决定自交的是垂直误差 Δt·sinθ,最大 241.47 LSB,而非沿光线的 Δt。
知道每个像素的误差量级之后,就可以让它自己决定什么时候停。本节实测三条停止准则(逐像素方差、相对误差阈值、预算上限)各跑两个最小样本数版本,量出各自停在哪、总样本数、最终误差,以及两种失效模式——四样本全零会让方差估计变成 0,于是最早停、错得最狠。
误差只在有参考解时才有意义,可参考解本身也是估计量。本节用四条互相独立的量法给它造不确定度:批均值的标准误、对半拆、换种子族的第二条参考解、以及只取全程无遮挡像素的确定性网格求积。实测 1024 样本/像素的参考解相对标准误 0.005729,对半拆比值 2.045、换族比值 2.219,都与理论吻合。
噪声不是「看起来脏」这种观感描述,而是一个可算的整数:同一像素在 N 个样本下是个随机变量。本节先把面光源半径取 0、N = 1 退化回卷三基线的逐字节锚点,再用 256 样本扫掠与 8 批参考解量出收敛速率——实测 log-log 斜率 -0.5027,理论值 -0.5。
有了求交点,法线靠差分、软阴影靠沿光取样,两者都不是免费午餐:差分步长 h 太大会跨过棱角、太小会被定点量化吃掉,实测中心差分在 h=64 时均值 0.0342 度;软阴影有一个系统性平面偏置 k*sin(theta),k 必须超过 1/sin(theta)≈1.732 才能让开阔地面回到满亮度。