内容工厂. / 演示空间 / 全局光照 GI · 第 2 课 · 把半球装进几个数 段 1 / 3
全局光照 GI | 第 2 课 | 上一课:最便宜的一次近似

把半球装进几个数

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

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

这条骨架把 GI 收成三件事:在哪采样(探针 / 像素 / 路径)、采几次(一次反弹 / 多次反弹)、 怎么排(随机 / 分层 / 蓝噪声)。第 1 课量清了"不采样"的那一头(一次乘法、一次反弹、一屏深度); 本课回答第一件:在哪采样 —— 不在像素上采,改在场景里采:插几根探针,把半球装进几个数。

2.1
在场景里插一根"天线"
2.2
九个系数装下整个半球
2.3
探针的三种错
2.1

在场景里插一根"天线"

示意 · 等价重现

上一课把三件事各自量到了边界:1.1 把间接光压成一次乘法(常数 × 可见度)、 1.2 让它真的跳一次(要全场景几何)、1.3 又把全场景换成只有一屏深度。 可有一件事从头到尾没动过:每条线都是从像素上发出去的 —— 一个着色点 16 条线,一屏 120 × 84 就是 161280 条。

真实画面里这个数字还要乘上分辨率:一帧几百万个着色点,每个都发几十条线,算不动。 于是退一步想:为什么非得在像素上采? 光照在空间里是平滑的 —— 相邻两个像素看到的天几乎一样。那就别在每个像素上算,改在场景里插几根"天线": 一根探针(probe)就是一个固定位置上的采样点,它把收到的那半个球存下来, 像素只是去查表。 本段只加一样东西:采样点从像素搬到探针。 三件事都是量出来的:① 便宜是按"射线数"算的:探针 8 × 8 = 64 个、每个 16 条方位线 = 1024 条, 而逐像素是 161280 条 —— 便宜 157 倍(判据①);但直接光那一份省不掉: 每个像素还得自己发一条影子线,探针在这件事上帮不上忙(判据⑧)。 ② 代价是"糊":查表出来的光照是插值出来的,误差集中在明暗交界(盒子轮廓)那一圈, 而且随探针加密会收敛(判据②、③)。 ③ 探针不认表面、也不认法线:它只知道"我这个位置收到多少光"—— 这一点在高度场里还看不出来(每个 (x,y) 只有一个面),换成真的三维场景就会变成 2.3 那三种错。 保真度如实标「示意 · 等价重现」:探针值与像素值都由本页按同一条算式算出来,件一框里那几行就是件二真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:采样点从像素搬到探针 同一个 aoAt(第 1 课那一刀)—— 只是从"每个着色点都算"改成"只在几个点上算" 这一段的做法(一根探针 = 一台固定在某个位置的天线) ① 先在场景里插 PN × PN 个探针,每个只算一次 aoAt    —— 一共 PN² 次,而不是"像素数 × 方位数"次 ② 像素不再自己算:取它周围 4 个探针,按距离加权插值    射线数因此从 像素数 × K 掉到 探针数 × K 左列:这一段吃什么 探针网格(位置) — 每个探针的值 — 件二每像素跑的就是这几行 const gx = x * PN - 0.5, gy = y * PN - 0.5; const i = Math.floor(gx), j = Math.floor(gy); const fx = gx - i, fy = gy - j; const a = pv(i, j), b = pv(i + 1, j); const c = pv(i, j + 1), d = pv(i + 1, j + 1); return mix(mix(a, b, fx), mix(c, d, fx), fy);右列:换来的两个数 每帧射线数(探针 / 逐像素) — 平均差(双线 / 最近邻) — 写进像素:byte = clamp(albedo/π × (E_direct + L_amb·π·AO) × 2.2, 0, 1) × 255,与第 1 课同一份算式
看什么:左边两格是这一段要吃的输入 —— 一批位置和每个位置上算出来的一个数。 中间框里是件二真正在跑的那几行:把探针网格当成一张小图,用双线性插值读出这一点的值 (框里为排版把 51 后缀省掉了,其余逐字)。 右列是它换来的两个数:省下多少条线、以及为此多出多少误差。
为什么探针上"只算一次"就够:pv(i, j) 读的那个数,就是在探针位置上跑一次 第 1 课那个 aoAt(对每条方位求地平线角、取 cos²、再平均)。 本段没有换算法,只换了算的地方:从"每个着色点都算"改成"只在 PN² 个点上算,其余靠插值"。 所以探针值得来全不费工夫 —— 它和 1.1 的读数逐位相同(判据⑤)。
如实标的四处:① 探针网格是规则的(场景里均匀铺开),真实实现会按几何自适应地放(2.3 会讲放错的代价); ② 本段的"探针值"只有一个数(AO),方向被压掉了 —— 这正是下一段要补的东西; ③ 高度场里每个 (x,y) 只有一个面,所以"探针不知道自己插在哪"这件事在这里看不出来, 换成三维场景才会变成墙里插着一根亮探针那样的问题; ④ 每帧射线数只数间接光那一份:直接光的影子线两边都要按像素发,探针帮不上忙(判据⑧量了这条)。

① 先看左块(逐像素):它就是 1.1 那张 AO 图,每个像素自己算 —— 这是"算得最准"的那一版, 代价是 161280 条线。

