← 图形原理 · 交互课 课题七 · 特效 · 第 4 课

后处理链:光为什么要「溢」出来

全课骨架:特效 = 一大堆各自独立的小东西;而画完之后的图,本身还可以再当成数据算一遍。 前三课都在"画":粒子、场、软边 —— 每个像素各算各的。可真实的光学系统里,光会溢到邻域去 (镜头、眼睛、传感器都在散射),所以一团很亮的光不该只亮它自己那几格。 这一课搭一条后处理链:亮部提取 → 模糊 → 合成。它从头到尾只做一件事 —— 把亮处的光搬到邻域去,一点也不创造它;而"一点也没创造"这件事可以被量成一条恒等式: Σ终图 = Σ原图 + (k−1)·Σ亮部。 保真度:画面走 CPU 软光栅 + CPU 后处理链(示意 · 等价重现); 读数真机可证 —— 提取恒等式、核的 Σ、总量与平均的守恒、能量闭式、还原性都是本页真算出来的数, 每条都能拿笔核。

分段:
1.0 | 提取:
核: | 5 | 1.00
显示: | (链按需重算)

(脚本启动中…)

定点观察:先点「10.2 核必须归一化」,再在「5-tap 盒式」与「忘了除 5」两档之间切 → 盯住整幅画面的亮度,以及读数里的「Σbloom ÷ Σ亮部」 → 忘了除 5 的那一档整幅画面变亮、这个比值从 1.000 跳到 5.000:归一化就是守恒。

本帧的读数

(脚本启动中…)

10.1 溢出来的那部分光,是从哪拿的

本段只增加一样东西:亮部提取(take = max(0, c − T),并且决定它要不要从底图里减掉)。
前三课的画面是"一个像素算一个像素":一条很亮的发光条只亮它自己那几格。真实的光学系统不是这样 —— 镜头、眼睛、传感器都在散射,亮处的光会渗到邻域。要做这件事,先得有"可溢的那一份光": 把超过阈值的那部分拿出来(c − T,不是整块拿走 —— 整块拿走会把暗处的色调一起搬走)。
而这里藏着一个看不见的陷阱:把模糊后的亮部加上去、却不从底图里减掉, 就是在凭空发光。这两条路的差别恰好等于一个数 —— Σ亮部。 本段的「提取」开关就是这两条路:拿走(守恒的那条)/ 留着(凭空加的那条)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 底图(HDR 线性) 发光条 12 亮窗 6 · 灯 2.5 / 0.7 阈值 T = 1.0 拿走 = c − T(只拿 超过阈值的那一份) ★ 本段只加"提取" // 亮部提取:只拿"超过阈值"的那一份 const take = Math.max(0, c - G.T); BRIGHT[i] = take; // ★ 底下这一行是"守恒 / 凭空"的分水岭: REST[i] = G.take ? c - take : c; ★ 拿走 ⇒ 留下 + 拿走 ≡ c(逐像素恒等) 留着 ⇒ 同一份光最后会被加两遍 (Σ 上多出来的正好是一个 Σ亮部) 五个像素: c → 拿走 + 留下 6.00 → 5.00 + 1.00 1.00 → 0.00 + 1.00 0.30 → 0.00 + 0.30 (每行相加必须 = c) 见表①,逐像素可核
定点观察:拖「阈值 T」从 0.5 拉到 4 → 看发光条周围那一圈光晕 (不是发光条本身)→ 阈值决定的是"谁有资格溢":T 抬高,先是不亮的灯退出、然后是亮窗、最后只剩发光条; 而光晕的强弱跟着"拿走"的那一份走。 ★ 再把「提取」在拿走 / 留着之间切一次:画面看起来只是"亮一点",可读数里的 「Σ终图 − Σ原图」会从 0 变成 +Σ亮部 —— 那就是"凭空发光"的量(判据⑦ 盯着这条)。
表① 亮部提取是逐像素恒等式(下表以 R 通道为例,三个通道同理):取页面里五个亮度不同的像素, 列出 c、拿走 max(0, c−T)、留下,以及两者之和 —— 最后一列必须逐行等于 c。 ★ 这一列是本段唯一要验的东西:它保证"拿走的那份光有出处",后面才谈得上"守恒"
像素(行 / 列)c(原值)T拿走 = max(0, c−T)留下拿走 + 留下

