@aardworx/wombat.rendering 0.9.2 → 0.9.4

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 (74) hide show
  1. package/dist/core/buffer.d.ts.map +1 -1
  2. package/dist/core/buffer.js +65 -5
  3. package/dist/core/buffer.js.map +1 -1
  4. package/dist/core/index.d.ts +2 -0
  5. package/dist/core/index.d.ts.map +1 -1
  6. package/dist/core/index.js +1 -0
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/provider.d.ts +38 -0
  9. package/dist/core/provider.d.ts.map +1 -0
  10. package/dist/core/provider.js +104 -0
  11. package/dist/core/provider.js.map +1 -0
  12. package/dist/core/renderObject.d.ts +17 -10
  13. package/dist/core/renderObject.d.ts.map +1 -1
  14. package/dist/core/sampler.d.ts.map +1 -1
  15. package/dist/core/sampler.js +71 -2
  16. package/dist/core/sampler.js.map +1 -1
  17. package/dist/core/texture.d.ts +9 -0
  18. package/dist/core/texture.d.ts.map +1 -1
  19. package/dist/core/texture.js +0 -0
  20. package/dist/core/texture.js.map +1 -1
  21. package/dist/resources/adaptiveTexture.d.ts.map +1 -1
  22. package/dist/resources/adaptiveTexture.js +7 -0
  23. package/dist/resources/adaptiveTexture.js.map +1 -1
  24. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  25. package/dist/resources/preparedRenderObject.js +20 -18
  26. package/dist/resources/preparedRenderObject.js.map +1 -1
  27. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  28. package/dist/resources/uniformBuffer.js +23 -0
  29. package/dist/resources/uniformBuffer.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -1
  31. package/dist/runtime/derivedUniforms/slots.js +4 -0
  32. package/dist/runtime/derivedUniforms/slots.js.map +1 -1
  33. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  34. package/dist/runtime/heapAdapter.js +72 -29
  35. package/dist/runtime/heapAdapter.js.map +1 -1
  36. package/dist/runtime/heapDecoder.d.ts +52 -0
  37. package/dist/runtime/heapDecoder.d.ts.map +1 -0
  38. package/dist/runtime/heapDecoder.js +363 -0
  39. package/dist/runtime/heapDecoder.js.map +1 -0
  40. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  41. package/dist/runtime/heapEffectIR.js +300 -3
  42. package/dist/runtime/heapEffectIR.js.map +1 -1
  43. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  44. package/dist/runtime/heapEligibility.js +42 -5
  45. package/dist/runtime/heapEligibility.js.map +1 -1
  46. package/dist/runtime/heapIrBuilders.d.ts +64 -0
  47. package/dist/runtime/heapIrBuilders.d.ts.map +1 -0
  48. package/dist/runtime/heapIrBuilders.js +311 -0
  49. package/dist/runtime/heapIrBuilders.js.map +1 -0
  50. package/dist/runtime/heapScene.d.ts.map +1 -1
  51. package/dist/runtime/heapScene.js +329 -112
  52. package/dist/runtime/heapScene.js.map +1 -1
  53. package/dist/runtime/textureAtlas/atlasPool.d.ts +67 -4
  54. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  55. package/dist/runtime/textureAtlas/atlasPool.js +152 -18
  56. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/core/buffer.ts +58 -6
  59. package/src/core/index.ts +11 -0
  60. package/src/core/provider.ts +143 -0
  61. package/src/core/renderObject.ts +17 -10
  62. package/src/core/sampler.ts +64 -2
  63. package/src/core/texture.ts +0 -0
  64. package/src/resources/adaptiveTexture.ts +7 -0
  65. package/src/resources/preparedRenderObject.ts +21 -16
  66. package/src/resources/uniformBuffer.ts +21 -0
  67. package/src/runtime/derivedUniforms/slots.ts +4 -0
  68. package/src/runtime/heapAdapter.ts +63 -29
  69. package/src/runtime/heapDecoder.ts +446 -0
  70. package/src/runtime/heapEffectIR.ts +314 -3
  71. package/src/runtime/heapEligibility.ts +34 -5
  72. package/src/runtime/heapIrBuilders.ts +365 -0
  73. package/src/runtime/heapScene.ts +338 -96
  74. package/src/runtime/textureAtlas/atlasPool.ts +152 -16
@@ -45,9 +45,9 @@
45
45
  import { Trafo3d, V3d, V4f, M44d, type V2f } from "@aardworx/wombat.base";
46
46
  import type { ITexture } from "../core/texture.js";
47
47
  import type { ISampler } from "../core/sampler.js";
48
- import { AVal, AdaptiveObject, AdaptiveToken } from "@aardworx/wombat.adaptive";
48
+ import { AVal, AdaptiveObject, AdaptiveToken, HashTable } from "@aardworx/wombat.adaptive";
49
49
  import type { aval, aset, IAdaptiveObject, IDisposable, IHashSetReader } from "@aardworx/wombat.adaptive";
50
- import type { Effect } from "@aardworx/wombat.shader";
50
+ import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
51
51
  import type { PipelineState } from "../core/pipelineState.js";
52
52
  import type { BufferView } from "../core/bufferView.js";
53
53
  import type { IBuffer, HostBufferSource } from "../core/buffer.js";
@@ -174,21 +174,43 @@ const PACKER_F32: WgslPacker = {
174
174
  dataBytes: 4, typeId: 0,
175
175
  pack: (val, dst, off) => { dst[off] = val as number; },
176
176
  };
