10.3 变体爆炸与编译缓存
上一节的展开器很好用,但它把一个麻烦留给了我们:开关越多,变体越多,而且是乘性的。6 个独立开关就是 2⁶ = 64 个变体;12 个就是 4096;20 个就是一百多万。真实引擎里「有没有法线贴图 / 有没有顶点色 / 是否 alpha 测试 / 是否接收阴影 / 是否自发光……」这样数下去,光开关就能轻松上两位数。这个乘性增长就是变体爆炸(variant explosion)。
变体爆炸的代价是双重的:每个变体要编译一次(耗时),编译结果要存一份(内存或磁盘)。所以工程上有两条出路:一是用依赖关系砍掉不可能的组合,二是把编译结果按内容缓存、复用它。这一节把两条都做出来。
本节产出一个完整可跑的
src/render/material/variant-cache.ts(L1,Node 直跑)和一个在设备上编译全部变体的 L2 脚本。文中所有变体数、命中数、编译结果都来自本机实跑。
爆炸有多大:朴素上界与依赖裁剪
先算朴素上界:每个开关独立地开或关,n 个开关就是 2ⁿ。这是最坏情况,也是很多引擎文档里那个吓人的数字。
但真实特性之间有依赖。看 helio 的六个开关:
NORMAL_MAP依赖USE_UV——没法线贴图采样用的 uv,就没有法线贴图;SHADOW_RECEIVE依赖NORMAL_MAP——接收阴影要先有能扰动的法线。
于是「NORMAL_MAP 开、USE_UV 关」这种组合根本不可能出现。枚举可达集就是把每个子集拿出来,检查「凡是选中的特性,它的所有 requires 是否也被选中」,不满足的整个丢掉。这样从 64 个候选里筛掉一半,剩下 32 个。
这 32 个就是真正需要编译的变体。砍掉的是编译期与运行期的实际开销,不只是纸面数字。
可以手动验算一遍这个 32:把六个特性分成独立组 USE_VCOLOR / ALPHA_TEST / EMISSIVE 与依赖链 USE_UV → NORMAL_MAP → SHADOW_RECEIVE。独立组各 2 种,共 2³ = 8 种;依赖链枚举:USE_UV 关时 NORMAL_MAP、SHADOW_RECEIVE 只能都关(1 种),USE_UV 开时 NORMAL_MAP 可开可关、开着时 SHADOW_RECEIVE 又可开可关(3 种),合计 4 种。于是 8 × 4 = 32,与脚本输出一致。手工推的数和机器枚举的数对上,才敢相信裁剪逻辑没写错。
可运行产物:枚举可达集与内容寻址缓存(L1)
下面是完整的 src/render/material/variant-cache.ts。它把「按依赖枚举」和「内容寻址缓存」放进同一个文件:enumerateReachable() 负责裁剪,VariantCache 负责去重。
// file: src/render/material/variant-cache.ts
// tier: L1
// verify: node src/render/material/variant-cache.ts
//
// helio 渲染卷 10.3:变体爆炸与编译缓存。
// n 个独立开关朴素地给出 2^n 个变体,但特性之间有依赖,真实可达集更小。
// 本文件按依赖枚举可达变体,并用内容寻址(源码 + 开关集合的哈希)给编译结果去重。
import { expandMacros, TEMPLATE_FEATURES, VARIANT_TEMPLATE } from './shader-variants.ts';
export type FeatureDef = { readonly name: string; readonly requires?: readonly string[] };
/** 6 个特性,其中 NORMAL_MAP 依赖 USE_UV,SHADOW_RECEIVE 依赖 NORMAL_MAP。 */
export const FEATURES: readonly FeatureDef[] = [
{ name: 'USE_UV' },
{ name: 'USE_VCOLOR' },
{ name: 'ALPHA_TEST' },
{ name: 'NORMAL_MAP', requires: ['USE_UV'] },
{ name: 'EMISSIVE' },
{ name: 'SHADOW_RECEIVE', requires: ['NORMAL_MAP'] },
];
/** 朴素上界:每个开关独立开/关。 */
export function naiveCount(features: readonly FeatureDef[]): number {
return 2 ** features.length;
}
/** 可达集:只保留「所有 requires 也被选中」的子集,结果按名字排序。 */
export function enumerateReachable(features: readonly FeatureDef[]): string[][] {
const n = features.length;
const out: string[][] = [];
for (let mask = 0; mask < (1 << n); mask++) {
const set = new Set<string>();
for (let i = 0; i < n; i++) if (mask & (1 << i)) set.add(features[i]!.name);
let ok = true;
for (const f of features) {
if (!set.has(f.name)) continue;
for (const r of f.requires ?? []) if (!set.has(r)) ok = false;
}
if (ok) out.push([...set].sort());
}
return out;
}
/** FNV-1a 32 位:短、稳定、无依赖,够做编译缓存的键。 */
export function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
return h >>> 0;
}
/** 内容寻址键:源码与「排序后的开关集合」一起哈希,任一变化即换键。 */
export function variantHash(source: string, defines: Iterable<string>): string {
const defs = [...defines].sort().join(',');
return fnv1a(`${source}\u0000${defs}`).toString(16).padStart(8, '0');
}
/** 编译缓存:键命中即复用,未命中才「编译」。stats 记录命中/未命中。 */
export class VariantCache {
readonly #compiled = new Set<string>();
hits = 0;
misses = 0;
/** 返回该变体的键与是否命中缓存。命中即代表无需再调用编译器。 */
request(source: string, defines: Iterable<string>): { readonly key: string; readonly hit: boolean } {
const key = variantHash(source, defines);
if (this.#compiled.has(key)) {
this.hits++;
return { key, hit: true };
}
this.#compiled.add(key);
this.misses++;
return { key, hit: false };
}
get size(): number {
return this.#compiled.size;
}
}
// ---- 直接运行本文件:打印爆炸规模与缓存命中 ----
if (typeof process !== 'undefined' && (process as unknown as { argv?: readonly string[] }).argv?.[1]?.endsWith('variant-cache.ts')) {
const reachable = enumerateReachable(FEATURES);
const naive = naiveCount(FEATURES);
const cache = new VariantCache();
let requests = 0;
for (const v of reachable) {
const src = expandMacros(VARIANT_TEMPLATE, new Set(v)); // 真实键基于展开后的源码
for (let rep = 0; rep < 2; rep++) {
cache.request(src, v);
requests++;
}
}
console.log(`features ${FEATURES.length} naive ${naive} reachable ${reachable.length}`);
console.log(`requests ${requests} unique ${cache.size} hits ${cache.hits} misses ${cache.misses}`);
console.log(`hitRate ${((cache.hits / requests) * 100).toFixed(1)}%`);
console.log(`pruned ${naive - reachable.length} of ${naive} (${(((naive - reachable.length) / naive) * 100).toFixed(1)}%)`);
console.log(`templateFeatures ${TEMPLATE_FEATURES.length} sample ${JSON.stringify(reachable[reachable.length - 1])}`);
}
node src/render/material/variant-cache.ts(本机实测):
features 6 naive 64 reachable 32
requests 64 unique 32 hits 32 misses 32
hitRate 50.0%
pruned 32 of 64 (50.0%)
templateFeatures 6 sample ["ALPHA_TEST","EMISSIVE","NORMAL_MAP","SHADOW_RECEIVE","USE_UV","USE_VCOLOR"]
朴素上界 64,可达集 32——依赖关系砍掉了整整一半。演示里每个可达变体请求两次,共 64 次请求,缓存只放了 32 个键,命中 32 次、未命中 32 次,命中率 50%。注意缓存的键是内容寻址的:variantHash() 把「展开后的 WGSL 源码」和「排序后的开关集合」一起哈希。这带来两个好处——开关集合顺序不同但内容相同,键相同;而只要源码或开关有任何一个字节变化,键就不同,绝不会把两个不同变体误判成同一个。
落到真实引擎里,这套缓存通常会写到磁盘:键就是文件名,命中即跳过编译。有三条规则值得写下来:
- 键必须包含源码:只哈希开关名,会漏掉「同一组开关但模板改过」的情况;
- 键必须排序后再拼:否则
{A,B}与{B,A}会算成两个不同的键,白白多编译一次; - 失效要显式:改了模板或编译器版本,旧键自然不再命中,但要记得清理旧文件。
可运行产物:在设备上编译全部变体并去重(L2)
L1 证明了计数正确,L2 把它搬到真实设备上:展开全部可达变体、逐个编译、用 VariantCache 去重。脚本 src/render/material/cache-l2.ts 还故意编译一个被依赖排除的非法组合,证明裁剪不是可选的。
// file: src/render/material/cache-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/material/cache-l2.ts
//
// 把 10.3 的枚举与缓存搬到真实设备上:对全部可达变体展开并逐个编译,
// 要求 0 error;用 VariantCache 去重,统计「请求数 / 实际编译数 / 命中数」。
// 最后故意编译一个被依赖排除的非法组合,证明裁剪不是可选的。
import { expandMacros, VARIANT_TEMPLATE } from './shader-variants.ts';
import { enumerateReachable, FEATURES, naiveCount, VariantCache, variantHash } from './variant-cache.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
readonly checks: L2Check[];
readonly data: Record<string, unknown>;
}
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const data: Record<string, unknown> = {};
const push = (name: string, ok: boolean, detail: unknown): void => {
checks.push({ name, ok, detail: String(detail) });
};
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('requestAdapter() returned null');
const device = await adapter.requestDevice();
const compileErrors = async (code: string): Promise<string[]> => {
const info = await device.createShaderModule({ code }).getCompilationInfo();
return info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum} ${m.message}`);
};
const reachable = enumerateReachable(FEATURES);
data.naive = naiveCount(FEATURES);
data.reachable = reachable.length;
push('可达变体数 = 32(朴素 64)', reachable.length === 32 && naiveCount(FEATURES) === 64,
`${reachable.length}/${naiveCount(FEATURES)}`);
// 展开 + 编译全部可达变体,统计错误。
const cache = new VariantCache();
let requests = 0;
let totalErrors = 0;
for (const v of reachable) {
const code = expandMacros(VARIANT_TEMPLATE, new Set(v));
for (let rep = 0; rep < 2; rep++) {
const r = cache.request(code, v);
requests++;
if (r.hit) continue; // 命中即复用,不再编译
const errs = await compileErrors(code);
totalErrors += errs.length;
}
}
data.requests = requests;
data.compiled = cache.misses;
data.hits = cache.hits;
push('全部可达变体编译 0 error', totalErrors === 0, `errors=${totalErrors}`);
push('缓存把 64 次请求压成 32 次编译', cache.misses === 32 && cache.hits === 32,
`${cache.misses} compiled / ${cache.hits} hits`);
// 非法组合:NORMAL_MAP 依赖 USE_UV,缺了它就引用不到 in.uv。
const bad = expandMacros(VARIANT_TEMPLATE, new Set(['NORMAL_MAP']));
const badErrors = await compileErrors(bad);
data.badErrors = badErrors.length;
push('非法组合(NORMAL_MAP 缺 USE_UV)编译报错', badErrors.length >= 1, badErrors[0] ?? 'no error');
// 真实编译耗时:串行编译 32 个唯一变体(本机 SwiftShader,数值不可移植)。
let reErr = 0;
const t0 = performance.now();
for (const v of reachable) reErr += (await compileErrors(expandMacros(VARIANT_TEMPLATE, new Set(v)))).length;
data.compileMsFor32 = Number((performance.now() - t0).toFixed(1));
push('串行重编译 32 个变体仍 0 error', reErr === 0, `errors=${reErr}, ${data.compileMsFor32} ms`);
data.sampleKey = variantHash(expandMacros(VARIANT_TEMPLATE, new Set(reachable[0]!)), reachable[0]!);
device.destroy();
return { checks, data };
}
node tools/run-l2.mjs src/render/material/cache-l2.ts(headless Chromium + SwiftShader,本机实测):
PASS 可达变体数 = 32(朴素 64) 32/64
PASS 全部可达变体编译 0 error errors=0
PASS 缓存把 64 次请求压成 32 次编译 32 compiled / 32 hits
PASS 非法组合(NORMAL_MAP 缺 USE_UV)编译报错 24 struct member uv not found
PASS 串行重编译 32 个变体仍 0 error errors=0, 2.7 ms
前三行说明:32 个可达变体全部编译 0 error,64 次请求被缓存压成 32 次实际编译。第四行是本节最该记住的一句——把 NORMAL_MAP 单独打开(不打开 USE_UV),编译器报 struct member uv not found。这正说明依赖裁剪不是「优化」,而是「正确性」:不裁掉非法组合,它们会在编译期炸;裁掉它们,剩下的 32 个才全部是能编译的。
最后一行 2.7 ms 是把 32 个变体串行编译一遍的本机实测耗时(同一脚本多次运行落在 2.4–3.4 ms 之间,随机器与驱动变化,不可移植,仅作量级参考)。它给出缓存的价值尺度:64 次请求若不去重就要编译 64 次,去重后只需 32 次——省下的正是这份时间的一半。
小结
这一节把「变体爆炸」从一句吓人的口号算成了两个具体数字:朴素 64、可达 32,再用一个内容寻址缓存把重复的编译请求折叠掉。两者合起来回答了本章开头的问题——「同一份材质怎么在多种开关下都跑得动、又不被组合数压垮」。
到这里,材质与着色器(一)就讲完了:10.1 定下参数的布局与绑定,10.2 用宏把模板裁剪成变体,10.3 用依赖与缓存把变体的数量与开销压回可控。下一章转入着色器(二)——WGSL 的组织方式、反射驱动的绑定,以及怎么读编译诊断。
阅读导航:上一节:10.2 着色器变体与宏 · 下一节:11.1 WGSL 组织与 include 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。