@aardworx/wombat.rendering 0.19.23 → 0.19.25

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.
Files changed (101) hide show
  1. package/dist/core/elementType.d.ts +2 -0
  2. package/dist/core/elementType.d.ts.map +1 -1
  3. package/dist/core/elementType.js +10 -0
  4. package/dist/core/elementType.js.map +1 -1
  5. package/dist/core/provider.d.ts.map +1 -1
  6. package/dist/core/provider.js +65 -32
  7. package/dist/core/provider.js.map +1 -1
  8. package/dist/core/renderObject.d.ts +9 -0
  9. package/dist/core/renderObject.d.ts.map +1 -1
  10. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  11. package/dist/resources/preparedRenderObject.js +19 -2
  12. package/dist/resources/preparedRenderObject.js.map +1 -1
  13. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
  14. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  15. package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
  16. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  17. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
  18. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
  19. package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
  20. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
  21. package/dist/runtime/derivedUniforms/dispatch.d.ts +5 -1
  22. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  23. package/dist/runtime/derivedUniforms/dispatch.js +28 -13
  24. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  25. package/dist/runtime/derivedUniforms/records.d.ts +15 -0
  26. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
  27. package/dist/runtime/derivedUniforms/records.js +81 -14
  28. package/dist/runtime/derivedUniforms/records.js.map +1 -1
  29. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
  30. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  31. package/dist/runtime/derivedUniforms/sceneIntegration.js +9 -0
  32. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  33. package/dist/runtime/flattenTree.d.ts.map +1 -1
  34. package/dist/runtime/flattenTree.js +46 -2
  35. package/dist/runtime/flattenTree.js.map +1 -1
  36. package/dist/runtime/heapAdapter.d.ts +3 -1
  37. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  38. package/dist/runtime/heapAdapter.js +42 -4
  39. package/dist/runtime/heapAdapter.js.map +1 -1
  40. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  41. package/dist/runtime/heapDecoder.js +8 -3
  42. package/dist/runtime/heapDecoder.js.map +1 -1
  43. package/dist/runtime/heapEffect.d.ts +14 -1
  44. package/dist/runtime/heapEffect.d.ts.map +1 -1
  45. package/dist/runtime/heapEffect.js +13 -1
  46. package/dist/runtime/heapEffect.js.map +1 -1
  47. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  48. package/dist/runtime/heapEffectIR.js +18 -9
  49. package/dist/runtime/heapEffectIR.js.map +1 -1
  50. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  51. package/dist/runtime/heapEligibility.js +64 -12
  52. package/dist/runtime/heapEligibility.js.map +1 -1
  53. package/dist/runtime/heapIrBuilders.d.ts +8 -0
  54. package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
  55. package/dist/runtime/heapIrBuilders.js +47 -2
  56. package/dist/runtime/heapIrBuilders.js.map +1 -1
  57. package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
  58. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
  59. package/dist/runtime/heapScene/chunkedArena.js +28 -91
  60. package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
  61. package/dist/runtime/heapScene/freelist.d.ts +1 -1
  62. package/dist/runtime/heapScene/freelist.js +3 -3
  63. package/dist/runtime/heapScene/freelist.js.map +1 -1
  64. package/dist/runtime/heapScene/pools.d.ts +103 -59
  65. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  66. package/dist/runtime/heapScene/pools.js +335 -194
  67. package/dist/runtime/heapScene/pools.js.map +1 -1
  68. package/dist/runtime/heapScene.d.ts +116 -1
  69. package/dist/runtime/heapScene.d.ts.map +1 -1
  70. package/dist/runtime/heapScene.js +581 -125
  71. package/dist/runtime/heapScene.js.map +1 -1
  72. package/dist/runtime/hybridScene.d.ts.map +1 -1
  73. package/dist/runtime/hybridScene.js +19 -3
  74. package/dist/runtime/hybridScene.js.map +1 -1
  75. package/dist/runtime/index.d.ts +1 -1
  76. package/dist/runtime/index.d.ts.map +1 -1
  77. package/dist/runtime/index.js +1 -1
  78. package/dist/runtime/index.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/core/elementType.ts +10 -0
  81. package/src/core/provider.ts +56 -28
  82. package/src/core/renderObject.ts +9 -0
  83. package/src/resources/preparedRenderObject.ts +22 -3
  84. package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
  85. package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
  86. package/src/runtime/derivedUniforms/dispatch.ts +26 -10
  87. package/src/runtime/derivedUniforms/records.ts +70 -15
  88. package/src/runtime/derivedUniforms/sceneIntegration.ts +10 -0
  89. package/src/runtime/flattenTree.ts +47 -2
  90. package/src/runtime/heapAdapter.ts +40 -5
  91. package/src/runtime/heapDecoder.ts +8 -3
  92. package/src/runtime/heapEffect.ts +15 -1
  93. package/src/runtime/heapEffectIR.ts +18 -9
  94. package/src/runtime/heapEligibility.ts +39 -12
  95. package/src/runtime/heapIrBuilders.ts +50 -2
  96. package/src/runtime/heapScene/chunkedArena.ts +29 -94
  97. package/src/runtime/heapScene/freelist.ts +3 -3
  98. package/src/runtime/heapScene/pools.ts +340 -218
  99. package/src/runtime/heapScene.ts +717 -141
  100. package/src/runtime/hybridScene.ts +20 -3
  101. package/src/runtime/index.ts +5 -0
