15.3 偏差的定位方法

给定一张「错了的图」,按二分定位法逐级比对覆盖率、法线、albedo 与最终颜色四张缓冲,把偏差锁到管线的某一段。差异数字本身就是线索:等于覆盖像素数说明逐像素操作出问题,只有几十个说明舍入或常数出问题。五个负例各定位到对应阶段,并对「插值后漏归一化」做一次完整演练,从法线图差异 43731 一路定位到漏掉的那一行。

15.3 偏差的定位方法

有了四张中间缓冲做证据,定位就变成了二分。站内的 《技术美术与管线工程》 用闸门卡住回归;本节给出闸门后面的那一步——闸门报警之后,怎么找到出错的那一行。

逐级二分

给定一张「错了的图」,按管线顺序逐级比对四张缓冲,前一级相同、下一级不同,偏差就锁在那一段:

1. 覆盖率图 —— 几何对不对?(光栅化 / 剔除 / 深度)
2. 法线图   —— 插值 / 归一化对不对?
3. albedo 图 —— 属性插值对不对?
4. 最终颜色 —— 前三级都对,则偏差在着色本身

这是逐级二分:每一步把「嫌疑范围」从整条管线缩到一半。四级全相同却仍不一致的情况不存在——颜色由前三级决定,前三级相同则颜色必相同。

决策表

五个负例各动一处口径,定位结果如下:

mode                       cov      nrm      alb      col  localized
P1_shift_to_trunc            0        0        0       44  color
P2_normalize_before          0    43731        0    17406  normal
P3_clamp_each_term           0        0        0    29548  color
P4_inv_pi_20860              0        0        0      103  color
P5_albedo_round              0        0    35215    18733  albedo

读表的关键是首个非零列:P2 在法线图(第 2 级)就分叉,所以定位到 normal;P5 在 albedo 图(第 3 级)分叉,定位到 albedo;P1/P3/P4 前三级全为 0,只在颜色分叉,定位到 color(着色本身)。五类负例的覆盖率差异全为 0——几何封版,偏差纯粹来自着色。

S 层:定位脚本

