14.1 光源类型与表示
上一章把材质收口成数据驱动的三宿主对账。这一章转进光照本身:光源怎么被表示、怎么被批量求值、出问题时怎么被看见。站内的 集群前向渲染 讲的是集群前向的 GPU 实现——把光源按视锥体切成簇再分派;本节不问 GPU,只问一个更靠前的问题:一盏灯,在整数世界里到底是怎么被算出来的。
真实引擎里至少有三类光源:方向光(太阳)、点光源(火把)、聚光(灯笼)。三者若各写一套求值代码,就会出现「同一种光照在三种灯下行为不一致」的经典问题。本节把它们统一到一套光照方向构造 + 一套衰减接口下:方向光只是「方向固定、衰减恒为 1」的特例,聚光只是「点光源再乘一个锥形因子」。统一之后,批量求值(14.2)与调试视图(14.3)才有可能建立在同一套原语上。
三种光源的整数表示
三种光源的差异被压缩到两张表——光照方向怎么来、衰减怎么算:
| 类型 | 数据 | 光照方向 L | 衰减 |
|---|---|---|---|
| 方向光 | 只有方向 dirQ16(单位向量) | L = dirQ16 | 恒为 ONE(无衰减) |
| 点光源 | 位置 posQ16 + 射程 rangeQ16 | L = 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、锥边平滑过渡。
同一场景,三种光源
只换光源类型、其余全不动,实测受光像素与光贡献(光贡献 = 带灯亮度 − 纯环境光亮度):
| 光源 | 受光像素 | 占比 | 光贡献均值 | 最大 | 整图亮度均值 |
|---|---|---|---|---|---|
| 方向光 | 41973 | 959.8‰ | 58.94 | 146 | 78.13 |
| 点光源 | 41687 | 953.3‰ | 33.07 | 122 | 53.08 |
| 聚光 | 3065 | 70.1‰ | 31.96 | 92 | 23.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 光照的批处理与代价 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。