@aardworx/wombat.rendering 0.19.24 → 0.20.0

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 (137) 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 +18 -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 +28 -5
  12. package/dist/resources/preparedRenderObject.js.map +1 -1
  13. package/dist/resources/uniformBuffer.d.ts.map +1 -1
  14. package/dist/resources/uniformBuffer.js +3 -1
  15. package/dist/resources/uniformBuffer.js.map +1 -1
  16. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +2 -12
  17. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  18. package/dist/runtime/derivedModes/modeKeyCpu.js +30 -13
  19. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  20. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +11 -0
  21. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -1
  22. package/dist/runtime/derivedModes/partitionDispatcher.js +31 -0
  23. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -1
  24. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
  25. package/dist/runtime/derivedUniforms/codegen.js +34 -13
  26. package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
  27. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  28. package/dist/runtime/derivedUniforms/dispatch.js +6 -0
  29. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/records.d.ts +15 -0
  31. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
  32. package/dist/runtime/derivedUniforms/records.js +81 -14
  33. package/dist/runtime/derivedUniforms/records.js.map +1 -1
  34. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
  35. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  36. package/dist/runtime/derivedUniforms/sceneIntegration.js +18 -4
  37. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  38. package/dist/runtime/flattenTree.d.ts.map +1 -1
  39. package/dist/runtime/flattenTree.js +46 -2
  40. package/dist/runtime/flattenTree.js.map +1 -1
  41. package/dist/runtime/heapAdapter.d.ts +3 -1
  42. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  43. package/dist/runtime/heapAdapter.js +45 -4
  44. package/dist/runtime/heapAdapter.js.map +1 -1
  45. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  46. package/dist/runtime/heapDecoder.js +8 -3
  47. package/dist/runtime/heapDecoder.js.map +1 -1
  48. package/dist/runtime/heapEffect.d.ts +36 -1
  49. package/dist/runtime/heapEffect.d.ts.map +1 -1
  50. package/dist/runtime/heapEffect.js +29 -2
  51. package/dist/runtime/heapEffect.js.map +1 -1
  52. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  53. package/dist/runtime/heapEffectIR.js +51 -11
  54. package/dist/runtime/heapEffectIR.js.map +1 -1
  55. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  56. package/dist/runtime/heapEligibility.js +64 -12
  57. package/dist/runtime/heapEligibility.js.map +1 -1
  58. package/dist/runtime/heapIrBuilders.d.ts +8 -0
  59. package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
  60. package/dist/runtime/heapIrBuilders.js +47 -2
  61. package/dist/runtime/heapIrBuilders.js.map +1 -1
  62. package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
  63. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
  64. package/dist/runtime/heapScene/chunkedArena.js +28 -91
  65. package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
  66. package/dist/runtime/heapScene/freelist.d.ts +1 -1
  67. package/dist/runtime/heapScene/freelist.js +3 -3
  68. package/dist/runtime/heapScene/freelist.js.map +1 -1
  69. package/dist/runtime/heapScene/pools.d.ts +103 -59
  70. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  71. package/dist/runtime/heapScene/pools.js +335 -194
  72. package/dist/runtime/heapScene/pools.js.map +1 -1
  73. package/dist/runtime/heapScene.d.ts +125 -1
  74. package/dist/runtime/heapScene.d.ts.map +1 -1
  75. package/dist/runtime/heapScene.js +671 -126
  76. package/dist/runtime/heapScene.js.map +1 -1
  77. package/dist/runtime/heapShaderFamily.d.ts.map +1 -1
  78. package/dist/runtime/heapShaderFamily.js +19 -0
  79. package/dist/runtime/heapShaderFamily.js.map +1 -1
  80. package/dist/runtime/hybridScene.d.ts +6 -0
  81. package/dist/runtime/hybridScene.d.ts.map +1 -1
  82. package/dist/runtime/hybridScene.js +36 -3
  83. package/dist/runtime/hybridScene.js.map +1 -1
  84. package/dist/runtime/index.d.ts +1 -1
  85. package/dist/runtime/index.d.ts.map +1 -1
  86. package/dist/runtime/index.js +1 -1
  87. package/dist/runtime/index.js.map +1 -1
  88. package/dist/runtime/renderTask.d.ts +6 -0
  89. package/dist/runtime/renderTask.d.ts.map +1 -1
  90. package/dist/runtime/renderTask.js +3 -0
  91. package/dist/runtime/renderTask.js.map +1 -1
  92. package/dist/runtime/runtime.d.ts +15 -0
  93. package/dist/runtime/runtime.d.ts.map +1 -1
  94. package/dist/runtime/runtime.js +3 -0
  95. package/dist/runtime/runtime.js.map +1 -1
  96. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts +19 -2
  97. package/dist/runtime/textureAtlas/atlasMipGutterKernel.d.ts.map +1 -1
  98. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js +37 -6
  99. package/dist/runtime/textureAtlas/atlasMipGutterKernel.js.map +1 -1
  100. package/dist/runtime/textureAtlas/atlasPool.d.ts +5 -0
  101. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  102. package/dist/runtime/textureAtlas/atlasPool.js +25 -3
  103. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  104. package/dist/window/loop.d.ts.map +1 -1
  105. package/dist/window/loop.js +31 -4
  106. package/dist/window/loop.js.map +1 -1
  107. package/package.json +3 -3
  108. package/src/core/elementType.ts +10 -0
  109. package/src/core/provider.ts +56 -28
  110. package/src/core/renderObject.ts +18 -0
  111. package/src/resources/preparedRenderObject.ts +29 -6
  112. package/src/resources/uniformBuffer.ts +3 -1
  113. package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
  114. package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
  115. package/src/runtime/derivedUniforms/codegen.ts +34 -13
  116. package/src/runtime/derivedUniforms/dispatch.ts +6 -0
  117. package/src/runtime/derivedUniforms/records.ts +70 -15
  118. package/src/runtime/derivedUniforms/sceneIntegration.ts +19 -4
  119. package/src/runtime/flattenTree.ts +47 -2
  120. package/src/runtime/heapAdapter.ts +43 -5
  121. package/src/runtime/heapDecoder.ts +8 -3
  122. package/src/runtime/heapEffect.ts +44 -2
  123. package/src/runtime/heapEffectIR.ts +54 -11
  124. package/src/runtime/heapEligibility.ts +39 -12
  125. package/src/runtime/heapIrBuilders.ts +50 -2
  126. package/src/runtime/heapScene/chunkedArena.ts +29 -94
  127. package/src/runtime/heapScene/freelist.ts +3 -3
  128. package/src/runtime/heapScene/pools.ts +340 -218
  129. package/src/runtime/heapScene.ts +826 -142
  130. package/src/runtime/heapShaderFamily.ts +21 -0
  131. package/src/runtime/hybridScene.ts +43 -3
  132. package/src/runtime/index.ts +5 -0
  133. package/src/runtime/renderTask.ts +9 -0
  134. package/src/runtime/runtime.ts +18 -0
  135. package/src/runtime/textureAtlas/atlasMipGutterKernel.ts +52 -3
  136. package/src/runtime/textureAtlas/atlasPool.ts +28 -2
  137. package/src/window/loop.ts +32 -4
