@aardworx/wombat.rendering 0.19.24 → 0.19.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/core/elementType.d.ts +2 -0
  2. package/dist/core/elementType.d.ts.map +1 -1
  3. package/dist/core/elementType.js +10 -0
  4. package/dist/core/elementType.js.map +1 -1
  5. package/dist/core/provider.d.ts.map +1 -1
  6. package/dist/core/provider.js +65 -32
  7. package/dist/core/provider.js.map +1 -1
  8. package/dist/core/renderObject.d.ts +9 -0
  9. package/dist/core/renderObject.d.ts.map +1 -1
  10. package/dist/resources/preparedRenderObject.d.ts.map +1 -1
  11. package/dist/resources/preparedRenderObject.js +19 -2
  12. package/dist/resources/preparedRenderObject.js.map +1 -1
  13. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
  14. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  15. package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
  16. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  17. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
  18. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
  19. package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
  20. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
  21. package/dist/runtime/derivedUniforms/records.d.ts +15 -0
  22. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
  23. package/dist/runtime/derivedUniforms/records.js +81 -14
  24. package/dist/runtime/derivedUniforms/records.js.map +1 -1
  25. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
  26. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  27. package/dist/runtime/derivedUniforms/sceneIntegration.js +9 -0
  28. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  29. package/dist/runtime/flattenTree.d.ts.map +1 -1
  30. package/dist/runtime/flattenTree.js +46 -2
  31. package/dist/runtime/flattenTree.js.map +1 -1
  32. package/dist/runtime/heapAdapter.d.ts +3 -1
  33. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  34. package/dist/runtime/heapAdapter.js +42 -4
  35. package/dist/runtime/heapAdapter.js.map +1 -1
  36. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  37. package/dist/runtime/heapDecoder.js +8 -3
  38. package/dist/runtime/heapDecoder.js.map +1 -1
  39. package/dist/runtime/heapEffect.d.ts +14 -1
  40. package/dist/runtime/heapEffect.d.ts.map +1 -1
  41. package/dist/runtime/heapEffect.js +13 -1
  42. package/dist/runtime/heapEffect.js.map +1 -1
  43. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  44. package/dist/runtime/heapEffectIR.js +18 -9
  45. package/dist/runtime/heapEffectIR.js.map +1 -1
  46. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  47. package/dist/runtime/heapEligibility.js +64 -12
  48. package/dist/runtime/heapEligibility.js.map +1 -1
  49. package/dist/runtime/heapIrBuilders.d.ts +8 -0
  50. package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
  51. package/dist/runtime/heapIrBuilders.js +47 -2
  52. package/dist/runtime/heapIrBuilders.js.map +1 -1
  53. package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
  54. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
  55. package/dist/runtime/heapScene/chunkedArena.js +28 -91
  56. package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
  57. package/dist/runtime/heapScene/freelist.d.ts +1 -1
  58. package/dist/runtime/heapScene/freelist.js +3 -3
  59. package/dist/runtime/heapScene/freelist.js.map +1 -1
  60. package/dist/runtime/heapScene/pools.d.ts +103 -59
  61. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  62. package/dist/runtime/heapScene/pools.js +335 -194
  63. package/dist/runtime/heapScene/pools.js.map +1 -1
  64. package/dist/runtime/heapScene.d.ts +116 -1
  65. package/dist/runtime/heapScene.d.ts.map +1 -1
  66. package/dist/runtime/heapScene.js +581 -125
  67. package/dist/runtime/heapScene.js.map +1 -1
  68. package/dist/runtime/hybridScene.d.ts.map +1 -1
  69. package/dist/runtime/hybridScene.js +19 -3
  70. package/dist/runtime/hybridScene.js.map +1 -1
  71. package/dist/runtime/index.d.ts +1 -1
  72. package/dist/runtime/index.d.ts.map +1 -1
  73. package/dist/runtime/index.js +1 -1
  74. package/dist/runtime/index.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/core/elementType.ts +10 -0
  77. package/src/core/provider.ts +56 -28
  78. package/src/core/renderObject.ts +9 -0
  79. package/src/resources/preparedRenderObject.ts +22 -3
  80. package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
  81. package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
  82. package/src/runtime/derivedUniforms/records.ts +70 -15
  83. package/src/runtime/derivedUniforms/sceneIntegration.ts +10 -0
  84. package/src/runtime/flattenTree.ts +47 -2
  85. package/src/runtime/heapAdapter.ts +40 -5
  86. package/src/runtime/heapDecoder.ts +8 -3
  87. package/src/runtime/heapEffect.ts +15 -1
  88. package/src/runtime/heapEffectIR.ts +18 -9
  89. package/src/runtime/heapEligibility.ts +39 -12
  90. package/src/runtime/heapIrBuilders.ts +50 -2
  91. package/src/runtime/heapScene/chunkedArena.ts +29 -94
  92. package/src/runtime/heapScene/freelist.ts +3 -3
  93. package/src/runtime/heapScene/pools.ts +340 -218
  94. package/src/runtime/heapScene.ts +717 -141
  95. package/src/runtime/hybridScene.ts +20 -3
  96. package/src/runtime/index.ts +5 -0
@@ -43,7 +43,7 @@
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
45
  import { AVal, AdaptiveObject, AdaptiveToken, HashTable } from "@aardworx/wombat.adaptive";
46
- import { buildBucketLayout, compileHeapEffect, } from "./heapEffect.js";
46
+ import { INLINE_HEADER_UNIFORMS, buildBucketLayout, compileHeapEffect, } from "./heapEffect.js";
47
47
  import { compileHeapEffectIR } from "./heapEffectIR.js";
48
48
  import { buildShaderFamily, compileShaderFamily, } from "./heapShaderFamily.js";
49
49
  import { ATLAS_PAGE_FORMATS, atlasFormatIndex, } from "./textureAtlas/atlasPool.js";
