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 着色管线的装配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。