14.3 光照的调试视图

把光照拆成环境 / 漫反射 / 镜面 / 衰减四张灰度图,再加一张合成图。故意把衰减写成 r²/(r²+d)(分母忘了平方),衰减通道 43731/43731 个像素全变、最大差 47042,而环境 / 漫反射 / 镜面三通道逐字节相同——缺陷被精确定位到衰减那一行。

14.3 光照的调试视图

前两节把光源的表示与批处理都做成了可验算的东西,但真实开发里更常见的场景是:画面就是不对,但你不知道是哪一项不对。 站内的 集群前向渲染 讨论的是性能侧的分派;本节讨论诊断侧——把光照拆成可分别观察的通道,让缺陷自己暴露出来。

五个通道

一个点光源 + 环境光,逐像素把光照拆成四个可分别可视化的项,再加一张合成图:

通道表达式含义
ambientambientQ16(常量)无光照时的地板
diffusendl = max(0, N·L)纯几何项,与距离无关
specularspec = max(0, N·H)^sBlinn-Phong,H = normalize(L + V)
attenuationatt = r²/(r² + d²)距离项
compositeclamp(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 跨语言着色对照 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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