@@ -52,7 +52,7 @@ import { DerivedUniformsScene, registerRoDerivations, deregisterRoDerivations, i
52
52
  import { packerForWgslType, PACKER_MAT4 } from "./heapScene/packers.js";
53
53
  import { HEAP_SCAN_WGSL, SCAN_TILE_SIZE, SCAN_WG_SIZE, SCAN_MAX_RECORDS, TILE_K, RECORD_U32, RECORD_BYTES, } from "./heapScene/scanKernel.js";
54
54
  import { GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16, } from "./heapScene/growBuffer.js";
55
- import { UniformPool, IndexPool, DrawHeap, AttributeArena, IndexAllocator, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS, } from "./heapScene/pools.js";
55
+ import { UniformPool, IndexPool, DrawHeap, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, ENC_OCT32, ENC_C4B, SEM_POSITIONS, SEM_NORMALS, COMPACTION_WASTE_FLOOR_BYTES, } from "./heapScene/pools.js";
56
56
  import { encodeModeKey } from "./pipelineCache/index.js";
57
57
  import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
58
58
  import { GpuPartitionScene } from "./derivedModes/partitionDispatcher.js";
@@ -60,6 +60,41 @@ import { emitPartitionKernel, substituteReadInputInStmt, collectUniformReadsInSt
60
60
  import {} from "./derivedModes/rule.js";
61
61
  import { addMarkingCallback } from "@aardworx/wombat.adaptive";
62
62
  import { analyseOutputSet, evaluateStructural, evaluateStructuralSet, stableStringify, } from "@aardworx/wombat.shader/ir";
63
+ const NOOP = () => { };
64
+ const packPackedAttr = (val, dst, off) => {
65
+ const src = val.data;
66
+ new Uint32Array(dst.buffer, dst.byteOffset + off * 4, src.length).set(src);
67
+ };
68
+ const packF32Verbatim = (val, dst, off) => {
69
+ dst.set(val, off);
70
+ };
71
+ const packHostU32Bits = (val, dst, off) => {
72
+ const ibuf = val;
73
+ if (ibuf.kind !== "host") {
74
+ throw new Error("heapScene: packed BufferView aval flipped to native GPUBuffer");
75
+ }
76
+ const d = ibuf.data;
77
+ // reinterpret the u32 payload as f32 bits for the arena write
78
+ dst.set(new Float32Array(d.buffer, d.byteOffset, d.byteLength >>> 2), off);
79
+ };
80
+ const packHostF32 = (val, dst, off) => {
81
+ const ibuf = val;
82
+ if (ibuf.kind !== "host") {
83
+ throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
84
+ }
85
+ dst.set(asFloat32(ibuf.data), off);
86
+ };
87
+ /** Broadcast prefix copy — the only variant that genuinely captures. */
88
+ function packHostPrefix(limitFloats) {
89
+ return (val, dst, off) => {
90
+ const ibuf = val;
91
+ if (ibuf.kind !== "host") {
92
+ throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
93
+ }
94
+ const src = asFloat32(ibuf.data);
95
+ dst.set(src.subarray(0, limitFloats), off);
96
+ };
97
+ }
63
98
  // GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
64
99
  // Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
65
100
  // ---------------------------------------------------------------------------
@@ -93,6 +128,57 @@ import { analyseOutputSet, evaluateStructural, evaluateStructuralSet, stableStri
93
128
  * prelude then uses the local vertex index directly (no indexStorage lookup).
94
129
  * Must match the literal in heapEffect.ts's `megacallSearchPrelude`. */
95
130
  export const HEAP_NONINDEXED = 0xffffffff;
131
+ export function oct32(data) {
132
+ const u = data instanceof Uint32Array ? data : new Uint32Array(data.buffer, data.byteOffset, data.length);
133
+ return { kind: "heap-packed", enc: "oct32", data: u };
134
+ }
135
+ export function c4b(data) {
136
+ if (data instanceof Uint8Array) {
137
+ if (data.byteLength % 4 !== 0 || data.byteOffset % 4 !== 0) {
138
+ throw new Error("heapScene.c4b: RGBA8 data must be 4-byte sized and aligned");
139
+ }
140
+ return { kind: "heap-packed", enc: "c4b", data: new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4) };
141
+ }
142
+ return { kind: "heap-packed", enc: "c4b", data };
143
+ }
144
+ export function isHeapPackedAttribute(v) {
145
+ return typeof v === "object" && v !== null
146
+ && v.kind === "heap-packed"
147
+ && v.data instanceof Uint32Array;
148
+ }
149
+ /** Create a standalone `HeapStorage`. Pass it to `buildHeapScene` via
150
+ * `opts.storage` to share one arena across multiple heaps. */
151
+ export function createHeapStorage(device, opts = {}) {
152
+ void device;
153
+ // Initial capacities are hints; the buffers pow2-grow on demand.
154
+ const arena = buildArenaState(device, Math.max(64 * 1024, opts.initialArenaBytes ?? 0), 16 * 1024, "heapStorage", GPUBufferUsage.STORAGE, opts.maxChunkBytes);
155
+ const pool = new UniformPool();
156
+ const indexPool = new IndexPool();
157
+ const consumers = new Set();
158
+ return {
159
+ arena, pool, indexPool,
160
+ register(c) {
161
+ consumers.add(c);
162
+ return { dispose: () => { consumers.delete(c); } };
163
+ },
164
+ compact(dev, floor, force) {
165
+ arena.attrs.flush(dev); // GPU buffer current for the copy
166
+ const remaps = arena.attrs.compact(dev, floor, force);
167
+ for (const { chunkIdx, remap } of remaps) {
168
+ // Storage-level dedup pools first (uniform refs); then each scene
169
+ // re-seats its drawHeaders / derive / partition / index `_indexStart`
170
+ // (the index re-seat reads `indexPool.baseFor` at the OLD ref, so the
171
+ // index pool is remapped LAST, after all consumers).
172
+ pool.remapRefs(chunkIdx, remap);
173
+ for (const c of consumers)
174
+ c.applyRemap(chunkIdx, remap);
175
+ indexPool.remapRefs(chunkIdx, remap);
176
+ }
177
+ return remaps.length;
178
+ },
179
+ dispose() { arena.attrs.destroy(); },
180
+ };
181
+ }
96
182
  export function buildHeapScene(device, sig, initialDraws, opts = {}) {
97
183
  const atlasPool = opts.atlasPool;
98
184
  const colorAttachmentName = sig.colorNames[0];
@@ -114,12 +200,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
114
200
  colorTargets.push({ format: fmt });
115
201
  }
116
202
  const depthFormat = sig.depthStencil?.format;
117
- // ─── Global arena (uniform/attribute data + index buffer) ────────
118
- // Initial capacities are just hints; both buffers pow2-grow on
119
- // demand. Skip per-draw enumeration since aval-keyed sharing makes
120
- // the actual allocated size hard to predict (10K instanced draws
121
- // sharing the same Positions array 1 alloc, not 10K).
122
- const arena = buildArenaState(device, 64 * 1024, 16 * 1024, "heapScene", GPUBufferUsage.STORAGE, opts.maxChunkBytes);
203
+ // ─── Storage (arena + dedup pools) — private or shared ───────────
204
+ // The chunked arena (uniform/attribute data + index buffer) and the
205
+ // aval-keyed dedup pools live in a `HeapStorage`. Default: a private
206
+ // store this scene owns. `opts.storage`: a shared store (the same
207
+ // geometry/uniforms rendered by multiple heaps e.g. shadow + color
208
+ // pass are deduped once). The local `arena`/`pool`/`indexPool`
209
+ // bindings below keep every downstream reference unchanged.
210
+ const ownsStorage = opts.storage === undefined;
211
+ const storage = opts.storage ?? createHeapStorage(device, {
212
+ ...(opts.maxChunkBytes !== undefined ? { maxChunkBytes: opts.maxChunkBytes } : {}),
213
+ ...(opts.initialArenaBytes !== undefined ? { initialArenaBytes: opts.initialArenaBytes } : {}),
214
+ });
215
+ const arena = storage.arena;
216
+ const pool = storage.pool;
217
+ const indexPool = storage.indexPool;
123
218
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
124
219
  const drawIdToBucket = [];
125
220
  const drawIdToLocalSlot = [];
@@ -291,6 +386,23 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
291
386
  const p = packerForWgslType(f.uniformWgslType ?? "");
292
387
  return { dataBytes: p.dataBytes, typeId: p.typeId, length: 1, pack: p.pack };
293
388
  }
389
+ // Packed attribute encodings (oct32 unit vectors / C4b colors): one
390
+ // u32 per element, decoded by the VS's typeId-selected arm. 3× smaller
391
+ // in the arena AND host-side (the caller keeps the packed source).
392
+ if (isHeapPackedAttribute(value)) {
393
+ const pa = value;
394
+ const wantType = pa.enc === "oct32" ? "vec3<f32>" : "vec4<f32>";
395
+ if (f.attributeWgslType !== wantType) {
396
+ throw new Error(`heapScene: ${pa.enc} attribute supplied for '${f.attributeWgslType}' field — ` +
397
+ `oct32 requires vec3<f32>, c4b requires vec4<f32>`);
398
+ }
399
+ return {
400
+ dataBytes: pa.data.byteLength,
401
+ typeId: pa.enc === "oct32" ? ENC_OCT32 : ENC_C4B,
402
+ length: pa.data.length,
403
+ pack: packPackedAttr,
404
+ };
405
+ }
294
406
  // attribute-ref: variable-size array; we copy verbatim into the
295
407
  // arena. The current encoding is V3F_TIGHT (3 f32s per element).
296
408
  const arr = value;
@@ -303,7 +415,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
303
415
  dataBytes: arr.byteLength,
304
416
  typeId: ENC_V3F_TIGHT,
305
417
  length,
306
- pack: (val, dst, off) => { dst.set(val, off); },
418
+ pack: packF32Verbatim,
307
419
  };
308
420
  }
