当前复现状态(2026-10-17)
已恢复有来源的场景规格、公共模块与配套源码,从空目录组装三卷累计工程。361 条明确命令已完成运行;初次报告顺序失败保留,在全部输入哈希不变的前提下刷新跨语言报告后通过。实际范围包含全分辨率渲染、定点/浮点判据、跨语言与浏览器验证;Python 3.14.6、Node 26 及所列图像/浏览器依赖为当前环境,长实验每条预算 1800 秒,未缩小采样或断言。
这些结果验证当前最终快照,不等同按章文件演进、所有正文数字或练习答案审校。CPU 图像与跨语言一致也不能替代真实 GPU、设备或全部画面的正确性验收。
这是《计算机图形学》三卷本的第三卷,也是收官卷。它回答一个问题:光能不能被还原成一个可估计的积分,并且这个估计是可度量、可复现的?
卷一把骨架封版了:装配、深度、覆盖全部落成整数,Python 与 TypeScript 产出逐字节相同的像素缓冲(
bac659770178037a…)。卷二把颜色还原成可算的量,逐像素着色基线是0d997343ca547ed5…。本卷换掉的是可见性本身:不再逐像素遍历屏幕、用边函数判定覆盖,而是逐像素发一条主光线,靠求交、递归与积分得到颜色。同一份config/scene_spec.json由此有了第三条消费路径。这条路径的第一个可度量事实是加速结构值不值。基线场景 896 个三角形:不做加速,76800 条主光线每条都要测试全部 896 个三角形;套上 511 个节点的 BVH 之后,每条主光线只访问 42.1658 个节点、测试 14.58 个三角形——六十多倍的削减,且这两个计数是逐光线可复现的整数。
第二个可度量事实是跨语言仍然逐位一致。光追的乘法原始乘积已摸到 2^53,TypeScript 侧必须全程
BigInt(Number的>>只有 32 位,越界会静默给出错误结果)。两侧的像素缓冲sha256都是213b413069ca66c9…,主光线数 76800、命中像素 43731、阴影光线数 77211、遮挡数 1357、BVH 访问节点 3238333、三角形测试 1119804——全部逐条相同。第三个事实要到第 5 章之后才出现:光是一个积分,而积分只能用估计。一旦引入蒙特卡洛,输出就不再是唯一确定的像素值,而是一个带方差的估计。本卷后半程都在处理这件事——怎么让估计无偏、怎么把方差压下去、怎么判断「够了」、怎么在跨语言与跨宿主之间定义「一致」。这是本卷与前两卷最本质的区别:前两卷的判据是相等,本卷有一半判据是收敛。
贯穿全书的仍是虚构场景 《烛影山谷》(工程名
lantern)与同名工具链。本卷复用的几何、顶点法线与 albedo 与卷一卷二完全同源——地形用法线的解析中心差分,球体用「顶点减球心」再归一化。场景全部由程序生成,不引入任何第三方素材。
这套书与站内既有内容的分工
站内已经有大量相邻内容,写清分工比重复写一遍更有价值。下面这几处边界是本卷最容易混淆的,务必分清:
- 混合光线追踪 讲的是实时混合光追的工程实现(光栅化主通道 + 光追阴影/反射,跑在 GPU 上,关心帧时间与降噪)。本卷第 1、2、6 章做的是离线光追的完整链路:求交原语怎么写、容差取多少、递归什么时候停——产出的是可逐光线计数的量与收敛曲线,而不是帧率。
- 剔除与可见性系统
讲的是实时的视锥剔除与 Hi-Z。本卷第 3 章讲光追 BVH 的构建与遍历代价:为什么用最长轴 + 中位数划分、叶子容量取多少、遍历时
best_t收缩如何把三角形测试数压下来——全部给出实跑计数。 - 水面与海洋渲染 是水面的工程实现(波形、反射、次表面)。本卷第 4 章讲折射的物理与数值:Snell 定律在定点下怎么算、全内反射的临界角在哪、递归光线树怎么组织与怎么截断。
- 实时全局光照 讲实时 GI 的若干方案(探针、屏幕空间、体素)。本卷第 5、7、8 章做的是离线的蒙特卡洛估计:估计量怎么构造、方差怎么随样本数收敛、分层与低差异序列能压掉多少、多重重要性采样为什么能把两种采样策略的弱点互相补上、辐射度与光子映射各自的无偏性边界在哪。
- 体积云与大气
是体积云的实时工程(噪声、光照模型、降噪)。本卷第 9 章讲体积渲染方程与 ray marching:透射率怎么累乘、步长与偏差的关系、体积阴影要额外多少条光线——站内
ray marching命中为 0,这一章从方程写起。 - 大地形渲染 是大地形的 LOD 与裁剪工程。本卷第 10 章讲分形地形与程序化场景:噪声怎么构造、fBm 的倍频与增益怎么选、程序化场景怎么保证两种语言生成同一份几何。
- 符号距离场渲染 讲 SDF 的渲染工程。本卷第 11 章把 SDF 当几何原语与求交:布尔运算怎么保号、ray marching 的步长为什么可以取「到最近表面的距离」、SDF 的法线怎么用中心差分求、软阴影的系数怎么调。
- GPU 剖析与调试 是 GPU 性能剖析工具。本卷第 12 章建的是离线渲染的收敛与误差度量:噪声怎么量化、参考解怎么得到、误差降到多少可以停。
- 《渲染引擎》
55 篇用 TypeScript 从零造一个引擎。那套是引擎架构;本卷第 13 章是光追的跨语言确定性——递归追踪链路在 Python 与 TypeScript 下必须给出同一个
sha256,连 BVH 的访问节点数都要逐条相同。 - 现代图形 API 对比 是 API 选型对比。本卷第 14 章是两条渲染路径的数值对照:同一份规格分别走光栅化与光追,把输出逐像素相减,量出「可见性判定方式」这一个变量的代价。
- 移动端渲染优化 是移动端的性能工程。本卷第 15 章建的是离线渲染的时间/质量取舍:样本数、递归深度、分辨率各自花多少时间换多少误差,并用帧序列输出把取舍做成可看的产物。
一句话:本卷做的是「光怎么被积出来、估计准不准、跨语言跨宿主能不能对齐」,不是「怎么把画面调得更好看」。
三卷递进:判据怎么从「相等」走到「收敛」
三卷共用同一条流水线、同一份规格、同一套定点约定,变的是判据本身。这是理解本卷位置的最短路径:
| 卷 | 核心问题 | 可见性怎么判 | 主判据 | 副判据 |
|---|---|---|---|---|
| 卷一 | 一次渲染能否还原成一组整数 | 屏幕空间边函数 | 逐字节相等(PNG sha256) | 覆盖率千分比 |
| 卷二 | 颜色从哪来 | 同上 | 逐字节相等 + 容差 | MAE / PSNR |
| 卷三 | 光能否还原成可估计的积分 | 世界空间求交 | 逐字节相等(确定性部分)+ 收敛(随机部分) | 相对误差 / 方差 / 每光线计数 |
第三列的「+ 收敛」是本卷独有的。前两卷的每一次渲染都只有一个正确答案,任何一个像素不同就是 bug;本卷一旦进入第 5 章,同一个像素在不同样本数下本来就该不同——判据必须换成「估计量是否无偏」「误差是否按 1/√N 下降」「相对参考解是否低于阈值」。这不是宽松了,而是把正确性拆成了两半:确定性的一半仍然逐字节严判,随机的一半用统计量严判。
明确不在本卷范围内
为了让边界清楚,下面这些这一卷不讲:
- 实时降噪与去噪器(SVGF、OIDN 之类):那是工程降噪,本卷只做误差度量与停止准则,不引入任何预训练模型或外部库。
- GPU 上的光追 API(DXR、Vulkan RT、Metal RT):本卷的 W 层是 WebGL2 片元着色器,做的是 SDF ray marching 与两路径对照,不涉及硬件光追单元。
- 生产级渲染器的材质与积分器(路径引导、双向方法、Metropolis 光传输):本卷覆盖到路径追踪、辐射度与光子映射为止,不做高级采样策略。
- 真实 GPU 的性能剖析:本机 headless 的 WebGL2 是 ANGLE/SwiftShader 软件后端。凡需要真实硬件才能成立的论断,一律标注 X 档并写明「需真实 GPU 环境,命令行不可复现」。
- 资产格式与交换(glTF / USD / Alembic):几何全部由程序生成,不读任何外部模型文件。
可运行性六档口径
| 档 | 数据源 | 算什么(真跑) | 产出 |
|---|---|---|---|
| S | config/scene_spec.json 或本章自己的 config | Python + numpy 向量化光追 | PNG / JSON / CSV / 纯 xml.etree SVG |
| P | 同一份 spec | TypeScript 定点内核(BigInt),node --test | core/test/cg_trace_*.test.ts |
| W | 同一份 spec 内联进单文件 HTML | WebGL2 片元着色器,Playwright 读像素 | web/dist/trace*.html + out/shots/*.png |
| D | 场景规格 | schema / 字段表 / 不变量清单 | JSON / Markdown 表(必须与 S/P/W 同节搭配) |
| X | 真实 GPU | 性能剖析、真实驱动的浮点差异 | 无(正文逐处显式标注不可复现) |
| B | 可扰动参数 | 采样数 / 递归深度 / 分辨率 / 停止阈值扰动 → 收敛与相变阈值 | JSON(软告警不硬失败,exit 0) |
各章档位:第 1–10、12、13 章为 S+P;第 11、14 章为 S+P+W;第 15 章为 S+P+B;第 16 章为 S+P+W+B。每一节至少有一个 S / P / W 档的代码块,且具体数值按各章记录的规格、种子、采样与环境解读;当前复跑范围见页首,不将历史数字视为新环境下的保证。
贯穿案例《烛影山谷》:卷三的视角
同一份 config/scene_spec.json(name: "lantern-hollow",seed: 20261015),三卷三种读法:
| 卷 | 读到的是 | 消费方式 |
|---|---|---|
| 卷一 | 屏幕空间三角形与顶点色 | 逐像素遍历屏幕,边函数判覆盖 |
| 卷二 | 屏幕空间三角形 + 顶点法线 + albedo | 逐像素遍历屏幕,插值法线后求值 |
| 卷三 | 世界空间三角形 + 顶点法线 + albedo + 相机基向量 | 逐像素发一条主光线,求交后递归求值 |
卷三新增的读法只有一个:不再做 MVP 变换。光栅化器要的是屏幕空间坐标,光追要的是世界空间坐标,两者在 mat_mul 之后分头处理——这是两条路径的第一个分岔点,也是第 14 章对照的起点。
场景规模(与卷一卷二逐条一致):
| 量 | 值 |
|---|---|
| 顶点 | 487(地形 289 + 三个球 198) |
| 三角形 | 896(地形 512 + 三个球 384) |
| 三角形记录 | 21 个整数(9 世界坐标 + 9 顶点法线 + 3 albedo) |
| 相机 | eye (0, 6, 24)、target (0, 2, 0)、tanHalfFovY ≈ 0.5774 |
| 光源 | 方向光 (-0.577, 0.577, 0.577) 强度 0.9 + 点光源 (5, 8, 5) 范围 16 + 环境光 0.2 |
| 分辨率 | 320×240 |
卷三的新基线:一条主光线
本卷的基准脚本是 sim/render_trace.py(P 侧对应 core/tools/render_trace_ts.ts)。它做的是直接光照 + 硬阴影:环境光 + 方向光(一条阴影光线)+ 点光源(距离衰减 + 一条阴影光线),MAX_DEPTH = 1 表示命中即着色、不再递归——反射与折射是第 4 章的事,路径追踪是第 6 章的事。
| 量 | 值 |
|---|---|
| 基线 sha256 | 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71 |
| P 层 sha256 | 与 S 层完全相同 |
| 主光线 | 76800 |
| 命中像素 | 43731(569.4141‰)—— 与卷二光栅化器的覆盖掩码只差 4 个像素 |
| 阴影光线 / 遮挡 | 77211 / 1357 |
| BVH 节点 | 511(叶子容量 4,树高 9;叶子 256 / 内部 255) |
| BVH 访问节点 | 3238333(每条主光线 42.1658 个) |
| BVH 三角形测试 | 1119804(每条主光线 14.58 个;无 BVH 时是 896 个) |
| 耗时 | S 层约 6.9 s / P 层约 1.16 s(随负载波动) |
命中像素这一行值得单独说:43731 与卷二光栅化器算出的覆盖像素数精确相等,且两个掩码逐像素比对只差 4 个(0.09‰,来自光栅化的 top-left rule 与半像素中心约定)。这条等式是卷三「可见性判定换了一种方式、但看到的是同一个场景」的第一道硬判据,也是第 14 章两路径对照的起点。注意它不是自明的:本卷底座最初把相机基取成了视图矩阵的列(而 mat_look_at 返回行主序,right/up/back 是第 0/1/2 行),主光线整体朝上偏,命中像素掉到 21519(只剩 49.2%,降幅 50.8%),与光栅化覆盖掩码差了两万多个像素——这个 bug 是靠这条判据抓出来的。
复现命令:
# snippet: 卷三基线的复现命令(在 lantern/ 目录下执行)
cd $HOME/.cgbook/lantern
/opt/homebrew/bin/python3 sim/render_trace.py
node core/tools/render_trace_ts.ts
判据对象是像素缓冲,不是 PNG 容器字节——sha256 对 (height, width, 3) 的 uint8 连续内存取,PNG 只用来给人看。
底座:卷三新增的文件
| 文件 | 作用 |
|---|---|
sim/cgtrace.py | 求交原语、世界空间场景装配、BVH 构建与遍历、递归追踪、确定性 RNG、误差度量 |
core/src/lantern/cgtrace.ts | 上者的 TypeScript 对应,全程 BigInt |
core/tools/render_trace_ts.ts | P 侧基线,断言与 S 侧 sha256 逐字节相同 |
复用的是卷一卷二封版的 sim/cglib.py(定点数学与几何生成)、sim/cgshade.py(解析地形法线)、core/src/lantern/{fixed,vec,mat,mesh,shade,png}.ts。这些文件本卷一律不改,只新增自己的章节脚本。
下面这段是 P 侧基线的完整内容,与工作区文件逐字节一致。它与 S 侧读同一份 config/scene_spec.json、走同一条世界空间求交链路与同一个直接光照公式,最后把两个像素缓冲的 sha256 摆在一起断言。这条链路存在的唯一目的仍是可证伪:只要有一条光线因为遍历次序或舍入不同而命中另一个三角形,两个 sha256 立刻分叉。
// file: lantern/core/tools/render_trace_ts.ts
// tier: P
// verify: node core/tools/render_trace_ts.ts
// 卷三基线的 P 侧:读与 Python 侧完全相同的 config/scene_spec.json,走同一条世界空间
// 求交链路 + 同一个直接光照公式,落盘像素缓冲并断言 sha256 与 Python 侧
// out/cg_trace_baseline.json 的 sha256 **逐字节相同**。
//
// 卷三与卷一卷二在这条链路上面临的数值压力更大:光追的乘法原始乘积已摸到 2^53,
// 故全部走 BigInt(`Number` 的 `>>` 只有 32 位,见 cgtrace.ts 顶部注释)。
// 这条链路存在的唯一目的仍是可证伪:只要求交顺序、法线归一化时机或 BVH 遍历次序
// 有一处不同,两个像素缓冲的 sha256 立刻分叉。
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
type SceneSpec,
type TraceCtx,
type TraceShadeFn,
buildBvh,
buildSceneTris,
bvhIntersect,
clamp8,
renderTrace,
toVec,
} from "../src/lantern/cgtrace.ts";
import { fdiv, fmul } from "../src/lantern/fixed.ts";
import { encodePNG } from "../src/lantern/png.ts";
import { type Vec3, vAdd, vDot, vNorm, vSub } from "../src/lantern/vec.ts";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..", "..");
const out = join(root, "out");
mkdirSync(out, { recursive: true });
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as SceneSpec;
const { width, height } = spec;
/** 8 位通道 -> Q16.16 的 [0, 1]。 */
const c8 = (v: number): bigint => (BigInt(v) << 16n) / 255n;
const shadow = { rays: 0, hits: 0 };
function makeShader(ctx: TraceCtx): TraceShadeFn {
const { tris } = buildSceneTris(ctx.spec);
const bvh = buildBvh(tris);
ctx.tris = tris;
ctx.bvh = bvh;
const dl = ctx.spec.directionalLight;
const ldir = toVec(dl.dirQ16);
const lcol = dl.colorQ8.map(c8);
const lint = BigInt(dl.intensityQ16);
const pl = ctx.spec.pointLight;
const ppos = toVec(pl.posQ16);
const pcol = pl.colorQ8.map(c8);
const pint = BigInt(pl.intensityQ16);
const prange = BigInt(pl.rangeQ16);
const pr2 = fmul(prange, prange);
const amb = BigInt(ctx.spec.ambientQ16);
const tmax = ctx.tmax;
const bvhStats = ctx.bvhStats;
return (p: Vec3, off: Vec3, n: Vec3, albedo: readonly number[], _depth: number) => {
const alb = albedo.map(c8);
const o: bigint[] = [0, 1, 2].map((i) => fmul(amb, alb[i]));
// 阴影光线的起点 = 命中点 + 法线偏移(`off` 是偏移向量,不是起点)
const so = vAdd(p, off);
// 方向光:一次阴影光线
const ndl = vDot(n, ldir);
if (ndl > 0n) {
shadow.rays += 1;
if (bvhIntersect(bvh, tris, so, ldir, tmax, bvhStats) === null) {
const k = fmul(lint, ndl);
for (let i = 0; i < 3; i++) o[i] += fmul(k, fmul(lcol[i], alb[i]));
} else {
shadow.hits += 1;
}
}
// 点光源:距离衰减 + 一次阴影光线
const toL = vSub(ppos, p);
const d2 = vDot(toL, toL);
if (d2 > 0n && d2 < pr2) {
const ldirP = vNorm(toL);
const ndlP = vDot(n, ldirP);
if (ndlP > 0n) {
shadow.rays += 1;
if (bvhIntersect(bvh, tris, so, ldirP, tmax, bvhStats) === null) {
const att = fdiv(pr2, pr2 + d2);
const k = fmul(fmul(pint, ndlP), att);
for (let i = 0; i < 3; i++) o[i] += fmul(k, fmul(pcol[i], alb[i]));
} else {
shadow.hits += 1;
}
}
}
return [clamp8(o[0]), clamp8(o[1]), clamp8(o[2])] as [number, number, number];
};
}
const t0 = Date.now();
const { rgb, stats } = renderTrace(spec, width, height, 1, makeShader, {});
const ms = Date.now() - t0;
const digest = createHash("sha256").update(rgb).digest("hex");
writeFileSync(join(out, "trace_hollow_ts.rgb"), Buffer.from(rgb));
writeFileSync(join(out, "trace_hollow_ts.png"), encodePNG(width, height, rgb));
const py = JSON.parse(readFileSync(join(out, "cg_trace_baseline.json"), "utf8")) as {
sha256: string;
triangles: number;
primaryRays: number;
shadowRays: number;
shadowHits: number;
bvhVisited: number;
bvhTested: number;
};
const match = digest === py.sha256;
console.log(`canvas : ${width}x${height}`);
console.log(`triangles : ${py.triangles}`);
console.log(`primary rays : ${stats.rays} (python ${py.primaryRays})`);
console.log(`shadow rays : ${shadow.rays} / ${shadow.hits} occluded (python ${py.shadowRays} / ${py.shadowHits})`);
console.log(`bvh visited : ${stats.visited} (python ${py.bvhVisited})`);
console.log(`bvh tested : ${stats.tested} (python ${py.bvhTested})`);
console.log(`sha256(rgb) : ${digest}`);
console.log(`python 侧 : ${py.sha256}`);
console.log(`逐字节相同 : ${match}`);
console.log(`elapsed : ${ms} ms`);
process.exit(match ? 0 : 1);
P 侧的实跑输出与上表逐条相同(逐字节相同: true、elapsed: 1157 ms),此处不再重复。
注意 off 是偏移向量,不是起点。shade 的第二个参数 off 是沿法线的自交偏移(模长 EPSILON),阴影光线的真正起点是 p + off。这个错误本卷真的犯过一次:把它当起点写,阴影光线会全部从世界原点附近发出并立刻自交,遮挡率逼近 100%,画面只剩环境光。第 4.1 节用一个 off_is_origin 开关把这两版并排跑出来。
卷三新增的两个数值设施:
floorDiv:Python 的//是向下取整,而 BigInt 的/是向零截断。凡是要与 Python 侧对齐的整数除法(地形高度、BVH 质心排序键、直方图分箱)都必须走它——这是卷三比卷一卷二多出来的一条纪律。- SplitMix64 确定性 RNG:第 5 章之后的蒙特卡洛采样不能依赖
random/Math.random(两侧算法不同),必须用纯整数、全 64 位掩码的确定性函数。rng_q16给[0, 1)的定点均匀采样,rng_below用拒绝采样给出[0, n)的整数。第 13 章的「累积的确定性」与第 16 章的三卷一致性都建立在它上面。
全书目录(16 章 48 节)
第一部分 · 从光线到求交(第 1–4 章)
- 第 1 章 光线追踪的最小模型
- 第 2 章 求交
- 第 3 章 加速结构
- 第 4 章 阴影、反射与折射
第二部分 · 把光写成积分(第 5–8 章)
- 第 5 章 蒙特卡洛积分
- 第 6 章 路径追踪
- 第 7 章 重要性采样
- 第 8 章 全局光照
第三部分 · 介质、程序化几何与质量(第 9–12 章)
- 第 9 章 体积与参与介质
- 第 10 章 程序化几何与地形
- 第 11 章 符号距离场
- 第 12 章 离线渲染的质量度量
第四部分 · 数值一致与三卷收官(第 13–16 章)
- 第 13 章 光线追踪的数值一致
- 第 14 章 光栅化与光追的对照
- 第 15 章 渲染管线的工程
- 第 16 章 装配:三卷总收官
- 附录
怎么读
三卷本有一条主线:把渲染从「调 API」还原成「可算、可断言、可复现的量」。卷一回答「一次渲染能不能被还原成一组整数」,判据是相等;卷二回答「颜色从哪来」,判据是相等加容差;卷三回答「光能不能被还原成一个可估计的积分」,判据是相等、容差与收敛。
- 顺读:第 1–4 章把「光线」这个对象从表示一路做到递归光线树,此时你已经有一个完整的、确定性的离线光追器——它的输出与 Python 侧逐字节相同,可以立刻当作前两卷的第三种消费路径来验。
- 带着问题跳读:只想知道「加速结构值不值」,读第 3.3 节 ;只想知道「光为什么必须写成积分」,读第 6.1 节 ;只想知道「怎么判断采样够了」,读第 12.3 节 ;只想知道「两条渲染路径差多少」,读第 14.1 节 。
- 只想看对账:直接翻第 16.2 节
,那里把三卷的全部
sha256、计数与容差判据列成一张表。
每一节的代码块都带 # tier: 与 # verify: 头注释,标出这一块属于哪一档、用什么命令验。历史输出与当前复跑范围分别保留;只有对应输入和实际命令的数值才能据运行记录复核。真实 GPU 性能仍属 X 档,CPU 仿真通过不能代替 GPU 实测。
需要查公式与参数,翻附录 A ;想看全书 50 篇的总览、分工与六档口径,回到本卷索引 。
完整项目配套
下载 lantern 三卷累计教学工程
。包内含当前正文、源码来源/哈希、明确命令清单及 verify.py;解压后准备依赖,运行 python3 verify.py --browser。长光追实验需要较长时间;超时、未运行和首次失败均保留,不计作通过。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。