内容工厂. / 演示空间 / 全局光照 GI · 第 5 课 · 工程上的取舍 段 1 / 3
全局光照 GI | 第 5 课 | 上一课:反弹多次

工程上的取舍

读时 约 7 分钟 本课 3 段 · 已上线 3 段 每段 三件套 保真度 示意 · 等价重现(5.1 / 5.2)· 示意 · 结构示意(5.3) 页面版本 v3
课题骨架(本课题只声明一次)

一个着色点接收到的光,有一部分来自别的物体反射的光;而别的物体,又接收到它反射的光。 要算清这一点,只能在场景里采样 —— 而一旦靠采样,就永远受 1/√N 支配。

这条骨架把 GI 收成三件事:在哪采样(探针 / 像素 / 路径)、采几次(一次反弹 / 多次反弹)、 怎么排(随机 / 分层 / 蓝噪声)。第 1–4 课把这三件事全量过了, 结论是一句很不好听的话:误差 ∝ 1/√N、排法只买常数 —— 在运行时算 GI,成本降不下来。 所以本课换一个方向:不再问"怎么算得更快",而是问"能不能不算" —— 把积分搬出运行时,以及为此要付的代价。

5.1
烘焙:算一次用一辈子
5.2
DDGI:动态场景的探针网格
5.3
硬件光追不可用:限制本身
5.1

烘焙:算一次用一辈子

示意 · 等价重现

第 4 课把"在运行时算 GI"这条路走到了头:误差 ∝ 1/√M(M 翻一倍只掉 29%), 而换排法只买到一个常数折扣(0.61 倍)。也就是说,只要还在每帧采样,成本就降不下来。

那就把问题反过来问:这些光真的需要每帧都重算吗?房间里的墙、地板、桌子 —— 它们一辈子都不动, 太阳也不动,那么"它们收到多少间接光"这个答案,算一次就一直有效。 于是有一种截然不同的做法:事先把每个表面点的间接光算好,存成一张图(光照贴图 / lightmap), 运行时一条光路都不抽,只按位置查表、在格子之间做双线性插值。 本段只加一样东西:这张预先算好的图(以及它的分辨率 NB)。 三件量出来的事见下:判据④给"一次性的代价换此后每帧 0 条光路"这本账; 判据⑤给这句工程老话的量化 —— 烘焙换来的是"改不了":把太阳拖走却不重烤,错得有多离谱; 判据⑧给低分辨率特有的那个伪影 —— 漏光(插值跨过了遮挡边界)。 保真度如实标「示意 · 等价重现」:整页仍是那台逐像素自算的 CPU 路径追踪器,件一框里那几行就是运行时真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:一张预先算好的图(以及它的分辨率 NB) 运行时不再抽光路,只按位置在格子里查一次、再插值 这一段的做法(算一次,之后只查表) ① 烘焙(一次性):把地面划成 NB × NB 个格子,每格抽 64 条光路算出该点的辐射度    存成一张 NB × NB 的图 —— 这就是"算一次用一辈子" ② 运行时:地面每个像素按它落在地面的位置,在图上查一次 + 双线性插值    盒子那几列像素仍按实时算(本段只把地面烤进图里) 左列:这一段吃什么 烘焙分辨率 NB — 烤一次的代价 — 实时那块每像素 — 运行时每格跑的就是这几行 // ★ 本段唯一改动:不算了,改成查预制图 function look51(x, y) { const u = x * NB51 - 0.5, v = y * NB51 - 0.5; const i = Math.floor(u), j = Math.floor(v); const a = u - i, b = v - j; return bilin51(i, j, a, b); } 右列:交出什么 查表那块 vs 真值 — 实时那块 vs 真值 — 太阳拖走却不重烤 — 双线性插值要 4 次乘加 —— 这就是"每帧的代价"了;而图是事先算好的
看什么:左边三格是这一段要吃的输入 —— 烘焙分辨率 NB(图上有多少格)、 烤一次的代价(NB² × 64 条光路,一次性)、以及实时那块每像素抽几条。 中间框里是运行时真正在跑的源码(函数 look51 的正文,逐字未改): 它一条光路都不抽,只按位置在图上取四个格子、按小数部分做双线性插值。 右列是它交出来的三个数:查表那块离真值多远、实时那块离真值多远、 以及把太阳拖走却不重烤时会错成什么样。
为什么"算一次"能成立:这张图存的是几何与光源都不动时的答案。 只要那两样不变,答案就不变 —— 于是"每帧的采样"变成了"一次性的采样 + 每帧几次乘加"。 这就是烘焙的全部内容,也是为什么离线渲染器能把画面算到很干净而游戏跑得动。
为什么它有个死穴:图里记的是烤的那一刻的光。太阳一挪,真实光照就变了, 而图还停在原地 —— 于是影子留在老地方,光照与场景脱钩(判据⑤量的就是这个)。 这不是"精度不够",是信息本身过期了:这就是"烘焙换来的是改不了"。
如实标的五处:① 本段是 CPU 路径追踪器(逐像素自算),不是 WebGPU 产出; ② 本段只把地面烤进图里(真实引擎的 lightmap 会覆盖所有静态表面)—— 盒子那几列像素仍按实时算,所以三块画面里盒子是一样的、差别全在地面; ③ 图里存的是辐射度(三个通道),本页按 RGB8 折算内存开销; ④ 参考解与 4.2 同源口径(同一台实时路径追踪器、M = 256、另一套种子); ⑤ 表里的数不随画面上的滑块变(整张表按页首那套配置算一遍;判据另在固定的那套配置上评估)。