309
421
  /**
@@ -323,6 +435,36 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
323
435
  if (offset !== 0) {
324
436
  throw new Error(`heapScene: BufferView offset ${offset} > 0 not yet supported`);
325
437
  }
438
+ // Packed encodings by ELEMENT TYPE (the BufferView analogue of the
439
+ // oct32()/c4b() HeapDrawSpec markers): one u32 per element in the
440
+ // arena, decoded by the VS typeId arm. Dedup stays aval-keyed like
441
+ // any other BufferView attribute.
442
+ const etName = bv.elementType.name;
443
+ if (etName === "oct32" || etName === "c4b") {
444
+ const want = etName === "oct32" ? "vec3<f32>" : "vec4<f32>";
445
+ if (f.attributeWgslType !== want) {
446
+ throw new Error(`heapScene: ${etName} BufferView requires a ${want} schema attribute (got ${f.attributeWgslType ?? "?"})`);
447
+ }
448
+ const strideP = bv.stride ?? 4;
449
+ const isBroadcastP = bv.singleValue !== undefined || strideP === 0;
450
+ if (!isBroadcastP && strideP !== 4) {
451
+ throw new Error(`heapScene: ${etName} BufferView stride ${strideP} not tight (4) and not a broadcast`);
452
+ }
453
+ const ibP = bv.buffer.force( /* allow-force */);
454
+ if (ibP.kind !== "host") {
455
+ throw new Error(`heapScene: ${etName} BufferView wraps a native GPUBuffer; not supported in heap path`);
456
+ }
457
+ const lengthP = isBroadcastP ? 1 : ibP.sizeBytes / 4;
458
+ const dataBytesP = 4 * lengthP;
459
+ return {
460
+ dataBytes: dataBytesP,
461
+ typeId: etName === "oct32" ? ENC_OCT32 : ENC_C4B,
462
+ length: lengthP,
463
+ // Tight case: the copy limit equals the source payload, so the
464
+ // static packer (which derives it from the buffer) is exact.
465
+ pack: isBroadcastP ? packHostPrefix(dataBytesP / 4) : packHostU32Bits,
466
+ };
467
+ }
326
468
  // In-arena element bytes: the storage layout the shader will read
327
469
  // from. vec4 is always 16 (read via `heapV4f[idx]`), vec3 is 12
328
470
  // (read as 3 f32s), vec2 is 8, scalar is 4. V3f source for a vec4
@@ -366,15 +508,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
366
508
  dataBytes,
367
509
  typeId: ENC_V3F_TIGHT,
368
510
  length,
369
- pack: (val, dst, off) => {
370
- const ibuf = val;
371
- if (ibuf.kind !== "host") {
372
- throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
373
- }
374
- const src = asFloat32(ibuf.data);
375
- const limitFloats = dataBytes / 4;
376
- dst.set(src.subarray(0, limitFloats), off);
377
- },
511
+ pack: isBroadcast ? packHostPrefix(dataBytes / 4) : packHostF32,
378
512
  };
379
513
  }
380
514
  /**
@@ -400,9 +534,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
400
534
  },
401
535
  };
402
536
  }
403
- // ─── Pools aval-keyed refcounted allocations ────────────────────
404
- const pool = new UniformPool();
405
- const indexPool = new IndexPool();
537
+ // (`pool` / `indexPool` are owned by `storage`, bound above.)
406
538
  // ─── Adaptive routing: aval marks → repack the pool entry ─────────
407
539
  // With pool-managed per-draw uniforms, value changes don't touch
408
540
  // any DrawHeader (refs stay constant) — only the arena allocation's
@@ -456,6 +588,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
456
588
  // both. `derivedScene` is undefined when the option is off; nothing
457
589
  // else in this file should run when it is. On by default — pass
458
590
  // `enableDerivedUniforms: false` to opt out.
591
+ const enableCompaction = opts.enableCompaction !== false;
592
+ const releaseConstantAttributes = opts.releaseConstantAttributes === true;
593
+ const compactionWasteFloor = opts.compactionWasteFloorBytes ?? COMPACTION_WASTE_FLOOR_BYTES;
459
594
  const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
460
595
  const derivedScene = enableDerivedUniforms
461
596
  ? new DerivedUniformsScene(device, {
@@ -826,6 +961,29 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
826
961
  }
827
962
  familyFor(effect); // throws if unknown
828
963
  }
964
+ /**
965
+ * Reactive family rebuild: register a new effect's family when it first
966
+ * appears after the initial freeze. Family-merge is disabled, so every
967
+ * Effect already gets its OWN independent single-member family (its own
968
+ * shader modules + buckets); a late effect just needs its family compiled
969
+ * and added — no rebuild of existing families. Idempotent.
970
+ */
971
+ function ensureFamilyForSpec(spec) {
972
+ if (!familyBuilt) {
973
+ buildFamilyFromSpecs([spec]);
974
+ return;
975
+ }
976
+ if (familyByEffectId.has(spec.effect.id))
977
+ return;
978
+ const entry = { attributes: new Set(), uniforms: new Set() };
979
+ if (spec.instanceAttributes !== undefined) {
980
+ for (const name of Object.keys(spec.instanceAttributes))
981
+ entry.attributes.add(name);
982
+ }
983
+ const singleMap = new Map();
984
+ singleMap.set(spec.effect, entry);
985
+ familyByEffectId.set(spec.effect.id, compileFamilyFor([spec.effect], singleMap));
986
+ }
829
987
  // ─── id-of helpers ────────────────────────────────────────────────
830
988
  const idOf = (s) => `effect#${s.id}`;
831
989
  const textureIds = new WeakMap();
@@ -961,7 +1119,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
961
1119
  // binary search. Minimum one zero-record to satisfy WebGPU non-
962
1120
  // zero size constraint when the bucket is empty.
963
1121
  const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
964
- entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.chunk(bucket.chunkIdx).buffer } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
1122
+ entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1123
+ // Index data lives in the attr arena now (the VS storage-decodes it);
1124
+ // bind the same buffer as the gather views.
1125
+ { binding: 5, resource: { buffer: attrsBuf } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
965
1126
  s.renderBoundRecordCount = s.recordCount;
966
1127
  }
967
1128
  // Schema-driven texture + sampler entries. v1 user surface still
@@ -1081,15 +1242,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1081
1242
  atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
1082
1243
  }
1083
1244
  }
1084
- arena.indices.onAnyResize((resizedChunkIdx) => {
1085
- for (const b of buckets) {
1086
- if (b.chunkIdx !== resizedChunkIdx)
1087
- continue;
1088
- for (let i = 0; i < b.slots.length; i++) {
1089
- b.slots[i].bindGroup = buildBucketBindGroup(b, i);
1090
- }
1091
- }
1092
- });
1093
1245
  // ─── findOrCreateBucket ───────────────────────────────────────────
1094
1246
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
1095
1247
  // Every member effect in the family shares one bucket per
