15.1 跨语言着色对照
「着色对不对」在实时渲染里常常靠肉眼,在可复现的渲染器里靠摘要。站内的 《技术美术与管线工程》
关心的是管线闸门与预算——把渲染拆成阶段、给每个阶段定预算、用闸门卡住回归;本节关心另一件事:同一份场景规格,在 Python 与 TypeScript 两条 CPU 路径下,能不能给出逐字节相同的像素缓冲。判据只有一个 sha256。
一条管线,两个宿主
本章说的「跨语言」只指 Python 与 TypeScript 两条 CPU 路径:S 层是 numpy 向量化的整数光栅化器,P 层是定点内核。GPU 不在此列——GPU 的光栅化规则(top-left 规则、子像素精度、浮点插值)与 CPU 不同,不可能逐位一致,那一类比对只能按声明容差做(见 12.3 节)。本节的两条路径都是整数运算,所以可以要求逐字节相同。
验证方式不是「看起来一样」,而是两件事同时成立:摘要相等说明忠实复现,扰动分叉说明这个摘要真的对运算细节敏感。一个对任何改动都无所谓的摘要,证明不了任何东西。
铁律:降精度一律右移
定点数(Q16.16)降精度的唯一正确做法是算术右移 >>。Python 的 // 向下取整,JS BigInt 的 / 向零截断——两者在负数上差 1:-1 // 65536 = -1,而 -1n / 65536n = 0n。只要有一处降精度用了除法,两条路径立刻分叉。这条纪律贯穿整章:顶点层用 Python int / JS BigInt(矩阵链中间量超 2^53),逐像素层用 Number / numpy int64(量级已证 ≤ 2^49.6)。
参数化的着色器
把逐像素 BRDF-Lambert 着色器写成可参数化的形式:同一条公式,五个旋钮各控制一处最容易出错的口径。
ndl = clamp(Σ_i fmul(n_i, L_i), >= 0) # N·L,逐项 Q16.16 乘
k = clamp(ambient + fmul(fmul(ndl, I), f_r), <= ONE)
c_i = (albedo_i * k) >> 16
f_r 是 Lambert BRDF 的 1/π 归一化系数(round(ONE/π) = 20861,与 2.1 节同源)。取 f_r = ONE 时 fmul(x, ONE) == x,整条管线退化成卷二基线的朴素 Lambert,必须复现 0d997343ca547ed5…。
五个负例
| 负例 | 只动这一处口径 |
|---|---|
P1_shift_to_trunc | Q16.16 乘法降精度从 >> 换成向零截断除法 |
P2_normalize_before | 法线在插值前归一化、插值后不再归一化 |
P3_clamp_each_term | N·L 从「求和后钳制」改成「逐项钳制后求和」 |
P4_inv_pi_20860 | 1/π 常数取 20860 而不是 20861(差 1 个 Q16.16 单位) |
P5_albedo_round | albedo 插值用四舍五入而不是 floor |
每个负例只改一处语义,其余全部不动。判据不被负例拒绝,则 PASS 不可信——若某个负例的摘要与基线相同,说明判据根本没测到那一处。
S 层:七个模式的摘要
驱动脚本把 cgshade.rasterize_shaded 的骨架逐字复刻成可参数化版本,对七个模式各渲染一帧、取摘要:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_15_1_cross_language.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_15_1_cross_language.py
"""15.1 跨语言着色对照(S 层驱动)。
把「逐像素 BRDF-Lambert 着色器」写成**可参数化**的整数管线:同一条公式,
五个旋钮各控制一处最容易出错的口径,另有一个 `f_r = ONE` 的退化模式用来
锚定卷一卷二的既有基线。
着色公式(全 Q16.16,降精度一律 `>>`):
ndl = clamp(Σ_i fmul(n_i, L_i), >= 0) # N·L,逐项 Q16.16 乘
k = clamp(ambient + fmul(fmul(ndl, I), f_r), <= ONE)
c_i = (albedo_i * k) >> 16
`f_r` 是 Lambert BRDF 的 1/π 归一化系数(= 20861);取 `f_r = ONE` 时
`fmul(x, ONE) == x`,整条管线退化成卷二基线的朴素 Lambert,必须复现
`0d997343ca547ed5…`。
五个负例(每个只动一处,必须让像素缓冲 sha256 分叉):
P1 shift_to_trunc Q16.16 乘法的降精度从算术右移 `>>` 换成向零截断除法
P2 normalize_before 法线在插值**前**归一化、插值后不再归一化
P3 clamp_each_term N·L 三项从「求和后钳制」改成「逐项钳制后求和」
P4 inv_pi_20860 BRDF 常数取 20860 而不是 20861(差 1 个 Q16.16 单位)
P5 albedo_round albedo 插值用四舍五入而不是 floor
产物:out/cg_shade_15_1_cross_language.json、out/cg_shade_15_1_baseline.rgb。
"""
import hashlib
import json
import os
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cgshade # noqa: E402
import render_shade # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
SUB1 = cglib.SUB1
INV_PI = 20861 # round(ONE / π),与 2.1 节的 BRDF 归一化同源
# 卷二基线的逐像素 sha256(`f_r = ONE` 的退化模式必须复现它)
PERPIXEL_BASELINE = "0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de"
MODES = {
"baseline": {"inv_pi": INV_PI},
"P1_shift_to_trunc": {"inv_pi": INV_PI, "trunc_fmul": True},
"P2_normalize_before": {"inv_pi": INV_PI, "skip_renorm": True},
"P3_clamp_each_term": {"inv_pi": INV_PI, "clamp_each_term": True},
"P4_inv_pi_20860": {"inv_pi": 20860},
"P5_albedo_round": {"inv_pi": INV_PI, "albedo_round": True},
"f_r_one": {"inv_pi": ONE},
}
def fmul_mode(a, b, trunc):
"""Q16.16 乘:降精度默认算术右移(floor);`trunc` 时换成向零截断除法。"""
if not trunc:
return (a * b) >> 16
p = a * b
q = abs(p) >> 16
return -q if p < 0 else q
def make_shader(spec, mode):
"""参数化的逐像素着色回调;`shade(normal, albedo) -> (r, g, b)`。"""
dlight = spec["directionalLight"]
ambient = spec["ambientQ16"]
dir_q16 = tuple(dlight["dirQ16"])
intensity = dlight["intensityQ16"]
inv_pi = mode["inv_pi"]
trunc = mode.get("trunc_fmul", False)
clamp_each = mode.get("clamp_each_term", False)
def shade(normal, albedo):
if clamp_each:
# 逐项钳制 >= 0 后求和(钳制时机从「求和后」移到「求和前」)
ndl = sum(t for t in (fmul_mode(normal[i], dir_q16[i], trunc) for i in range(3)) if t > 0)
else:
ndl = sum(fmul_mode(normal[i], dir_q16[i], trunc) for i in range(3))
if ndl < 0:
ndl = 0
k = ambient + fmul_mode(fmul_mode(ndl, intensity, trunc), inv_pi, trunc)
if k > ONE:
k = ONE
return tuple((albedo[i] * k) >> 16 for i in range(3))
return shade
def rasterize_mode(tris, width, height, background, shade, mode):
"""`cgshade.rasterize_shaded` 的可参数化镜像(骨架逐字复用)。"""
color = np.empty((height, width, 3), dtype=np.int64)
color[:, :] = background
depth = np.full((height, width), np.iinfo(np.int64).max, dtype=np.int64)
covered = np.zeros((height, width), dtype=bool)
half = SUB1 // 2
culled = 0
for t in tris:
x0, y0, d0 = t[0], t[1], t[2]
x1, y1, d1 = t[3], t[4], t[5]
x2, y2, d2 = t[6], t[7], t[8]
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
if area <= 0:
culled += 1
continue
minx = max(0, min(x0, x1, x2) // SUB1)
maxx = min(width - 1, max(x0, x1, x2) // SUB1)
miny = max(0, min(y0, y1, y2) // SUB1)
maxy = min(height - 1, max(y0, y1, y2) // SUB1)
if minx > maxx or miny > maxy:
continue
px = np.arange(minx, maxx + 1, dtype=np.int64) * SUB1 + half
py = np.arange(miny, maxy + 1, dtype=np.int64) * SUB1 + half
PX, PY = np.meshgrid(px, py)
w0 = (x2 - x1) * (PY - y1) - (y2 - y1) * (PX - x1)
w1 = (x0 - x2) * (PY - y2) - (y0 - y2) * (PX - x2)
w2 = (x1 - x0) * (PY - y0) - (y1 - y0) * (PX - x0)
mask = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
if not mask.any():
continue
z = (w0 * d0 + w1 * d1 + w2 * d2) // area
sub_d = depth[miny : maxy + 1, minx : maxx + 1]
take = mask & (z < sub_d)
if not take.any():
continue
nrm = [(w0 * t[9 + ch] + w1 * t[12 + ch] + w2 * t[15 + ch]) // area for ch in range(3)]
alb = []
for ch in range(3):
num = w0 * t[18 + ch] + w1 * t[21 + ch] + w2 * t[24 + ch]
alb.append((num + area // 2) // area if mode.get("albedo_round") else num // area)
sub_c = color[miny : maxy + 1, minx : maxx + 1]
sub_v = covered[miny : maxy + 1, minx : maxx + 1]
ys, xs = np.nonzero(take)
for i, j in zip(ys, xs):
raw = (int(nrm[0][i, j]), int(nrm[1][i, j]), int(nrm[2][i, j]))
n = raw if mode.get("skip_renorm") else cglib.v_norm(raw)
sub_c[i, j] = shade(n, (int(alb[0][i, j]), int(alb[1][i, j]), int(alb[2][i, j])))
sub_d[:] = np.where(take, z, sub_d)
sub_v[:] = sub_v | take
return color, depth, covered, culled
def main():
t0 = time.perf_counter()
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
tris, total, dropped = render_shade.build_scene_shaded(spec)
results, colors = {}, {}
for name, mode in MODES.items():
color, _depth, covered, culled = rasterize_mode(
tris, width, height, spec["backgroundQ8"], make_shader(spec, mode), mode
)
px = np.clip(color, 0, 255).astype(np.uint8)
colors[name] = px
results[name] = {
"sha256": cgshade.digest(px),
"covered": int(covered.sum()),
"culled": culled,
}
base = colors["baseline"]
for name in MODES:
if name == "baseline":
results[name]["divergent_vs_baseline"] = 0
continue
results[name]["divergent_vs_baseline"] = int((base != colors[name]).any(axis=2).sum())
digests = [results[n]["sha256"] for n in MODES]
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "cg_shade_15_1_baseline.rgb"), "wb") as fh:
fh.write(base.tobytes())
record = {
"canvas": [width, height],
"triangles_total": total,
"triangles_dropped": dropped,
"inv_pi_q16": INV_PI,
"per_pixel_baseline": PERPIXEL_BASELINE,
"f_r_one_matches_baseline": results["f_r_one"]["sha256"] == PERPIXEL_BASELINE,
"distinct_digests": len(set(digests)),
"modes": results,
}
with open(os.path.join(out, "cg_shade_15_1_cross_language.json"), "w", encoding="utf-8") as fh:
json.dump(record, fh, ensure_ascii=False, indent=2)
print(f"canvas : {width}x{height} {total} 三角形 dropped {dropped}")
print(f"1/π (Q16.16) : {INV_PI}")
print(f"f_r=ONE 复现卷二基线 : {record['f_r_one_matches_baseline']}")
for name in MODES:
r = results[name]
tag = "" if name == "baseline" else f" 分歧 {r['divergent_vs_baseline']}"
print(f" {name:20s} {r['sha256'][:16]}… 覆盖 {r['covered']} 剔除 {r['culled']}{tag}")
print(f"两两不同的摘要数 : {record['distinct_digests']} / {len(MODES)}")
print(f"elapsed : {round((time.perf_counter() - t0) * 1000, 1)} ms")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑(/opt/homebrew/bin/python3 sim/cg_shade_15_1_cross_language.py):
canvas : 320x240 896 三角形 dropped 0
1/π (Q16.16) : 20861
f_r=ONE 复现卷二基线 : True
baseline d24da7b867655e69… 覆盖 43731 剔除 290
P1_shift_to_trunc 5b1771bbdd8ee96c… 覆盖 43731 剔除 290 分歧 44
P2_normalize_before 4b5d3655dd7b34e8… 覆盖 43731 剔除 290 分歧 17406
P3_clamp_each_term c2fbf1c764b1c888… 覆盖 43731 剔除 290 分歧 29548
P4_inv_pi_20860 9f5a3626d6d1c34a… 覆盖 43731 剔除 290 分歧 103
P5_albedo_round 219d2c85c0ee7776… 覆盖 43731 剔除 290 分歧 18733
f_r_one 0d997343ca547ed5… 覆盖 43731 剔除 290 分歧 41965
两两不同的摘要数 : 7 / 7
f_r=ONE 复现卷二基线 : True 是第一层证据——参数化实现没有引入任何偏差。七个 sha256 两两不同是第二层证据——摘要对五处口径都敏感。五个负例里 P4(1/π 差 1 个 Q16.16 单位)最隐蔽:它只让 103 个像素的通道差 1,但摘要照样分叉;P3(逐项钳制)影响 29548 个像素,是最「响」的一处。
P 层:逐字节复现
TypeScript 侧重写同一套参数化管线,断言与 Python 侧逐字节相同,且五个负例各自分叉:
// snippet: core/test/cg_shade_15_1_cross_language.test.ts(节选:逐字节判据与五个负例)
const NEGATIVES = ["P1_shift_to_trunc", "P2_normalize_before", "P3_clamp_each_term", "P4_inv_pi_20860", "P5_albedo_round"];
test("baseline:TypeScript 与 Python 的像素缓冲逐字节相同", () => {
const r = renderMode(spec, MODES.baseline);
assert.equal(sha(r.rgb), g.modes.baseline.sha256);
assert.equal(r.coveredCount, g.modes.baseline.covered);
});
test("【核心】五个负例:摘要逐字节相同且各自分叉", () => {
for (const name of NEGATIVES) {
const r = renderMode(spec, MODES[name]);
assert.equal(sha(r.rgb), g.modes[name].sha256, `${name} 摘要与 Python 侧不符`);
assert.notEqual(sha(r.rgb), g.modes.baseline.sha256, `${name} 未分叉`);
assert.ok(g.modes[name].divergent_vs_baseline > 0, `${name} 分歧像素必须 > 0`);
}
});
ℹ tests 4 ℹ pass 4 ℹ fail 0
四条断言分别锁住:baseline 逐字节相同、五个负例逐字节相同且各自分叉、f_r=ONE 复现卷二基线、七个摘要两两不同。
小结
摘要相等、扰动分叉、退化档复现基线——三件事同时成立,跨语言着色的一致性才是可证伪的。但「分叉了」只告诉我们「错了」,没告诉我们「错在哪一段」。下一节把四张中间缓冲各自导出并断言,为定位做准备。
阅读导航:上一节:14.3 光照的调试视图 · 下一节:15.2 中间缓冲的着色验证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。