10.2 Bloom 与泛光
站内 /graphics-post-processing/ 那篇把 Bloom 列进后处理链的第三格;本节把它拆开,看看这一格内部到底是什么。直觉上的说法是「把亮的地方糊开」,但这个说法没法实现——糊多大范围?糊完怎么叠回去?叠的时候会不会把整帧提亮?
把它写成可验收的四步,每一步都是整数运算:
- 亮部提取:亮度用 Rec.601 整数权重
lum = (77R + 150G + 29B) >> 8,阈值比较也在整数上做,保留lum > threshold的像素、其余置零。 - 降采样链:每级尺寸减半(奇数边复制末行末列),2×2 盒式平均即
和 >> 2。 - 上采样合并:每级用整数双线性放回全分辨率,按 Q16.16 权重
w_i相加,Σ w_i恰为ONE。 - 合成:
final = clamp(original + intensity * bloom)。
第 3 步的权重和是整节的关键。它和第 10.1 节的核权重和是同一个坑:只要 Σ w_i ≠ ONE,泛光层整体就偏亮或偏暗,而且叠回原帧后这个偏差会被误读成「泛光强度」。
实现
四个函数分别对应上面四步,全部走 numpy 的 int64,没有一次浮点:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_10_2_bloom.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_10_2_bloom.py
"""10.2 Bloom 与泛光:亮部提取 -> 降采样链 -> 上采样合并 -> 与原始帧相加。
全整数实现:
1. **亮部提取**:亮度用 Rec.601 整数权重 lum = (77R + 150G + 29B) >> 8,阈值比较在
整数上进行,保留 lum > threshold 的像素,其余置零。
2. **降采样链**:每级尺寸 ceil(一半),2x2 盒式平均(和 >> 2),奇数边复制末行/末列。
3. **上采样合并**:每级用整数双线性放回全分辨率,按 Q16.16 权重 w_i 相加,
**Σ w_i 恰为 ONE**——否则泛光整体偏亮或偏暗。
4. **合成**:final = clamp(original + intensity * bloom)。
扫描:级数 2/4/6(光晕半径与耗时)、阈值 {64,96,128}、强度 {0.25,0.5,1.0,2.0}。
产物:out/cg_shade_10_2_bloom.rgb(级数 4、阈值 96、强度 1.0 的金标准)、
out/cg_shade_10_2_bloom.json。
"""
import hashlib
import json
import os
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ONE = cglib.ONE
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
THRESHOLDS = [64, 96, 128]
INTENSITIES = [0.25, 0.5, 1.0, 2.0]
def luminance(img):
"""Rec.601 整数亮度:(77R + 150G + 29B) >> 8。"""
return (77 * img[:, :, 0] + 150 * img[:, :, 1] + 29 * img[:, :, 2]) >> 8
def bright_pass(img, threshold):
"""保留 lum > threshold 的像素,其余置零(整数阈值比较)。"""
mask = luminance(img) > threshold
out = np.zeros_like(img)
out[mask] = img[mask]
return out, int(mask.sum())
def downsample_box(img):
"""2x2 盒式平均(和 >> 2);奇数边复制末行/末列。"""
if img.shape[0] % 2:
img = np.concatenate([img, img[-1:]], axis=0)
if img.shape[1] % 2:
img = np.concatenate([img, img[:, -1:]], axis=1)
s = img[0::2, 0::2] + img[0::2, 1::2] + img[1::2, 0::2] + img[1::2, 1::2]
return s >> 2
def _axis_coords(D, S):
"""目标像素中心 (d+0.5)*S/D - 0.5 的 Q16 整数坐标 -> (i0, i1, frac)。"""
num = (2 * np.arange(D, dtype=np.int64) + 1) * S
u = (num * ONE) // (2 * D) - (ONE // 2)
i0 = np.floor_divide(u, ONE)
frac = u - i0 * ONE
i0c = np.clip(i0, 0, S - 1)
i1c = np.clip(i0 + 1, 0, S - 1)
return i0c, i1c, frac
def upsample_bilinear(img, W, H):
"""整数双线性放大到 (H, W)。与 GLSL 的 LINEAR 采样同一像素中心约定。"""
nh, nw = img.shape[:2]
y0, y1, fy = _axis_coords(H, nh)
x0, x1, fx = _axis_coords(W, nw)
fxv, fyv = fx[None, :, None], fy[:, None, None]
top = (img[np.ix_(y0, x0)] * (ONE - fxv) + img[np.ix_(y0, x1)] * fxv) >> 16
bot = (img[np.ix_(y1, x0)] * (ONE - fxv) + img[np.ix_(y1, x1)] * fxv) >> 16
return (top * (ONE - fyv) + bot * fyv) >> 16
def level_weights(levels):
"""Q16.16 合并权重:w_i = ONE >> (i+1),最后一级吃掉余数,使 Σ w_i 恰为 ONE。"""
w = [ONE >> (i + 1) for i in range(levels - 1)]
w.append(ONE - sum(w))
return w
def build_levels(img, levels):
lv = [img]
for _ in range(levels - 1):
lv.append(downsample_box(lv[-1]))
return lv
def bloom_full(img, levels, weights):
H, W = img.shape[:2]
acc = np.zeros((H, W, 3), dtype=np.int64)
for lv, wi in zip(build_levels(img, levels), weights):
acc += wi * upsample_bilinear(lv, W, H)
return acc >> 16
def composite(img, bloom, intensity_q16):
return np.clip(img + ((intensity_q16 * bloom) >> 16), 0, 255).astype(np.uint8)
def timed(fn, reps=3):
best = None
for _ in range(reps):
t0 = time.perf_counter()
out = fn()
dt = (time.perf_counter() - t0) * 1000.0
best = dt if best is None else min(best, dt)
return out, round(best, 3)
def main():
base = np.fromfile(os.path.join(ROOT, "out", "shade_hollow.rgb"), dtype=np.uint8)
frame = base.reshape(240, 320, 3).astype(np.int64)
H, W = frame.shape[:2]
# ---- 级数扫描:光晕半径与耗时 ----
level_sweep = []
for L in [2, 4, 6]:
w = level_weights(L)
assert sum(w) == ONE
bright, n_bright = bright_pass(frame, 96)
bloom, ms = timed(lambda b=bright, L=L, w=w: bloom_full(b, L, w))
lv = build_levels(bright, L)
area = int((bloom.max(axis=2) > 0).sum())
level_sweep.append({
"levels": L,
"coarsest": [lv[-1].shape[1], lv[-1].shape[0]],
"weights": w,
"weight_sum": sum(w),
"bright_px": n_bright,
"bloom_area_px": area,
"halo_radius_px": round(float(np.sqrt(area / np.pi)), 2),
"ms": ms,
})
# ---- 阈值扫描:亮部像素数 ----
threshold_sweep = []
for t in THRESHOLDS:
_, n = bright_pass(frame, t)
threshold_sweep.append({"threshold": t, "bright_px": n})
# ---- 强度扫描:泛光对整帧的改动量(MAE vs 原始)----
bright, _ = bright_pass(frame, 96)
bloom = bloom_full(bright, 4, level_weights(4))
intensity_sweep = []
for it in INTENSITIES:
iq = int(round(it * ONE))
out = composite(frame, bloom, iq)
intensity_sweep.append({
"intensity": it,
"intensity_q16": iq,
"mae_vs_original": round(float(np.abs(out.astype(np.int64) - frame).mean()), 4),
"max_added": int((out.astype(np.int64) - frame).max()),
})
# ---- 金标准:级数 4、阈值 96、强度 1.0 ----
golden = composite(frame, bloom, ONE)
with open(os.path.join(ROOT, "out", "cg_shade_10_2_bloom.rgb"), "wb") as fh:
fh.write(golden.tobytes())
# 泛光层本身(未合成)也落盘,供 W 层分开比对
bloom_u8 = np.clip(bloom, 0, 255).astype(np.uint8)
with open(os.path.join(ROOT, "out", "cg_shade_10_2_bloom_layer.rgb"), "wb") as fh:
fh.write(bloom_u8.tobytes())
stats = {
"size": [W, H],
"threshold_golden": 96,
"intensity_golden": 1.0,
"level_sweep": level_sweep,
"threshold_sweep": threshold_sweep,
"intensity_sweep": intensity_sweep,
"bloom_layer_sha256": hashlib.sha256(bloom_u8.tobytes()).hexdigest(),
"final_sha256": hashlib.sha256(golden.tobytes()).hexdigest(),
"bloom_layer_mean": round(float(bloom_u8.mean()), 4),
}
with open(os.path.join(ROOT, "out", "cg_shade_10_2_bloom.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"帧 : {W}x{H} (out/shade_hollow.rgb)")
print("级数扫描(阈值 96,强度 1.0):")
print(" 级数 最粗层 权重和 亮部像素 泛光面积 等效半径 ms")
for s in level_sweep:
print(f" {s['levels']:4d} {str(s['coarsest']):8s} {s['weight_sum']:6d} "
f"{s['bright_px']:8d} {s['bloom_area_px']:8d} {s['halo_radius_px']:8.2f} {s['ms']:6.3f}")
print("阈值扫描(亮部像素数):", {s["threshold"]: s["bright_px"] for s in threshold_sweep})
print("强度扫描(MAE vs 原始 / 最大增量):")
for s in intensity_sweep:
print(f" 强度 {s['intensity']:4.2f} MAE = {s['mae_vs_original']:6.4f} 最大增量 = {s['max_added']:3d}")
print(f"金标准 final : out/cg_shade_10_2_bloom.rgb sha256 {stats['final_sha256'][:16]}…")
print(f"泛光层 : out/cg_shade_10_2_bloom_layer.rgb 均值 = {stats['bloom_layer_mean']}")
return 0
if __name__ == "__main__":
sys.exit(main())
两个细节值得单独点出来。
降采样用盒式而非高斯:级数链每一级都要再卷一次核,若每级都用半径 4 的高斯,六级的代价会失控。2×2 盒式平均只需三次加法一次移位,而且它天然就是「下一级的低通」——多级盒式串联的等效点扩散函数已足够接近高斯,视觉上看不出接缝。
上采样的像素中心约定必须和 GLSL 对齐:_axis_coords() 算的是目标像素中心 (d+0.5)·S/D - 0.5 的 Q16.16 整数坐标,再拆成 (i0, i1, frac)。GLSL 的 LINEAR 采样用的正是同一条公式——这是后面 W 层能对上号的前提。若这里写成 d·S/D(左上角对齐而非中心对齐),整幅泛光会平移半个像素,sha256 直接不对。
三张扫描表
级数决定光晕能铺多远。亮部像素固定 3817 个,级数越多最粗层越小、泛光面积越大:
| 级数 | 最粗层 | 权重和 | 亮部像素 | 泛光面积 | 等效半径 | 耗时 |
|---|---|---|---|---|---|---|
| 2 | 160×120 | 65536 | 3817 | 5853 px | 43.16 px | 11.6 ms |
| 4 | 40×30 | 65536 | 3817 | 9403 px | 54.71 px | 21.9 ms |
| 6 | 10×8 | 65536 | 3817 | 10999 px | 59.17 px | 32.4 ms |
等效半径按 sqrt(面积/π) 折算。从 4 级到 6 级,耗时涨了 48%,半径只从 54.7 涨到 59.2——收益递减。工程上取 4 级(最粗层 40×30)是常见折中:再细一层只是把已经很淡的外圈再摊开一点。
阈值决定哪些像素算「亮」。这张 320×240 的场景亮度最高只有 184、均值 50.5,所以阈值一动,亮部像素数就是数量级的变化:
| 阈值 | 亮部像素 | 占比 |
|---|---|---|
| 64 | 37543 | 48.9% |
| 96 | 3817 | 5.0% |
| 128 | 43 | 0.06% |
阈值 64 会把近一半画面当成亮部,泛光变成「整幅提亮」;128 只剩 43 个像素,几乎只有高光点。96 是这张图的分水岭——它落在亮度分布的长尾上,金标准取的就是它。
强度是唯一的线性旋钮,直接决定泛光对整帧的改动量:
| 强度 | Q16.16 | MAE vs 原始 | 最大增量 |
|---|---|---|---|
| 0.25 | 16384 | 1.1669 | 35 |
| 0.50 | 32768 | 2.3904 | 67 |
| 1.00 | 65536 | 4.8319 | 116 |
| 2.00 | 131072 | 8.2676 | 169 |
MAE 与强度严格成正比(2.3904 / 1.1669 ≈ 2.05,4.8319 / 2.3904 ≈ 2.02),最大增量则被 clamp 压住:强度 1.0 时最大加 116,强度 2.0 时本该加 232,实际只加到 169 就顶到 255 了。这说明强度不是越大越好——超过 1.0 之后,多出来的能量全被截断丢掉,只会让高光周围糊成一片白。
实跑输出
帧 : 320x240 (out/shade_hollow.rgb)
级数扫描(阈值 96,强度 1.0):
级数 最粗层 权重和 亮部像素 泛光面积 等效半径 ms
2 [160, 120] 65536 3817 5853 43.16 11.555
4 [40, 30] 65536 3817 9403 54.71 21.946
6 [10, 8] 65536 3817 10999 59.17 32.379
阈值扫描(亮部像素数): {64: 37543, 96: 3817, 128: 43}
强度扫描(MAE vs 原始 / 最大增量):
强度 0.25 MAE = 1.1669 最大增量 = 35
强度 0.50 MAE = 2.3904 最大增量 = 67
强度 1.00 MAE = 4.8319 最大增量 = 116
强度 2.00 MAE = 8.2676 最大增量 = 169
金标准 final : out/cg_shade_10_2_bloom.rgb sha256 68e5feeecd4f7543…
泛光层 : out/cg_shade_10_2_bloom_layer.rgb 均值 = 4.8484
金标准取级数 4、阈值 96、强度 1.0,合成帧的 sha256 是 68e5feeecd4f7543…。泛光层本身(未合成)的均值只有 4.85——它是稀疏的:3817 个亮部像素被摊开成 9403 个非零像素,绝大多数位置的值很低,只有高光附近才有明显亮度。
和 W 层的关系
这一节的链在 WebGL2 里是同一套:亮部提取是一个片元着色器、降采样链每级一个 FBO、合并是一次五纹理采样。差别只有宿主——Python 侧是 int64 加右移,GLSL 侧是 float 加纹理采样。两者不可能逐位相等,只能按声明容差比对(下一节 10.3 色调与调色
会给出这条容差线)。这也是为什么本节把每一步的中间量都落盘:有了 out/cg_shade_10_2_bloom_layer.rgb 这个中间产物,W 层出问题时才能判断是亮部提取错了、还是降采样错了,而不是只看到「最后不一样」。
阅读导航:上一节:10.1 卷积与模糊 · 下一节:10.3 色调与调色 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。