内容工厂. / 演示空间 / 全局光照 GI · 第 1 课 · 最便宜的一次近似 段 1 / 3
全局光照 GI | 第 1 课 | 上一课:环境与输出

最便宜的一次近似

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

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

这条骨架把 GI 收成三件事:在哪采样(探针 / 像素 / 路径)、采几次(一次反弹 / 多次反弹)、 怎么排(随机 / 分层 / 蓝噪声)。本课先把"不采样"的那一头量清楚 —— 因为它是最便宜的一刀,也正好定义了后面所有采样法的靶子:这一刀错在哪,就必须在哪采样。

1.1
把一个积分压成一次乘法
1.2
让光真的跳一次
1.3
屏幕空间的边界
1.1

把一个积分压成一次乘法

示意 · 等价重现

上一课(课题三第 7 课)我们把入射光从「一盏灯」换成了一整张环境图。 可那张图画的全是「外面」—— 天、地、远山。它对这个着色点近旁的东西一无所知: 椅子挡住的、墙角里藏着的、桌腿根部那一圈,在环境图里压根不存在。

而现实里最亮的地方常常正是那些地方:墙角、缝里、物体贴着地面的那一圈。 那些位置直接光最少,那里的光是从别的物体上跳过来的 —— 这就是全局光照。 可这一项是个积分,而且它递归:要算 A 收到多少间接光,得知道 B 反射了多少;而 B 反射多少, 又取决于 B 收到的间接光…… 于是第一刀不是"算准一点",而是把递归砍掉: 假设「别处反射来的光」在整个上半球上是同一个数 Lamb。 递归一断,问题就只剩一件纯几何的事:这一点平均能看见多少天。 本段只增加一样东西:把"别处反射来的光"当成一个各方向相同的常数。三件反直觉的事: ① AO 不是阴影:它没有光源、没有方向,只认几何 —— 所以把太阳拖到任何方位,它逐位不动 (判据①),而阴影会跟着跑。这一条也是它的说明书:它答的是"挡掉了多少方向",不是"挡掉的是哪些方向"。 ② 它便宜的原因和它出错的原因是同一句话:正因为把那个积分换成了常数, 一个像素只需要一次乘法(判据③量的就是这个"乘法"的字面意思); 也正因为如此,它天生不知道邻居是什么颜色、有多亮。 ③ 换来的画面会"看起来对了,但很平":阴影被填上了、缝里暗下去了,可是每一处暗下去的理由都一样 —— 没有一处被邻居染上色。那正是 1.2 要补的东西。 保真度如实标「示意 · 等价重现」:本页的场景是程序化的高度场,AO 按下面写明的算式逐像素重现, 件一框里那几行就是件二真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只增加一样东西:把「别处反射来的光」当成一个常数 于是它只剩一个问题要答:这一点平均看得见多少天 —— 而这个问题不需要光源 这一刀的来路(递归断在哪一步) ① 别的物体反的光 = 一个积分,而且是递归的:   A 要算 B,而 B 又要算 A ② 第一刀:换成一个各方向相同的常数 L_amb ⇒ 递归断在这里 剩下的是纯几何:这一点平均能看见多少天 = AO 左列:这一步吃什么 高度场 h(x,y) — 间接光常数 L_amb — 件二每像素跑的就是这几行 let ao = 0; for (let k = 0; k < NK; k++) { const c = Math.cos(horizonA(p, az[k])); ao += c * c; // 该扇区可见的占比 } ao /= NK; // 归一化 ⇒ 平地 = 1 右列:交给像素什么 AO(只认几何) — L_ind = L_amb × AO — 写进像素:byte = clamp(L_o × 2.2, 0, 1) × 255(本课不做色调映射 —— 那是课题三 7.3 的事)
看什么:左边那两格是这一刀要吃的输入 —— 一份几何(高度场)和一个数(间接光常数)。 中间框里是件二真正在跑的那几行:对每一条方位找一个地平线角,取它的 cos²,再按方位数求平均。 右边是它交给像素的东西:一个 AO、一个 Lind = Lamb × AO。
为什么地平线角取 cos²(这一步可以直接笔算):某条方位上的地平线角是 α, 那条方位上"看得见"的仰角就是 θ ∈ (α, 90°];而余弦加权的权重是 cosθ sinθ dθ, 积出来是 (1 − sin²α)/2 = cos²α/2,除以平地那一份(1/2)正好是 cos²α。 所以仰角方向上是闭式、没有任何离散误差,唯一在离散的是方位 —— 判据⑤量的就是它。
如实标的三处:① 场景是程序化的高度场(地面 + 两个盒子),不是导入的模型; ② 画面是顶视:高度场只有朝上的那一面可见,所以盒子的四个侧面没有画出来(它们的 AO 由顶面边缘那一圈代表); ③ 这一刀把间接光当成常数,所以它没有颜色、也没有方向 —— 这不是实现偷懒,正是这一段的题目。

① 先只看左半(只直接光):地上那块黑是盒子投下的硬阴影,边界锐利、有明确方向。 拖太阳方位,阴影跟着转 —— 记住它转到哪去了。

② 再看右半(加上这一刀):阴影被填亮了,缝里和盒子根部暗下去。 这时把太阳方位从 135° 一路拖到 300°:阴影跑到另一边去了,而右半那圈暗边一步没动 (判据①报的就是这个"逐位不动")。原因很简单 —— 这一刀里根本没有光源这一项,它只认几何。

③ 把「这一刀」关掉:右半逐字节变回左半(判据②),一个字节都不差。 所以它不是"另一个渲染风格",而是往同一个算式里加了一项 —— 关掉就是那一项为 0。

④ 拖「间接光强度」:填进阴影里的亮度按比例跟着走(判据③:加倍就是精确加倍)。 注意拖到很亮时,贴墙那两处依然最暗(盒 B 根部 0.4865 ≪ 空地 0.9560)—— 因为 AO 把同一份常数按可见度分配,它不区分"这束光是从哪来的",只区分"看得见多少"。

⑤ 换方位方向数(4 → 32):盒 A 根部那一点的 AO 从 0.504 一路走到 0.569, 相对 K = 512 的参考,误差从 9.2% 降到 2.4%(判据⑤)。这个移动不是"精度不够", 而是方位方向的离散误差 —— 仰角方向是闭式的,一分误差都没有。 也请注意它到了 32 个方位还差 2.4%:只要还在用有限条方位,它就永远差一点。

⑥ 看四个点的 AO,它拉成一条链:盒 A 顶面 1.0000(上面没有东西)> 空地 0.9560 (只被远处挡掉一点)> 盒 A 根部 0.5162 > 盒 B 根部 0.4865(它同时被两个盒子挡)。 四个值、四个理由,全部只由几何决定 —— 这正是"它只认几何"的另一面。 顺便说清一件容易想反的事:贴着一堵高墙时,挡住的是"半个方位扇区",所以 AO 约等于 0.5, 而不是接近 0;真正把它压到 0.35 的是几个方位同时被挡的地方(判据⑦报出全场最暗 0.3544)。

件二 可操作活例 · 本页自算(示意 · 等价重现) · 一个程序化的高度场

左:只直接光(阴影是黑的) | 右:加上这一刀

启动中…

本段新增 · 把间接光当成一个常数
用来核判据的两个开关

五个控件里只有前两个是"调画面"的;方位方向数 / 场景 / 这一刀三个是对照组 —— 它们各自对应一条判据,把这一刀的边界一条条量出来。

定点观察(三步):① 把太阳方位从 135° 拖到 300°; ② 看右半那圈贴着盒子的暗边(不是阴影,是 AO); ③ 结论:阴影整个换了个位置,而那圈暗边一步没动 —— 因为这一刀里没有"光从哪来"这个量,它只知道"哪些方向被挡住了"。 再把「这一刀」关掉,右半会逐字节回到左半:它是一项,不是一个风格。

看什么:两块画面是同一个场景、同一个太阳、同一条算式,只差右半多加了一项 Lind = Lamb × AO。左半的阴影里是纯黑(直接光为 0), 右半把这一项填进去 —— 现实里的阴影正是被这部分光填亮的,而它便宜到只需要一次乘法。
画面上那圈"贴着盒子的暗边"是怎么来的:每个像素对 8(可换)条方位各扫一次, 找出这条方位上最高的那个遮挡角 α,取 cos²α 求平均。 这个量与环境图无关、与太阳无关、与颜色无关 —— 它只读几何。
如实标的三处:① 顶视意味着盒子的侧面没有像素,所以侧面的 AO 没有直接显示 (它体现在顶面边缘那一圈);② 高度场是 2.5D:没有悬空、没有底切(凹槽), 真实场景里这两样都会让 AO 出错,但那属于 1.2/1.3 的范围;③ 这一刀不是"加了一点环境光" —— 它加的是一份按可见度分配的常数,所以它会把缝变暗、也会把阴影填亮,两件事是同一个乘法。
件三 中间量数值表 · 与件一、件二同源
① 四个点的账 · 可以拿笔复算(K = 16,L_amb = 0.35)
点AOE_ind = L_amb·π·AO字节(有直接光)字节(阴影里)
② 方位方向数 K 的收敛(点在盒根)
KAO与 K=512 的相对差
① 每一行一个点,全部走同一条链:AO → E_ind → L_o → 字节。 笔算只需三步:E_ind = 0.35 × π × AO、L_o = albedo/π × E、 字节 = clamp(L_o × 2.2) × 255。地面反照率 0.35、盒子顶面 0.62(画面与表里是同一个值); "阴影里"那一列把直接光那一项取 0,其余不动 —— 所以它减掉的部分可以由上一列算出来。 ② K 从 4 到 32:仰角方向是闭式(cos²α),所以唯一的误差来源是方位。 末列的相对差是拿 K = 512 当参考算的 —— 它随 K 单调变小,但不会归零: 只要还在用有限条方位,AO 就不是精确的那个积分。这条"永远差一点"的性质,正是后面采样法的入口。 ①里那四个数取自 K = 16(默认档),它比"收敛值"低约 7% —— 两个表是对着同一份算式量的, 差别只在方位条数,这也正是这一段唯一在离散的那个东西。
本节获得

这一段把 GI 的第一次近似做成了一次乘法,并且量清了它的边界:

  • 递归被砍断了:间接光从一个"物体之间互相问"的积分,换成一个各方向相同的常数 Lamb。代价是它没有颜色、没有方向 —— 这是这一刀的定义,不是实现缺陷。
  • 剩下的是纯几何,而且仰角方向是闭式的:AO = (1/K)·Σcos²αk。 这个式子可以笔算、可以逐位核对(平地 α = 0 ⇒ AO = 1), 唯一的离散误差落在方位上(判据⑤)。
  • 它到底是"什么"被量出来了:拖太阳它不动(判据①)、关掉它右半逐字节回到左半(判据②)、 强度加倍就精确加倍(判据③)—— 它是一项,不是一个观感。
  • 下一段要收的尾:这一刀把"缝隙会不会暗"讲对了,却讲不出"缝隙是亮的" —— 真实房间里墙角最亮,因为它们互相照。要看见那件事,必须让光真的跳一次: 把邻居的颜色带回这个点上来。1.2 就做这一件事。
1.2

让光真的跳一次:颜色是从哪来的

示意 · 等价重现

上一段那一刀把「别处反射来的光」压成了一个各方向相同的常数: 它答对了「这里被挡住多少」,却答不出「挡在对面的东西是什么、有多亮、是什么颜色」。

最容易看出这件事的是颜色:白墙旁边那块地面偏白、红地砖旁边泛红,连阴影里都带着邻居的色。 这在上一段那一刀里结构上不可能出现 —— 那个常数是个标量,它给三个通道乘的是同一个数, 所以无论邻居是什么颜色,画面都只能是一张灰度的明暗图。 本段只增加一样东西:把那个常数换成「按方向去看」—— 从每个方向向外走,撞到谁,就把谁反射出来的光带回来。 三件反直觉的事:① 它不是「更准的 AO」:一次反弹给出的三通道可以不同比, 而 AO 那一路三通道永远同比 —— 判据①把盒 B 单独换成灰色再算一遍,量出这一条路逐位不动、那一条路色度偏移; ② 光的「来路」变成了可以数出来的一份份:某点收到的间接光能按「撞到了地面 / 盒 A / 盒 B」分解, 三份占比之和恰好是 1(判据②,件三表②); ③ 两把刀的绝对量级本来就不一样:常数那把刀的常数是任意的, 所以这一段比的是颜色与来路,不是谁更亮(判据④把「比值」铺开给人看,它不是一个常数)。 保真度如实标「示意 · 等价重现」:场景仍是上一张程序化高度场,一次反弹按下面写明的算式逐像素重现, 件一框里那几行就是件二真正在跑的。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只增加一样东西:把那个常数换成「按方向去看」 于是间接光有了颜色、也有了来路 —— 它不再是「挡住多少」,而是「对面是谁」 这一段的来路(把常数换成一趟趟带回来) ① 上一段:间接光 = 一个各方向相同的常数 × 可见度   三个通道乘同一个数 ⇒ 只有明暗,染不上色 ② 本段:从每个方向向外走,撞到谁就取谁反射出来的光   于是三通道可以不同比;而且每一份都能追到「谁给的」 左列:这一步吃什么 高度场 h(x,y) — 各面 albedo · 盒 B 红 — 件二每像素跑的就是这几行 function indirectAt42(p) { const out = [0, 0, 0], h0 = hgt(p[0], p[1]); for (let i = 0; i < N42; i++) { const d = DIR42[i], q = trace42(p, h0, d); if (q) add42(out, lout42(q), d[3]); }右列:交给像素什么 E_ind(三通道) — 色度 R−B(它证明染色) — 走空的那一趟按 0 计(出界 ⇒ 没有东西可反);写进像素仍是 clamp(L_o × 2.2) × 255,与上一段同一个曝光
看什么:左边两格是这一步要吃的输入 —— 一份几何、一份各面的反照率(盒 B 是红的)。 中间那几行是件二真正在跑的:对每个采样方向向外走一步,撞到谁就取谁反射出来的光,按立体角加权累加。 右列是它交给像素的东西:一个三通道的 E_ind,以及那个能证明"染了色"的数 R−B。
为什么会有颜色:撞到的那一点返回的是 albedo(q)/π · E_sun · max(0, n·l) · V_sun(q) —— 其中 albedo(q) 是它的反照率。红盒子把绿蓝吸收掉,只把红反出来,所以它旁边那块地面收到的就是偏红的光。 上一段那一刀没有"q"这个东西,自然没有这一维。
如实标的五处:① 场景仍是那张程序化高度场(地面 + 两个盒子),盒子的侧面在顶视画面里看不到, 但它们参与一次反弹(撞到墙面按墙的法线算);② 只算一次反弹 —— 光跳到邻居身上就停住, 邻居收到的那份只按它自己的直接光算;多次反弹是第 3 课的题目; ③ 地面在本段里只当接收者、不当反射源:太阳 → 地面 → 盒子 已经是两次反弹了(判据⑥量了这一条); ④ 采样是方位 × 仰角中点的规则网格,仰角取 cos 加权的中点,所以方位方向的离散是唯一的离散源(判据⑤); ⑤ 这一段的绝对量级与上一段不可比(上一段那个常数是任意的),比的只是颜色与来路。

① 先看三块的分工:左 = 只直接光(阴影是纯黑),中 = 上一段那一刀(阴影被灰的常数填亮), 右 = 一次反弹(阴影里是邻居的颜色)。三块用的是同一几何、同一个太阳、同一个曝光。

② 盯住盒 B 旁边那一圈地面:右块那里明显泛红,而中块是同一种灰。 原因写在件一框里:中块给三通道乘的是同一个数,它连"邻居是什么颜色"这个概念都没有。

③ 把盒 B 换成灰(件二没有这个开关 —— 判据①在内部换了一次并报出数):右块那一圈的红会消失, 而中块逐位不动。同一处改动,一条路动、一条路不动,这就是"它不是更准的 AO"。

④ 拖太阳方位:右块的染色跟着亮面一起转(被太阳照到的那一面才反光), 而中块那圈暗边一步不动(上一段已经量过这条)。

⑤ 拖方位方向数(4 → 32):右块会粗糙地变化然后稳定;中块几乎不动(它的积分简单得多)。 两者收敛速度不同这件事本身也说明:它们算的不是同一样东西。

⑥ 换成两个盒子:盒 A 与盒 B 各自挡住对方的一部分方向,件三表②把每一趟的归属列全了。 这里有一个量出来才知道的结果:两盒之间那点地面看着被两个邻居夹着,它收到的间接光却恰好是 0 —— 夹着它的两面墙都在影子里(一面背对太阳、一面被挡住),撞上了 39 条方向,没有一条反光。 "有没有邻居"和"邻居反不反光"是两件事。

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

左:只直接光 | 中:上一段那一刀(常数 × 可见度)| 右:一次反弹(本段)

启动中…

与上一段相同的四个控件 + 一档新的对照
用来核判据的三个开关

控件与上一段逐字相同(只剩下两个滑块与两个开关):上一段那个「这一刀 开/关」被并排的三块取代了 —— 对照不再需要切来切去,三块永远同时在。

定点观察(三步):① 看盒 B 旁边那一圈地面(红盒子在地图偏右上); ② 对比中块同一条位置;③ 结论:中块那一圈是灰的,右块是红的 —— 同一几何、同一太阳、同一个曝光,差别只在于"间接光有没有去问对面是谁"。 再把太阳方位拖到让盒 B 背光,那一圈的红会明显变淡:反光也要它自己先被照到。

看什么:三块并排,只有间接光那一样东西不同 —— 左块没有它、中块把它当成一个常数乘上可见度、右块让它真的跳一次。直接光那一份三块完全一样。
色从哪来:右块的间接光是三通道分别累加的(件一右列那个 E_ind), 而中块累加的是同一个标量。所以中块永远染不上色,这不是精度问题,是结构问题。
如实标的三处:① 盒 B 是红的,这是为了让你一眼看见染色;判据①会把它在内部换成灰再算一遍并用数字报出结论; ② 画面的分辨率比上一段低(三块并排,150 × 96),像素是顶视的表面采样; ③ 阴影里右块仍然比真实房间暗 —— 因为这里只跳一次,缝里那些要跳好几次才亮起来的光还没有(第 3 课)。
件三 中间量数值表 · 与件一、件二同源
① 三个点的间接光 · 可以拿笔复算(K = 16,太阳方位 135°)
点中块(常数×可见度)右块 E_ind(R/G/B)色度 R−B
② 这一点收到的间接光,是谁给的(按"撞到谁、那一点自己亮不亮"归类)
点撞上的方向 · 盒A亮面 / 盒A暗面 / 盒B亮面 / 盒B暗面其中反光的E_ind(R/G/B)
① 每一行两步:中块那一列是 L_amb·π·AO(一个标量,三通道共用); 右块那一列是 Σ L_out(q)·Δω(三通道分别累加,笔算时要对每个采样方向找一次 q)。 「色度 R−B」那一列是右块三通道里红减蓝 —— 它非 0 就说明染了色,而中块那一列永远是 0。
② 把每一趟撞到的那个 q 归成四格:盒 A 的亮面 / 盒 A 的暗面 / 盒 B 的亮面 / 盒 B 的暗面(盒 B 就是那个红盒子)。 四格相加 = 这一点撞上东西的方向数;「其中反光的」是其中真的被太阳照到的那些 —— 只有它们能带光回来。 所以后三行是撞上了、也分好了类、结果却是 0:撞到的全都是暗面。两个「暗」的原因还不一样 —— 盒 A 那一面是背对太阳(n·l < 0),盒 B 那一面是朝着太阳、却被盒 A 挡住(V_sun = 0): 一个是它自己不想接,一个是有人挡着,而画面上的结果一模一样。 第三行更能说明"颜色跟着来路走":那一点自己贴着盒 A 的暗墙(那面墙不反光),却从远处盒 B 的亮面捡到一点光, 于是它的 E_ind 只有 0.0084 / 0.0016 / 0.0016 —— 小,但 R−B 占 81%,是红的。
这一列为什么没有「顶面」:本段的接收点都在朝上的面上,而从低于某个盒子顶面的位置看不到它的顶面 —— 射线要落到顶面得先越过它的侧墙上沿,可那时它已经在盒子上面飞过去了。所以间接光只可能来自侧墙; 判据⑥把这条当几何命题逐条验过(不是"取样不够密")。
三处如实标:① 表里的数都是规则网格采样(方位 × 仰角中点)的估计,不是解析解, K 越小越粗(判据⑤量了收敛);② 中块那一列与右块那一列不可比大小(上一段那个常数是任意的), 能比的只有下面这两件事:三个通道同不同比、以及这一份是从谁那儿来的; ③ 撞到的那一点自己有没有被太阳照到,是查它脚下的位置(与画面同一张 256 × 256 的可见性表)—— 对侧墙来说这是个近似:矮墙的上沿会比它脚下更早见到太阳。
本节获得

这一段把 GI 里最容易被看见的那件事装上了:颜色会走。

  • 一次反弹只多了两样东西,但都是结构性的:三通道可以不同比(颜色), 以及每一份都能追到"谁给的"(来路)。上一段那一刀两样都没有,而且不是精度不够,是表达不了。
  • "更准的 AO"这个说法可以量着否掉:把盒 B 单独换成灰,中块逐位不动、右块色度偏移(判据①); 两把刀在场上的比值铺开也不是常数(判据④)。它们算的不是同一样东西。
  • 来路是可数的,而且"来路"决定了颜色:每一趟撞到的面都能归到 「盒 A 亮面 / 盒 A 暗面 / 盒 B 亮面 / 盒 B 暗面」四格之一(件三表②),光按"是哪个盒子给的"分成两份、占比之和恰好 1(判据②)—— 这就是后面所有 GI 方法心里那张表(谁给的光最多,就该往哪儿多采样)。 最能说明这件事的是表②第三行:那点地面自己贴着盒 A 的暗墙,却从远处盒 B 的亮面捡到一点红, E_ind 只有 0.0084 / 0.0016 / 0.0016,R−B 却占 81% —— 颜色跟着来路走,不跟着距离走。
  • 下一段要收的尾:这一段的场景是全场景已知的 —— 我们知道盒子在哪、多高、什么颜色, 所以能精确地发一条线出去找它。真实渲染里没有这份几何,只有眼前那一屏的深度。 把"全场景"换成"只有一屏",遮蔽与反光会在屏幕边缘凭空消失:1.3 就做这件事。
1.3

屏幕空间的边界

示意 · 等价重现

1.2 那一次"真的跳一次"能成立,全靠一件事:我们手上有整个场景 —— 盒子在哪、多高、哪一面是亮的,trace42 一条线出去就能精确地撞上它。 可这是本页的特权:场景是程序生成的,"全场景已知"在这里是免费的。

真实的实时渲染没有这份几何。它手上只有一样东西:眼前这一屏 —— 每个像素上"最先撞到的那个面离相机多远",一张深度缓冲。 屏幕空间的那些方法(SSAO、SSR)就是这么做的:不去问场景,只在这一屏里找遮挡。 好处是它不需要几何、也不需要加速结构;代价只有一句话:屏幕里没有的东西,它就当不存在。 本段只换一样东西:求交的依据 —— 从「全场景高度场 h(x,y)」换成「眼前这一屏的深度 z(u,v)」。 件一是这件事最省事的写法:两条路共用同一个函数,只有一行不同 —— 调哪个求地平线的函数。 量出来的是三件事(都不是靠猜的):① 屏幕外的东西不参与计算,被切在屏外的那份遮挡凭空消失 (判据①:同一个点 0.6507 → 0.8526);但这一笔有多大,全看你切掉了什么 —— 同一批格子(盒 B 西边那一小块):把这一屏放大到罩住整块场地是 0.0235、默认这一屏是 0.0411 (那 0.0176 就是"只切到空地"的代价)、把盒 B 也切在屏外立刻变成 0.1247(判据②,3.0 倍)。 ② 同一个点、同一份几何,只因为这一屏挪了位置,它的 AO 就变了 —— 1.1 那把刀是"与光无关",这一把刀是"与相机有关"(判据①里参考那一列逐位不动)。 ③ 误差只会偏亮、不会偏暗(判据④逐格验),而且它分成两笔:离散那一笔(走一步查一格、 走多远查多远)随步数与像素加细而收敛;信息缺口那一笔不收敛,加细之后反而更显眼(判据⑤、⑥)—— 屏幕空间的那些方法看上去总"发白",原因就在这两笔里。 保真度如实标「示意 · 等价重现」:本段的"这一屏"取顶视正交,缓冲里存的就是那一点的高度, 这样两条路用的是同一个算法,差别只来自信息源。

件一 含代码的数据流示意图 · 框里是件二真正在跑的那几行
本段只换一样东西:求交的依据(从整个场景换成这一屏) 两条路算法完全相同 —— 都还是那个 cos²α 的平均;只有一行不同 这一段的缺口(1.2 用的是全场景几何) ① 1.2:手上有整个高度场 h(x,y) —— 一条线出去,把每条方   位的地平线角精确算出来(对盒子解析求交,一次到位) ② 本段:手上只有这一屏的深度 z(u,v) —— 沿方位往外走,   走一步查一格;走出这一屏,就什么也查不到 左列:两条路各吃什么 全场景:h(x,y) — 这一屏:z(u,v) — 件二每像素跑的就是这几行 let s = 0; for (const w of az) { const a = ss ? ssH43(p, w) : horizonA(p, w); s += Math.cos(a) * Math.cos(a); } return s / az.length;右列:交给像素什么 AO:参考 → 屏幕空间 — 平均差:边界切盒前 / 后 — 写进像素:byte = clamp(albedo/π × (E_direct + L_amb·π·AO) × 2.2, 0, 1) × 255,与 1.1 同一份算式
看什么:左边两格是两条路各自的输入 —— 一条有整个高度场,一条只有这一屏的深度。 中间框里是件二真正在跑的那几行:对每条方位求一个地平线角,取 cos²,再按方位数求平均; 第 3 行那个三元判断就是本段的全部改动:地平线是从整个场景算,还是从这一屏查。 右列是它交给像素的东西:同一个点的两个 AO,以及把盒 B 切在屏外之后、同一批格子的平均差跳到了多少。
为什么"两条路共用一个函数"很重要:这样两边的差别不可能来自算法 —— 方位怎么取、cos² 怎么平均、写像素怎么夹取,全都一样。唯一的差别是那条方位上能查到什么。 这也是本段能拿数字说话的前提:差的每一分都必须记在"信息源"头上。
如实标的四处:① 本段的"这一屏"取顶视正交(缓冲里存的就是那一点的高度), 真实实现是透视投影;边界与离散这两条限制一模一样,但透视下还会多一条 「别的视线上的面被当成遮挡」的光晕(真实 SSAO 常见的"发白晕圈"),本段不演示那一条; ② 深度缓冲只有最前面那一层:顶视下这一层恰好就是地形本身,所以这条限制在本段看不出来 —— 但换成透视,背面的东西(被前面挡住的那些面)就没有像素,屏幕空间方法对它们一无所知; ③ 向外走到 T_max = 1.5 就算到头(本场地的对角线约 1.41,够用); ④ 走一步查一格 ⇒ 只会漏掉峰、不会造出峰,所以屏幕空间这一路只会偏亮(判据④逐格验了这一条)。

① 先看左块(全场景):它就是 1.1 那张 AO 图 —— 盒子根部暗下去、空地接近 1。 这是"信息完整"的那一版,接下来所有的锅都要它来背。

② 看中块偏右上的那一圈(紧贴盒 B):它明显比左块同一位置亮 —— 那是本段最大的一笔误差(最多 0.19)。 原因是"一步有多长":沿方位往外走一步是 1.5 ÷ 步数,步数 32 时一步约 0.047, 而盒 B 的西面离那些格子只有 0.01–0.05 —— 一步就跨过去了,那半个天被当成了空的。 把步数走密这一笔就消退(判据⑤量了它):这是精度账,不是信息账。

③ 拖「这一屏的中心 x / y」:那块有数据的矩形跟着走,中块和右块(差)一起变。 而左块一步不动 —— 同一个点、同一份几何、同一个太阳,只有"这一屏在哪"变了。 把这一屏一直拖到盒 B 露在框外:中块立刻整体变亮一大截(判据①:同一个点 0.6507 → 0.8526)—— 盒 B 挡住的那半个天,在屏幕上凭空回来了。

④ 对比一下这两笔的"性格":精度那一笔(②)把步数拉满就会消退; 而③那一笔把步数拉满也不会消退,加细之后反而更容易看见(判据⑤、⑥:同一批格子、步数 64, 盒 B 在屏内 0.0202 / 在屏外 0.1157,差 5.7 倍)。一个是格子不够细,一个是屏外那些东西根本没算 —— 件三表②把它们排在同一张表里,各自的走法一眼可辨。

⑤ 拖「方位方向数」:两条路一起变(它们共用同一张 az)—— 这一层的离散误差和 1.1 是同一条,不是本段的新东西。

⑥ 拖「太阳方位」:阴影跟着转,而右块那块"差"的形状一步不动。 屏幕空间的缺口只跟相机有关,跟光无关 —— 它和 1.1 一样,是几何账。

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

左:有整个场景 | 中:只有眼前这一屏 | 右:两者之差(×10)

启动中…

与上一段相同的四个控件 + 摆这一屏的两个滑块
用来核判据的四个开关

控件与上一段只多不少:两个滑块与两个开关逐字相同(L_amb、太阳方位、方位数、盒子数), 另加摆这一屏的两个滑块与两个精度开关(步数、像素)—— 它们加起来只是"把这一屏放在哪、多细", 不是新概念:本段只换了一样东西,就是求交的依据。

定点观察(三步):① 看中块偏右上、紧贴盒 B 的那一圈,与左块同一位置比 —— 那边更亮:那是"一步跨过去"造成的(一步约 0.047,盒 B 的西面离它只有 0.01–0.05); ② 把步数从 32 拖到 64 再拖回 8:那一圈随之变淡 / 变浓 —— 它在收敛,这是精度账; ③ 把这一屏的中心 x一直往左拖,直到盒 B 露在框外:中块整体变亮一大截, 而且把步数拉满也消不掉(判据①:同一个点 0.6507 → 0.8526)—— 这一笔是"屏外那些东西根本没算"。

看什么:三块并排,用的是同一个相机、同一个太阳、同一份场景,只有"地平线从哪来"这一样不同 —— 左 = 整个场景,中 = 这一屏的深度,右 = 两者的差(灰阶,×10 放大;屏幕外没有数据,所以右块只在矩形里有像素)。
两块会差在哪,量出来是两笔:一笔是精度 —— 中块每走一步都要问"这一格离我多远", 一步约 0.047(步数 32),而紧贴盒 B 的格子离它只有 0.01–0.05,一步就跨过去了, 所以右块上最亮的是盒子旁边那一圈(最大 0.19),把步数走密它就会消退。 另一笔是信息缺口 —— 中块只有屏幕范围内那些格子的答案,一旦走出一屏,答案就没了, 而"没查到"在算法里就等于"什么也没挡住":把盒 B 拖出这一屏,中块会整体变亮一大截,而步数拉满也消不掉它。 件三表②把这两笔排在同一张表里,各自的走法一眼可辨。
如实标的三处:① 右边那块差是放大 10 倍的灰阶,不放大在深底上看不见; ② 中块在矩形外不写像素(那是"没有数据",不是"差为 0"); ③ 本段的屏是顶视正交的一块矩形,不是透视视锥 —— 边界这一条限制两者相同,光晕那一条本段不含(见件一)。
件三 中间量数值表 · 与件一、件二同源
① 同一个世界点,两种"这一屏"下的 AO(K = 16)
点参考 AO(与这一屏无关)屏幕空间 AO差在这一屏里吗
② 两笔误差排在一起:平均差(屏幕空间 − 参考),同一批格子
这一屏步数 8步数 16步数 32步数 64
① 四个探针点各报两个 AO:参考那一列与这一屏放在哪完全无关(拖滑块,它一格都不动), 屏幕空间那一列会变、而且永远不小于参考(判据①、④)。末列写明这一点在不在这一屏的范围内 —— 不在的话屏幕空间那一路根本没有数据,表里如实写"屏外"。
② 三行量的是同一批格子(盒 B 西边那一小块地面),逐行只改一件事: ① 这一屏罩住整块场地(没有边界)、② 默认这一屏(盒 B 在屏内)、③ 把边界收回一点(盒 B 在屏外)。 竖着看:三行都随步数下降 —— 那是精度那一笔,谁都有。 横着看 ② → ③:只是把边界挪了 0.02,平均差就跳了 3 倍,而且步数越细跳得越明显 (步数 64 时是 5.7 倍)—— 那一笔是信息缺口,加细不能修,反而更藏不住。 ① → ② 那一跳就是"把这一屏收小、但只切到空地"的代价(0.0235 → 0.0411,0.0176)—— 边界这一笔有多大,全看你切掉了什么。
两处如实标:① 表②里每一格是对那一小块场地逐格取平均,采样步长 3(不是全部像素); ② 三行的步数不同、其余全同(同一批格子、同一个相机、同一份几何)。
本节获得

这一段把 GI 里最后一块"免费的东西"拿走了:几何。

  • 屏幕空间方法的全部代价只有一句话:屏幕里没有的东西,它就当不存在。 所以把盒 B 拖到屏外,它挡住的那半个天就在画面上凭空回来了(判据①:同一个点 0.6507 → 0.8526)—— 而这一笔不会随精度提高而消失:把步数从 8 加到 64,它反而更显眼(判据⑥)。 边界这一笔有多大,全看你切掉了什么:只切到空地时它值 0.0176,切到盒子立刻翻 3 倍 (判据②把这几档量在同一批格子上)。
  • 它换来的东西也是真的:不需要几何、不需要加速结构、只用一个深度缓冲, 所以在实时渲染里它几乎无处不在(SSAO、SSR、接触阴影)。记住它的名字里那个前缀 —— "屏幕空间"不是一个技术流派,是在说"我只能看见一屏"。
  • 两条路共用一个函数(件一那 6 行),只差一行 —— 这样差的每一分都必须记在信息源头上, 不能记在"算法不同"上。这是本段能拿数字说话的前提。
  • 本课到此收束:1.1 砍掉递归(常数 × 可见度)、1.2 让光真的跳一次(要全场景几何)、 1.3 把全场景换成只有一屏。三次都在回答同一个问题的不同侧面:要算清那笔间接光,你到底需要什么? 第 2 课换一个方向回答它:既然几何拿不到,就把整个半球装进几个数(探针与球谐)。
本课其余段落 · 未上线
1.4 (本课已收束) 第 1 课三段全部上线:1.1 把一个积分压成一次乘法 · 1.2 让光真的跳一次 · 1.3 屏幕空间的边界。下一课「把半球装进几个数」也已收束:2.1「在场景里插一根"天线"」· 2.2「九个系数装下整个半球」· 2.3「探针的三种错」三段全部上线。 已收束
运行时自检 等待脚本…

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


  

页面版本 v6(v6 = 第 3 段 · 1.3「屏幕空间的边界」上线 —— 本课三段全部上线。 这一段只换一样东西:求交的依据 —— 从「全场景高度场 h(x,y)」换成「眼前这一屏的深度 z(u,v)」。 两条路共用同一个 aoAt43()、只有一行不同(调 ssH43 还是 horizonA), 所以差的每一分都只能记在"信息源"头上。 ★ 量出来的东西与"想当然"不一样,两处都照实写进了页面: ① 误差不是挤在屏幕边缘的一圈亮边 —— 同一批格子上,把这一屏放大到罩住整块场地是 0.0235、 默认这一屏是 0.0411(那 0.0176 才是"只切到空地"的代价);而误差最大的是紧贴盒子的那一圈(最大 0.19), 来源是"一步跨过去"(步长 0.047 对盒 B 那面墙的 0.01–0.05); ② 把边界挪到切过盒 B,同一批格子的平均差立刻跳 3.0 倍(0.0411 → 0.1247), 而且步数越细跳得越明显(步数 64 时 5.7 倍)—— 所以"边界"不是一圈固定的亮边,它等于你切在屏外的那份遮挡。 判据八条:① 屏幕外的东西不参与(同一个点 0.6507 → 0.8526,参考那一路逐位不动)/ ② 边界这一笔全看切掉了什么(同一批格子,只挪 0.02 ⇒ ×3.0)/ ③ 屏外没有数据(中块与右块各只写 4736 格)/ ④ 只会偏亮(逐格验:最小差 0.00e+0、最大 0.1917)/ ⑤ 精度那一笔会收敛(罩住整块场地:步数 8→64 一路降; 屏幕像素 64→16 更差)/ ⑥ 信息缺口那一笔不收敛(同一批格子,步数 64 时仍高 5.7 倍,且差距随加细变大)/ ⑦ 画面(三块写入格子数、非黑占比、非法值 0)/ ⑧ 同源(件一那几行重算逐位对齐 float32; 参考那一路与 1.1 的 aoAt 逐位相同)。件二是三块并排:整个场景 / 只有这一屏 / 两者之差(×10)。 ★ 顺带补上站内的「段链联动」(滚动时更新顶栏段号与轨道高亮)—— 这一页此前没有。 v5 = 第 2 段 · 1.2「让光真的跳一次:颜色是从哪来的」上线。 件三表②与判据②、⑥ 在这一版重做过,起因是一次量出来的可疑读数: 表格第一版按「盒 A 顶面 / 盒 A 墙面 / 盒 B」分三桶,结果顶面那一桶永远是 0。 追下去发现这不是"取样不够密",而是一条几何命题:从低于某个盒子顶面的位置看不到它的顶面 —— 射线要落到顶面得先越过侧墙上沿,可越过上沿那一刻它已经在盒子上面了。所以本段的间接光只可能来自侧墙。 表②因此改成四格「盒A亮面 / 盒A暗面 / 盒B亮面 / 盒B暗面」的计数 + 光的两份占比,并把两个"0"分成了两种暗: 盒 A 那一面背对太阳(n·l < 0,它自己不想接),盒 B 那一面朝着太阳、被盒 A 整面挡住(V_sun = 0,有人挡着)—— 结果一模一样,原因完全不同。另加一行"一小片红":贴着盒 A 暗墙的那点地面从远处盒 B 的亮面捡到一点光, E_ind 只有 0.0084 / 0.0016 / 0.0016,可 R−B 占 81% —— 颜色跟着来路走,不跟着距离走。 判据②换成"两份占比之和 = 1 + 四格计数自洽 + 一个亮面都没撞到的点必须逐位为 0"; 判据⑥从"撞地面 0 条"(把分支删了之后的必然,等于没验)换成逐条验那条几何命题: 对每条"想落顶面"的方向,要求它进入底面的时刻 ≤ 升到顶面高度的时刻(即先撞侧墙),漏一条即失败, 并报出这条验法真的在开火(blocked > 0)。顺带把仰角带数 NM 从常量改成可调, 5 → 51 逐次加密复量:两个 0 一直是 0,所以它们是结构性的 0,不是"没算"。 ★ 一并纠一处错:上一版描写的 ⑥「地面按无限延伸处理、画面边缘不会凭空变暗」是错的,已删。 本段只增加一样东西:把上一段那个「各方向相同的常数」换成按方向去看 —— 从每个方向向外走,撞到谁就取谁反射出来的光。 于是间接光多出两样上一段结构上做不到的东西:颜色(三通道可以不同比)与来路(可以按"撞到谁"分解)。 件二是三块并排:只直接光 / 上一段那一刀 / 一次反弹 —— 同一几何、同一个太阳、同一个曝光。 判据八条:① 染色(把盒 B 单独换成灰再真重建一遍:中块差 0 个像素、右块差几千个像素; 盒 B 根部 E_ind 的 R−B 在灰时逐位为 0)/ ② 来路可数(光按"撞到谁"分成两份、占比之和 = 1, 归属跟着撞到的面走;四格计数相加 = 撞上的条数)/ ③ 线性(太阳强度加倍 ⇒ 间接光精确加倍)/ ④ 不是"更准的 AO"(右块÷中块逐点铺开不是一个常数)/ ⑤ 收敛(方位 4→32 对比 K=96)/ ⑥ 两条模型边界(地面只当接收者 · 盒子的顶面根本看不见,逐条验)/ ⑦ 画面(三块写入像素数、非黑占比、含色像素占比、非法值 0)/ ⑧ 同源(一次反弹那一路重算对齐 float32 逐位相同, 中块那一路与 1.1 的 aoAt 逐位相同)。 ★ 与上一段共享同一张高度场 hgt、同一个 horizonA(判断撞到那点有没有被太阳照到)、 同一个 aoAt(中块)与同一个曝光 —— 三件同源不是口号,是同一份函数在三个地方显示。 ★ 如实标:① 盒 B 默认就是红的(为了让你一眼看见染色;判据①在内部把它换成灰再算一遍并用数字报出结论); ② 只算一次反弹,多次反弹是第 3 课的题目;③ 采样是方位 × 仰角中点(默认 K × 5),所以方位条数是唯一在离散的那个量; ④ 两把刀的绝对量级不可比(上一段那个常数是任意的),比的只是颜色与来路; ⑤ 撞到的那一点自己有没有被太阳照到,是查它脚下的位置(一张 256 × 256 的可见性表)—— 对侧墙来说是近似: 矮墙的上沿会比它脚下更早见到太阳。 v3 = 把画面上的字全部移出去 —— 用户第二次报:「已逐像素写入还是遮挡效果」。 v2 只是把两行字拉开距离,可它们仍然压在画面上;而这张画布是一张铺满每一格的 AO 图, 没有留白,字一压就是压内容。现在:分屏说明放画布上方、绘制读数放画布下方, 两者都在正常流里(.hint,不再是 .pane-lbl / .status 叠加层), 画面上一个字都没有;读数顺带并成一行(绘制读数 | L_amb · K · 太阳)。 站内其它课页仍把标签叠在画面角上 —— 那些图的角上是"空天",本页没有这种地方,这是本页与站内基线的有意差异。 v2 = 修一处叠加层撞车(用户报「右边被文字挡住了」): 件二左上那行分屏说明原先 506 px 宽,在 744 px 宽的画布上与右上角读数(193 px、right:14px) 真实相交 8.4 px,把读数开头那两个字压住了。左上那行收成「左:只直接光(阴影是黑的)| 右:加上这一刀」(273 px), 两行之间留出 250 px;去掉的「同一几何、同一太阳」下面 figcaption 里本来就有。 ★ 为什么门禁没抓住:check-overlay.mjs 把由 JS 写的那一格建模成 0 宽 (本页 module 在它的 DOM 打桩里要 getContext('2d'),跑不到写标签那一步)⇒ 算出来"不重叠"。 本轮给该门禁补了两条:① 那一格的代表串(SAMPLE 表里原先没有本页), ② 一条间距余量哨兵(同一行两个标签间距 <12 px 记 ⚠,因为它的字宽是估算的,不能信"刚好不重叠")。 负例自测:把旧的长标签放回去,门禁从「0 问题」变成「4 处硬失败」;换回来是 0。 同批还把右下角状态条收短(74 px 高 → 40 px,从 4 行变 2 行):它本来就压着画面右下角, 信息写长一倍就多挡一倍画面 —— 只留门禁要的那两句,其余从 62 字压到 44 字。 v1 = 课题四「全局光照 GI」第 1 课 · 第 1 段 · 1.1「把一个积分压成一次乘法」上线 —— 课题四开工。 这一段把 GI 的第一次近似讲成一次乘法:把"别处反射来的光"换成一个各方向相同的常数 L_amb, 递归就断在这一步;剩下的全是纯几何 —— 这一点平均能看见多少天。地平线角取 cos²α 是闭式 (∫α90°cosθ sinθ dθ ÷ ∫090°cosθ sinθ dθ = cos²α), 所以仰角方向一分离散误差都没有,唯一在离散的是方位。 判据八条:与光无关(太阳方位 0° → 300°,AO 场逐位不动,最大差 0)/ 可关(关掉这一刀 ⇒ 画面逐字节等于只直接光)/ 乘法的账(L_amb 加倍 ⇒ 间接项精确加倍)/ 归一化(平地 AO 逐位 = 1)/ 收敛(K = 4/8/16/32 单调逼近 K=512 的参考)/ 单调(盒高 0.10 → 0.40,盒根的 AO 单调不增)/ 画面(写入像素数、非背景占比、非法值 0)/ 同源(件一读数、件三两张表、件二两块画面走的是同一个 aoAt())。 ★ 与课题三 7.2 的关系如实写明:7.2 已经逐位推过 AO 的公式(那里它是环境积分的可见性项 V(ω))—— 本段不复推,换的是问法:从"环境积分的可见性"换成"GI 的第一次近似"。 只读本课题也能懂;读过 7.2 的读者可以直接看本段的落点(这一刀结构上做不到 color bleeding)。 ★ 如实标的四点:① 场景是程序化的高度场(地面 + 两个盒子),不是导入模型; ② 顶视 ⇒ 盒子侧面没有像素;③ 高度场是 2.5D,没有悬空与底切; ④ 本页不做色调映射(那是课题三 7.3),写像素前只有一次固定的曝光与夹取,并在件一末尾写明。 | 看不到新内容时请硬刷新(Ctrl+Shift+R),或给地址加 ?t=N —— 本站的 HTML 不带缓存指令。