16.1 着色管线的装配

把卷二前面十五节的环节首尾相接成一条从规格到成像的链:S 层在 320x240 场景上逐级核对十二个缓冲契约,w 不变量 487/487 成立、重心权重和违反 0 次、覆盖 43731 像素,成片 sha256 与卷二基线逐字节一致;P 层用 BigInt 重算同一链路复现同一摘要,负例(顶点顺序交换)必须让摘要分叉。

16.1 着色管线的装配

站内的 技术美术与管线工程 关心的是资产从 DCC 导出、进引擎、再上传运行时的流转;本节只做最后一道工序——把卷二前面十五节的环节首尾相接成一条链,并逐级核对每一级的入口缓冲与出口缓冲。装配的难点不在任何单个环节,而在接口:上一级的出口缓冲必须正好是下一级的入口缓冲,一处量纲不匹配,成像就会整体偏掉,而且往往偏得「看着还挺像」。

十二级缓冲契约

卷一的第 16.1 节封住了「场景到像素」的几何链路。卷二在这条链路的中段插入了着色:插值的不再是颜色,而是法线与 albedo,逐像素调用一次着色函数。于是同一套几何计数(487 顶点 / 896 三角形 / 290 剔除 / 43731 覆盖像素)必须原封不动地保留下来,多出来的只有一处——片元着色调用次数。

级环节入口缓冲出口缓冲量纲
1规格 → MVPscene_spec.json 相机字段mvp 4×4Q16.16
2顶点变换487 个世界坐标顶点裁剪空间 4 元组Q16.16
3近平面剔除裁剪空间 4 元组保留 / 丢弃计数整数
4屏幕映射NDC 三元组(sx, sy, depth)Q6 / Q16.16
5三角形装配896 个索引三元组896 个 27 元组Q6 / Q16.16 / 0-255
6背面剔除屏幕空间 27 元组保留 606 / 剔除 290整数
7边函数覆盖屏幕空间 27 元组覆盖判定像素整数
8插值法线 / albedo覆盖像素 + 顶点法线片元法线 + albedoQ16.16 / 0-255
9逐像素着色片元法线 + albedo片元颜色0-255
10深度测试每像素插值深度z-buffer + 覆盖掩码Q16.16
11后处理颜色缓冲clamp + 8 位量化uint8
12输出uint8 缓冲320×240×3 像素缓冲uint8

第 5 级的 27 元组是本卷的私有格式,卷一定义了前 9 个槽位,本卷往后追加 18 个:

位置                   含义
0,1 / 3,4 / 6,7        三个顶点的屏幕坐标(Q6,1 像素 = 64 单位)
2 / 5 / 8              三个顶点的深度(Q16.16,落在 [0, ONE])
9-11 / 12-14 / 15-17   三个顶点的法线(Q16.16,单位向量)
18-20 / 21-23 / 24-26  三个顶点的 albedo(8 位整数通道)

这个「往后追加、不动前面」的约定是两卷能拼起来的前提,也是本节最容易踩空的地方——如果装配时把法线塞进前 9 个槽位,几何环节立刻就会读到垃圾数据。同理,albedo 必须是顶点级的 8 位通道,不能先算成浮点颜色再存回来:第 8 级插值的就是这 8 位整数,中间夹一次浮点,跨语言的逐字节一致立刻失效。

不变量:链的每一段都要能自证

装配之后要能回答「这条链是对的」,靠的不是肉眼看图,而是四条不变量:

  1. w 不变量。透视矩阵第三行是 $(0,0,-\text{ONE},0)$,所以裁剪空间的 $w$ 完全由未截断的视图空间 $z$ 点积决定:$w = (-z_{\text{view full}}) \gg 16$。这里必须用原始精度算 $z$,若先把 $z_{\text{view}}$ 降精度再取负,负数上两种顺序会差 1。实测 487 个顶点全部满足。
  2. 重心权重和等于面积。覆盖判定为真的像素上,$w_0+w_1+w_2$ 必须正好等于三角形的两倍面积。实测 47673 个覆盖判定像素,违反 0 次。
  3. 法线模长。顶点法线与插值归一化后的片元法线,模长都应落在 $\text{ONE}$ 附近。实测顶点 $[65533, 65536]$、片元 $[65533, 65537]$,偏差不超过 3 个 Q16.16 单位($4.6\times10^{-5}$),来源是 fdiv 的向零截断。这个 3 是实测出来的,不是拍的——它比「逐位相等」宽松,又比「看着差不多」严格得多。
  4. 深度哨兵。覆盖像素深度落在 $[0,\text{ONE}]$ 内,未覆盖像素必须落回哨兵 int64 最大值,用来证明深度缓冲没有被误写。

