@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
@@ -42,7 +42,7 @@
42
42
  // package adds the texture (binding 4) + sampler (binding 5) to that
43
43
  // group's bind-group layout; the user's FS WGSL declares them.
44
44
  import { Trafo3d, V3d, V4f, M44d } from "@aardworx/wombat.base";
45
- import { AVal, AdaptiveObject, AdaptiveToken } from "@aardworx/wombat.adaptive";
45
+ import { AVal, AdaptiveObject, AdaptiveToken, HashTable } from "@aardworx/wombat.adaptive";
46
46
  import { buildBucketLayout, compileHeapEffect, } from "./heapEffect.js";
47
47
  import { compileHeapEffectIR } from "./heapEffectIR.js";
48
48
  import { buildShaderFamily, compileShaderFamily, } from "./heapShaderFamily.js";
@@ -115,21 +115,43 @@ const PACKER_F32 = {
115
115
  dataBytes: 4, typeId: 0,
116
116
  pack: (val, dst, off) => { dst[off] = val; },
117
117
  };
118
- // u32 lives in the arena as 4 bytes too — we write through a transient
119
- // Uint32 view over the same backing buffer so the integer bit-pattern is
120
- // preserved (Float32Array assignment would lossily truncate `(u32) -> f32`).
121
- const PACKER_U32 = {
122
- dataBytes: 4, typeId: 0,
123
- pack: (val, dst, off) => {
124
- new Uint32Array(dst.buffer, dst.byteOffset + off * 4, 1)[0] = val >>> 0;
125
- },
126
- };
127
- const PACKER_I32 = {
128
- dataBytes: 4, typeId: 0,
129
- pack: (val, dst, off) => {
130
- new Int32Array(dst.buffer, dst.byteOffset + off * 4, 1)[0] = val | 0;
131
- },
132
- };
118
+ // Integer scalars / vectors. The arena is fronted by a `Float32Array`,
119
+ // so writing raw bits has to go through a same-buffer Uint32/Int32 view
120
+ // to avoid the lossy `i32 f32` coercion you'd get from a direct
121
+ // `dst[off] = ...` assignment.
122
+ function makeIntPacker(ctor, dim) {
123
+ const bytes = dim * 4;
124
+ if (dim === 1) {
125
+ return {
126
+ dataBytes: bytes, typeId: 0,
127
+ pack: (val, dst, off) => {
128
+ new ctor(dst.buffer, dst.byteOffset + off * 4, 1)[0] = val;
129
+ },
130
+ };
131
+ }
132
+ // Vector: accept {x,y,z,w} components.
133
+ return {
134
+ dataBytes: bytes, typeId: 0,
135
+ pack: (val, dst, off) => {
136
+ const view = new ctor(dst.buffer, dst.byteOffset + off * 4, dim);
137
+ const v = val;
138
+ view[0] = v.x;
139
+ view[1] = v.y;
140
+ if (dim >= 3)
141
+ view[2] = v.z;
142
+ if (dim >= 4)
143
+ view[3] = v.w;
144
+ },
145
+ };
146
+ }
147
+ const PACKER_U32 = makeIntPacker(Uint32Array, 1);
148
+ const PACKER_UVEC2 = makeIntPacker(Uint32Array, 2);
149
+ const PACKER_UVEC3 = makeIntPacker(Uint32Array, 3);
150
+ const PACKER_UVEC4 = makeIntPacker(Uint32Array, 4);
151
+ const PACKER_I32 = makeIntPacker(Int32Array, 1);
152
+ const PACKER_IVEC2 = makeIntPacker(Int32Array, 2);
153
+ const PACKER_IVEC3 = makeIntPacker(Int32Array, 3);
154
+ const PACKER_IVEC4 = makeIntPacker(Int32Array, 4);
133
155
  function packerForWgslType(wgslType) {
134
156
  switch (wgslType) {
135
157
  case "mat4x4<f32>": return PACKER_MAT4;
@@ -138,7 +160,13 @@ function packerForWgslType(wgslType) {
138
160
  case "vec2<f32>": return PACKER_VEC2;
139
161
  case "f32": return PACKER_F32;
140
162
  case "u32": return PACKER_U32;
163
+ case "vec2<u32>": return PACKER_UVEC2;
164
+ case "vec3<u32>": return PACKER_UVEC3;
165
+ case "vec4<u32>": return PACKER_UVEC4;
141
166
  case "i32": return PACKER_I32;
167
+ case "vec2<i32>": return PACKER_IVEC2;
168
+ case "vec3<i32>": return PACKER_IVEC3;
169
+ case "vec4<i32>": return PACKER_IVEC4;
142
170
  default:
143
171
  throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
144
172
  }
@@ -221,6 +249,17 @@ class GrowBuffer {
221
249
  * Same code path either way.
222
250
  */
223
251
  class UniformPool {
252
+ // Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
253
+ // keys are overwhelmingly reactive `cval`s (per-object trafos,
254
+ // colours, …) and the hot path is `acquire`/`release` ~once per
255
+ // drawHeader field per RO. A content-keyed `HashTable` would buy
256
+ // nothing here — reactive avals never compare content-equal — and
257
+ // would cost: reactive avals have no `equals`/`getHashCode`, so a
258
+ // `HashTable` falls back to a WeakMap-counter identity hash per
259
+ // lookup, measurably slower than `Map`'s native hashing. (Constant-
260
+ // aval dedup matters where keys are *texture* avals — there the
261
+ // `AtlasPool` is content-keyed; constant avals there carry a cached
262
+ // hash and a fast `equals`.)
224
263
  byAval = new Map();
225
264
  has(aval) { return this.byAval.has(aval); }
226
265
  entry(aval) { return this.byAval.get(aval); }
@@ -471,22 +510,58 @@ class AttributeArena {
471
510
  }
472
511
  release(ref, dataBytes) {
473
512
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
474
- this.freeList.push({ off: ref, size: allocBytes });
475
- // Coalesce adjacent free entries for cleanliness.
476
- this.freeList.sort((a, b) => a.off - b.off);
477
- for (let i = 0; i < this.freeList.length - 1;) {
478
- const a = this.freeList[i], b = this.freeList[i + 1];
479
- if (a.off + a.size === b.off) {
480
- a.size += b.size;
481
- this.freeList.splice(i + 1, 1);
482
- }
483
- else
484
- i++;
485
- }
513
+ insertSortedFreeBlock(this.freeList, ref, allocBytes);
486
514
  }
487
515
  onResize(cb) { return this.buf.onResize(cb); }
488
516
  destroy() { this.buf.destroy(); }
489
517
  }
518
+ /**
519
+ * Insert `{off, size}` into a free-list kept sorted by `off`, then
520
+ * coalesce with the two immediate neighbours.
521
+ *
522
+ * The list invariant — sorted, non-overlapping, never-adjacent — is
523
+ * preserved across allocs (which take from the front or split a
524
+ * block) and releases (this function). The previous implementation
525
+ * did `push + Array.prototype.sort + linear coalesce scan`, which is
526
+ * O(N log N) per release. Under a 500-RO bulk-remove the sort
527
+ * dominated `removeDraw` (~41 ms of self-time in the heap-demo-sg
528
+ * toggle profile). Binary-search insert + 2-neighbour merge collapses
529
+ * that to O(log N + N-shift), and is principled — the sort never
530
+ * actually mattered since we already had the sorted prefix as an
531
+ * invariant.
532
+ */
533
+ function insertSortedFreeBlock(freeList, off, size) {
534
+ // Binary-search for the insertion index (first entry whose off > new).
535
+ let lo = 0, hi = freeList.length;
536
+ while (lo < hi) {
537
+ const mid = (lo + hi) >>> 1;
538
+ if (freeList[mid].off <= off)
539
+ lo = mid + 1;
540
+ else
541
+ hi = mid;
542
+ }
543
+ // lo is the index where the new entry would be inserted.
544
+ // Try merging with the predecessor first; if successful, the merged
545
+ // block may now be adjacent to its (former) successor too.
546
+ const prev = lo > 0 ? freeList[lo - 1] : undefined;
547
+ if (prev !== undefined && prev.off + prev.size === off) {
548
+ prev.size += size;
549
+ // Check forward-merge with what was freeList[lo].
550
+ const next = freeList[lo];
551
+ if (next !== undefined && prev.off + prev.size === next.off) {
552
+ prev.size += next.size;
553
+ freeList.splice(lo, 1);
554
+ }
555
+ return;
556
+ }
557
+ const next = freeList[lo];
558
+ if (next !== undefined && off + size === next.off) {
559
+ next.off = off;
560
+ next.size += size;
561
+ return;
562
+ }
563
+ freeList.splice(lo, 0, { off, size });
564
+ }
490
565
  /**
491
566
  * Element-bump allocator over an index GrowBuffer (units = u32). Each
492
567
  * draw's index range is allocated as one block; on release the block
@@ -550,17 +625,7 @@ class IndexAllocator {
550
625
  return off;
551
626
  }
552
627
  release(off, elements) {
553
- this.freeList.push({ off, size: elements });
554
- this.freeList.sort((a, b) => a.off - b.off);
555
- for (let i = 0; i < this.freeList.length - 1;) {
556
- const a = this.freeList[i], b = this.freeList[i + 1];
557
- if (a.off + a.size === b.off) {
558
- a.size += b.size;
559
- this.freeList.splice(i + 1, 1);
560
- }
561
- else
562
- i++;
563
- }
628
+ insertSortedFreeBlock(this.freeList, off, elements);
564
629
  }
565
630
  onResize(cb) { return this.buf.onResize(cb); }
566
631
  destroy() { this.buf.destroy(); }
@@ -784,6 +849,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
784
849
  throw new Error("buildHeapScene: framebuffer signature has no color attachment");
785
850
  }
786
851
  const colorFormat = sig.colors.tryFind(colorAttachmentName);
852
+ // All color targets, ordered by the signature's `colorNames`. Each
853
+ // entry's format flows into the pipeline's `fragment.targets[i].format`.
854
+ // The fragmentOutputLayout's `locations` map tells the WGSL emit which
855
+ // output @location maps to which color attachment; this array is the
856
+ // matching pipeline-side ordering.
857
+ const colorTargets = [];
858
+ for (const name of sig.colorNames) {
859
+ const fmt = sig.colors.tryFind(name);
860
+ if (fmt === undefined) {
861
+ throw new Error(`buildHeapScene: signature missing format for color '${name}'`);
862
+ }
863
+ colorTargets.push({ format: fmt });
864
+ }
787
865
  const depthFormat = sig.depthStencil?.format;
788
866
  // ─── Global arena (uniform/attribute data + index buffer) ────────
789
867
  // Initial capacities are just hints; both buffers pow2-grow on
@@ -946,7 +1024,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
946
1024
  // buckets) — `atlasAvalRefs` tracks all (bucket, localSlot) pairs so
947
1025
  // one swap rewrites N drawHeaders in one drain.
948
1026
  const atlasAvalDirty = new Set();
949
- const atlasAvalRefs = new Map();
1027
+ // Content-keyed (`HashTable`, not a JS `Map`) so distinct
1028
+ // `AVal.constant(tex)` wrappers sharing the same texture collapse to
1029
+ // one `(bucket, slot)` ref-list — matching `AtlasPool.entriesByAval`,
1030
+ // which is now content-keyed too. Reactive avals key by reference.
1031
+ const atlasAvalRefs = new HashTable();
950
1032
  /**
951
1033
  * Per-draw bucket dirty (rare in steady state — only fires when
952
1034
  * something forces a header rewrite, e.g. a drawHeap GrowBuffer
@@ -1147,30 +1229,59 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1147
1229
  // ─── Buckets ──────────────────────────────────────────────────────
1148
1230
  const buckets = [];
1149
1231
  const bucketByKey = new Map();
1150
- const familyByEffect = new Map();
1232
+ // Keyed by `effect.id` (content hash), NOT object identity. Two
1233
+ // Effect objects with identical content (e.g. produced by separate
1234
+ // calls to `effect(...)` or the pickChain composer) share one
1235
+ // FamilyState. This is the right correctness/perf knob: building a
1236
+ // family by content means an upstream caller that legitimately
1237
+ // produces different-but-identical Effect objects per leaf still
1238
+ // reuses one pipeline + one bucket.
1239
+ const familyByEffectId = new Map();
1151
1240
  let familyBuilt = false;
1152
1241
  const enableFamilyMerge = opts.enableFamilyMerge === true;
1153
1242
  function compileFamilyFor(effects, perInstanceByEffect) {
1154
- const schema = buildShaderFamily(effects, opts.fragmentOutputLayout, undefined, {
1243
+ // Family-merge (multi-effect dispatch via layoutId switch) has been
1244
+ // disabled — it was a perf illusion. Every effect compiles to its
1245
+ // own standalone pipeline; the per-bucket WGSL comes from
1246
+ // `compileHeapEffectIR(effect, layout, opts, "standalone")` and is
1247
+ // used directly as the bucket's shader module (no wrapper, no
1248
+ // dispatch). The schema is still derived from the same
1249
+ // `buildShaderFamily` path so the runtime's drawHeader packer,
1250
+ // bind-group layout, etc. don't change shape — `__layoutId`
1251
+ // remains a u32 slot that's written as 0 per RO (harmless).
1252
+ //
1253
+ // `heapShaderFamily.compileShaderFamily` is kept around but
1254
+ // unused; the function and its tests stay disabled.
1255
+ if (effects.length !== 1) {
1256
+ throw new Error("heapScene: multi-effect family-merge disabled (was a perf illusion). " +
1257
+ "Build one bucket per effect.");
1258
+ }
1259
+ const effect = effects[0];
1260
+ const schema = buildShaderFamily([effect], opts.fragmentOutputLayout, undefined, {
1155
1261
  atlasizeAllTextures: atlasPool !== undefined,
1156
1262
  perEffectPerInstance: perInstanceByEffect,
1157
1263
  });
1158
- const compiled = compileShaderFamily(schema, opts.fragmentOutputLayout);
1159
- const vsModule = device.createShaderModule({ code: compiled.vs, label: `heapScene/family/${schema.id}/vs` });
1160
- const fsModule = device.createShaderModule({ code: compiled.fs, label: `heapScene/family/${schema.id}/fs` });
1264
+ const compileOpts = opts.fragmentOutputLayout !== undefined
1265
+ ? { target: "wgsl", fragmentOutputLayout: opts.fragmentOutputLayout }
1266
+ : { target: "wgsl" };
1267
+ const ir = compileHeapEffectIR(effect, schema.drawHeaderUnion, compileOpts, "standalone");
1268
+ const vsModule = device.createShaderModule({ code: ir.vs, label: `heapScene/standalone/${schema.id}/vs` });
1269
+ const fsModule = device.createShaderModule({ code: ir.fs, label: `heapScene/standalone/${schema.id}/fs` });
1161
1270
  const fieldsForEffect = new Map();
1162
- for (const e of schema.effects) {
1163
- const s = schema.perEffectSchema.get(e);
1164
- const fields = new Set();
1165
- for (const a of s.attributes)
1166
- fields.add(a.name);
1167
- for (const u of s.uniforms)
1168
- fields.add(u.name);
1169
- for (const t of s.textures)
1170
- fields.add(t.name);
1171
- fieldsForEffect.set(e, fields);
1172
- }
1173
- return { schema, vsModule, fsModule, fieldsForEffect };
1271
+ const s = schema.perEffectSchema.get(effect);
1272
+ const fields = new Set();
1273
+ for (const a of s.attributes)
1274
+ fields.add(a.name);
1275
+ for (const u of s.uniforms)
1276
+ fields.add(u.name);
1277
+ for (const t of s.textures)
1278
+ fields.add(t.name);
1279
+ fieldsForEffect.set(effect.id, fields);
1280
+ return {
1281
+ schema, vsModule, fsModule, fieldsForEffect,
1282
+ vsEntryName: ir.vsEntry,
1283
+ fsEntryName: ir.fsEntry,
1284
+ };
1174
1285
  }
1175
1286
  function buildFamilyFromSpecs(specs) {
1176
1287
  if (familyBuilt)
@@ -1184,48 +1295,43 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1184
1295
  // layout to address per-instance attribute reads via `instId`
1185
1296
  // instead of `vertex_index` — without this, instanced effects
1186
1297
  // produce broken geometry.
1187
- const seen = new Set();
1298
+ // De-dupe by effect.id so two Effects with identical content but
1299
+ // distinct object identities collapse to one family + one bucket.
1300
+ const seenIds = new Set();
1188
1301
  const unique = [];
1189
- const perInstanceByEffect = new Map();
1302
+ const perInstanceByEffectId = new Map();
1190
1303
  for (const spec of specs) {
1191
1304
  const e = spec.effect;
1192
- let entry = perInstanceByEffect.get(e);
1305
+ let entry = perInstanceByEffectId.get(e.id);
1193
1306
  if (entry === undefined) {
1194
1307
  entry = { attributes: new Set(), uniforms: new Set() };
1195
- perInstanceByEffect.set(e, entry);
1308
+ perInstanceByEffectId.set(e.id, entry);
1196
1309
  }
1197
1310
  if (spec.instanceAttributes !== undefined) {
1198
1311
  for (const name of Object.keys(spec.instanceAttributes))
1199
1312
  entry.attributes.add(name);
1200
1313
  }
1201
- if (!seen.has(e)) {
1202
- seen.add(e);
1314
+ if (!seenIds.has(e.id)) {
1315
+ seenIds.add(e.id);
1203
1316
  unique.push(e);
1204
1317
  }
1205
1318
  }
1206
- if (enableFamilyMerge) {
1207
- const merged = compileFamilyFor(unique, perInstanceByEffect);
1208
- for (const e of unique)
1209
- familyByEffect.set(e, merged);
1210
- }
1211
- else {
1212
- // Default: one family per effect — no shared layoutId switch.
1213
- // Per-effect bucketing is at-or-better than merged on tested
1214
- // workloads; merge stays opt-in pending trace-based v2.
1215
- for (const e of unique) {
1216
- const perI = perInstanceByEffect.get(e);
1217
- const singleMap = new Map();
1218
- if (perI !== undefined)
1219
- singleMap.set(e, perI);
1220
- familyByEffect.set(e, compileFamilyFor([e], singleMap));
1221
- }
1319
+ // Family-merge disabled: always one bucket per effect.
1320
+ // `enableFamilyMerge` is ignored.
1321
+ void enableFamilyMerge;
1322
+ for (const e of unique) {
1323
+ const perI = perInstanceByEffectId.get(e.id);
1324
+ const singleMap = new Map();
1325
+ if (perI !== undefined)
1326
+ singleMap.set(e, perI);
1327
+ familyByEffectId.set(e.id, compileFamilyFor([e], singleMap));
1222
1328
  }
1223
1329
  familyBuilt = true;
1224
1330
  }
1225
1331
  function familyFor(effect) {
1226
- const f = familyByEffect.get(effect);
1332
+ const f = familyByEffectId.get(effect.id);
1227
1333
  if (f === undefined) {
1228
- const known = [...familyByEffect.keys()].map(e => e.id).join(",");
1334
+ const known = [...familyByEffectId.keys()].join(",");
1229
1335
  throw new Error(`heapScene: family is frozen; effect ${effect.id} not in {${known}}; ` +
1230
1336
  `reactive family rebuild is v2`);
1231
1337
  }
@@ -1251,17 +1357,102 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1251
1357
  }
1252
1358
  return id;
1253
1359
  };
1254
- const psIds = new WeakMap();
1255
- let psCounter = 0;
1360
+ // PipelineState content key. Hashes by the identities of the inner
1361
+ // aval references rather than the wrapper object itself: callers
1362
+ // (notably `wombat.dom`'s `derivePipelineState`) construct a fresh
1363
+ // PipelineState object per leaf even when every contributing aval
1364
+ // is shared — without a content-aware key, every leaf gets its own
1365
+ // bucket and the heap path's drawIndirect coalescing collapses to
1366
+ // one record per bucket. With this key, two leaves that pulled the
1367
+ // same `state.mode` / `state.cullMode` / `state.depthTest` / … avals
1368
+ // bucket together.
1369
+ //
1370
+ // For CONSTANT avals (`isConstant === true`) we key by *value* — two
1371
+ // distinct `AVal.constant(0)` objects produced at different call
1372
+ // sites must collapse to the same bucket. Reactive avals fall back
1373
+ // to reference identity (the right semantic — value can tick).
1374
+ const avalIds = new WeakMap();
1375
+ const valueIds = new Map();
1376
+ let avalCounter = 0;
1377
+ const avalIdOf = (av) => {
1378
+ if (av === undefined)
1379
+ return "_";
1380
+ // Reactive aval — key by reference. (`isConstant` is on the
1381
+ // public IAdaptive surface; guard via runtime check so off-spec
1382
+ // duck-types don't blow up.)
1383
+ const isConst = av.isConstant === true;
1384
+ if (!isConst) {
1385
+ let id = avalIds.get(av);
1386
+ if (id === undefined) {
1387
+ id = avalCounter++;
1388
+ avalIds.set(av, id);
1389
+ }
1390
+ return `a${id}`;
1391
+ }
1392
+ // Constant: key by value. Force is safe (constant: no upstream
1393
+ // dep) and only runs once per distinct aval-object thanks to the
1394
+ // outer avalIds cache below.
1395
+ let id = avalIds.get(av);
1396
+ if (id !== undefined)
1397
+ return `c${id}`;
1398
+ // Constant avals ignore the token; use AdaptiveToken.top to satisfy
1399
+ // the type and traverse a no-op evaluation.
1400
+ const v = av.getValue(AdaptiveToken.top);
1401
+ // Value-typed (`equals` + `getHashCode`)? Try to intern by hash
1402
+ // bucket + equals so two distinct AVal.constant(M44d.identity)
1403
+ // collapse. Falls back to a per-value string key otherwise.
1404
+ let vKey;
1405
+ if (v !== null && typeof v === "object" &&
1406
+ typeof v.getHashCode === "function" &&
1407
+ typeof v.equals === "function") {
1408
+ const hc = v.getHashCode() | 0;
1409
+ vKey = `hv:${hc}`;
1410
+ }
1411
+ else if (v === null || typeof v !== "object") {
1412
+ vKey = `pv:${typeof v}:${String(v)}`;
1413
+ }
1414
+ else {
1415
+ // Plain object — fall back to reference identity (matches the
1416
+ // memo runtime's behaviour for unhashable objects).
1417
+ let oid = avalIds.get(v);
1418
+ if (oid === undefined) {
1419
+ oid = avalCounter++;
1420
+ avalIds.set(v, oid);
1421
+ }
1422
+ vKey = `ov:${oid}`;
1423
+ }
1424
+ let vid = valueIds.get(vKey);
1425
+ if (vid === undefined) {
1426
+ vid = avalCounter++;
1427
+ valueIds.set(vKey, vid);
1428
+ }
1429
+ avalIds.set(av, vid);
1430
+ return `c${vid}`;
1431
+ };
1432
+ const psContentIds = new WeakMap();
1256
1433
  const psIdOf = (ps) => {
1257
1434
  if (ps === undefined)
1258
1435
  return "ps#default";
1259
- let id = psIds.get(ps);
1260
- if (id === undefined) {
1261
- id = `ps#${psCounter++}`;
1262
- psIds.set(ps, id);
1263
- }
1264
- return id;
1436
+ const cached = psContentIds.get(ps);
1437
+ if (cached !== undefined)
1438
+ return cached;
1439
+ const r = ps.rasterizer;
1440
+ const parts = [
1441
+ avalIdOf(r.topology),
1442
+ avalIdOf(r.cullMode),
1443
+ avalIdOf(r.frontFace),
1444
+ avalIdOf(r.depthBias),
1445
+ ps.depth !== undefined
1446
+ ? `d:${avalIdOf(ps.depth.write)}:${avalIdOf(ps.depth.compare)}:${avalIdOf(ps.depth.clamp)}`
1447
+ : "d:_",
1448
+ ps.stencil !== undefined ? "s:1" : "s:_",
1449
+ avalIdOf(ps.blends),
1450
+ avalIdOf(ps.alphaToCoverage),
1451
+ avalIdOf(ps.blendConstant),
1452
+ ];
1453
+ const key = `ps#${parts.join("|")}`;
1454
+ psContentIds.set(ps, key);
1455
+ return key;
1265
1456
  };
1266
1457
  function resolvePipelineState(ps) {
1267
1458
  if (ps === undefined) {
@@ -1446,14 +1637,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1446
1637
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
1447
1638
  const vsModule = fam.vsModule;
1448
1639
  const fsModule = fam.fsModule;
1449
- const vsEntry = "family_vs_main";
1450
- const fsEntry = "family_fs_main";
1640
+ const vsEntry = fam.vsEntryName;
1641
+ const fsEntry = fam.fsEntryName;
1451
1642
  const { pipelineLayout } = getBgl(layout, isAtlasBucket);
1452
1643
  const pipeline = device.createRenderPipeline({
1453
1644
  label: `heapScene/${bk}/pipeline`,
1454
1645
  layout: pipelineLayout,
1455
1646
  vertex: { module: vsModule, entryPoint: vsEntry, buffers: [] },
1456
- fragment: { module: fsModule, entryPoint: fsEntry, targets: [{ format: colorFormat }] },
1647
+ fragment: { module: fsModule, entryPoint: fsEntry, targets: colorTargets },
1457
1648
  primitive: { topology: ps.topology, cullMode: ps.cullMode, frontFace: ps.frontFace },
1458
1649
  ...(depthFormat !== undefined && ps.depth !== undefined
1459
1650
  ? { depthStencil: { format: depthFormat, depthWriteEnabled: ps.depth.write, depthCompare: ps.depth.compare } }
@@ -1480,7 +1671,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1480
1671
  localPosRefs: [], localNorRefs: [],
1481
1672
  localEntries: [], localToDrawId: [],
1482
1673
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
1483
- drawSlots: [], dirty: new Set(),
1674
+ drawSlots: new Set(), dirty: new Set(),
1484
1675
  drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1485
1676
  recordCount: 0, slotToRecord: [], recordToSlot: [],
1486
1677
  totalEmitEstimate: 0,
@@ -1488,6 +1679,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1488
1679
  isAtlasBucket,
1489
1680
  localAtlasReleases: [],
1490
1681
  localAtlasTextures: [],
1682
+ localAtlasArrIdx: [],
1491
1683
  };
1492
1684
  {
1493
1685
  const dtBuf = new GrowBuffer(device, `heapScene/${bk}/drawTable`, GPUBufferUsage.STORAGE, 1024);
@@ -1681,7 +1873,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1681
1873
  };
1682
1874
  Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
1683
1875
  // ─── addDraw / removeDraw ─────────────────────────────────────────
1876
+ // Public addDraw wrapper. Establishes a sceneObj.evaluateAlways
1877
+ // scope so external callers (no batched outer eval) still register
1878
+ // sceneObj as an output of any aval the spec touches. Internal call
1879
+ // sites (drainAsetWith, batched initial-population) already run
1880
+ // inside a single outer evaluateAlways and invoke addDrawImpl
1881
+ // directly with their token — collapsing 1000× nested
1882
+ // evaluateAlways into 1× outer.
1684
1883
  function addDraw(spec) {
1884
+ let id = -1;
1885
+ sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
1886
+ id = addDrawImpl(spec, tok);
1887
+ });
1888
+ return id;
1889
+ }
1890
+ function addDrawImpl(spec, outerTok) {
1685
1891
  const drawId = nextDrawId++;
1686
1892
  // Family-merge (slice 3c): build the family lazily from this
1687
1893
  // single spec when no batched lazy-build occurred earlier (e.g.
@@ -1699,7 +1905,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1699
1905
  : new Set();
1700
1906
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState);
1701
1907
  const fam = familyFor(spec.effect);
1702
- const effectFields = fam.fieldsForEffect.get(spec.effect);
1908
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id);
1703
1909
  // Indices live in their own INDEX-usage buffer (WebGPU constraint).
1704
1910
  // Aval-keyed: 19K instanced clones of the same mesh share one
1705
1911
  // index allocation + one upload.
@@ -1718,7 +1924,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1718
1924
  // emerges from aval identity either way.
1719
1925
  const perDrawAvals = [];
1720
1926
  const perDrawRefs = new Map();
1721
- sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
1927
+ {
1928
+ const tok = outerTok;
1722
1929
  for (const f of bucket.layout.drawHeaderFields) {
1723
1930
  // Atlas-variant texture bindings carry inline values rather than
1724
1931
  // pool refs; packAtlasTextureFields fills them after this loop.
@@ -1783,14 +1990,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1783
1990
  perDrawRefs.set(f.name, ref);
1784
1991
  perDrawAvals.push(av);
1785
1992
  }
1786
- });
1993
+ }
1787
1994
  bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
1788
1995
  bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
1789
1996
  bucket.localEntries[localSlot] = {
1790
1997
  indexCount: idxAlloc.count, firstIndex: idxAlloc.firstIndex, instanceCount,
1791
1998
  };
1792
1999
  bucket.localToDrawId[localSlot] = drawId;
1793
- bucket.drawSlots.push(localSlot);
2000
+ bucket.drawSlots.add(localSlot);
1794
2001
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
1795
2002
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
1796
2003
  const layoutId = fam.schema.layoutIdOf.get(spec.effect);
@@ -1805,15 +2012,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1805
2012
  const sourceAval = spec.textures.sourceAval;
1806
2013
  const repackFn = spec.textures.repack;
1807
2014
  if (sourceAval !== undefined && repackFn !== undefined) {
1808
- sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
1809
- // touch — registers sceneObj as an output of sourceAval.
1810
- sourceAval.getValue(tok);
1811
- });
2015
+ // touch — registers sceneObj as an output of sourceAval.
2016
+ sourceAval.getValue(outerTok);
1812
2017
  let arr = atlasAvalRefs.get(sourceAval);
1813
2018
  if (arr === undefined) {
1814
2019
  arr = [];
1815
2020
  atlasAvalRefs.set(sourceAval, arr);
1816
2021
  }
2022
+ bucket.localAtlasArrIdx[localSlot] = arr.length;
1817
2023
  arr.push({ bucket, localSlot, repack: repackFn, sampler: spec.textures.sampler });
1818
2024
  }
1819
2025
  }
@@ -1963,12 +2169,18 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1963
2169
  const atlasTex = bucket.localAtlasTextures[localSlot];
1964
2170
  if (atlasTex !== undefined) {
1965
2171
  const sourceAval = atlasTex.sourceAval;
1966
- if (sourceAval !== undefined) {
2172
+ const i = bucket.localAtlasArrIdx[localSlot];
2173
+ if (sourceAval !== undefined && i !== undefined) {
1967
2174
  const arr = atlasAvalRefs.get(sourceAval);
1968
2175
  if (arr !== undefined) {
1969
- const i = arr.findIndex(r => r.bucket === bucket && r.localSlot === localSlot);
1970
- if (i >= 0)
1971
- arr.splice(i, 1);
2176
+ // Swap-pop: O(1) regardless of array length.
2177
+ const last = arr.length - 1;
2178
+ if (i !== last) {
2179
+ const moved = arr[last];
2180
+ arr[i] = moved;
2181
+ moved.bucket.localAtlasArrIdx[moved.localSlot] = i;
2182
+ }
2183
+ arr.pop();
1972
2184
  if (arr.length === 0) {
1973
2185
  atlasAvalRefs.delete(sourceAval);
1974
2186
  atlasAvalDirty.delete(sourceAval);
@@ -1978,6 +2190,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1978
2190
  }
1979
2191
  bucket.localAtlasReleases[localSlot] = undefined;
1980
2192
  bucket.localAtlasTextures[localSlot] = undefined;
2193
+ bucket.localAtlasArrIdx[localSlot] = undefined;
1981
2194
  bucket.localPerDrawAvals[localSlot] = undefined;
1982
2195
  bucket.localPerDrawRefs[localSlot] = undefined;
1983
2196
  bucket.localLayoutIds[localSlot] = undefined;
@@ -1985,9 +2198,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1985
2198
  bucket.localNorRefs[localSlot] = undefined;
1986
2199
  bucket.localEntries[localSlot] = undefined;
1987
2200
  bucket.localToDrawId[localSlot] = undefined;
1988
- const idx = bucket.drawSlots.indexOf(localSlot);
1989
- if (idx >= 0)
1990
- bucket.drawSlots.splice(idx, 1);
2201
+ bucket.drawSlots.delete(localSlot);
1991
2202
  bucket.dirty.delete(localSlot);
1992
2203
  bucket.drawHeap.release(localSlot);
1993
2204
  drawIdToBucket[drawId] = undefined;
@@ -2005,7 +2216,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2005
2216
  const delta = asetReader.getChanges(tok);
2006
2217
  delta.iter((op) => {
2007
2218
  if (op.count > 0) {
2008
- const id = addDraw(op.value);
2219
+ // Re-use the outer evaluateAlways scope's token (caller is
2220
+ // already sceneObj) — collapses N nested evaluateAlways into
2221
+ // one for a bulk add. Saves a per-RO setUnsafeEvaluationDepth
2222
+ // + outputs.add(sceneObj) round trip.
2223
+ const id = addDrawImpl(op.value, tok);
2009
2224
  specToDrawId.set(op.value, id);
2010
2225
  }
2011
2226
  else {
@@ -2025,8 +2240,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2025
2240
  const arr = initialDraws;
2026
2241
  if (arr.length > 0)
2027
2242
  buildFamilyFromSpecs(arr);
2028
- for (const d of arr)
2029
- addDraw(d);
2243
+ sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2244
+ for (const d of arr)
2245
+ addDrawImpl(d, tok);
2246
+ });
2030
2247
  }
2031
2248
  else {
2032
2249
  asetReader = initialDraws.getReader();
@@ -2049,7 +2266,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2049
2266
  buildFamilyFromSpecs(adds);
2050
2267
  for (const op of remaining) {
2051
2268
  if (op.count > 0) {
2052
- const id = addDraw(op.value);
2269
+ const id = addDrawImpl(op.value, tok);
2053
2270
  specToDrawId.set(op.value, id);
2054
2271
  }
2055
2272
  else {