@aardworx/wombat.rendering 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,6 +56,11 @@ import {
56
56
  headersU32,
57
57
  } from "./heapIrBuilders.js";
58
58
 
59
+ /** Carrier name for the per-draw header index handed to the FS (flat u32). */
60
+ export const HEAP_FS_DRAWIDX = "_h_drawIdx";
61
+ /** Carrier name for the instance index (only when per-instance uniforms exist). */
62
+ export const HEAP_FS_INSTID = "_h_instId";
63
+
59
64
  // Build an Expr that returns the u32 value of a specific inline u32
60
65
  // slot in the drawHeader: `headersU32[drawIdx * stride + offset_u32]`.
61
66
  function readHeaderU32(drawIdx: Expr, byteOffset: number, strideU32: number): Expr {
@@ -247,10 +252,47 @@ export function synthesizeHeapDecoderModule(layout: BucketLayout, mode: HeapDeco
247
252
  vidExpr = { kind: "ReadInput", scope: "Input", name: "vid", type: Tu32 };
248
253
  }
249
254
 
250
- // Walk the bucket layout's drawHeader fields. The order doesn't
251
- // matter for correctness — every surfaced name becomes a unique
252
- // output and the carrier carries them all.
255
+ // THE CARRIER CARRIES THE DRAW INDEX, NOT THE DRAW'S VALUES.
256
+ //
257
+ // Per-draw uniforms and atlas-texture sub-fields are CONSTANT across a draw:
258
+ // pushing each of them across the stage boundary as its own flat varying
259
+ // costs one inter-stage location per uniform and FOUR per texture, and WebGPU
260
+ // allows only 16 in total. Shaders with a handful of per-draw uniforms plus a
261
+ // texture ran into that ceiling — and exceeding it does not fail loudly: the
262
+ // pipeline is created INVALID, which poisons the whole command buffer, so an
263
+ // entire pass silently vanishes. It made the varying budget a hidden design
264
+ // constraint on every heap shader.
265
+ //
266
+ // So the decoder surfaces the DRAW INDEX (one flat u32) and the FS loads what
267
+ // it needs straight from the heap arena, exactly as the family-member path
268
+ // already does. Cost: scalar, flat, draw-uniform storage reads per fragment
269
+ // instead of per vertex. Varying count is now O(1) in uniforms and textures.
270
+ //
271
+ // Per-VERTEX attribute refs are a different animal — they genuinely vary
272
+ // across the primitive — so they keep their carrier slots below.
253
273
  let nextLocation = 0;
274
+ outputs.push({
275
+ name: HEAP_FS_DRAWIDX,
276
+ type: Tu32,
277
+ semantic: HEAP_FS_DRAWIDX,
278
+ decorations: [
279
+ { kind: "Location", value: nextLocation++ },
280
+ { kind: "Interpolation", mode: "flat" },
281
+ ],
282
+ });
283
+ stmts.push({ kind: "WriteOutput", name: HEAP_FS_DRAWIDX, value: { kind: "Expr", value: drawIdxExpr } });
284
+ if (layout.perInstanceUniforms.size > 0) {
285
+ outputs.push({
286
+ name: HEAP_FS_INSTID,
287
+ type: Tu32,
288
+ semantic: HEAP_FS_INSTID,
289
+ decorations: [
290
+ { kind: "Location", value: nextLocation++ },
291
+ { kind: "Interpolation", mode: "flat" },
292
+ ],
293
+ });
294
+ stmts.push({ kind: "WriteOutput", name: HEAP_FS_INSTID, value: { kind: "Expr", value: instIdExpr } });
295
+ }
254
296
  // Texture-ref drawHeader fields are grouped by `textureBindingName` —
255
297
  // each atlas-routed texture binding produces four contiguous entries
256
298
  // (pageRef / formatBits / origin / size). We emit them as four
@@ -16,6 +16,7 @@
16
16
  // attribute reads with heap loads at the IR level.
17
17
 
18
18
  import type { Effect } from "@aardworx/wombat.shader";
19
+ import { effectHoleKey } from "@aardworx/wombat.shader";
19
20
 
20
21
  // ─── Schema (extracted from CompiledEffect.interface) ───────────────
21
22
 
@@ -108,7 +109,7 @@ export interface FragmentOutputLayout {
108
109
  // and on quota; every failure simply degrades to "recompute".
109
110
 
110
111
  /** Cache-generation stamp for the persistent (localStorage) tier. */
111
- export const HEAP_PERSIST_VERSION = "h7"; // h7: OIT resolve early-exit
112
+ export const HEAP_PERSIST_VERSION = "h9"; // h9: standalone FS reads the heap directly (one _h_drawIdx varying)
112
113
 
113
114
  /**
114
115
  * Uniforms whose drawHeader word IS the u32 value (no arena
@@ -180,7 +181,13 @@ function isCompiledHeapEffect(v: unknown): v is CompiledHeapEffect {
180
181
  }
181
182
 
182
183
  export function compileHeapEffect(effect: Effect, fragmentOutputLayout?: FragmentOutputLayout): CompiledHeapEffect {
183
- const contentKey = effect.id + fboLayoutKey(fragmentOutputLayout);
184
+ // `effect.id` hashes the TEMPLATE, not the emitted code: closure-hole values
185
+ // are specialised into the source as literals and do NOT move the id when a
186
+ // getter's value changes at runtime. Keying only on the id therefore serves
187
+ // WGSL built from a previous hole value — and because this cache is also
188
+ // persisted, the stale shader outlives the page. Pair the id with a
189
+ // fingerprint of the current holes (empty for the hole-free hot path).
190
+ const contentKey = effect.id + effectHoleKey(effect) + fboLayoutKey(fragmentOutputLayout);
184
191
  const mem = _compiledHeapEffectMemCache.get(contentKey);
185
192
  if (mem !== undefined) return mem;
186
193
  const lsKey = persistKey(HEAP_PERSIST_VERSION, "che", contentKey);
@@ -17,12 +17,12 @@
17
17
  // This module knows nothing about the runtime; the BucketLayout it
18
18
  // consumes is identical to the one heapScene.ts already builds.
19
19
 
20
- import { compileModule, stage as makeStage, effect as makeEffect } from "@aardworx/wombat.shader";
20
+ import { compileModule, stage as makeStage, effect as makeEffect, effectHoleKey } from "@aardworx/wombat.shader";
21
21
  import { isInlineHeaderField } from "./heapEffect.js";
22
22
  import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
23
23
  import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns, uniformsToInputs, resolveHoles } from "@aardworx/wombat.shader/passes";
24
24
  import type { HoleValue } from "@aardworx/wombat.shader/passes";
25
- import { synthesizeHeapDecoderModule } from "./heapDecoder.js";
25
+ import { synthesizeHeapDecoderModule, HEAP_FS_DRAWIDX, HEAP_FS_INSTID } from "./heapDecoder.js";
26
26
  import {
27
27
  type Module, type Expr, type Stmt, type Type, type ValueDef,
28
28
  type EntryDef, type EntryParameter, type ParamDecoration,
@@ -33,6 +33,7 @@ import {
33
33
  generateAtlasBindings, generateAtlasSwitch, generateAtlasPrelude,
34
34
  HEAP_PERSIST_VERSION, persistKey, lsLoad, lsStore,
35
35
  type BucketLayout,
36
+ type DrawHeaderField,
36
37
  } from "./heapEffect.js";
37
38
  import type { IntrinsicRef } from "@aardworx/wombat.shader/ir";
38
39
 
@@ -348,6 +349,23 @@ function addInterstageParams(
348
349
  return { ...m, values };
349
350
  }
350
351
 
352
+ /**
353
+ * Declare extra FRAGMENT inputs, leaving the vertex side alone. The decoder
354
+ * path already writes its carrier on the VS; the FS just needs the matching
355
+ * declaration to read it.
356
+ */
357
+ function addFsInputs(m: Module, extras: readonly EntryParameter[]): Module {
358
+ if (extras.length === 0) return m;
359
+ const values = m.values.map((v): typeof v => {
360
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") return v;
361
+ const have = new Set(v.entry.inputs.map(p => p.name));
362
+ const add = extras.filter(p => !have.has(p.name));
363
+ if (add.length === 0) return v;
364
+ return { ...v, entry: { ...v.entry, inputs: [...v.entry.inputs, ...add] } };
365
+ });
366
+ return { ...m, values };
367
+ }
368
+
351
369
  /**
352
370
  * Prepend `stmts` to every VS entry's body. Flatten when the body is
353
371
  * already a `Sequential` — CSE numbers `_cse0..N` per Sequential, so
@@ -407,7 +425,15 @@ function wgslTypeFromHeapField(wgslType: string): Type {
407
425
  * emit `applyFamilyMemberFsShape` rewires the FS entry signature to
408
426
  * surface them as plain u32 fn parameters.
409
427
  */
410
- function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
428
+ function rewriteFsUniformsDirect(
429
+ m: Module,
430
+ layout: BucketLayout,
431
+ // Where the FS gets the per-draw header index from. `family-member` receives
432
+ // it as a plain u32 fn parameter (a Var); `standalone` reads it from the one
433
+ // flat varying it threads. Everything else about the direct load is identical.
434
+ drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr,
435
+ instIdExpr: Expr = { kind: "Var", var: { name: "instId", type: Tu32, mutable: false } } as Expr,
436
+ ): Module {
411
437
  const used = new Set<string>();
412
438
  for (const v of m.values) {
413
439
  if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
@@ -418,8 +444,6 @@ function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
418
444
  if (used.size === 0) return m;
419
445
 
420
446
  const fieldByName = new Map(layout.drawHeaderFields.map(f => [f.name, f]));
421
- const drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
422
- const instIdExpr: Expr = { kind: "Var", var: { name: "instId", type: Tu32, mutable: false } } as Expr;
423
447
  const fsSubst = new Map<string, Expr>();
424
448
  for (const name of used) {
425
449
  const f = fieldByName.get(name);
@@ -445,7 +469,11 @@ function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
445
469
  * `heap_drawIdx`. The wrapper-supplied `heap_drawIdx` u32 fn parameter
446
470
  * carries the per-draw header index.
447
471
  */
448
- function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
472
+ function rewriteFsAtlasTexturesDirect(
473
+ m: Module,
474
+ layout: BucketLayout,
475
+ drawIdxExprIn: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr,
476
+ ): Module {
449
477
  if (layout.atlasTextureBindings.size === 0) return m;
450
478
  const used = new Set<string>();
451
479
  for (const v of m.values) {
@@ -466,7 +494,7 @@ function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
466
494
  inner.set(sub, byteOffsetOf(f.byteOffset));
467
495
  }
468
496
 
469
- const drawIdxExpr: Expr = { kind: "Var", var: { name: "heap_drawIdx", type: Tu32, mutable: false } } as Expr;
497
+ const drawIdxExpr: Expr = drawIdxExprIn;
470
498
  const stride = layout.strideU32;
471
499
  const headerU32At = (offU32: number): Expr =>
472
500
  item(headersU32, add(mul(drawIdxExpr, constU32(stride), Tu32), constU32(offU32), Tu32), Tu32);
@@ -499,6 +527,78 @@ function rewriteFsAtlasTexturesDirect(m: Module, layout: BucketLayout): Module {
499
527
  }));
500
528
  }
501
529
 
530
+ /**
531
+ * STANDALONE FS heap access — one varying, not N.
532
+ *
533
+ * The old standalone path threaded a VS→FS varying per FS-used uniform AND
534
+ * FOUR per atlas texture (`_h_<t>{PageRef,FormatBits,Origin,Size}`). WebGPU
535
+ * caps inter-stage variables at 16 locations, so a shader with a handful of
536
+ * per-draw uniforms and a texture ran into the ceiling — and blowing it does
537
+ * not fail loudly: the pipeline is created INVALID, which poisons the whole
538
+ * command buffer, so the entire pass silently disappears. It made the varying
539
+ * budget a design constraint on every shader written against the heap.
540
+ *
541
+ * `family-member` never had this problem: it loads uniforms and atlas headers
542
+ * straight out of the heap arena in the FS, keyed by the draw index. Standalone
543
+ * now does the same — it just has to carry the draw index itself, so it threads
544
+ * exactly ONE flat u32 varying (`_h_drawIdx`), plus `_iidx` when a per-instance
545
+ * uniform is actually read from the FS. Varying count is now O(1) in the number
546
+ * of uniforms and textures.
547
+ *
548
+ * The trade is per-fragment storage reads instead of per-vertex ones. They are
549
+ * scalar, flat, and uniform across a draw (so they hit cache), and this is the
550
+ * path `family-member` has been running in production all along.
551
+ */
552
+ function rewriteFsHeapDirectStandalone(m: Module, layout: BucketLayout): Module {
553
+ // What does the FS actually touch?
554
+ const usedUniforms = new Set<string>();
555
+ const usedAtlas = new Set<string>();
556
+ for (const v of m.values) {
557
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
558
+ for (const sn of readInputs(v.entry.body).values()) {
559
+ if (sn.scope === "Uniform") usedUniforms.add(sn.name);
560
+ }
561
+ visitStmtExprs(v.entry.body, e => {
562
+ const hit = isAtlasSampleCall(e, layout.atlasTextureBindings);
563
+ if (hit !== null) usedAtlas.add(hit.name);
564
+ });
565
+ }
566
+ if (usedUniforms.size === 0 && usedAtlas.size === 0) return m;
567
+
568
+ // Thread the carrier(s). `_iidx` only when a per-instance uniform is read —
569
+ // otherwise it is a varying nobody needs.
570
+ const needsIidx = [...usedUniforms].some(n => layout.perInstanceUniforms.has(n));
571
+ const threadParams: EntryParameter[] = [{
572
+ name: "_h_drawIdx", type: Tu32, semantic: "_h_drawIdx",
573
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
574
+ }];
575
+ const vsWrites: Stmt[] = [{
576
+ kind: "WriteOutput", name: "_h_drawIdx",
577
+ value: { kind: "Expr", value: drawIdxExprFor(layout) },
578
+ }];
579
+ if (needsIidx) {
580
+ threadParams.push({
581
+ name: "_iidx", type: Tu32, semantic: "_iidx",
582
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
583
+ });
584
+ vsWrites.push({
585
+ kind: "WriteOutput", name: "_iidx",
586
+ value: { kind: "Expr", value: readScope("Builtin", "instance_index", Tu32) },
587
+ });
588
+ }
589
+
590
+ const fsDrawIdx = readScope("Input", "_h_drawIdx", Tu32);
591
+ const fsInstId = readScope("Input", "_iidx", Tu32);
592
+
593
+ let out = m;
594
+ out = addInterstageParams(out, threadParams);
595
+ out = prependVsBodyStmts(out, vsWrites);
596
+ // Same loaders the family path uses — only the source of the draw index differs.
597
+ out = rewriteFsAtlasTexturesDirect(out, layout, fsDrawIdx);
598
+ out = rewriteFsUniformsDirect(out, layout, fsDrawIdx, fsInstId);
599
+ return out;
600
+ }
601
+
502
602
  /**
503
603
  * FS uniform threading: thread `_h_<name>Ref` (and `_iidx`, when
504
604
  * needed) varyings from VS to FS, and rewrite FS `ReadInput("Uniform",
@@ -846,7 +946,10 @@ export function compileHeapEffectIR(
846
946
  compileOptions: CompileOptions,
847
947
  mode: HeapEffectEmitMode = "standalone",
848
948
  ): HeapEffectIR {
849
- const contentKey = `${userEffect.id}|${layout.id}|${mode}|${compileOptionsKey(compileOptions)}`;
949
+ // Hole values are baked into the emitted code but do NOT move `effect.id`
950
+ // (see compileHeapEffect) — they must be part of the key, memory AND
951
+ // persisted, or a changed capture silently reuses the old shader.
952
+ const contentKey = `${userEffect.id}${effectHoleKey(userEffect)}|${layout.id}|${mode}|${compileOptionsKey(compileOptions)}`;
850
953
  const mem = _heapIrMemCache.get(contentKey);
851
954
  if (mem !== undefined) return mem;
852
955
  const lsKey = persistKey(HEAP_PERSIST_VERSION, "ir", contentKey);
@@ -897,8 +1000,11 @@ export function compileHeapEffectIR(
897
1000
  combined = rewriteFsAtlasTexturesDirect(combined, layout);
898
1001
  combined = rewriteFsUniformsDirect(combined, layout);
899
1002
  } else {
900
- combined = rewriteFsAtlasTextures(combined, layout);
901
- combined = rewriteFsUniforms(combined, layout);
1003
+ // Standalone reads the heap directly too — it just carries the draw index
1004
+ // in ONE flat varying instead of receiving it as a parameter. (The old
1005
+ // per-uniform / 4-per-texture varying threading is gone: it hit WebGPU's
1006
+ // 16-location inter-stage cap and silently invalidated whole pipelines.)
1007
+ combined = rewriteFsHeapDirectStandalone(combined, layout);
902
1008
  }
903
1009
  // Add heap storage buffers as bindings.
904
1010
  combined = { ...combined, values: [...heapStorageBufferDecls(), ...combined.values] };
@@ -971,6 +1077,67 @@ export function compileHeapEffectIR(
971
1077
  * No post-emit string mangling — `applyMegacallToEmittedVs` /
972
1078
  * `applyFamilyMemberShape` are gone for this path.
973
1079
  */
1080
+ /**
1081
+ * FS heap access for the DECODER path: read per-draw uniforms and atlas-texture
1082
+ * headers directly out of the arena, keyed by the draw index the decoder put on
1083
+ * the carrier (`_h_drawIdx`), instead of reading values the decoder pushed
1084
+ * across as one varying each.
1085
+ *
1086
+ * Only `uniform-ref` fields are substituted. `attribute-ref` fields are
1087
+ * per-VERTEX data — they must keep flowing through the carrier, and they were
1088
+ * renamed into `Input` scope by `uniformsToInputs` just like the uniforms, so
1089
+ * the substitution has to be name-driven, not scope-driven.
1090
+ */
1091
+ function rewriteFsHeapDirectViaCarrier(m: Module, layout: BucketLayout): Module {
1092
+ const fsDrawIdx = readScope("Input", HEAP_FS_DRAWIDX, Tu32);
1093
+ const fsInstId = readScope("Input", HEAP_FS_INSTID, Tu32);
1094
+
1095
+ // Declare the carrier(s) as FRAGMENT INPUTS. The decoder already writes them
1096
+ // on the vertex side; without the matching input declaration the emitter has
1097
+ // no struct member to resolve the read against (it reaches for the output
1098
+ // struct and the module fails to parse).
1099
+ const fsInputs: EntryParameter[] = [{
1100
+ name: HEAP_FS_DRAWIDX, type: Tu32, semantic: HEAP_FS_DRAWIDX,
1101
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
1102
+ }];
1103
+ if (layout.perInstanceUniforms.size > 0) {
1104
+ fsInputs.push({
1105
+ name: HEAP_FS_INSTID, type: Tu32, semantic: HEAP_FS_INSTID,
1106
+ decorations: [{ kind: "Interpolation", mode: "flat" } as ParamDecoration],
1107
+ });
1108
+ }
1109
+ let out = addFsInputs(m, fsInputs);
1110
+
1111
+ // Atlas samples first (they read header sub-fields, not uniform values).
1112
+ out = rewriteFsAtlasTexturesDirect(out, layout, fsDrawIdx);
1113
+
1114
+ // Which per-draw uniforms does the FS still read?
1115
+ const uniformFields = new Map<string, DrawHeaderField>();
1116
+ for (const f of layout.drawHeaderFields) {
1117
+ if (f.kind === "uniform-ref") uniformFields.set(f.name, f);
1118
+ }
1119
+ const used = new Set<string>();
1120
+ for (const v of out.values) {
1121
+ if (v.kind !== "Entry" || v.entry.stage !== "fragment") continue;
1122
+ for (const sn of readInputs(v.entry.body).values()) {
1123
+ if (sn.scope === "Input" && uniformFields.has(sn.name)) used.add(sn.name);
1124
+ }
1125
+ }
1126
+ if (used.size === 0) return out;
1127
+
1128
+ const subst = new Map<string, Expr>();
1129
+ for (const name of used) {
1130
+ const f = uniformFields.get(name)!;
1131
+ const refExpr = loadHeaderRef(fsDrawIdx, f.byteOffset, layout.strideU32);
1132
+ subst.set(name, isInlineHeaderField(f)
1133
+ ? refExpr
1134
+ : layout.perInstanceUniforms.has(name)
1135
+ ? loadInstanceByRef(refExpr, fsInstId, f.uniformWgslType ?? "")
1136
+ : loadUniformByRef(refExpr, f.uniformWgslType ?? ""));
1137
+ }
1138
+ return substituteInputsInStage(out, "fragment", "Input", n => subst.get(n));
1139
+ }
1140
+
974
1141
  function compileHeapEffectIRViaDecoder(
975
1142
  userEffect: Effect,
976
1143
  layout: BucketLayout,
@@ -1017,9 +1184,14 @@ function compileHeapEffectIRViaDecoder(
1017
1184
  combined = liftReturns(combined);
1018
1185
  combined = injectVsBuiltins(combined);
1019
1186
 
1020
- // 6. FS-side atlas rewrite (texture-sample atlas-sample, reading
1021
- // from the carrier varyings the decoder wrote).
1022
- combined = rewriteFsAtlasTexturesViaCarrier(combined, layout);
1187
+ // 6. FS-side heap access. The decoder no longer ships per-draw VALUES across
1188
+ // the stage boundary (that cost one varying per uniform and four per texture,
1189
+ // against WebGPU's 16-location cap — see heapDecoder). It ships the draw
1190
+ // INDEX, and the FS loads what it needs straight from the arena. Whatever the
1191
+ // fragment stage stops reading, `pruneCrossStage` then drops from the carrier
1192
+ // by itself, so the varying count collapses to what genuinely varies per
1193
+ // vertex.
1194
+ combined = rewriteFsHeapDirectViaCarrier(combined, layout);
1023
1195
 
1024
1196
  // 6b. USER storage buffers (the OIT node pool & co): pin each to the
1025
1197
  // slot the bucket's BGL committed to. `keep: true` makes the emitter