177
- // u32 lives in the arena as 4 bytes too — we write through a transient
178
- // Uint32 view over the same backing buffer so the integer bit-pattern is
179
- // preserved (Float32Array assignment would lossily truncate `(u32) -> f32`).
180
- const PACKER_U32: WgslPacker = {
181
- dataBytes: 4, typeId: 0,
182
- pack: (val, dst, off) => {
183
- new Uint32Array(dst.buffer, dst.byteOffset + off * 4, 1)[0] = (val as number) >>> 0;
184
- },
185
- };
186
- const PACKER_I32: WgslPacker = {
187
- dataBytes: 4, typeId: 0,
188
- pack: (val, dst, off) => {
189
- new Int32Array(dst.buffer, dst.byteOffset + off * 4, 1)[0] = (val as number) | 0;
190
- },
191
- };
177
+ // Integer scalars / vectors. The arena is fronted by a `Float32Array`,
178
+ // so writing raw bits has to go through a same-buffer Uint32/Int32 view
179
+ // to avoid the lossy `i32 f32` coercion you'd get from a direct
180
+ // `dst[off] = ...` assignment.
181
+ function makeIntPacker(
182
+ ctor: typeof Uint32Array | typeof Int32Array,
183
+ dim: 1 | 2 | 3 | 4,
184
+ ): WgslPacker {
185
+ const bytes = dim * 4;
186
+ if (dim === 1) {
187
+ return {
188
+ dataBytes: bytes, typeId: 0,
189
+ pack: (val, dst, off) => {
190
+ new ctor(dst.buffer as ArrayBuffer, dst.byteOffset + off * 4, 1)[0] = val as number;
191
+ },
192
+ };
193
+ }
194
+ // Vector: accept {x,y,z,w} components.
195
+ return {
196
+ dataBytes: bytes, typeId: 0,
197
+ pack: (val, dst, off) => {
198
+ const view = new ctor(dst.buffer as ArrayBuffer, dst.byteOffset + off * 4, dim);
199
+ const v = val as { x: number; y: number; z?: number; w?: number };
200
+ view[0] = v.x; view[1] = v.y;
201
+ if (dim >= 3) view[2] = v.z!;
202
+ if (dim >= 4) view[3] = v.w!;
203
+ },
204
+ };
205
+ }
206
+ const PACKER_U32 = makeIntPacker(Uint32Array, 1);
207
+ const PACKER_UVEC2 = makeIntPacker(Uint32Array, 2);
208
+ const PACKER_UVEC3 = makeIntPacker(Uint32Array, 3);
209
+ const PACKER_UVEC4 = makeIntPacker(Uint32Array, 4);
210
+ const PACKER_I32 = makeIntPacker(Int32Array, 1);
211
+ const PACKER_IVEC2 = makeIntPacker(Int32Array, 2);
212
+ const PACKER_IVEC3 = makeIntPacker(Int32Array, 3);
213
+ const PACKER_IVEC4 = makeIntPacker(Int32Array, 4);
192
214
 