装配最容易错在哪

装配环节的 bug 有个共同的坏性质:它们都「看着还挺像」。把前面十五节里踩过的坑按「装配时才暴露」筛一遍,剩下三类:

接口错位。法线槽位与 albedo 槽位写反,得到的是「颜色当法线用」的图像——球体上会出现大块的、边界模糊的明暗块,远看像打了环境光,近看才发现高光位置全错。这类错误不会崩、不会报错,只会让整幅图均匀地不对。挡住它的办法不是看,而是把 27 元组的槽位表写进注释、再让 P 层用同一张表重算一遍。

量纲错位。屏幕坐标是 Q6、深度是 Q16.16、albedo 是 8 位整数,三种量纲在同一个数组里挨着放。把 Q6 的屏幕坐标当 Q16.16 用,坐标会缩小 1024 倍,所有三角形挤在左上角一个像素里;把 8 位 albedo 当 Q16.16 用,颜色会变成 0。两者都会让覆盖像素数骤降,所以「覆盖像素数」本身就是一条灵敏的量纲探针。

精度错位。第 3 级的 $w$ 不变量是本节唯一的顺序敏感项:$z_{\text{view}}$ 先降精度再取负,与先取负再降精度,在负数上差 1。这个 1 个 Q16.16 单位会顺着透视除法放大到屏幕坐标上,最终让少数边缘像素的覆盖判定翻转——图像上只表现为轮廓上零星几个像素的锯齿差异,肉眼几乎看不出来,但 sha256 会立刻分叉。正因为肉眼看不出来,它才必须由不变量而不是由眼睛来守。

S 层:把十六节串成一条链

S 层脚本自己重建一份顶点列表用于断言(顺序与 render_shade.build_scene_shaded 完全相同:先地形、后球体),但成像仍然走 cgshade.rasterize_shaded 本体,不改任何底座。跑一次:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_shade_16_1_assembly.py
规格 -> MVP     : mvp 4x4 Q16.16,w 不变量成立 487/487
顶点变换        : 487 顶点;近平面剔除保留 487,丢弃 0
屏幕映射        : sx in [-96501, 117959]  sy in [5218, 30345] Q6
三角形装配      : 896 装配 / 896 总数(相机后方丢弃 0)
背面剔除        : 剔除 290,保留 606
覆盖判定        : 47673 像素,w0+w1+w2 == area 违反 0 次
顶点法线模长    : in [65533, 65536] Q16.16(ONE = 65536)
片元法线模长    : in [65533, 65537] Q16.16,着色回调调用 47365 次(overdraw 1083‰)
深度测试        : 覆盖 43731 像素(569‰),深度 in [60516, 65335] Q16.16
后处理 + 输出   : 未覆盖落回哨兵 9223372036854775807 -> True
sha256(逐像素)  : 0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de
与卷二基线一致  : True   着色耗时 165.6 ms
几何与卷一一致  : True(487 顶点 / 896 三角形 / 290 剔除 / 43731 覆盖)
装配总耗时      : 340.1 ms

有两处数字值得停下来看。

第一处是片元着色调用 47365 次,而覆盖像素只有 43731 个。多出来的 3634 次不是 bug,而是重绘(overdraw):一个像素可能先被远处三角形写入、再被近处三角形覆盖,每次通过深度测试的写入都要重新算一次着色,实测 overdraw 1083‰。装配表第 9 级的出口缓冲因此不是「每个像素一个颜色」,而是「每个通过深度测试的 (三角形, 像素) 对一次调用」。断言只能写成 calls >= covered,写成相等就会立刻失败——这条是装配时最容易写错的契约。

