后处理链:光为什么要「溢」出来
全课骨架:特效 = 一大堆各自独立的小东西;而画完之后的图,本身还可以再当成数据算一遍。
前三课都在"画":粒子、场、软边 —— 每个像素各算各的。可真实的光学系统里,光会溢到邻域去
(镜头、眼睛、传感器都在散射),所以一团很亮的光不该只亮它自己那几格。
这一课搭一条后处理链:亮部提取 → 模糊 → 合成。它从头到尾只做一件事 ——
把亮处的光搬到邻域去,一点也不创造它;而"一点也没创造"这件事可以被量成一条恒等式:
Σ终图 = Σ原图 + (k−1)·Σ亮部。
保真度:画面走 CPU 软光栅 + CPU 后处理链(示意 · 等价重现);
读数真机可证 —— 提取恒等式、核的 Σ、总量与平均的守恒、能量闭式、还原性都是本页真算出来的数,
每条都能拿笔核。
本页的原理与数值表照常可读(画面只是把同一个数画出来)。
(脚本启动中…)
定点观察:先点「10.2 核必须归一化」,再在「5-tap 盒式」与「忘了除 5」两档之间切 → 盯住整幅画面的亮度,以及读数里的「Σbloom ÷ Σ亮部」 → 忘了除 5 的那一档整幅画面变亮、这个比值从 1.000 跳到 5.000:归一化就是守恒。
本帧的读数
(脚本启动中…)
10.1 溢出来的那部分光,是从哪拿的
本段只增加一样东西:亮部提取(take = max(0, c − T),并且决定它要不要从底图里减掉)。
前三课的画面是"一个像素算一个像素":一条很亮的发光条只亮它自己那几格。真实的光学系统不是这样 ——
镜头、眼睛、传感器都在散射,亮处的光会渗到邻域。要做这件事,先得有"可溢的那一份光":
把超过阈值的那部分拿出来(c − T,不是整块拿走 —— 整块拿走会把暗处的色调一起搬走)。
而这里藏着一个看不见的陷阱:把模糊后的亮部加上去、却不从底图里减掉,
就是在凭空发光。这两条路的差别恰好等于一个数 —— Σ亮部。
本段的「提取」开关就是这两条路:拿走(守恒的那条)/ 留着(凭空加的那条)。
| 像素(行 / 列) | 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)。
| 核 / 算例 | 权重逐项 | Σ | 脉冲响应(中段五格) | 响应之和 |
|---|
10.3 大核太贵:用金字塔拆开
本段只增加一样东西:金字塔级数(降采样 + 逐级上采样相加;核与提取沿用上一段)。
上一段证明了"归一化的核不改变总量"。可真实要的核很大:一个 41×41 的核每像素要 1681 次采样,
满屏算下来就是天文数字。而这件事有一个便宜的等价做法:先把图缩小一半,再缩一半……
在每一级上只做一个小核,然后把它们逐级放大着叠回去 —— 等效于一个很大的核,代价只随级数线性增长。
但这里有一笔必须说清的账:降采样会把总量按面积除下去(像素少了 4 倍)。
所以每一级保持的不是"总量",而是"平均亮度"。而且逐级相加时
每多一级,bloom 层的总量就多一份 —— 于是最后那一次"把总量对回去"的归一系数恰好是 1/K。
引擎里那个接近 0 的混合权重(Jimenez 说的 0.04),另一半职责就是在补这笔账。
| 级 | 分辨率 | 像素数 | Σ | 平均亮度 | Σ / Σ₀ | avg / avg₀ |
|---|
10.4 加回去多少:合成强度
本段只增加一样东西:合成强度 k(其余控件沿用上一段)。
上一段拿到了一张"溢开的亮部",可它还没回到画面上。而"加多少"正是 bloom 被滥用最多的那一个数。
本页把它写成一个能被验算的式子:out = 留下的底图 + k · bloom。
于是整条链的账可以一句话说完:Σ终图 = Σ原图 + (k − 1)·Σ亮部(提取 = 拿走档)。
k = 0 时那些光被拿走了却没还(Σ 少掉正好一个 Σ亮部);k = 1 时总量逐位回到原图 ——
光只是搬到了邻域;k > 1 才是凭空发光,多出来的正好是 (k−1)·Σ亮部。
而在「留着」档下,整个式子变成 Σ终图 = Σ原图 + k·Σ亮部:同一份光被加了两遍。
两式之差恰好是一个 Σ亮部 —— 这就是"守恒"与"凭空"的全部分别。
| 提取 | 强度 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)—— 同样用上一课那张深度图,但算的是遮蔽与反射。
本页用到的做法,出处在哪
- 亮部 → 模糊 → 加回去 —— GPU Gems 1 第 21 章(Greg James, NVIDIA;John O'Rorke, Monolith) Real-Time Glow:经典做法就是"把场景的一张模糊副本有选择地加回原图", 并明确点出模糊是这条链最贵的一步,代价与核的尺寸成正比(10.3 的立项依据): developer.nvidia.com/gpugems/gpugems/part-iv-image-processing/chapter-21-real-time-glow。 更早的一处同源写法见 GPU Gems 1 第 6 章 Fire in the "Vulcan" Demo: "created glow by selectively adding a blurred version of the scene on top of the original one"。
- 13-tap 降采样核与 3×3 tent、以及"energy preserving"那句原话 ——
Next Generation Post Processing in Call of Duty: Advanced Warfare(Jorge Jimenez, SIGGRAPH 2014)的多级链;
逐项源码与注释("to have an energy preserving downsample…
0.125*5 + 0.03125*4 + 0.0625*4 = 1"、 tent 的×1/16)见 learnopengl 的 Physically Based Bloom: learnopengl.com/Guest-Articles/2022/Phys.-Based-Bloom。 本课 10.2 的正题就是那句 "energy preserving"。 - 为什么现代做法可以不设阈值 —— 同一篇里 Jimenez 的原话: "When using PBR the dynamic range is usually very high, so you don't need to threshold. The blurred bloom layer is set to a low value (say 0.04)"; 以及它点名推荐的 Bloom disasters(阈值把画面切成"亮部"与"其余"、过量模糊洗掉对比度等四条滥用)。 ★ 本页把两条路都留着:阈值档(可手算、可对照)与"不设阈值 + 小权重"这一句如实写在正文里。