① 先看左块(查表):地面是干净的 —— 它没有采样,所以没有任何噪声。 这是烘焙最直观的好处:用一次性的代价,买来每帧的干净。

② 把「每条像素抽几条光路 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 条光路",但它把答案和"烤的那一刻"绑死了。

看什么:三块并排,用的是同一个场景、同一台相机、同一批主射线 —— 左 = 查预制图(地面查图 + 双线性插值,0 条光路)/ 中 = 实时路径追踪(每像素 M 条)/ 右 = 两者的色差(灰阶,满量程 0.25)。
为什么左块没有噪声:左块的值来自烘焙时那 64 条光路,已经求过平均了。 运行时它只做插值 —— 插值不会引入噪声。所以 M 拖到 2,右块亮成一片,左块照样干净。
为什么低分辨率会漏光:格子里的值是整格的平均。如果一格横跨了"亮处 / 影子"的边界, 它记下的就是那两边的混合值 —— 查表时这个混合值被平滑地铺开,于是亮处把光"漏"进了影子。 格子越大漏得越狠(判据⑧把跨边界的格子和格内均匀的格子分开量了)。
如实标的两处:① 右块是逐像素色差的灰阶(满量程 0.25),不区分正负; ② 右块里同时含"烘焙误差"与"实时那块的噪声"两样东西 —— 表①用参考解把前者单独量了出来。
件三 中间量数值表 · 与件一、件二同源
① 烘焙分辨率扫描:越细越准,但烤一次也越贵
NB格子数查表 vs 真值烤一次的光路数约合几帧实时图多大
② 这本账怎么算(可笔算)
项光路数次数折成"每帧"
③ 低分辨率特有的伪影:漏光(跨边界的格子 vs 格内均匀的格子)
NB跨边界格子的误差格内均匀的误差倍数
① 把 NB 从 8 扫到 64:格子数是 NB²,"查表 vs 真值"的误差随 NB 单调降; "烤一次的光路数"是 NB² × 64(一次性),末两列把它折成帧数(一帧实时 = 8640 像素 × M 条) 与内存(NB² × 3 字节,RGB8)。"约合几帧"是这道题最关键的读数: 烤一次相当于几帧的采样量,而之后每帧只要 0 条。
② 这张表把上面那笔账摊开:一次性那行是烘焙的代价,每帧那行是实时的代价 —— 两行一比就是"算一次用一辈子"的盈亏点。可以拿笔验:两次的每帧之比 = 0 : M。
③ 最后一条是分辨率的真实代价,而不是数值误差:把地面像素分成两类 —— 落进"四个角格子亮度差得大"的那些(跨边界)与其余的(格内均匀),分别算误差。 跨边界那些明显更差,而且 NB 越小差得越多 —— 这就是漏光。
两处如实标:① 误差是在步长 2 的样本网格上、与同一台实时路径追踪器的参考解比出来的; ② 只统计地面像素(本段只烤了地面)。
本节获得

