13.1 材质的表示与继承

材质是六个定点参数而非浮点结构体。继承用不动点松弛解析,与输入顺序无关:叶子 gold_trim_dark 逐字段来源确定,解析 digest 074b01f1… 在打乱输入后不变,自指父指针报错;叶子材质驱动一帧,改被继承字段帧变、改旁系帧不变。

13.1 材质的表示与继承

前十二章谈的都是「怎么把一个 BRDF 算对」。从本章起换一个视角:一个真实场景里有成百上千个材质,它们怎么被表示、怎么被组织、怎么被复现。站内的 着色器编译与 SPIR-V 管线 讲的是材质参数在 GPU 侧怎么编译成变体、怎么绑定到管线;本节不碰编译器,只把材质压成一组整数参数,并证明同一份材质表在两种语言下能解析出逐字段相同的结果——这是「材质数据的规格化」。

材质系统的第一课不是光照,是表示:把材质定义成一个浮点结构体,跨语言、跨机器就无法保证一致;把它定义成一组定点整数,一致性才是可断言的。

材质是一组整数参数

附录 A.4 给了材质字段表。本节的材质就是这六个字段,全部是整数(Q16.16 或 8 位通道),没有浮点、没有字符串枚举:

参数格式范围含义
albedoQ88 位 ×30–255基础色(绝缘体漫反射色,金属的 F0)
specularQ16Q16.160–65536镜面反射率
shininessQ16Q16.16≥ 65536高光指数(整数,右移 16 位即指数)
roughnessQ16Q16.160–65536粗糙度
metalnessQ16Q16.160–65536金属度,0 绝缘体、65536 金属
normalScaleQ16Q16.160–65536法线贴图强度

「表示」的关键不在字段本身,而在字段顺序。把材质序列化成规范串时,字段必须按固定顺序排列;顺序一变,digest 就变。所以本节的 digest 不是装饰,它是「表示是否规范」的判据——两个宿主只要有一个字段顺序不同,sha256 立刻分叉。

继承:子材质只声明覆盖的字段

真实材质库靠继承复用:stone_base 定义岩石的公共参数,stone_mossy 只覆盖 albedoQ8 与 roughnessQ16,metal_trim 只覆盖镜面相关的四个字段。子材质不写的字段从父材质继承。

解析顺序必须确定。本节用不动点松弛:先把每个材质自己的字段放进去,然后反复扫描,凡是从父材质能补上的字段就补上,直到一轮扫描没有任何字段被补。这个算法有两个好性质:

  1. 与输入顺序无关——松弛的收敛结果只取决于继承关系,不取决于材质在表里的先后。
  2. 环会暴露——父指针成环时字段永远填不满,函数在末尾检测到「有字段缺失」并报错,而不是静默给出一份半成品材质。

叶材质 gold_trim_dark 的继承链是 gold_trim_dark → gold_trim → metal_trim → stone_base(深度 3)。逐字段解析结果与来源:

字段解析值来源
albedoQ8140,112,60gold_trim_dark(自己)
specularQ1665536metal_trim
shininessQ162097152metal_trim
roughnessQ1613107metal_trim
metalnessQ1665536metal_trim
normalScaleQ1665536stone_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 着色变体 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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