10.2 着色器变体与宏
上一节把材质参数送进了 GPU,但那段 material-flat.wgsl 是写死的:它永远采样 uv、永远乘 tint。真实材质不是这样——同一份材质要在「有法线贴图」和「没法线贴图」、「带顶点色」和「不带顶点色」、「开 alpha 测试」和「不开」等许多开关下都能跑。一组开关组合就叫一个变体(variant)。
问题在于 WGSL 没有 C 的预处理器,没有 #ifdef。你不可能把「可选功能」写进一份源码再交给 GPU 去挑。可行做法只有一条:在 CPU 侧先用一个宏展开器,把一份带条件指令的模板裁剪成一份具体、完整、可编译的 WGSL,再送去 createShaderModule。
本节产出一个完整可跑的
src/render/material/shader-variants.ts(L1,Node 直跑)和一个把展开结果逐个编译的 L2 脚本。文中所有变体键、行数、编译结果都来自本机实跑。
变体从哪来:开关、模板与展开
展开器要处理的就是四个指令:#ifdef X / #ifndef X / #else / #endif。规则和 C 预处理器一致:
- 遇到条件指令行本身不产出任何文本,只改变「当前是否在产出」的状态;
- 用一个栈支持嵌套:每个
#ifdef压一帧,#else翻转栈顶,#endif弹栈; - 只有所有栈帧都 active 时,普通行才原样保留;
- 不成对的
#else/#endif、或未闭合的#ifdef,直接抛错——模板写坏了应该在构建期就发现。
模板里每个 #ifdef 都会真的改变源码。这一点很关键:如果一个开关在模板里根本没出现,那么「开」和「关」展开出的是同一份源码,变体去重就会把它俩错误地合并成一个(这正是 10.3 会踩的坑,本节先把规矩立住)。
模板里还有一处细节值得盯:函数参数表的逗号。vs 的位置参数恒有,而 uv、color 是条件性的,所以逗号必须写在条件块内部(#ifdef USE_UV 之后的 , @location(1) uv: vec2<f32>)。否则关掉开关时就会留下一个悬空逗号,WGSL 编译不过。
除了展开,还要给每个变体一个稳定的变体键:按特性声明顺序,把每个开关编成 0 或 1。它与开关名本身解耦,因此两个变体是否相同可以逐字符比较,也方便写进缓存文件名。
可运行产物:一个行级 WGSL 宏展开器(L1)
下面是完整的 src/render/material/shader-variants.ts:一份六开关的 WGSL 模板、展开器、以及变体键编码。
// file: src/render/material/shader-variants.ts
// tier: L1
// verify: node src/render/material/shader-variants.ts
//
// helio 渲染卷 10.2:着色器变体与宏。
// WGSL 没有 C 预处理器,但材质需要「同一份源码按特性开关生成不同程序」。
// 本文件实现一个只认 #ifdef / #ifndef / #else / #endif 的行级展开器,
// 并把一份模板与一组开关编码成稳定的变体键。
export type FeatureSet = ReadonlySet<string>;
/** 一份可被特性开关裁剪的 WGSL 模板。每个特性都会改变展开后的源码。 */
export const VARIANT_TEMPLATE = `struct Uniforms {
baseColor: vec4<f32>,
params: vec4<f32>,
emissive: vec3<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VSOut {
@builtin(position) pos: vec4<f32>,
#ifdef USE_UV
@location(0) uv: vec2<f32>,
#endif
#ifdef USE_VCOLOR
@location(1) color: vec4<f32>,
#endif
};
@vertex
fn vs(
@location(0) position: vec3<f32>
#ifdef USE_UV
, @location(1) uv: vec2<f32>
#endif
#ifdef USE_VCOLOR
, @location(2) color: vec4<f32>
#endif
) -> VSOut {
var o: VSOut;
o.pos = vec4<f32>(position, 1.0);
#ifdef USE_UV
o.uv = uv;
#endif
#ifdef USE_VCOLOR
o.color = color;
#endif
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
var c = u.baseColor;
#ifdef USE_VCOLOR
c = c * in.color;
#endif
#ifdef USE_UV
c = c * vec4<f32>(in.uv, 0.0, 1.0);
#endif
#ifdef NORMAL_MAP
c = c * (0.5 + 0.5 * in.uv.x);
#endif
#ifdef EMISSIVE
c = c + vec4<f32>(u.emissive, 0.0);
#endif
#ifdef ALPHA_TEST
if (c.a < u.params.x) { discard; }
#endif
#ifdef SHADOW_RECEIVE
c = c * u.params.y;
#endif
return c;
}
`;
const DIRECTIVE = /^#(ifdef|ifndef|else|endif)\b\s*(\S+)?/;
/**
* 行级展开:条件指令行本身不产出,普通行在「所有栈帧都 active」时原样保留。
* 用栈支持嵌套,遇到不成对的 #else / #endif 或未闭合的 #ifdef 直接抛错。
*/
export function expandMacros(source: string, defines: FeatureSet): string {
const out: string[] = [];
const stack: { active: boolean; taken: boolean }[] = [];
const emitting = (): boolean => stack.every((f) => f.active);
for (const raw of source.split('\n')) {
const m = DIRECTIVE.exec(raw.trim());
if (!m) {
if (emitting()) out.push(raw);
continue;
}
const kind = m[1]!;
const name = m[2];
if (kind === 'ifdef' || kind === 'ifndef') {
const has = name !== undefined && defines.has(name);
const cond = kind === 'ifdef' ? has : !has;
stack.push({ active: emitting() && cond, taken: cond });
} else if (kind === 'else') {
const top = stack[stack.length - 1];
if (!top) throw new Error('#else 没有配对的 #ifdef');
const parentActive = stack.slice(0, -1).every((f) => f.active);
top.active = parentActive && !top.taken;
top.taken = true;
} else {
if (!stack.pop()) throw new Error('#endif 没有配对的 #ifdef');
}
}
if (stack.length > 0) throw new Error('有 #ifdef 未闭合');
return out.join('\n');
}
/** 变体键:按特性声明顺序,每个开关编成 0/1,与开关名本身解耦,稳定可比。 */
export function variantKey(features: readonly string[], defines: FeatureSet): string {
return features.map((f) => (defines.has(f) ? '1' : '0')).join('');
}
/** 供 10.3 复用的特性清单(含依赖关系,依赖由调用方保证满足)。 */
export const TEMPLATE_FEATURES: readonly string[] = [
'USE_UV', 'USE_VCOLOR', 'ALPHA_TEST', 'NORMAL_MAP', 'EMISSIVE', 'SHADOW_RECEIVE',
];
// ---- 直接运行本文件:展开两个变体并打印差异 ----
if (typeof process !== 'undefined' && (process as unknown as { argv?: readonly string[] }).argv?.[1]?.endsWith('shader-variants.ts')) {
const base: FeatureSet = new Set();
const foliage: FeatureSet = new Set(['USE_UV', 'ALPHA_TEST']);
const opaque = expandMacros(VARIANT_TEMPLATE, base);
const leafy = expandMacros(VARIANT_TEMPLATE, foliage);
const directives = (s: string): number => s.split('\n').filter((l) => DIRECTIVE.test(l.trim())).length;
console.log(`template lines ${VARIANT_TEMPLATE.split('\n').length}`);
console.log(`base key=${variantKey(TEMPLATE_FEATURES, base)} lines=${opaque.split('\n').length} directives=${directives(opaque)}`);
console.log(`foliage key=${variantKey(TEMPLATE_FEATURES, foliage)} lines=${leafy.split('\n').length} directives=${directives(leafy)}`);
console.log(`base has uv? ${opaque.includes('o.uv = uv;')}`);
console.log(`foliage has uv? ${leafy.includes('o.uv = uv;')} alpha? ${leafy.includes('discard')}`);
}
node src/render/material/shader-variants.ts(本机实测):
template lines 62
base key=000000 lines=26 directives=0
foliage key=101000 lines=31 directives=0
base has uv? false
foliage has uv? true alpha? true
模板 62 行。空开关集(key=000000)展开成 26 行,全部 #ifdef 都被裁掉,base 里没有 o.uv = uv;;{USE_UV, ALPHA_TEST}(key=101000,第 1 位与第 3 位为 1)展开成 31 行,o.uv = uv; 与 discard 都出现了。两次展开的 directives=0,说明所有指令行都被消费掉,产出的是干净的、可直接编译的 WGSL。
可运行产物:把变体编译出来验证(L2)
展开对不对,最终要 GPU 说了算。{USE_UV, ALPHA_TEST} 展开后的完整文件被物化成 shaders/variant-foliage.wgsl:
// file: shaders/variant-foliage.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/material/variant-l2.ts
// 由 shader-variants.ts 的 expandMacros() 展开 USE_UV + ALPHA_TEST 得到。
struct Uniforms {
baseColor: vec4<f32>,
params: vec4<f32>,
emissive: vec3<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VSOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(
@location(0) position: vec3<f32>
, @location(1) uv: vec2<f32>
) -> VSOut {
var o: VSOut;
o.pos = vec4<f32>(position, 1.0);
o.uv = uv;
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
var c = u.baseColor;
c = c * vec4<f32>(in.uv, 0.0, 1.0);
if (c.a < u.params.x) { discard; }
return c;
}
L2 脚本 src/render/material/variant-l2.ts 把展开器搬进浏览器:对若干开关组合调用 expandMacros(),逐个 createShaderModule() + getCompilationInfo(),要求每个变体 0 error;再取回物化文件,断言它就是 USE_UV+ALPHA_TEST 的展开。
// file: src/render/material/variant-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/material/variant-l2.ts
//
// 在真实 WebGPU 上验证展开器:对若干开关组合调用 expandMacros() 得到 WGSL,
// 逐个 createShaderModule() + getCompilationInfo(),要求每个变体 0 error。
// 再取回物化文件 shaders/variant-foliage.wgsl,断言它就是 USE_UV+ALPHA_TEST 的展开。
import { expandMacros, TEMPLATE_FEATURES, VARIANT_TEMPLATE, variantKey } from './shader-variants.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
readonly checks: L2Check[];
readonly data: Record<string, unknown>;
}
const CASES: readonly (readonly string[])[] = [
[],
['USE_UV'],
['USE_VCOLOR'],
['USE_UV', 'ALPHA_TEST'],
['USE_UV', 'NORMAL_MAP'],
['USE_UV', 'NORMAL_MAP', 'SHADOW_RECEIVE', 'EMISSIVE', 'USE_VCOLOR', 'ALPHA_TEST'],
];
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 errorsOf = async (code: string): Promise<string[]> => {
const module = device.createShaderModule({ code });
const info = await module.getCompilationInfo();
return info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
};
const keys: string[] = [];
for (const features of CASES) {
const defines = new Set(features);
const code = expandMacros(VARIANT_TEMPLATE, defines);
const errs = await errorsOf(code);
const key = variantKey(TEMPLATE_FEATURES, defines);
keys.push(key);
push(`variant ${key || '(base)'} 0 error`, errs.length === 0, errs.join(' | ') || 'ok');
}
data.keys = keys;
data.caseCount = CASES.length;
const onDisk = await (await fetch('/shaders/variant-foliage.wgsl')).text();
const diskErrors = await errorsOf(onDisk);
push('物化文件 variant-foliage.wgsl 0 error', diskErrors.length === 0, diskErrors.join(' | ') || 'ok');
const expected = expandMacros(VARIANT_TEMPLATE, new Set(['USE_UV', 'ALPHA_TEST'])).trim();
push('物化文件 == USE_UV+ALPHA_TEST 展开', onDisk.includes(expected), `contains=${onDisk.includes(expected)}`);
device.destroy();
return { checks, data };
}
node tools/run-l2.mjs src/render/material/variant-l2.ts(headless Chromium + SwiftShader,本机实测):
PASS variant 000000 0 error ok
PASS variant 100000 0 error ok
PASS variant 010000 0 error ok
PASS variant 101000 0 error ok
PASS variant 100100 0 error ok
PASS variant 111111 0 error ok
PASS 物化文件 variant-foliage.wgsl 0 error ok
PASS 物化文件 == USE_UV+ALPHA_TEST 展开 contains=true
六个开关组合——从全关的 000000 到全开的 111111——全部编译 0 error。这证明展开器不只是「删掉几行」:它产出的结构体成员、参数表、函数体都自洽。特别值得看 111111:USE_UV 与 USE_VCOLOR 同时打开时,vs 的参数表是 position, uv, color 三个都在,逗号拼接正确,没有出现悬空逗号或重复逗号。
小结
这一节把「着色器变体」落成了一个行级宏展开器:四个指令、一个栈、一条「所有帧 active 才产出」的规则,加上一个稳定的变体键编码。它把「一份模板 → 多份可编译 WGSL」这件事从「靠人肉复制粘贴」变成了可测试的纯函数。
但展开器也把问题放大了:6 个开关朴素地意味着 2⁶ = 64 个变体。若特性之间还有依赖,很多组合根本不可能出现;而每个变体都要真的编译一次、占一份缓存。下一节就算这笔账——变体爆炸到底有多大、哪些可以被依赖关系裁掉、编译结果怎么用内容寻址缓存起来。
阅读导航:上一节:10.1 材质模型与参数 · 下一节:10.3 变体爆炸与编译缓存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。