这一段把"算 GI"换成了"存 GI",并把它的好处与死穴都量成了数。

  • 一次性代价换每帧 0 条光路(判据④,本段主判据):烤一次 = NB² × 64 条光路; 之后每帧只做几次乘加。按本页的配置,NB = 32 时烤一次约合 1 帧实时的采样量 —— 这就是"算一次用一辈子"的盈亏点。
  • 烘焙换来的是"改不了"(判据⑤):图里记的是烤的那一刻的光。把太阳拖走却不重烤, 误差会成倍暴涨(表里给出倍数),而影子会钉在原地 —— 光照与场景脱钩。 这不是精度问题,是信息过期:想改就得重烤,而重烤的代价就是表①那一列。
  • 分辨率不只是"细不细"(判据⑧):格子越大,跨遮挡边界的格子越容易把亮处的光 漏进影子(漏光)。实测跨边界格子的误差比格内均匀的格子大好几倍 —— 所以提高分辨率的动机,一半是为了精度,另一半是为了让格子别横跨边界。
  • 这套取舍把"采样"搬走了,没把"积分"解决:烘焙只是把同一台路径追踪器 挪到离线去跑。所以本课后面两段接着问:场景要动怎么办(5.2 探针网格), 以及为什么这里只能靠近似(5.3 硬件光追)。
5.2

DDGI:动态场景的探针网格

示意 · 等价重现

5.1 把"算 GI"换成了"存 GI",但它把答案存在表面上:一个格子对应地面上的一小块, 记的是那一小块自己收到的光。于是答案和"烤的那一刻"绑死 —— 太阳一动就得重烤,物体一动更没辙。

问题出在"存在哪"。表面上的点只能代表它自己,而光是在空间里走的。 那就换个存法:把答案存到空间中的一组格点上(探针 / probe),每隔一段距离插一根, 每根记下"我这儿四面八方的光"。着色时,一个表面点不必有属于它自己的格子 —— 它去问周围那几根探针,按距离做三线性插值。 本段只加一样东西:这套摆在空间里的探针网格(以及它的疏密 NG)。 好处是它不挂在任何表面上:物体挪了、太阳动了,把探针重算一遍就是新的答案, 而且每帧只更新其中一部分也够用(判据④给这本账、判据⑤给"能跟上")。 代价是一笔偏差 —— 光泄漏(判据⑧):探针在空间里,它看不见"表面上能看见什么", 于是它看到的光会被插值铺到本来该是暗的地方去。加采样压不掉它,因为它是方法与生俱来的错配。 保真度如实标「示意 · 等价重现」:整页仍是那台逐像素自算的 CPU 路径追踪器,件一框里那几行就是运行时真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:摆在空间里的一组探针(以及它的疏密 NG) 答案不再挂在表面上,而是挂在空间里的格点上 这一段的做法(把答案存到空间里) ① 探针:在场景空间里每隔 1/NG 摆一根,与几何无关(盒子在哪,格线都在原处)    每根存六个轴向的入射辐照度 —— 本页的面全是轴对齐的,按法线取哪一根是精确的 ② 着色:表面点不问"我的格子在哪",而是问周围最近的八根探针,按距离插值    探针不挂在表面上 ⇒ 物体动了、太阳动了,重算探针就是新答案 左列:这一段吃什么 探针疏密 NG — 探针总数 — 更新一次 — 运行时每个像素跑的就是这几行 // ★ 本段唯一改动:答案改存到空间里 function look52(P, n) { const u = P[0] * NG52 - 0.5; const v = P[1] * NG52 - 0.5; const w = zl52(P[2]); const i = Math.floor(u), j = Math.floor(v); const k = Math.floor(w); const a = u - i, b = v - j, c = w - k; return tri52(i, j, k, a, b, c, axis52(n)); } 右列:交出什么 探针那块 vs 真值 — 实时那块 vs 真值 — 盒脚一圈 vs 别处 — 三线性插值要 8 次乘加 —— 这就是"每帧的代价";而探针每帧都能重算
看什么:左边三格是这一段要吃的输入 —— 探针疏密 NG(每轴几根)、 探针总数(NG² × 层数)、更新一次的代价(探针数 × 每根几条光路)。 中间框里是运行时真正在跑的源码(函数 look52 的正文,逐字未改): 它把表面点的位置换算成探针格点坐标,取周围八根探针、按小数部分做三线性插值, 再按表面法线选一根轴。右列是它交出来的三个数:探针那块离真值多远、 实时那块离真值多远、以及盒脚一圈比别处差几倍。
为什么它"不挂在表面上":探针摆在空间里的固定格线上,与几何无关 —— 盒子挪走,格线还在原处。所以同一套探针可以给任何表面用,包括烤图时根本不存在的物体。 这就是它能跟着场景动的原因;代价是探针的位置和表面的位置对不上,看见的光也就不一样。
为什么它一定会漏:一根探针记的是它所在那一点四面八方的光。 可它要为周围一整格里的所有表面点服务 —— 而那些点被遮挡的情况可能和它完全不同。 插值把这个"位置错配"平滑地铺开:探针看得见的光,会漏到本该看不见的地方去(判据⑧量的就是这个)。 所以提高每根探针的采样数救不了它 —— 那是偏差,不是噪声。
如实标的六处:① 本段仍是 CPU 路径追踪器(逐像素自算),不是 WebGPU 产出; ② 本页把方向粗切成六个轴向存(真实 DDGI 存球谐系数,能表达任意方向)—— 本页的面全是轴对齐的,所以按法线取轴不引入误差; ③ 探针只摆两层高度(0.06 / 0.45),且只在 x–y 上按 NG 分格; ④ 探针站在空间里、没有表面去接直射阳光,所以"太阳那一份"是本页显式加进去的 (按法线投影 + 一次遮挡判断);不加它,整张网格会缺一个跟疏密无关的常数(真实实现同样要单独处理直射); ⑤ 参考解与 5.1 同源口径(同一台实时路径追踪器、M = 256、另一套种子); ⑥ 表里的数不随画面上的滑块变(整张表按页首那套配置算一遍;判据另在固定的那套配置上评估)。