@@ -1746,7 +1898,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1746
1898
  if (cpuMigration !== undefined) {
1747
1899
  let totalEmit = 0;
1748
1900
  for (const m of cpuMigration) {
1749
- const uRefs = bucket.uniformOrder.map(n => m.perDrawRefs.get(n) ?? 0);
1901
+ const uRefs = bucket.uniformOrder.map(n => {
1902
+ const i = bucket.fieldIdx.get(n);
1903
+ const r = i === undefined ? -1 : m.perDrawRefs[i];
1904
+ return r < 0 ? 0 : r;
1905
+ });
1750
1906
  const recIdx = partition.appendRecord(m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs);
1751
1907
  bucket.drawIdToRecord.set(m.drawId, recIdx);
1752
1908
  bucket.recordToDrawId[recIdx] = m.drawId;
@@ -1857,6 +2013,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1857
2013
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
1858
2014
  const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
1859
2015
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
2016
+ const fieldIdx = new Map();
2017
+ layout.drawHeaderFields.forEach((f, i) => fieldIdx.set(f.name, i));
1860
2018
  const bucket = {
1861
2019
  label: bk, textures: undefined, layout, chunkIdx,
1862
2020
  slots: [], // populated lazily by ensureSlot per modeKey
@@ -1866,7 +2024,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1866
2024
  headerDirtyMin: Infinity, headerDirtyMax: 0,
1867
2025
  localPosRefs: [], localNorRefs: [],
1868
2026
  localEntries: [], localToDrawId: [],
1869
- localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
2027
+ localPerDrawAvals: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
2028
+ fieldIdx,
2029
+ posFieldIdx: fieldIdx.get("Positions") ?? -1,
2030
+ norFieldIdx: fieldIdx.get("Normals") ?? -1,
1870
2031
  drawSlots: new Set(), dirty: new Set(),
1871
2032
  isAtlasBucket,
1872
2033
  localAtlasReleases: [],
@@ -1936,7 +2097,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1936
2097
  const dst = bucket.drawHeaderStaging;
1937
2098
  const u32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length);
1938
2099
  const baseFloat = (localSlot * bucket.layout.drawHeaderBytes) / 4;
1939
- for (const f of bucket.layout.drawHeaderFields) {
2100
+ const fields = bucket.layout.drawHeaderFields;
2101
+ for (let i = 0; i < fields.length; i++) {
2102
+ const f = fields[i];
1940
2103
  // texture-ref fields carry inline values (pageRef/formatBits as u32,
1941
2104
  // origin/size as vec2<f32>) and are filled by packAtlasTextureFields.
1942
2105
  if (f.kind === "texture-ref")
@@ -1950,15 +2113,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1950
2113
  u32[fOff] = layoutId >>> 0;
1951
2114
  continue;
1952
2115
  }
1953
- const ref = perDrawRefs.get(f.name);
1954
- if (ref === undefined) {
1955
- // Family-merge: a slot's effect doesn't populate every field of
1956
- // the union; leave the unused slots zero the layoutId switch
1957
- // ensures they're never read by the wrong effect's helper.
1958
- u32[fOff] = 0;
1959
- continue;
1960
- }
1961
- u32[fOff] = ref;
2116
+ const ref = perDrawRefs[i];
2117
+ // Family-merge: a slot's effect doesn't populate every field of
2118
+ // the union (−1); leave the unused slots zero — the layoutId
2119
+ // switch ensures they're never read by the wrong effect's helper.
2120
+ u32[fOff] = ref < 0 ? 0 : ref;
1962
2121
  }
1963
2122
  }
1964
2123
  // ─── Atlas drawHeader packing + page-set tracking ─────────────────
@@ -2043,6 +2202,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2043
2202
  totalDraws: 0,
2044
2203
  drawBytes: 0,
2045
2204
  geometryBytes: 0,
2205
+ compactions: 0,
2046
2206
  derivedPullMs: 0,
2047
2207
  derivedUploadMs: 0,
2048
2208
  derivedEncodeMs: 0,
@@ -2085,12 +2245,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2085
2245
  // single spec when no batched lazy-build occurred earlier (e.g.
2086
2246
  // direct addDraw at runtime). The aset / array initial-population
2087
2247
  // paths build from the full effect set up front.
2088
- if (!familyBuilt) {
2089
- buildFamilyFromSpecs([spec]);
2090
- }
2091
- else {
2092
- ensureFamilyKnowsEffect(spec.effect);
2093
- }
2248
+ // Build the family lazily from this spec (first addDraw), or register a
2249
+ // newly-seen effect's family on the fly (reactive rebuild) — supports a
2250
+ // scene mixing multiple effects added over time (tiles + markers + …).
2251
+ ensureFamilyForSpec(spec);
2094
2252
  const perInstanceUniforms = new Set();
2095
2253
  const perInstanceAttributes = spec.instanceAttributes !== undefined
2096
2254
  ? new Set(Object.keys(spec.instanceAttributes))
@@ -2111,16 +2269,48 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2111
2269
  token: outerTok,
2112
2270
  });
2113
2271
  }
2114
- // §3 chunk routing pick which arena chunk this RO's allocations
2115
- // land in. Preference order:
2116
- // 1. If any of the RO's bindings is an aval that's already
2117
- // allocated in some chunk via the uniform pool, prefer that
2118
- // chunk to avoid duplication.
2119
- // 2. Otherwise, use the newest open chunk (highest chunkIdx).
2120
- // ChunkedArena.alloc spills to the next chunk automatically
2121
- // when this one is full; the spill ends up driving the
2122
- // pool's `finalChunk` selection so all of the RO's
2123
- // allocations land in one chunk consistently.
2272
+ // ─── Group-fit page placement (§3) ───────────────────────────────
2273
+ // A draw's whole group — uniforms + attributes + INDEX data (all one
2274
+ // arena now) must land on ONE chunk/page (the draw binds one buffer).
2275
+ // Estimate the group's arena bytes (exact per-field via the same
2276
+ // placement helpers, ignoring dedup ⇒ an upper bound on the NEW bytes a
2277
+ // chunk must absorb), then place the group on a chunk with room — prefer
2278
+ // one already holding a shared input, else the newest, else open a fresh
2279
+ // page. This replaces the old cross-chunk spill throw, so a scene can
2280
+ // grow past one buffer's cap (WebGPU's storage-buffer binding limit).
2281
+ const fam = familyFor(spec.effect);
2282
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id);
2283
+ const layout = fam.schema.drawHeaderUnion;
2284
+ const hasIndices = spec.indices !== undefined;
2285
+ const indicesAval = hasIndices ? asAval(spec.indices) : undefined;
2286
+ const indexArr = hasIndices ? readPlain(spec.indices) : undefined;
2287
+ const instanceCount = spec.instanceCount !== undefined
2288
+ ? readPlain(spec.instanceCount) : 1;
2289
+ let estBytes = indexArr !== undefined ? ALIGN16(ALLOC_HEADER_PAD_TO + indexArr.byteLength) : 0;
2290
+ for (const f of layout.drawHeaderFields) {
2291
+ if (f.kind === "texture-ref" || f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name) || !effectFields.has(f.name))
2292
+ continue;
2293
+ if (ruleForUniform(spec, f.name) !== undefined) {
2294
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + PACKER_MAT4.dataBytes); // §7 derived dummy mat4
2295
+ continue;
2296
+ }
2297
+ const perInstUniform = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
2298
+ const perInstAttr = f.kind === "attribute-ref" && perInstanceAttributes.has(f.name);
2299
+ const provided = perInstAttr ? spec.instanceAttributes[f.name] : spec.inputs[f.name];
2300
+ if (provided === undefined)
2301
+ continue; // the acquire loop throws with a precise message
2302
+ let dataBytes;
2303
+ if (f.kind === "attribute-ref" && !perInstUniform && isBufferView(provided)) {
2304
+ dataBytes = bufferViewPlacement(f, provided).dataBytes;
2305
+ }
2306
+ else {
2307
+ const value = asAval(provided).getValue(outerTok);
2308
+ dataBytes = (perInstUniform
2309
+ ? perInstancePlacementFor(f, value, instanceCount)
2310
+ : poolPlacementFor(f, value)).dataBytes;
2311
+ }
2312
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
2313
+ }
2124
2314
  let chunkIdx;
2125
2315
  {
2126
2316
  let preferred;
@@ -2135,7 +2325,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2135
2325
  }
2136
2326
  }
2137
2327
  }
2138
- chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
2328
+ const fits = (c) => arena.attrs.bumpHeadroom(c) >= estBytes;
2329
+ const newest = arena.attrs.chunkCount - 1;
2330
+ chunkIdx =
2331
+ preferred !== undefined && fits(preferred) ? preferred
2332
+ : fits(newest) ? newest
2333
+ : arena.attrs.openPage();
2139
2334
  }
2140
2335
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
2141
2336
  // Phase 5c.3 — every RO carrying a derived-mode rule is registered
@@ -2169,18 +2364,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2169
2364
  // tables every frame from the master.
2170
2365
  const roSlot = bucket.gpuRouted ? bucket.slots[0] : ensureSlot(bucket, roDescriptor);
2171
2366
  const roSlotIdx = bucket.slots.indexOf(roSlot);
2172
- const fam = familyFor(spec.effect);
2173
- const effectFields = fam.fieldsForEffect.get(spec.effect.id);
2174
- // Indices live in their own INDEX-usage buffer (WebGPU constraint).
2175
- // Aval-keyed: 19K instanced clones of the same mesh share one
2176
- // index allocation + one upload. NON-INDEXED draws (spec.indices
2177
- // omitted) skip the index pool entirely: the drawTable record stores
2178
- // firstIndex = HEAP_NONINDEXED (sentinel) and indexCount = vertexCount,
2179
- // and the megacall prelude uses the local vertex index directly.
2180
- const hasIndices = spec.indices !== undefined;
2181
- const indicesAval = hasIndices ? asAval(spec.indices) : undefined;
2367
+ // Indices ride the same arena as attributes/uniforms (the VS storage-
2368
+ // decodes them). Aval-keyed: 19K instanced clones of the same mesh share
2369
+ // one index allocation + one upload. NON-INDEXED draws (spec.indices
2370
+ // omitted) skip the pool: the drawTable record stores firstIndex =
2371
+ // HEAP_NONINDEXED (sentinel) and indexCount = vertexCount, and the
2372
+ // megacall prelude uses the local vertex index directly. `indicesAval` /
2373
+ // `indexArr` were read above for the group estimate; the group landed
2374
+ // wholly on `bucket.chunkIdx`, so this allocation can't spill.
2182
2375
  const idxAlloc = hasIndices
