14.1 光源类型与表示

三种光源共用一套光照方向构造与衰减接口:方向光无衰减、点光源 r²/(r²+d²)、聚光再乘锥软化。同一场景下受光像素 959.8‰ / 953.3‰ / 70.1‰;衰减的两种整数写法在 178/1280 个距离上分叉、最大差 1 个 Q16.16,d=range 处恰好 0.5。

14.1 光源类型与表示

上一章把材质收口成数据驱动的三宿主对账。这一章转进光照本身:光源怎么被表示、怎么被批量求值、出问题时怎么被看见。站内的 集群前向渲染 讲的是集群前向的 GPU 实现——把光源按视锥体切成簇再分派;本节不问 GPU,只问一个更靠前的问题:一盏灯,在整数世界里到底是怎么被算出来的。

真实引擎里至少有三类光源:方向光(太阳)、点光源(火把)、聚光(灯笼)。三者若各写一套求值代码,就会出现「同一种光照在三种灯下行为不一致」的经典问题。本节把它们统一到一套光照方向构造 + 一套衰减接口下:方向光只是「方向固定、衰减恒为 1」的特例,聚光只是「点光源再乘一个锥形因子」。统一之后,批量求值(14.2)与调试视图(14.3)才有可能建立在同一套原语上。

三种光源的整数表示

三种光源的差异被压缩到两张表——光照方向怎么来、衰减怎么算:

类型数据光照方向 L衰减
方向光只有方向 dirQ16(单位向量)L = dirQ16恒为 ONE(无衰减)
点光源位置 posQ16 + 射程 rangeQ16L = normalize(pos − world)att = r²/(r² + d²)
聚光点光源 + 轴 axisQ16 + 内外锥余弦同上同上 × 锥软化 spot

三者的求值都落到同一个 light_terms:拿到 (ndl, att, spot),contrib = ndl · att · spot · intensity。方向光的 att 与 spot 恒为 ONE,于是它走的是与点光源完全相同的乘法链,只是乘了两个 1。这不是省代码,而是让「换一种灯」不引入新的数值路径——任何分叉都只可能来自 att 或 spot 的构造。

点光源的衰减用 r²/(r²+d²):d=0 时是 1,d=r 时是 1/2,d→∞ 时趋近 0 但永远不为 0。这一点在 14.2 会变成麻烦(每盏灯都影响每个像素,剔除无从谈起),但在 14.1 先照直实现。聚光的锥软化用 t²(3−2t)(t 是锥边归一化位置),锥外 0、锥内 1、锥边平滑过渡。

同一场景,三种光源

只换光源类型、其余全不动,实测受光像素与光贡献(光贡献 = 带灯亮度 − 纯环境光亮度):

光源受光像素占比光贡献均值最大整图亮度均值
方向光41973959.8‰58.9414678.13
点光源41687953.3‰33.0712253.08
聚光306570.1‰31.969223.80

方向光把几乎整个可见面都点亮(959.8‰),且贡献最强(均值 58.94)——它没有距离衰减,只要法线朝向它就满强度。点光源覆盖面相近(953.3‰)但强度被距离拉低(均值 33.07):射程 16 个单位、灯悬在 8 单位高处,近处的像素亮、远处的像素几乎只剩环境光。聚光只点亮了 70.1‰——锥角把它压成了地面上的一小片光斑。

几何            : 487 顶点 / 896 三角形  覆盖 43731
dir    受光像素 :  41973 ( 959.8‰)  光贡献均值  58.94  最大 146  整图亮度均值 78.13
point  受光像素 :  41687 ( 953.3‰)  光贡献均值  33.07  最大 122  整图亮度均值 53.08
spot   受光像素 :   3065 (  70.1‰)  光贡献均值  31.96  最大  92  整图亮度均值 23.80
衰减 A 最大误差 : 1.526e-05  均值 7.530e-06
衰减 B 最大误差 : 1.526e-05  均值 6.230e-06
A vs B 分叉     : 178 / 1280 个距离  最大差 1 Q16.16  最远分叉 d=1291264 (19.7031 世界单位)
d = range 处 att: 32768 Q16.16 (=0.500000,理论 0.5)
锥软化 首/末     : 0 -> 65536  (cd 0.7500..1.0000)

S 层:三种光源的整数求值