① 先看左块(探针):它没有麻点 —— 每根探针的 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° —— 左块跟着变(这张网格不需要离线重烤)。 结论:把答案搬进空间,换来了"能跟着动",代价是一笔采样数压不掉的偏差。

看什么:三块并排,用的是同一个场景、同一台相机、同一批主射线 —— 左 = 探针网格查表(每个像素问周围八根探针、三线性插值,0 条光路)/ 中 = 实时路径追踪(每像素 M 条)/ 右 = 两者的色差(灰阶,满量程 0.25)。
为什么左块没有麻点:左块的值来自探针那 256 条光路,每根探针已经求过平均了。 运行时它只做插值 —— 插值不会引入颗粒噪声。所以 M 拖到 2,右块亮成一片,左块照样干净; 左块剩下的错是成片的(探针站错了地方),不是一粒一粒的。
为什么盒脚那一圈最容易错:一根探针站在它自己的位置上,看见的是它头顶那片光和那点遮挡; 而它服务的表面点可能在盒子的另一边。格子越宽、探针离表面越远,这个错配越大。 把盒子内部那几根探针(它们的半球被自己堵死、值是 0,会把盒脚整体压暗)剔掉, 能修掉其中一半;剩下那一半来自位置错配 —— 邻居探针看见的光里同样混着本该被挡掉的部分,剔不掉。
如实标的三处:① 右块是逐像素色差的灰阶(满量程 0.25),不区分正负; ② 右块里同时含"探针的偏差"与"实时那块的噪声"两样东西 —— 表①用参考解把前者单独量了出来; ③ 主射线打到天(没命中任何表面)的像素不归探针网格管,两块都直接给天色。
件三 中间量数值表 · 与件一、件二同源
① 探针疏密扫描:粗端掉得快,到了某个密度就不再降
NG探针数查表 vs 真值更新一次的光路数更新一次 ≈ 几帧内存
② 这本账怎么算(可笔算)
项光路数次数折成"每帧"
③ 方法带来的那一笔:光泄漏(盒脚一圈 vs 别处)
探针盒脚一圈的误差别处的误差倍数
① 把 NG 从 2 扫到 32:探针数是 NG² × 2,"查表 vs 真值"的误差在粗端掉得很快(2 → 8 掉了约 3 倍), 之后就不再降了 —— 因为剩下那一份不是插值误差。末三列里"更新一次的光路数"是探针数 × 256, "约合几帧实时"把它折成一帧实时的采样量(一帧 = 8640 像素 × M 条),"内存"是每根探针 6 个轴向 × 3 通道 × 4 字节。 注意倒数第二列不是每帧的开销:DDGI 是按固定预算每帧只更一批的(表②第一行), 所以真正摊到每帧的那一份与探针疏密无关 —— 这才是"能跟着动"的本钱。
② 这张表把账摊开:探针那两行是把它做完的代价(一行是每帧固定更的那一批, 一行是整张刷一遍要几帧),实时那行是每帧的代价,末行是 5.1 的 lightmap 做对照。 可以拿笔验:每帧那一批 = 64 根 × 每根条数,跟屏幕分辨率无关、跟探针疏密也无关 —— 开销是恒定的。
③ 最后一条是本段真正要付的账:把表面像素分成两类 —— 落在盒子附近一圈的, 与离盒子很远的,分别算误差。盒脚那一圈明显更差,而且加密压不下去; 把每根探针的采样数加 4 倍,那一圈几乎不动,而别处同时降了一半左右 (那是 1/√4 该有的样子):一个不掉、一个掉一半,清楚地分出了偏差与噪声。
末两行是那个开关的两边:把落在盒子内部、值为 0 的探针剔掉(权重让给邻居), 盒脚那一圈的偏差归零、误差降约四分之一 —— 但降不到"别处"那个水平, 剩下的那一半来自位置错配,采样与剔除都修不掉。
两处如实标:① 误差是在步长 2 的样本网格上、与同一台实时路径追踪器的参考解比出来的; ② 只统计命中了表面的像素(天空像素不归探针网格管)。
本节获得