@@ -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, {
@@ -544,6 +679,18 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
544
679
  // that don't yet point at an allocated AtlasPage. WebGPU rejects
545
680
  // bind-group entries with `undefined` resources, so every slot in
546
681
  // the N-wide ladder must hold a valid view at all times.
682
+ // 4-byte placeholder storage buffer — binds user-storage slots on the
683
+ // seed bind group built before the first addDraw delivers the real
684
+ // buffers (rebuilt immediately after).
685
+ let storagePlaceholder;
686
+ function getStoragePlaceholder() {
687
+ if (storagePlaceholder === undefined) {
688
+ storagePlaceholder = device.createBuffer({
689
+ label: "heapScene/storagePlaceholder", size: 4, usage: GPUBufferUsage.STORAGE,
690
+ });
691
+ }
692
+ return storagePlaceholder;
693
+ }
547
694
  let atlasPlaceholders;
548
695
  function getAtlasPlaceholder(format) {
549
696
  if (atlasPlaceholders === undefined) {
@@ -566,6 +713,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
566
713
  function getBgl(layout, withAtlasArrays) {
567
714
  const key = `t${layout.textureBindings.map(t => t.wgslType).join(",")}` +
568
715
  `|s${layout.samplerBindings.map(s => s.wgslType).join(",")}` +
716
+ `|sb${layout.storageBindings.map(b => `${b.binding}${b.access === "read_write" ? "w" : "r"}`).join(",")}` +
569
717
  `|a${withAtlasArrays ? 1 : 0}`;
570
718
  let e = bglCache.get(key);
571
719
  if (e !== undefined)
@@ -593,6 +741,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
593
741
  sampler: { type: s.wgslType === "sampler_comparison" ? "comparison" : "filtering" },
594
742
  });
595
743
  }
744
+ for (const sb of layout.storageBindings) {
745
+ // FRAGMENT only: WebGPU forbids writable storage in vertex stages,
746
+ // and the OIT writers are fragment-side anyway.
747
+ entries.push({
748
+ binding: sb.binding, visibility: GPUShaderStage.FRAGMENT,
749
+ buffer: { type: sb.access === "read_write" ? "storage" : "read-only-storage" },
750
+ });
751
+ }
596
752
  if (withAtlasArrays) {
597
753
  // 2N + 1 entries: N linear textures, N srgb textures, 1 sampler.
598
754
  for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
@@ -697,11 +853,32 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
697
853
  if (cached !== undefined)
698
854
  return cached;
699
855
  const { pipelineLayout } = getBgl(layout, isAtlasBucket);
856
+ // Per-descriptor color targets: blend + write mask come from the RO's
857
+ // PipelineState (`desc.attachments`, ordered by signature colorNames —
858
+ // the same values the modeKey encodes). Using the bare scene-level
859
+ // `colorTargets` here silently dropped blends/write-masks from every
860
+ // heap pipeline: transparent heap leaves painted opaque, and the OIT
861
+ // pick passes' write-mask-0 "Colors" leaked color onto the composite.
862
+ const targets = colorTargets.map((t, i) => {
863
+ const a = desc.attachments[i];
864
+ if (a === undefined)
865
+ return { format: t.format };
866
+ return {
867
+ format: t.format,
868
+ writeMask: a.writeMask,
869
+ ...(a.enabled
870
+ ? { blend: {
871
+ color: { operation: a.color.operation, srcFactor: a.color.srcFactor, dstFactor: a.color.dstFactor },
872
+ alpha: { operation: a.alpha.operation, srcFactor: a.alpha.srcFactor, dstFactor: a.alpha.dstFactor },
873
+ } }
874
+ : {}),
875
+ };
876
+ });
700
877
  const pipeline = device.createRenderPipeline({
701
878
  label: `heapScene/cached/${cacheKey}`,
702
879
  layout: pipelineLayout,
703
880
  vertex: { module: fam.vsModule, entryPoint: fam.vsEntryName, buffers: [] },
704
- fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets: colorTargets },
881
+ fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets },
705
882
  primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
706
883
  ...(depthFormat !== undefined && desc.depth !== undefined
707
884
  ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
@@ -826,6 +1003,29 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
826
1003
  }
827
1004
  familyFor(effect); // throws if unknown
828
1005
  }
1006
+ /**
1007
+ * Reactive family rebuild: register a new effect's family when it first
1008
+ * appears after the initial freeze. Family-merge is disabled, so every
1009
+ * Effect already gets its OWN independent single-member family (its own
1010
+ * shader modules + buckets); a late effect just needs its family compiled
1011
+ * and added — no rebuild of existing families. Idempotent.
1012
+ */
1013
+ function ensureFamilyForSpec(spec) {
1014
+ if (!familyBuilt) {
1015
+ buildFamilyFromSpecs([spec]);
1016
+ return;
1017
+ }
1018
+ if (familyByEffectId.has(spec.effect.id))
1019
+ return;
1020
+ const entry = { attributes: new Set(), uniforms: new Set() };
1021
+ if (spec.instanceAttributes !== undefined) {
1022
+ for (const name of Object.keys(spec.instanceAttributes))
1023
+ entry.attributes.add(name);
1024
+ }
1025
+ const singleMap = new Map();
1026
+ singleMap.set(spec.effect, entry);
1027
+ familyByEffectId.set(spec.effect.id, compileFamilyFor([spec.effect], singleMap));
1028
+ }
829
1029
  // ─── id-of helpers ────────────────────────────────────────────────
830
1030
  const idOf = (s) => `effect#${s.id}`;
831
1031
  const textureIds = new WeakMap();
@@ -961,7 +1161,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
961
1161
  // binary search. Minimum one zero-record to satisfy WebGPU non-
962
1162
  // zero size constraint when the bucket is empty.
963
1163
  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 } });
1164
+ entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1165
+ // Index data lives in the attr arena now (the VS storage-decodes it);
1166
+ // bind the same buffer as the gather views.
1167
+ { binding: 5, resource: { buffer: attrsBuf } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
965
1168
  s.renderBoundRecordCount = s.recordCount;
966
1169
  }
967
1170
  // Schema-driven texture + sampler entries. v1 user surface still
@@ -1009,6 +1212,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1009
1212
  // here — the atlas slots come from the per-format page tracking
1010
1213
  // below.
1011
1214
  }
