@aardworx/wombat.rendering 0.9.3 → 0.9.5

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 +286 -97
  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 +4 -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 +295 -81
  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";
@@ -358,6 +358,17 @@ interface PoolEntry {
358
358
  * Same code path either way.
359
359
  */
360
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`.)
361
372
  private readonly byAval = new Map<aval<unknown>, PoolEntry>();
362
373
 
363
374
  has(aval: aval<unknown>): boolean { return this.byAval.has(aval); }
@@ -631,19 +642,62 @@ class AttributeArena {
631
642
  }
632
643
  release(ref: number, dataBytes: number): void {
633
644
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
634
- this.freeList.push({ off: ref, size: allocBytes });
635
- // Coalesce adjacent free entries for cleanliness.
636
- this.freeList.sort((a, b) => a.off - b.off);
637
- for (let i = 0; i < this.freeList.length - 1; ) {
638
- const a = this.freeList[i]!, b = this.freeList[i + 1]!;
639
- if (a.off + a.size === b.off) { a.size += b.size; this.freeList.splice(i + 1, 1); }
640
- else i++;
641
- }
645
+ insertSortedFreeBlock(this.freeList, ref, allocBytes);
642
646
  }
643
647
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
644
648
  destroy(): void { this.buf.destroy(); }
645
649
  }
646
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
+
647
701
  /**
648
702
  * Element-bump allocator over an index GrowBuffer (units = u32). Each
649
703
  * draw's index range is allocated as one block; on release the block
@@ -702,13 +756,7 @@ class IndexAllocator {
702
756
  return off;
703
757
  }
704
758
  release(off: number, elements: number): void {
705
- this.freeList.push({ off, size: elements });
706
- this.freeList.sort((a, b) => a.off - b.off);
707
- for (let i = 0; i < this.freeList.length - 1; ) {
708
- const a = this.freeList[i]!, b = this.freeList[i + 1]!;
709
- if (a.off + a.size === b.off) { a.size += b.size; this.freeList.splice(i + 1, 1); }
710
- else i++;
711
- }
759
+ insertSortedFreeBlock(this.freeList, off, elements);
712
760
  }
713
761
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
714
762
  destroy(): void { this.buf.destroy(); }
@@ -1023,8 +1071,15 @@ interface Bucket {
1023
1071
  */
1024
1072
  readonly localLayoutIds: (number | undefined)[];
1025
1073
 
1026
- /** Live local slots (drives the render loop). */
1027
- 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>;
1028
1083
  /** Local slots whose DrawHeader needs re-pack + writeBuffer next frame. */
1029
1084
  readonly dirty: Set<number>;
1030
1085
 
@@ -1067,6 +1122,12 @@ interface Bucket {
1067
1122
  * GrowBuffer reallocation. Standalone-only buckets keep this empty.
1068
1123
  */
1069
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)[];
1070
1131
  }
1071
1132
 
1072
1133
  // ---------------------------------------------------------------------------
@@ -1338,6 +1399,19 @@ export function buildHeapScene(
1338
1399
  throw new Error("buildHeapScene: framebuffer signature has no color attachment");
1339
1400
  }
1340
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
+ }
1341
1415
  const depthFormat = sig.depthStencil?.format;
1342
1416
 
1343
1417
  // ─── Global arena (uniform/attribute data + index buffer) ────────
@@ -1541,7 +1615,11 @@ export function buildHeapScene(
1541
1615
  readonly repack: (newTex: ITexture) => import("./textureAtlas/atlasPool.js").AtlasAcquisition;
1542
1616
  readonly sampler: ISampler;
1543
1617
  }
1544
- 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[]>();
1545
1623
  /**
1546
1624
  * Per-draw bucket dirty (rare in steady state — only fires when
1547
1625
  * something forces a header rewrite, e.g. a drawHeap GrowBuffer
@@ -1778,9 +1856,20 @@ export function buildHeapScene(
1778
1856
  schema: ShaderFamilySchema;
1779
1857
  vsModule: GPUShaderModule;
1780
1858
  fsModule: GPUShaderModule;
1781
- 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>>;
1782
1864
  }
1783
- 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>();
1784
1873
  let familyBuilt = false;
1785
1874
  const enableFamilyMerge = opts.enableFamilyMerge === true;
1786
1875
 
@@ -1788,26 +1877,50 @@ export function buildHeapScene(
1788
1877
  effects: readonly Effect[],
1789
1878
  perInstanceByEffect: ReadonlyMap<Effect, { attributes: Set<string>; uniforms: Set<string> }>,
1790
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]!;
1791
1899
  const schema = buildShaderFamily(
1792
- effects, opts.fragmentOutputLayout, undefined,
1900
+ [effect], opts.fragmentOutputLayout, undefined,
1793
1901
  {
1794
1902
  atlasizeAllTextures: atlasPool !== undefined,
1795
1903
  perEffectPerInstance: perInstanceByEffect,
1796
1904
  },
1797
1905
  );
1798
- const compiled = compileShaderFamily(schema, opts.fragmentOutputLayout);
1799
- const vsModule = device.createShaderModule({ code: compiled.vs, label: `heapScene/family/${schema.id}/vs` });
1800
- const fsModule = device.createShaderModule({ code: compiled.fs, label: `heapScene/family/${schema.id}/fs` });
1801
- const fieldsForEffect = new Map<Effect, Set<string>>();
1802
- for (const e of schema.effects) {
1803
- const s = schema.perEffectSchema.get(e)!;
1804
- const fields = new Set<string>();
1805
- for (const a of s.attributes) fields.add(a.name);
1806
- for (const u of s.uniforms) fields.add(u.name);
1807
- for (const t of s.textures) fields.add(t.name);
1808
- fieldsForEffect.set(e, fields);
1809
- }
1810
- 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
+ };
1811
1924
  }
1812
1925
 
1813
1926
  function buildFamilyFromSpecs(specs: readonly HeapDrawSpec[]): void {
@@ -1821,45 +1934,42 @@ export function buildHeapScene(
1821
1934
  // layout to address per-instance attribute reads via `instId`
1822
1935
  // instead of `vertex_index` — without this, instanced effects
1823
1936
  // produce broken geometry.
1824
- 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>();
1825
1940
  const unique: Effect[] = [];
1826
- const perInstanceByEffect = new Map<Effect, {
1941
+ const perInstanceByEffectId = new Map<string, {
1827
1942
  attributes: Set<string>;
1828
1943
  uniforms: Set<string>;
1829
1944
  }>();
1830
1945
  for (const spec of specs) {
1831
1946
  const e = spec.effect;
1832
- let entry = perInstanceByEffect.get(e);
1947
+ let entry = perInstanceByEffectId.get(e.id);
1833
1948
  if (entry === undefined) {
1834
1949
  entry = { attributes: new Set<string>(), uniforms: new Set<string>() };
1835
- perInstanceByEffect.set(e, entry);
1950
+ perInstanceByEffectId.set(e.id, entry);
1836
1951
  }
1837
1952
  if (spec.instanceAttributes !== undefined) {
1838
1953
  for (const name of Object.keys(spec.instanceAttributes)) entry.attributes.add(name);
1839
1954
  }
1840
- if (!seen.has(e)) { seen.add(e); unique.push(e); }
1955
+ if (!seenIds.has(e.id)) { seenIds.add(e.id); unique.push(e); }
1841
1956
  }
1842
- if (enableFamilyMerge) {
1843
- const merged = compileFamilyFor(unique, perInstanceByEffect);
1844
- for (const e of unique) familyByEffect.set(e, merged);
1845
- } else {
1846
- // Default: one family per effect — no shared layoutId switch.
1847
- // Per-effect bucketing is at-or-better than merged on tested
1848
- // workloads; merge stays opt-in pending trace-based v2.
1849
- for (const e of unique) {
1850
- const perI = perInstanceByEffect.get(e);
1851
- const singleMap = new Map<Effect, { attributes: Set<string>; uniforms: Set<string> }>();
1852
- if (perI !== undefined) singleMap.set(e, perI);
1853
- familyByEffect.set(e, compileFamilyFor([e], singleMap));
1854
- }
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));
1855
1965
  }
1856
1966
  familyBuilt = true;
1857
1967
  }
1858
1968
 
1859
1969
  function familyFor(effect: Effect): FamilyState {
1860
- const f = familyByEffect.get(effect);
1970
+ const f = familyByEffectId.get(effect.id);
1861
1971
  if (f === undefined) {
1862
- const known = [...familyByEffect.keys()].map(e => e.id).join(",");
1972
+ const known = [...familyByEffectId.keys()].join(",");
1863
1973
  throw new Error(
1864
1974
  `heapScene: family is frozen; effect ${effect.id} not in {${known}}; ` +
1865
1975
  `reactive family rebuild is v2`,
@@ -1885,13 +1995,89 @@ export function buildHeapScene(
1885
1995
  if (id === undefined) { id = `tex#${texCounter++}`; textureIds.set(t, id); }
1886
1996
  return id;
1887
1997
  };
