13.2 着色变体

材质的每个开关都让着色器分叉:4 开关理论 16 组合、实际只用 9;8 开关理论 256、实际 23,爆炸比 11.13×。变体键用整数位编码去重,与 frozenset、关键词串去重同数;键漏掉 shadow 位则去重数从 9 塌缩到 7。逐个渲染实际变体,不同键必给不同帧。

13.2 着色变体

上一节把材质压成一组整数参数。这一节问一个工程问题:材质的每个开关——有无法线贴图、有无 albedo 贴图、金属还是绝缘体、接不接收阴影——都会让着色器分叉。站内的 着色器编译与 SPIR-V 管线 讲的是这些分叉在编译期怎么变成不同的管线对象;本节不碰编译器,只算分叉的组合数,并证明这个数可以被一个整数键精确编码与去重。

变体系统最容易出的不是数学错,是计数错:要么以为组合数就是实际数(于是编译了一堆没人用的管线),要么去重时键写漏一位(于是两套不同的着色器悄悄塌缩成一套,运行时表现诡异)。本节把这两种错都做成可跑断言。

变体爆炸:组合数是乘积,实际数是常数

n 个布尔开关理论上有 $2^n$ 个变体。这是乘积增长:4 个开关是 16,8 个开关就是 256,16 个开关是 65536。但一个真实场景实际用到的变体远少于它——因为开关之间并不自由组合,某些组合在场景里根本不会出现。

本节的实跑计数:

开关数理论组合 $2^n$实际使用爆炸比
4(法线/albedo/金属/阴影)1691.7778×
8(再加 specular/roughness/自发光/双面)2562311.1304×

4 个开关时组合数还只比实际数大一倍不到;一旦加到 8 个,理论组合涨到 256,而实际数只从 9 涨到 23——爆炸比冲到 11.13×。组合数随开关数指数增长,实际数只随场景复杂度线性增长,两者的比值就是「变体爆炸」。它的工程含义很直接:如果按理论组合去预编译管线,8 个开关就要编译 256 套,其中 233 套永远用不到。

再往上加开关,差距会失控:12 个开关是 4096 套,16 个开关是 65536 套。真实引擎(如现代前向/延迟管线)的开关动辄十几个,所以它们从不预编译全部组合,而是只编译场景实际用到的键。这就要求「实际用到哪些键」能被精确、去重地数出来——这正是整数键的用途。

变体键:整数位编码与去重

要把「实际用了哪些变体」数清楚,得给每个开关组合一个键,再去重。最省事的键是整数位掩码:第 $i$ 个开关占第 $i$ 位,组合的键就是各置位之和。

key(combo) = Σ_{s ∈ combo} (1 << index(s))

这样 [normalMap, metal] 的键是 1 | 4 = 5,[] 的键是 0。整数键去重只需一个集合。为验证编码正确,本节同时用另外两种口径去重——frozenset 与「排序后的关键词串」——三者必须给出同一个数。4 开关与 8 开关两组实测都一致(True / True)。

负例:键里漏掉一位

去重最危险的错是键漏了一位。假设为了「省一位」,键只取前三个开关、丢掉 shadow 位:那么只差 shadow 的两个变体会算成同一个键。本节的 9 个实际变体里有两对正好只差 shadow([] 与 [shadow]、[albedoMap] 与 [albedoMap, shadow]),于是去重数从 9 塌缩到 7——凭空少了两套着色器。这类缺陷不会让程序崩溃,只会让「某些材质在特定光照下高光不对」,极难定位。故键的每一位都必须在。

两组开关的位分配如下(位序即 switches 数组下标):

位4 开关组8 开关组
0normalMapnormalMap
1albedoMapalbedoMap
2metalspecularMap
3shadowroughnessMap
4—metal
5—shadow
6—emissive
7—twoSided

注意 4 开关组与 8 开关组的位含义不同:同一个整数键 4 在前者是 metal、在后者是 specularMap。所以键只在同一套开关集内可比,跨集比较没有意义。这一点决定了变体键必须带上「开关集标识」(哪怕只是开关个数的隐式约定),否则两套表的键会串味。

位序也不是随便定的:把最常出现的开关放在低位,能让「只差高频开关」的键在数值上更接近,方便把用到的那批键聚成连续区间来批量处理。本节为简单起见按数组顺序分配位,不额外排序。

变体表的三条不变量

把上面的结论收成三条可以写进测试的不变量:

  1. 组合数 = $2^n$:理论变体数只由开关个数决定,与场景无关。
  2. 去重数 ≤ 组合数:实际使用数是理论组合数的子集大小;两种口径(整数键、frozenset)必须给出同一个去重数。
  3. 不同键 → 不同帧:把开关接进着色器后,任意两个不同的键必须渲染出不同的像素缓冲 digest;若相同,说明某个开关写了却没接分支。