1215
+ for (const sb of bucket.layout.storageBindings) {
1216
+ const av = bucket.storageBuffers?.get(sb.name);
1217
+ if (av === undefined) {
1218
+ // Buffers arrive with the first addDraw; the seed slot built at
1219
+ // bucket creation is rebuilt right after they land. Bind a
1220
+ // 4-byte placeholder so the seed bind group validates.
1221
+ entries.push({ binding: sb.binding, resource: { buffer: getStoragePlaceholder() } });
1222
+ continue;
1223
+ }
1224
+ const ib = av.force( /* allow-force */);
1225
+ if (ib.kind !== "gpu") {
1226
+ throw new Error(`heapScene: user storage buffer '${sb.name}' must be a GPU-backed IBuffer`);
1227
+ }
1228
+ entries.push({ binding: sb.binding, resource: { buffer: ib.buffer } });
1229
+ }
1012
1230
  if (bucket.isAtlasBucket) {
1013
1231
  if (atlasPool === undefined) {
1014
1232
  throw new Error("heapScene: atlas-variant bucket needs `atlasPool` in BuildHeapSceneOptions");
@@ -1081,15 +1299,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1081
1299
  atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
1082
1300
  }
1083
1301
  }
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
1302
  // ─── findOrCreateBucket ───────────────────────────────────────────
1094
1303
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
1095
1304
  // Every member effect in the family shares one bucket per
@@ -1746,7 +1955,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1746
1955
  if (cpuMigration !== undefined) {
1747
1956
  let totalEmit = 0;
1748
1957
  for (const m of cpuMigration) {
1749
- const uRefs = bucket.uniformOrder.map(n => m.perDrawRefs.get(n) ?? 0);
1958
+ const uRefs = bucket.uniformOrder.map(n => {
1959
+ const i = bucket.fieldIdx.get(n);
1960
+ const r = i === undefined ? -1 : m.perDrawRefs[i];
1961
+ return r < 0 ? 0 : r;
1962
+ });
1750
1963
  const recIdx = partition.appendRecord(m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs);
1751
1964
  bucket.drawIdToRecord.set(m.drawId, recIdx);
1752
1965
  bucket.recordToDrawId[recIdx] = m.drawId;
@@ -1857,8 +2070,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1857
2070
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
1858
2071
  const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
1859
2072
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
2073
+ const fieldIdx = new Map();
2074
+ layout.drawHeaderFields.forEach((f, i) => fieldIdx.set(f.name, i));
1860
2075
  const bucket = {
1861
2076
  label: bk, textures: undefined, layout, chunkIdx,
2077
+ storageBuffers: undefined, storageDirty: false, storageSubs: [],
1862
2078
  slots: [], // populated lazily by ensureSlot per modeKey
1863
2079
  bindGroup: null, // legacy field; slot.bindGroup is the real one
1864
2080
  drawHeap,
@@ -1866,7 +2082,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1866
2082
  headerDirtyMin: Infinity, headerDirtyMax: 0,
1867
2083
  localPosRefs: [], localNorRefs: [],
1868
2084
  localEntries: [], localToDrawId: [],
1869
- localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
2085
+ localPerDrawAvals: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
2086
+ fieldIdx,
2087
+ posFieldIdx: fieldIdx.get("Positions") ?? -1,
2088
+ norFieldIdx: fieldIdx.get("Normals") ?? -1,
1870
2089
  drawSlots: new Set(), dirty: new Set(),
1871
2090
  isAtlasBucket,
1872
2091
  localAtlasReleases: [],
@@ -1936,7 +2155,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1936
2155
  const dst = bucket.drawHeaderStaging;
1937
2156
  const u32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length);
1938
2157
  const baseFloat = (localSlot * bucket.layout.drawHeaderBytes) / 4;
1939
- for (const f of bucket.layout.drawHeaderFields) {
2158
+ const fields = bucket.layout.drawHeaderFields;
2159
+ for (let i = 0; i < fields.length; i++) {
2160
+ const f = fields[i];
1940
2161
  // texture-ref fields carry inline values (pageRef/formatBits as u32,
1941
2162
  // origin/size as vec2<f32>) and are filled by packAtlasTextureFields.
1942
2163
  if (f.kind === "texture-ref")
@@ -1950,15 +2171,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1950
2171
  u32[fOff] = layoutId >>> 0;
1951
2172
  continue;
1952
2173
  }
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;
2174
+ const ref = perDrawRefs[i];
2175
+ // Family-merge: a slot's effect doesn't populate every field of
2176
+ // the union (−1); leave the unused slots zero — the layoutId
2177
+ // switch ensures they're never read by the wrong effect's helper.
2178
+ u32[fOff] = ref < 0 ? 0 : ref;
1962
2179
  }
1963
2180
  }
1964
2181
  // ─── Atlas drawHeader packing + page-set tracking ─────────────────
@@ -2043,6 +2260,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2043
2260
  totalDraws: 0,
2044
2261
  drawBytes: 0,
2045
2262
  geometryBytes: 0,
2263
+ compactions: 0,
2046
2264
  derivedPullMs: 0,
2047
2265
  derivedUploadMs: 0,
2048
2266
  derivedEncodeMs: 0,
@@ -2085,12 +2303,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2085
2303
  // single spec when no batched lazy-build occurred earlier (e.g.
2086
2304
  // direct addDraw at runtime). The aset / array initial-population
2087
2305
  // paths build from the full effect set up front.
2088
- if (!familyBuilt) {
2089
- buildFamilyFromSpecs([spec]);
2090
- }
2091
- else {
2092
- ensureFamilyKnowsEffect(spec.effect);
2093
- }
2306
+ // Build the family lazily from this spec (first addDraw), or register a
2307
+ // newly-seen effect's family on the fly (reactive rebuild) — supports a
2308
+ // scene mixing multiple effects added over time (tiles + markers + …).
2309
+ ensureFamilyForSpec(spec);
2094
2310
  const perInstanceUniforms = new Set();
2095
2311
  const perInstanceAttributes = spec.instanceAttributes !== undefined
2096
2312
  ? new Set(Object.keys(spec.instanceAttributes))
@@ -2111,16 +2327,48 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2111
2327
  token: outerTok,
2112
2328
  });
2113
2329
  }
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.
2330
+ // ─── Group-fit page placement (§3) ───────────────────────────────
2331
+ // A draw's whole group — uniforms + attributes + INDEX data (all one
2332
+ // arena now) must land on ONE chunk/page (the draw binds one buffer).
2333
+ // Estimate the group's arena bytes (exact per-field via the same
2334
+ // placement helpers, ignoring dedup ⇒ an upper bound on the NEW bytes a
2335
+ // chunk must absorb), then place the group on a chunk with room — prefer
2336
+ // one already holding a shared input, else the newest, else open a fresh
2337
+ // page. This replaces the old cross-chunk spill throw, so a scene can
2338
+ // grow past one buffer's cap (WebGPU's storage-buffer binding limit).
2339
+ const fam = familyFor(spec.effect);
2340
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id);
2341
+ const layout = fam.schema.drawHeaderUnion;
2342
+ const hasIndices = spec.indices !== undefined;
2343
+ const indicesAval = hasIndices ? asAval(spec.indices) : undefined;
2344
+ const indexArr = hasIndices ? readPlain(spec.indices) : undefined;
2345
+ const instanceCount = spec.instanceCount !== undefined
2346
+ ? readPlain(spec.instanceCount) : 1;
2347
+ let estBytes = indexArr !== undefined ? ALIGN16(ALLOC_HEADER_PAD_TO + indexArr.byteLength) : 0;
2348
+ for (const f of layout.drawHeaderFields) {
2349
+ if (f.kind === "texture-ref" || f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name) || !effectFields.has(f.name))
2350
+ continue;
2351
+ if (ruleForUniform(spec, f.name) !== undefined) {
2352
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + PACKER_MAT4.dataBytes); // §7 derived dummy mat4
2353
+ continue;
2354
+ }
2355
+ const perInstUniform = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
2356
+ const perInstAttr = f.kind === "attribute-ref" && perInstanceAttributes.has(f.name);
2357
+ const provided = perInstAttr ? spec.instanceAttributes[f.name] : spec.inputs[f.name];
2358
+ if (provided === undefined)
2359
+ continue; // the acquire loop throws with a precise message
2360
+ let dataBytes;
2361
+ if (f.kind === "attribute-ref" && !perInstUniform && isBufferView(provided)) {
2362
+ dataBytes = bufferViewPlacement(f, provided).dataBytes;
2363
+ }
2364
+ else {
2365
+ const value = asAval(provided).getValue(outerTok);
2366
+ dataBytes = (perInstUniform
2367
+ ? perInstancePlacementFor(f, value, instanceCount)
2368
+ : poolPlacementFor(f, value)).dataBytes;
2369
+ }
2370
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
2371
+ }
2124
2372
  let chunkIdx;