2183
- ? indexPool.acquire(device, arena.indices, indicesAval, bucket.chunkIdx, readPlain(spec.indices))
2376
+ ? indexPool.acquire(device, arena.attrs, indicesAval, bucket.chunkIdx, indexArr)
2184
2377
  : undefined;
2185
2378
  // Emit count per instance + the firstIndex field written to the record.
2186
2379
  // Slice: emit only [firstIndex, firstIndex+indexCount) of the (shared,
@@ -2189,17 +2382,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2189
2382
  const emitCount = hasIndices ? (spec.indexCount ?? idxAlloc.count) : (spec.vertexCount ?? 0);
2190
2383
  const firstIndexField = hasIndices ? (idxAlloc.firstIndex + (spec.firstIndex ?? 0)) : HEAP_NONINDEXED;
2191
2384
  const localSlot = bucket.drawHeap.alloc();
2192
- // Per-RO instancing: read `spec.instanceCount` (defaults to 1).
2193
- const instanceCount = spec.instanceCount !== undefined
2194
- ? readPlain(spec.instanceCount)
2195
- : 1;
2196
2385
  // Walk the bucket's schema-driven DrawHeader fields. Per-instance
2197
2386
  // attributes pull from `spec.instanceAttributes` and pack into an
2198
2387
  // array allocation; everything else pulls from `spec.inputs` and
2199
2388
  // packs as a single value. Both go through the same pool — sharing
2200
2389
  // emerges from aval identity either way.
2201
2390
  const perDrawAvals = [];
2202
- const perDrawRefs = new Map();
2391
+ let perDrawRawFieldIdx;
2392
+ const perDrawRefs = new Int32Array(bucket.layout.drawHeaderFields.length).fill(-1);
2393
+ const setRef = (name, ref) => {
2394
+ const i = bucket.fieldIdx.get(name);
2395
+ if (i !== undefined)
2396
+ perDrawRefs[i] = ref;
2397
+ };
2398
+ const getRef = (name) => {
2399
+ const i = bucket.fieldIdx.get(name);
2400
+ if (i === undefined)
2401
+ return undefined;
2402
+ const r = perDrawRefs[i];
2403
+ return r < 0 ? undefined : r;
2404
+ };
2203
2405
  {
2204
2406
  const tok = outerTok;
2205
2407
  for (const f of bucket.layout.drawHeaderFields) {
@@ -2214,15 +2416,42 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2214
2416
  continue;
2215
2417
  if (!effectFields.has(f.name))
2216
2418
  continue;
2419
+ // Inline header uniforms (PickId): the header word IS the u32
2420
+ // value — write it via perDrawRefs (packBucketHeader copies
2421
+ // refs verbatim), skip the pool entirely. A provider-supplied
2422
+ // PickId aval still works below as the (slow) fallback.
2423
+ if (INLINE_HEADER_UNIFORMS.has(f.name)) {
2424
+ const iv = f.name === "PickId" ? spec.pickId : undefined;
2425
+ if (iv !== undefined) {
2426
+ setRef(f.name, iv >>> 0);
2427
+ continue;
2428
+ }
2429
+ const provInline = spec.inputs[f.name];
2430
+ if (typeof provInline === "number") {
2431
+ setRef(f.name, provInline >>> 0);
2432
+ continue;
2433
+ }
2434
+ if (provInline === undefined) {
2435
+ // No id at all (picking disabled but the effect still
2436
+ // reads the uniform): 0 = "no pick", a reserved id.
2437
+ setRef(f.name, 0);
2438
+ continue;
2439
+ }
2440
+ // fall through: aval-provided → legacy pooled path
2441
+ }
2217
2442
  // §7: derived-uniform fields are produced by the compute
2218
2443
  // pre-pass. We still allocate an arena slot here so the
2219
2444
  // drawHeader gets a valid ref written by packBucketHeader; §7
2220
2445
  // overwrites the arena data each frame the inputs are dirty.
2221
2446
  if (ruleForUniform(spec, f.name) !== undefined) {
2222
- const dummyAval = AVal.constant(M44d.zero);
2223
- const ref = pool.acquire(device, arena.attrs, dummyAval, bucket.chunkIdx, M44d.zero, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack);
2224
- perDrawRefs.set(f.name, ref);
2225
- perDrawAvals.push(dummyAval);
2447
+ // Raw (aval-less) slot: §7's compute pre-pass overwrites the
2448
+ // data every dirty frame, so a keyed pool entry (placeholder
2449
+ // ConstantVal + Map per draw) bought nothing. The ref lives
2450
+ // in the drawHeader cell (compaction re-seats it there);
2451
+ // removeDraw frees it via `perDrawRawFieldIdx`.
2452
+ const ref = pool.allocRaw(arena.attrs, bucket.chunkIdx, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1);
2453
+ setRef(f.name, ref);
2454
+ (perDrawRawFieldIdx ??= []).push(bucket.fieldIdx.get(f.name));
2226
2455
  continue;
2227
2456
  }
2228
2457
  const isPerInstanceUniformField = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
@@ -2249,12 +2478,37 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2249
2478
  let av;
2250
2479
  let value;
2251
2480
  let placement;
2481
+ let releasable;
2252
2482
  if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
2253
2483
  const bv = provided;
2254
2484
  placement = bufferViewPlacement(f, bv);
2255
2485
  av = bv.buffer;
2256
2486
  value = bv.buffer.getValue(tok);
2257
2487
  }
2488
+ else if (releaseConstantAttributes
2489
+ && f.kind === "attribute-ref"
2490
+ && !isPerInstanceUniformField
2491
+ && !(typeof provided === "object" && provided !== null
2492
+ && typeof provided.getValue === "function")) {
2493
+ // Plain value (raw array / packed marker) → CONSTANT by definition.
2494
+ // Wrap in a droppable holder: after this field's arena write the GPU
2495
+ // copy is the only copy the heap ever reads again, so the payload is
2496
+ // released (real avals never take this branch — changeable data
2497
+ // stays alive by contract).
2498
+ const holder = {
2499
+ _v: provided,
2500
+ getValue() {
2501
+ if (holder._v === null) {
2502
+ throw new Error("heapScene: constant attribute payload was released after staging (releaseConstantAttributes)");
2503
+ }
2504
+ return holder._v;
2505
+ },
2506
+ };
2507
+ releasable = holder;
2508
+ av = holder;
2509
+ value = provided;
2510
+ placement = poolPlacementFor(f, value);
2511
+ }
2258
2512
  else {
2259
2513
  av = asAval(provided);
2260
2514
  value = av.getValue(tok);
@@ -2263,18 +2517,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2263
2517
  : poolPlacementFor(f, value);
2264
2518
  }
2265
2519
  const ref = pool.acquire(device, arena.attrs, av, bucket.chunkIdx, value, placement.dataBytes, placement.typeId, placement.length, placement.pack);
2266
- perDrawRefs.set(f.name, ref);
2520
+ if (releasable !== undefined)
2521
+ releasable._v = null; // staged — drop the CPU copy
2522
+ setRef(f.name, ref);
2267
2523
  perDrawAvals.push(av);
2268
2524
  }
2269
2525
  }
2270
- bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
2271
- bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
2526
+ bucket.localPosRefs[localSlot] = getRef("Positions");
2527
+ bucket.localNorRefs[localSlot] = getRef("Normals");
2272
2528
  bucket.localEntries[localSlot] = {
2273
2529
  indexCount: emitCount, firstIndex: firstIndexField, instanceCount,
2274
2530
  };
2275
2531
  bucket.localToDrawId[localSlot] = drawId;
2276
2532
  bucket.drawSlots.add(localSlot);
2277
2533
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
2534
+ bucket.localPerDrawRawFieldIdx[localSlot] = perDrawRawFieldIdx;
2278
2535
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
2279
2536
  const layoutId = fam.schema.layoutIdOf.get(spec.effect);
2280
2537
  bucket.localLayoutIds[localSlot] = layoutId;
@@ -2377,7 +2634,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2377
2634
  // don't carry a given name (e.g. their effect doesn't declare