10.2 溢开的核必须归一化

本段只增加一样东西:核的归一化(Σw = 1;其余控件沿用上一段)。
上一段手里有了一张"亮部图"。要让它溢开就得模糊;而模糊有一个最低要求: 别改变总量。正常人都不会"忘了除 N"…… 可在这条链上它是最常见的一种错, 因为它不会让画面报错,只会让整幅画面悄悄变亮(或者变暗)。
归一化就是守恒:Σw = 1 这一个条件,把这条链的账锁死了。 业界那份被抄得最多的核(COD:AW 的 13-tap 降采样)在源码注释里就明写着 "to have an energy preserving downsample … 0.125*5 + 0.03125*4 + 0.0625*4 = 1"; 上采样用的 3×3 tent 也是 (1×4 + 2×4 + 4)/16 = 1。 本段提供三个档:5-tap 盒式(Σ = 1)、忘了除 5(Σ = 5)与 COD 13-tap(Σ = 1)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 亮部图(上一段) 核:5-tap 盒式 每格 1/5 = 0.2 对照档:忘了除 5 每格 1 ⇒ Σ = 5 归一化 = 守恒 ★ 本段只加"归一化" // 5-tap 盒式(可分离,这里按一维写) const W_BOX = [0.2, 0.2, 0.2, 0.2, 0.2]; // Σ = 1 const W_RAW = [1, 1, 1, 1, 1]; // Σ = 5 ★ 忘了除 N // COD:AW 的 13-tap 降采样(原文注释:energy preserving) d = e*0.125 + (a+c+g+i)*0.03125; d += (b+d+f+h)*0.0625 + (j+k+l+m)*0.125; // 0.125 + 4*0.03125 + 4*0.0625 + 4*0.125 = 1 // 上采样用 3×3 tent:Σ = (1*4 + 2*4 + 4)/16 = 1 四个核的 Σ: 5-tap 盒式 1.000000 忘了除 5 → 5.000000 COD 13-tap 1.000000 3×3 tent 1.000000 脉冲算例(正中间五格): 盒式 0.2 ×5(Σ = 1.0000) 忘了除 1.0 ×5(Σ = 5.0000)
定点观察:在「5-tap 盒式」与「忘了除 5」之间切 → 看整幅画面的亮度 与读数里的「Σbloom ÷ Σ亮部」→ 归一化那一档恒等于 1.000000,忘了除的那一档恰好是 5.000000 —— 因为只差一个常数因子,画面看上去只是"整体亮了一点",所以这类错工程上极容易漏过去。 ★ 表② 把四个核的权重逐项摊开,Σ 那一列都能拿笔加出来:脉冲放正中间时五格各 0.2(和 = 1), 同一颗脉冲贴到左边界上,和就变成 1.2 —— 归一化管的是"摊平的权重之和为 1", 边界外那些权重不会消失,而是被夹取重复采回边界那一格。
表② 四个核的权重逐项 + Σ(每一行都能拿笔加):归一化的三行 Σ 恰为 1,忘了除 N 的那一行是 5。 下半段是一个 1D 脉冲的响应(脉冲放正中间,五个抽头全落在内部,只列摊开的那五格): 归一化时五格各 0.2、和为 1;忘了除时五格各 1.0、和为 5 —— 这就是"总量 ×5"的可手算版本。 ★ 最后一行是同一颗脉冲贴着左边界放:Σ 变成 1.2 —— 归一化只保证"摊平的权重之和为 1", 出界的那些权重不会丢,而是被夹取重复采回边界那一格(画面边缘因此会偏亮一点)
核 / 算例权重逐项Σ脉冲响应(中段五格)响应之和

10.3 大核太贵:用金字塔拆开