2125
2373
  {
2126
2374
  let preferred;
@@ -2135,9 +2383,35 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2135
2383
  }
2136
2384
  }
2137
2385
  }
2138
- chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
2386
+ const fits = (c) => arena.attrs.bumpHeadroom(c) >= estBytes;
2387
+ const newest = arena.attrs.chunkCount - 1;
2388
+ chunkIdx =
2389
+ preferred !== undefined && fits(preferred) ? preferred
2390
+ : fits(newest) ? newest
2391
+ : arena.attrs.openPage();
2139
2392
  }
2140
2393
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
2394
+ // User storage buffers (layout.storageBindings): bucket-level, set once.
2395
+ if (bucket.layout.storageBindings.length > 0 && bucket.storageBuffers === undefined) {
2396
+ if (spec.storageBuffers === undefined) {
2397
+ throw new Error(`heapScene: bucket '${bucket.label}' needs storage buffers ` +
2398
+ `(${bucket.layout.storageBindings.map(b => b.name).join(", ")}) but spec.storageBuffers is undefined`);
2399
+ }
2400
+ bucket.storageBuffers = spec.storageBuffers;
2401
+ // A storage aval marking (e.g. the OIT head buffer reallocating on a
2402
+ // canvas resize) invalidates every slot bind group of this bucket.
2403
+ for (const av of spec.storageBuffers.values()) {
2404
+ const disposer = av
2405
+ .addMarkingCallback?.(() => { bucket.storageDirty = true; });
2406
+ if (disposer !== undefined)
2407
+ bucket.storageSubs.push(disposer);
2408
+ }
2409
+ // Slots seeded before the buffers were known carry incomplete bind
2410
+ // groups — rebuild them now.
2411
+ for (let i = 0; i < bucket.slots.length; i++) {
2412
+ bucket.slots[i].bindGroup = buildBucketBindGroup(bucket, i);
2413
+ }
2414
+ }
2141
2415
  // Phase 5c.3 — every RO carrying a derived-mode rule is registered
2142
2416
  // on the bucket (lazy-promoting it to GPU-routed on the first
2143
2417
  // call). ROs may carry DIFFERENT rules even when they share the
@@ -2169,18 +2443,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2169
2443
  // tables every frame from the master.
2170
2444
  const roSlot = bucket.gpuRouted ? bucket.slots[0] : ensureSlot(bucket, roDescriptor);
2171
2445
  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;
2446
+ // Indices ride the same arena as attributes/uniforms (the VS storage-
2447
+ // decodes them). Aval-keyed: 19K instanced clones of the same mesh share
2448
+ // one index allocation + one upload. NON-INDEXED draws (spec.indices
2449
+ // omitted) skip the pool: the drawTable record stores firstIndex =
2450
+ // HEAP_NONINDEXED (sentinel) and indexCount = vertexCount, and the
2451
+ // megacall prelude uses the local vertex index directly. `indicesAval` /
2452
+ // `indexArr` were read above for the group estimate; the group landed
2453
+ // wholly on `bucket.chunkIdx`, so this allocation can't spill.
2182
2454
  const idxAlloc = hasIndices
2183
- ? indexPool.acquire(device, arena.indices, indicesAval, bucket.chunkIdx, readPlain(spec.indices))
2455
+ ? indexPool.acquire(device, arena.attrs, indicesAval, bucket.chunkIdx, indexArr)
2184
2456
  : undefined;
2185
2457
  // Emit count per instance + the firstIndex field written to the record.
2186
2458
  // Slice: emit only [firstIndex, firstIndex+indexCount) of the (shared,
@@ -2189,17 +2461,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2189
2461
  const emitCount = hasIndices ? (spec.indexCount ?? idxAlloc.count) : (spec.vertexCount ?? 0);
2190
2462
  const firstIndexField = hasIndices ? (idxAlloc.firstIndex + (spec.firstIndex ?? 0)) : HEAP_NONINDEXED;
2191
2463
  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
2464
  // Walk the bucket's schema-driven DrawHeader fields. Per-instance
2197
2465
  // attributes pull from `spec.instanceAttributes` and pack into an
2198
2466
  // array allocation; everything else pulls from `spec.inputs` and
2199
2467
  // packs as a single value. Both go through the same pool — sharing
2200
2468
  // emerges from aval identity either way.
2201
2469
  const perDrawAvals = [];