第 3 条是本节的负例判据:它拒绝「开关表里列了但着色器没实现」的实现。三条里只要有一条不成立,变体系统就不可信。

S 层:计数、编码与渲染

开关集与实际用到的组合放在 config/cg_shade_13_2_variants.json,脚本是 sim/cg_shade_13_2_shader_variants.py:

{
  "switches4": ["normalMap", "albedoMap", "metal", "shadow"],
  "switches8": ["normalMap", "albedoMap", "specularMap", "roughnessMap", "metal", "shadow", "emissive", "twoSided"],
  "used4": [[], ["albedoMap"], ["normalMap"], ["normalMap", "albedoMap"], ["metal"], ["metal", "albedoMap"], ["shadow"], ["albedoMap", "shadow"], ["normalMap", "albedoMap", "metal", "shadow"]],
  "used8": [[], ["albedoMap"], ["normalMap"], ["normalMap", "albedoMap"], ["metal"], ["metal", "albedoMap"], ["shadow"], ["albedoMap", "shadow"], ["specularMap"], ["specularMap", "metal"], ["roughnessMap"], ["roughnessMap", "metal"], ["emissive"], ["twoSided"], ["twoSided", "albedoMap"], ["normalMap", "roughnessMap"], ["normalMap", "roughnessMap", "specularMap"], ["normalMap", "roughnessMap", "specularMap", "metal"], ["albedoMap", "specularMap", "metal", "shadow"], ["emissive", "twoSided"], ["emissive", "shadow"], ["metal", "shadow"], ["normalMap", "albedoMap", "metal", "shadow"]]
}

