14.3 光照的调试视图
前两节把光源的表示与批处理都做成了可验算的东西,但真实开发里更常见的场景是:画面就是不对,但你不知道是哪一项不对。 站内的 集群前向渲染 讨论的是性能侧的分派;本节讨论诊断侧——把光照拆成可分别观察的通道,让缺陷自己暴露出来。
五个通道
一个点光源 + 环境光,逐像素把光照拆成四个可分别可视化的项,再加一张合成图:
| 通道 | 表达式 | 含义 |
|---|---|---|
| ambient | ambientQ16(常量) | 无光照时的地板 |
| diffuse | ndl = max(0, N·L) | 纯几何项,与距离无关 |
| specular | spec = max(0, N·H)^s | Blinn-Phong,H = normalize(L + V) |
| attenuation | att = r²/(r² + d²) | 距离项 |
| composite | clamp(ambient + (ndl·att + spec)·intensity) × albedo | 最终合成 |
每个通道渲染成一张灰度图,复用卷一就有的 save_gray_png:未覆盖像素置成哨兵值 GRAY_BG,画出来是纯黑,于是「哪里没画到」也一眼可见。把五张图并排摆出来,一个「合成图偏亮」的模糊症状就被拆成「到底是哪一项偏了」的可判问题。
S 层:通道分解与缺陷注入
脚本 sim/cg_shade_14_3_debug_view.py 的关键设计是把衰减做成一个可替换的函数参数:make_terms(lt, eye, shininess, att_fn) 接受 att_fn,正确版传 att_correct,缺陷版传 att_buggy。同一个渲染管线跑两遍,只有衰减那一行不同:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_14_3_debug_view.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_14_3_debug_view.py
"""14.3 光照的调试视图:把每个光照项拆成独立灰度图,用它定位一个真实缺陷。
一个点光源 + 环境光,逐像素把光照拆成四个**可分别可视化**的项:
环境项 ambient(常量,标出「无光照时的地板」)
漫反射 ndl = max(0, N·L)(纯几何项,与距离无关)
镜面项 spec = max(0, N·H)^s(Blinn-Phong,H = normalize(L + V))
衰减项 att = r²/(r² + d²)
最终合成 = clamp(ambient + (ndl·att + spec)·intensity) × albedo。
**用调试视图定位缺陷**:故意把衰减写成「分母用距离 d 而不是 d²」——一个极常见的
「忘了平方」bug。合成图只是整体偏亮、肉眼难判;而把四个通道逐张摆出来,只有**衰减
通道**与正确版不同,环境 / 漫反射 / 镜面三项**逐字节相同**,缺陷因此被精确定位到
衰减那一行。
产物:out/dbg_{ambient,diffuse,specular,attenuation,composite}.png、
out/dbg_attenuation_buggy.png、out/dbg_composite_buggy.png、
out/cg_shade_14_3_debug_view.{json,csv}
"""
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
GRAY_BG = np.iinfo(np.int64).max
SPEC = os.path.join(ROOT, "config", "cg_shade_14_3_debug_view.json")
def att_correct(d2, d, r2):
"""正确:分母用距离**平方** d²。"""
return cglib.fdiv(r2, r2 + d2)
def att_buggy(d2, d, r2):
"""缺陷:分母用了**距离** d(忘了平方)——近处偏亮、远处偏暗。"""
return cglib.fdiv(r2, r2 + d)
def blinn_spec(n, direction, world, eye, shininess):
"""Blinn-Phong 镜面项 max(0, N·H)^s,H = normalize(L + V)。"""
to_eye = cglib.v_norm(cglib.v_sub(eye, world))
h = cglib.v_norm((direction[0] + to_eye[0], direction[1] + to_eye[1],
direction[2] + to_eye[2]))
ndh = cglib.v_dot(n, h)
if ndh <= 0:
return 0
s = ndh
for _ in range(shininess - 1):
s = cglib.fmul(s, ndh)
return s
def make_terms(lt, eye, shininess, att_fn):
"""返回 terms(normal, world) -> (ndl, att, spec),三项皆为 Q16.16。"""
def terms(n, world):
to_light = cglib.v_sub(lt["posQ16"], world)
d = cglib.v_len(to_light)
direction = cglib.v_norm(to_light)
d2 = cglib.fmul(d, d)
att = att_fn(d2, d, lt["r2"])
ndl = cglib.v_dot(n, direction)
if ndl < 0:
ndl = 0
spec = blinn_spec(n, direction, world, eye, shininess)
return ndl, att, spec
return terms
def render_channel(ltris, w, h, terms, pick):
"""把某一项渲染成灰度通道(未覆盖像素置 GRAY_BG,save_gray_png 会画成黑)。"""
def shade(n, world, alb):
v = pick(terms(n, world))
return (v, v, v)
color, _d, covered, _c = L.rasterize_lit(ltris, w, h, GRAY_BG, shade)
return color[:, :, 0], covered
def render_composite(ltris, w, h, bg, ambient, terms, lt):
def shade(n, world, alb):
ndl, att, spec = terms(n, world)
k = ambient + cglib.fmul(cglib.fmul(ndl, att), lt["intensityQ16"]) \
+ cglib.fmul(spec, lt["intensityQ16"])
if k > ONE:
k = ONE
if k < 0:
k = 0
return tuple((alb[i] * k) >> 16 for i in range(3))
color, _d, covered, _c = L.rasterize_lit(ltris, w, h, bg, shade)
return color, covered
def golden_samples(cfg, spec, lt, eye, shininess, count):
terr = spec["terrain"]
seed, hs, extent = terr["noiseSeed"], terr["heightScaleQ16"], 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
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"]
eye = tuple(spec["camera"]["eyeQ16"])
shininess = cfg["shininess"]
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)
lt = L.make_light(cfg["point"])
terms_ok = make_terms(lt, eye, shininess, att_correct)
terms_bad = make_terms(lt, eye, shininess, att_buggy)
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
channels = {
"ambient": (lambda t: ambient, terms_ok),
"diffuse": (lambda t: t[0], terms_ok),
"specular": (lambda t: t[2], terms_ok),
"attenuation": (lambda t: t[1], terms_ok),
}
bufs, covered = {}, None
for name, (pick, terms) in channels.items():
buf, cov = render_channel(ltris, w, h, terms, pick)
bufs[name] = buf
covered = cov
cglib.save_gray_png(os.path.join(out, f"dbg_{name}.png"), buf)
# 缺陷版衰减通道 + 两张合成图
att_bad, _ = render_channel(ltris, w, h, terms_bad, lambda t: t[1])
cglib.save_gray_png(os.path.join(out, "dbg_attenuation_buggy.png"), att_bad)
comp_ok, _ = render_composite(ltris, w, h, bg, ambient, terms_ok, lt)
comp_bad, _ = render_composite(ltris, w, h, bg, ambient, terms_bad, lt)
cglib.save_png(os.path.join(out, "dbg_composite.png"), comp_ok)
cglib.save_png(os.path.join(out, "dbg_composite_buggy.png"), comp_bad)
# 通道级比对:缺陷版相对正确版,哪些通道变了、变多少
cov = covered
rows, loc = [], {}
for name in ("ambient", "diffuse", "specular", "attenuation"):
d = np.abs(bufs[name][cov] - (att_bad if name == "attenuation" else bufs[name])[cov])
ndiff = int((d > 0).sum())
loc[name] = {"pixels": int(cov.sum()), "diff_pixels": ndiff,
"max_abs_q16": int(d.max()) if d.size else 0}
rows.append([name, int(cov.sum()), ndiff,
int(d.max()) if d.size else 0])
d_comp = np.abs(comp_ok[cov].astype(np.int64) - comp_bad[cov].astype(np.int64))
loc["composite"] = {"pixels": int(cov.sum()), "diff_pixels": int((d_comp > 0).any(axis=1).sum()),
"max_abs_8bit": int(d_comp.max())}
rows.append(["composite", int(cov.sum()), int((d_comp > 0).any(axis=1).sum()),
int(d_comp.max())])
# 供 P 层复核的 golden:正确/缺陷两版的三项
gold_pts = golden_samples(cfg, spec, lt, eye, shininess, cfg["goldenSamples"])
golden = []
for wp, nrm in gold_pts:
ok = terms_ok(nrm, wp)
bad = terms_bad(nrm, wp)
golden.append({"world": list(wp), "normal": list(nrm),
"correct": {"ndl": ok[0], "att": ok[1], "spec": ok[2]},
"buggy": {"att": bad[1]}})
cglib.write_csv(os.path.join(out, "cg_shade_14_3_debug_view.csv"),
["channel", "covered", "diff_pixels", "max_abs"], rows)
result = {
"ambientQ16": ambient, "canvas": [w, h], "shininess": shininess,
"tris_drawn": len(ltris), "dropped": dropped,
"light": {"posQ16": list(lt["posQ16"]), "rangeQ16": lt["rangeQ16"],
"r2": lt["r2"], "intensityQ16": lt["intensityQ16"]},
"eyeQ16": list(eye), "camera": spec["camera"], "width": w, "height": h,
"defect": "attenuation 分母用了距离 d 而非 d²(忘了平方)",
"channels": loc, "golden": golden,
}
with open(os.path.join(out, "cg_shade_14_3_debug_view.json"), "w", encoding="utf-8") as fh:
json.dump(result, fh, ensure_ascii=False, indent=2)
print(f"几何 : {len(verts)} 顶点 / {len(tris)} 三角形 覆盖 {int(cov.sum())}")
print(f"{'通道':<12} {'覆盖像素':>8} {'差异像素':>8} {'最大差':>8}")
for name, covn, nd, mx in rows:
print(f"{name:<12} {covn:>8} {nd:>8} {mx:>8}")
print(f"缺陷 : {result['defect']}")
print(f"定位 : 只有 attenuation / composite 两通道变化,"
f"环境 / 漫反射 / 镜面逐字节相同")
print("png : out/dbg_{ambient,diffuse,specular,attenuation,composite}.png")
return 0
if __name__ == "__main__":
sys.exit(main())
blinn_spec 的 shininess 取 8 而不是 32:8 次幂下镜面项还能在相当一部分采样点上非零(24 个采样点里 14 个非零、最大 41956),而 32 次幂会让 ndh^32 在大多数像素上下溢成 0,调试通道就退化成一张全黑图——调试视图本身也要保证「有东西可看」,这是选参数时容易被忽略的一条。
一个真实缺陷:分母忘了平方
注入的缺陷极常见:衰减写成分母用距离 d 而不是 d²:
正确:att = fdiv(r², r² + d²) # d=0 → 1,d=r → 1/2,d→∞ → 0
缺陷:att = fdiv(r², r² + d) # 近处偏亮、远处偏暗,衰减曲线整体被拉平
合成图上这只是「整体偏亮一点」,肉眼很难判。但把通道逐张摆出来,结论是斩钉截铁的:
几何 : 487 顶点 / 896 三角形 覆盖 43731
通道 覆盖像素 差异像素 最大差
ambient 43731 0 0
diffuse 43731 0 0
specular 43731 0 0
attenuation 43731 43731 47042
composite 43731 31582 48
缺陷 : attenuation 分母用了距离 d 而非 d²(忘了平方)
定位 : 只有 attenuation / composite 两通道变化,环境 / 漫反射 / 镜面逐字节相同
png : out/dbg_{ambient,diffuse,specular,attenuation,composite}.png
衰减通道 43731 个像素全变(最大差 47042 Q16.16 ≈ 0.72),而 ambient / diffuse / specular 三个通道的差异像素数恰好为 0——逐字节相同。缺陷因此被精确定位到「衰减那一行」,而不是「光照哪里都错了」。合成图的差异像素是 31582、最大差只有 48(8 位)——比衰减通道小得多,这正是「为什么直接看合成图看不出来」的定量证据:缺陷在合成前是 0.72 的大偏差,被 albedo 与 clamp 一压,到合成图上只剩不到 1/5 的 8 位强度差。
P 层:跨语言复核
// snippet: core/test/cg_shade_14_3_debug_view.test.ts(节选:三项 golden 与缺陷定位)
test("【核心】golden 采样点上三光照项 ndl / att / spec 与 S 侧逐项相同", () => {
let specNonZero = 0;
for (let i = 0; i < stats.golden.length; i += 1) {
const g = stats.golden[i];
const t = terms(V(g.normal), V(g.world));
assert.equal(Number(t.ndl), g.correct.ndl, `ndl @${i}`);
assert.equal(Number(t.att), g.correct.att, `att @${i}`);
assert.equal(Number(t.spec), g.correct.spec, `spec @${i}`);
if (t.spec > 0n) specNonZero += 1;
}
assert.ok(specNonZero > 0, "镜面项至少要在一个采样点上非零,否则该判据无区分力");
});
test("通道级定位:只有 attenuation 通道变化,环境 / 漫反射 / 镜面逐像素相同", () => {
const c = stats.channels;
assert.equal(c.ambient.diff_pixels, 0);
assert.equal(c.diffuse.diff_pixels, 0);
assert.equal(c.specular.diff_pixels, 0);
assert.equal(c.attenuation.diff_pixels, c.attenuation.pixels,
"衰减通道应全部像素都变——缺陷正落在这里");
assert.ok(c.composite.diff_pixels > 0, "缺陷最终会污染合成图");
assert.ok(c.composite.diff_pixels < c.composite.pixels,
"合成图的差异像素应少于全部——镜面高光处两版仍可能相近");
});
node --test 'core/test/cg_shade_14_3_debug_view.test.ts' 的实测输出:
✔ 【核心】golden 采样点上三光照项 ndl / att / spec 与 S 侧逐项相同
✔ 缺陷被精确限制在衰减项:正确 / 缺陷两版除 att 外完全一致
✔ 通道级定位:只有 attenuation 通道变化,环境 / 漫反射 / 镜面逐像素相同
ℹ tests 3 ℹ pass 3 ℹ fail 0
3 条全绿。TS 侧用 BigInt 独立重建 ndl / att / spec 与 S 侧 golden 逐项比对,并额外断言「镜面项至少一个采样点非零」——没有这条,一张全黑的镜面通道也能骗过逐项比对。第 14 章到此闭环:光源被统一表示(14.1)、被批量求值并计价(14.2)、出问题时能被通道分解精确定位(14.3)。下一章把同一套着色搬进不同语言,看逐位一致能维持到什么程度。
阅读导航:上一节:14.2 光照的批处理与代价 · 下一节:15.1 跨语言着色对照 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。