脚本 sim/cg_shade_14_1_light_types.py 复用本章公共库 cg_shade_14_lightlib:后者在 27 项三角形布局之后追加 9 项世界坐标,于是片元阶段同时拿到归一化法线、插值世界坐标与 albedo——点光源与聚光必须有世界坐标才能算距离与方向锥。这与第 4 章 cg_shade_04_shadowlib 追加「光源空间坐标」是同一套扩展手法。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_14_1_light_types.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_14_1_light_types.py
"""14.1 光源类型与表示:三种光源的整数求值 + 同一场景下的覆盖与亮度分布。

三种光源共用一套 `L` 构造与衰减接口(`cg_shade_14_lightlib`):

  方向光  L = dirQ16(单位向量)                     att = 1
  点光源  L = normalize(pos − world)                 att = r²/(r²+d²)
  聚光    同点光源 + 轴 axisQ16 与内外锥余弦          att × spot

三件事:

1. **同一场景、同一环境光**,只换光源类型,实测受光像素数、亮度均值/最大值与直方图。
2. **衰减因子的两种整数写法**:A = `fdiv(r², r²+d²)`(一次除法)与 B = `fdiv(1, 1+fdiv(d²,r²))`
   (两次除法)。两者数学等价,整数结果却会分叉——这正是「同一公式不同舍入路径」的实测。
3. **锥软化曲线**:扫 `cd` 从锥外到锥内,看 `t²(3−2t)` 的整数阶梯。

产物:out/cg_shade_14_1_light_types.{json,csv}、out/light_dir.png / light_point.png / light_spot.png
"""

import json
import os
import sys

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
SPEC = os.path.join(ROOT, "config", "cg_shade_14_1_lights.json")


def attenuation_error_sweep(cfg):
    """扫距离,比较衰减的两种整数写法与实数真值。"""
    r = cfg["point"]["rangeQ16"]
    r2 = cglib.fmul(r, r)
    sw = cfg["attenuationSweep"]
    rows = []
    for k in range(sw["count"]):
        d = sw["startQ16"] + k * sw["stepQ16"]
        d2 = cglib.fmul(d, d)
        a = L.attenuation(d2, r2)
        b = L.attenuation_two_div(d2, r2)
        dw, rw = d / ONE, r / ONE
        real = 1.0 / (1.0 + (dw / rw) ** 2)
        rows.append({"d_q16": d, "d": round(dw, 6), "attA": a, "attB": b,
                     "attA_f": round(a / ONE, 8), "attB_f": round(b / ONE, 8),
                     "real": round(real, 8),
                     "errA": round(a / ONE - real, 8), "errB": round(b / ONE - real, 8)})
    return rows


def spot_sweep(cfg):
    """扫 cd(片段方向与轴的夹角余弦),看锥软化系数。"""
    sp = cfg["spot"]
    sw = cfg["spotSweep"]
    rows = []
    for k in range(sw["count"]):
        cd = sw["startCosQ16"] + k * sw["stepQ16"]
        rows.append({"cd_q16": cd, "cd": round(cd / ONE, 6),
                     "spot": L.spot_from_cos(cd, sp["cosOuterQ16"], sp["cosInnerQ16"])})
    return rows