2378
2635
  // it) get 0 — the kernel only reads refs through combo fns that
2379
2636
  // gate on `r.comboId`, so an unread 0 is harmless.
2380
- const uniformRefs = (bucket.uniformOrder ?? []).map(name => perDrawRefs.get(name) ?? 0);
2637
+ const uniformRefs = (bucket.uniformOrder ?? []).map(name => getRef(name) ?? 0);
2381
2638
  partition.appendRecord(localSlot, firstIndexField, emitCount, instanceCount, roComboId, uniformRefs);
2382
2639
  bucket.drawIdToRecord.set(drawId, recIdx);
2383
2640
  bucket.recordToDrawId[recIdx] = drawId;
@@ -2477,7 +2734,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2477
2734
  }
2478
2735
  uniformAvals = m;
2479
2736
  }
2480
- const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => { dirtyModeKeyDrawIds.add(drawId); }, {
2737
+ const tracker = new ModeKeyTracker(spec.pipelineState, sig,
2738
+ // skipSubscribe means the tracker never invokes onDirty itself
2739
+ // (scene-level modeAvalToDrawIds dispatches instead) — a shared
2740
+ // noop avoids one closure per draw.
2741
+ NOOP, {
2481
2742
  skipSubscribe: true,
2482
2743
  ...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
2483
2744
  ...(uniformAvals !== undefined ? { uniformAvals } : {}),
@@ -2509,14 +2770,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2509
2770
  const ruleSubset = new Map();
2510
2771
  const outOffsetByName = new Map();
2511
2772
  for (const f of bucket.layout.drawHeaderFields) {
2512
- if (f.name === "__layoutId")
2773
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name))
2513
2774
  continue;
2514
2775
  if (!effectFields.has(f.name))
2515
2776
  continue;
2516
2777
  const rule = ruleForUniform(spec, f.name);
2517
2778
  if (rule === undefined)
2518
2779
  continue;
2519
- const ref = perDrawRefs.get(f.name);
2780
+ const ref = getRef(f.name);
2520
2781
  if (ref === undefined)
2521
2782
  continue;
2522
2783
  ruleSubset.set(f.name, rule);
@@ -2545,7 +2806,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2545
2806
  trafoAvals,
2546
2807
  ...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
2547
2808
  hostUniformOffset: (n) => {
2548
- const r = perDrawRefs.get(n);
2809
+ const r = getRef(n);
2549
2810
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
2550
2811
  },
2551
2812
  outputOffset: (n) => outOffsetByName.get(n),
@@ -2660,9 +2921,23 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2660
2921
  if (avals !== undefined)
2661
2922
  for (const av of avals)
2662
2923
  pool.release(arena.attrs, av, bucket.chunkIdx);
2924
+ // Raw §7 slots: the live ref is the drawHeader cell (compaction
2925
+ // keeps it current); free it directly.
2926
+ const rawIdxs = bucket.localPerDrawRawFieldIdx[localSlot];
2927
+ if (rawIdxs !== undefined) {
2928
+ const rawRefs = bucket.localPerDrawRefs[localSlot];
2929
+ if (rawRefs !== undefined) {
2930
+ for (const fi of rawIdxs) {
2931
+ const ref = rawRefs[fi];
2932
+ if (ref >= 0)
2933
+ pool.releaseRaw(arena.attrs, bucket.chunkIdx, ref, PACKER_MAT4.dataBytes);
2934
+ }
2935
+ }
2936
+ bucket.localPerDrawRawFieldIdx[localSlot] = undefined;
2937
+ }
2663
2938
  const idxAval = drawIdToIndexAval[drawId];
2664
2939
  if (idxAval !== undefined)
2665
- indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
2940
+ indexPool.release(arena.attrs, idxAval, bucket.chunkIdx);
2666
2941
  const atlasRel = bucket.localAtlasReleases[localSlot];
2667
2942
  if (atlasRel !== undefined)
2668
2943
  atlasRel();
@@ -2729,14 +3004,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2729
3004
  if (asetReader === undefined)
2730
3005
  return;
2731
3006
  const delta = asetReader.getChanges(tok);
3007
+ // Process removals before adds. A remove+add in the same
3008
+ // transaction must free its slot / arena region / atlas tile
3009
+ // before the add allocates, so the freelist reuses the just-freed
3010
+ // block instead of growing the high-water toward
3011
+ // live + removed-this-tick. (Matches Aardvark's removals-first
3012
+ // delta discipline.)
3013
+ const adds = [];
2732
3014
  delta.iter((op) => {
2733
3015
  if (op.count > 0) {
2734
- // Re-use the outer evaluateAlways scope's token (caller is
2735
- // already sceneObj) — collapses N nested evaluateAlways into
2736
- // one for a bulk add. Saves a per-RO setUnsafeEvaluationDepth
2737
- // + outputs.add(sceneObj) round trip.
2738
- const id = addDrawImpl(op.value, tok);
2739
- specToDrawId.set(op.value, id);
3016
+ adds.push(op.value);
2740
3017
  }
2741
3018
  else {
2742
3019
  const id = specToDrawId.get(op.value);
@@ -2746,6 +3023,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2746
3023
  }
2747
3024
  }
2748
3025
  });
3026
+ // Re-use the outer evaluateAlways scope's token (caller is already
3027
+ // sceneObj) — collapses N nested evaluateAlways into one for a bulk
3028
+ // add. Saves a per-RO setUnsafeEvaluationDepth + outputs.add round trip.
3029
+ for (const value of adds) {
3030
+ const id = addDrawImpl(value, tok);
3031
+ specToDrawId.set(value, id);
3032
+ }
2749
3033
  }
2750
3034
  // ─── Initial population ───────────────────────────────────────────
2751
3035
  if (Array.isArray(initialDraws)) {
@@ -2764,9 +3048,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2764
3048
  asetReader = initialDraws.getReader();
2765
3049
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2766
3050
  // First drain: snapshot all add-ops, build the family from their
2767
- // effects up front, then run addDraw. Subsequent drains either
2768
- // reuse the frozen family (matching effects) or throw on a new
2769
- // unseen effect (per the v1 frozen-family contract).
3051
+ // effects up front, then run addDraw. Subsequent drains reuse the
3052
+ // family, or register a newly-seen effect's family on the fly
3053
+ // (ensureFamilyForSpec, via addDrawImpl).
2770
3054
  if (!familyBuilt) {
2771
3055
  const reader = asetReader;
2772
3056
  const delta = reader.getChanges(tok);
@@ -2798,6 +3082,148 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2798
3082
  }
2799
3083
  });
2800
3084
  }
