#138

`validateUniformLayout()` — dev-time schema ↔ WGSL struct validation via `Reflection.hostShareableLayouts`

Openmatiasngfopened 4mo ago

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.hostShareableLayouts already 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 receives MangleModule[] whose .tokens field carries the scanner output (resolveShader.ts:69, scanner.ts). Struct extraction should walk tokens, not regex raw source: Eve's Params body contains line comments (glass-common.wgsl:15,17) that the scanner already classifies, and @align/@size attribute handling is token-shaped (cf. parser.ts skipAttrs). 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 only entryPoints/bindings/overrides — filling hostShareableLayouts does 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.ts re-exports it, API unchanged. Dep direction is already render → core → wgsl, so no cycle.
  • Real-world hazard found in Eve: the resolved graph contains two structs named Params (shaders/env/background.wgsl:6 and shaders/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 a sideEffects: false module costs zero prod bytes once the guarded call is stripped. It would also couple @vgpu/render to @vgpu/wgsl at the class level, while the structural free function keeps @vgpu/wgsl dependency-free and usable from CI scripts that never touch a GPU.
  • Runtime-only guard via minBindingSize on 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/@size attributes; the tokens are already computed and attached to every module reflect() 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.ts deliberately made the lightweight trade already, and an external dependency for ~60 lines contradicts the package's zero-dependency stance.

Activity

0

No activity yet.