11.2 反射驱动的参数绑定
11.1 把多个 WGSL 文件拼成了一个模块,但模块里的 uniform 结构体,CPU 侧得按一模一样的内存布局把数据填进去——偏移差一个字节,roughness 就会被读成 baseColor 的一部分,画面立刻错。手写偏移是 bug 温床:着色器里加一个字段,TS 侧的魔数就得跟着改,改漏一处就是深夜调试。这一节从 WGSL 的 struct 反射出布局,让着色器当布局的唯一真源。
本节产出
src/render/shader/uniform-reflect.ts(L1,vitest 直跑)与一个 L2 脚本:用反射出的偏移打包 uniform,渲染一帧回读像素,并与「手填偏移」做对照。
为什么参数绑定要「反射」
WebGPU 的 uniform 地址空间有一套明确的对齐规则,和 C 的紧凑结构体不一样:
| 类型 | 对齐 | 尺寸 |
|---|---|---|
f32 / i32 / u32 | 4 | 4 |
vec2<f32> | 8 | 8 |
vec3<f32> | 16 | 12 |
vec4<f32> | 16 | 16 |
mat4x4<f32> | 16 | 64 |
array<T, N> | 16 | 元素步长按 16 对齐 × N |
两条最容易踩的规则:vec3 对齐到 16 但只占 12 字节,所以 vec3 后面接 vec3 会空出 4 字节;结构体整体按 16 对齐,尾部也要补齐。人脑算这些还行,字段一多就会错,于是交给代码算。
手写偏移在真实项目里的翻车方式很典型:着色器里给 Material 加了一个 emissive: vec3<f32>,CPU 侧忘了在 roughness 前面留出对齐的空隙,于是之后每个字段整体错位,画面「好像只是颜色怪了一点」,排查却要一下午。反射把这种错误从「运行时像素错」提前到「布局计算时就能对上」,因为两边用的是同一份声明。
反射出什么
反射的产物是一张成员表:每个成员的 name、type、offset、size,外加整份 UniformLayout 的 size 与 align。有了这张表,打包就是「按偏移写字节」的机械动作,创建 GPUBuffer 时用 layout.size 当大小,也再不会开小或开大。
可运行产物:从 struct 反射出偏移并打包
下面这个 L1 模块做三件事:解析类型文本得到对齐与尺寸、按声明顺序累加偏移、把 JS 值按布局写进 ArrayBuffer。它同时支持 array<T, N>(元素步长按 16 对齐)。
// file: src/render/shader/uniform-reflect.ts
// tier: L1
// verify: npx vitest run src/render/shader/uniform-reflect.test.ts
//
// helio 渲染卷:从 WGSL 的 struct 反射出 uniform 内存布局,并把 JS 值按该布局打包。
// 让「参数怎么放」由着色器自己决定:C++/TS 侧不再手填偏移,改着色器里的 struct
// 就会自动反映到打包结果上(这是 11.2 的核心)。布局规则取 WGSL uniform 地址空间。
/** 支持反射的标量/向量/矩阵/数组类型。 */
export type ReflectKind = 'f32' | 'i32' | 'u32';
export interface MemberLayout {
readonly name: string;
readonly type: string;
/** 在缓冲区里的字节偏移。 */
readonly offset: number;
/** 该成员占用的字节数(含数组元素填充,不含尾部结构填充)。 */
readonly size: number;
readonly kind: ReflectKind;
/** 分量个数;数组为元素分量总数。 */
readonly components: number;
/** 是否为数组类型。 */
readonly isArray: boolean;
}
export interface UniformLayout {
readonly structName: string;
readonly binding: number;
readonly members: readonly MemberLayout[];
/** 整个 uniform 缓冲区的字节大小(已按 16 对齐)。 */
readonly size: number;
/** 结构体对齐,uniform 地址空间里恒为 16 的正倍数。 */
readonly align: number;
}
const roundUp = (align: number, v: number): number => Math.ceil(v / align) * align;
interface TypeInfo { align: number; size: number; kind: ReflectKind; components: number; isArray: boolean }
/** 解析单个类型文本(支持标量、vecN<f32>、matNxN<f32>、array<T, N>)。 */
export function typeInfo(type: string): TypeInfo {
const t = type.replace(/\s+/g, '');
const arr = /^array<(.+),(\d+)>$/.exec(t);
if (arr) {
const elem = typeInfo(arr[1]!);
const n = Number(arr[2]);
const stride = roundUp(16, elem.size); // uniform 里数组元素步长按 16 对齐
return { align: 16, size: stride * n, kind: elem.kind, components: elem.components * n, isArray: true };
}
const m = /^mat(\d)x(\d)<f32>$/.exec(t);
if (m) {
const cols = Number(m[1]);
// 每列是 vecR,按 16 对齐(vec3 列会被填到 16)
const colSize = roundUp(16, Number(m[2]) * 4);
return { align: 16, size: colSize * cols, kind: 'f32', components: cols * Number(m[2]), isArray: false };
}
const v = /^vec([234])<f32>$/.exec(t);
if (v) {
const n = Number(v[1]);
return { align: n === 2 ? 8 : 16, size: n * 4, kind: 'f32', components: n, isArray: false };
}
if (t === 'f32' || t === 'i32' || t === 'u32') {
return { align: 4, size: 4, kind: t, components: 1, isArray: false };
}
throw new Error(`不支持的反射类型: ${type}`);
}
/** 按 `<>` 深度切分 struct 成员(数组里的逗号不算分隔)。 */
function splitMembers(body: string): string[] {
const parts: string[] = [];
let depth = 0, cur = '';
for (const ch of body) {
if (ch === '<') depth++;
else if (ch === '>') depth--;
if (ch === ',' && depth === 0) { if (cur.trim()) parts.push(cur); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) parts.push(cur);
return parts;
}
/** 反射 WGSL 源码里第一个 `var<uniform>` 所指结构体的布局。 */
export function reflectUniform(wgsl: string): UniformLayout {
const bind = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var<uniform>\s+([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/.exec(wgsl);
if (!bind) throw new Error('未找到 var<uniform> 绑定');
const structName = bind[4]!;
const sm = new RegExp(`struct\\s+${structName}\\s*\\{([\\s\\S]*?)\\}`).exec(wgsl);
if (!sm) throw new Error(`未找到 struct ${structName}`);
const members: MemberLayout[] = [];
let offset = 0, maxAlign = 1;
for (const raw of splitMembers(sm[1]!)) {
const mm = /^\s*([A-Za-z_]\w*)\s*:\s*(.+?)\s*$/.exec(raw);
if (!mm) throw new Error(`无法解析成员: ${raw}`);
const info = typeInfo(mm[2]!);
offset = roundUp(info.align, offset);
members.push({
name: mm[1]!, type: mm[2]!, offset, size: info.size,
kind: info.kind, components: info.components, isArray: info.isArray,
});
offset += info.size;
maxAlign = Math.max(maxAlign, info.align);
}
const align = roundUp(16, maxAlign);
return { structName, binding: Number(bind[2]), members, size: roundUp(align, offset), align };
}
/** 按布局把值打包进 ArrayBuffer;缺字段按 0 处理。 */
export function packUniform(layout: UniformLayout, values: Readonly<Record<string, number | number[]>>): ArrayBuffer {
const buf = new ArrayBuffer(layout.size);
const dv = new DataView(buf);
for (const m of layout.members) {
const v = values[m.name];
if (v === undefined) continue;
const arr = Array.isArray(v) ? v : [v];
for (let i = 0; i < m.components; i++) {
const x = arr[i] ?? 0;
const at = m.offset + i * 4;
if (m.kind === 'f32') dv.setFloat32(at, x, true);
else if (m.kind === 'i32') dv.setInt32(at, x, true);
else dv.setUint32(at, x, true);
}
}
return buf;
}
npx vitest run src/render/shader/uniform-reflect.test.ts 本机实跑 Tests 7 passed。其中两个用例专门钉住对齐规则:Material 的成员偏移是 baseColor@0 / roughness@16 / metallic@20 / uvScale@24、总大小 32;而 vec3 后接 vec3 的 Light 会把第二个 vec3 推到偏移 16,总大小被尾部填充撑到 32。
真跑一次:反射出的布局被 GPU 接受
光算偏移还不够,得让 GPU 真的按这个布局读出正确的值。下面这份 shaders/material-uniform.wgsl 是完整的材质着色器,getCompilationInfo() 0 error。
// file: shaders/material-uniform.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/shader/uniform-reflect-l2.ts
struct Material {
baseColor: vec4<f32>,
roughness: f32,
metallic: f32,
uvScale: vec2<f32>,
};
@group(0) @binding(0) var<uniform> mat: Material;
struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0),
);
var o: VSOut;
o.pos = vec4<f32>(p[i], 0.0, 1.0);
o.uv = p[i] * 0.5 + vec2<f32>(0.5);
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
let shade = mat.baseColor.rgb * (1.0 - mat.roughness * mat.metallic);
let uv = in.uv * mat.uvScale;
return vec4<f32>(shade * step(0.0, uv.x), mat.baseColor.a);
}
L2 脚本反射这份 WGSL,按偏移打包 baseColor=(0.8,0.4,0.2,1)、roughness=0.5、metallic=0.5,画一帧回读中心像素。本机实跑(SwiftShader,node tools/run-l2.mjs src/render/shader/uniform-reflect-l2.ts):
offsets {baseColor: 0, roughness: 16, metallic: 20, uvScale: 24}
size 32
errors 0
center [153, 76, 38, 255]
回读值可以对账:baseColor.rgb × (1 − roughness × metallic) = (0.8,0.4,0.2) × 0.75 = (0.6,0.3,0.15),换算成 rgba8unorm 就是 [153, 76, 38, 255]——roughness 和 metallic 分别落在偏移 16 和 20,没有被写串。若把 uvScale 手填到偏移 22(反射给的是 24),UV 就被污染,step 的结果随之改变。偏移对不对,像素说了算。
反射本身有成本(正则扫描 + 字符串处理),但它只跑一次:按 WGSL 源码哈希缓存 UniformLayout,同一份着色器第二次创建绑定组时直接命中缓存。真正每帧跑的是 packUniform(),它只做 DataView 写入,不碰字符串。
另一个容易忽略的点是缓冲区大小:GPUBuffer 的 size 必须是 4 的倍数,而 layout.size 已经按 16 对齐,天然满足。如果手动把 Material 算成 28 字节(少了尾部填充),writeBuffer 会因为越界写被校验层拦下——反射顺手也把这个坑填了。
对齐规则从哪里来
WGSL uniform 地址空间的对齐规则不是拍脑袋定的,它对齐的是 GPU 的硬件限制:多数 GPU 读一个 vec4 要求 16 字节对齐;vec3 虽然只有 12 字节,但为了能塞进一次 vec4 读取,也被要求对齐到 16。array 的元素步长同理,必须是 16 的倍数。
和 C 的紧凑布局对照一下,差异一目了然:
| 结构 | C 紧凑布局 | WGSL uniform | 差异来自 |
|---|---|---|---|
{ vec3, f32 } | 16 | 16 | 无 |
{ vec3, vec3 } | 24 | 32 | 第二个 vec3 对齐到 16 |
{ vec3, f32, f32 } | 20 | 24 | 尾部按 16 补齐 |
{ f32, vec4 } | 20 | 32 | vec4 要求 16 对齐 |
手写偏移时最容易漏的正是 { vec3, f32, f32 } 这一行——在 C 里它是 20 字节,在 WGSL uniform 里却是 24。反射器把这套规则编码进 typeInfo(),就不会漏。
这也是为什么 helio 不用「手写布局 + 单测兜底」:单测只能覆盖你想到的字段组合,反射覆盖所有组合。代价是反射器本身要支持足够多的类型——目前覆盖标量、vec2/3/4、mat3x3/mat4x4、array<T, N>,够材质、光照、阴影用;遇到不支持的组合会直接抛错,而不是悄悄算错。
顺带一提,UniformLayout 里还带了 binding——反射时顺手把 @binding(n) 也记下来。创建 GPUBindGroup 时就能直接用它对齐槽位号,不必再手抄一遍。
与卷一反射机制的关系
「布局由声明推导」这件事,卷一已经在 CPU 侧做过一遍:16.2 类型注册与属性描述符
里的 TypeRegistry 从字段类型累加偏移,产出名字、类型、偏移、默认值四件套。本节是同一思想在 GPU 侧的对应物——只不过这次的对齐规则由 WGSL 的 uniform 地址空间规定,而不是 CPU 的紧凑布局。两者的共同点很关键:声明是唯一真源,布局是推导出来的,永远不手写。
两者也有一个现实差异:卷一的 TypeRegistry 服务于编辑器与序列化,要求信息尽可能全(默认值、枚举、引用关系);本节只为绑定服务,只要偏移与大小,所以实现可以更薄。需要更多信息时,再加字段即可,接口形状不变。
小结
uniform 绑定的痛点从来不是「怎么写」,而是「怎么保证两边一致」。手填偏移把一致性押在人的记忆上,反射把一致性押在声明上。helio 用 reflectUniform() 从 WGSL 的 struct 算出偏移与大小,再用 packUniform() 按同一份布局打包——着色器改一个字段,打包结果自动跟着变,TS 侧一行都不用动。
这套思路会一直用到后面的光照、阴影、后处理:凡是「着色器里声明了一块 uniform」的地方,CPU 侧都用同一套反射去填。它不是某节的一次性技巧,而是贯穿全卷的绑定范式。
下一章的光照就会用到它:一帧里几十盏灯的参数都塞进一块 uniform 数组,靠反射算出的步长逐灯写入,做到一处声明、处处对齐。
布局对了,但着色器出错时怎么定位?这是下一节的主题:getCompilationInfo() 给出带行列号的诊断,而经过 include 展开的源码,行号还需要映射回原始文件。
阅读导航:上一节:11.1 WGSL 组织与 include · 下一节:11.3 着色器调试与错误定位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。