一个着色点接收到的光,有一部分来自别的物体反射的光;而别的物体,又接收到它反射的光。 要算清这一点,只能在场景里采样 —— 而一旦靠采样,就永远受 1/√N 支配。
这条骨架把 GI 收成三件事:在哪采样(探针 / 像素 / 路径)、采几次(一次反弹 / 多次反弹)、 怎么排(随机 / 分层 / 蓝噪声)。第 1–4 课把这三件事全量过了, 结论是一句很不好听的话:误差 ∝ 1/√N、排法只买常数 —— 在运行时算 GI,成本降不下来。 所以本课换一个方向:不再问"怎么算得更快",而是问"能不能不算" —— 把积分搬出运行时,以及为此要付的代价。
第 4 课把"在运行时算 GI"这条路走到了头:误差 ∝ 1/√M(M 翻一倍只掉 29%), 而换排法只买到一个常数折扣(0.61 倍)。也就是说,只要还在每帧采样,成本就降不下来。
那就把问题反过来问:这些光真的需要每帧都重算吗?房间里的墙、地板、桌子 —— 它们一辈子都不动, 太阳也不动,那么"它们收到多少间接光"这个答案,算一次就一直有效。 于是有一种截然不同的做法:事先把每个表面点的间接光算好,存成一张图(光照贴图 / lightmap), 运行时一条光路都不抽,只按位置查表、在格子之间做双线性插值。 本段只加一样东西:这张预先算好的图(以及它的分辨率 NB)。 三件量出来的事见下:判据④给"一次性的代价换此后每帧 0 条光路"这本账; 判据⑤给这句工程老话的量化 —— 烘焙换来的是"改不了":把太阳拖走却不重烤,错得有多离谱; 判据⑧给低分辨率特有的那个伪影 —— 漏光(插值跨过了遮挡边界)。 保真度如实标「示意 · 等价重现」:整页仍是那台逐像素自算的 CPU 路径追踪器,件一框里那几行就是运行时真正在跑的。
look51 的正文,逐字未改):
它一条光路都不抽,只按位置在图上取四个格子、按小数部分做双线性插值。
右列是它交出来的三个数:查表那块离真值多远、实时那块离真值多远、
以及把太阳拖走却不重烤时会错成什么样。
① 先看左块(查表):地面是干净的 —— 它没有采样,所以没有任何噪声。 这是烘焙最直观的好处:用一次性的代价,买来每帧的干净。
② 把「每条像素抽几条光路 M」拖大:中块(实时)一点点变干净,而左块一动不动 —— 因为左块根本不采样。这两块的差别,就是"算 vs 查"的差别。
③ 再把 M 拖回 2:中块一片麻,右块(差)亮起来 —— 但请分清: 右块里混着两样东西:烘焙的误差与实时那块的噪声(表①把烘焙误差单独量了出来)。
④ 把「烘焙分辨率 NB」从 8 拖到 64:左块越来越细,右块越来越暗; 而把 NB 拖到 8 时,看盒子脚那一圈 —— 亮块会渗到影子里去,这就是漏光(判据⑧)。
⑤ 把开关切到「不重烤」,再拖「太阳方位」:中块的光跟着太阳走,左块的影子却钉在原地, 右块整块烧起来 —— 烘焙最贵的那笔账,就是这一下。
左:查预制图(每帧 0 条光路)| 中:实时路径追踪(每像素 M 条)| 右:两者之差(灰阶)
启动中…
NB = 8 是一张 8×8 的图(格子比盒子脚还大,会漏光); NB = 64 是 64×64(细,但烤一次也更贵)。本段唯一的旋钮就是它。
控件与上一段只多不少:光路数 M、天光、太阳方位、眼位、盒子数逐字相同, 另加本段的新变量(烘焙分辨率 NB)与一个用来核判据的开关。 注意 M 在本段只管中块 —— 拖它的时候左块一动不动,这本身就是本段的论点。
定点观察(三步):① 把 NB 从 64 拖到 8,盯盒子脚那一圈; ② 看亮块怎么渗进影子里(漏光);③ 再把开关切到"不重烤"、拖走太阳 —— 影子钉住不动。 结论:烘焙用"一次性代价"换"每帧 0 条光路",但它把答案和"烤的那一刻"绑死了。
| NB | 格子数 | 查表 vs 真值 | 烤一次的光路数 | 约合几帧实时 | 图多大 |
|---|
| 项 | 光路数 | 次数 | 折成"每帧" |
|---|
| NB | 跨边界格子的误差 | 格内均匀的误差 | 倍数 |
|---|
这一段把"算 GI"换成了"存 GI",并把它的好处与死穴都量成了数。
5.1 把"算 GI"换成了"存 GI",但它把答案存在表面上:一个格子对应地面上的一小块, 记的是那一小块自己收到的光。于是答案和"烤的那一刻"绑死 —— 太阳一动就得重烤,物体一动更没辙。
问题出在"存在哪"。表面上的点只能代表它自己,而光是在空间里走的。 那就换个存法:把答案存到空间中的一组格点上(探针 / probe),每隔一段距离插一根, 每根记下"我这儿四面八方的光"。着色时,一个表面点不必有属于它自己的格子 —— 它去问周围那几根探针,按距离做三线性插值。 本段只加一样东西:这套摆在空间里的探针网格(以及它的疏密 NG)。 好处是它不挂在任何表面上:物体挪了、太阳动了,把探针重算一遍就是新的答案, 而且每帧只更新其中一部分也够用(判据④给这本账、判据⑤给"能跟上")。 代价是一笔偏差 —— 光泄漏(判据⑧):探针在空间里,它看不见"表面上能看见什么", 于是它看到的光会被插值铺到本来该是暗的地方去。加采样压不掉它,因为它是方法与生俱来的错配。 保真度如实标「示意 · 等价重现」:整页仍是那台逐像素自算的 CPU 路径追踪器,件一框里那几行就是运行时真正在跑的。
look52 的正文,逐字未改):
它把表面点的位置换算成探针格点坐标,取周围八根探针、按小数部分做三线性插值,
再按表面法线选一根轴。右列是它交出来的三个数:探针那块离真值多远、
实时那块离真值多远、以及盒脚一圈比别处差几倍。
① 先看左块(探针):它没有麻点 —— 每根探针的 256 条光路已经求过平均, 运行时只做插值。所以左块不干净的地方是成片的(插值 + 位置错配), 不是颗粒状的(采样噪声)。这是探针网格最直观的好处:用一次性的光路,买来每帧的干净。
② 把「每条像素抽几条光路 M」拖大:中块一点点变干净,而左块一动不动。 这一对反差就是"每帧采样"与"一次算好"的分野。
③ 把「探针疏密 NG」从 32 拖到 2:左块越来越钝,右块整体亮起来; 而看盒子脚那一圈 —— 亮块会渗进影子里,或者反过来把盒子脚下压黑。这就是光泄漏(判据⑧)。 注意它加密也压不下去:拖到 32 那一圈还是老样子(判据②)。
④ 把开关切到「剔除」再看盒脚:落在盒子内部的那几根探针(半球被自己堵死、值≈0) 会被剔掉、权重让给邻居 —— 盒脚那一圈从偏暗归到基本不偏(实测平均差 −0.042 → +0.000, 误差 0.096 → 0.073)。但剩下的 0.073 仍是别处的两倍,而且加密、加采样都压不下去 —— 那才是方法自带的那一笔(表③最后两行就是这个开关的两边)。
⑤ 把「太阳方位」拖到 300°:左块的光跟着太阳走(重算探针就行), 这一点是 5.1 做不到的 —— 那张 lightmap 一挪太阳就把影子钉在原地。
左:探针网格查表(每帧 0 条光路)| 中:实时路径追踪(每像素 M 条)| 右:两者之差(灰阶)
启动中…
NG = 2 是每轴 2 根(一格比盒子还宽,漏得最狠); NG = 32 是每轴 32 根(细,但探针数与内存也翻 256 倍)。本段唯一的旋钮就是它。
控件与上一段只多不少:光路数 M、天光、太阳方位、眼位、盒子数逐字相同, 另加本段的新变量(探针疏密 NG)与一个用来核判据的开关。 注意 M 在本段只管中块 —— 拖它的时候左块一动不动,这本身就是本段的论点。
定点观察(三步):① 把 NG 从 32 拖到 4,盯盒子脚那一圈; ② 把开关在"照用 / 剔除"之间来回切,看那一圈怎么从一个方向翻到另一个方向; ③ 再把太阳拖到 300° —— 左块跟着变(这张网格不需要离线重烤)。 结论:把答案搬进空间,换来了"能跟着动",代价是一笔采样数压不掉的偏差。
| NG | 探针数 | 查表 vs 真值 | 更新一次的光路数 | 更新一次 ≈ 几帧 | 内存 |
|---|
| 项 | 光路数 | 次数 | 折成"每帧" |
|---|
| 探针 | 盒脚一圈的误差 | 别处的误差 | 倍数 |
|---|
这一段把答案从"表面上"搬到了"空间里",于是它第一次可以跟着场景动; 代价是一笔采样数压不掉的偏差。
5.2 把答案搬进了空间,于是它能跟着场景动 —— 但它每帧仍要花一笔固定的射线预算, 而且误差停在一条采样数压不下去的偏差上。前两段(还有 4.2)都在回答"怎么便宜", 可有一个前提一直没被点破:我们从来没问"为什么不干脆算准"。
这一课的活例全是 CPU 自算。原因一半在 API:WebGPU 不暴露硬件光追(没有 DXR / Vulkan RT 那一套管线), 真要用硬件加速,得自己在 compute shader 里建加速结构、自己写遍历 —— 那是另一个课题。 但更值得记的不是"这个 API 没有",而是就算有,也救不了这件事。把限制拆成两层: ① 硬件的限制 —— 每次查询有多贵;② 问题本身的限制 —— 要查询多少次。 渲染方程是一个递归积分,没有闭式解:要么采样(误差 ~ 1/√N,4.2 量过), 要么近似(偏差,5.1 的过期、5.2 的泄漏)。硬件光追只把 ① 乘一个常数,② 一动不动 —— 所以它换来的是帧率,不是答案。 本段只加一样东西:一次「问场景」的单价。于是"多快"与"多准"第一次被拆成两个数,可以各自读出来。 保真度如实标「示意 · 结构示意」:画面与误差仍是那台逐像素自算的 CPU 路径追踪器(等价重现), 但"硬件光追"那一档在本页只能是一个换算假设 —— 本页能实测的只有查询次数与 CPU 的耗时。
ask53 的正文,逐字未改):
它只做一件事 —— 每问一次场景先记一笔账,然后原样把问题转给求交例程。
右列是它交出来的三个数:本帧一共问了几次、本机每次花多少微秒、
以及把单价便宜 4 倍以后噪声降到多少。
① 先看左块(本机自算):它就是 4.x 那台逐像素路径追踪器,麻点还在 —— 因为左块每一帧都真的在问场景,问的次数就是右列那个数。
② 把「单价」从 1 拖到 0.25:左块一动不动(同一个 M、同一批样本、逐位相同), 而中块干净了一截 —— 因为同样一份时间预算,现在买到了 4 倍的条数。
③ 再盯中块看得多干净:噪声只降到 1/2,不是 1/4。便宜 4 倍,只买到"减半"—— 这就是 1/√N 没有被硬件光追改掉的样子。
④ 把「每条像素抽几条光路 M」拖大:右列那个"本帧问了几次"会严格按比例涨, 误差按 1/√M 落 —— 和单价无关。真正降误差的旋钮,一直是这一个。
⑤ 把开关切到「本机(单价 1)」:左块与中块变得一模一样(差块全黑)—— 因为单价不是"画得更准",只是"买得更多"。
左:本机自算(单价 1,每像素 M 条)| 中:同样一份时间预算、单价更低 ⇒ 买到的条数更多 | 右:两者之差(灰阶)
启动中…
它不改画面,只改"同样一笔时间预算能买几条"。 0.25 相当于每次查询便宜 4 倍(硬件光追给的一个量级);本页只画到这一档, 更小的档在表②里按算式摊开。本段唯一的旋钮就是它。
控件与上一段只多不少:光路数 M、天光、太阳方位、眼位、盒子数逐字相同, 另加本段的新变量(一次查询的单价)与一个用来核判据的开关。 注意单价在本段只管中块买得到几条 —— 拖它的时候左块一动不动,这本身就是本段的论点。
定点观察(三步):① 把单价从 1 拖到 0.25,盯左块与中块的差别; ② 看清中块只干净了一倍(不是四倍);③ 再把 M 拖大 —— 右列那个计数按比例涨、误差按 1/√M 落。 结论:硬件光追动的是"每次多贵",1/√N 才是"要问多少次"这一维的地板。
| 问的是什么 | 每条光路上界 | 实测每条 | 本帧实测 |
|---|
| 单价 | 买到的 M | 实测误差 | 相对耗时 |
|---|
| 每像素条数 M | 实测误差 | 误差 × √M | 几倍预算 |
|---|
| 方法 | 每帧问场景几次 | 误差由什么决定 | 它动的是哪一维 |
|---|
这一段没有再给一个新方法,而是把整课的账翻过来看:限制有两层,硬件光追只动得了其中一层。
本段的活例由本页自己计算(示意 · 等价重现,非硬件产出):不依赖 WebGPU,任何设备都能跑。同一页的示意图与数值表照常可读。
页面版本 v3(课题四第 5 课「工程上的取舍」:5.1 / 5.2 / 5.3 全部上线 ——
5.1 烘焙:算一次用一辈子 / 5.2 DDGI:动态场景的探针网格 / 5.3 硬件光追不可用:限制本身)。
5.1 这一段只加一样东西:一张预先算好的图(以及它的分辨率 NB)。
第 4 课已经量到"在运行时算 GI"的成本底线(误差 ∝ 1/√M、排法只买常数),
所以本段换方向:不问了,改存 —— 把地面划成 NB × NB 个格子,每格抽 64 条光路算出该点的辐射度,
存成一张图;运行时一条光路都不抽,只按位置查一次 + 双线性插值。
件一框里那几行 = look51 的正文(逐字)。量出来的四件事:
① ★ 一次性的代价换此后每帧 0 条光路(判据④,主判据):烤一次 = NB² × 64 条光路
(NB = 32 时 65,536 条,一次性);实时那条路 = 8640 像素 × M 条(M = 8 时 69,120 条,每帧);
查表那条路 = 每像素 4 次乘加、0 条光路 —— 烤一次约合 0.95 帧实时的采样量,之后每帧的采样量是 0;
② ★ 烘焙换来的是"改不了"(判据⑤,主判据):把图钉死在太阳 135°、再把太阳拖到 300°,
误差从 0.02084(重烤过)涨到 0.06266(用旧图)—— 3.01 倍,而画面上表现为影子钉在原地。
这不是精度问题,是信息过期;
③ ★ 分辨率越高越准,但代价也在涨(判据②):NB = 8/16/32/64 的误差依次
0.03599 / 0.02909 / 0.02188 / 0.01738(单调降,共 2.07 倍);
而烤一次的光路数从 4,096 涨到 262,144(0.06 → 3.79 帧),图从 0.2 KB 涨到 12 KB;
④ ★ 漏光(判据⑧):跨边界的格子误差 / 格内均匀的格子误差 = 9.98 / 9.41 / 6.89 / 4.66 倍
(NB 从 8 到 64)—— 格子越粗,亮处的光漏进影子越狠。所以提高分辨率的动机,一半是精度,另一半是让格子别横跨边界。
5.1 的判据八条:① 可复现(同配置烤两遍逐位相同)/ ② 分辨率越高越准(单调降)/ ③ 画面(含法线朝向哨兵)/
④ ★ 一次性的账(主判据)/ ⑤ ★ 不重烤时误差暴涨(主判据)/ ⑥ 内存(可笔算)/
⑦ 同源(件一框里那几行 = look51 的正文,重算逐位相同)/ ⑧ 漏光。
★ 如实标:① 本页是 CPU 路径追踪器(逐像素自算),不依赖 WebGPU;
② 本段只把地面烤进图里,盒子那几列像素仍按实时算(真实引擎的 lightmap 会覆盖所有静态表面);
③ 图里存辐射度(三通道),内存按 RGB8 折算;④ 参考解与新场景同源口径(M = 256、另一套种子);
⑤ "不重烤"那条的真值也跟着换到 300°(M = 64 的参考解,够用来比倍数)。
5.2 这一段只加一样东西:摆在空间里的一组探针(以及它的疏密 NG)。
5.1 把答案存在表面上,于是它和"烤的那一刻"绑死。5.2 换个存法:把答案存到空间中的格点上 ——
探针按固定间隔摆放、与几何无关,每根存六个轴向的入射辐照度;着色时表面点去问周围八根探针、做三线性插值。
于是场景可以动。件一框里那几行 = look52 的正文(逐字)。量出来的四件事:
① ★ 按固定预算每帧重算(判据④,主判据):探针 16×16×2 = 512 根 × 每根 256 条;
每帧固定更 64 根 = 16,384 条光路 —— 只占实时一帧(8,640 像素 × 8 条 = 69,120 条)的 0.237,
而且这个开销与探针疏密无关;代价是整张刷一遍要 8 帧;
② ★ 能跟上(判据⑤,主判据):把网格钉在太阳 135°、再把太阳拖到 300°,不重算的误差是 0.07222、
重算一遍降到 0.04807(1.50 倍)—— 对照 5.1 那张 lightmap 的 3.01 倍与"影子钉在原地";
③ ★ 加密压掉的是"插值"那一份,压不掉"偏差"那一份(判据②):整体误差 NG = 2/4/8/16/32 依次
0.12425 / 0.06325 / 0.04311 / 0.04688 / 0.04537(2 → 8 掉了 2.88 倍,之后就不再降)。
分开看两带就清楚:别处从 0.13122 降到 0.03483(3.77 倍),
而盒脚一圈始终在 0.0958 → 0.0976 之间(只动 1.9%)—— 加密压不动它;
④ ★ 光泄漏是偏差(判据⑧,主判据):盒脚一圈的误差 / 别处 = 2.60 倍;
把每根探针的采样从 256 加到 1024(4 倍),盒脚那一圈只动 9.5%,而别处降 55%
(0.03693 → 0.01661,正好是 1/√4 该有的样子)—— 一个不掉、一个掉一半,清楚地分出偏差与噪声。
这笔偏差里有一半可以靠额外机制修掉:探针按固定格线摆、与几何无关,NG = 16 时有 22 根直接落在盒子内部
(占 4.3%)—— 它们半球被自己堵死、值≈0,插值把这个 0 抹到盒脚上,让那里整体偏暗
(实测平均差 −0.04243);把这几根剔掉、权重让给邻居,平均差归到 0.00033、误差 0.09600 → 0.07275。
但剩下的 0.07275 仍是别处(0.03693)的 1.97 倍 —— 那一半来自位置错配,采样与剔除都修不掉。
5.2 的判据八条:① 可复现(同配置建两遍网格逐位相同)/ ② ★ 加密只买到"插值"那一份/
③ 画面(含法线朝向哨兵)/ ④ ★ 固定预算每帧重算(主判据)/ ⑤ ★ 能跟上(主判据)/
⑥ 探针数与内存(可笔算:512 × 72 = 36.0 KB)/
⑦ 同源(件一框里那几行 = look52 的正文,重算逐位相同)/ ⑧ ★ 光泄漏是偏差(主判据)。
★ 5.2 如实标:① 本页是 CPU 路径追踪器(逐像素自算);② 方向粗切成六个轴向存(真实 DDGI 存球谐系数),
本页的面全是轴对齐的,所以按法线取轴不引入误差;③ 探针只摆两层高度(0.06 / 0.45);
④ 探针在空间里没有表面去接直射阳光,"太阳那一份"是本页显式加进去的;⑤ 参考解与 5.1 同源口径;
⑥ 表里的数不随画面上的滑块变。
5.3 这一段只加一样东西:一次「问场景」的单价。
5.1 把答案搬出运行时、5.2 把答案搬进空间,但两条路都默认了"只能近似"。本段把限制拆成两层:
① 每次查询多贵(硬件的事,硬件光追买的就是这一条);② 要查询多少次(问题的事:渲染方程是递归积分,
没有闭式解)。件一框里那几行 = ask53 的正文(逐字):每问一次场景记一笔账。
量出来的四件事:
① ★ 查询次数是可数、可笔算的(判据②):走最多 1 + 反弹次数 = 5 次/条(实测 2.12)、
问太阳挡没挡住最多 4 次/条(实测 1.05),合计实测 3.17 次/条 ⇒
本帧 219,352 次(8640 像素 × 8 条 × 3.17);本机每次查询 0.29 µs(实测)。
② ★ 便宜 4 倍 = 同样一笔预算买到 4 倍条数(判据④):单价 1 → 0.25,条数 8 → 32,
而"条数 × 单价"两边都是 8 —— 那就是那笔预算本身;同一个 M 下的查询次数一个都不变。
③ ★ 便宜 4 倍只买到"噪声减半"(判据⑤,主判据):条数 8 → 32(同预算、单价 ÷4),
误差从 0.02827 只降到 0.01472(0.52 倍,不是 0.25)。想让噪声真降到 1/4,得把预算乘 16。
④ ★ 指数没有被改(判据⑥):M = 4/8/16/32 的误差依次 0.03984 / 0.02827 / 0.02103 / 0.01472,
各乘 √M 得 0.07968 / 0.07997 / 0.08413 / 0.08327(最大/最小 1.056)—— 还是 4.2 那条 1/√M。
5.3 的判据八条:① 可复现(计数与画面都逐位相同)/ ② ★ 查询次数可数、上界可笔算/
③ 画面(含法线朝向哨兵)/ ④ ★ 同样的时间预算(次数 × 单价 不变)/ ⑤ ★ 便宜 4 倍只买到噪声减半(主判据)/
⑥ ★ 指数没有被改(主判据)/ ⑦ 同源(件一框里那几行 = ask53 的正文,重算逐位相同)/
⑧ ★ 限制本身(本课各段的账摆在一张表上,硬件光追两个数都不改)。
★ 5.3 如实标:① WebGPU 不暴露硬件光追 ⇒ 本页所有画面都是 CPU 自算,件一框里那几行也是 CPU 在跑;
② "单价"是本页的换算假设,不是实测的硬件数据(本页没有 DXR 可测);
③ 本页能实测的只有两个数 —— 查询次数(精确计数)与 CPU 每次查询的耗时(计时);
④ 表② 只算不画(所以能算到 160 条),画面那块最多画到 64 条;⑤ 表③的误差与 4.2/5.1/5.2 同源口径。
本课收束:三段把"工程上的取舍"落成一句话 —— 本课全部的近似,都在"要问多少次"这一维上省钱
(换被积函数 / 换被采样对象 / 换时间轴 / 换粒度),而硬件光追只动"每次多贵"。
| 看不到新内容时请硬刷新(Ctrl+Shift+R),或给地址加 ?t=N —— 本站的 HTML 不带缓存指令。