② 看中块(探针查表):整体很像,但盒子轮廓那一圈"糊"了 —— 明暗交界不再是一条边, 而是一段渐变。因为像素拿到的是插值:它把旁边两个数值不同的探针混在一起。 右块(差)把这件事画出来了:亮的地方全在轮廓附近,空地上几乎是黑的(判据③)。

③ 拖「探针密度」4 → 32:轮廓那一圈越来越细,误差一路降(判据②)。 但注意降到 32×32 = 1024 个探针时,射线数已经涨到 16384 条 —— 便宜是有代价的: 探针越密越准,也越贵。这条曲线就是这一类方法的全部取舍。

④ 把「插值」从双线性切成最近邻:中块立刻变成一块一块的格子(每个探针管一片), 误差和最大差都变大,但只大 1.2 倍左右(判据④)。真正管用的还是加密: 换插值方式只买到 0.0066,加密一档买到 0.0104 —— 插值只能抹平,补不出没采到的信息。

⑤ 拖「太阳方位」:阴影转,而右块那块"差"不跟着转 —— 探针值(AO)本来就与光无关(1.1 量过),所以插值误差也是几何账。

⑥ 把「间接光强度」拖到 0:三块逐字节变得一模一样(判据⑧)—— 探针只经手间接光那一项;直接光那一份(阴影)两条路完全一样,探针省不掉它。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 三块并排

左:逐像素(自己算)| 中:探针查表 | 右:两者之差(灰阶满量程 0.25)

启动中…

与上一段相同的四个控件 + 探针的两个
用来核判据的四个开关

控件与上一段只多不少:两个滑块与两个开关逐字相同(L_amb、太阳方位、方位数、盒子数), 另加探针的两个开关:密度(探针插多密)与插值(查表怎么读)。它们加起来只是"在哪采、怎么读", 不是新概念:本段只加了一样东西,就是采样点从像素搬到了探针。

定点观察(三步):① 看中块盒子轮廓那一圈,与左块同一位置比 —— 那边糊成了渐变; ② 把探针密度从 8 拖到 32:那一圈变细、误差下降(判据②),但右列的射线数同时在涨; ③ 把插值切成最近邻:中块变成一块一块的格子,误差只大 1.2 倍 —— 结论:真正管用的是加密(换插值只买到 0.0066,加密一档买到 0.0104); 插值只能把已知的抹平,补不出没采到的信息。

看什么:三块并排,用的是同一个场景、同一个太阳、同一份算式, 只有"AO 从哪来"这一样不同 —— 左 = 每个像素自己算,中 = 从 PN² 个探针查表插值出来,右 = 两者的差(灰阶,满量程 0.25)。 左块上那些亮点是探针的位置(只是一个标记,不参与计算)。
为什么中块会糊:一块 120 × 84 的画面,只有 64 个(8×8)真正的采样点,其余全是插值出来的。 光照在空间里平滑,所以大部分地方插出来的值很准;但明暗交界不是平滑的 —— 盒子轮廓两侧的真值可以差出 0.3,插值只能给你中间那一段(表①里"两个探针中间"那一行差 −0.26 就是这么来的)。 误差因此集中在轮廓附近(判据③把这件事按梯度分了档:梯度最大那一档是平移区的 4.0 倍)。
如实标的三处:① 右边那块差是绝对量的灰阶,满量程 0.25(差到 0.25 就画满)—— 它不区分正负; ② 左块上的探针标记是注释(画在数据上的标记),它不参与任何计算; ③ 本段的探针是规则网格,不是按几何放的 —— 真实实现会自适应摆放,摆错的代价在 2.3。
件三 中间量数值表 · 与件一、件二同源
① 四个位置上的 AO:真值 vs 查表(8×8 探针,双线性)
位置逐像素(真值)探针查表差离最近探针的距离
② 密度与插值方式:平均差(探针查表 − 逐像素)
插值4×48×816×1632×32
① 四个位置各报两个 AO:探针正下方那一点(插值权重正好落在格点上,差值应当是 0)、 两个探针之间的中点、盒子轮廓附近、空地。末列给出它离最近的那个探针有多远 —— 误差与这个距离有关,而不是与"画面哪个位置"有关。表里的数是拿笔能复算的: 真值 = aoAt,查表 = 四个探针值按 fx, fy 加权。
② 同一份误差,八种配置各量一遍:行是插值方式、列是探针密度。看两件事: ① 密度每翻一倍,误差大致降三到五成(0.0630 → 0.0359 → 0.0255 → 0.0162); ② 同一密度下最近邻总是更差(8×8 时平均差 1.18 倍、最大差 1.20 倍)—— 但注意这两件事的量级差:换插值方式只买到 0.0066,而加密一档买到 0.0104(判据④)。 插值只能把已知的东西抹平,补不出没采到的信息。
两处如实标:① 表里的平均差是对整块画面逐格取平均,采样步长 2; ② 八种配置用的是同一份逐像素真值(真值只跟方位数与场景有关),所以两行四列之间是可比的。
本节获得