这一段把答案从"表面上"搬到了"空间里",于是它第一次可以跟着场景动; 代价是一笔采样数压不掉的偏差。

  • 可以动的账(判据④):探针数 = NG² × 层数。DDGI 不是每帧全更,而是每帧固定更一批 —— 本页按每帧 64 根算,那一批就是 64 × 256 = 16,384 条光路,只占实时一帧的 0.24, 而且这个开销与探针疏密无关(代价是整张刷一遍要好几帧)。这与 5.1 的 lightmap 是两种东西: 那张图必须离线烤好、运行时一条光路都不抽,代价是改不了。
  • 能跟上(判据⑤):把太阳拖走,探针重算一遍就对了 —— 而 5.1 里同一件事会让影子钉在原地、 误差成倍暴涨。差别不在算法有多聪明,而在答案存在哪。
  • 代价是光泄漏(判据⑧,本段主判据):探针为周围一整格服务,却在它自己的位置上看光。 这个错配会把它看得见的光插值到本该看不见的地方。实测盒脚一圈的误差比别处大好几倍, 而把每根探针的采样数加 4 倍,那一圈几乎不动 —— 因为它是偏差,不是噪声。 这也把 4.2 那条结论划出了边界:1/√N 管的是方差,管不了偏差。
  • 还有一半只能靠额外机制:探针按固定格线摆,与几何无关, 所以总有一部分探针落在物体内部(本页 NG = 16 时有 22 根、占 4.3%)。 它们半球被自己堵死、值是 0,插值把这个 0 抹到盒脚上,让那里整体偏暗 (平均差 −0.042)。剔掉它们、权重让给邻居,平均差归到 0.000、误差 0.096 → 0.073 —— 但剩下的仍是别处的 1.97 倍。所以:能靠机制修掉的是落在几何里的探针, 修不掉的是位置错配本身。这又一次说明:泄漏不是参数问题,是方法自带的一笔。
5.3

硬件光追不可用:限制本身

示意 · 结构示意

5.2 把答案搬进了空间,于是它能跟着场景动 —— 但它每帧仍要花一笔固定的射线预算, 而且误差停在一条采样数压不下去的偏差上。前两段(还有 4.2)都在回答"怎么便宜", 可有一个前提一直没被点破:我们从来没问"为什么不干脆算准"。

