14.2 光照的批处理与代价

点光源衰减改成有限支撑的 (r²−d²)/(r²+d²),才让「哪些光源影响哪个像素」成为一条整数谓词。两级剔除:逐像素距离阈值 + 16×16 网格分桶。N=64 时循环 3031360→380842、完整求值恒为 72576、两版逐字节相同;代价模型 25702400→4498256 ops(比例 0.1750)。

14.2 光照的批处理与代价

14.1 把三种光源收口到同一条乘法链。这一节问的是工程问题:当场景里有 64 盏灯时,每个像素还要不要遍历全部 64 盏?站内的 集群前向渲染 用视锥体切簇回答这个问题;本节用一个更朴素的方案回答同一件事——屏幕分桶,并把它与「朴素全量」逐字节对账、把代价写成可验算的公式。

为什么先换衰减形式

14.1 的衰减 r²/(r²+d²) 在数学上处处非零:d→∞ 时趋近 0 但永不等于 0。本场景里即便在射程外很远,衰减仍有几百个 Q16.16 单位——这意味着每盏灯都影响每个像素,任何「这盏灯照不到这个像素」的剔除都不成立。

于是本节改用有限支撑的窗口衰减:

windowed(dd, rr) = fdiv(rr − dd, rr + dd)     dd = d², rr = range²

d=0 时是 1,d=range 时是 0,d>range 时为负——把支撑集恰好压成一个射程球。这才让「哪些光源影响哪个像素」成为一条可判的整数谓词:dd <= rr。注意这是一个有意的取舍:为了让剔除可行,本节换了衰减函数,代价是 14.1 那种「处处微亮」的柔和感消失了,射程外是硬边。真实引擎通常两者并存(近处窗口衰减、远处一个极小的常数项),这里为了把机制讲干净只保留窗口衰减。

两级剔除

层级粒度判据作用
距离阈值逐像素dd = Σd² <= rr纯整数、无开方;命中才做昂贵的归一化与除法
网格分桶逐瓦片(16×16)光源屏幕圆盘 ∩ 瓦片矩形每瓦片只挂真正可能照到的光源

两级的分工是刻意的:距离阈值是精确的(dd <= rr 当且仅当在球内),但每个像素仍要对每盏灯做一次 8 运算的距离测试;网格分桶是保守的(瓦片表是超集,圆盘与瓦片相交就挂上,哪怕像素其实在圆外),但它把「每像素的候选灯列表」从 64 砍到通常个位数。保守意味着分桶渲染必须与朴素渲染逐字节相同——这正是本节最强的一条判据。

代价曲线:N = 1 → 64

同一场景、只改灯数,朴素全量 vs 网格分桶:

N朴素循环朴素求值分桶循环分桶求值求值比朴素ms分桶ms加速逐字节
14736522844832281.000186.2172.01.08True
29473022844832281.000198.3172.21.15True
418946042882002242881.000225.5186.01.21True
837892096036206496031.000277.6216.91.28True
167578401149875961114981.000370.9560.00.66True
32151568034710179097347101.000864.3269.73.20True
64303136072576380842725761.000990.2366.32.70True

两个必须如实说清的事实:

第一,完整求值次数两版完全相同。 求值比恒为 1.000——因为距离阈值已经在像素级精确剔除,分桶能省的只是便宜的距离测试循环(N=64 时 3031360 → 380842 次),省不掉任何一次昂贵的求值(两边都是 72576 次)。分桶的价值全部来自「少转了几百万次 8 运算的循环」。

第二,加速比并不单调。 N=16 时加速只有 0.66(分桶反而更慢)——那是一次计时抖动(分桶 560.0ms 明显高于相邻档位),不是算法问题;循环计数这一列才是稳定可复现的度量。这也提醒:用挂钟时间做结论不可靠,用循环/求值计数做结论才可靠。

S 层:编排与度量