3085
+ // ─── Heap compaction (attribute arena + index buffer) ────────────
3086
+ /**
3087
+ * Compact every arena/index chunk whose fragmentation waste crosses the
3088
+ * floor (or unconditionally when `force`), then re-seat EVERY cached ref so
3089
+ * the relocated allocations stay addressable. Attribute arena:
3090
+ * • uniform-pool entries (`pool.remapRefs`)
3091
+ * • §7 derived-uniform record handles (`derivedScene.remapHostHeap`)
3092
+ * • modes partition master refs (`partitionScene.remapUniformRefs`)
3093
+ * • drawHeader cells + the Pos/Nor caches (re-`packBucketHeader`)
3094
+ * Index buffer (ref = `allocBase + slice`, held in the drawTable / partition
3095
+ * master + IndexPool entry):
3096
+ * • per-draw `indexStart` in the CPU drawTable or partition master
3097
+ * • `localEntries[slot].firstIndex` + IndexPool entry base
3098
+ * Must run with the buffers current (we flush first) and before the
3099
+ * per-bucket header / drawTable flush so the re-seated records upload.
3100
+ */
3101
+ // Per-scene ref re-seat after the shared store relocates regions. Registered
3102
+ // with `storage`; the store calls it (for THIS scene + any others sharing the
3103
+ // store) inside `storage.compact`. Re-seats derived records, partition master
3104
+ // refs, drawHeader cells, and index `_indexStart` — everything this scene
3105
+ // caches that points into the arena. (The storage-level uniform/index pools
3106
+ // are re-seated by the store itself, around this call.)
3107
+ function applyRemap(chunkIdx, remap) {
3108
+ if (derivedScene !== undefined) {
3109
+ // §7 handles store DATA offsets (ref + header pad), not header refs.
3110
+ const dataRemap = new Map();
3111
+ for (const [o, n] of remap) {
3112
+ dataRemap.set(o + ALLOC_HEADER_PAD_TO, n + ALLOC_HEADER_PAD_TO);
3113
+ }
3114
+ derivedScene.remapHostHeap(chunkIdx, dataRemap);
3115
+ }
3116
+ {
3117
+ for (const bucket of buckets) {
3118
+ if (bucket.chunkIdx !== chunkIdx)
3119
+ continue;
3120
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
3121
+ if (bucket.partitionScene.remapUniformRefs(remap) > 0) {
3122
+ bucket.partitionDirty = true;
3123
+ }
3124
+ }
3125
+ for (const localSlot of bucket.drawSlots) {
3126
+ // (a) Index region re-seat. Index data is an attr-arena region now,
3127
+ // so it relocates in THIS pass. Its draw stores `_indexStart` (an
3128
+ // element offset = allocBase + slice) in the drawTable / partition
3129
+ // master; shift it by the region's element delta. Independent of the
3130
+ // uniform re-seat below (a slot's indices may move while its uniforms
3131
+ // don't), so it runs before the `changed` gate.
3132
+ const drawId = bucket.localToDrawId[localSlot];
3133
+ if (drawId !== undefined) {
3134
+ const iav = drawIdToIndexAval[drawId];
3135
+ const oldRef = iav !== undefined ? indexPool.baseFor(iav, chunkIdx) : undefined;
3136
+ const newRef = oldRef !== undefined ? remap.get(oldRef) : undefined;
3137
+ const entry = bucket.localEntries[localSlot];
3138
+ if (oldRef !== undefined && newRef !== undefined && entry !== undefined) {
3139
+ const newStart = entry.firstIndex + ((newRef - oldRef) >> 2);
3140
+ entry.firstIndex = newStart;
3141
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
3142
+ const recIdx = bucket.drawIdToRecord?.get(drawId);
3143
+ if (recIdx !== undefined) {
3144
+ bucket.partitionScene.setRecordIndexStart(recIdx, newStart);
3145
+ bucket.partitionDirty = true;
3146
+ }
3147
+ }
3148
+ else {
3149
+ const slotIdx = drawIdToSlotIdx[drawId];
3150
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
3151
+ const recIdx = slot?.slotToRecord[localSlot];
3152
+ if (slot !== undefined && recIdx !== undefined && recIdx >= 0) {
3153
+ slot.drawTableShadow[recIdx * RECORD_U32 + 2] = newStart >>> 0;
3154
+ const off = recIdx * RECORD_BYTES;
3155
+ if (off < slot.drawTableDirtyMin)
3156
+ slot.drawTableDirtyMin = off;
3157
+ if (off + RECORD_BYTES > slot.drawTableDirtyMax)
3158
+ slot.drawTableDirtyMax = off + RECORD_BYTES;
3159
+ }
3160
+ }
3161
+ }
3162
+ }
3163
+ // (b) Uniform/attr drawHeader cells (+ Pos/Nor caches).
3164
+ const refs = bucket.localPerDrawRefs[localSlot];
3165
+ if (refs === undefined)
3166
+ continue;
3167
+ let changed = false;
3168
+ for (let i = 0; i < refs.length; i++) {
3169
+ const ref = refs[i];
3170
+ if (ref < 0)
3171
+ continue;
3172
+ const fld = bucket.layout.drawHeaderFields[i];
3173
+ // Inline header values (PickId/__layoutId) are NOT arena
3174
+ // refs — a numeric collision with an old offset must not
3175
+ // remap them.
3176
+ if (fld.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(fld.name))
3177
+ continue;
3178
+ const nn = remap.get(ref);
3179
+ if (nn !== undefined) {
3180
+ refs[i] = nn;
3181
+ changed = true;
3182
+ }
3183
+ }
3184
+ if (!changed)
3185
+ continue;
3186
+ const pr = bucket.posFieldIdx >= 0 ? refs[bucket.posFieldIdx] : -1;
3187
+ const nr = bucket.norFieldIdx >= 0 ? refs[bucket.norFieldIdx] : -1;
3188
+ bucket.localPosRefs[localSlot] = pr < 0 ? undefined : pr;
3189
+ bucket.localNorRefs[localSlot] = nr < 0 ? undefined : nr;
3190
+ packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
3191
+ if (bucket.isAtlasBucket) {
3192
+ const ts = bucket.localAtlasTextures[localSlot];
3193
+ if (ts !== undefined)
3194
+ packAtlasTextureFields(bucket, localSlot, ts);
3195
+ }
3196
+ const byteOff = localSlot * bucket.layout.drawHeaderBytes;
3197
+ if (byteOff < bucket.headerDirtyMin)
3198
+ bucket.headerDirtyMin = byteOff;
3199
+ const end = byteOff + bucket.layout.drawHeaderBytes;
3200
+ if (end > bucket.headerDirtyMax)
3201
+ bucket.headerDirtyMax = end;
3202
+ }
3203
+ }
3204
+ }
3205
+ }
3206
+ // Register this scene as a consumer of the (possibly shared) store, so a
3207
+ // compaction — triggered by THIS scene or any other sharing the store —
3208
+ // re-seats this scene's refs. Disposed in `dispose()`.
3209
+ const storageReg = storage.register({ applyRemap });
3210
+ // ─── Compaction (delegates to the shared store) ──────────────────
3211
+ function runCompaction(force) {
3212
+ const compacted = storage.compact(device, compactionWasteFloor, force);
3213
+ stats.compactions += compacted;
3214
+ // update() flushes the re-seated drawHeaders via its own per-bucket loop
3215
+ // right after this returns; the forced (test) path runs outside update(),
3216
+ // so it flushes this scene's dirtied headers itself.
3217
+ if (force) {
3218
+ for (const bucket of buckets) {
3219
+ if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
3220
+ device.queue.writeBuffer(bucket.drawHeap.buffer, bucket.headerDirtyMin, bucket.drawHeaderStaging.buffer, bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin, bucket.headerDirtyMax - bucket.headerDirtyMin);
3221
+ bucket.headerDirtyMin = Infinity;
3222
+ bucket.headerDirtyMax = 0;
3223
+ }
3224
+ }
3225
+ }
3226
+ }
2801
3227
  // ─── update / encodeIntoPass / frame / dispose ───────────────────