def golden_samples(cfg, spec, count):
    """一组固定的 (世界坐标, 法线) 采样点,供 P 层逐项复核三种光源的求值。"""
    terr = spec["terrain"]
    seed, hs = terr["noiseSeed"], terr["heightScaleQ16"]
    extent = terr["extentQ16"]
    pts = []
    n = int(round(count ** 0.5))
    for iz in range(n):
        for ix in range(n):
            x = -extent + (2 * extent * (2 * ix + 1)) // (2 * n)
            z = -extent + (2 * extent * (2 * iz + 1)) // (2 * n)
            y = cglib.terrain_height(x, z, seed, hs) + 65536  # 抬高 1 个单位,避免贴地
            pts.append(((x, y, z), cgshade.terrain_normal(x, z, seed, hs)))
    return pts[:count]


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"]

    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)

    lights = {"dir": L.make_light(cfg["directional"]),
              "point": L.make_light(cfg["point"]),
              "spot": L.make_light(cfg["spot"])}

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    # 纯环境光基线:受光像素 = 带光源亮度**严格大于**环境底(即该光源至少贡献 1 个亮度单位)
    amb_color, _d, covered, _c = L.rasterize_lit(ltris, w, h, bg, L.make_shade(ambient, []))
    amb_lum = L.luma(amb_color)
    covered_n = int(covered.sum())

    per_light, stats = {}, {}
    for name, lt in lights.items():
        color, depth, covered, culled = L.rasterize_lit(
            ltris, w, h, bg, L.make_shade(ambient, [lt]))
        lum = L.luma(color)
        contrib = lum - amb_lum
        lit = covered & (lum > amb_lum)
        vals = contrib[lit]
        hist, _ = np.histogram(vals, bins=8, range=(0, 256)) if vals.size else (np.zeros(8), None)
        cglib.save_png(os.path.join(out, f"light_{name}.png"), color)
        per_light[name] = {
            "lit": int(lit.sum()),
            "lit_permille": round(int(lit.sum()) * 1000.0 / covered_n, 4),
            "contrib_mean": round(float(vals.mean()), 4) if vals.size else 0.0,
            "contrib_max": int(vals.max()) if vals.size else 0,
            "lum_mean": round(float(lum[covered].mean()), 4),
            "lum_max": int(lum[covered].max()),
            "hist": [int(c) for c in hist],
        }
        stats[name] = {"type": lt["type"],
                       "posQ16": list(lt.get("posQ16", [])),
                       "rangeQ16": lt.get("rangeQ16"),
                       "intensityQ16": lt["intensityQ16"]}

    att = attenuation_error_sweep(cfg)
    sp = spot_sweep(cfg)
    gold_pts = golden_samples(cfg, spec, cfg["goldenSamples"])
    golden = []
    for wp, nrm in gold_pts:
        entry = {"world": list(wp), "normal": list(nrm), "terms": {}}
        for name, lt in lights.items():
            ndl, a, s, c = L.light_terms(lt, nrm, wp)
            entry["terms"][name] = {"ndl": ndl, "att": a, "spot": s, "contrib": c}
        golden.append(entry)

    attA = np.array([r["attA"] for r in att], dtype=np.int64)
    attB = np.array([r["attB"] for r in att], dtype=np.int64)
    errA = np.array([abs(r["errA"]) for r in att], dtype=np.float64)
    errB = np.array([abs(r["errB"]) for r in att], dtype=np.float64)
    dq = np.array([r["d_q16"] for r in att], dtype=np.int64)
    rng = cfg["point"]["rangeQ16"]
    at_rng = int(attA[np.argmin(np.abs(dq - rng))])
    diverge = attA != attB
    div = int(diverge.sum())
    max_div = int(np.abs(attA - attB).max())
    div_d_max = int(dq[diverge].max()) if div else 0

    cglib.write_csv(
        os.path.join(out, "cg_shade_14_1_light_types.csv"),
        ["d_q16", "d", "attA_q16", "attB_q16", "attA", "attB", "real", "errA", "errB"],
        [[r["d_q16"], r["d"], r["attA"], r["attB"], r["attA_f"], r["attB_f"],
          r["real"], r["errA"], r["errB"]] for r in att])

    result = {
        "ambientQ16": ambient, "canvas": [w, h], "tris_drawn": len(ltris), "dropped": dropped,
        "lights": stats, "per_light": per_light,
        "attenuation": {
            "samples": len(att), "rQ16": rng, "r2_q16": lights["point"]["r2"],
            "attA_vs_attB_divergent": div, "max_abs_diff_q16": max_div,
            "divergent_max_d_q16": div_d_max,
            "errA_max": round(float(errA.max()), 8), "errA_mean": round(float(errA.mean()), 8),
            "errB_max": round(float(errB.max()), 8), "errB_mean": round(float(errB.mean()), 8),
            "att_at_range_q16": at_rng,
            "rows": att,
        },
        "spot_sweep": sp,
        "golden": golden,
    }
    with open(os.path.join(out, "cg_shade_14_1_light_types.json"), "w", encoding="utf-8") as fh:
        json.dump(result, fh, ensure_ascii=False, indent=2)

    print(f"几何            : {len(verts)} 顶点 / {len(tris)} 三角形  覆盖 {covered_n}")
    for name in ("dir", "point", "spot"):
        p = per_light[name]
        print(f"{name:6s} 受光像素 : {p['lit']:6d} ({p['lit_permille']:6.1f}‰)  "
              f"光贡献均值 {p['contrib_mean']:6.2f}  最大 {p['contrib_max']:3d}  "
              f"整图亮度均值 {p['lum_mean']:.2f}")
    print(f"衰减 A 最大误差 : {result['attenuation']['errA_max']:.3e}  "
          f"均值 {result['attenuation']['errA_mean']:.3e}")
    print(f"衰减 B 最大误差 : {result['attenuation']['errB_max']:.3e}  "
          f"均值 {result['attenuation']['errB_mean']:.3e}")
    print(f"A vs B 分叉     : {div} / {len(att)} 个距离  最大差 {max_div} Q16.16  "
          f"最远分叉 d={div_d_max} ({div_d_max / ONE:.4f} 世界单位)")
    print(f"d = range 处 att: {at_rng} Q16.16 (={at_rng / ONE:.6f},理论 0.5)")
    print(f"锥软化 首/末     : {sp[0]['spot']} -> {sp[-1]['spot']}  "
          f"(cd {sp[0]['cd']:.4f}..{sp[-1]['cd']:.4f})")
    print("png             : out/light_dir.png / light_point.png / light_spot.png")
    return 0


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