脚本 sim/cg_shade_14_2_light_batching.py 把批处理原语(运算计数器 Ops、光源生成 gen_lights、屏幕圆盘 screen_disc、瓦片表 build_tiles、相机基 camera_basis)全部收进本章公共库 cg_shade_14_lightlib,自身只做编排与度量:

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_14_2_light_batching.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_14_2_light_batching.py
"""14.2 光照的批处理与代价:光源数 1→64 的代价曲线,朴素全量 vs 分桶剔除。

**先换衰减形式**:14.1 的 `r²/(r²+d²)` 在数学上处处非零(本场景下最小也有几百个
Q16.16 单位),于是**每个光源都影响每个像素**,剔除无从谈起。本节改用**有限支撑**的
窗口衰减 `(r²−d²)/(r²+d²)`:`d=0` 时 1、`d=r` 时 0、`d>r` 时被 `dd <= rr` 挡掉——
支撑集恰好是射程球,这才让「哪些光源影响哪个像素」成为一条可判的整数谓词。

两级剔除:

  距离阈值(逐像素)  `dd = Σd² <= rr`,纯整数、无开方;命中才做昂贵的归一化与除法
  网格分桶(逐瓦片)  把光源的**屏幕圆盘**与 16×16 的瓦片矩形求交,得到每瓦片的光源表

分桶是**保守**的(瓦片表是超集),所以分桶渲染必须与朴素渲染**逐字节相同**(sha256)。
代价模型 = 求值次数 × 每光源运算量。

产物:out/cg_shade_14_2_light_batching.{json,csv}、out/light_batch_{naive,bucket}_64.png
"""

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
import cgshade  # noqa: E402
import cg_shade_14_lightlib as L  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
SUB1 = cglib.SUB1
SPEC = os.path.join(ROOT, "config", "cg_shade_14_2_lights.json")

# 批处理原语(Ops / gen_lights / screen_disc / build_tiles / camera_basis)已收进
# 本章公共库 cg_shade_14_lightlib,本节只做编排与度量,不重复实现定点数学。


