13.1 材质的表示与继承
前十二章谈的都是「怎么把一个 BRDF 算对」。从本章起换一个视角:一个真实场景里有成百上千个材质,它们怎么被表示、怎么被组织、怎么被复现。站内的 着色器编译与 SPIR-V 管线 讲的是材质参数在 GPU 侧怎么编译成变体、怎么绑定到管线;本节不碰编译器,只把材质压成一组整数参数,并证明同一份材质表在两种语言下能解析出逐字段相同的结果——这是「材质数据的规格化」。
材质系统的第一课不是光照,是表示:把材质定义成一个浮点结构体,跨语言、跨机器就无法保证一致;把它定义成一组定点整数,一致性才是可断言的。
材质是一组整数参数
附录 A.4 给了材质字段表。本节的材质就是这六个字段,全部是整数(Q16.16 或 8 位通道),没有浮点、没有字符串枚举:
| 参数 | 格式 | 范围 | 含义 |
|---|---|---|---|
albedoQ8 | 8 位 ×3 | 0–255 | 基础色(绝缘体漫反射色,金属的 F0) |
specularQ16 | Q16.16 | 0–65536 | 镜面反射率 |
shininessQ16 | Q16.16 | ≥ 65536 | 高光指数(整数,右移 16 位即指数) |
roughnessQ16 | Q16.16 | 0–65536 | 粗糙度 |
metalnessQ16 | Q16.16 | 0–65536 | 金属度,0 绝缘体、65536 金属 |
normalScaleQ16 | Q16.16 | 0–65536 | 法线贴图强度 |
「表示」的关键不在字段本身,而在字段顺序。把材质序列化成规范串时,字段必须按固定顺序排列;顺序一变,digest 就变。所以本节的 digest 不是装饰,它是「表示是否规范」的判据——两个宿主只要有一个字段顺序不同,sha256 立刻分叉。
继承:子材质只声明覆盖的字段
真实材质库靠继承复用:stone_base 定义岩石的公共参数,stone_mossy 只覆盖 albedoQ8 与 roughnessQ16,metal_trim 只覆盖镜面相关的四个字段。子材质不写的字段从父材质继承。
解析顺序必须确定。本节用不动点松弛:先把每个材质自己的字段放进去,然后反复扫描,凡是从父材质能补上的字段就补上,直到一轮扫描没有任何字段被补。这个算法有两个好性质:
- 与输入顺序无关——松弛的收敛结果只取决于继承关系,不取决于材质在表里的先后。
- 环会暴露——父指针成环时字段永远填不满,函数在末尾检测到「有字段缺失」并报错,而不是静默给出一份半成品材质。
叶材质 gold_trim_dark 的继承链是 gold_trim_dark → gold_trim → metal_trim → stone_base(深度 3)。逐字段解析结果与来源:
| 字段 | 解析值 | 来源 |
|---|---|---|
albedoQ8 | 140,112,60 | gold_trim_dark(自己) |
specularQ16 | 65536 | metal_trim |
shininessQ16 | 2097152 | metal_trim |
roughnessQ16 | 13107 | metal_trim |
metalnessQ16 | 65536 | metal_trim |
normalScaleQ16 | 65536 | stone_base |
一个字段值「来自谁」是可追溯的:从叶子沿父链上溯,第一个显式声明该字段的材质就是来源。表中 normalScaleQ16 一路继承到根,因为它只有 stone_base 定义过。
S 层:解析、确定性与渲染
材质表放在 config/ 下(全整数)。解析与渲染的脚本是 sim/cg_shade_13_1_material_repr.py:
{
"fieldOrder": ["albedoQ8", "specularQ16", "shininessQ16", "roughnessQ16", "metalnessQ16", "normalScaleQ16"],
"materials": [
{"name": "stone_base", "parent": null, "fields": {"albedoQ8": [150, 148, 132], "specularQ16": 6554, "shininessQ16": 1048576, "roughnessQ16": 45875, "metalnessQ16": 0, "normalScaleQ16": 65536}},
{"name": "stone_mossy", "parent": "stone_base", "fields": {"albedoQ8": [96, 132, 88], "roughnessQ16": 52429}},
{"name": "metal_trim", "parent": "stone_base", "fields": {"specularQ16": 65536, "shininessQ16": 2097152, "roughnessQ16": 13107, "metalnessQ16": 65536}},
{"name": "gold_trim", "parent": "metal_trim", "fields": {"albedoQ8": [220, 180, 90]}},
{"name": "gold_trim_dark", "parent": "gold_trim", "fields": {"albedoQ8": [140, 112, 60]}}
]
}
核心是解析与规范串两段(# snippet: 摘自真实脚本,省略了着色与主流程):
# snippet: sim/cg_shade_13_1_material_repr.py 的解析与规范串(节选)
def resolve(table):
"""继承的不动点松弛:返回 {name: {field: value}},与输入顺序无关。"""
order = table["fieldOrder"]
by_name = {m["name"]: m for m in table["materials"]}
out = {name: dict(m.get("fields", {})) for name, m in by_name.items()}
for _ in range(len(by_name) + 1):
changed = False
for name, m in by_name.items():
parent = m.get("parent")
if parent is None:
continue
if parent not in by_name:
raise ValueError(f"材质 {name} 的父 {parent} 不存在")
for f in order:
if f not in out[name] and f in out[parent]:
out[name][f] = out[parent][f]
changed = True
if not changed:
break
for name in out:
missing = [f for f in order if f not in out[name]]
if missing:
raise ValueError(f"材质 {name} 字段未解析完整:{missing}(父链成环?)")
return out
def canonical(name, fields, order):
"""规范串:字段按固定顺序、整数十进制、albedoQ8 三通道逗号分隔。"""
parts = []
for f in order:
v = fields[f]
parts.append(f"{f}={','.join(str(x) for x in v)}" if isinstance(v, list) else f"{f}={v}")
return name + "|" + "|".join(parts)
def digest(table, resolved):
order = table["fieldOrder"]
blob = "\n".join(canonical(n, resolved[n], order) for n in sorted(resolved))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
实跑输出(/opt/homebrew/bin/python3 sim/cg_shade_13_1_material_repr.py):
字段顺序 : albedoQ8 / specularQ16 / shininessQ16 / roughnessQ16 / metalnessQ16 / normalScaleQ16
材质数 / 叶子链深度 : 5 / 3
解析 digest : 074b01f1ad13e5bd…
输入顺序打乱后 : 074b01f1ad13e5bd… 一致=True
自指父指针负例 : 报错=True
叶子材质渲染 : 43731 覆盖 sha256 52335f431a71f474…
扰动 metal_trim.specularQ16 65536 -> 32768(叶子继承): 叶子值变=True digest变=True 帧变=True
扰动 metal_trim.roughnessQ16 13107 -> 45875(叶子继承): 叶子值变=True digest变=True 帧变=True
扰动 stone_mossy.albedoQ8 -> [10,10,10](旁系,不影响叶子): 叶子值变=False digest变=True 帧变=False
解析 digest 是 074b01f1ad13e5bdce84a3c7cbf98c441711ef706ac761da6d540a3c5eb0d74d。把材质表整体反转后再解析,digest 仍是同一个——这就是「与输入顺序无关」的实证。自指父指针的负例必须报错。
叶子材质套到整个场景逐像素着色,得到 43731 个覆盖像素与 52335f431a71f474… 的帧 digest。三条扰动里有两条改的是被叶子继承的父字段(metal_trim 的镜面与粗糙度),叶子值与帧都变;第三条改的是旁系材质 stone_mossy,叶子逐字段不变、帧也不变——只是整表 digest 会变,因为 digest 覆盖全表。这正好说明两件事要分开看:帧只依赖叶子(继承链是局部的),表 digest依赖全表。
P 层:同构解析与跨宿主帧
TypeScript 侧重写同一套解析与着色,断言解析 digest、叶子逐字段、渲染帧与 S 侧完全一致:
// file: lantern/core/test/cg_shade_13_1_material_repr.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_13_1_material_repr.test.ts'
// 13.1 的 P 层:TypeScript 侧重解析材质表、重算 digest、重渲染叶子材质帧,
// 断言与 S 侧 out/cg_shade_13_1_materials.json 逐字段 / 逐字节相同。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
type MatFields,
type MatTable,
canonicalTable,
renderWithMaterial,
resolve,
} 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)), "..", "..");
const table = JSON.parse(
readFileSync(join(root, "config", "cg_shade_13_1_materials.json"), "utf8"),
) as MatTable & { leaf: string; perturbations: { parent: string; field: keyof MatFields; value: number | number[] }[] };
const g = JSON.parse(readFileSync(join(root, "out", "cg_shade_13_1_materials.json"), "utf8")) as {
digest: string;
order_independent: boolean;
cycle_detected: boolean;
leaf: string;
leaf_resolved: Record<string, number | number[]>;
render: { sha256: string; covered_pixels: number };
perturbations: { frame_changed: boolean }[];
};
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as Spec;
const sha = (buf: Uint8Array) => createHash("sha256").update(Buffer.from(buf)).digest("hex");
const tableSha = (t: MatTable) => sha(new TextEncoder().encode(canonicalTable(resolve(t), t.fieldOrder)));
test("【核心】继承解析:digest 与 S 侧逐字节相同,且与输入顺序无关", () => {
assert.equal(tableSha(table), g.digest);
const reversed: MatTable = { ...table, materials: [...table.materials].reverse() };
assert.equal(tableSha(reversed), g.digest, "输入顺序打乱后 digest 必须不变");
assert.ok(g.order_independent);
});
test("【核心】叶子材质逐字段与 S 侧相同", () => {
const leaf = resolve(table)[g.leaf];
for (const [k, v] of Object.entries(g.leaf_resolved)) {
assert.deepEqual(leaf[k as keyof MatFields], v, `字段 ${k} 不一致`);
}
});
test("负例:自指父指针必须抛错,而不是给出半成品", () => {
const bad = { name: "b", fieldOrder: table.fieldOrder, materials: [{ name: "a", parent: "a", fields: { albedoQ8: [1, 2, 3] } }] } as MatTable;
assert.throws(() => resolve(bad));
assert.ok(g.cycle_detected);
});
test("【核心】叶子材质渲染:像素缓冲 sha256 与 S 侧逐字节相同", () => {
const r = renderWithMaterial(spec, resolve(table)[g.leaf]);
assert.equal(r.coveredCount, g.render.covered_pixels);
assert.equal(sha(r.rgb), g.render.sha256);
});
test("继承链是局部的:改被继承的父字段帧变,改旁系帧不变", () => {
const base = sha(renderWithMaterial(spec, resolve(table)[g.leaf]).rgb);
assert.equal(base, g.render.sha256);
const patched = (parent: string, field: keyof MatFields, value: number | number[]) => {
const t = JSON.parse(JSON.stringify(table)) as MatTable;
for (const m of t.materials) if (m.name === parent) (m.fields as Record<string, unknown>)[field] = value;
return t;
};
const inherited = resolve(patched("metal_trim", "specularQ16", 32768))[g.leaf];
assert.notEqual(sha(renderWithMaterial(spec, inherited).rgb), base, "继承字段变化必须改变帧");
const sibling = resolve(patched("stone_mossy", "albedoQ8", [10, 10, 10]))[g.leaf];
assert.equal(sha(renderWithMaterial(spec, sibling).rgb), base, "旁系材质不应影响叶子帧");
assert.deepEqual(sibling, resolve(table)[g.leaf]);
assert.equal(g.perturbations[2].frame_changed, false);
});
node --test 'core/test/cg_shade_13_1_material_repr.test.ts' 的实测输出:
✔ 【核心】继承解析:digest 与 S 侧逐字节相同,且与输入顺序无关
✔ 【核心】叶子材质逐字段与 S 侧相同
✔ 负例:自指父指针必须抛错,而不是给出半成品
✔ 【核心】叶子材质渲染:像素缓冲 sha256 与 S 侧逐字节相同
✔ 继承链是局部的:改被继承的父字段帧变,改旁系帧不变
ℹ tests 5 ℹ pass 5 ℹ fail 0
5 条全绿。S 与 P 两侧的解析 digest、叶子六个字段、渲染帧 sha256 全部相同——材质表示的规格化在两种语言下闭环。下一节把这些字段的每个开关展开成着色变体,看「组合数」与「实际数」差多远。
阅读导航:上一节:12.3 CPU 与 GPU 的比对 · 下一节:13.2 着色变体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。