193
215
  function packerForWgslType(wgslType: string): WgslPacker {
194
216
  switch (wgslType) {
@@ -198,7 +220,13 @@ function packerForWgslType(wgslType: string): WgslPacker {
198
220
  case "vec2<f32>": return PACKER_VEC2;
199
221
  case "f32": return PACKER_F32;
200
222
  case "u32": return PACKER_U32;
223
+ case "vec2<u32>": return PACKER_UVEC2;
224
+ case "vec3<u32>": return PACKER_UVEC3;
225
+ case "vec4<u32>": return PACKER_UVEC4;
201
226
  case "i32": return PACKER_I32;
227
+ case "vec2<i32>": return PACKER_IVEC2;
228
+ case "vec3<i32>": return PACKER_IVEC3;
229
+ case "vec4<i32>": return PACKER_IVEC4;
202
230
  default:
203
231
  throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
204
232
  }
@@ -330,6 +358,17 @@ interface PoolEntry {
330
358
  * Same code path either way.
331
359
  */
332
360
  class UniformPool {
361
+ // Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
362
+ // keys are overwhelmingly reactive `cval`s (per-object trafos,
363
+ // colours, …) and the hot path is `acquire`/`release` ~once per
364
+ // drawHeader field per RO. A content-keyed `HashTable` would buy
365
+ // nothing here — reactive avals never compare content-equal — and
366
+ // would cost: reactive avals have no `equals`/`getHashCode`, so a
367
+ // `HashTable` falls back to a WeakMap-counter identity hash per
368
+ // lookup, measurably slower than `Map`'s native hashing. (Constant-
369
+ // aval dedup matters where keys are *texture* avals — there the
370
+ // `AtlasPool` is content-keyed; constant avals there carry a cached
371
+ // hash and a fast `equals`.)
333
372
  private readonly byAval = new Map<aval<unknown>, PoolEntry>();
334
373
 
335
374
  has(aval: aval<unknown>): boolean { return this.byAval.has(aval); }
@@ -603,19 +642,62 @@ class AttributeArena {
603
642
  }
604
643
  release(ref: number, dataBytes: number): void {
605
644
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
606
- this.freeList.push({ off: ref, size: allocBytes });
607
- // Coalesce adjacent free entries for cleanliness.
608
- this.freeList.sort((a, b) => a.off - b.off);
609
- for (let i = 0; i < this.freeList.length - 1; ) {
610
- const a = this.freeList[i]!, b = this.freeList[i + 1]!;
611
- if (a.off + a.size === b.off) { a.size += b.size; this.freeList.splice(i + 1, 1); }
612
- else i++;
613
- }
645
+ insertSortedFreeBlock(this.freeList, ref, allocBytes);
614
646
  }
615
647
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
616
648
  destroy(): void { this.buf.destroy(); }
617
649
  }
618
650
 
651
+ /**
652
+ * Insert `{off, size}` into a free-list kept sorted by `off`, then
653
+ * coalesce with the two immediate neighbours.
654
+ *
655
+ * The list invariant — sorted, non-overlapping, never-adjacent — is
656
+ * preserved across allocs (which take from the front or split a
657
+ * block) and releases (this function). The previous implementation
658
+ * did `push + Array.prototype.sort + linear coalesce scan`, which is
659
+ * O(N log N) per release. Under a 500-RO bulk-remove the sort
660
+ * dominated `removeDraw` (~41 ms of self-time in the heap-demo-sg
661
+ * toggle profile). Binary-search insert + 2-neighbour merge collapses
662
+ * that to O(log N + N-shift), and is principled — the sort never
663
+ * actually mattered since we already had the sorted prefix as an
664
+ * invariant.
665
+ */
666
+ function insertSortedFreeBlock(
667
+ freeList: { off: number; size: number }[],
668
+ off: number,
669
+ size: number,
670
+ ): void {
671
+ // Binary-search for the insertion index (first entry whose off > new).
672
+ let lo = 0, hi = freeList.length;
673
+ while (lo < hi) {
674
+ const mid = (lo + hi) >>> 1;
675
+ if (freeList[mid]!.off <= off) lo = mid + 1;
676
+ else hi = mid;
677
+ }
678
+ // lo is the index where the new entry would be inserted.
679
+ // Try merging with the predecessor first; if successful, the merged
680
+ // block may now be adjacent to its (former) successor too.
681
+ const prev = lo > 0 ? freeList[lo - 1] : undefined;
682
+ if (prev !== undefined && prev.off + prev.size === off) {
683
+ prev.size += size;
684
+ // Check forward-merge with what was freeList[lo].
685
+ const next = freeList[lo];
686
+ if (next !== undefined && prev.off + prev.size === next.off) {
687
+ prev.size += next.size;
688
+ freeList.splice(lo, 1);
689
+ }
690
+ return;
691
+ }
692
+ const next = freeList[lo];
693
+ if (next !== undefined && off + size === next.off) {
694
+ next.off = off;
695
+ next.size += size;
696
+ return;
697
+ }
698
+ freeList.splice(lo, 0, { off, size });
699
+ }
700
+
619
701
  /**
620
702
  * Element-bump allocator over an index GrowBuffer (units = u32). Each
621
703
  * draw's index range is allocated as one block; on release the block
@@ -674,13 +756,7 @@ class IndexAllocator {
674
756
  return off;
675
757
  }
676
758
  release(off: number, elements: number): void {
677
- this.freeList.push({ off, size: elements });
678
- this.freeList.sort((a, b) => a.off - b.off);
679
- for (let i = 0; i < this.freeList.length - 1; ) {
680
- const a = this.freeList[i]!, b = this.freeList[i + 1]!;
681
- if (a.off + a.size === b.off) { a.size += b.size; this.freeList.splice(i + 1, 1); }
682
- else i++;
683
- }
759
+ insertSortedFreeBlock(this.freeList, off, elements);
684
760
  }
685
761
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
686
762
  destroy(): void { this.buf.destroy(); }
@@ -995,8 +1071,15 @@ interface Bucket {
995
1071
  */
996
1072
  readonly localLayoutIds: (number | undefined)[];
997
1073
 
998
- /** Live local slots (drives the render loop). */
999
- readonly drawSlots: number[];
1074
+ /**
1075
+ * Live local slots. A Set rather than an array so addDraw/removeDraw
1076
+ * are both O(1): a 500-element bulk-remove on a 1000-element bucket
1077
+ * would otherwise burn 250K shifts in the linear-array splice path.
1078
+ * The render loop iterates this once per frame; iteration order
1079
+ * isn't load-bearing (slot→record indirection happens via
1080
+ * `slotToRecord` / `recordToSlot`).
1081
+ */
1082
+ readonly drawSlots: Set<number>;
1000
1083
  /** Local slots whose DrawHeader needs re-pack + writeBuffer next frame. */
1001
1084
  readonly dirty: Set<number>;
1002
1085
 
@@ -1039,6 +1122,12 @@ interface Bucket {
1039
1122
  * GrowBuffer reallocation. Standalone-only buckets keep this empty.
1040
1123
  */
1041
1124
  readonly localAtlasTextures: ((HeapTextureSet & { kind: "atlas" }) | undefined)[];
1125
+ /**
1126
+ * Per-local-slot index into `atlasAvalRefs[sourceAval]` — lets
1127
+ * removeDraw do swap-pop without an O(N) findIndex scan. `undefined`
1128
+ * for slots without an atlas source aval.
1129
+ */
1130
+ readonly localAtlasArrIdx: (number | undefined)[];
1042
1131
  }
1043
1132
 
1044
1133
  // ---------------------------------------------------------------------------
@@ -1310,6 +1399,19 @@ export function buildHeapScene(
1310
1399
  throw new Error("buildHeapScene: framebuffer signature has no color attachment");
1311
1400
  }
1312
1401
  const colorFormat = sig.colors.tryFind(colorAttachmentName)!;
1402
+ // All color targets, ordered by the signature's `colorNames`. Each
1403
+ // entry's format flows into the pipeline's `fragment.targets[i].format`.
1404
+ // The fragmentOutputLayout's `locations` map tells the WGSL emit which
1405
+ // output @location maps to which color attachment; this array is the
1406
+ // matching pipeline-side ordering.
1407
+ const colorTargets: { format: GPUTextureFormat }[] = [];
1408
+ for (const name of sig.colorNames) {
1409
+ const fmt = sig.colors.tryFind(name);
1410
+ if (fmt === undefined) {
1411
+ throw new Error(`buildHeapScene: signature missing format for color '${name}'`);
1412
+ }
1413
+ colorTargets.push({ format: fmt });
1414
+ }
1313
1415
  const depthFormat = sig.depthStencil?.format;
1314
1416
 
1315
1417
  // ─── Global arena (uniform/attribute data + index buffer) ────────
@@ -1513,7 +1615,11 @@ export function buildHeapScene(
1513
1615
  readonly repack: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition;
1514
1616
  readonly sampler: ISampler;
1515
1617
  }
1516
- const atlasAvalRefs = new Map<aval<ITexture>, AtlasAvalRef[]>();
1618
+ // Content-keyed (`HashTable`, not a JS `Map`) so distinct
1619
+ // `AVal.constant(tex)` wrappers sharing the same texture collapse to
1620
+ // one `(bucket, slot)` ref-list — matching `AtlasPool.entriesByAval`,
1621
+ // which is now content-keyed too. Reactive avals key by reference.
1622
+ const atlasAvalRefs = new HashTable<aval<ITexture>, AtlasAvalRef[]>();
1517
1623
  /**
1518
1624
  * Per-draw bucket dirty (rare in steady state — only fires when
1519
1625
  * something forces a header rewrite, e.g. a drawHeap GrowBuffer
@@ -1750,9 +1856,20 @@ export function buildHeapScene(
1750
1856
  schema: ShaderFamilySchema;
1751
1857
  vsModule: GPUShaderModule;
1752
1858
  fsModule: GPUShaderModule;
1753
- fieldsForEffect: Map<Effect, Set<string>>;
1859
+ /** Actual @vertex entry-point name in `vsModule`. */
1860
+ vsEntryName: string;
1861
+ /** Actual @fragment entry-point name in `fsModule`. */
1862
+ fsEntryName: string;
1863
+ fieldsForEffect: Map<string, Set<string>>;
1754
1864
  }
1755
- const familyByEffect = new Map<Effect, FamilyState>();
1865
+ // Keyed by `effect.id` (content hash), NOT object identity. Two
1866
+ // Effect objects with identical content (e.g. produced by separate
1867
+ // calls to `effect(...)` or the pickChain composer) share one
1868
+ // FamilyState. This is the right correctness/perf knob: building a
1869
+ // family by content means an upstream caller that legitimately
1870
+ // produces different-but-identical Effect objects per leaf still
1871
+ // reuses one pipeline + one bucket.
1872
+ const familyByEffectId = new Map<string, FamilyState>();
1756
1873
  let familyBuilt = false;
1757
1874
  const enableFamilyMerge = opts.enableFamilyMerge === true;
1758
1875
 
@@ -1760,26 +1877,50 @@ export function buildHeapScene(
1760
1877
  effects: readonly Effect[],
1761
1878
  perInstanceByEffect: ReadonlyMap<Effect, { attributes: Set<string>; uniforms: Set<string> }>,
1762
1879
  ): FamilyState {
1880
+ // Family-merge (multi-effect dispatch via layoutId switch) has been
1881
+ // disabled — it was a perf illusion. Every effect compiles to its
1882
+ // own standalone pipeline; the per-bucket WGSL comes from
1883
+ // `compileHeapEffectIR(effect, layout, opts, "standalone")` and is
1884
+ // used directly as the bucket's shader module (no wrapper, no
1885
+ // dispatch). The schema is still derived from the same
1886
+ // `buildShaderFamily` path so the runtime's drawHeader packer,
1887
+ // bind-group layout, etc. don't change shape — `__layoutId`
1888
+ // remains a u32 slot that's written as 0 per RO (harmless).
1889
+ //
1890
+ // `heapShaderFamily.compileShaderFamily` is kept around but
1891
+ // unused; the function and its tests stay disabled.
1892
+ if (effects.length !== 1) {
1893
+ throw new Error(
1894
+ "heapScene: multi-effect family-merge disabled (was a perf illusion). " +
1895
+ "Build one bucket per effect.",
1896
+ );
1897
+ }
1898
+ const effect = effects[0]!;
1763
1899
  const schema = buildShaderFamily(
1764
- effects, opts.fragmentOutputLayout, undefined,
1900
+ [effect], opts.fragmentOutputLayout, undefined,
1765
1901
  {
1766
1902
  atlasizeAllTextures: atlasPool !== undefined,
1767
1903
  perEffectPerInstance: perInstanceByEffect,
1768
1904
  },
1769
1905
  );
1770
- const compiled = compileShaderFamily(schema, opts.fragmentOutputLayout);
1771
- const vsModule = device.createShaderModule({ code: compiled.vs, label: `heapScene/family/${schema.id}/vs` });
1772
- const fsModule = device.createShaderModule({ code: compiled.fs, label: `heapScene/family/${schema.id}/fs` });
1773
- const fieldsForEffect = new Map<Effect, Set<string>>();
1774
- for (const e of schema.effects) {
1775
- const s = schema.perEffectSchema.get(e)!;
1776
- const fields = new Set<string>();
1777
- for (const a of s.attributes) fields.add(a.name);
1778
- for (const u of s.uniforms) fields.add(u.name);
1779
- for (const t of s.textures) fields.add(t.name);
1780
- fieldsForEffect.set(e, fields);
1781
- }
1782
- return { schema, vsModule, fsModule, fieldsForEffect };
1906
+ const compileOpts: CompileOptions = opts.fragmentOutputLayout !== undefined
1907
+ ? { target: "wgsl", fragmentOutputLayout: opts.fragmentOutputLayout }
1908
+ : { target: "wgsl" };
1909
+ const ir = compileHeapEffectIR(effect, schema.drawHeaderUnion, compileOpts, "standalone");
1910
+ const vsModule = device.createShaderModule({ code: ir.vs, label: `heapScene/standalone/${schema.id}/vs` });
1911
+ const fsModule = device.createShaderModule({ code: ir.fs, label: `heapScene/standalone/${schema.id}/fs` });
1912
+ const fieldsForEffect = new Map<string, Set<string>>();
1913
+ const s = schema.perEffectSchema.get(effect)!;
1914
+ const fields = new Set<string>();
1915
+ for (const a of s.attributes) fields.add(a.name);
1916
+ for (const u of s.uniforms) fields.add(u.name);
1917
+ for (const t of s.textures) fields.add(t.name);
1918
+ fieldsForEffect.set(effect.id, fields);
1919
+ return {
1920
+ schema, vsModule, fsModule, fieldsForEffect,
1921
+ vsEntryName: ir.vsEntry,
1922
+ fsEntryName: ir.fsEntry,
1923
+ };
1783
1924
  }
1784
1925
 
1785
1926
  function buildFamilyFromSpecs(specs: readonly HeapDrawSpec[]): void {
@@ -1793,45 +1934,42 @@ export function buildHeapScene(
1793
1934
  // layout to address per-instance attribute reads via `instId`
1794
1935
  // instead of `vertex_index` — without this, instanced effects
1795
1936
  // produce broken geometry.
1796
- const seen = new Set<Effect>();
1937
+ // De-dupe by effect.id so two Effects with identical content but
1938
+ // distinct object identities collapse to one family + one bucket.
1939
+ const seenIds = new Set<string>();
1797
1940
  const unique: Effect[] = [];
1798
- const perInstanceByEffect = new Map<Effect, {
1941
+ const perInstanceByEffectId = new Map<string, {
1799
1942
  attributes: Set<string>;
1800
1943
  uniforms: Set<string>;
1801
1944
  }>();
1802
1945
  for (const spec of specs) {
1803
1946
  const e = spec.effect;
1804
- let entry = perInstanceByEffect.get(e);
1947
+ let entry = perInstanceByEffectId.get(e.id);
1805
1948
  if (entry === undefined) {
1806
1949
  entry = { attributes: new Set<string>(), uniforms: new Set<string>() };
1807
- perInstanceByEffect.set(e, entry);
1950
+ perInstanceByEffectId.set(e.id, entry);
1808
1951
  }
1809
1952
  if (spec.instanceAttributes !== undefined) {
1810
1953
  for (const name of Object.keys(spec.instanceAttributes)) entry.attributes.add(name);
1811
1954
  }
1812
- if (!seen.has(e)) { seen.add(e); unique.push(e); }
1955
+ if (!seenIds.has(e.id)) { seenIds.add(e.id); unique.push(e); }
1813
1956
  }
1814
- if (enableFamilyMerge) {
1815
- const merged = compileFamilyFor(unique, perInstanceByEffect);
1816
- for (const e of unique) familyByEffect.set(e, merged);
1817
- } else {
1818
- // Default: one family per effect — no shared layoutId switch.
1819
- // Per-effect bucketing is at-or-better than merged on tested
1820
- // workloads; merge stays opt-in pending trace-based v2.
1821
- for (const e of unique) {
1822
- const perI = perInstanceByEffect.get(e);
1823
- const singleMap = new Map<Effect, { attributes: Set<string>; uniforms: Set<string> }>();
1824
- if (perI !== undefined) singleMap.set(e, perI);
1825
- familyByEffect.set(e, compileFamilyFor([e], singleMap));
1826
- }
1957
+ // Family-merge disabled: always one bucket per effect.
1958
+ // `enableFamilyMerge` is ignored.
1959
+ void enableFamilyMerge;
1960
+ for (const e of unique) {
1961
+ const perI = perInstanceByEffectId.get(e.id);
1962
+ const singleMap = new Map<Effect, { attributes: Set<string>; uniforms: Set<string> }>();
1963
+ if (perI !== undefined) singleMap.set(e, perI);
1964
+ familyByEffectId.set(e.id, compileFamilyFor([e], singleMap));
1827
1965
  }
1828
1966
  familyBuilt = true;
1829
1967
  }
1830
1968
 
1831
1969
  function familyFor(effect: Effect): FamilyState {
1832
- const f = familyByEffect.get(effect);
1970
+ const f = familyByEffectId.get(effect.id);
1833
1971
  if (f === undefined) {
1834
- const known = [...familyByEffect.keys()].map(e => e.id).join(",");
1972
+ const known = [...familyByEffectId.keys()].join(",");
1835
1973
  throw new Error(
1836
1974
  `heapScene: family is frozen; effect ${effect.id} not in {${known}}; ` +
1837
1975
  `reactive family rebuild is v2`,
@@ -1857,13 +1995,89 @@ export function buildHeapScene(
1857
1995
  if (id === undefined) { id = `tex#${texCounter++}`; textureIds.set(t, id); }
1858
1996
  return id;
1859
1997
  };
1860
- const psIds = new WeakMap<PipelineState, string>();
1861
- let psCounter = 0;
1998
+ // PipelineState content key. Hashes by the identities of the inner
1999
+ // aval references rather than the wrapper object itself: callers
2000
+ // (notably `wombat.dom`'s `derivePipelineState`) construct a fresh
2001
+ // PipelineState object per leaf even when every contributing aval
2002
+ // is shared — without a content-aware key, every leaf gets its own
2003
+ // bucket and the heap path's drawIndirect coalescing collapses to
2004
+ // one record per bucket. With this key, two leaves that pulled the
2005
+ // same `state.mode` / `state.cullMode` / `state.depthTest` / … avals
2006
+ // bucket together.
2007
+ //
2008
+ // For CONSTANT avals (`isConstant === true`) we key by *value* — two
2009
+ // distinct `AVal.constant(0)` objects produced at different call
2010
+ // sites must collapse to the same bucket. Reactive avals fall back
2011
+ // to reference identity (the right semantic — value can tick).
2012
+ const avalIds = new WeakMap<object, number>();
2013
+ const valueIds = new Map<string, number>();
2014
+ let avalCounter = 0;
2015
+ const avalIdOf = (av: aval<unknown> | undefined): string => {
2016
+ if (av === undefined) return "_";
2017
+ // Reactive aval — key by reference. (`isConstant` is on the
2018
+ // public IAdaptive surface; guard via runtime check so off-spec
2019
+ // duck-types don't blow up.)
2020
+ const isConst = (av as { isConstant?: unknown }).isConstant === true;
2021
+ if (!isConst) {
2022
+ let id = avalIds.get(av);
2023
+ if (id === undefined) { id = avalCounter++; avalIds.set(av, id); }
2024
+ return `a${id}`;
2025
+ }
2026
+ // Constant: key by value. Force is safe (constant: no upstream
2027
+ // dep) and only runs once per distinct aval-object thanks to the
2028
+ // outer avalIds cache below.
2029
+ let id = avalIds.get(av);
2030
+ if (id !== undefined) return `c${id}`;
2031
+ // Constant avals ignore the token; use AdaptiveToken.top to satisfy
2032
+ // the type and traverse a no-op evaluation.
2033
+ const v = av.getValue(AdaptiveToken.top);
2034
+ // Value-typed (`equals` + `getHashCode`)? Try to intern by hash
2035
+ // bucket + equals so two distinct AVal.constant(M44d.identity)
2036
+ // collapse. Falls back to a per-value string key otherwise.
2037
+ let vKey: string;
2038
+ if (
2039
+ v !== null && typeof v === "object" &&
2040
+ typeof (v as { getHashCode?: unknown }).getHashCode === "function" &&
2041
+ typeof (v as { equals?: unknown }).equals === "function"
2042
+ ) {
2043
+ const hc = (v as { getHashCode(): number }).getHashCode() | 0;
2044
+ vKey = `hv:${hc}`;
2045
+ } else if (v === null || typeof v !== "object") {
2046
+ vKey = `pv:${typeof v}:${String(v)}`;
2047
+ } else {
2048
+ // Plain object — fall back to reference identity (matches the
2049
+ // memo runtime's behaviour for unhashable objects).
2050
+ let oid = avalIds.get(v as object);
2051
+ if (oid === undefined) { oid = avalCounter++; avalIds.set(v as object, oid); }
2052
+ vKey = `ov:${oid}`;
2053
+ }
2054
+ let vid = valueIds.get(vKey);
2055
+ if (vid === undefined) { vid = avalCounter++; valueIds.set(vKey, vid); }
2056
+ avalIds.set(av, vid);
2057
+ return `c${vid}`;
2058
+ };
2059
+ const psContentIds = new WeakMap<PipelineState, string>();
1862
2060
  const psIdOf = (ps: PipelineState | undefined): string => {
1863
2061
  if (ps === undefined) return "ps#default";
1864
- let id = psIds.get(ps);
1865
- if (id === undefined) { id = `ps#${psCounter++}`; psIds.set(ps, id); }
1866
- return id;
2062
+ const cached = psContentIds.get(ps);
2063
+ if (cached !== undefined) return cached;
2064
+ const r = ps.rasterizer;
2065
+ const parts: string[] = [
2066
+ avalIdOf(r.topology),
2067
+ avalIdOf(r.cullMode),
2068
+ avalIdOf(r.frontFace),
2069
+ avalIdOf(r.depthBias),
2070
+ ps.depth !== undefined
2071
+ ? `d:${avalIdOf(ps.depth.write)}:${avalIdOf(ps.depth.compare)}:${avalIdOf(ps.depth.clamp)}`
2072
+ : "d:_",
2073
+ ps.stencil !== undefined ? "s:1" : "s:_",
2074
+ avalIdOf(ps.blends),
2075
+ avalIdOf(ps.alphaToCoverage),
2076
+ avalIdOf(ps.blendConstant),
2077
+ ];
2078
+ const key = `ps#${parts.join("|")}`;
2079
+ psContentIds.set(ps, key);
2080
+ return key;
1867
2081
  };
1868
2082
 
1869
2083
  /** Resolved (forced) snapshot of the user's PipelineState. */
@@ -2074,15 +2288,15 @@ export function buildHeapScene(
2074
2288
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
2075
2289
  const vsModule = fam.vsModule;
2076
2290
  const fsModule = fam.fsModule;
2077
- const vsEntry = "family_vs_main";
2078
- const fsEntry = "family_fs_main";
2291
+ const vsEntry = fam.vsEntryName;
2292
+ const fsEntry = fam.fsEntryName;
2079
2293
  const { pipelineLayout } = getBgl(layout, isAtlasBucket);
2080
2294
 
2081
2295
  const pipeline = device.createRenderPipeline({
2082
2296
  label: `heapScene/${bk}/pipeline`,
2083
2297
  layout: pipelineLayout,
2084
2298
  vertex: { module: vsModule, entryPoint: vsEntry, buffers: [] },
2085
- fragment: { module: fsModule, entryPoint: fsEntry, targets: [{ format: colorFormat }] },
2299
+ fragment: { module: fsModule, entryPoint: fsEntry, targets: colorTargets },
2086
2300
  primitive: { topology: ps.topology, cullMode: ps.cullMode, frontFace: ps.frontFace },
2087
2301
  ...(depthFormat !== undefined && ps.depth !== undefined
2088
2302
  ? { depthStencil: { format: depthFormat, depthWriteEnabled: ps.depth.write, depthCompare: ps.depth.compare } }
@@ -2114,7 +2328,7 @@ export function buildHeapScene(
2114
2328
  localPosRefs: [], localNorRefs: [],
2115
2329
  localEntries: [], localToDrawId: [],
2116
2330
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
2117
- drawSlots: [], dirty: new Set<number>(),
2331
+ drawSlots: new Set<number>(), dirty: new Set<number>(),
2118
2332
  drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
2119
2333
  recordCount: 0, slotToRecord: [], recordToSlot: [],
2120
2334
  totalEmitEstimate: 0,
@@ -2122,6 +2336,7 @@ export function buildHeapScene(
2122
2336
  isAtlasBucket,
2123
2337
  localAtlasReleases: [],
2124
2338
  localAtlasTextures: [],
2339
+ localAtlasArrIdx: [],
2125
2340
  };
2126
2341
  {
2127
2342
  const dtBuf = new GrowBuffer(
@@ -2343,7 +2558,21 @@ export function buildHeapScene(
2343
2558
  Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
2344
2559
 
2345
2560
  // ─── addDraw / removeDraw ─────────────────────────────────────────
2561
+ // Public addDraw wrapper. Establishes a sceneObj.evaluateAlways
2562
+ // scope so external callers (no batched outer eval) still register
2563
+ // sceneObj as an output of any aval the spec touches. Internal call
2564
+ // sites (drainAsetWith, batched initial-population) already run
2565
+ // inside a single outer evaluateAlways and invoke addDrawImpl
2566
+ // directly with their token — collapsing 1000× nested
2567
+ // evaluateAlways into 1× outer.
2346
2568
  function addDraw(spec: HeapDrawSpec): number {
2569
+ let id = -1;
2570
+ sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2571
+ id = addDrawImpl(spec, tok);
2572
+ });
2573
+ return id;
2574
+ }
2575
+ function addDrawImpl(spec: HeapDrawSpec, outerTok: AdaptiveToken): number {
2347
2576
  const drawId = nextDrawId++;
2348
2577
  // Family-merge (slice 3c): build the family lazily from this
2349
2578
  // single spec when no batched lazy-build occurred earlier (e.g.
@@ -2360,7 +2589,7 @@ export function buildHeapScene(
2360
2589
  : new Set<string>();
2361
2590
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState);
2362
2591
  const fam = familyFor(spec.effect);
2363
- const effectFields = fam.fieldsForEffect.get(spec.effect)!;
2592
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
2364
2593
 
2365
2594
  // Indices live in their own INDEX-usage buffer (WebGPU constraint).
2366
2595
  // Aval-keyed: 19K instanced clones of the same mesh share one
@@ -2382,7 +2611,8 @@ export function buildHeapScene(
2382
2611
  // emerges from aval identity either way.
2383
2612
  const perDrawAvals: aval<unknown>[] = [];
2384
2613
  const perDrawRefs = new Map<string, number>();
2385
- sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2614
+ {
2615
+ const tok = outerTok;
2386
2616
  for (const f of bucket.layout.drawHeaderFields) {
2387
2617
  // Atlas-variant texture bindings carry inline values rather than
2388
2618
  // pool refs; packAtlasTextureFields fills them after this loop.
@@ -2454,7 +2684,7 @@ export function buildHeapScene(
2454
2684
  perDrawRefs.set(f.name, ref);
2455
2685
  perDrawAvals.push(av);
2456
2686
  }
2457
- });
2687
+ }
2458
2688
 
2459
2689
  bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
2460
2690
  bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
@@ -2462,7 +2692,7 @@ export function buildHeapScene(
2462
2692
  indexCount: idxAlloc.count, firstIndex: idxAlloc.firstIndex, instanceCount,
2463
2693
  };
2464
2694
  bucket.localToDrawId[localSlot] = drawId;
2465
- bucket.drawSlots.push(localSlot);
2695
+ bucket.drawSlots.add(localSlot);
2466
2696
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
2467
2697
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
2468
2698
  const layoutId = fam.schema.layoutIdOf.get(spec.effect)!;
@@ -2478,15 +2708,14 @@ export function buildHeapScene(
2478
2708
  const sourceAval = spec.textures.sourceAval;
2479
2709
  const repackFn = spec.textures.repack;
2480
2710
  if (sourceAval !== undefined && repackFn !== undefined) {
2481
- sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2482
- // touch — registers sceneObj as an output of sourceAval.
2483
- sourceAval.getValue(tok);
2484
- });
2711
+ // touch — registers sceneObj as an output of sourceAval.
2712
+ sourceAval.getValue(outerTok);
2485
2713
  let arr = atlasAvalRefs.get(sourceAval);
2486
2714
  if (arr === undefined) {
2487
2715
  arr = [];
2488
2716
  atlasAvalRefs.set(sourceAval, arr);
2489
2717
  }
2718
+ bucket.localAtlasArrIdx[localSlot] = arr.length;
2490
2719
  arr.push({ bucket, localSlot, repack: repackFn, sampler: spec.textures.sampler });
2491
2720
  }
2492
2721
  }
@@ -2629,11 +2858,18 @@ export function buildHeapScene(
2629
2858
  const atlasTex = bucket.localAtlasTextures[localSlot];
2630
2859
  if (atlasTex !== undefined) {
2631
2860
  const sourceAval = atlasTex.sourceAval;
2632
- if (sourceAval !== undefined) {
2861
+ const i = bucket.localAtlasArrIdx[localSlot];
2862
+ if (sourceAval !== undefined && i !== undefined) {
2633
2863
  const arr = atlasAvalRefs.get(sourceAval);
2634
2864
  if (arr !== undefined) {
2635
- const i = arr.findIndex(r => r.bucket === bucket && r.localSlot === localSlot);
2636
- if (i >= 0) arr.splice(i, 1);
2865
+ // Swap-pop: O(1) regardless of array length.
2866
+ const last = arr.length - 1;
2867
+ if (i !== last) {
2868
+ const moved = arr[last]!;
2869
+ arr[i] = moved;
2870
+ moved.bucket.localAtlasArrIdx[moved.localSlot] = i;
2871
+ }
2872
+ arr.pop();
2637
2873
  if (arr.length === 0) {
2638
2874
  atlasAvalRefs.delete(sourceAval);
2639
2875
  atlasAvalDirty.delete(sourceAval);
@@ -2643,6 +2879,7 @@ export function buildHeapScene(
2643
2879
  }
2644
2880
  bucket.localAtlasReleases[localSlot] = undefined;
2645
2881
  bucket.localAtlasTextures[localSlot] = undefined;
2882
+ bucket.localAtlasArrIdx[localSlot] = undefined;
2646
2883
 
2647
2884
  bucket.localPerDrawAvals[localSlot] = undefined;
2648
2885
  bucket.localPerDrawRefs[localSlot] = undefined;
@@ -2651,8 +2888,7 @@ export function buildHeapScene(
2651
2888
  bucket.localNorRefs[localSlot] = undefined;
2652
2889
  bucket.localEntries[localSlot] = undefined;
2653
2890
  bucket.localToDrawId[localSlot] = undefined;
2654
- const idx = bucket.drawSlots.indexOf(localSlot);
2655
- if (idx >= 0) bucket.drawSlots.splice(idx, 1);
2891
+ bucket.drawSlots.delete(localSlot);
2656
2892
  bucket.dirty.delete(localSlot);
2657
2893
  bucket.drawHeap.release(localSlot);
2658
2894
 
@@ -2672,7 +2908,11 @@ export function buildHeapScene(
2672
2908
  const delta = asetReader.getChanges(tok);
2673
2909
  delta.iter((op: { value: HeapDrawSpec; count: number }) => {
2674
2910
  if (op.count > 0) {
2675
- const id = addDraw(op.value);
2911
+ // Re-use the outer evaluateAlways scope's token (caller is
2912
+ // already sceneObj) — collapses N nested evaluateAlways into
2913
+ // one for a bulk add. Saves a per-RO setUnsafeEvaluationDepth
2914
+ // + outputs.add(sceneObj) round trip.
2915
+ const id = addDrawImpl(op.value, tok);
2676
2916
  specToDrawId.set(op.value, id);
2677
2917
  } else {
2678
2918
  const id = specToDrawId.get(op.value);
@@ -2691,7 +2931,9 @@ export function buildHeapScene(
2691
2931
  // array is empty (family will build lazily when an addDraw fires).
2692
2932
  const arr = initialDraws as readonly HeapDrawSpec[];
2693
2933
  if (arr.length > 0) buildFamilyFromSpecs(arr);
2694
- for (const d of arr) addDraw(d);
2934
+ sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2935
+ for (const d of arr) addDrawImpl(d, tok);
2936
+ });
2695
2937
  } else {
2696
2938
  asetReader = (initialDraws as aset<HeapDrawSpec>).getReader();
2697
2939
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
@@ -2711,7 +2953,7 @@ export function buildHeapScene(
2711
2953
  if (adds.length > 0) buildFamilyFromSpecs(adds);
2712
2954
  for (const op of remaining) {
2713
2955
  if (op.count > 0) {
2714
- const id = addDraw(op.value);
2956
+ const id = addDrawImpl(op.value, tok);
2715
2957
  specToDrawId.set(op.value, id);
2716
2958
  } else {
2717
2959
  const id = specToDrawId.get(op.value);