@@ -48,7 +48,42 @@ const MIN_STRIDE_U32 = 5; // rule_id, out_slot, in0, in1, in2 — covers the 13
48
48
  export class RecordsBuffer {
49
49
  private buf: Uint32Array;
50
50
  private owners: (RecordOwner | undefined)[] = [];
51
- private readonly byOwner = new Map<RecordOwner, Set<number>>();
51
+ /** Record indices per owner. A single number for the common one-
52
+ * record-per-owner case, promoted to a small array on the second —
53
+ * saves a whole `Set` per RO at heap scale. */
54
+ private readonly byOwner = new Map<RecordOwner, number | number[]>();
55
+
56
+ private ownerAdd(owner: RecordOwner, idx: number): void {
57
+ const v = this.byOwner.get(owner);
58
+ if (v === undefined) this.byOwner.set(owner, idx);
59
+ else if (typeof v === "number") this.byOwner.set(owner, [v, idx]);
60
+ else v.push(idx);
61
+ }
62
+ private ownerDelete(owner: RecordOwner, idx: number): void {
63
+ const v = this.byOwner.get(owner);
64
+ if (v === undefined) return;
65
+ if (typeof v === "number") {
66
+ if (v === idx) this.byOwner.delete(owner);
67
+ return;
68
+ }
69
+ const i = v.indexOf(idx);
70
+ if (i >= 0) {
71
+ v[i] = v[v.length - 1]!;
72
+ v.pop();
73
+ if (v.length === 1) this.byOwner.set(owner, v[0]!);
74
+ else if (v.length === 0) this.byOwner.delete(owner);
75
+ }
76
+ }
77
+ private ownerReplace(owner: RecordOwner, oldIdx: number, newIdx: number): void {
78
+ const v = this.byOwner.get(owner);
79
+ if (v === undefined) return;
80
+ if (typeof v === "number") {
81
+ if (v === oldIdx) this.byOwner.set(owner, newIdx);
82
+ return;
83
+ }
84
+ const i = v.indexOf(oldIdx);
85
+ if (i >= 0) v[i] = newIdx;
86
+ }
52
87
  private strideU32 = MIN_STRIDE_U32;
53
88
  private count = 0;
54
89
  /** Bumps whenever the byte layout changes shape (stride growth) so the dispatcher recompiles. */
@@ -86,44 +121,64 @@ export class RecordsBuffer {
86
121
  for (let i = 0; i < inSlots.length; i++) this.buf[base + 2 + i] = inSlots[i]! >>> 0;
87
122
  for (let i = base + 2 + inSlots.length; i < base + this.strideU32; i++) this.buf[i] = 0;
88
123
  this.owners[idx] = owner;
89
- let set = this.byOwner.get(owner);
90
- if (set === undefined) {
91
- set = new Set();
92
- this.byOwner.set(owner, set);
93
- }
94
- set.add(idx);
124
+ this.ownerAdd(owner, idx);
95
125
  this.generation++;
96
126
  return idx;
97
127
  }
98
128
 
99
129
  /** Remove every record owned by `owner` (swap-remove against the tail). */
100
130
  removeAllForOwner(owner: RecordOwner): void {
101
- const set = this.byOwner.get(owner);
102
- if (set === undefined || set.size === 0) {
103
- this.byOwner.delete(owner);
131
+ const v = this.byOwner.get(owner);
132
+ if (v === undefined) {
104
133
  return;
105
134
  }
106
135
  // Process highest indices first so a swap can never move a not-yet-removed
107
136
  // record of this owner that we still hold a stale index for.
108
- const indices = [...set].sort((a, b) => b - a);
137
+ const indices = typeof v === "number" ? [v] : [...v].sort((a, b) => b - a);
109
138
  for (const idx of indices) this.removeAt(idx);
110
139
  this.byOwner.delete(owner);
111
140
  this.generation++;
112
141
  }
113
142
 
143
+ /**
144
+ * Re-seat `HostHeap` handles after a main-heap compaction relocated their
145
+ * backing allocations. `remap` maps an OLD handle payload (the data byte
146
+ * offset in the main heap) to its NEW one. `Constituent` handles point into
147
+ * the separate df32 slot buffer (never compacted) and are left untouched.
148
+ * Returns the number of handle words rewritten; bumps `generation` so the
149
+ * dispatcher re-uploads. O(records × stride).
150
+ */
151
+ remapHostHeap(remap: ReadonlyMap<number, number>): number {
152
+ if (remap.size === 0 || this.count === 0) return 0;
153
+ const s = this.strideU32;
154
+ let changed = 0;
155
+ for (let i = 0; i < this.count; i++) {
156
+ const base = i * s;
157
+ // out_slot at base+1, in_slots at base+2 .. base+s-1.
158
+ for (let w = base + 1; w < base + s; w++) {
159
+ const h = this.buf[w]!;
160
+ if (handleTag(h) !== SlotTag.HostHeap) continue;
161
+ const nn = remap.get(handlePayload(h));
162
+ if (nn === undefined) continue;
163
+ this.buf[w] = makeHandle(SlotTag.HostHeap, nn);
164
+ changed++;
165
+ }
166
+ }
167
+ if (changed > 0) this.generation++;
168
+ return changed;
169
+ }
170
+
114
171
  private removeAt(idx: number): void {
115
172
  const last = this.count - 1;
116
173
  // The record currently at `idx` is the one being removed.
117
- this.byOwner.get(this.owners[idx]!)?.delete(idx);
174
+ this.ownerDelete(this.owners[idx]!, idx);
118
175
  if (idx !== last) {
119
176
  // Move the tail record's words into the hole, and re-home it.
120
177
  const dst = idx * this.strideU32;
121
178
  const src = last * this.strideU32;
122
179
  this.buf.copyWithin(dst, src, src + this.strideU32);
123
180
  const lastOwner = this.owners[last]!;
124
- const lset = this.byOwner.get(lastOwner)!;
125
- lset.delete(last);
126
- lset.add(idx);
181
+ this.ownerReplace(lastOwner, last, idx);
127
182
  this.owners[idx] = lastOwner;
128
183
  }
129
184
  this.owners[last] = undefined;
@@ -109,6 +109,16 @@ export class DerivedUniformsScene {
109
109
  return r;
110
110
  }
111
111
 
112
+ /**
113
+ * Re-seat the per-chunk derived-uniform records' `HostHeap` handles
114
+ * (derived outputs and host-uniform inputs) after the main heap compacted
115
+ * `chunkIdx`. `remap` maps OLD→NEW data byte offsets. Returns words changed.
116
+ */
117
+ remapHostHeap(chunkIdx: number, remap: ReadonlyMap<number, number>): number {
118
+ const r = this.recordsByChunk.get(chunkIdx);
119
+ return r === undefined ? 0 : r.remapHostHeap(remap);
120
+ }
121
+
112
122
  /** Total record count summed across every chunk's records buffer.
113
123
  * Used by the scene's stats / diagnostics. */
114
124
  get totalRecordCount(): number {
@@ -19,6 +19,49 @@ import { AListBridges } from "@aardworx/wombat.adaptive";
19
19
  import type { RenderObject } from "../core/renderObject.js";
20
20
  import type { RenderTree } from "../core/renderTree.js";
21
21
 
22
+ /** True when `tree` contains no reactive nodes — its RenderObjects can
23
+ * be enumerated as a plain iterable (no inner aset / reader needed). */
24
+ function isStaticTree(tree: RenderTree): boolean {
25
+ switch (tree.kind) {
26
+ case "Empty":
27
+ case "Leaf":
28
+ return true;
29
+ case "Ordered":
30
+ case "Unordered":
31
+ return tree.children.every(isStaticTree);
32
+ default:
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Enumerate a static subtree's RenderObjects (pre: `isStaticTree`). */
38
+ function* staticObjects(tree: RenderTree): Iterable<RenderObject> {
39
+ switch (tree.kind) {
40
+ case "Empty":
41
+ return;
42
+ case "Leaf":
43
+ yield tree.object;
44
+ return;
45
+ case "Ordered":
46
+ case "Unordered":
47
+ for (const c of tree.children) yield* staticObjects(c);
48
+ return;
49
+ default:
50
+ throw new Error(`staticObjects: reactive node '${tree.kind}'`);
51
+ }
52
+ }
53
+
54
+ /** Flatten a reactive container of subtrees. The static children go
55
+ * through `collectSeq` (plain iterables — ONE reader for the whole
56
+ * set, instead of a ConstantAset + reader + subscription PER LEAF,
57
+ * which at heap scale was megabytes of ballast); reactive children
58
+ * keep the full `collect`. */
59
+ function flattenChildSet(children: aset<RenderTree>): aset<RenderObject> {
60
+ const staticPart = children.filter(isStaticTree).collectSeq(staticObjects);
61
+ const reactivePart = children.filter(t => !isStaticTree(t)).collect(flattenRenderTree);
62
+ return ASet.union(staticPart, reactivePart);
63
+ }
64
+
22
65
  export function flattenRenderTree(tree: RenderTree): aset<RenderObject> {
23
66
  switch (tree.kind) {
24
67
  case "Empty":
@@ -27,6 +70,8 @@ export function flattenRenderTree(tree: RenderTree): aset<RenderObject> {
27
70
  return ASet.single(tree.object);
28
71
  case "Ordered":
29
72
  case "Unordered": {
73
+ // Fully-static subtree → one constant aset over a plain array.
74
+ if (isStaticTree(tree)) return ASet.ofArray([...staticObjects(tree)]);
30
75
  // N-ary union via `unionMany(aset<aset<T>>)`. The naïve
31
76
  // `children.reduce(ASet.union)` builds a 2N-deep nested-union
32
77
  // tree; the reader's traversal recurses through it and blows
@@ -39,8 +84,8 @@ export function flattenRenderTree(tree: RenderTree): aset<RenderObject> {
39
84
  case "Adaptive":
40
85
  return ASet.bind(flattenRenderTree, tree.tree);
41
86
  case "OrderedFromList":
42
- return ASet.collect(flattenRenderTree, AListBridges.toASet(tree.children));
87
+ return flattenChildSet(AListBridges.toASet(tree.children));
43
88
  case "UnorderedFromSet":
44
- return ASet.collect(flattenRenderTree, tree.children);
89
+ return flattenChildSet(tree.children);
45
90
  }
46
91
  }
@@ -113,6 +113,8 @@ import type { HeapDrawSpec, HeapTextureSet } from "./heapScene.js";
113
113
  import { isBufferView } from "./heapScene/pools.js";
114
114
  import { compileHeapEffect } from "./heapEffect.js";
115
115
  import { AtlasPool } from "./textureAtlas/atlasPool.js";
116
+ import { STANDARD_DERIVED_RULES, STANDARD_TRAFO_LEAVES } from "./derivedUniforms/recipes.js";
117
+ import { inputsOf } from "./derivedUniforms/flatten.js";
116
118
 
117
119
  /**
118
120
  * View `HostBufferSource` as `Uint32Array`. Index buffers are u32 in
@@ -326,6 +328,7 @@ export function renderObjectToHeapSpec(
326
328
  ro: RenderObject,
327
329
  token: AdaptiveToken,
328
330
  pool?: AtlasPool,
331
+ opts?: { readonly enableDerivedUniforms?: boolean },
329
332
  ): HeapDrawSpec {
330
333
  // 1. Inputs map: vertex attributes (BufferView) + uniforms, pulled
331
334
  // SHADER-DRIVEN from the providers. We compile `ro.effect` (cached
@@ -356,12 +359,38 @@ export function renderObjectToHeapSpec(
356
359
  const av = uProv.tryGet(name);
357
360
  if (av !== undefined) inputs[name] = av;
358
361
  };
359
- for (const u of schema.uniforms) pullUniform(u.name);
362
+ // §7-covered derived trafos (ModelViewProjTrafo, NormalMatrix, ) are
363
+ // GPU-computed from the M/V/P constituents — addDraw never reads their
364
+ // `spec.inputs` entry when the rule applies. Deferring the pull keeps
365
+ // the provider from materialising a per-RO derived-aval chain (MapVal +
366
+ // nested closure contexts + a never-forced lazy constant) that would sit
367
+ // in `inputs` as pure ballast. Pulled after all as a fallback only when
368
+ // the recipe's base leaves turn out not to be bound (mirrors
369
+ // heapScene's `ruleForUniform` applicability test).
370
+ const deriveOnGpu = opts?.enableDerivedUniforms !== false;
371
+ const deferredDerived: string[] = [];
372
+ for (const u of schema.uniforms) {
373
+ if (deriveOnGpu && STANDARD_DERIVED_RULES.has(u.name)) {
374
+ deferredDerived.push(u.name);
375
+ continue;
376
+ }
377
+ pullUniform(u.name);
378
+ }
360
379
  // §7 derived-uniforms constituents — cheap (these are the raw
361
380
  // `state.model/view/proj` avals, no `compose`/`inverse`) and the
362
381
  // compute pre-pass needs them even when the effect itself doesn't
363
382
  // declare them. No-ops if the provider doesn't carry them.
364
383
  for (const n of ["ModelTrafo", "ViewTrafo", "ProjTrafo"]) pullUniform(n);
384
+ for (const name of deferredDerived) {
385
+ const rule = STANDARD_DERIVED_RULES.get(name)!;
386
+ let leavesBound = true;
387
+ for (const inp of inputsOf(rule.ir)) {
388
+ if (inp.name === "Model" && ro.modelChain !== undefined) continue;
389
+ const specName = STANDARD_TRAFO_LEAVES.get(inp.name) ?? inp.name;
390
+ if (inputs[specName] === undefined) { leavesBound = false; break; }
391
+ }
392
+ if (!leavesBound) pullUniform(name);
393
+ }
365
394
 
366
395
  // 1b. Instance attributes — provider is map-backed (user-supplied via
367
396
  // `Sg.instanced({attributes})`), so enumerate its names. Threaded
@@ -399,9 +428,11 @@ export function renderObjectToHeapSpec(
399
428
  // WGSL schema's binding shape doesn't matter at scene time — that
400
429
  // leaves us with two HashMap entries pointing at the same aval.
401
430
  // Dedupe by identity before applying the single-pair rule.
402
- const distinctTexAvals = new Set<aval<ITexture>>();
403
- ro.textures.iter((_n, av) => { distinctTexAvals.add(av as aval<ITexture>); });
404
- const distinctSamplerAvals = new Set<aval<ISampler>>();
431
+ // `let` + nulled after use: the spec's long-lived closures share
432
+ // this scope context a leftover Set here is per-RO ballast.
433
+ let distinctTexAvals: Set<aval<ITexture>> | undefined = new Set<aval<ITexture>>();
434
+ ro.textures.iter((_n, av) => { distinctTexAvals!.add(av as aval<ITexture>); });
435
+ let distinctSamplerAvals: Set<aval<ISampler>> | undefined = new Set<aval<ISampler>>();
405
436
  // Shader-defined sampler state (from a `sampler2d { filter …; addressU … }`
406
437
  // builder, carried through the IR) overrides the scene's default sampler.
407
438
  const stateBinding = schema.samplers.find(b => b.state !== undefined);
@@ -418,7 +449,7 @@ export function renderObjectToHeapSpec(
418
449
  AVal.constant(ISampler.fromDescriptor(desc)) as aval<ISampler>,
419
450
  );
420
451
  } else {
421
- ro.samplers.iter((_n, av) => { distinctSamplerAvals.add(av as aval<ISampler>); });
452
+ ro.samplers.iter((_n, av) => { distinctSamplerAvals!.add(av as aval<ISampler>); });
422
453
  }
423
454
  let textures: HeapTextureSet | undefined;
424
455
  if (distinctTexAvals.size === 1 && distinctSamplerAvals.size === 1) {
@@ -531,6 +562,9 @@ export function renderObjectToHeapSpec(
531
562
  `single-pair only in v1 (classifier should have caught this)`,
532
563
  );
533
564
  }
565
+ // Drop the dedup scratch — see the `let` note above.
566
+ distinctTexAvals = undefined;
567
+ distinctSamplerAvals = undefined;
534
568
 
535
569
  // 4. DrawCall geometry. Classifier validates fields are heap-
536
570
  // compatible (instanceCount≥1, firstInstance=0, non-indexed firstVertex=0).
@@ -550,6 +584,7 @@ export function renderObjectToHeapSpec(
550
584
 
551
585
  return {
552
586
  effect: ro.effect,
587
+ ...(ro.pickId !== undefined ? { pickId: ro.pickId } : {}),
553
588
  pipelineState: ro.pipelineState,
554
589
  inputs,
555
590
  ...(instanceAttributes !== undefined ? { instanceAttributes } : {}),
@@ -46,6 +46,7 @@ import {
46
46
  Tu32, Tf32,
47
47
  } from "@aardworx/wombat.shader/ir";
48
48
  import type { BucketLayout, DrawHeaderField } from "./heapEffect.js";
49
+ import { isInlineHeaderField } from "./heapEffect.js";
49
50
  import {
50
51
  Tvec2, Tvec3, Tvec4, Tmat4, TarrU32,
51
52
  constU32, add, mul, div, item, newVec, readScope,
@@ -307,9 +308,13 @@ function addUniformOutput(
307
308
  }
308
309
  const irType = wgslTypeToIrType(wgslType);
309
310
  const refExpr = loadHeaderRef(drawIdxExpr, f.byteOffset, layout.strideU32);
310
- const value = layout.perInstanceUniforms.has(f.name)
311
- ? loadInstanceByRef(refExpr, instIdExpr, wgslType)
312
- : loadUniformByRef(refExpr, wgslType);
311
+ // Inline fields (PickId, __layoutId family): the header word IS the
312
+ // u32 value — no arena indirection, no pool entry per draw.
313
+ const value = isInlineHeaderField(f)
314
+ ? refExpr
315
+ : layout.perInstanceUniforms.has(f.name)
316
+ ? loadInstanceByRef(refExpr, instIdExpr, wgslType)
317
+ : loadUniformByRef(refExpr, wgslType);
313
318
 
314
319
  outputs.push({
315
320
  name: f.name,
@@ -104,7 +104,21 @@ export interface FragmentOutputLayout {
104
104
  // and on quota; every failure simply degrades to "recompute".
105
105
 
106
106
  /** Cache-generation stamp for the persistent (localStorage) tier. */
107
- export const HEAP_PERSIST_VERSION = "h2";
107
+ export const HEAP_PERSIST_VERSION = "h4"; // h4: inline PickId drawHeader read (was h3: packed decode arms)
108
+
109
+ /**
110
+ * Uniforms whose drawHeader word IS the u32 value (no arena
111
+ * indirection, no pool entry, no per-RO aval) — the `__layoutId`
112
+ * mechanism generalised. Values come from first-class RenderObject
113
+ * fields (`ro.pickId`), not the uniform provider. Fields must be
114
+ * u32-typed.
115
+ */
116
+ export const INLINE_HEADER_UNIFORMS: ReadonlySet<string> = new Set(["PickId"]);
117
+
118
+ /** True for fields read inline from the drawHeader word. */
119
+ export function isInlineHeaderField(f: { kind: string; name: string }): boolean {
120
+ return f.kind === "uniform-ref" && (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name));
121
+ }
108
122
  export const HEAP_PERSIST_PREFIX = "wbt.heapfx.";
109
123
 
110
124
  export function persistKey(version: string, kind: string, contentKey: string): string {
@@ -18,6 +18,7 @@
18
18
  // consumes is identical to the one heapScene.ts already builds.
19
19
 
20
20
  import { compileModule, stage as makeStage, effect as makeEffect } from "@aardworx/wombat.shader";
21
+ import { isInlineHeaderField } from "./heapEffect.js";
21
22
  import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
22
23
  import { substituteInputsInStage, readInputs, mapExpr, mapStmt, liftReturns, uniformsToInputs } from "@aardworx/wombat.shader/passes";
23
24
  import { synthesizeHeapDecoderModule } from "./heapDecoder.js";
@@ -278,9 +279,12 @@ function buildVertexUniformMap(layout: BucketLayout): Map<string, Expr> {
278
279
  for (const f of layout.drawHeaderFields) {
279
280
  if (f.kind !== "uniform-ref") continue;
280
281
  const refExpr = loadHeaderRef(drawIdx, f.byteOffset, stride);
281
- const value = layout.perInstanceUniforms.has(f.name)
282
- ? loadInstanceByRef(refExpr, iidx, f.uniformWgslType ?? "")
283
- : loadUniformByRef(refExpr, f.uniformWgslType ?? "");
282
+ // Inline fields: the header word IS the u32 value.
283
+ const value = isInlineHeaderField(f)
284
+ ? refExpr
285
+ : layout.perInstanceUniforms.has(f.name)
286
+ ? loadInstanceByRef(refExpr, iidx, f.uniformWgslType ?? "")
287
+ : loadUniformByRef(refExpr, f.uniformWgslType ?? "");
284
288
  out.set(f.name, value);
285
289
  }
286
290
  return out;
@@ -420,9 +424,11 @@ function rewriteFsUniformsDirect(m: Module, layout: BucketLayout): Module {
420
424
  const f = fieldByName.get(name);
421
425
  if (f === undefined || f.kind !== "uniform-ref") continue;
422
426
  const refExpr = loadHeaderRef(drawIdxExpr, f.byteOffset, layout.strideU32);
423
- const value = layout.perInstanceUniforms.has(name)
424
- ? loadInstanceByRef(refExpr, instIdExpr, f.uniformWgslType ?? "")
425
- : loadUniformByRef(refExpr, f.uniformWgslType ?? "");
427
+ const value = isInlineHeaderField(f)
428
+ ? refExpr
429
+ : layout.perInstanceUniforms.has(name)
430
+ ? loadInstanceByRef(refExpr, instIdExpr, f.uniformWgslType ?? "")
431
+ : loadUniformByRef(refExpr, f.uniformWgslType ?? "");
426
432
  fsSubst.set(name, value);
427
433
  }
428
434
  return substituteInputsInStage(m, "fragment", "Uniform", n => fsSubst.get(n));
@@ -541,9 +547,12 @@ function rewriteFsUniforms(m: Module, layout: BucketLayout): Module {
541
547
  value: { kind: "Expr", value: refExprWriter },
542
548
  });
543
549
  const refReadFs = readScope("Input", refParamName, Tu32);
544
- const value = layout.perInstanceUniforms.has(name)
545
- ? loadInstanceByRef(refReadFs, readScope("Input", "_iidx", Tu32), f.uniformWgslType ?? "")
546
- : loadUniformByRef(refReadFs, f.uniformWgslType ?? "");
550
+ // Inline fields: the threaded varying already carries the value.
551
+ const value = isInlineHeaderField(f)
552
+ ? refReadFs
553
+ : layout.perInstanceUniforms.has(name)
554
+ ? loadInstanceByRef(refReadFs, readScope("Input", "_iidx", Tu32), f.uniformWgslType ?? "")
555
+ : loadUniformByRef(refReadFs, f.uniformWgslType ?? "");
547
556
  fsSubst.set(name, value);
548
557
  }
549
558
 
@@ -34,7 +34,7 @@
34
34
  // grows feature support each becomes a per-buffer eligibility
35
35
  // question instead of a blanket "no".
36
36
 
37
- import { AVal, type aval } from "@aardworx/wombat.adaptive";
37
+ import { AVal, AdaptiveToken, type aval } from "@aardworx/wombat.adaptive";
38
38
  import type { IBuffer } from "../core/buffer.js";
39
39
  import type { BufferView } from "../core/bufferView.js";
40
40
  import type { ITexture } from "../core/texture.js";
@@ -174,14 +174,14 @@ export function isHeapEligible(ro: RenderObject): aval<boolean> {
174
174
  // per-RO path, which at scale (thousands of textured ROs ⇒ thousands
175
175
  // of draw calls + bind-group switches) is far slower than the heap
176
176
  // megacall sampling one shared atlas page.
177
- const distinctTextureAvals = new Set<aval<ITexture>>();
178
- ro.textures.iter((_n, av) => { distinctTextureAvals.add(av); });
179
- if (distinctTextureAvals.size > 1) {
177
+ // NOTE identity-dedup WITHOUT a Set: the long-lived AVal.custom
178
+ // closure below shares this function's V8 scope context, so any
179
+ // temporary Set here would be retained per-RO for the scene's
180
+ // lifetime (measured: 1 Set/RO of pure ballast at heap scale).
181
+ if (countDistinct(ro.textures) > 1) {
180
182
  return AVal.constant(false);
181
183
  }
182
- const distinctSamplerAvals = new Set<aval<ISampler>>();
183
- ro.samplers.iter((_n, av) => { distinctSamplerAvals.add(av); });
184
- if (distinctSamplerAvals.size > 1) {
184
+ if (countDistinct(ro.samplers) > 1) {
185
185
  return AVal.constant(false);
186
186
  }
187
187
 
@@ -202,11 +202,16 @@ export function isHeapEligible(ro: RenderObject): aval<boolean> {
202
202
  const textures = textureAvals(ro);
203
203
  const samplers = samplerAvals(ro);
204
204
 
205
- // Reactive AND-fold over all participating avals. We also subscribe
206
- // to drawCall (instanceCount/baseVertex/firstIndex/firstInstance
207
- // can flip; if they violate heap constraints the RO routes to the
208
- // legacy path that frame).
209
- return AVal.custom(token => {
205
+ // Constant collapse: when every participating aval is constant the
206
+ // answer can never change — evaluate once and return a plain
207
+ // constant. This keeps the hybrid partition from holding a custom
208
+ // aval + subscriptions per static RO (the overwhelmingly common
209
+ // case at heap scale).
210
+ let allConstant = ro.drawCall.isConstant;
211
+ if (allConstant) for (const av of buffers) { if (!av.isConstant) { allConstant = false; break; } }
212
+ if (allConstant) for (const av of textures) { if (!av.isConstant) { allConstant = false; break; } }
213
+ if (allConstant) for (const av of samplers) { if (!av.isConstant) { allConstant = false; break; } }
214
+ const evaluate = (token: AdaptiveToken): boolean => {
210
215
  let payloadBytes = 0;
211
216
  for (const av of buffers) {
212
217
  const b = av.getValue(token);
@@ -240,5 +245,27 @@ export function isHeapEligible(ro: RenderObject): aval<boolean> {
240
245
  if (dc.firstVertex !== 0) return false;
241
246
  }
242
247
  return true;
248
+ };
249
+ if (allConstant) return AVal.constant(evaluate(AdaptiveToken.top));
250
+ // Reactive AND-fold over all participating avals. We also subscribe
251
+ // to drawCall (instanceCount/baseVertex/firstIndex/firstInstance
252
+ // can flip; if they violate heap constraints the RO routes to the
253
+ // legacy path that frame).
254
+ return AVal.custom(evaluate);
255
+ }
256
+
257
+
258
+ /** Count distinct avals in a name→aval map by identity, allocation-free
259
+ * for the 0/1/2-distinct cases that matter (we only compare > 1). */
260
+ function countDistinct(m: { iter(f: (n: string, av: aval<unknown>) => void): void }): number {
261
+ let first: aval<unknown> | undefined;
262
+ let second: aval<unknown> | undefined;
263
+ let n = 0;
264
+ m.iter((_n, av) => {
265
+ if (av === first || av === second) return;
266
+ if (first === undefined) { first = av; n = 1; }
267
+ else if (second === undefined) { second = av; n = 2; }
268
+ else n++;
243
269
  });
270
+ return n;
244
271
  }
@@ -62,6 +62,23 @@ export const shr = (lhs: Expr, rhs: Expr, type: Type): Expr => ({
62
62
  export const select = (cond: Expr, ifTrue: Expr, ifFalse: Expr, type: Type): Expr => ({
63
63
  kind: "Conditional", cond, ifTrue, ifFalse, type,
64
64
  });
65
+ export const bitAnd = (lhs: Expr, rhs: Expr, type: Type): Expr => ({
66
+ kind: "BitAnd", lhs, rhs, type,
67
+ });
68
+ export const ltF32 = (lhs: Expr, rhs: Expr): Expr => ({ kind: "Lt", lhs, rhs, type: Tbool });
69
+ export const geF32 = (lhs: Expr, rhs: Expr): Expr => ({ kind: "Ge", lhs, rhs, type: Tbool });
70
+ /** u32 → f32 numeric conversion. */
71
+ export const toF32 = (value: Expr): Expr => ({ kind: "Convert", value, type: Tf32 } as Expr);
72
+ /** Pure WGSL/GLSL intrinsic call (same emit name unless glsl differs). */
73
+ const callIntrinsic = (wgsl: string, glsl: string, ret: Type, args: Expr[]): Expr => ({
74
+ kind: "CallIntrinsic",
75
+ op: { name: wgsl, returnTypeOf: () => ret, pure: true, emit: { wgsl, glsl } },
76
+ args, type: ret,
77
+ } as Expr);
78
+ export const absF32 = (e: Expr): Expr => callIntrinsic("abs", "abs", Tf32, [e]);
79
+ export const normalize3 = (e: Expr): Expr => callIntrinsic("normalize", "normalize", Tvec3, [e]);
80
+ export const unpack4x8unorm = (e: Expr): Expr =>
81
+ callIntrinsic("unpack4x8unorm", "unpackUnorm4x8", Tvec4, [e]);
65
82
  export const newVec = (components: Expr[], type: Type): Expr => ({
66
83
  kind: "NewVector", components, type,
67
84
  });
@@ -174,14 +191,40 @@ export function loadAttributeByRef(refIdent: Expr, idx: Expr, wgslType: string):
174
191
  const length = item(heapU32, add(div(refIdent, constU32(4), Tu32), constU32(1), Tu32), Tu32);
175
192
  return mul(mod(idx, length, Tu32), constU32(elemSize), Tu32);
176
193
  };
194
+ /** Header typeId (word 0 — same 16-byte cache line as the length the
195
+ * broadcast modulo already loads, so the packed-encoding branch costs
196
+ * no extra memory round-trip and is warp-coherent per field). */
197
+ const typeId = item(heapU32, div(refIdent, constU32(4), Tu32), Tu32);
198
+ /** One u32 element at the cyclic index (packed encodings: 4 B/elt). */
199
+ const packedWord = (): Expr => add(dataF32Base, cyclic(1), Tu32);
177
200
  switch (wgslType) {
178
201
  case "vec3<f32>": {
179
202
  const base = add(dataF32Base, cyclic(3), Tu32);
180
- return newVec([
203
+ const tight = newVec([
181
204
  item(heapF32, base, Tf32),
182
205
  item(heapF32, add(base, constU32(1), Tu32), Tf32),
183
206
  item(heapF32, add(base, constU32(2), Tu32), Tf32),
184
207
  ], Tvec3);
208
+ // ENC_OCT32 (typeId 2): one u32/element — x = low unorm16, y = high
209
+ // unorm16, both → [-1,1]; octahedral fold when z<0 (matches
210
+ // build_vienna.py's oct_pack and the CPU decoder in renderbench).
211
+ const u = item(heapU32, packedWord(), Tu32);
212
+ const toSnorm = (word: Expr): Expr =>
213
+ add(mul(toF32(word), constF32(2 / 65535), Tf32), constF32(-1.0), Tf32);
214
+ const fx = toSnorm(bitAnd(u, constU32(0xffff), Tu32));
215
+ const fy = toSnorm(shr(u, constU32(16), Tu32));
216
+ const z = sub(sub(constF32(1.0), absF32(fx), Tf32), absF32(fy), Tf32);
217
+ const neg = ltF32(z, constF32(0.0));
218
+ const signOf = (v: Expr): Expr =>
219
+ select(geF32(v, constF32(0.0)), constF32(1.0), constF32(-1.0), Tf32);
220
+ const fold = (a: Expr, b: Expr): Expr => // (1-|b|) * sign(a)
221
+ mul(sub(constF32(1.0), absF32(b), Tf32), signOf(a), Tf32);
222
+ const oct = normalize3(newVec([
223
+ select(neg, fold(fx, fy), fx, Tf32),
224
+ select(neg, fold(fy, fx), fy, Tf32),
225
+ z,
226
+ ], Tvec3));
227
+ return select(eqU32(typeId, constU32(2 /* ENC_OCT32 */)), oct, tight, Tvec3);
185
228
  }
186
229
  case "vec4<f32>": {
187
230
  const strideBytes = item(heapU32, add(div(refIdent, constU32(4), Tu32), constU32(2), Tu32), Tu32);
@@ -190,12 +233,17 @@ export function loadAttributeByRef(refIdent: Expr, idx: Expr, wgslType: string):
190
233
  const off = mul(cycled, strideF, Tu32);
191
234
  const base = add(dataF32Base, off, Tu32);
192
235
  const w = select(eqU32(strideF, constU32(4)), item(heapF32, add(base, constU32(3), Tu32), Tf32), constF32(1.0), Tf32);
193
- return newVec([
236
+ const tight = newVec([
194
237
  item(heapF32, base, Tf32),
195
238
  item(heapF32, add(base, constU32(1), Tu32), Tf32),
196
239
  item(heapF32, add(base, constU32(2), Tu32), Tf32),
197
240
  w,
198
241
  ], Tvec4);
242
+ // ENC_C4B (typeId 3): one u32/element, RGBA8 unorm — a single
243
+ // hardware unpack. (Vienna colours ship as C4b; storing them raw is
244
+ // 4 B/vertex instead of the 12 B tight-RGB expansion.)
245
+ const c4b = unpack4x8unorm(item(heapU32, packedWord(), Tu32));
246
+ return select(eqU32(typeId, constU32(3 /* ENC_C4B */)), c4b, tight, Tvec4);
199
247
  }
200
248
  case "vec2<f32>": {
201
249
  const base = add(dataF32Base, cyclic(2), Tu32);