这一段换了采样点在哪:从"每个像素自己采"换成"在场景里插几根探针,像素去查表"。

  • 便宜是可以数出来的:1024 条线 vs 161280 条,差 157 倍(判据①)—— 而且这个比例只跟"探针数 ÷ 像素数"有关,跟场景复杂度无关。这就是探针这一类方法的全部卖点。
  • 但省下的只有间接光那一份:直接光的影子每个像素还得自己发一条线, 探针在这件事上一点忙都帮不上(判据⑧:把间接光关掉,两条路逐字节相同)。
  • 代价是"糊",而糊是有形状的:误差集中在明暗交界那一圈,空地上几乎没有(判据③: 按真值梯度分四档,最大的那档是平移区的 4.0 倍);密度每翻一倍误差降三到五成(判据②)。 ★ 而"怎么插"只值 0.0066、"加密一档"值 0.0104(判据④)—— 插值只能把已知的抹平,补不出没采到的信息,所以调参的力气应该花在间距上。
  • 下一段要收的尾:本段的探针只存了一个数(AO)—— 方向在积分里被压掉了。 一个数能做漫反射,但做不了高光:它说不出"光是从哪个方向来的"。 2.2 就用九个系数把整个半球的方向装进去。
2.2

九个系数装下整个半球

示意 · 等价重现

2.1 的探针很便宜,但它把整张环境图压成了一个数:"这一点平均收到多少光"。 方向在那次积分里被丢掉了:两个探针可以读出完全相同的数,一个背后是一盏小灯, 另一个背后是一整片天光。