2202
- const perDrawRefs = new Map();
2470
+ let perDrawRawFieldIdx;
2471
+ const perDrawRefs = new Int32Array(bucket.layout.drawHeaderFields.length).fill(-1);
2472
+ const setRef = (name, ref) => {
2473
+ const i = bucket.fieldIdx.get(name);
2474
+ if (i !== undefined)
2475
+ perDrawRefs[i] = ref;
2476
+ };
2477
+ const getRef = (name) => {
2478
+ const i = bucket.fieldIdx.get(name);
2479
+ if (i === undefined)
2480
+ return undefined;
2481
+ const r = perDrawRefs[i];
2482
+ return r < 0 ? undefined : r;
2483
+ };
2203
2484
  {
2204
2485
  const tok = outerTok;
2205
2486
  for (const f of bucket.layout.drawHeaderFields) {
@@ -2214,15 +2495,42 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2214
2495
  continue;
2215
2496
  if (!effectFields.has(f.name))
2216
2497
  continue;
2498
+ // Inline header uniforms (PickId): the header word IS the u32
2499
+ // value — write it via perDrawRefs (packBucketHeader copies
2500
+ // refs verbatim), skip the pool entirely. A provider-supplied
2501
+ // PickId aval still works below as the (slow) fallback.
2502
+ if (INLINE_HEADER_UNIFORMS.has(f.name)) {
2503
+ const iv = f.name === "PickId" ? spec.pickId : undefined;
2504
+ if (iv !== undefined) {
2505
+ setRef(f.name, iv >>> 0);
2506
+ continue;
2507
+ }
2508
+ const provInline = spec.inputs[f.name];
2509
+ if (typeof provInline === "number") {
2510
+ setRef(f.name, provInline >>> 0);
2511
+ continue;
2512
+ }
2513
+ if (provInline === undefined) {
2514
+ // No id at all (picking disabled but the effect still
2515
+ // reads the uniform): 0 = "no pick", a reserved id.
2516
+ setRef(f.name, 0);
2517
+ continue;
2518
+ }
2519
+ // fall through: aval-provided → legacy pooled path
2520
+ }
2217
2521
  // §7: derived-uniform fields are produced by the compute
2218
2522
  // pre-pass. We still allocate an arena slot here so the
2219
2523
  // drawHeader gets a valid ref written by packBucketHeader; §7
2220
2524
  // overwrites the arena data each frame the inputs are dirty.
2221
2525
  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);
2526
+ // Raw (aval-less) slot: §7's compute pre-pass overwrites the
2527
+ // data every dirty frame, so a keyed pool entry (placeholder
2528
+ // ConstantVal + Map per draw) bought nothing. The ref lives
2529
+ // in the drawHeader cell (compaction re-seats it there);
2530
+ // removeDraw frees it via `perDrawRawFieldIdx`.
2531
+ const ref = pool.allocRaw(arena.attrs, bucket.chunkIdx, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1);
2532
+ setRef(f.name, ref);
2533
+ (perDrawRawFieldIdx ??= []).push(bucket.fieldIdx.get(f.name));
2226
2534
  continue;
2227
2535
  }
2228
2536
  const isPerInstanceUniformField = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
@@ -2249,12 +2557,37 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2249
2557
  let av;
2250
2558
  let value;
2251
2559
  let placement;
2560
+ let releasable;
2252
2561
  if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
2253
2562
  const bv = provided;
2254
2563
  placement = bufferViewPlacement(f, bv);
2255
2564
  av = bv.buffer;
2256
2565
  value = bv.buffer.getValue(tok);
2257
2566
  }
2567
+ else if (releaseConstantAttributes
2568
+ && f.kind === "attribute-ref"
2569
+ && !isPerInstanceUniformField
2570
+ && !(typeof provided === "object" && provided !== null
2571
+ && typeof provided.getValue === "function")) {
2572
+ // Plain value (raw array / packed marker) → CONSTANT by definition.
2573
+ // Wrap in a droppable holder: after this field's arena write the GPU
2574
+ // copy is the only copy the heap ever reads again, so the payload is
2575
+ // released (real avals never take this branch — changeable data
2576
+ // stays alive by contract).
2577
+ const holder = {
2578
+ _v: provided,
2579
+ getValue() {
2580
+ if (holder._v === null) {
2581
+ throw new Error("heapScene: constant attribute payload was released after staging (releaseConstantAttributes)");
2582
+ }
2583
+ return holder._v;
2584
+ },
2585
+ };
2586
+ releasable = holder;
2587
+ av = holder;
2588
+ value = provided;
2589
+ placement = poolPlacementFor(f, value);
2590
+ }
2258
2591
  else {
2259
2592
  av = asAval(provided);
2260
2593
  value = av.getValue(tok);
@@ -2263,18 +2596,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2263
2596
  : poolPlacementFor(f, value);
2264
2597
  }
2265
2598
  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);
2599
+ if (releasable !== undefined)
2600
+ releasable._v = null; // staged — drop the CPU copy
2601
+ setRef(f.name, ref);
2267
2602
  perDrawAvals.push(av);
2268
2603
  }
2269
2604
  }
2270
- bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
2271
- bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
2605
+ bucket.localPosRefs[localSlot] = getRef("Positions");
2606
+ bucket.localNorRefs[localSlot] = getRef("Normals");
2272
2607
  bucket.localEntries[localSlot] = {
2273
2608
  indexCount: emitCount, firstIndex: firstIndexField, instanceCount,
2274
2609
  };
2275
2610
  bucket.localToDrawId[localSlot] = drawId;
2276
2611
  bucket.drawSlots.add(localSlot);
2277
2612
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
2613
+ bucket.localPerDrawRawFieldIdx[localSlot] = perDrawRawFieldIdx;
2278
2614
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
2279
2615
  const layoutId = fam.schema.layoutIdOf.get(spec.effect);
2280
2616
  bucket.localLayoutIds[localSlot] = layoutId;
@@ -2377,7 +2713,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2377
2713
  // don't carry a given name (e.g. their effect doesn't declare
2378
2714
  // it) get 0 — the kernel only reads refs through combo fns that
2379
2715
  // gate on `r.comboId`, so an unread 0 is harmless.
2380
- const uniformRefs = (bucket.uniformOrder ?? []).map(name => perDrawRefs.get(name) ?? 0);
2716
+ const uniformRefs = (bucket.uniformOrder ?? []).map(name => getRef(name) ?? 0);
2381
2717
  partition.appendRecord(localSlot, firstIndexField, emitCount, instanceCount, roComboId, uniformRefs);
2382
2718
  bucket.drawIdToRecord.set(drawId, recIdx);
2383
2719
  bucket.recordToDrawId[recIdx] = drawId;
@@ -2477,7 +2813,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2477
2813
  }
2478
2814
  uniformAvals = m;
2479
2815
  }