第二处是 sx in [-96501, 117959]:屏幕 Q6 坐标可以越出画布(320 像素 = 20480 个 Q6 单位),这是正常的。屏幕映射不负责裁剪,越界像素由第 7 级的边函数在覆盖判定时自然排除,第 4 级的出口缓冲只是「可越界的定点坐标」,不是「像素索引」。把这两件事混起来,就会在装配时误加一次裁剪。

封版断言

链跑通之后,把它钉死成断言——任何一级的契约被破坏都必须在这里炸掉:

# snippet: lantern/sim/cg_shade_16_1_assembly.py 的封版断言(第 11/12 级之后)
assert digest == PIX_BASELINE, "串联后的逐像素成片 sha256 与卷二基线不一致"
assert w_ok == len(verts), "w == (-z_view_full)>>16 不变量被破坏"
assert bad == 0, "重心权重和不等于面积"
assert calls >= n_cov, "着色调用次数不可能少于覆盖像素数"
assert abs(n_lo - cglib.ONE) <= 3 and abs(n_hi - cglib.ONE) <= 3, "顶点法线模长偏离 ONE"
assert abs(f_lo - cglib.ONE) <= 3 and abs(f_hi - cglib.ONE) <= 3, "片元法线模长偏离 ONE"
assert 0 <= d_lo <= d_hi <= cglib.ONE, "覆盖像素深度越界"
assert uncov_ok, "未覆盖像素未落回哨兵"
assert len(verts) == V1_VERTICES and total == V1_TRIANGLES, "顶点/三角形数与卷一不一致"
assert int(culled) == V1_CULLED, "背面剔除数与卷一不一致"
assert n_cov == V1_COVERED, "覆盖像素数与卷一不一致"

最后三行是跨卷封版:顶点数、三角形数、剔除数、覆盖像素数四项必须与卷一第 16 节完全相同。卷二只允许在链路中段插入着色,不允许改动几何计数——这条断言就是那个「不允许」的机器版本。

P 层复现

TypeScript 侧用 BigInt 重算整条链路,并把 S 层落盘的 out/cg_shade_16_1_assembly.json 当作独立产出的金标准逐项对账:

// file: lantern/core/test/cg_shade_16_1_assembly.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_16_1_assembly.test.ts'
// 16.1 的装配断言:TypeScript 侧把同一份规格串成完整链路,逐级核对契约与不变量,
// 并断言成片 sha256 与 S 层基线**逐字节一致**;负例(顶点顺序交换)必须让摘要分叉。
// 契约与计数由 S 侧 out/cg_shade_16_1_assembly.json 独立产出,本文件只做对账。

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import { buildSceneShaded, makeShade, renderShaded, sha256hex } from "../src/lantern/cg_shade_16_scene.ts";
import { type SceneSpec } from "../src/lantern/pipeline.ts";
import { rasterizeShaded } from "../src/lantern/shade.ts";
import { vLen } from "../src/lantern/vec.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as SceneSpec;
const BASE = "0d997343ca547ed5cada5e4b1f75ce1782d79e524dba817278853de77307a6de";
const g = JSON.parse(
  readFileSync(join(root, "out", "cg_shade_16_1_assembly.json"), "utf8"),
) as {
  stages: { stage: string }[];
  vertices: number;
  triangles: number;
  triangles_culled: number;
  pixels_covered: number;
  coverage_pixels: number;
  coverage_invariant_violations: number;
  w_invariant_ok: number;
  fragment_calls: number;
  sha256: string;
};

test("装配成片 + 十二级契约对账:复现卷二基线 sha256 与逐级计数", () => {
  const f = renderShaded(spec);
  assert.equal(sha256hex(f.rgb), BASE, "成片摘要必须复现卷二基线");
  assert.equal(f.verts.length, 487);
  assert.equal(f.tris.length, 896);
  assert.equal(f.dropped, 0, "相机后方丢弃必须为 0");
  assert.equal(f.culled, 290, "背面剔除必须为 290");
  assert.equal(f.coveredCount, 43731);
  assert.equal(g.stages.length, 12, "装配表必须是十二级");
  assert.equal(g.vertices, 487);
  assert.equal(g.triangles, 896);
  assert.equal(g.triangles_culled, 290);
  assert.equal(g.pixels_covered, 43731);
  assert.equal(g.w_invariant_ok, 487, "w 不变量必须对全部顶点成立");
  assert.equal(g.coverage_pixels, 47673, "覆盖判定像素数(含被遮挡重判)");
  assert.equal(g.coverage_invariant_violations, 0, "重心权重和必须等于面积");
  assert.equal(g.fragment_calls, 47365, "片元着色调用次数(overdraw 1083‰)");
  assert.equal(g.sha256, BASE);
});