一个数只能喂给漫反射 —— 它就是"总量",没有方向可用;要做高光、要把环境转起来、 要让同一个探针服务各个朝向的表面,就得把方向也存下来。 可又不能把每个方向都存一遍(那又回到 12800 个数)。办法是: 用一族固定的形状函数去逼近整张环境图,只存这些形状的系数—— 这族形状就是球面上的谐波(球谐,SH):阶数越高,形状越细。 本段只加一样东西:把探针里那个数换成几个数(球谐系数)。 四件量出来的事:① 9 个数确实装下了整张图:12800 个方向压成 L=2 的 9 个系数,压缩 1422 倍(判据①)—— 但话要说实:重建出来的那张图并不像,平均差 0.17,而这张图上的天光本身才 0.66; ② 可它真正要拿去做的那件事却算得很准:同一份 9 个系数拿去算漫反射,相对误差最大只有 1.4%(判据⑤)—— "重建得像不像"与"光算得对不对"是两件事,这是整段的骨头; ③ 它的代价压在"又小又亮"上:太阳(6° 的盘)的峰值只留下 11%,加到 L=4 也只有 15%(判据③)—— 球谐装得下"平滑",装不下一个点,这是它的适用范围,不是 bug; ④ 旋转在系数空间里做:把环境绕极轴转一个角度,系数只需做几次 2×2 旋转, 与"把图转过去再重新投影"一致到 浮点误差(判据④)—— 这是 SH 在实际工程里被选中的真正理由。 保真度如实标「示意 · 等价重现」:环境图与球谐都按定义在本页算出来,件一框里那几行就是件二真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:把探针里那个数换成几个数 不存 12800 个方向,存"这族固定形状各占多少" —— 形状是球谐,占比就是系数 这一段的做法(投影 → 几个系数 → 重建) ① 环境图:每个方向一个亮度(本页用 12800 个方向采样)    方向被"被盒子挡住"切成有锐利边界的亮暗两块 ② 投影:每个方向乘上那族形状函数再求和 ⇒ 几个系数    ③ 重建:几个系数乘回同样的形状 ⇒ 整张环境图回来了 左列:这一段吃什么 环境图(方向×亮度) — 球谐系数 — 件二每帧跑的就是这几行 for (let p = 0; p < NP52; p++) { // ① 投影 const y = shBasis52(p), v = map[p]; const w = dirs52[p * 4 + 3]; for (let m = 0; m < n; m++) c[m] += v * y[m] * w; } for (let p = 0; p < NP52; p++) // ② 重建 rec[p] = dot52(c, shBasis52(p), n); 右列:交给像素什么 重建平均差(当前阶数) — 辐照度误差(L=2 · 最大) — 着色:E(n) = Σ Â_l · c_k · Y_k(n) —— 每个着色点 9 次乘加,不需要再扫那 12800 个方向
看什么:左边两格是这一段要吃的输入 —— 一张有方向的图,和几个数。 中间框里是件二真正在跑的源码(函数 shPass52 的正文,逐字未改): 扫过整张图,把每个方向的亮度按形状函数累加到系数(投影);再用这几个系数把图乘回来(重建)。 右列是它交出来的两个数,这两个数就是整段的结论:图重建得并不准、而真正要用的那个积分很准 (上面那个"重建平均差"跟着阶数走;下面那个辐照度误差固定在 L=2,取表②四个方向里最大的那一个 —— 所以它不随阶数变,这正是判据⑤要说的那件事)。
为什么"几个数"能顶上 12800 个方向:形状函数是一族越来越细的球面波 —— L=0 是一个常数, L=1 是三个线性梯度,L=2 是五个二次形状…… 环境图里大部分内容是平滑的(天光、大片明暗),低阶就吃掉了; 只有"小、亮、边界锐利"的东西剩下来。所以球谐不是"近似得不太好",而是恰好把"平滑"那一部分装完了 —— 这也正是它重建出来的图不好看(平均差 0.17),拿去算漫反射却准到 1.4% 的原因: 那个积分(cos 核)本来也只吃得下低频。
如实标的四处:① 环境图取自本页场景的可见性(被盒子挡住的方向按 0 计)+ 一片常数天光 + 一个 6° 的太阳盘; 盒子自己的反光没有算进去(那是"一次反弹",1.2 做过)—— 本段要的是"图上有锐利边界",可见性已经给足了; ② 图上的采样是 160 × 80 的等距柱状网格(近极点会挤,但每个格子都按 cos(仰角) 加了立体角权重,投影出来是对的); ③ 系数只算到 L=4(25 个),再往上收益很小而代码会长得多; ④ 本段的"探针"只借用了 2.1 的位置概念,不涉及 2.1 的插值;默认站在空地 —— 换成"两盒之间",太阳被盒 A 挡掉,图上就没有那个亮斑了(判据③会自己挑"看得见太阳"的那个探针去量)。

① 先看左块(真值环境图):上面亮的是天,下面黑的是被盒子挡住的方向; 中间那个亮斑是太阳(只有 6° 宽)。注意它有多小、多亮 —— 记住它,它是本段的"反派"。

② 看中块(用 9 个数重建):大块的明暗、地平线的高度都对上了,但太阳不见了 —— 它被糊成了一大片淡淡的亮。右块(差)把这件事画成灰阶:太阳那一块直接画满(差到 7.8,而满量程只有 2.0), 其余地方还压着一层约 0.17 的浅灰 —— 那是九个低阶形状与"一片天光 + 一条硬地平线"之间差出来的余量。 两笔账都在,只是一笔亮得刺眼、一笔淡得几乎看不出。

③ 把「阶数」从 L=2 一路拖到 L=4:地平线慢慢变锐、平均差一路降(判据②报了每一档), 但那个亮斑几乎不动 —— 峰值保留率只从 11% 挪到 15%。 要看清这件事:它永远追不上真值 —— 那是一个 δ 形状,需要无穷阶。球谐的适用面是"平滑的东西"。

④ 把「阶数」调到 L=2 与 L=3:重建的图明显不一样(L=3 更准), 可是右边那个辐照度读数一个数字都不变(判据⑤)—— 因为 cos 那个核的奇数阶系数全是 0。 "重建得准"和"着色用得准"是两件事:我们要的从来不是把图重建出来,而是把那个积分算对。

⑤ 拖「绕极轴旋转」:整张环境图跟着转。旋转是在系数上做的(几次 2×2 旋转), 不是把图转一遍再重新投影 —— 两者结果一致到浮点误差(判据④)。这就是 SH 在工程里被选中的理由: 环境的旋转可以事后做,不用重算。

⑥ 换「探针位置」(默认是空地):移到"两盒之间" —— 地平线抬高、背后一大片被盒子挡住, 最要紧的是那个太阳亮斑整块没了(被盒 A 挡掉)。同一个场景、同一份天光, 探针站哪儿,它就存下哪儿的半球,而"存下来的半球"直接决定这几个系数长什么样。 也正因如此,"低频的代价"那一项只在看得见太阳的位置才量得出来 —— 判据③自己会挑那个位置去量。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 三块并排

左:真值环境图(方位 × 仰角)| 中:用系数重建 | 右:两者之差(灰阶满量程 2.0)

启动中…

与上一段相同的四个控件 + 球谐的两个
用来核判据的四个开关

控件与上一段只多不少:两个滑块与"盒子数"逐字相同,另加球谐的两个开关 (阶数、绕极轴旋转)与一个探针位置开关 —— 前两个是"存多细"与"怎么转", 最后一个只是"展开哪儿的半球"。不是新概念:本段只加了一样东西,就是把那个数换成几个数。

定点观察(三步):① 先找到左块里那个小亮斑(太阳), ② 把「阶数」在 L=2 与 L=4 之间来回切,盯住那个亮斑和地平线 —— 亮斑被糊成一片、边缘发毛, ③ 结论:球谐装得下"平滑",装不下"又小又亮";而把阶数从 2 调到 3, 右边那个辐照度读数一动不动(判据⑤:cos 核的奇数阶是 0)—— 说明"重建得准"和"着色用得准"根本不是同一件事。

看什么:三块并排,画的是探针那个位置看到的一整张环境图(横轴方位 360°、纵轴仰角 −90°…+90°), 三块用的是同一份真值:左 = 采样出来的原图,中 = 只用几个系数重建出来的,右 = 两者的差(灰阶满量程 2.0)。
为什么中块会"糊":球谐是一族按阶数排序的形状 —— 阶数越低越平滑。 L=2 的九个形状加起来,能描述"大片的明暗"和"平缓的渐变",但描述不了"6° 宽的小亮斑"和"一条硬边"。 这不是近似得不好,而是这族形状里根本没有那种东西;想有,就得加阶数(判据②量了每一档的代价与收益)。
如实标的两处:① 右块是绝对量的灰阶,满量程 2.0(差到 2.0 就画满)—— 太阳附近会整片画满; ② 图上"太阳"是画出来的 6° 圆盘(真实太阳约 0.5°,这里放大是为了看得见;角度越大,球谐越容易抓住它 —— 所以本页的太阳比真实的"更友好",低阶的误差比真实情况要小)。
件三 中间量数值表 · 与件一、件二同源
① 阶数越高,重建越准(同一份真值图)
阶数系数个数重建平均差最大差太阳峰值保留
② 真正要用的那个积分:辐照度 E(n)(逐方向精确积分 vs 系数)
方向精确积分L=0L=1L=2L=2 相对误差
① 同一份真值图,五档阶数各重建一遍:系数个数是 1 / 4 / 9 / 16 / 25, 误差一路下降,但降得越来越慢(第一档降得最多,后面每一档的收益都在变小)—— 这也是"低频够用"的真正含义:要装的东西大部分在低阶里。 末列是太阳那个亮斑的峰值保留率:重建出来的峰值 ÷ 真值峰值 —— 这一列只在"看得见太阳"的探针上有意义(默认的空地就是这个位置;换到"两盒之间", 太阳被盒 A 挡住,图上根本没有那个亮斑,判据③会自己挑看得见的那个探针去量)。
② 换个问法:我们真的需要把图重建出来吗? 漫反射着色要的只是那个 cos 加权积分 E(n) = ∫ L(ω) max(0, n·ω) dω。左边是逐方向精确积分(12800 个方向), 右边是用系数算的(E(n) = Σ Âl ck Yk(n),每个方向 25 次乘加以内)。 注意 L=2 那一列与 L=3 完全一样:cos 那个核的奇数阶系数全是 0(Â3 = 0), 所以漫反射只需要偶数阶,而 L=2 已经吃掉了几乎全部。 把两张表并排看就是整段的结论:① 表①里 L=2 的平均差 0.17(图不像),表②里同一个 L=2 的相对误差只有 0.1–1.4%(光很准) —— 差的不是精度,是我们问的问题。表①里"最大差"那一列可以到 7.8,比平均差大 45 倍: 那笔误差几乎全压在"太阳那几格"上,平均下来就被摊薄了。
两处如实标:① 表②里的"精确积分"也是数值积分(12800 个方向的中点求和),不是解析解; ② 两张表里的数都来自同一份采样图与同一套系数(就是件一框里那几行算出来的),互相能对得上。
本节获得

这一段把 2.1 丢掉的那一维找回来了:方向。

  • "几个数"够不够,取决于你拿它做什么:12800 个方向 → 9 个数(判据①)。 如果问的是"把图重建回来",答案是不够—— 平均差 0.17;如果问的是"拿它算漫反射", 答案是绰绰有余—— 相对误差 1.4%。它装下的恰好是环境里平滑的那一部分, 而"又小又亮"的东西(太阳、锐利边界)它永远追不上(判据③:峰值只留 11%)。
  • 它是低频的,这是它的定义不是它的缺陷:球谐的形状函数天生按平滑程度排序, 低阶=大块、高阶=细节。所以"要不要用 SH"这个问题应该改成"场景里的光是不是低频的"—— 天光、大面积反射是;点光、锐利阴影不是。
  • 最容易搞混的一件事:重建得准 ≠ 着色用得准。判据⑤量到: 把阶数从 2 加到 3,重建的图明显变准,而辐照度一个数字都没变 —— 因为 cos 那个核的奇数阶全是 0,L=2 与 L=3 算出来的辐照度逐位相同。 这才是 SH 在 GI 里真正的定位:它不需要重建环境,它直接算那个积分。
  • 旋转可以在系数上做(判据④):绕极轴转 α,系数只需要在每个 |m| 块里做一次 2×2 旋转, 与"转图再投影"一致到浮点误差(实测 4.7e-10)。所以环境转起来的时候,不用重新采一遍图—— 这一条是它当年被选进实时渲染的核心理由。
  • 下一段要收的尾:2.1 与 2.2 讲的都是"探针里存什么"。可探针还有一半问题没碰: 它插在哪儿、插错了会怎样 —— 插在墙里的探针会把墙外的光也报出来。 2.3 把这件事拆成三种错。
2.3

探针的三种错

示意 · 等价重现

2.1 与 2.2 讲的都是"探针里存什么"(一个数 → 几个系数)。可探针还有一半没碰: 它插在哪儿。前两段的探针都刚好躺在表面上 —— 每一个探针就在它服务的那个点上, 这是理想化。

真实工程里探针是插在场景里的一个规则格子上:格子是自己铺的,与几何无关; 而表面点只是借用附近探针的半球 —— 并不保证那个半球是"从它自己的位置看出去"的。 于是"探针站在哪儿"本身就成了一个自变量,三种错全从它来: ① 错位:探针不在表面上,它看到的半球就不是那个点该看到的 —— 抬高只会让它看得更多, 所以误差是单边的(只能偏亮); ② 漏光:抬高 d 会让附近每个遮挡物挡住的角度变小;而一旦探针站得比某个遮挡物还高, 那个遮挡物就从它的视野里整个消失 —— 被它挡住的那一块于是变亮。★ 但这条曲线不是台阶: 它随 d 越抬越快地涨,涨到 d = 场景里最高的那个遮挡物时彻底饱和(探针的半球里 一点几何都不剩,AO 恒等于 1);矮的遮挡物先"失效",高的后失效。这不是"越来越糊",是一笔有方向的偏差; ③ 无穷远:探针的半球里只记着"某个方向有多亮",不记那个东西离它多远 —— 所以"让表面点离探针更近"(加密探针)治不了前面两样:站错了地方,站得再密也还是错。 本段只加一样东西:让探针沿表面法线抬离表面 d。(d = 0 就是前两段的做法。) 三件量出来的事见下:判据①给"零位",判据④给"饱和平台",判据⑤给"矮的先失效",判据⑥给"加密治不了"。 保真度如实标「示意 · 等价重现」:高度场、地平线、AO 都按定义在本页算出来,件一框里那几行就是件二真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只加一样东西:让探针抬离表面 d 探针不再"刚好躺在表面上" —— 它插在场景里,格子是自己铺的,与几何无关 这一段的做法(同一个 aoAt,只是观察点换了高度) ① 表面点:每个像素在自己位置上算(与 d 无关,这是"真值")    探针:同一个 (x,y),但高度 = 该处表面高度 + d ② 像素借用附近 4 个探针的半球(双线性)⇒ d 一变它就变    抬高只让探针"看得更多" ⇒ 误差单边:只偏亮,不偏暗 左列:这一段吃什么 表面点(每个像素) — 探针网格(抬高 d) — 件二每帧跑的就是这几行 // ★ 本段唯一改动:观察点从表面抬到表面之上 d const z = hgt(p[0], p[1]) + d; let s = 0; for (const w of az53) { const c = Math.cos(horizonZ53(p, w, z)); s += c * c; } return s / az53.length; 右列:交给像素什么 平均差(探针 − 真值) — 比 d=0 变暗的格子数 — 写进像素仍是 byte = clamp(albedo/π × (E_direct + L_amb·π·AO) × 2.2, 0, 1) × 255,与第 1 课同一份算式
看什么:左边两格是这一段要吃的输入 —— 一批表面点和一张抬高了的探针网格。 中间框里是件二真正在跑的源码(函数 aoZ53 的正文,逐字未改):唯一的改动是 把观察点从表面抬到表面之上 d,然后照旧对每个方位求地平线角、取 cos² 再平均。 右列是它交出来的两个数:误差有多大、以及有没有格子比 d = 0 那一版更暗(那个数应该是 0)。
为什么抬高只会偏亮:从更高的地方看出去,附近任何遮挡物挡住的角度都只会变小 —— 可见的天只多不少,AO 只增不减。这不是"近似得不准",而是单边偏差: 探针把"它那个高度上能看到的光"发给了表面点,而表面点被墙挡得更死。
如实标的三处:① 高度场里每个 (x,y) 只有一个面,所以"沿法线抬高"在这里就等于 竖直抬高(法线处处朝上)—— 真实的三维场景里法线各向不同,本段的结论仍然成立; ② 探针网格是规则铺开的(真实实现会按几何自适应),本段量的正是"规则铺开"的代价; ③ 本段不涉及 2.2 的球谐 —— 探针里存的仍是一个数(AO),本段问的只有"它站在哪儿"。

① 先看左块与中块(d = 0):两块应该几乎一样 —— 中块就是 2.1 那一版。 判据①量的是这条"零位":d = 0 时中块与 2.1 的探针查表逐字节相同。

② 把「探针抬离表面 d」从 0 慢慢拖到 0.5:盯住盒 A 西侧那条窄带(画面上盒 A 左边的那一小条)。 它本来偏暗,越抬越亮,而且越抬涨得越快;到 d = 0.30(盒 A 的高度)时它等于整片天 —— 再往上拖,画面一个字节都不变了(判据④:d = 0.30 时 64 个探针的值全等于 1; d = 0.31 与 0.40 的画面逐位相同)。★ 顺带看一眼盒 B 旁边那片地:它在 d = 0.16 那阵子就已经漏得差不多了 (判据⑤:漏光进度 91%),而盒 A 旁边那片地到那时才 24% —— 矮的遮挡物先"失效"。

③ 看右块(差):把 d 拖大之后它整块变亮,却不会冒出"比 d = 0 那一版更暗"的格子 (右列那个读数恒为 0,判据③)。★ 这里要分清楚:右块画的是"探针 − 真值", 而真值那一栏在 d = 0 时就带着 2.1 的插值误差(两边都会偏);判据③比的是 "抬高后的那一版"对"不抬高的那一版" —— 那才是一笔干净的、只增不减的账。

④ 把「探针密度」从 8×8 一路拖到 32×32:格子间的过渡变细了、误差也降了一点, 但那条亮带还在 —— 加密只是让"借来的半球"更连续,借的位置并没有变对。 判据⑥把这件事量成一句可核的话:最密的一档下,d = 0.4 的误差仍是 d = 0 的好几倍。

⑤ 把 d 拖回 0,再拖上去:反复几次就能看出这不是"越来越糊" —— 它一直在往同一个方向偏(只偏亮),而且涨到某个高度就停了。停在哪儿由场景里最高的遮挡物决定 (这里是盒 A 的 0.30),跟探针多密、画面多大都无关。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 三块并排

左:逐像素(真值)| 中:抬离表面 d 的探针查表 | 右:两者之差(灰阶满量程 0.5)

启动中…

与上一段相同的三个控件 + 本段的两个

两个盒子的高度:盒 A 0.30 · 盒 B 0.16 —— d 拖过它们时会各有一个台阶。

用来核判据的两个开关

控件与上一段只多不少:两个滑块与"盒子数"逐字相同, 另加本段的一个新变量(探针抬离表面 d)与一个从 2.1 原样搬来的开关(探针密度,四档逐字相同)。

定点观察(三步):① 看盒 A 西侧那条窄带; ② 把 d 从 0 拖到 0.5,盯住它 —— 一过 0.30 就整块亮起来; ③ 再把密度拉到 32×32,它还是亮的。结论:漏光来自"站在哪儿",加密治不了它; 而 2.1 量到的"糊"来自"离得多远",加密治得了。这两件事必须分开。

看什么:三块并排,用的是同一个场景、同一个太阳、同一份算式, 只有"探针站在哪儿"这一样不同 —— 左 = 每个像素在自己位置上算(真值), 中 = 从一张抬高了 d 的探针网格查表插值出来,右 = 两者的差(灰阶,满量程 0.5)。
为什么抬高会漏光:地平线角是从探针的高度算的。抬高 d 之后,附近每个遮挡物挡住的角度都变小; 而当 d 超过某个遮挡物的高度时,那个遮挡物对探针来说根本不存在(它已经站在那东西的头顶上了)。 两件事叠起来就是光泄漏,DDGI 这类探针网格出了名的病: 漏光随 d 越抬越快地涨,涨到场景里最高的那个遮挡物时彻底饱和 —— 探针的半球里只剩整片天。 ★ 顺带说清一件事:它不是台阶(遮挡角是连续趋于 0 的),但这不妨碍它是"站错了地方"而不是"精度不够"。
为什么加密治不了它:加密让"表面点离最近的探针更近", 治的是"隔得远、半球不一样"这笔账(2.1 量过:密度每翻一倍误差降三到五成)。 可 d 造成的错不在距离上,在位置上 —— 每一个探针都站在错的高度上,多插几个也一样错。 所以"漏光不是参数没调好,是方法本身的缺陷"。
如实标的两处:① 右块是绝对量的灰阶、满量程 0.5,而且它只画"探针比真值亮"的那一半 (小于 0 的那一半留白)—— 但"真值更亮"的格子确实存在(那是 2.1 的插值误差,两边都会偏), 判据③量的是"抬高后对不抬高",而不是"探针对真值";② 探针标记画在左块上,是注释,不参与计算。
件三 中间量数值表 · 与件一、件二同源
① 抬高多少:d 扫描(8×8 探针,双线性)
d探针值平均增量画面平均差盒 A 西侧 漏光进度盒 B 东侧 漏光进度
② 加密治不了它:平均差(行 = d,列 = 探针密度)
d4×48×816×1632×32
① 固定 8×8 探针,把 d 从 0 扫到 0.4:第一列是探针自己的值平均涨了多少(只会涨,不会跌 —— 判据②), 第二列是画面的平均差,最后两列是两片最暗的地各自的漏光进度 (已经把"它能漏的全部"漏掉了百分之多少;上限 = 1 = 整片天)。 ★ 表里有两处值得停一下: ① d = 0.16 那一行(=盒 B 的高度):旁边是矮盒的那片地已经漏掉 91%,而旁边是高盒的那片才 24% —— 矮的遮挡物先"失效"(判据⑤); ② 最后两行一模一样(d = 0.30 与 0.40):那不是算错了,是饱和 —— d 高过场景里最高的遮挡物(盒 A 的 0.30)之后,探针的半球里再没有任何几何, 所以画面一个字节都不会再变(判据④)。 另外 d = 0 那一行不是全零:那一版的误差是 2.1 讲过的插值误差(0.0359),本段要说的是它上面多出来的那一份。
② 换个问法:多插几个探针能不能救? 行是 d、列是密度。 d = 0 那一行从 4×4 到 32×32 降了 74%(0.0630 → 0.0162)—— 因为那笔误差是"离得远"; d = 0.40 那一行就是一条水平线(0.1261 → 0.1261)—— 因为那笔误差是"站错了地方"。 "站错了"和"隔远了"是两笔不同的账,只有后者能用密度买。
两处如实标:① 表里的"平均差"是在整块画面上逐格取平均(采样步长 2),两表口径一致; ② 表格里的数不随画面上的滑块变(判据在固定的那套配置上评估:空地 / 一个不转 / 两盒 / L_amb = 0.35 / 太阳 135°),免得读者随手一拖就以为判据错了。
本节获得

这一段把探针的"下半句"说完了:前两段问它存什么,这一段问它站在哪儿。

  • 探针不是相机,它不知道该往哪看。它只存"从自己那一点看出去的半球", 谁借给它,它就把这个半球给谁 —— 而借它的那个表面点,可能在墙的另一侧、也可能被墙挡得更死。
  • 错位是单边的(判据②③):抬高只让探针看得更多,所以误差只偏亮; 右列的"变暗格子数"恒为 0。记住这个符号,它是后面认漏光的指纹。
  • 漏光是"越抬越多",但有一条平台(判据④⑤):d 一抬起来就在漏,越抬涨得越快, 涨到场景里最高的那个遮挡物(这里 0.30)时彻底饱和 —— 探针的半球里只剩整片天。 而哪一片地先漏完,由它旁边那个遮挡物的高度决定:d = 0.16 时,矮盒(0.16)旁边那片已经漏了 91%, 高盒(0.30)旁边那片才 24%。矮的先"失效",高的后失效。
  • 加密治不了"站错地方"(判据⑥):d = 0 那一行密度一翻倍就降三到五成, d = 0.4 那一行几乎是水平线。"隔得远"能用密度买,"站错了"不能。 这也是为什么工程上宁可给探针加可见性/距离信息,也不肯只堆密度。
  • 下一课要接的:三课到现在,探针存的是"从一点看出去的半球"。 可它连"光是从哪个方向来的、离得多远"都记不全 —— 那一次镜像为什么能给出精确的反射? 第 3 课从平面反射开始,把"一次反弹"拆开看。
本课已收束
2.4 (本课已收束) 第 2 课三段全部上线:2.1 在场景里插一根"天线" · 2.2 九个系数装下整个半球 · 2.3 探针的三种错。下一课「反弹一次」已收束:3.1「镜面反射:把相机搬过去就够了」、 3.2「粗糙反射:一片糊的高光」、3.3「一次反弹漏掉了什么」三段全部上线; 而再下一课 「反弹多次」也已开工(4.1「路径追踪:随机走一条光路」—— 不再预先切场景,让光路自己走)。 已收束
运行时自检 等待脚本…

本段的活例由本页自己计算(示意 · 等价重现,非硬件产出):不依赖 WebGPU,任何设备都能跑。同一页的示意图与数值表照常可读。


  

页面版本 v3(v1 = 2.1「在场景里插一根"天线"」· v2 = 2.2「九个系数装下整个半球」· v3 = 2.3「探针的三种错」;本课三段到此全部上线)。 【2.1】只加一样东西:采样点从像素搬到探针 —— 同一个 aoAt(1.1 那一刀,逐字不改), 从"每个着色点都算"改成"只在 PN² 个点上算",其余像素查表。量到:① 便宜按射线数算 (8×8 = 1024 条 vs 逐像素 161280 条,157.5 倍),而直接光那一份省不掉 (判据⑧:L_amb = 0 ⇒ 两块逐字节相同);② 误差随密度收敛(0.0630 → 0.0359 → 0.0255 → 0.0162); ③ 误差有形状(集中在明暗交界,最大档是平移区的 4.0 倍);④ 插值只能"抹平"不能"补信息" (换插值只买到 0.0066,而加密一档买到 0.0104)。 【2.2】只加一样东西:把探针里那个数换成几个数(球谐系数)。12800 个方向的环境图 → L=2 的 9 个系数 (压缩 1422 倍)。量到:① "重建得像"和"光算得对"是两件事(重建平均差 0.1714、最大差 7.7687, 而同一份系数算漫反射的相对误差只有 1.42%);② 阶数收益递减(0.3534 → 0.1253); ③ 低频的代价压在"又小又亮"上(太阳的峰值保留率 L=2 只有 11.4%); ④ 旋转在系数空间里做(与"转图再投影"最大差 4.7e-10)。 【2.3 这一段】只加一样东西:让探针沿表面法线抬离表面 d(d = 0 就是前两段的做法 —— 此时中块与 2.1 的探针查表逐字节相同)。探针里存的仍是一个数(AO),这一段问的只有"它站在哪儿": ① 错位只偏亮:抬高只会让探针"看得更多" ⇒ AO 只增不减,"比 d = 0 变暗"的格子恒为 0; ② 漏光随 d 越抬越快,且有一条平台:d 高过场景里最高的遮挡物(盒 A 的 0.30)之后, 探针的半球里再没有任何几何(64 个探针的值全等于 1),画面逐位不再变; 而矮的遮挡物先"失效" —— d = 0.16(=盒 B 的高)时,B 旁边那片地的漏光进度已 91%、A 旁边那片才 24%; ③ 无穷远 ⇒ 加密治不了:最密的一档下,d = 0.40 的误差仍是 d = 0 的好几倍 —— 那笔错不在"隔得远",在"站错了"。 判据八条:① 零位(d = 0 与 2.1 逐位 / 逐字节相同)/ ② 探针只增不减(六档逐个体检)/ ③ 画面只增不减(变暗格数 0)/ ④ 饱和平台(d = 0.30 时 64 个探针全为 1;0.31 与 0.40 逐位相同)/ ⑤ 矮的遮挡物先失效(d = 0.16 时漏光进度 91% vs 24%)/ ⑥ 加密治不了/ ⑦ 画面(写入格子数、非黑占比、非法值 0、探针标记)/ ⑧ 同源(件一框里那几行就是 aoZ53 的正文;且 horizonZ53 在 z = 表面高度时 与共享基线的 horizonA 逐位相同,aoZ53(p, 0) 与 aoAt 也逐位相同)。 ★ 与第 1 课共享同一张高度场 hgt、同一个 horizonA、同一个 aoAt、同一个 byte41/曝光 —— 四份函数一处不差,所以"探针只是换了算的地方"这句话是能验的。 ★ 如实标:① 探针网格是规则的(不是按几何自适应摆的);② 高度场是 2.5D ⇒"沿表面法线抬高"在这里就等于 竖直抬高(法线处处朝上);③ 盒子的一次反弹没有算进环境图(那是 1.2 的事)。 | 看不到新内容时请硬刷新(Ctrl+Shift+R),或给地址加 ?t=N —— 本站的 HTML 不带缓存指令。