2480
- const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => { dirtyModeKeyDrawIds.add(drawId); }, {
2816
+ const tracker = new ModeKeyTracker(spec.pipelineState, sig,
2817
+ // skipSubscribe means the tracker never invokes onDirty itself
2818
+ // (scene-level modeAvalToDrawIds dispatches instead) — a shared
2819
+ // noop avoids one closure per draw.
2820
+ NOOP, {
2481
2821
  skipSubscribe: true,
2482
2822
  ...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
2483
2823
  ...(uniformAvals !== undefined ? { uniformAvals } : {}),
@@ -2509,14 +2849,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2509
2849
  const ruleSubset = new Map();
2510
2850
  const outOffsetByName = new Map();
2511
2851
  for (const f of bucket.layout.drawHeaderFields) {
2512
- if (f.name === "__layoutId")
2852
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name))
2513
2853
  continue;
2514
2854
  if (!effectFields.has(f.name))
2515
2855
  continue;
2516
2856
  const rule = ruleForUniform(spec, f.name);
2517
2857
  if (rule === undefined)
2518
2858
  continue;
2519
- const ref = perDrawRefs.get(f.name);
2859
+ const ref = getRef(f.name);
2520
2860
  if (ref === undefined)
2521
2861
  continue;
2522
2862
  ruleSubset.set(f.name, rule);
@@ -2545,7 +2885,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2545
2885
  trafoAvals,
2546
2886
  ...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
2547
2887
  hostUniformOffset: (n) => {
2548
- const r = perDrawRefs.get(n);
2888
+ const r = getRef(n);
2549
2889
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
2550
2890
  },
2551
2891
  outputOffset: (n) => outOffsetByName.get(n),
@@ -2660,9 +3000,23 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2660
3000
  if (avals !== undefined)
2661
3001
  for (const av of avals)
2662
3002
  pool.release(arena.attrs, av, bucket.chunkIdx);
3003
+ // Raw §7 slots: the live ref is the drawHeader cell (compaction
3004
+ // keeps it current); free it directly.
3005
+ const rawIdxs = bucket.localPerDrawRawFieldIdx[localSlot];
3006
+ if (rawIdxs !== undefined) {
3007
+ const rawRefs = bucket.localPerDrawRefs[localSlot];
3008
+ if (rawRefs !== undefined) {
3009
+ for (const fi of rawIdxs) {
3010
+ const ref = rawRefs[fi];
3011
+ if (ref >= 0)
3012
+ pool.releaseRaw(arena.attrs, bucket.chunkIdx, ref, PACKER_MAT4.dataBytes);
3013
+ }
3014
+ }
3015
+ bucket.localPerDrawRawFieldIdx[localSlot] = undefined;
3016
+ }
2663
3017
  const idxAval = drawIdToIndexAval[drawId];
2664
3018
  if (idxAval !== undefined)
2665
- indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
3019
+ indexPool.release(arena.attrs, idxAval, bucket.chunkIdx);
2666
3020
  const atlasRel = bucket.localAtlasReleases[localSlot];
2667
3021
  if (atlasRel !== undefined)
2668
3022
  atlasRel();
@@ -2729,14 +3083,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2729
3083
  if (asetReader === undefined)
2730
3084
  return;
2731
3085
  const delta = asetReader.getChanges(tok);
3086
+ // Process removals before adds. A remove+add in the same
3087
+ // transaction must free its slot / arena region / atlas tile
3088
+ // before the add allocates, so the freelist reuses the just-freed
3089
+ // block instead of growing the high-water toward
3090
+ // live + removed-this-tick. (Matches Aardvark's removals-first
3091
+ // delta discipline.)
3092
+ const adds = [];
2732
3093
  delta.iter((op) => {
2733
3094
  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);
3095
+ adds.push(op.value);
2740
3096
  }
2741
3097
  else {
2742
3098
  const id = specToDrawId.get(op.value);
@@ -2746,6 +3102,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2746
3102
  }
2747
3103
  }
2748
3104
  });
3105
+ // Re-use the outer evaluateAlways scope's token (caller is already
3106
+ // sceneObj) — collapses N nested evaluateAlways into one for a bulk
3107
+ // add. Saves a per-RO setUnsafeEvaluationDepth + outputs.add round trip.
3108
+ for (const value of adds) {
3109
+ const id = addDrawImpl(value, tok);
3110
+ specToDrawId.set(value, id);
3111
+ }
2749
3112
  }
2750
3113
  // ─── Initial population ───────────────────────────────────────────
2751
3114
  if (Array.isArray(initialDraws)) {
@@ -2764,9 +3127,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2764
3127
  asetReader = initialDraws.getReader();
2765
3128
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
2766
3129
  // 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).
3130
+ // effects up front, then run addDraw. Subsequent drains reuse the
3131
+ // family, or register a newly-seen effect's family on the fly
3132
+ // (ensureFamilyForSpec, via addDrawImpl).
2770
3133
  if (!familyBuilt) {
2771
3134
  const reader = asetReader;
2772
3135
  const delta = reader.getChanges(tok);
@@ -2798,6 +3161,148 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2798
3161
  }
2799
3162
  });
2800
3163
  }