本段只增加一样东西:金字塔级数(降采样 + 逐级上采样相加;核与提取沿用上一段)。
上一段证明了"归一化的核不改变总量"。可真实要的核很大:一个 41×41 的核每像素要 1681 次采样, 满屏算下来就是天文数字。而这件事有一个便宜的等价做法:先把图缩小一半,再缩一半…… 在每一级上只做一个小核,然后把它们逐级放大着叠回去 —— 等效于一个很大的核,代价只随级数线性增长。
但这里有一笔必须说清的账:降采样会把总量按面积除下去(像素少了 4 倍)。 所以每一级保持的不是"总量",而是"平均亮度"。而且逐级相加时 每多一级,bloom 层的总量就多一份 —— 于是最后那一次"把总量对回去"的归一系数恰好是 1/K。 引擎里那个接近 0 的混合权重(Jimenez 说的 0.04),另一半职责就是在补这笔账。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 亮部图(上一段) 级数 K = 5 每级 ÷2:480→30 降采样 13-tap(Σ = 1) 上采样 3×3 tent(Σ = 1) 每级保住的是"平均" ★ 本段只加级数 // ① 降采样链:每级分辨率减半,核仍归一 mip[0] = down(BRIGHT, 480, 300, G.kernel); for (let l = 1; l < K; l++) mip[l] = down(mip[l-1], G.kernel); // ② 逐级上采样相加(tent,Σ = 1) for (let l = K - 1; l > 0; l--) addTent(mip[l-1], mip[l]); ★ 降采样把像素数除了 4 ⇒ 总额按 1/4 掉 ★ 每多一级 ⇒ bloom 层总量多一份 ⇒ 最后那次归一系数 = 1 / K(本页实测 K = 5) 各级(480 起): Σ 按 1/4ˡ 掉 平均 = 常数 叠回去之后: Σbloom = K · Σ亮部 ⇒ 除以 K 才对得回去 (见表③ 逐级可核)
定点观察:把「级数」从 1 拉到 5 → 看光晕的半径(不是亮度)→ 级数越大,等效核越大:光晕一圈圈铺开,而画面的总亮度几乎不动 —— 因为每级都归一过、 最后又把账按 1/K 对了回去。读数里的「Σbloom ÷ Σ亮部(未归一)」会从 1.00 走到 5.00: K = 5 时本页实测 5.0029,多出来的 0.06% 全在最粗那两级的边界上。 ★ 表③ 把每级的分辨率、Σ、平均列在一起:Σ 每级掉 4 倍(实测吻合到 5e−9),平均一动不动 —— 这两列一起看才明白"降采样保的是平均"(后两级平均虚高 1.35% / 4.17%:75→37、37→18 的像素数 不是正好一半,多出来那行被夹取重复采了)。
表③ 金字塔逐级:分辨率每级减半 ⇒ 像素数 ÷4 ⇒ Σ 按 1/4ˡ 掉,而 平均亮度一动不动。 ★ 表里两列请对照着读:把"总量掉了"当成"能量丢了"是这条链上最常见的误读 —— 总量掉的只是"像素少了",平均(也就是每个像素的典型亮度)才是守恒的那个量。 ★ 后两级的平均会略高一点(1.35%、4.17%),那是因为 75→37、37→18 的像素数不是正好 1/4、 多出来那行被夹取重复采了 —— 表里照实测报,不修饰。 最后一行是叠回去之后的 bloom 层:Σ = K · Σ亮部,所以归一系数恰好是 1/K
级分辨率像素数Σ平均亮度Σ / Σ₀avg / avg₀

10.4 加回去多少:合成强度