这一课的活例全是 CPU 自算。原因一半在 API:WebGPU 不暴露硬件光追(没有 DXR / Vulkan RT 那一套管线), 真要用硬件加速,得自己在 compute shader 里建加速结构、自己写遍历 —— 那是另一个课题。 但更值得记的不是"这个 API 没有",而是就算有,也救不了这件事。把限制拆成两层: ① 硬件的限制 —— 每次查询有多贵;② 问题本身的限制 —— 要查询多少次。 渲染方程是一个递归积分,没有闭式解:要么采样(误差 ~ 1/√N,4.2 量过), 要么近似(偏差,5.1 的过期、5.2 的泄漏)。硬件光追只把 ① 乘一个常数,② 一动不动 —— 所以它换来的是帧率,不是答案。 本段只加一样东西:一次「问场景」的单价。于是"多快"与"多准"第一次被拆成两个数,可以各自读出来。 保真度如实标「示意 · 结构示意」:画面与误差仍是那台逐像素自算的 CPU 路径追踪器(等价重现), 但"硬件光追"那一档在本页只能是一个换算假设 —— 本页能实测的只有查询次数与 CPU 的耗时。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:一次「问场景」的单价(以及它换不到的东西) 单价只动"多快";"多准"由"要问多少次"决定 这一段的做法(把账算在"问了几次"上) ① 一帧的账 = 像素数 × 每像素几条 × 一条光路问场景几次 × 单价 ② 同样的时间预算 ⇒ 单价 ÷4 就买到 4 倍的条数 ⇒ 噪声只降到 1/2(√4) ③ 但 WebGPU 不暴露硬件光追 ⇒ "单价"这一个数在本页只能假设,不能实测 ④ 能实测的是另外两个:本帧真的问了几次(本页数出来)、CPU 每次花多少微秒 左列:这一段吃什么 单价 — 一条光路问几次 — 每像素几条 M — 每个像素每条光路都在跑的这几行 // ★ 本段唯一改动:每问一次场景,先记一笔账 function ask53(o, d, occ) { N53++; if (occ) N53O++; else N53W++; return hit51(o, d); } 右列:交出什么 本帧问了几次 — 本机每次多少 µs — 便宜 4 倍后噪声 — 换到"次数"这一维上的动作,才动得了误差 —— 那就是本课全部的近似
看什么:左边三格是这一段要吃的输入 —— 单价(一次"问场景"值多少钱)、 一条光路问几次(本页数出来的实测值)、每像素几条 M。 中间框里是运行时真正在跑的源码(函数 ask53 的正文,逐字未改): 它只做一件事 —— 每问一次场景先记一笔账,然后原样把问题转给求交例程。 右列是它交出来的三个数:本帧一共问了几次、本机每次花多少微秒、 以及把单价便宜 4 倍以后噪声降到多少。
为什么要把"记账"当成内容:因为这一段要讲的是"限制",而限制只有一个办法看清 —— 数出来。查询次数是本页真的执行出来的(不是估的),单价是本页真的计时量出来的; 只有"硬件光追会便宜多少"这一个数没法在这里测 —— 那正是本段标题里说的那件事。
两条限制的分别:① 每次查询多贵 —— 这是硬件的事,硬件光追就是来买这一条的; ② 要查询多少次 —— 这是问题的事:渲染方程是递归积分,没有闭式解, 要么采样(误差 ~ 1/√N),要么近似(偏差)。硬件光追只动 ①,所以它买来的是帧率。
如实标的四处:① 本段画面仍是 CPU 路径追踪器(逐像素自算),不是 WebGPU 产出; ② "单价"是换算假设,不是本页测得的硬件数据(WebGPU 不暴露硬件光追,本页也没有 DXR 可测); ③ 本页能实测的只有两个:查询次数(精确计数)与 CPU 每次查询的耗时(计时); ④ 参考解与 5.1/5.2 同源口径(同一台实时路径追踪器、M = 256、另一套种子)。

① 先看左块(本机自算):它就是 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 才是"要问多少次"这一维的地板。

