16.1 着色管线的装配
站内的 技术美术与管线工程 关心的是资产从 DCC 导出、进引擎、再上传运行时的流转;本节只做最后一道工序——把卷二前面十五节的环节首尾相接成一条链,并逐级核对每一级的入口缓冲与出口缓冲。装配的难点不在任何单个环节,而在接口:上一级的出口缓冲必须正好是下一级的入口缓冲,一处量纲不匹配,成像就会整体偏掉,而且往往偏得「看着还挺像」。
十二级缓冲契约
卷一的第 16.1 节封住了「场景到像素」的几何链路。卷二在这条链路的中段插入了着色:插值的不再是颜色,而是法线与 albedo,逐像素调用一次着色函数。于是同一套几何计数(487 顶点 / 896 三角形 / 290 剔除 / 43731 覆盖像素)必须原封不动地保留下来,多出来的只有一处——片元着色调用次数。
| 级 | 环节 | 入口缓冲 | 出口缓冲 | 量纲 |
|---|---|---|---|---|
| 1 | 规格 → MVP | scene_spec.json 相机字段 | mvp 4×4 | Q16.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 | 覆盖像素 + 顶点法线 | 片元法线 + albedo | Q16.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 位整数,中间夹一次浮点,跨语言的逐字节一致立刻失效。
不变量:链的每一段都要能自证
装配之后要能回答「这条链是对的」,靠的不是肉眼看图,而是四条不变量:
- w 不变量。透视矩阵第三行是 $(0,0,-\text{ONE},0)$,所以裁剪空间的 $w$ 完全由未截断的视图空间 $z$ 点积决定:$w = (-z_{\text{view full}}) \gg 16$。这里必须用原始精度算 $z$,若先把 $z_{\text{view}}$ 降精度再取负,负数上两种顺序会差 1。实测 487 个顶点全部满足。
- 重心权重和等于面积。覆盖判定为真的像素上,$w_0+w_1+w_2$ 必须正好等于三角形的两倍面积。实测 47673 个覆盖判定像素,违反 0 次。
- 法线模长。顶点法线与插值归一化后的片元法线,模长都应落在 $\text{ONE}$ 附近。实测顶点 $[65533, 65536]$、片元 $[65533, 65537]$,偏差不超过 3 个 Q16.16 单位($4.6\times10^{-5}$),来源是
fdiv的向零截断。这个 3 是实测出来的,不是拍的——它比「逐位相等」宽松,又比「看着差不多」严格得多。 - 深度哨兵。覆盖像素深度落在 $[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 三种消费的联调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。