键编码与去重(# snippet: 摘自真实脚本):

# snippet: sim/cg_shade_13_2_shader_variants.py 的键编码与去重(节选)
def variant_key(combo, switches, drop=None):
    """整数键:第 i 个开关占第 i 位。drop 用于演示「漏掉一个开关位」的负例。"""
    idx = {s: i for i, s in enumerate(switches)}
    k = 0
    for s in combo:
        if s == drop:
            continue
        k |= 1 << idx[s]
    return k


def count(used, switches, drop=None):
    return len({variant_key(c, switches, drop) for c in used})


def keyword_key(combo):
    return "+".join(sorted(combo))

以 [normalMap, metal] 为例:normalMap 是位 0、metal 是位 2,键 = 1 | 4 = 5。空组合的键是 0,4 开关全开的键是 15。键是稠密的——16 个组合恰好映射到 0..15,没有空洞,所以「场景用到哪些变体」可以用一张长度为 16 的位图表示。

实跑输出(/opt/homebrew/bin/python3 sim/cg_shade_13_2_shader_variants.py):

4 开关 : 组合 16 / 实际 9  爆炸比 1.7778x
8 开关 : 组合 256 / 实际 23  爆炸比 11.1304x
三种编码去重一致 : 4 开关=True 8 开关=True
负例 键漏 shadow 位: 去重 9 -> 7 (塌缩 2 个)
变体渲染:
  key= 0 []                                           sha 08d21d3cb52d…  新=True
  key= 2 ['albedoMap']                                sha 0f1af5d2a253…  新=True
  key= 4 ['metal']                                    sha b7e5ac8988d6…  新=True
  key= 8 ['shadow']                                   sha 2aaa3b551ab2…  新=True
  key= 3 ['normalMap', 'albedoMap']                   sha ca6832db0765…  新=True
  key=15 ['normalMap', 'albedoMap', 'metal', 'shadow'] sha daaacba275c6…  新=True
全部 digest 互异    : True

把开关真正接进着色器,逐个渲染实际用到的变体。四个开关在着色器里的落点:

  • metal:把 metalnessQ16 覆盖成 ONE 或 0,镜面颜色在「白」与 albedo 之间切换。
  • shadow:命中时整体乘固定衰减 39322(0.6)。
  • albedoMap:按法线 x 分量调制 albedo(没有纹理坐标可用时的确定性替代)。
  • normalMap:法线向 +z 倾斜 normalScaleQ16。

六个变体给出六个互异的帧 digest:

键开关组合帧 sha256(前 12 位)
0(无)08d21d3cb52d
2albedoMap0f1af5d2a253
4metalb7e5ac8988d6
8shadow2aaa3b551ab2
3normalMap, albedoMapca6832db0765
15normalMap, albedoMap, metal, shadowdaaacba275c6

「不同键 → 不同帧」不是巧合:它反过来证明键确实区分了着色器。若某个开关没接进着色器(写了开关但没写分支),两个只差该开关的变体就会渲染出相同的帧,去重数虽对、帧却重复——那是另一种变体缺陷,靠帧 digest 才能发现。

表里六行的 新=True 表示每一行的 digest 都是首次出现。六行覆盖了「单开关」「双开关」「四开关全开」三种规模:键 0(空组合)与键 15(全开)在视觉上差异最大,而键 4(仅金属)与键 0 只差金属度一项——它们仍然给出完全不同的 digest,说明单个开关的贡献是可测量的。这正是变体系统能「按需编译」的前提:每个开关的像素级效果都可区分、可断言。

P 层:计数与帧的跨宿主复现

// file: lantern/core/test/cg_shade_13_2_shader_variants.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_13_2_shader_variants.test.ts'
// 13.2 的 P 层:TypeScript 侧重算变体计数与整数键去重、重渲染变体帧,
// 断言与 S 侧 out/cg_shade_13_2_variants.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 { countVariants, renderVariant, variantKey } from "../src/lantern/cg_shade_13_variants.ts";
import type { MatFields } 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 cfg = JSON.parse(readFileSync(join(root, "config", "cg_shade_13_2_variants.json"), "utf8")) as {
  switches4: string[]; switches8: string[]; used4: string[][]; used8: string[][];
  renderVariants4: string[][]; activeMaterial: Partial<MatFields>;
};
const g = JSON.parse(readFileSync(join(root, "out", "cg_shade_13_2_variants.json"), "utf8")) as {
  counts: { combos4: number; used4: number; combos8: number; used8: number; explosion8: number };
  negative_drop_bit: { dedup_without_shadow: number };
  variant_renders: { combo: string[]; key: number; sha256: string }[];
  all_render_digests_distinct: 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");

test("【核心】变体计数与 S 侧一致:4 开关 16/9、8 开关 256/23", () => {
  assert.equal(1 << cfg.switches4.length, g.counts.combos4);
  assert.equal(1 << cfg.switches8.length, g.counts.combos8);
  assert.equal(countVariants(cfg.used4, cfg.switches4), g.counts.used4);
  assert.equal(countVariants(cfg.used8, cfg.switches8), g.counts.used8);
  assert.equal(Number((g.counts.combos8 / g.counts.used8).toFixed(4)), g.counts.explosion8);
});

test("【核心】整数键去重与 frozenset 去重给出同一个数", () => {
  assert.equal(countVariants(cfg.used4, cfg.switches4), new Set(cfg.used4.map((c) => [...c].sort().join("+"))).size);
  assert.equal(countVariants(cfg.used8, cfg.switches8), new Set(cfg.used8.map((c) => [...c].sort().join("+"))).size);
});

test("负例:键里漏掉 shadow 位 -> 去重数塌缩", () => {
  assert.equal(countVariants(cfg.used4, cfg.switches4, "shadow"), g.negative_drop_bit.dedup_without_shadow);
  assert.ok(g.negative_drop_bit.dedup_without_shadow < g.counts.used4, "漏位必须让变体数变少");
});

test("【核心】变体渲染:每个键的帧 sha256 与 S 侧逐字节相同且互异", () => {
  const seen = new Set<string>();
  for (const r of g.variant_renders) {
    const out = renderVariant(spec, cfg.activeMaterial, r.combo);
    assert.equal(variantKey(r.combo, cfg.switches4), r.key);
    assert.equal(sha(out.rgb), r.sha256, `变体 ${JSON.stringify(r.combo)} 的帧不一致`);
    assert.ok(!seen.has(r.sha256), "不同键必须给出不同帧");
    seen.add(r.sha256);
  }
  assert.equal(seen.size, g.variant_renders.length);
  assert.ok(g.all_render_digests_distinct);
});

node --test 'core/test/cg_shade_13_2_shader_variants.test.ts' 的实测输出:

✔ 【核心】变体计数与 S 侧一致:4 开关 16/9、8 开关 256/23
✔ 【核心】整数键去重与 frozenset 去重给出同一个数
✔ 负例:键里漏掉 shadow 位 -> 去重数塌缩
✔ 【核心】变体渲染:每个键的帧 sha256 与 S 侧逐字节相同且互异
ℹ tests 4  ℹ pass 4  ℹ fail 0

4 条全绿。变体计数、整数键去重、六帧 sha256 在两种语言下完全相同。

回到开头的两个错:计数错被第 1 条测试挡住(组合数与实际数分别断言),键漏位错被负例测试挡住(去重数必须从 9 掉到 7,而不是保持不变)。而「开关没接进着色器」的错被第 4 条测试挡住(不同键必须给不同帧)。三类缺陷各有各的判据,没有一条是「看着差不多」。

下一节把整张材质表做成数据驱动:一份 JSON 同时喂给 Python、TypeScript 与内联 HTML 三种宿主,证明三种宿主读到的参数逐字段相同,并且改一个字段后三处的产物同步变化。

阅读导航:上一节:13.1 材质的表示与继承 · 下一节:13.3 材质的数据驱动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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