1.3 着色频率与插值

同一份场景、同一套顶点变换、同一个 Lambert 公式,只把「插值颜色」换成「插值法线再逐像素求值」:覆盖掩码与深度缓冲逐像素相同(几何封版),颜色却有 41261 个像素不同(537.2526‰),MAE 3.5764、PSNR 37.06 dB、最大通道差 48。

1.3 着色频率与插值

前两节把漫反射与高光都写成了整数。这一节不引入新公式,只改一件事:光照在哪一级求值。这是本卷的开篇论点——卷一把「几何到像素」的骨架封版,卷二只换着色环节。站内 /graphics-lighting-shadows/ 那篇讲的是 GPU 上这两个阶段的工程代价;这里要把它变成可断言的数字。

几何封版:只换着色环节

两条路径共用 config/scene_spec.json、同一套顶点变换、同一个 mvp 投影:

  • 逐顶点(Gouraud):每个顶点算一次颜色,光栅器插值颜色。这是卷一的路径。
  • 逐像素:每个顶点只输出法线,光栅器插值法线,每个像素各求一次光。这是卷二的路径。

两者唯一的差别是「法线在哪里归一化、光照在哪里求值」。因为 rasterize_shaded 的三角形布局与卷一的 rasterize 逐字段对应,几何阶段一个字节都没动。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_01_3_frequency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_01_3_frequency.py
"""1.3 着色频率与插值:几何封版、只换着色环节,代价有多大。

同一份 config/scene_spec.json、同一套顶点变换与投影、同一个 Lambert 公式,两条路径:

  逐顶点 (Gouraud)  render_raster.build_scene + cglib.rasterize     —— 插值**颜色**
  逐像素 (Phong 着色) render_shade.build_scene_shaded + cgshade.rasterize_shaded —— 插值**法线**

两者唯一的差别是「法线在哪里归一化、光照在哪里求值」。脚本核对:

  1. 逐顶点路径逐字节复现卷一基线 sha256(几何与顶点色都没动)
  2. 逐像素路径得到卷二新基线 sha256
  3. **覆盖掩码与深度缓冲逐像素相同**——几何封版的直接证据
  4. 颜色差异的规模:差异像素数 / 占比 / MAE / PSNR / 最大通道差
  5. 差异的空间分布:16x16 分块里最密的块在哪,以及最大通道差的直方图

产物:out/cg_shade_01_3_frequency.json、out/cg_shade_01_3_diff.png、
      out/cg_shade_01_3_hist.csv、out/cg_shade_01_3_gouraud.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_raster  # noqa: E402
import render_shade  # noqa: E402

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

GOURAUD_BASELINE = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced"
PERPIXEL_BASELINE = "0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de"

TILE = 16
BINS = [0, 1, 3, 5, 9, 17, 33, 65, 256]


def render_gouraud(spec):
    """卷一逐顶点路径:插值颜色。投影块与 render_shade.main 中的逐顶点分支同序。"""
    width, height = spec["width"], spec["height"]
    cam = spec["camera"]
    mvp = cglib.mat_mul(
        cglib.mat_perspective(cam["tanHalfFovYQ16"],
                              cglib.fdiv(cglib.f_from_int(width), cglib.f_from_int(height)),
                              cam["nearQ16"], cam["farQ16"]),
        cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"])),
    )
    verts, tris, colors = render_raster.build_scene(spec)
    screen = []
    for v in verts:
        p = cglib.project(mvp, v)
        screen.append(None if p is None else (p[0], p[1], p[2]))
    rtris = []
    for a, b, c in tris:
        sa, sb, sc = screen[a], screen[b], screen[c]
        if sa is None or sb is None or sc is None:
            continue
        pa = cglib.to_screen(sa[0], sa[1], width, height)
        pb = cglib.to_screen(sb[0], sb[1], width, height)
        pc = cglib.to_screen(sc[0], sc[1], width, height)
        rtris.append((pa[0], pa[1], sa[2], *colors[a],
                      pb[0], pb[1], sb[2], *colors[b],
                      pc[0], pc[1], sc[2], *colors[c]))
    return cglib.rasterize(rtris, width, height, spec["backgroundQ8"])


def render_per_pixel(spec):
    """卷二逐像素路径:插值法线。着色回调复用 render_shade.make_shader。"""
    width, height = spec["width"], spec["height"]
    tris, total, dropped = render_shade.build_scene_shaded(spec)
    shade = render_shade.make_shader(spec)
    res = cgshade.rasterize_shaded(tris, width, height, spec["backgroundQ8"], shade)
    return res, total, dropped


def main():
    spec = cglib.load_spec()
    width, height = spec["width"], spec["height"]

    t0 = time.perf_counter()
    gcolor, gdepth, gcov, gculled = render_gouraud(spec)
    gour_ms = (time.perf_counter() - t0) * 1000

    t1 = time.perf_counter()
    (pcolor, pdepth, pcov, pculled), total, dropped = render_per_pixel(spec)
    pix_ms = (time.perf_counter() - t1) * 1000

    gpx = np.clip(gcolor, 0, 255).astype(np.uint8)
    ppx = np.clip(pcolor, 0, 255).astype(np.uint8)
    gsha = hashlib.sha256(gpx.tobytes()).hexdigest()
    psha = hashlib.sha256(ppx.tobytes()).hexdigest()

    gd = gpx.astype(np.int64)
    pd = ppx.astype(np.int64)
    chan = np.abs(gd - pd)                      # 每通道绝对差
    maxchan = chan.max(axis=2)                  # 每像素最大通道差
    diff_mask = maxchan > 0

    # 几何封版的两条证据:覆盖掩码与深度缓冲
    cov_diff_px = int((gcov ^ pcov).sum())
    depth_diff_px = int((gdepth != pdepth).sum())

    # 分块:哪一块差异最密
    ty, tx = height // TILE, width // TILE
    tiles = diff_mask[: ty * TILE, : tx * TILE].reshape(ty, TILE, tx, TILE).sum(axis=(1, 3))
    best = np.unravel_index(int(tiles.argmax()), tiles.shape)

    # 最大通道差直方图
    hist = []
    for lo, hi in zip(BINS[:-1], BINS[1:]):
        hist.append({"lo": lo, "hi": hi - 1,
                     "pixels": int(((maxchan >= lo) & (maxchan < hi)).sum())})

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "cg_shade_01_3_gouraud.rgb"), "wb") as fh:
        fh.write(gpx.tobytes())
    cglib.save_png(os.path.join(out, "cg_shade_01_3_gouraud.png"), gcolor)
    cglib.save_gray_png(os.path.join(out, "cg_shade_01_3_diff.png"), maxchan)
    cglib.write_csv(os.path.join(out, "cg_shade_01_3_hist.csv"),
                    ["bin_lo", "bin_hi", "pixels"],
                    [[h["lo"], h["hi"], h["pixels"]] for h in hist])

    stats = {
        "canvas": [width, height],
        "triangles_total": total,
        "triangles_dropped": dropped,
        "gouraud": {
            "sha256": gsha,
            "matches_volume1_baseline": gsha == GOURAUD_BASELINE,
            "covered": int(gcov.sum()),
            "culled": int(gculled),
            "ms": round(gour_ms, 1),
        },
        "per_pixel": {
            "sha256": psha,
            "matches_volume2_baseline": psha == PERPIXEL_BASELINE,
            "covered": int(pcov.sum()),
            "culled": int(pculled),
            "ms": round(pix_ms, 1),
        },
        "geometry_frozen": {
            "coverage_diff_pixels": cov_diff_px,
            "coverage_diff_permille": cgshade.coverage_diff_permille(gcov, pcov),
            "depth_diff_pixels": depth_diff_px,
        },
        "frequency_gap": {
            "diff_pixels": int(diff_mask.sum()),
            "diff_permille": round(float(diff_mask.sum()) * 1000.0 / diff_mask.size, 4),
            "mae": round(float(chan.mean()), 4),
            "psnr_db": round(cgshade.psnr(gpx, ppx), 2),
            "max_channel_diff": int(maxchan.max()),
            "mean_max_channel_diff": round(float(maxchan.mean()), 4),
        },
        "tiles": {
            "tile": TILE,
            "grid": [tx, ty],
            "max_tile_pixels": int(tiles.max()),
            "max_tile_at": [int(best[1]), int(best[0])],
            "max_tile_region": [int(best[1]) * TILE, int(best[0]) * TILE],
            "tiles_with_zero_diff": int((tiles == 0).sum()),
            "tiles_total": int(tiles.size),
        },
        "histogram": hist,
    }
    with open(os.path.join(out, "cg_shade_01_3_frequency.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    g = stats["gouraud"]
    p = stats["per_pixel"]
    f = stats["frequency_gap"]
    print(f"canvas            : {width}x{height}  {total} 三角形  dropped {dropped}")
    print(f"逐顶点 (Gouraud)  : covered {g['covered']}  culled {g['culled']}  {g['ms']} ms")
    print(f"  复现卷一基线    : {g['matches_volume1_baseline']}  sha256 {gsha[:16]}…")
    print(f"逐像素            : covered {p['covered']}  culled {p['culled']}  {p['ms']} ms")
    print(f"  复现卷二基线    : {p['matches_volume2_baseline']}  sha256 {psha[:16]}…")
    print(f"几何封版证据      : 覆盖差异 {cov_diff_px} 像素("
          f"{stats['geometry_frozen']['coverage_diff_permille']}‰)  深度差异 {depth_diff_px} 像素")
    print(f"着色频率差异      : {f['diff_pixels']} 像素({f['diff_permille']}‰)")
    print(f"  MAE {f['mae']}  PSNR {f['psnr_db']} dB  最大通道差 {f['max_channel_diff']}")
    print(f"分块 {TILE}x{TILE}      : 最密块 ({stats['tiles']['max_tile_at'][0]},"
          f"{stats['tiles']['max_tile_at'][1]}) 起于像素 "
          f"{stats['tiles']['max_tile_region']}  差异 {stats['tiles']['max_tile_pixels']} 像素")
    print(f"  零差异块        : {stats['tiles']['tiles_with_zero_diff']} / {stats['tiles']['tiles_total']}")
    print("最大通道差直方图  : " + "  ".join(f"[{h['lo']}-{h['hi']}] {h['pixels']}" for h in hist))
    return 0


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

跑一次:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_shade_01_3_frequency.py
canvas            : 320x240  896 三角形  dropped 0
逐顶点 (Gouraud)  : covered 43731  culled 290  30.4 ms
  复现卷一基线    : True  sha256 bac659770178037a…
逐像素            : covered 43731  culled 290  158.2 ms
  复现卷二基线    : True  sha256 0d997343ca547ed5…
几何封版证据      : 覆盖差异 0 像素(0.0‰)  深度差异 0 像素
着色频率差异      : 41261 像素(537.2526‰)
  MAE 3.5764  PSNR 37.06 dB  最大通道差 48
分块 16x16      : 最密块 (4,7) 起于像素 [64, 112]  差异 256 像素
  零差异块        : 116 / 300
最大通道差直方图  : [0-0] 35539  [1-2] 7319  [3-4] 7291  [5-8] 13725  [9-16] 10193  [17-32] 2618  [33-64] 115  [65-255] 0

三条封版证据

覆盖掩码与深度缓冲逐像素相同。 covered 两边都是 43731(569‰),culled 都是 290,三角形总数 896。覆盖差异 0 像素、深度差异 0 像素——两条路径的光栅化结果完全一致。这不是「差不多」,是同一个整数。所以后面所有颜色差异都只能来自着色环节,不能归因于几何。

两条 sha256 同时命中基线。 逐顶点 bac659770178037a… 是卷一基线,逐像素 0d997343ca547ed5… 是卷二基线。前者命中说明我没碰几何与顶点色,后者命中说明新路径本身是可复现的。

dropped 0。 没有三角形因为近裁剪面被丢弃,几何是完整的。

差异有多大

41261 个像素不同,占画布 76800 像素的 537.2526‰——超过一半。这个比例比直觉大:只有背光区域(两条路径都给环境光)与光照几乎均匀的平坦块才会相同。

  • MAE 3.5764:平均每个通道差约 3.6 级。听起来小,但这是全画布平均,包含大量零差异像素。
  • PSNR 37.06 dB:按 8 位通道算,肉眼在静态图上不一定分得出,但连续动画里会出现「顶点色块缓慢滑动」与「高光沿表面平滑掠过」的区别。
  • 最大通道差 48:出现在高光与终结线附近——那里 N·L 随像素变化最快,插值颜色与插值法线的分歧最大。

直方图印证这一点:35539 个像素完全相同(多为背光与平坦区),115 个像素落在 [33-64],没有像素超过 64。

差异在哪里

16×16 分块把画布切成 300 块。最密的块在 (4,7),起于像素 [64,112],整块 256 个像素全不同;同时有 116 块完全为零。也就是说差异不是均匀铺开的,而是聚在几何的曲率大、光照梯度陡的地方——球体的轮廓与地形的高频起伏。

这解释了为什么「着色频率」在工程上是个真问题:三角形越小、曲率越大,逐顶点越不够用;三角形足够密时,两条路径的差会收敛到零。第 13 章会把这个观察变成一条可计算的判据。

代价

逐顶点 30.4 ms,逐像素 158.2 ms,约 5.2 倍。这里要诚实说明:这两个数字是墙钟时间,每次跑都会抖(本机多次运行里逐顶点落在 30–58 ms、逐像素落在 155–172 ms 区间),所以不要拿单次绝对值做结论,比值才是稳定的。

差别来自求值次数:逐顶点是每顶点一次(地形 289 个顶点 + 三个球各 66 个),逐像素是每覆盖像素一次(43731 次)。求值次数从几百涨到四万,而每次求值的代价没变——这就是为什么定点内核的每次 fmul 都要数清楚。

跨语言核对

P 层不重跑光栅化,而是直接对两条路径的像素缓冲做断言:几何量逐项相等、差异指标复现到同一位小数。

// snippet: 节选自 lantern/core/test/cg_shade_01_3_frequency.test.ts
assert.equal(gouraud.covered, perPixel.covered);       // 几何封版
assert.equal(gouraud.culled, perPixel.culled);
assert.equal(coverageDiffPixels, 0);                   // 覆盖差异 0
assert.equal(depthDiffPixels, 0);                      // 深度差异 0
assert.equal(frequencyGap.diffPixels, 41261);          // 差异像素
assert.equal(frequencyGap.maxChannelDiff, 48);         // 最大通道差

负例把判据钉死:故意跳过逐像素路径的法线归一化,差异指标必须与基线分叉。若跳过归一化后指标仍相等,说明断言根本没在测着色环节。

阅读导航:上一节:1.2 Phong 与高光 · 下一节:2.1 BRDF 与能量守恒 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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