15.1 跨语言着色对照

同一份场景规格在 Python 与 TypeScript 两条 CPU 路径下产出逐字节相同的像素缓冲。把着色器写成可参数化形式,五个负例各只动一处口径——降精度改用除法、插值后不归一化、逐项钳制、1/π 取 20860、albedo 四舍五入——每个都让 sha256 分叉,七个摘要两两不同,退化档复现卷二基线。

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_truncQ16.16 乘法降精度从 >> 换成向零截断除法
P2_normalize_before法线在插值前归一化、插值后不再归一化
P3_clamp_each_termN·L 从「求和后钳制」改成「逐项钳制后求和」
P4_inv_pi_208601/π 常数取 20860 而不是 20861(差 1 个 Q16.16 单位)
P5_albedo_roundalbedo 插值用四舍五入而不是 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 中间缓冲的着色验证 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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