10.2 Bloom 与泛光

泛光不是「把亮的地方涂糊」,而是一条四步整数链:Rec.601 亮度做整数阈值提取亮部、每级减半的 2×2 盒式降采样、整数双线性上采样、以及权重和恰好为 ONE 的合并。本节扫描级数 2/4/6、阈值 64/96/128、强度 0.25~2.0,并给出金标准泛光帧的 sha256。

10.2 Bloom 与泛光

站内 /graphics-post-processing/ 那篇把 Bloom 列进后处理链的第三格;本节把它拆开,看看这一格内部到底是什么。直觉上的说法是「把亮的地方糊开」,但这个说法没法实现——糊多大范围?糊完怎么叠回去?叠的时候会不会把整帧提亮?

把它写成可验收的四步,每一步都是整数运算:

  1. 亮部提取:亮度用 Rec.601 整数权重 lum = (77R + 150G + 29B) >> 8,阈值比较也在整数上做,保留 lum > threshold 的像素、其余置零。
  2. 降采样链:每级尺寸减半(奇数边复制末行末列),2×2 盒式平均即 和 >> 2。
  3. 上采样合并:每级用整数双线性放回全分辨率,按 Q16.16 权重 w_i 相加,Σ w_i 恰为 ONE。
  4. 合成: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 个,级数越多最粗层越小、泛光面积越大:

级数最粗层权重和亮部像素泛光面积等效半径耗时
2160×1206553638175853 px43.16 px11.6 ms
440×306553638179403 px54.71 px21.9 ms
610×865536381710999 px59.17 px32.4 ms

等效半径按 sqrt(面积/π) 折算。从 4 级到 6 级,耗时涨了 48%,半径只从 54.7 涨到 59.2——收益递减。工程上取 4 级(最粗层 40×30)是常见折中:再细一层只是把已经很淡的外圈再摊开一点。

阈值决定哪些像素算「亮」。这张 320×240 的场景亮度最高只有 184、均值 50.5,所以阈值一动,亮部像素数就是数量级的变化:

阈值亮部像素占比
643754348.9%
9638175.0%
128430.06%

阈值 64 会把近一半画面当成亮部,泛光变成「整幅提亮」;128 只剩 43 个像素,几乎只有高光点。96 是这张图的分水岭——它落在亮度分布的长尾上,金标准取的就是它。

强度是唯一的线性旋钮,直接决定泛光对整帧的改动量:

强度Q16.16MAE vs 原始最大增量
0.25163841.166935
0.50327682.390467
1.00655364.8319116
2.001310728.2676169

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 色调与调色 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配