1888
- const psIds = new WeakMap<PipelineState, string>();
1889
- 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>();
1890
2060
  const psIdOf = (ps: PipelineState | undefined): string => {
1891
2061
  if (ps === undefined) return "ps#default";
1892
- let id = psIds.get(ps);
1893
- if (id === undefined) { id = `ps#${psCounter++}`; psIds.set(ps, id); }
1894
- 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;
1895
2081
  };
1896
2082
 
1897
2083
  /** Resolved (forced) snapshot of the user's PipelineState. */
@@ -2102,15 +2288,15 @@ export function buildHeapScene(
2102
2288
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
2103
2289
  const vsModule = fam.vsModule;
2104
2290
  const fsModule = fam.fsModule;
2105
- const vsEntry = "family_vs_main";
2106
- const fsEntry = "family_fs_main";
2291
+ const vsEntry = fam.vsEntryName;
2292
+ const fsEntry = fam.fsEntryName;
2107
2293
  const { pipelineLayout } = getBgl(layout, isAtlasBucket);
2108
2294
 
2109
2295
  const pipeline = device.createRenderPipeline({
2110
2296
  label: `heapScene/${bk}/pipeline`,
2111
2297
  layout: pipelineLayout,
2112
2298
  vertex: { module: vsModule, entryPoint: vsEntry, buffers: [] },
2113
- fragment: { module: fsModule, entryPoint: fsEntry, targets: [{ format: colorFormat }] },
2299
+ fragment: { module: fsModule, entryPoint: fsEntry, targets: colorTargets },
2114
2300
  primitive: { topology: ps.topology, cullMode: ps.cullMode, frontFace: ps.frontFace },
2115
2301
  ...(depthFormat !== undefined && ps.depth !== undefined
2116
2302
  ? { depthStencil: { format: depthFormat, depthWriteEnabled: ps.depth.write, depthCompare: ps.depth.compare } }
@@ -2142,7 +2328,7 @@ export function buildHeapScene(
2142
2328
  localPosRefs: [], localNorRefs: [],
2143
2329
  localEntries: [], localToDrawId: [],
2144
2330
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
2145
- drawSlots: [], dirty: new Set<number>(),
2331
+ drawSlots: new Set<number>(), dirty: new Set<number>(),
2146
2332
  drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
2147
2333
  recordCount: 0, slotToRecord: [], recordToSlot: [],
2148
2334
  totalEmitEstimate: 0,
@@ -2150,6 +2336,7 @@ export function buildHeapScene(
2150
2336
  isAtlasBucket,
2151
2337
  localAtlasReleases: [],
2152
2338
  localAtlasTextures: [],
2339
+ localAtlasArrIdx: [],
2153
2340
  };
2154
2341
  {
2155
2342
  const dtBuf = new GrowBuffer(
@@ -2371,7 +2558,21 @@ export function buildHeapScene(
2371
2558
  Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
2372
2559
 
2373
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.
2374
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 {
2375
2576
  const drawId = nextDrawId++;
2376
2577
  // Family-merge (slice 3c): build the family lazily from this
2377
2578
  // single spec when no batched lazy-build occurred earlier (e.g.
@@ -2388,7 +2589,7 @@ export function buildHeapScene(
2388
2589
  : new Set<string>();
2389
2590
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState);
2390
2591
  const fam = familyFor(spec.effect);
2391
- const effectFields = fam.fieldsForEffect.get(spec.effect)!;
2592
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
2392
2593
 
2393
2594
  // Indices live in their own INDEX-usage buffer (WebGPU constraint).
2394
2595
  // Aval-keyed: 19K instanced clones of the same mesh share one
@@ -2410,7 +2611,8 @@ export function buildHeapScene(
2410
2611
  // emerges from aval identity either way.
2411
2612
  const perDrawAvals: aval<unknown>[] = [];
2412
2613
  const perDrawRefs = new Map<string, number>();
2413
- sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2614
+ {
2615
+ const tok = outerTok;
2414
2616
  for (const f of bucket.layout.drawHeaderFields) {
2415
2617
  // Atlas-variant texture bindings carry inline values rather than
2416
2618
  // pool refs; packAtlasTextureFields fills them after this loop.
@@ -2482,7 +2684,7 @@ export function buildHeapScene(
2482
2684
  perDrawRefs.set(f.name, ref);
2483
2685
  perDrawAvals.push(av);
2484
2686
  }
2485
- });
2687
+ }
2486
2688
 
2487
2689
  bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
2488
2690
  bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
@@ -2490,7 +2692,7 @@ export function buildHeapScene(
2490
2692
  indexCount: idxAlloc.count, firstIndex: idxAlloc.firstIndex, instanceCount,
2491
2693
  };
2492
2694
  bucket.localToDrawId[localSlot] = drawId;
2493
- bucket.drawSlots.push(localSlot);
2695
+ bucket.drawSlots.add(localSlot);
2494
2696
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
2495
2697
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
2496
2698
  const layoutId = fam.schema.layoutIdOf.get(spec.effect)!;
@@ -2506,15 +2708,14 @@ export function buildHeapScene(
2506
2708
  const sourceAval = spec.textures.sourceAval;
2507
2709
  const repackFn = spec.textures.repack;
2508
2710
  if (sourceAval !== undefined && repackFn !== undefined) {
2509
- sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2510
- // touch — registers sceneObj as an output of sourceAval.
2511
- sourceAval.getValue(tok);
2512
- });
2711
+ // touch — registers sceneObj as an output of sourceAval.
2712
+ sourceAval.getValue(outerTok);
2513
2713
  let arr = atlasAvalRefs.get(sourceAval);
2514
2714
  if (arr === undefined) {
2515
2715
  arr = [];
2516
2716
  atlasAvalRefs.set(sourceAval, arr);
2517
2717
  }
2718
+ bucket.localAtlasArrIdx[localSlot] = arr.length;
2518
2719
  arr.push({ bucket, localSlot, repack: repackFn, sampler: spec.textures.sampler });
2519
2720
  }
2520
2721
  }
@@ -2657,11 +2858,18 @@ export function buildHeapScene(
2657
2858
  const atlasTex = bucket.localAtlasTextures[localSlot];
2658
2859
  if (atlasTex !== undefined) {
2659
2860
  const sourceAval = atlasTex.sourceAval;
2660
- if (sourceAval !== undefined) {
2861
+ const i = bucket.localAtlasArrIdx[localSlot];
2862
+ if (sourceAval !== undefined && i !== undefined) {
2661
2863
  const arr = atlasAvalRefs.get(sourceAval);
2662
2864
  if (arr !== undefined) {
2663
- const i = arr.findIndex(r => r.bucket === bucket && r.localSlot === localSlot);
2664
- 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();
2665
2873
  if (arr.length === 0) {
2666
2874
  atlasAvalRefs.delete(sourceAval);
2667
2875
  atlasAvalDirty.delete(sourceAval);
@@ -2671,6 +2879,7 @@ export function buildHeapScene(
2671
2879
  }
2672
2880
  bucket.localAtlasReleases[localSlot] = undefined;
2673
2881
  bucket.localAtlasTextures[localSlot] = undefined;
2882
+ bucket.localAtlasArrIdx[localSlot] = undefined;
2674
2883
 
2675
2884
  bucket.localPerDrawAvals[localSlot] = undefined;
2676
2885
  bucket.localPerDrawRefs[localSlot] = undefined;
@@ -2679,8 +2888,7 @@ export function buildHeapScene(
2679
2888
  bucket.localNorRefs[localSlot] = undefined;
2680
2889
  bucket.localEntries[localSlot] = undefined;
2681
2890
  bucket.localToDrawId[localSlot] = undefined;
2682
- const idx = bucket.drawSlots.indexOf(localSlot);
2683
- if (idx >= 0) bucket.drawSlots.splice(idx, 1);
2891
+ bucket.drawSlots.delete(localSlot);
2684
2892
  bucket.dirty.delete(localSlot);
2685
2893
  bucket.drawHeap.release(localSlot);
2686
2894
 
@@ -2700,7 +2908,11 @@ export function buildHeapScene(
2700
2908
  const delta = asetReader.getChanges(tok);
2701
2909
  delta.iter((op: { value: HeapDrawSpec; count: number }) => {
2702
2910
  if (op.count > 0) {
2703
- 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);
2704
2916
  specToDrawId.set(op.value, id);
2705
2917
  } else {
2706
2918
  const id = specToDrawId.get(op.value);
@@ -2719,7 +2931,9 @@ export function buildHeapScene(
2719
2931
  // array is empty (family will build lazily when an addDraw fires).
2720
2932
  const arr = initialDraws as readonly HeapDrawSpec[];
2721
2933
  if (arr.length > 0) buildFamilyFromSpecs(arr);
2722
- for (const d of arr) addDraw(d);
2934
+ sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2935
+ for (const d of arr) addDrawImpl(d, tok);
2936
+ });
2723
2937
  } else {
2724
2938
  asetReader = (initialDraws as aset<HeapDrawSpec>).getReader();
2725
2939
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
@@ -2739,7 +2953,7 @@ export function buildHeapScene(
2739
2953
  if (adds.length > 0) buildFamilyFromSpecs(adds);
2740
2954
  for (const op of remaining) {
2741
2955
  if (op.count > 0) {
2742
- const id = addDraw(op.value);
2956
+ const id = addDrawImpl(op.value, tok);
2743
2957
  specToDrawId.set(op.value, id);
2744
2958
  } else {
2745
2959
  const id = specToDrawId.get(op.value);