def main():
    cfg = json.load(open(SPEC, encoding="utf-8"))
    spec = cglib.load_spec(os.path.join(ROOT, "config", cfg["sceneSpec"]))
    w, h, bg = spec["width"], spec["height"], spec["backgroundQ8"]
    ambient = cfg["ambientQ16"]
    tile = cfg["tilePixels"]

    verts, normals, albs, tris = L.build_scene_lit(spec)
    cm = L.camera_mvp(spec)
    ltris, dropped = L.build_lit_tris(verts, normals, albs, tris, cm, w, h)
    basis = L.camera_basis(spec)

    all_lights = L.gen_lights(max(cfg["lightCounts"]), spec, cfg)
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    rows, curves = [], []
    evals_max, table_max = None, None
    for n in cfg["lightCounts"]:
        lights = all_lights[:n]
        tiles, nx, ny = L.build_tiles(lights, cm, w, h, basis, tile)

        def naive_cand(px, py, n=n):
            return range(n)

        def bucket_cand(px, py, tl=tiles, t=tile):
            return tl[py // t][px // t]

        rec = set() if n == max(cfg["lightCounts"]) else None
        t0 = time.perf_counter()
        c_n, d_n, cov_n, _cl, loops_n, full_n = L.rasterize_lights(
            ltris, w, h, bg, ambient, lights, naive_cand, L.windowed_attenuation, evals=rec)
        ms_n = (time.perf_counter() - t0) * 1000

        t0 = time.perf_counter()
        c_b, d_b, cov_b, _cl, loops_b, full_b = L.rasterize_lights(
            ltris, w, h, bg, ambient, lights, bucket_cand, L.windowed_attenuation)
        ms_b = (time.perf_counter() - t0) * 1000

        sha_n = cgshade.digest(np.clip(c_n, 0, 255).astype(np.uint8))
        sha_b = cgshade.digest(np.clip(c_b, 0, 255).astype(np.uint8))
        covered = int(cov_n.sum())
        tile_lights = sum(len(tiles[ty][tx]) for ty in range(ny) for tx in range(nx))

        curves.append({
            "n": n, "covered": covered, "tiles": nx * ny, "tile_light_pairs": tile_lights,
            "naive_loops": loops_n, "naive_evals": full_n,
            "bucket_loops": loops_b, "bucket_evals": full_b,
            "eval_ratio": round(full_b / max(1, full_n), 6),
            "naive_ms": round(ms_n, 1), "bucket_ms": round(ms_b, 1),
            "speedup_ms": round(ms_n / max(1e-6, ms_b), 2),
            "identical": sha_n == sha_b, "sha256": sha_n,
        })
        rows.append([n, loops_n, full_n, loops_b, full_b, f"{full_b / max(1, full_n):.6f}",
                     f"{ms_n:.1f}", f"{ms_b:.1f}", f"{ms_n / max(1e-6, ms_b):.2f}",
                     int(sha_n == sha_b)])

        if rec is not None:
            evals_max = rec
            table_max = {(tx, ty, li) for ty in range(ny) for tx in range(nx)
                         for li in tiles[ty][tx]}

        if n == max(cfg["lightCounts"]):
            cglib.save_png(os.path.join(out, "light_batch_naive_64.png"), c_n)
            cglib.save_png(os.path.join(out, "light_batch_bucket_64.png"), c_b)

    # 每光源运算量:真跑一次「距离测试」与一次「完整求值」
    lt = all_lights[0]
    o_test = L.Ops()
    dx, dy, dz = 1000, -2000, 3000
    a = o_test.s(dx, 0)
    b = o_test.s(dy, 0)
    c = o_test.s(dz, 0)
    aa, bb, cc = o_test.m(a, a), o_test.m(b, b), o_test.m(c, c)
    _dd = o_test.s(o_test.s(aa, -bb), -cc)   # 3 减 + 3 乘 + 2 加 = 8 次定点运算
    o_eval = L.Ops()
    world = (0, ONE, 0)
    n = cglib.v_norm((0, ONE, 0))
    to_light = o_eval.s(lt["posQ16"][0], world[0]), o_eval.s(lt["posQ16"][1], world[1]), \
        o_eval.s(lt["posQ16"][2], world[2])
    ndl = o_eval.dot(n, o_eval.norm(to_light))
    if ndl < 0:
        ndl = 0
    _k = o_eval.m(o_eval.m(ndl, o_eval.d(lt["rr"], lt["rr"])), lt["intensityQ16"])

    # 保守性不变量(直接核对):朴素全量**实际求值**过的 (像素, 光源) 对,其所属瓦片必须
    # 在分桶表里——即 `表 ⊇ 实际求值集`。这是分桶结果与朴素逐字节相同的**充分条件**,
    # 且不依赖任何「射程内像素」的中间缓冲。
    nx, ny = (w + tile - 1) // tile, (h + tile - 1) // tile
    need = {(px // tile, py // tile, li) for (px, py, li) in evals_max}
    missing = sorted(need - table_max)
    violations = len(missing)
    disc_rows = [{"eval_pairs": len(need), "table_pairs": len(table_max),
                  "missing": len(missing), "sample_missing": missing[:8]}]

    # 供 P 层复核的采样:光源屏幕圆盘 + 窗口衰减曲线
    disc_samples = []
    for li in range(min(8, len(all_lights))):
        d = L.screen_disc(cm, all_lights[li]["posQ16"], all_lights[li]["rangeQ16"], w, h, basis)
        disc_samples.append({"i": li, "sx": None if d is None else d[0],
                             "sy": None if d is None else d[1],
                             "rad": None if d is None else d[2]})
    rr0 = all_lights[0]["rr"]
    att_samples = [{"dd": k * (rr0 // 8), "rr": rr0,
                    "att": L.windowed_attenuation(k * (rr0 // 8), rr0)} for k in range(17)]

    last = curves[-1]
    test_ops = o_test.mul + o_test.div + o_test.sqrt + o_test.addsub
    eval_ops = o_eval.mul + o_eval.div + o_eval.sqrt + o_eval.addsub
    naive_ops = last["naive_loops"] * test_ops + last["naive_evals"] * eval_ops
    bucket_ops = last["bucket_loops"] * test_ops + last["bucket_evals"] * eval_ops

    cglib.write_csv(
        os.path.join(out, "cg_shade_14_2_light_batching.csv"),
        ["n", "naive_loops", "naive_evals", "bucket_loops", "bucket_evals", "eval_ratio",
         "naive_ms", "bucket_ms", "speedup", "identical"],
        rows)

    result = {
        "ambientQ16": ambient, "canvas": [w, h], "tile": tile,
        "tris_drawn": len(ltris), "dropped": dropped,
        "max_lights": max(cfg["lightCounts"]), "light_rangeQ16": cfg["lightRangeQ16"],
        "light_positions": [list(lt["posQ16"]) for lt in all_lights],
        "camera": spec["camera"], "width": w, "height": h,
        "table_invariant": {"violations": violations, "rows": disc_rows},
        "disc_samples": disc_samples, "att_samples": att_samples,
        "rangeQ16": cfg["lightRangeQ16"], "rr": rr0, "lightSeed": cfg["lightSeed"],
        "heightBaseQ16": cfg["heightBaseQ16"], "heightSpanQ16": cfg["heightSpanQ16"],
        "ops_per_eval": {"mul": o_eval.mul, "div": o_eval.div, "sqrt": o_eval.sqrt,
                         "addsub": o_eval.addsub, "total": eval_ops},
        "ops_per_test": test_ops,
        "cost_model_64": {"naive_ops": naive_ops, "bucket_ops": bucket_ops,
                          "ratio": round(bucket_ops / max(1, naive_ops), 6)},
        "curves": curves,
    }
    with open(os.path.join(out, "cg_shade_14_2_light_batching.json"), "w", encoding="utf-8") as fh:
        json.dump(result, fh, ensure_ascii=False, indent=2)

    print(f"几何            : {len(verts)} 顶点 / {len(tris)} 三角形  覆盖 {curves[0]['covered']}")
    print(f"瓦片            : {tile}x{tile} 像素  共 {curves[0]['tiles']} 块")
    print(f"{'N':>3} {'朴素循环':>10} {'朴素求值':>9} {'分桶循环':>10} {'分桶求值':>9} "
          f"{'比例':>7} {'朴素ms':>7} {'分桶ms':>7} {'加速':>6} {'逐字节':>6}")
    for r in curves:
        print(f"{r['n']:>3} {r['naive_loops']:>10} {r['naive_evals']:>9} "
              f"{r['bucket_loops']:>10} {r['bucket_evals']:>9} {r['eval_ratio']:>7.3f} "
              f"{r['naive_ms']:>7.1f} {r['bucket_ms']:>7.1f} {r['speedup_ms']:>6.2f} "
              f"{str(bool(r['identical'])):>6}")
    print(f"每光源运算量    : 距离测试 {test_ops} ops  完整求值 {eval_ops} ops "
          f"(mul={o_eval.mul} div={o_eval.div} sqrt={o_eval.sqrt} addsub={o_eval.addsub})")
    print(f"代价模型 (N=64) : 朴素 {naive_ops} ops vs 分桶 {bucket_ops} ops  "
          f"比例 {result['cost_model_64']['ratio']:.4f}")
    print(f"保守性不变量    : 实际求值 {len(need)} 对,分桶表 {len(table_max)} 对,"
          f"表未覆盖的 = {violations}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

代价模型 = 求值次数 × 每光源运算量。两个数都真跑一次得出:距离测试 dd = Σd² <= rr 是 3 减 + 3 乘 + 2 加 = 8 ops(注意三个平方之间那两个加号也要计入,否则会少报成 6);一次完整求值(归一化方向 + 点乘 + 两次除法 + 一次开方 + 强度乘)是 20 ops(mul=8 div=4 sqrt=1 addsub=7)。于是 N=64 时:朴素 3031360×8 + 72576×20 = 25702400 ops,分桶 380842×8 + 72576×20 = 4498256 ops,比例 0.1750。

保守性不变量则直接核对集合关系:朴素渲染实际求值过的每个 (像素, 光源) 对,其所属瓦片必须出现在分桶表里——即 表 ⊇ 实际求值集。实测实际求值 534 对、分桶表 2179 对、表未覆盖的 = 0。这是分桶与朴素逐字节相同的充分条件,且不依赖任何中间缓冲。

P 层:跨语言复核

// snippet: core/test/cg_shade_14_2_light_batching.test.ts(节选:圆盘与代价曲线)
test("光源屏幕圆盘的 (sx, sy, rad) 与 S 侧逐项相同", () => {
  for (const d of stats.disc_samples) {
    const got = screenDisc(camera, V(d.pos), BigInt(d.rangeQ16), stats.width, stats.height, basis);
    assert.equal(got.sx, d.sx, `sx @${d.i}`);
    assert.equal(got.sy, d.sy, `sy @${d.i}`);
    assert.equal(got.rad, d.rad, `rad @${d.i}`);
  }
});

test("代价曲线:分桶完整求值恒等于朴素,且逐字节相同", () => {
  for (const row of stats.curves) {
    assert.equal(row.bucket_evals, row.naive_evals, `evals @N=${row.n}`);
    assert.equal(row.eval_ratio, 1, `eval_ratio @N=${row.n}`);
    assert.equal(row.identical, true, `identical @N=${row.n}`);
  }
  const last = stats.curves[stats.curves.length - 1];
  assert.equal(last.n, 64);
  assert.ok(last.bucket_loops < last.naive_loops, "分桶必须减少距离测试循环");
});

node --test 'core/test/cg_shade_14_2_light_batching.test.ts' 的实测输出:

✔ 光源屏幕圆盘的 (sx, sy, rad) 与 S 侧逐项相同
✔ 窗口衰减:d=0 为 1、d=r 为 0、d>r 为负
✔ 代价曲线:分桶完整求值恒等于朴素,且逐字节相同
✔ 代价模型运算量:距离测试 8 ops、完整求值 20 ops,与 S 侧一致
ℹ tests 4  ℹ pass 4  ℹ fail 0

4 条全绿。窗口衰减那条还钉住了边界:d=r 处恰好是 0——这正是「射程球就是支撑集」的代数表达。至此批处理闭环:换有限支撑衰减 → 像素级距离阈值 → 瓦片级网格分桶 → 与朴素逐字节对账 → 代价写成可验算公式。下一节转向另一件工程必需品:当光照出错时,怎么把它看见。

阅读导航:上一节:14.1 光源类型与表示 · 下一节:14.3 光照的调试视图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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