2802
3228
  /**
2803
3229
  * CPU-side data refresh: drain pending aset deltas, repack any
@@ -3032,7 +3458,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3032
3458
  // initial population for 10K ROs this collapses ~30K calls into
3033
3459
  // ~5 (arena, indices, plus one per bucket).
3034
3460
  arena.attrs.flush(device);
3035
- arena.indices.flush(device);
3461
+ // ─── Waste-triggered attribute-arena compaction ─────────────────
3462
+ // Relocate live allocations to the front of each chunk once
3463
+ // fragmentation waste crosses the floor — exact-size freelist reuse
3464
+ // can't reclaim a drifting size distribution, so the arena would
3465
+ // otherwise ratchet to its high-water forever. Runs AFTER the arena
3466
+ // flush (GPU buffer current) and BEFORE the header flush (so the
3467
+ // re-seated drawHeaders upload below). Indices live in a separate
3468
+ // buffer and are not compacted here.
3469
+ if (enableCompaction)
3470
+ runCompaction(false);
3036
3471
  for (const bucket of buckets) {
3037
3472
  if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
3038
3473
  device.queue.writeBuffer(bucket.drawHeap.buffer, bucket.headerDirtyMin, bucket.drawHeaderStaging.buffer, bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin, bucket.headerDirtyMax - bucket.headerDirtyMin);
@@ -3232,14 +3667,44 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3232
3667
  destroyBucketResources(b);
3233
3668
  }
3234
3669
  function dispose() {
3235
- arena.attrs.destroy();
3236
- arena.indices.destroy();
3670
+ // Stop receiving compaction remaps for the shared store.
3671
+ storageReg.dispose();
3672
+ // Shared storage: release this scene's allocations so the shared pool's
3673
+ // refcounts stay correct (another scene may still hold the same regions),
3674
+ // but don't destroy the buffers — the storage owner does that. Private
3675
+ // storage: the whole arena is torn down wholesale, so per-draw release is
3676
+ // unnecessary.
3677
+ if (!ownsStorage) {
3678
+ for (let drawId = 0; drawId < drawIdToBucket.length; drawId++) {
3679
+ if (drawIdToBucket[drawId] !== undefined)
3680
+ removeDraw(drawId);
3681
+ }
3682
+ }
3237
3683
  for (const b of buckets)
3238
3684
  destroyBucketResources(b);
3685
+ if (ownsStorage)
3686
+ storage.dispose();
3239
3687
  }
3240
3688
  // Test-only escape hatch for inspecting megacall bucket state. Not
3241
3689
  // part of the public API surface — keep cast at use-site.
3242
3690
  const _debug = {
3691
+ /** Force a full heap compaction pass (attribute arena + index buffer)
3692
+ * regardless of the waste heuristics (bypasses the floor + 50%-live
3693
+ * gate), re-seat all refs, flush. Returns residual waste afterwards in
3694
+ * bytes (attr bytes + index elements×4; 0 when fully packed). Test-only —
3695
+ * drives relocation deterministically without engineering megabytes of
3696
+ * fragmentation. Callers should `update()` afterward to flush drawTables /
3697
+ * re-dispatch the partition (the render path does this). */
3698
+ forceCompact() {
3699
+ runCompaction(true);
3700
+ return arena.attrs.totalWasteBytes();
3701
+ },
3702
+ /** Current arena fragmentation waste (bytes) — covers uniforms,
3703
+ * attributes, and index regions (all one arena now). */
3704
+ attrWasteBytes() { return arena.attrs.totalWasteBytes(); },
3705
+ /** Number of arena chunks (pages) currently open. >1 means group
3706
+ * placement rolled past one buffer cap. */
3707
+ pageCount() { return arena.attrs.chunkCount; },
3243
3708
  bucketsForTest() {
3244
3709
  return buckets.map(b => ({
3245
3710
  indirectBuf: b.slots[0].indirectBuf,
@@ -3279,12 +3744,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3279
3744
  // multi-chunk-aware validator needs per-bucket-chunkIdx
3280
3745
  // staging which is a bigger refactor; assert for now so
3281
3746
  // multi-chunk callers don't get false-passes.
3282
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
3747
+ if (arena.attrs.chunkCount > 1) {
3283
3748
  throw new Error("heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
3284
3749
  "Run with the default chunk cap (no second chunk opened) for now.");
3285
3750
  }
3286
3751
  const arenaSize = arena.attrs.chunk(0).buffer.size;
3287
- const indicesSize = arena.indices.chunk(0).buffer.size;
3752
+ const indicesSize = arenaSize; // indices live in the attr arena now
3288
3753
  const enc = device.createCommandEncoder({ label: "validateHeap" });
3289
3754
  const stage = (src, size) => {
3290
3755
  const c = device.createBuffer({
@@ -3295,7 +3760,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3295
3760
  return c;
3296
3761
  };
3297
3762
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3298
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
3763
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
3299
3764
  const TILE_K_LOCAL = 64;
3300
3765
  const dcs = [];
3301
3766
  for (const b of buckets) {
@@ -3348,7 +3813,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3348
3813
  for (let slot = 0; slot < recordCount; slot++) {
3349
3814
  const slotOff = slot * stride;
3350
3815
  for (const f of dc.bucket.layout.drawHeaderFields) {
3351
- if (f.name === "__layoutId")
3816
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name))
3352
3817
  continue;
3353
3818
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
3354
3819
  continue;
@@ -3384,18 +3849,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3384
3849
  ? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
3385
3850
  : "";
3386
3851
  const here = `here=[${arenaU32[refU32]},${arenaU32[refU32 + 1]},${arenaU32[refU32 + 2]},${arenaU32[refU32 + 3]},${arenaU32[refU32 + 4]},${arenaU32[refU32 + 5]},${arenaU32[refU32 + 6]},${arenaU32[refU32 + 7]}]`;
3387
- // Compare CPU shadow vs GPU read-back. Differ → GPU
3388
- // write corrupted; match → bug is in CPU-side write path
3389
- // OR the alloc never went through pool.acquire.
3390
- const shadowChunk = arena.attrs.chunk(0);
3391
- const shadowU32 = shadowChunk.peekShadowU32(ref, 8);
3392
- const shadowStr = `shadow=[${shadowU32[0]},${shadowU32[1]},${shadowU32[2]},${shadowU32[3]},${shadowU32[4]},${shadowU32[5]},${shadowU32[6]},${shadowU32[7]}]`;
3393
- const cpuMatches = shadowU32[0] === arenaU32[refU32]
3394
- && shadowU32[1] === arenaU32[refU32 + 1]
3395
- && shadowU32[2] === arenaU32[refU32 + 2]
3396
- && shadowU32[3] === arenaU32[refU32 + 3];
3397
- const verdict = cpuMatches ? "GPU=CPU (likely CPU-write bug)" : "GPU≠CPU (likely GPU-write corruption)";
3398
- push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${verdict} ${before}${here} ${shadowStr}`);
3852
+ // (Mirror-less arena: no CPU shadow to cross-check against
3853
+ // the GPU read-back is the only copy.)
3854
+ push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${before}${here}`);
3399
3855
  attrAllocsBad++;
3400
3856
  continue;
3401
3857
  }
@@ -3598,11 +4054,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3598
4054
  const push = (s) => { if (issues.length < 30)
3599
4055
  issues.push(s); };
3600
4056
  // §3 v1: chunk-0-only like validateHeap.
3601
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4057
+ if (arena.attrs.chunkCount > 1) {
3602
4058
  throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
3603
4059
  }
3604
4060
  const arenaSize = arena.attrs.chunk(0).buffer.size;
3605
- const indicesSize = arena.indices.chunk(0).buffer.size;
4061
+ const indicesSize = arenaSize; // indices live in the attr arena now
3606
4062
  // Stage all buffers we'll need.
3607
4063
  const enc = device.createCommandEncoder({ label: "simulateDraws" });
3608
4064
  const stage = (src, size) => {
@@ -3613,7 +4069,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3613
4069
  return c;
3614
4070
  };
3615
4071
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3616
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
4072
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
3617
4073
  const dcs = [];
3618
4074
  for (const b of buckets) {
3619
4075
  if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
@@ -3654,7 +4110,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3654
4110
  for (const dc of dcs) {
3655
4111
  const fs = [];
3656
4112
  for (const f of dc.bucket.layout.drawHeaderFields) {
3657
- if (f.name === "__layoutId")
4113
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name))
3658
4114
  continue;
3659
4115
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
3660
4116
  continue;
@@ -3865,11 +4321,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3865
4321
  // cross-RO triangle symptom.
3866
4322
  const allocClaims = [];
3867
4323
  // §3 v1: chunk-0-only (see validateHeap).
3868
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4324
+ if (arena.attrs.chunkCount > 1) {
3869
4325
  throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
3870
4326
  }
3871
4327
  const arenaBuf0 = arena.attrs.chunk(0).buffer;
3872
- const indicesBuf0 = arena.indices.chunk(0).buffer;
4328
+ const indicesBuf0 = arena.attrs.chunk(0).buffer;
3873
4329
  // Stage arena + arena.indices once (shared across buckets).
3874
4330
  const arenaCopy = device.createBuffer({
3875
4331
  size: arenaBuf0.size,
@@ -4322,7 +4778,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
4322
4778
  { binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
4323
4779
  { binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
4324
4780
  { binding: 2, resource: { buffer: sampleBuf } },
4325
- { binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
4781
+ { binding: 3, resource: { buffer: arena.attrs.chunk(target.chunkIdx).buffer } },
4326
4782
  { binding: 4, resource: { buffer: outBuf } },
4327
4783
  { binding: 5, resource: { buffer: paramBuf } },
4328
4784
  ],
@@ -4346,7 +4802,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
4346
4802
  outBuf.destroy();
4347
4803
  paramBuf.destroy();
4348
4804
  // Read drawTable + indices for CPU expectation.
4349
- const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
4805
+ const idxBuf = arena.attrs.chunk(target.chunkIdx).buffer;
4350
4806
  const indicesCopy2 = device.createBuffer({
4351
4807
  size: idxBuf.size,
4352
4808
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,