看什么:三块并排,用的是同一个场景、同一台相机、同一批主射线 —— 左 = 本机自算(单价 1,每像素 M 条)/ 中 = 同样一份时间预算、单价更低时买到的条数 (每像素 M ÷ 单价 条,本页最多画到 64)/ 右 = 两者的色差(灰阶,满量程 0.25)。
为什么左块一动不动:左块用的是你设的那个 M,与单价无关; 单价只出现在"同样一笔预算能买几条"这个除法里。所以拖单价时,左块连一个比特都不变。
为什么中块只干净一倍:单价便宜 4 倍 ⇒ 买到 4 倍的样本 ⇒ 误差按 √4 = 2 倍下降。 这就是"误差 ∝ 1/√N"在硬件光追之下的样子:它把上面的常数挪了挪,把指数留在原处。
如实标的三处:① 中块不是"另一种渲染器",是同一台估计量多抽了几倍 —— 它便宜的那个前提(单价)在本页是假设;② 右块是逐像素色差的灰阶,不区分正负; ③ "本机每次多少 µs"是这一帧的实测(计时 ÷ 查询次数),会随机器与负载浮动。
件三 中间量数值表 · 与件一、件二同源
① 这一帧的查询次数怎么摊开(可笔算:上界 + 实测)
问的是什么每条光路上界实测每条本帧实测
② 同样的时间预算,单价决定你买到几条
单价买到的 M实测误差相对耗时
③ 指数没有被改:误差 × √M 仍是常数
每像素条数 M实测误差误差 × √M几倍预算
④ 本课各段与"硬件光追"的账摆在一起(前几行的数字取自各段自己的读数)
方法每帧问场景几次误差由什么决定它动的是哪一维
① 把这一帧的查询摊成两类:走(主路径求交)与问太阳挡没挡住(直接光的遮挡判断)。 两类都有可笔算的上界 —— 走最多 1 + 反弹次数 次、遮挡最多 反弹次数 次; 实测比上界小(光路一旦碰到天就提前结束、背光的面也不问遮挡)。末列是这一帧真的执行了多少次。
② 这一张是本段的核心账:同样的时间预算下,单价越低,买到的条数越多。 但误差只按 √(条数倍数) 下降 —— 单价 ÷4 只买到误差 ÷2;最后一档单价 ÷20、条数买到 20 倍, 误差也只降到 0.28 倍。末列把每一档折成"相对本机要花多久":三档都是 1.00 —— 那正是"同样一笔预算"的意思。(表只算不画,所以能算到 160 条;画面那块最多画到 64 条。)
③ 把 M 单独扫一遍:误差 × √M 基本是一条常数线(4.2 量过的那条), 本页在"有硬件光追"的前提下复量一遍,指数一模一样。末列是同样一笔预算的售价。
④ 最后一张把本课的几段摆到一起看:每一段都在"问几次"这一维上做文章 (换被积函数 / 换被采样对象 / 换时间轴 / 换粒度),而最后一行的"硬件光追"只动单价 —— 两个数一个都不改。这就是"限制本身"。
两处如实标:① 表③的误差是在步长 2 的样本网格上、与同一台实时路径追踪器的参考解比出来的; ② 表④前两行的数字取自 4.x 与 5.1/5.2 各自已上线的读数,本页只做汇总、不重算。
本节获得

这一段没有再给一个新方法,而是把整课的账翻过来看:限制有两层,硬件光追只动得了其中一层。

  • 查询次数是可数的(判据②):这一帧真的问了多少次场景,由本页精确计数, 并且能拿笔算上界(走 ≤ 1 + 反弹次数 次/条,遮挡 ≤ 反弹次数 次/条)。
  • 单价只管"多快",不管"多准"(判据④):把单价从 1 改到 1/4,同一个 M 下的画面逐位不变、 查询次数逐个不变;变的只是"同样一笔预算能买几条"。
  • 便宜 4 倍只买到噪声减半(判据⑤):同预算下 M 翻 4 倍,误差只降到 0.49 倍; 要想让噪声真正减到 1/4,得把预算乘 16 —— 这是 1/√N 没有被改掉的样子。
  • 本课全部的近似,都在"问几次"这一维上省钱(判据⑥,收束): 1.x–2.x 换被积函数(少问甚至不问)、3.x 换被采样的对象(一次反弹)、 4.x 在次数与排布上做文章(1/√N 与常数)、5.1 把积分搬出运行时(运行时 0 次)、 5.2 把答案搬进空间(每帧固定一批)。而"硬件光追"这一行只动单价 —— 两个数一个都不改。
本课三段 · 全部上线
5.3 (本课已收束) 第 5 课三段全部上线:5.1 烘焙(算一次用一辈子,代价是改不了)· 5.2 DDGI(答案搬进空间、每帧固定更一批,代价是光泄漏这笔偏差)· 5.3 硬件光追不可用(把限制本身拆成两层:每次查询多贵 / 要查询多少次)。 三段落在一起,把骨架收成一句话:本课全部的近似,都在"要问多少次"这一维上省钱。 已收束
运行时自检 等待脚本…

本段的活例由本页自己计算(示意 · 等价重现,非硬件产出):不依赖 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 不带缓存指令。