本段只增加一样东西:合成强度 k(其余控件沿用上一段)。
上一段拿到了一张"溢开的亮部",可它还没回到画面上。而"加多少"正是 bloom 被滥用最多的那一个数。 本页把它写成一个能被验算的式子:out = 留下的底图 + k · bloom。
于是整条链的账可以一句话说完:Σ终图 = Σ原图 + (k − 1)·Σ亮部(提取 = 拿走档)。 k = 0 时那些光被拿走了却没还(Σ 少掉正好一个 Σ亮部);k = 1 时总量逐位回到原图 —— 光只是搬到了邻域;k > 1 才是凭空发光,多出来的正好是 (k−1)·Σ亮部。
而在「留着」档下,整个式子变成 Σ终图 = Σ原图 + k·Σ亮部:同一份光被加了两遍。 两式之差恰好是一个 Σ亮部 —— 这就是"守恒"与"凭空"的全部分别。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 底图(已拿走) bloom 层(已叠) 强度 k = 1.00 放回的正好是 拿走的那一份 ★ 本段只加 k // 先把 bloom 层的总量对回"拿走的那一份" const s = sumBright / sumBloom; // 金字塔 ⇒ 1/K // 再按强度合成:k = 0 只留底图、k = 1 恰好还清 OUT[i] = REST[i] + G.k * BLOOM[i] * s; ★ 提取 = 拿走:Σ终图 = Σ原图 + (k − 1)·Σ亮部 ★ 提取 = 留着:Σ终图 = Σ原图 + k·Σ亮部 ★ 两式之差 = 一个 Σ亮部(凭空发光的全部) (判据⑦ 把这两条闭式都钉住) Σ终图 − Σ原图: 留着 k=0 → 0 留着 k=1 → +Σ亮部 拿走 k=0 → −Σ亮部 拿走 k=1 → 0 k=1 档:光只是 搬到了邻域 (见表④ 四行)
定点观察:在「提取 = 拿走」档下拖「强度 k」0 → 1 → 1.5 → 盯住读数里的「Σ终图 − Σ原图」与「与闭式的差」→ k=0 时画面比原图暗、差是 −Σ亮部(那些光被你拿走了却没还); k=1 时这一栏回到 0(逐位);k=1.5 才是凭空发光,多出来的正好是 (k−1)·Σ亮部。 ★ 再把「提取」切到「留着」:同一个 k 下多出来的量正好多一个 Σ亮部 —— 两式之差就是"守恒 / 凭空"的全部分别。
表④ 四个组合(提取 × 强度 k)的能量账:第 3 列是页面实测的 Σ终图 − Σ原图,第 4 列是闭式, 第 5 列是两者的差。★ 这一列的容差只能按量的量级给(判据⑦ 取 1e−8·(Σ原图 + Σ亮部)): 两边都是 170 万项的浮点累加,绝对误差 ~1e−4 是躲不掉的,而 k=1 那一格的闭式恰好是 0 —— 拿"相对误差"去比一个真值为 0 的数,再好的实现也会被判不通过。★ 最值得看的是第三行(拿走 + k=0): 它跑的是最省事的写法,看上去只是"画面暗了一点",可账上少掉的正好是一个 Σ亮部 —— 而"少掉的那份光"就是后来被加回去的那一份
提取强度 kΣ终图 − Σ原图(实测)闭式与闭式之差逐位等于原图?

这一课的位置

四段只做了一件事:把画完的图当成数据,再算一遍 —— 而算的全部内容是"把亮处的光搬到邻域"。 提取、核、金字塔、合成四步里,每一步都有一条能被拿笔核的账;把它们串起来就是那条恒等式 Σ终图 = Σ原图 + (k−1)·Σ亮部。
本页那一格"如实说明":这条链是 CPU 逐像素跑的(示意 · 等价重现), 真机上它在 compute / 全屏三角里跑、通常还是半分辨率;本页的 mip 从 480×300 起(相当于引擎里的半分辨率 bloom)。 降采样按真机那样走双线性读取:目的像素中心正好落在源的两个纹素之间,所以每个抽头是两个纹素各取 0.5 (13 抽头 ⇒ 每次 52 个纹素)。这一条是本课自己踩出来的:第一版写成了最近邻, 于是"每级总量除以 4"这件事被打折 —— 实测每级只剩 0.20 而不是 0.25、平均反而逐级掉两成; 换回双线性后 1/4ˡ 与平均守恒都回到 1e−9 量级。★ 这也说明判据要量在对的那个缓冲上: 判据⑤ 的第一版是在逐级累加之后去量各级的,量到的其实是"第 l 级 + 更粗各级铺上来的一摞"(总量 (K−l) 倍), 于是量出 0.2 / 0.1875 / 0.1667 / 0.125 这一串看着像"守恒坏了"的数。 另外:所有判据都在 HDR 线性值上算,显示前才过一条 Reinhard 色调映射(否则 >1 的部分全被钳成白色,看不出差别)。
本课题还剩一课:屏幕空间(SSAO 与 SSR)—— 同样用上一课那张深度图,但算的是遮蔽与反射。

本页用到的做法,出处在哪

运行时自检 等待脚本…