3164
+ // ─── Heap compaction (attribute arena + index buffer) ────────────
3165
+ /**
3166
+ * Compact every arena/index chunk whose fragmentation waste crosses the
3167
+ * floor (or unconditionally when `force`), then re-seat EVERY cached ref so
3168
+ * the relocated allocations stay addressable. Attribute arena:
3169
+ * • uniform-pool entries (`pool.remapRefs`)
3170
+ * • §7 derived-uniform record handles (`derivedScene.remapHostHeap`)
3171
+ * • modes partition master refs (`partitionScene.remapUniformRefs`)
3172
+ * • drawHeader cells + the Pos/Nor caches (re-`packBucketHeader`)
3173
+ * Index buffer (ref = `allocBase + slice`, held in the drawTable / partition
3174
+ * master + IndexPool entry):
3175
+ * • per-draw `indexStart` in the CPU drawTable or partition master
3176
+ * • `localEntries[slot].firstIndex` + IndexPool entry base
3177
+ * Must run with the buffers current (we flush first) and before the
3178
+ * per-bucket header / drawTable flush so the re-seated records upload.
3179
+ */
3180
+ // Per-scene ref re-seat after the shared store relocates regions. Registered
3181
+ // with `storage`; the store calls it (for THIS scene + any others sharing the
3182
+ // store) inside `storage.compact`. Re-seats derived records, partition master
3183
+ // refs, drawHeader cells, and index `_indexStart` — everything this scene
3184
+ // caches that points into the arena. (The storage-level uniform/index pools
3185
+ // are re-seated by the store itself, around this call.)
3186
+ function applyRemap(chunkIdx, remap) {
3187
+ if (derivedScene !== undefined) {
3188
+ // §7 handles store DATA offsets (ref + header pad), not header refs.
3189
+ const dataRemap = new Map();
3190
+ for (const [o, n] of remap) {
3191
+ dataRemap.set(o + ALLOC_HEADER_PAD_TO, n + ALLOC_HEADER_PAD_TO);
3192
+ }
3193
+ derivedScene.remapHostHeap(chunkIdx, dataRemap);
3194
+ }
3195
+ {
3196
+ for (const bucket of buckets) {
3197
+ if (bucket.chunkIdx !== chunkIdx)
3198
+ continue;
3199
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
3200
+ if (bucket.partitionScene.remapUniformRefs(remap) > 0) {
3201
+ bucket.partitionDirty = true;
3202
+ }
3203
+ }
3204
+ for (const localSlot of bucket.drawSlots) {
3205
+ // (a) Index region re-seat. Index data is an attr-arena region now,
3206
+ // so it relocates in THIS pass. Its draw stores `_indexStart` (an
3207
+ // element offset = allocBase + slice) in the drawTable / partition
3208
+ // master; shift it by the region's element delta. Independent of the
3209
+ // uniform re-seat below (a slot's indices may move while its uniforms
3210
+ // don't), so it runs before the `changed` gate.
3211
+ const drawId = bucket.localToDrawId[localSlot];
3212
+ if (drawId !== undefined) {
3213
+ const iav = drawIdToIndexAval[drawId];
3214
+ const oldRef = iav !== undefined ? indexPool.baseFor(iav, chunkIdx) : undefined;
3215
+ const newRef = oldRef !== undefined ? remap.get(oldRef) : undefined;
3216
+ const entry = bucket.localEntries[localSlot];
3217
+ if (oldRef !== undefined && newRef !== undefined && entry !== undefined) {
3218
+ const newStart = entry.firstIndex + ((newRef - oldRef) >> 2);
3219
+ entry.firstIndex = newStart;
3220
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
3221
+ const recIdx = bucket.drawIdToRecord?.get(drawId);
3222
+ if (recIdx !== undefined) {
3223
+ bucket.partitionScene.setRecordIndexStart(recIdx, newStart);
3224
+ bucket.partitionDirty = true;
3225
+ }
3226
+ }
3227
+ else {
3228
+ const slotIdx = drawIdToSlotIdx[drawId];
3229
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
3230
+ const recIdx = slot?.slotToRecord[localSlot];
3231
+ if (slot !== undefined && recIdx !== undefined && recIdx >= 0) {
3232
+ slot.drawTableShadow[recIdx * RECORD_U32 + 2] = newStart >>> 0;
3233
+ const off = recIdx * RECORD_BYTES;
3234
+ if (off < slot.drawTableDirtyMin)
3235
+ slot.drawTableDirtyMin = off;
3236
+ if (off + RECORD_BYTES > slot.drawTableDirtyMax)
3237
+ slot.drawTableDirtyMax = off + RECORD_BYTES;
3238
+ }
3239
+ }
3240
+ }
3241
+ }
3242
+ // (b) Uniform/attr drawHeader cells (+ Pos/Nor caches).
3243
+ const refs = bucket.localPerDrawRefs[localSlot];
3244
+ if (refs === undefined)
3245
+ continue;
3246
+ let changed = false;
3247
+ for (let i = 0; i < refs.length; i++) {
3248
+ const ref = refs[i];
3249
+ if (ref < 0)
3250
+ continue;
3251
+ const fld = bucket.layout.drawHeaderFields[i];
3252
+ // Inline header values (PickId/__layoutId) are NOT arena
3253
+ // refs — a numeric collision with an old offset must not
3254
+ // remap them.
3255
+ if (fld.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(fld.name))
3256
+ continue;
3257
+ const nn = remap.get(ref);
3258
+ if (nn !== undefined) {
3259
+ refs[i] = nn;
3260
+ changed = true;
3261
+ }
3262
+ }
3263
+ if (!changed)
3264
+ continue;
3265
+ const pr = bucket.posFieldIdx >= 0 ? refs[bucket.posFieldIdx] : -1;
3266
+ const nr = bucket.norFieldIdx >= 0 ? refs[bucket.norFieldIdx] : -1;
3267
+ bucket.localPosRefs[localSlot] = pr < 0 ? undefined : pr;
3268
+ bucket.localNorRefs[localSlot] = nr < 0 ? undefined : nr;
3269
+ packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
3270
+ if (bucket.isAtlasBucket) {
3271
+ const ts = bucket.localAtlasTextures[localSlot];
3272
+ if (ts !== undefined)
3273
+ packAtlasTextureFields(bucket, localSlot, ts);
3274
+ }
3275
+ const byteOff = localSlot * bucket.layout.drawHeaderBytes;
3276
+ if (byteOff < bucket.headerDirtyMin)
3277
+ bucket.headerDirtyMin = byteOff;
3278
+ const end = byteOff + bucket.layout.drawHeaderBytes;
3279
+ if (end > bucket.headerDirtyMax)
3280
+ bucket.headerDirtyMax = end;
3281
+ }
3282
+ }
3283
+ }
3284
+ }
3285
+ // Register this scene as a consumer of the (possibly shared) store, so a
3286
+ // compaction — triggered by THIS scene or any other sharing the store —
3287
+ // re-seats this scene's refs. Disposed in `dispose()`.
3288
+ const storageReg = storage.register({ applyRemap });
3289
+ // ─── Compaction (delegates to the shared store) ──────────────────
3290
+ function runCompaction(force) {
3291
+ const compacted = storage.compact(device, compactionWasteFloor, force);
3292
+ stats.compactions += compacted;
3293
+ // update() flushes the re-seated drawHeaders via its own per-bucket loop
3294
+ // right after this returns; the forced (test) path runs outside update(),
3295
+ // so it flushes this scene's dirtied headers itself.
3296
+ if (force) {
3297
+ for (const bucket of buckets) {
3298
+ if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
3299
+ device.queue.writeBuffer(bucket.drawHeap.buffer, bucket.headerDirtyMin, bucket.drawHeaderStaging.buffer, bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin, bucket.headerDirtyMax - bucket.headerDirtyMin);
3300
+ bucket.headerDirtyMin = Infinity;
3301
+ bucket.headerDirtyMax = 0;
3302
+ }
3303
+ }
3304
+ }
3305
+ }
2801
3306
  // ─── update / encodeIntoPass / frame / dispose ───────────────────