脚本 sim/cg_shade_15_3_localize.py 逐级比对并输出决策表,再对 P2 做一次完整演练:

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_15_3_localize.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_15_3_localize.py
"""15.3 偏差的定位方法(S 层)。

给定一张「错了的图」,按**二分定位法**逐级比对四张缓冲,把偏差定位到
管线的某一段:

  1. 覆盖率图 —— 几何对不对?(光栅化/剔除/深度)
  2. 法线图   —— 插值/归一化对不对?
  3. albedo 图 —— 属性插值对不对?
  4. 最终颜色 —— 前三级都对,则偏差在**着色**本身。

前一级相同、下一级不同,偏差就锁定在那一段;这是**逐级二分**:每一步把
「嫌疑范围」从整条管线缩到一半。表里给出五个负例的定位结果,并对
`P2_normalize_before` 做一次完整演练——从「法线图不同」一路定位到
「插值后漏掉了 `v_norm`」这一行。

产物:out/cg_shade_15_3_localize.json、out/cg_shade_15_3_decision.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 render_shade  # noqa: E402
import cg_shade_15_1_cross_language as cl  # noqa: E402
import cg_shade_15_2_buffers as b2  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

ONE = cglib.ONE

STAGES = ["coverage", "normal", "albedo", "color"]
NEGATIVES = ["P1_shift_to_trunc", "P2_normalize_before", "P3_clamp_each_term", "P4_inv_pi_20860", "P5_albedo_round"]


def capture(spec, tris, mode):
    shade = cl.make_shader(spec, mode)
    return b2.rasterize_capture(tris, spec["width"], spec["height"], spec["backgroundQ8"], shade, mode)


def diff_pixels(a, b):
    """两个缓冲不同的像素数(任一通道不同即算)。"""
    return int((np.asarray(a) != np.asarray(b)).any(axis=2).sum())


def localize(base, cand):
    """逐级比对,返回 (各阶段差异, 首个分歧阶段)。"""
    diffs = {
        "coverage": int((base[2] ^ cand[2]).sum()),
        "normal": diff_pixels(base[3], cand[3]),
        "albedo": diff_pixels(base[4], cand[4]),
        "color": diff_pixels(base[0], cand[0]),
    }
    first = next((s for s in STAGES if diffs[s] > 0), "none")
    return diffs, first


def normal_lengths(nbuf, covered):
    """覆盖像素上法线的整数模长统计。"""
    vals = [cglib.v_len((int(nbuf[i, j, 0]), int(nbuf[i, j, 1]), int(nbuf[i, j, 2])))
            for i, j in zip(*np.nonzero(covered))]
    return int(min(vals)), int(max(vals))


def main():
    spec = cglib.load_spec()
    tris, _total, _dropped = render_shade.build_scene_shaded(spec)

    base = capture(spec, tris, cl.MODES["baseline"])
    b_cov, b_nrm, b_alb = base[2], base[3], base[4]
    b_len = normal_lengths(b_nrm, b_cov)

    rows = []
    for name in NEGATIVES:
        cand = capture(spec, tris, cl.MODES[name])
        diffs, first = localize(base, cand)
        rows.append({"mode": name, **diffs, "localized_stage": first})

    # ---- 演练:P2_normalize_before 从「法线图不同」定位到具体一行 ----
    cand = capture(spec, tris, cl.MODES["P2_normalize_before"])
    c_len = normal_lengths(cand[3], cand[2])
    drill = {
        "mode": "P2_normalize_before",
        "coverage_diff": int((b_cov ^ cand[2]).sum()),
        "normal_diff": diff_pixels(b_nrm, cand[3]),
        "baseline_normal_len": list(b_len),
        "deviated_normal_len": list(c_len),
        "verdict": "normal interpolation without re-normalization",
        "line": "n = cglib.v_norm(raw)  —— 插值后必须归一化;漏掉这一行",
    }

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    cglib.write_csv(
        os.path.join(out, "cg_shade_15_3_decision.csv"),
        ["mode", "coverage_diff", "normal_diff", "albedo_diff", "color_diff", "localized_stage"],
        [[r["mode"], r["coverage"], r["normal"], r["albedo"], r["color"], r["localized_stage"]] for r in rows],
    )
    record = {
        "canvas": [spec["width"], spec["height"]],
        "stages": STAGES,
        "decision_table": rows,
        "drilldown": drill,
    }
    with open(os.path.join(out, "cg_shade_15_3_localize.json"), "w", encoding="utf-8") as fh:
        json.dump(record, fh, ensure_ascii=False, indent=2)

    print(f"{'mode':22s} {'cov':>7s} {'nrm':>8s} {'alb':>8s} {'col':>8s}  localized")
    for r in rows:
        print(f"{r['mode']:22s} {r['coverage']:7d} {r['normal']:8d} {r['albedo']:8d} {r['color']:8d}  {r['localized_stage']}")
    print(f"\n演练 P2:覆盖率差异 {drill['coverage_diff']}(几何 OK)  法线图差异 {drill['normal_diff']}")
    print(f"  baseline |n|  : {b_len}   deviated |n| : {c_len}")
    print(f"  定位           : {drill['line']}")
    return 0


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

实跑(/opt/homebrew/bin/python3 sim/cg_shade_15_3_localize.py):

mode                       cov      nrm      alb      col  localized
P1_shift_to_trunc            0        0        0       44  color
P2_normalize_before          0    43731        0    17406  normal
P3_clamp_each_term           0        0        0    29548  color
P4_inv_pi_20860              0        0        0      103  color
P5_albedo_round              0        0    35215    18733  albedo

演练 P2:覆盖率差异 0(几何 OK)  法线图差异 43731
  baseline |n|  : (65533, 65537)   deviated |n| : (60148, 65531)
  定位           : n = cglib.v_norm(raw)  —— 插值后必须归一化;漏掉这一行

演练:从「法线图不同」到「漏掉的那一行」

P2_normalize_before 是最值得演练的一个——它演的是「归一化时机」这个经典坑:

  • 覆盖率差异 0 → 几何没问题,排除光栅化 / 剔除 / 深度。
  • 法线图差异 43731(等于全部覆盖像素)→ 每个像素的法线都被改动了,嫌疑锁定在法线这一级。
  • 看模长:baseline |n| ∈ (65533, 65537),deviated |n| ∈ (60148, 65531) → 偏差版本的法线明显短于单位长(最短 60148 ≈ 0.918×ONE)。
  • 结论:法线是插值后没归一化。插值得到的 raw 是三个顶点法线的加权和,长度一般 ≠ ONE;正确写法是 n = cglib.v_norm(raw),漏掉这一行,法线就变短,Lambert 的 N·L 随之偏小,整幅图变暗。

这正是「逐级二分」的价值:差异数字本身就是线索。差异等于覆盖像素数(43731)说明「每个像素都变了」,指向逐像素级的操作;差异只有 44 或 103 说明「只有极少数像素踩到了边界」,指向舍入 / 常数级的操作。

P 层:独立重跑二分

TypeScript 侧独立重跑 localize(),断言五个负例的四级差异与「首个分歧阶段」与 S 层决策表逐项一致:

// snippet: core/test/cg_shade_15_3_localize.test.ts(节选:独立重跑二分定位)
const STAGES = ["coverage", "normal", "albedo", "color"];

function localize(base: CaptureResult, cand: CaptureResult) {{
  const diffs: Record<string, number> = {{ coverage: 0, normal: 0, albedo: 0, color: 0 }};
  for (let i = 0; i < W * H; i += 1) {{
    if (base.covered[i] !== cand.covered[i]) diffs.coverage += 1;
    let n = false, a = false, c = false;
    for (let k = 0; k < 3; k += 1) {{
      if (base.normal[i * 3 + k] !== cand.normal[i * 3 + k]) n = true;
      if (base.albedo[i * 3 + k] !== cand.albedo[i * 3 + k]) a = true;
      if (base.rgb[i * 3 + k] !== cand.rgb[i * 3 + k]) c = true;
    }}
    if (n) diffs.normal += 1;
    if (a) diffs.albedo += 1;
    if (c) diffs.color += 1;
  }}
  const first = STAGES.find((s) => diffs[s] > 0) ?? "none";
  return {{ diffs, first }};
}}

test("【核心】五个负例的四级差异与首个分歧阶段与 S 层逐项一致", () => {{
  for (const row of g.decision_table) {{
    const {{ diffs, first }} = localize(base, captureMode(spec, MODES[row.mode as string]));
    assert.equal(diffs.normal, row.normal, `${{row.mode}} 法线图差异不符`);
    assert.equal(first, row.localized_stage, `${{row.mode}} 定位阶段不符`);
  }}
}});
ℹ tests 3  ℹ pass 3  ℹ fail 0

小结

覆盖率定位几何、法线图定位插值、albedo 图定位属性、颜色定位着色——四级缓冲就是一条定位流水线。这套方法不依赖任何具体场景:换一份规格、换一种着色器,只要中间缓冲还能单独导出,二分定位就还能用。下一章进入装配,把前面所有环节拼成一条从着色到成像的完整管线。

阅读导航:上一节:15.2 中间缓冲的着色验证 · 下一节:16.1 着色管线的装配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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