Part of #114.
Context
Depends on the StructuredUniform sub-issue (Wave 1). Do not start until that API's UniformLayoutInfo shape has landed. This is the Wave 2 follow-up and the highest-leverage item in the whole proposal: it turns StructuredUniform's TS schema and the WGSL struct into a checked pair instead of two sources of truth.
The motivating incident: Eve's Params uniform (shaders/shared/glass-common.wgsl:1) is mirrored by a hand-counted 44-slot Float32Array writer whose only correctness guarantee is a comment (// INVARIANT: 176 bytes / 44 f32; field order must match …). A 32-byte drift between branches (144 B → 176 B) produced silent shader garbage. StructuredUniform fixes the writing; this API fixes the drift — schema vs WGSL mismatch becomes a thrown ValidationError at dev-time setup, not garbage pixels.
Implementation facts (verified against main):
Reflection.hostShareableLayoutsalready exists as a reserved empty tuple (packages/wgsl/src/runtime/reflect.ts:8). This proposal fills it — the slot was pre-approved.reflect(modules)already receivesMangleModule[]whose.tokensfield carries the scanner output (resolveShader.ts:69,scanner.ts). Struct extraction should walk tokens, not regex raw source: Eve'sParamsbody contains line comments (glass-common.wgsl:15,17) that the scanner already classifies, and@align/@sizeattribute handling is token-shaped (cf.parser.tsskipAttrs). This stays within the house "no full parser" budget (~60 lines) while being strictly more robust than regex.- Extraction runs pre-minify on source-level names, so struct/member names are stable regardless of
minify.identifiers. cacheKeys()(cacheKey.ts:5-12) reads onlyentryPoints/bindings/overrides— fillinghostShareableLayoutsdoes not invalidate existing shader caches.- The byte-layout table (
TYPES+roundUp, ~25 lines) currently lives in@vgpu/render/src/domain/wgsl-alignment.ts. It moves to@vgpu/wgsl(which has zero deps);wgsl-alignment.tsre-exports it, API unchanged. Dep direction is alreadyrender → core → wgsl, so no cycle. - Real-world hazard found in Eve: the resolved graph contains two structs named
Params(shaders/env/background.wgsl:6andshaders/shared/glass-common.wgsl:1). Name-only lookup is ambiguous today — the API must handle this, not assume uniqueness.
Proposed API
// ── @vgpu/wgsl (new: runtime/structLayout.ts; wired into runtime/reflect.ts) ──
interface StructMemberLayout {
readonly name: string;
readonly type: string; // WGSL source type text, e.g. "vec4f", "mat4x4<f32>"
readonly offset: number; // WGSL uniform layout rules, honoring @align/@size
readonly size: number;
readonly align: number;
}
interface StructLayout {
readonly name: string; // source-level name (pre-minify)
readonly module: string; // module path — disambiguates duplicate struct names
readonly byteSize: number;
readonly members: readonly StructMemberLayout[];
/** false when the struct uses constructs the layout table doesn't model
* (nested structs, runtime-sized arrays, f16, atomics). Members computed
* up to the first unmodeled one are still reported. */
readonly complete: boolean;
}
// Reflection.hostShareableLayouts: readonly StructLayout[] (was: readonly [] — reserved, reflect.ts:8)
// v1 scope: scalars, vectors, matrices (the 15 WgslUniformType names) + array<T, N> of those.
// Moved from @vgpu/render/domain/wgsl-alignment.ts (re-exported there, unchanged):
export { alignUniforms, wgslType, isWgslUniformType, type WgslUniformType, type UniformLayoutInfo };
// ── validateUniformLayout — LIVES IN @vgpu/wgsl, re-exported by @vgpu/render ──
// Parameter types are structural: no @vgpu/render import; usable from node build scripts.
interface UniformLayoutLike { // structural match of UniformLayoutInfo
readonly fields: readonly { name: string; type: string; offset: number; size: number }[];
readonly byteSize: number;
}
function validateUniformLayout(
uniform: UniformLayoutLike | { readonly layout: UniformLayoutLike }, // StructuredUniform matches via .layout
shader: { readonly reflection: { readonly hostShareableLayouts: readonly StructLayout[] } }, // ResolvedShader matches
opts: {
readonly struct: string;
/** Module path (or unambiguous suffix). REQUIRED when `struct` matches >1 module —
* ambiguity throws, listing candidates. */
readonly module?: string;
/** "layout" (default): compares BYTE SPANS, not names. Valid iff byteSize matches and
* every WGSL member span is exactly tiled by schema field spans (a schema may subdivide
* a WGSL vec4f into 4 named f32s); where spans coincide 1:1, types must match.
* "names": additionally requires 1:1 name equality in order — catches same-type
* reorders (e.g. fov/aspect swap) that layout mode provably cannot see. */
readonly mode?: "layout" | "names";
},
): void;
// Throws ValidationError with a field-by-field offset diff table (below).
// Throws a DISTINCT error if the struct's layout is complete: false — never silently passes.Dev-only by construction, not by flag: the function lives in a sideEffects: false package and is called behind the consumer's NODE_ENV guard — bundlers drop both call and code from production builds. No if (__DEV__) inside the library, no cost in the hot write() path (validation is a setup-time act, run once).
Example
import { resolveShader } from "@vgpu/wgsl/runtime";
import { validateUniformLayout } from "@vgpu/render"; // re-export of @vgpu/wgsl's
const shader = await resolveShader({ entry: "./shaders/glass/front.wgsl" });
if (process.env.NODE_ENV !== "production") {
validateUniformLayout(sceneParams, shader, {
struct: "Params",
module: "shared/glass-common.wgsl", // two `Params` exist in Eve's graph — suffix disambiguates
});
}
// On drift, thrown at startup:
// ValidationError: struct 'Params' (shared/glass-common.wgsl) is 192 B; schema is 176 B.
// offset WGSL member schema field(s)
// 0..64 viewProj: mat4x4f viewProj: mat4x4f ok
// 64..76 cameraPos: vec3f cameraPos: vec3f ok
// ...
// 144..160 lightTint: vec4f — no schema fields — MISSING
// 160..176 ascii0: vec4f ascii0: vec4f shifted (schema: 144)A schema that subdivides ascii0: vec4f into four named scalars (progress/gridScale/glyphScale/time) passes in the default "layout" mode — the spans tile exactly. Name-based matching would reject that correct code, which is why "layout" is the default and "names" is the opt-in strict mode.
Same example without the util
// The entirety of today's protection (render.ts, eve-colored-lights branch):
// INVARIANT: 176 bytes / 44 f32; field order must match shaders/shared/glass-common.wgsl
// A reviewer must, by hand, on every schema or WGSL edit:
// 1. recompute every field offset under WGSL uniform alignment rules
// (vec3f aligns to 16 — the rule everyone forgets),
// 2. recount PARAMS_BYTE_SIZE (this step produced the 144 B vs 176 B cross-branch
// drift and hours of debugging garbage pixels),
// 3. visually diff TS field order against the WGSL struct member order.
// There is no failure signal. The GPU reads whatever bytes are there.Alternatives
StructuredUniform.validate(shader, opts)method instead of a free function. Rejected — deciding factor is tree-shaking: a method ships the validator inside the class in every production bundle; a free function in asideEffects: falsemodule costs zero prod bytes once the guarded call is stripped. It would also couple@vgpu/renderto@vgpu/wgslat the class level, while the structural free function keeps@vgpu/wgsldependency-free and usable from CI scripts that never touch a GPU.- Runtime-only guard via
minBindingSizeon the bind group layout. Cheap and worth doing independently, but it catches only size drift — not field reordering or interior shifts at equal total size, which is the dangerous silent case. Complement, not substitute. - Regex extraction, matching the rest of
reflect.ts. Rejected after checking real input: Eve's struct bodies contain interior comments, and WGSL allows@align/@sizeattributes; the tokens are already computed and attached to every modulereflect()sees. Token-walking is the same LOC budget with none of the fragility. - Full WGSL type-checker / third-party reflection lib (e.g. wgsl_reflect). Disproportionate;
reflect.tsdeliberately made the lightweight trade already, and an external dependency for ~60 lines contradicts the package's zero-dependency stance.
Activity
0No activity yet.