2802
3307
  /**
2803
3308
  * CPU-side data refresh: drain pending aset deltas, repack any
@@ -2810,6 +3315,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2810
3315
  let totalDirtyBytes = 0;
2811
3316
  sceneObj.evaluateAlways(token, (tok) => {
2812
3317
  drainAsetWith(tok);
3318
+ // User storage buffers that marked (e.g. the OIT head buffer
3319
+ // reallocating on a resize) invalidate the bucket's bind groups.
3320
+ for (const b of buckets) {
3321
+ if (!b.storageDirty)
3322
+ continue;
3323
+ b.storageDirty = false;
3324
+ for (let i = 0; i < b.slots.length; i++) {
3325
+ b.slots[i].bindGroup = buildBucketBindGroup(b, i);
3326
+ }
3327
+ }
2813
3328
  // 0. Reactive rebucket: any RO whose PipelineState modeKey
2814
3329
  // changed since last frame moves to its new bucket.
2815
3330
  // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
@@ -3032,7 +3547,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3032
3547
  // initial population for 10K ROs this collapses ~30K calls into
3033
3548
  // ~5 (arena, indices, plus one per bucket).
3034
3549
  arena.attrs.flush(device);
3035
- arena.indices.flush(device);
3550
+ // ─── Waste-triggered attribute-arena compaction ─────────────────
3551
+ // Relocate live allocations to the front of each chunk once
3552
+ // fragmentation waste crosses the floor — exact-size freelist reuse
3553
+ // can't reclaim a drifting size distribution, so the arena would
3554
+ // otherwise ratchet to its high-water forever. Runs AFTER the arena
3555
+ // flush (GPU buffer current) and BEFORE the header flush (so the
3556
+ // re-seated drawHeaders upload below). Indices live in a separate
3557
+ // buffer and are not compacted here.
3558
+ if (enableCompaction)
3559
+ runCompaction(false);
3036
3560
  for (const bucket of buckets) {
3037
3561
  if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
3038
3562
  device.queue.writeBuffer(bucket.drawHeap.buffer, bucket.headerDirtyMin, bucket.drawHeaderStaging.buffer, bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin, bucket.headerDirtyMax - bucket.headerDirtyMin);
@@ -3232,14 +3756,44 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3232
3756
  destroyBucketResources(b);
3233
3757
  }
3234
3758
  function dispose() {
3235
- arena.attrs.destroy();
3236
- arena.indices.destroy();
3759
+ // Stop receiving compaction remaps for the shared store.
3760
+ storageReg.dispose();
3761
+ // Shared storage: release this scene's allocations so the shared pool's
3762
+ // refcounts stay correct (another scene may still hold the same regions),
3763
+ // but don't destroy the buffers — the storage owner does that. Private
3764
+ // storage: the whole arena is torn down wholesale, so per-draw release is
3765
+ // unnecessary.
3766
+ if (!ownsStorage) {
3767
+ for (let drawId = 0; drawId < drawIdToBucket.length; drawId++) {
3768
+ if (drawIdToBucket[drawId] !== undefined)
3769
+ removeDraw(drawId);
3770
+ }
3771
+ }
3237
3772
  for (const b of buckets)
3238
3773
  destroyBucketResources(b);
3774
+ if (ownsStorage)
3775
+ storage.dispose();
3239
3776
  }
3240
3777
  // Test-only escape hatch for inspecting megacall bucket state. Not
3241
3778
  // part of the public API surface — keep cast at use-site.
3242
3779
  const _debug = {
3780
+ /** Force a full heap compaction pass (attribute arena + index buffer)
3781
+ * regardless of the waste heuristics (bypasses the floor + 50%-live
3782
+ * gate), re-seat all refs, flush. Returns residual waste afterwards in
3783
+ * bytes (attr bytes + index elements×4; 0 when fully packed). Test-only —
3784
+ * drives relocation deterministically without engineering megabytes of
3785
+ * fragmentation. Callers should `update()` afterward to flush drawTables /
3786
+ * re-dispatch the partition (the render path does this). */
3787
+ forceCompact() {
3788
+ runCompaction(true);
3789
+ return arena.attrs.totalWasteBytes();
3790
+ },
3791
+ /** Current arena fragmentation waste (bytes) — covers uniforms,
3792
+ * attributes, and index regions (all one arena now). */
3793
+ attrWasteBytes() { return arena.attrs.totalWasteBytes(); },
3794
+ /** Number of arena chunks (pages) currently open. >1 means group
3795
+ * placement rolled past one buffer cap. */
3796
+ pageCount() { return arena.attrs.chunkCount; },
3243
3797
  bucketsForTest() {
3244
3798
  return buckets.map(b => ({
3245
3799
  indirectBuf: b.slots[0].indirectBuf,
@@ -3279,12 +3833,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3279
3833
  // multi-chunk-aware validator needs per-bucket-chunkIdx
3280
3834
  // staging which is a bigger refactor; assert for now so
3281
3835
  // multi-chunk callers don't get false-passes.
3282
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
3836
+ if (arena.attrs.chunkCount > 1) {
3283
3837
  throw new Error("heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
3284
3838
  "Run with the default chunk cap (no second chunk opened) for now.");
3285
3839
  }
3286
3840
  const arenaSize = arena.attrs.chunk(0).buffer.size;
3287
- const indicesSize = arena.indices.chunk(0).buffer.size;
3841
+ const indicesSize = arenaSize; // indices live in the attr arena now
3288
3842
  const enc = device.createCommandEncoder({ label: "validateHeap" });
3289
3843
  const stage = (src, size) => {
3290
3844
  const c = device.createBuffer({
@@ -3295,7 +3849,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3295
3849
  return c;
3296
3850
  };
3297
3851
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3298
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
3852
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
3299
3853
  const TILE_K_LOCAL = 64;
3300
3854
  const dcs = [];
3301
3855
  for (const b of buckets) {
@@ -3348,7 +3902,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3348
3902
  for (let slot = 0; slot < recordCount; slot++) {
3349
3903
  const slotOff = slot * stride;
3350
3904
  for (const f of dc.bucket.layout.drawHeaderFields) {
3351
- if (f.name === "__layoutId")
3905
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name))
3352
3906
  continue;
3353
3907
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
3354
3908
  continue;
@@ -3384,18 +3938,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3384
3938
  ? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
3385
3939
  : "";
3386
3940
  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}`);
3941
+ // (Mirror-less arena: no CPU shadow to cross-check against
3942
+ // the GPU read-back is the only copy.)
3943
+ push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${before}${here}`);
3399
3944
  attrAllocsBad++;
3400
3945
  continue;
3401
3946
  }
@@ -3598,11 +4143,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3598
4143
  const push = (s) => { if (issues.length < 30)
3599
4144
  issues.push(s); };
3600
4145
  // §3 v1: chunk-0-only like validateHeap.
3601
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4146
+ if (arena.attrs.chunkCount > 1) {
3602
4147
  throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
3603
4148
  }
3604
4149
  const arenaSize = arena.attrs.chunk(0).buffer.size;
3605
- const indicesSize = arena.indices.chunk(0).buffer.size;
4150
+ const indicesSize = arenaSize; // indices live in the attr arena now
3606
4151
  // Stage all buffers we'll need.
3607
4152
  const enc = device.createCommandEncoder({ label: "simulateDraws" });
3608
4153
  const stage = (src, size) => {
@@ -3613,7 +4158,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3613
4158
  return c;
3614
4159
  };
3615
4160
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3616
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
4161
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
3617
4162
  const dcs = [];
3618
4163
  for (const b of buckets) {
3619
4164
  if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
@@ -3654,7 +4199,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3654
4199
  for (const dc of dcs) {
3655
4200
  const fs = [];
3656
4201
  for (const f of dc.bucket.layout.drawHeaderFields) {
3657
- if (f.name === "__layoutId")
4202
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name))
3658
4203
  continue;
3659
4204
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
3660
4205
  continue;
@@ -3865,11 +4410,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3865
4410
  // cross-RO triangle symptom.
3866
4411
  const allocClaims = [];
3867
4412
  // §3 v1: chunk-0-only (see validateHeap).
3868
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4413
+ if (arena.attrs.chunkCount > 1) {
3869
4414
  throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
3870
4415
  }
3871
4416
  const arenaBuf0 = arena.attrs.chunk(0).buffer;
3872
- const indicesBuf0 = arena.indices.chunk(0).buffer;
4417
+ const indicesBuf0 = arena.attrs.chunk(0).buffer;
3873
4418
  // Stage arena + arena.indices once (shared across buckets).
3874
4419
  const arenaCopy = device.createBuffer({
3875
4420
  size: arenaBuf0.size,
@@ -4322,7 +4867,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
4322
4867
  { binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
4323
4868
  { binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
4324
4869
  { binding: 2, resource: { buffer: sampleBuf } },
4325
- { binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
4870
+ { binding: 3, resource: { buffer: arena.attrs.chunk(target.chunkIdx).buffer } },
4326
4871
  { binding: 4, resource: { buffer: outBuf } },
4327
4872
  { binding: 5, resource: { buffer: paramBuf } },
4328
4873
  ],
@@ -4346,7 +4891,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
4346
4891
  outBuf.destroy();
4347
4892
  paramBuf.destroy();
4348
4893
  // Read drawTable + indices for CPU expectation.
4349
- const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
4894
+ const idxBuf = arena.attrs.chunk(target.chunkIdx).buffer;
4350
4895
  const indicesCopy2 = device.createBuffer({
4351
4896
  size: idxBuf.size,
4352
4897
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,