13.3 材质的数据驱动
前两节把材质表示成整数参数、把参数开关展开成变体。这一节把它们收口成数据驱动:一份材质表(JSON)同时驱动三种宿主。站内的 着色器编译与 SPIR-V 管线 讲的是着色器代码怎么在编译期被特化;本节不问编译器,只问一个更基础的问题——同一份数据,三种不同的消费方,读出来的东西是不是逐字段一样。
这不是形式主义。真实工程里材质表的消费方至少有三种:离线烘焙/校验脚本(Python)、运行时引擎(TypeScript/C++)、以及把材质内联进页面的预览工具(HTML)。只要有一处解析口径不同——字段顺序、缺省值、单位——三者就会对同一份资产给出不同结果,而且这种分歧往往要等到画面出问题才被发现。所以本节把「三宿主逐字段一致」做成可跑的对账表。
三种宿主,同一份数据
三种宿主各自怎么消费这份材质表:
| 宿主 | 实现 | 消费方式 | 产物 |
|---|---|---|---|
| S 层 | Python | 读 JSON 建材质对象,逐像素渲染 | 帧 sha256 + 对账 CSV |
| P 层 | TypeScript | 读同一 JSON 建同构对象,重渲染 | 帧 sha256(与 S 逐字节相同) |
| W 层 | HTML | 把材质 JSON 内联进 <script>,读回 | 内联值(与 S 逐字段相同) |
W 层这里只做数据面:把材质内联进单文件 HTML,再从 HTML 里把内联的 JSON 读回来,逐字段比对。真正的 WebGL2 渲染在 12 章与 16 章,本节不涉及 GPU——它保证的是「三种宿主读到的参数相同」,这是渲染一致性的前置条件,不是渲染本身。
三宿主对账表
活动材质 hollow_gold 在三种宿主下解析出的六个字段:
| 字段 | S(Python) | W(HTML 内联) | P(TypeScript) |
|---|---|---|---|
albedoQ8 | 220 180 90 | 220 180 90 | 220 180 90 |
specularQ16 | 65536 | 65536 | 65536 |
shininessQ16 | 2097152 | 2097152 | 2097152 |
roughnessQ16 | 13107 | 13107 | 13107 |
metalnessQ16 | 65536 | 65536 | 65536 |
normalScaleQ16 | 65536 | 65536 | 65536 |
三列逐字段相同。S 列由 Python 脚本产出,W 列由「内联后读回」得到,P 列由 node --test 断言与 S/W 相同(并把结果落盘到 out/cg_shade_13_3_p_host.json)。整表的规范 digest 是 c456c8c182d208b08c7a164d8f11afe2212f515676a7e5a8ef6a17fb8956b7f1,S 与 P 两侧相同。
对账的口径是逐字段而不是「整表 digest 相同」——两者都做,但逐字段更细。整表 digest 相同只能说明「没有差别」,逐字段比对能在不一致时指出是哪一个字段出了分歧。本节的 CSV 对账表因此按字段逐行写:每行一个字段、三列三个宿主。只要有一行三列不全相等,就说明某个宿主的解析口径有偏差。
三处一致不等于渲染一致
需要把一件事说清楚:本节证明的是数据一致,不是渲染一致。三种宿主读到逐字段相同的参数,是渲染一致的必要条件,不是充分条件——即便参数完全相同,GPU 的光栅化规则(top-left 规则、子像素精度、浮点插值)与 CPU 的整数光栅化仍会带来差异,那属于 12 章的声明容差范畴,不能靠本节的数据对账消除。
所以本节的定位很明确:它把「材质数据的规格化」这件事与「着色正确性」解耦。前者是纯数据问题(可逐字段断言),后者是数值问题(按容差断言)。混淆两者,就会出现「参数明明一样、画面却不同」的困惑——而正确答案是:参数一样只是起点。
S 层:建对象、内联、读回
材质表放在 config/cg_shade_13_3_material_table.json,脚本是 sim/cg_shade_13_3_data_driven.py:
{
"fieldOrder": ["albedoQ8", "specularQ16", "shininessQ16", "roughnessQ16", "metalnessQ16", "normalScaleQ16"],
"materials": [
{"name": "hollow_stone", "albedoQ8": [150, 148, 132], "specularQ16": 6554, "shininessQ16": 1048576, "roughnessQ16": 45875, "metalnessQ16": 0, "normalScaleQ16": 65536},
{"name": "hollow_gold", "albedoQ8": [220, 180, 90], "specularQ16": 65536, "shininessQ16": 2097152, "roughnessQ16": 13107, "metalnessQ16": 65536, "normalScaleQ16": 65536},
{"name": "hollow_moss", "albedoQ8": [96, 132, 88], "specularQ16": 4587, "shininessQ16": 65536, "roughnessQ16": 58982, "metalnessQ16": 0, "normalScaleQ16": 32768}
],
"activeMaterial": "hollow_gold"
}
建对象与「内联 → 读回」两段(# snippet: 摘自真实脚本):
# snippet: sim/cg_shade_13_3_data_driven.py 的建对象与内联读回(节选)
def build_object(entry, order):
"""把材质表的一项建成 S 层材质对象(字段按固定顺序)。"""
return {f: entry[f] for f in order}
def write_html(path, mat):
"""W 宿主:把材质 JSON 内联进单文件 HTML 的 <script>。"""
os.makedirs(os.path.dirname(path), exist_ok=True)
blob = json.dumps(mat, ensure_ascii=False)
html = (
"<!DOCTYPE html>\n<html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
"<title>lantern 材质(13.3)</title></head>\n<body>\n<pre id=\"dump\"></pre>\n"
"<script>\nconst MATERIAL = " + blob + ";\n"
"document.getElementById(\"dump\").textContent = JSON.stringify(MATERIAL);\n"
"</script>\n</body></html>\n"
)
with open(path, "w", encoding="utf-8") as fh:
fh.write(html)
return len(html)
def read_html_material(path):
"""从 HTML 里把内联的材质 JSON 抽回来(正则抓 `const MATERIAL = {...};`)。"""
with open(path, encoding="utf-8") as fh:
text = fh.read()
m = re.search(r"const MATERIAL = (\{.*?\});", text, re.S)
if not m:
raise ValueError("HTML 中未找到内联材质")
return json.loads(m.group(1))
生成的 HTML 只有 402 字节,材质就是内联的那一行:
<script>
const MATERIAL = {"albedoQ8": [220, 180, 90], "specularQ16": 65536, "shininessQ16": 2097152, "roughnessQ16": 13107, "metalnessQ16": 65536, "normalScaleQ16": 65536};
document.getElementById("dump").textContent = JSON.stringify(MATERIAL);
</script>
「读回」这一步是关键:它不信任内存里的对象,而是从落盘的 HTML 文本里重新解析出材质,再与 Python 对象逐字段比。只要内联时的序列化有任何走样(少字段、顺序错、数字被写成浮点),读回就对不上。
改一个字段,三处同步
数据驱动最实在的检验是改一个字段,看三处产物是否同步变化。把活动材质的某字段改掉,重渲染帧、重写 HTML、再读回:
| 改的字段 | 新值 | 帧 sha256(前 12) | 帧变 | W 值变 | 同步 |
|---|---|---|---|---|---|
albedoQ8[0] | 90 | cfecf6a44aa7 | 是 | 是 | 是 |
roughnessQ16 | 65536 | 6d65c10193d4 | 是 | 是 | 是 |
metalnessQ16 | 0 | 07080ba46816 | 是 | 是 | 是 |
shininessQ16 | 4194304 | 3e5ad4a86113 | 是 | 是 | 是 |
基线帧是 2b50b1bd13358dd6…(43731 覆盖像素)。四次扰动全部「帧变 = W 值变 = 真」,即同步。四个字段分别落在着色器的不同位置:albedoQ8 是漫反射色、roughnessQ16 缩镜面幅度、metalnessQ16 切镜面颜色、shininessQ16 定高光指数——四者都改得动像素,说明材质表里的字段没有一个是「存了但没用」。
P 层:同构对象与内联读回
// file: lantern/core/test/cg_shade_13_3_material_data_driven.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_13_3_material_data_driven.test.ts'
// 13.3 的 P 层:TypeScript 读同一份材质表建**同构**对象,重渲染帧,并把解析结果
// 写成 out/cg_shade_13_3_p_host.json 供三宿主对账。断言与 S 侧逐字段 / 逐字节相同。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
type MatFields,
canonicalTable,
renderWithMaterial,
} from "../src/lantern/cg_shade_13_material.ts";
import type { Spec } from "../src/lantern/cg_shade_02_material.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
type Entry = { name: string } & MatFields;
const table = JSON.parse(readFileSync(join(root, "config", "cg_shade_13_3_material_table.json"), "utf8")) as {
fieldOrder: (keyof MatFields)[]; materials: Entry[]; activeMaterial: string;
perturbations: { field: keyof MatFields; index?: number; value: number; label: string }[];
};
const g = JSON.parse(readFileSync(join(root, "out", "cg_shade_13_3_data_driven.json"), "utf8")) as {
table_digest: string; active_object: MatFields; w_inline_object: MatFields;
render: { sha256: string; covered_pixels: number };
perturbations: { field: keyof MatFields; sha256: string; w_inline_value: string; sync: boolean }[];
all_sync: boolean;
};
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as Spec;
const sha = (b: Uint8Array) => createHash("sha256").update(Buffer.from(b)).digest("hex");
/** TS 侧建对象:按 fieldOrder 抽字段,与 S 层 build_object 同构。 */
function buildObject(entry: Entry): MatFields {
const out: Record<string, unknown> = {};
for (const f of table.fieldOrder) out[f] = entry[f];
return out as unknown as MatFields;
}
test("【核心】TS 建的对象规范 digest 与 S 侧逐字节相同", () => {
const resolved: Record<string, Partial<MatFields>> = {};
for (const m of table.materials) resolved[m.name] = buildObject(m);
const blob = canonicalTable(resolved, table.fieldOrder);
assert.equal(sha(new TextEncoder().encode(blob)), g.table_digest);
});
test("【核心】active 材质逐字段与 S 侧对象相同", () => {
const active = buildObject(table.materials.find((m) => m.name === table.activeMaterial) as Entry);
for (const f of table.fieldOrder) assert.deepEqual(active[f], g.active_object[f]);
});
test("【核心】W 宿主:HTML 内联材质读回后与 S 侧逐字段相同", () => {
const html = readFileSync(join(root, "web", "dist", "shade13.html"), "utf8");
const m = html.match(/const MATERIAL = (\{.*?\});/s);
assert.ok(m, "HTML 中必须有内联材质");
const inline = JSON.parse(m[1]) as MatFields;
for (const f of table.fieldOrder) {
assert.deepEqual(inline[f], g.active_object[f], `W 内联字段 ${String(f)} 与 S 不一致`);
}
// 把 TS 解析结果落盘,供三宿主对账表取 P 列
writeFileSync(join(root, "out", "cg_shade_13_3_p_host.json"),
JSON.stringify(buildObject(table.materials.find((m2) => m2.name === table.activeMaterial) as Entry), null, 2));
});
test("【核心】active 材质渲染:像素缓冲 sha256 与 S 侧逐字节相同", () => {
const active = buildObject(table.materials.find((m) => m.name === table.activeMaterial) as Entry);
const r = renderWithMaterial(spec, active);
assert.equal(r.coveredCount, g.render.covered_pixels);
assert.equal(sha(r.rgb), g.render.sha256);
});
test("改一个字段:帧 digest 与 W 内联值同步变化", () => {
const base = buildObject(table.materials.find((m) => m.name === table.activeMaterial) as Entry);
assert.equal(sha(renderWithMaterial(spec, base).rgb), g.render.sha256);
for (const p of g.perturbations) {
const mod = JSON.parse(JSON.stringify(base)) as MatFields;
if (p.field === "albedoQ8") mod.albedoQ8[0] = Number(p.w_inline_value.split(",")[0]);
else (mod as unknown as Record<string, number>)[p.field] = Number(p.w_inline_value);
assert.notEqual(sha(renderWithMaterial(spec, mod).rgb), g.render.sha256, "改字段必须改帧");
assert.equal(sha(renderWithMaterial(spec, mod).rgb), p.sha256, "P 侧帧必须与 S 侧相同");
assert.ok(p.sync);
}
assert.ok(g.all_sync);
});
node --test 'core/test/cg_shade_13_3_material_data_driven.test.ts' 的实测输出:
✔ 【核心】TS 建的对象规范 digest 与 S 侧逐字节相同
✔ 【核心】active 材质逐字段与 S 侧对象相同
✔ 【核心】W 宿主:HTML 内联材质读回后与 S 侧逐字段相同
✔ 【核心】active 材质渲染:像素缓冲 sha256 与 S 侧逐字节相同
✔ 改一个字段:帧 digest 与 W 内联值同步变化
ℹ tests 5 ℹ pass 5 ℹ fail 0
5 条全绿。三宿主对账表三列一致,四次扰动全部同步。至此本章的材质系统闭环:表示(六个定点字段)→ 组织(继承与变体键)→ 消费(三宿主同构解析)。下一章转进光照本身,看光源怎么被表示、怎么被批量求值。
阅读导航:上一节:13.2 着色变体 · 下一节:14.1 光源类型与表示 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。