test("不变量:归一化后的片元法线模长落在 ONE±3(Q16.16)", () => {
  const { tris } = buildSceneShaded(spec);
  const shade = makeShade(spec);
  let lo = 1n << 62n;
  let hi = -(1n << 62n);
  let calls = 0;
  const probe = (n: [bigint, bigint, bigint], a: readonly number[]): [number, number, number] => {
    calls += 1;
    const L = vLen(n);
    if (L < lo) lo = L;
    if (L > hi) hi = L;
    return shade(n, a);
  };
  rasterizeShaded(tris, spec.width, spec.height, spec.backgroundQ8, probe);
  const ONE = 65536n;
  assert.ok(lo >= ONE - 3n && hi <= ONE + 3n, `片元法线模长 [${lo}, ${hi}] 偏离 ONE`);
  assert.equal(calls, g.fragment_calls, "着色调用次数必须与 S 层一致");
});

test("负例:三角形顶点顺序交换(b<->c)必须让成片摘要分叉", () => {
  const { tris } = buildSceneShaded(spec);
  const swapped = tris.map((t) => ({
    xy: [t.xy[0], t.xy[2], t.xy[1]] as [number, number][],
    d: [t.d[0], t.d[2], t.d[1]],
    n: [t.n[0], t.n[2], t.n[1]] as [number, number, number][],
    alb: [t.alb[0], t.alb[2], t.alb[1]] as [number, number, number][],
  }));
  const r = rasterizeShaded(swapped, spec.width, spec.height, spec.backgroundQ8, makeShade(spec));
  assert.notEqual(sha256hex(r.rgb), BASE, "顶点顺序交换后摘要必须分叉");
});
✔ 装配成片 + 十二级契约对账:复现卷二基线 sha256 与逐级计数
✔ 不变量:归一化后的片元法线模长落在 ONE±3(Q16.16)
✔ 负例:三角形顶点顺序交换(b<->c)必须让成片摘要分叉
ℹ tests 3  ℹ pass 3  ℹ fail 0

两侧是两份独立代码(Python int 对 BigInt)算出同一个 12 级链路、同一个 47365 次着色调用、同一个 sha256,这比「两边抄同一段公式」有说服力得多。第三个测试是负例:把每个三角形的后两个顶点对调,覆盖判定的绕向随之翻转,摘要必须分叉——判据若对负例无动于衷,PASS 就不值钱。

装配完成的自检清单

把这一节的结论压成一张可执行的清单,后面任何一节改动链路时都按它回查:

  • 27 元组的槽位表没有变,法线仍在 9–17、albedo 仍在 18–26;
  • 几何四项计数与卷一封版一致:487 顶点 / 896 三角形 / 290 剔除 / 43731 覆盖;
  • $w$ 不变量对全部顶点成立,而不是「大多数」;
  • 覆盖判定像素的重心权重和违反数为 0;
  • 片元着色调用次数 $\geq$ 覆盖像素数,且 overdraw 占比可解释;
  • 未覆盖像素的深度仍是哨兵值;
  • S 层与 P 层成片 sha256 逐字节相同,且负例被拒绝。

清单第 7 条是下一节的主线:逐字节相同这条纪律只在两处定点实现之间成立,一旦宿主换成浮点着色器,它就必须让位给另一套判据。

装配完成,链路上每一级的入口/出口缓冲都钉死了。但「同一条链」在 Python 与 TypeScript 里跑出相同摘要,只证明了两处定点实现一致;真正的第三种宿主是浏览器里的 WebGL2 着色器,它是浮点的。下一节把三处并排摆上桌对账。

阅读导航:上一节:15.3 偏差的定位方法 · 下一节:16.2 三种消费的联调 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 12.3 CPU 与 GPU 的比对