衰减的两种写法是本节的实测重点。写法 A 是单次除法 fdiv(r², r²+d²);写法 B 是两次除法 fdiv(ONE, ONE + fdiv(d², r²))。两者在实数上恒等,但整数下舍入路径不同:扫 1280 个距离(0 到 20 世界单位),有 178 个距离上 A 与 B 给出不同的 Q16.16 值,最大差 1 个最小单位,最远分叉出现在 d=1291264(19.7031 世界单位)。这就是「同一公式、不同写法」的代价——它不会让画面崩,但会让跨语言对账失败:若 S 层用 A、P 层用 B,逐字节比较立刻爆红。

P 层:跨语言逐项复核

// snippet: core/test/cg_shade_14_1_lights.test.ts(节选:golden 比对与分叉判据)
test("【核心】24 个采样点上三种光源的 ndl / att / spot / contrib 与 S 侧 golden 逐项相同", () => {
  for (let i = 0; i < stats.golden.length; i += 1) {
    const g = stats.golden[i];
    const world = V(g.world);
    const normal = V(g.normal);
    for (const name of ["dir", "point", "spot"]) {
      const t = lightTerms(lights[name], normal, world);
      assert.equal(Number(t.ndl), g.terms[name].ndl, `${name} ndl @${i}`);
      assert.equal(Number(t.att), g.terms[name].att, `${name} att @${i}`);
      assert.equal(Number(t.spot), g.terms[name].spot, `${name} spot @${i}`);
      assert.equal(Number(t.contrib), g.terms[name].contrib, `${name} contrib @${i}`);
    }
  }
});

test("衰减的两种写法 A / B:分叉处数、最大差与 S 侧一致", () => {
  const r2 = lights.point.r2 as bigint;
  let divergent = 0;
  let maxDiff = 0n;
  for (const row of stats.attenuation.rows) {
    const d2 = fmul(BigInt(row.d_q16), BigInt(row.d_q16));
    const a = attenuation(d2, r2);
    const b = attenuationTwoDiv(d2, r2);
    assert.equal(Number(a), row.attA, `attA @d=${row.d_q16}`);
    assert.equal(Number(b), row.attB, `attB @d=${row.d_q16}`);
    if (a !== b) divergent += 1;
    const d = a > b ? a - b : b - a;
    if (d > maxDiff) maxDiff = d;
  }
  assert.equal(divergent, stats.attenuation.attA_vs_attB_divergent);
  assert.equal(Number(maxDiff), stats.attenuation.max_abs_diff_q16);
  assert.ok(divergent > 0, "两种数学等价的写法必须至少在一个距离上分叉,否则该判据无区分力");
});

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

✔ 【核心】24 个采样点上三种光源的 ndl / att / spot / contrib 与 S 侧 golden 逐项相同
✔ 衰减的两种写法 A / B:分叉处数、最大差与 S 侧一致
✔ d = range 处 att 恰好是 ONE/2(r²/(r²+r²) = 1/2)
✔ 负例:点光源方向不归一化会把 N·L 抬到远超 ONE,与 golden 分叉
ℹ tests 4  ℹ pass 4  ℹ fail 0

4 条全绿。最后一条是负例:把点光源的方向从 normalize(pos−world) 退化成未归一化的 pos−world,N·L 会被抬到远超 ONE(因为 |pos−world| 可达数十),与 golden 立刻分叉。这条负例保证「归一化」不是装饰——少了它,判据就抓不到这个错误。三种光源的表示至此闭环:构造方向 → 算衰减 → 乘强度。下一节看当灯多到几十盏时,这条乘法链怎么被组织才不至于每像素遍历全部灯。

阅读导航:上一节:13.3 材质的数据驱动 · 下一节:14.2 光照的批处理与代价 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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