@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
@@ -51,7 +51,7 @@ import type { Effect, CompileOptions } from "@aardworx/wombat.shader";
51
51
  import type { PipelineState } from "../core/pipelineState.js";
52
52
  import type { BufferView } from "../core/bufferView.js";
53
53
  import type { IBuffer, HostBufferSource } from "../core/buffer.js";
54
- import {
54
+ import { INLINE_HEADER_UNIFORMS,
55
55
  buildBucketLayout, compileHeapEffect,
56
56
  type BucketLayout, type FragmentOutputLayout,
57
57
  type HeapEffectSchema,
@@ -85,11 +85,11 @@ import {
85
85
  } from "./heapScene/growBuffer.js";
86
86
  import {
87
87
  UniformPool, IndexPool, DrawHeap,
88
- AttributeArena, IndexAllocator,
89
88
  buildArenaState, arenaBytes, writeAttribute,
90
89
  asAval, isBufferView, asFloat32,
91
90
  ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO,
92
- ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS,
91
+ ENC_V3F_TIGHT, ENC_OCT32, ENC_C4B, SEM_POSITIONS, SEM_NORMALS,
92
+ COMPACTION_WASTE_FLOOR_BYTES,
93
93
  type ArenaState,
94
94
  } from "./heapScene/pools.js";
95
95
  import { encodeModeKey, type PipelineStateDescriptor } from "./pipelineCache/index.js";
@@ -110,6 +110,49 @@ import {
110
110
  type Expr, type Stmt,
111
111
  } from "@aardworx/wombat.shader/ir";
112
112
 
113
+ const NOOP = (): void => {};
114
+
115
+ // ─── Shared pool packers ──────────────────────────────────────────────
116
+ // Static functions where possible: a fresh closure per pool entry was
117
+ // measurable ballast at heap scale (3 closures/leaf). Only the
118
+ // broadcast prefix variant needs a capture (the copy limit differs
119
+ // from the source buffer's size).
120
+ type PoolPackFn = (val: unknown, dst: Float32Array, off: number) => void;
121
+ const packPackedAttr: PoolPackFn = (val, dst, off) => {
122
+ const src = (val as HeapPackedAttribute).data;
123
+ new Uint32Array(dst.buffer, dst.byteOffset + off * 4, src.length).set(src);
124
+ };
125
+ const packF32Verbatim: PoolPackFn = (val, dst, off) => {
126
+ dst.set(val as Float32Array, off);
127
+ };
128
+ const packHostU32Bits: PoolPackFn = (val, dst, off) => {
129
+ const ibuf = val as IBuffer;
130
+ if (ibuf.kind !== "host") {
131
+ throw new Error("heapScene: packed BufferView aval flipped to native GPUBuffer");
132
+ }
133
+ const d = ibuf.data as ArrayBufferView;
134
+ // reinterpret the u32 payload as f32 bits for the arena write
135
+ dst.set(new Float32Array(d.buffer, d.byteOffset, d.byteLength >>> 2), off);
136
+ };
137
+ const packHostF32: PoolPackFn = (val, dst, off) => {
138
+ const ibuf = val as IBuffer;
139
+ if (ibuf.kind !== "host") {
140
+ throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
141
+ }
142
+ dst.set(asFloat32(ibuf.data), off);
143
+ };
144
+ /** Broadcast prefix copy — the only variant that genuinely captures. */
145
+ function packHostPrefix(limitFloats: number): PoolPackFn {
146
+ return (val, dst, off) => {
147
+ const ibuf = val as IBuffer;
148
+ if (ibuf.kind !== "host") {
149
+ throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
150
+ }
151
+ const src = asFloat32(ibuf.data);
152
+ dst.set(src.subarray(0, limitFloats), off);
153
+ };
154
+ }
155
+
113
156
  // GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
114
157
  // Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
115
158
 
@@ -148,6 +191,41 @@ import {
148
191
  * Must match the literal in heapEffect.ts's `megacallSearchPrelude`. */
149
192
  export const HEAP_NONINDEXED = 0xffffffff;
150
193
 
194
+ /**
195
+ * Packed attribute source — stored in the arena at 4 B/element (one u32)
196
+ * instead of the 12 B tight-f32 expansion, decoded by the VS's
197
+ * typeId-selected arm. Use for memory-bound scenes (phones): both the
198
+ * host-side aval payload AND the GPU arena shrink 3×.
199
+ *
200
+ * - `oct32(...)` — unit vectors (normals): 2×unorm16 octahedral, for
201
+ * `vec3<f32>` fields. Matches build_vienna.py's `oct_pack`.
202
+ * - `c4b(...)` — RGBA8-unorm colors, for `vec4<f32>` fields
203
+ * (`unpack4x8unorm` in the shader; alpha rides along).
204
+ */
205
+ export interface HeapPackedAttribute {
206
+ readonly kind: "heap-packed";
207
+ readonly enc: "oct32" | "c4b";
208
+ readonly data: Uint32Array;
209
+ }
210
+ export function oct32(data: Int32Array | Uint32Array): HeapPackedAttribute {
211
+ const u = data instanceof Uint32Array ? data : new Uint32Array(data.buffer, data.byteOffset, data.length);
212
+ return { kind: "heap-packed", enc: "oct32", data: u };
213
+ }
214
+ export function c4b(data: Uint8Array | Uint32Array): HeapPackedAttribute {
215
+ if (data instanceof Uint8Array) {
216
+ if (data.byteLength % 4 !== 0 || data.byteOffset % 4 !== 0) {
217
+ throw new Error("heapScene.c4b: RGBA8 data must be 4-byte sized and aligned");
218
+ }
219
+ return { kind: "heap-packed", enc: "c4b", data: new Uint32Array(data.buffer, data.byteOffset, data.byteLength / 4) };
220
+ }
221
+ return { kind: "heap-packed", enc: "c4b", data };
222
+ }
223
+ export function isHeapPackedAttribute(v: unknown): v is HeapPackedAttribute {
224
+ return typeof v === "object" && v !== null
225
+ && (v as { kind?: unknown }).kind === "heap-packed"
226
+ && (v as { data?: unknown }).data instanceof Uint32Array;
227
+ }
228
+
151
229
  export interface HeapGeometry {
152
230
  readonly positions: Float32Array;
153
231
  readonly normals: Float32Array;
@@ -299,13 +377,24 @@ interface Bucket {
299
377
  * keyed by aval identity.
300
378
  */
301
379
  readonly localPerDrawAvals: (aval<unknown>[] | undefined)[];
380
+ /** Field indices (into `layout.drawHeaderFields`) whose drawHeader
381
+ * cells hold RAW arena refs (§7 output slots, no pool entry). */
382
+ readonly localPerDrawRawFieldIdx: (number[] | undefined)[];
302
383
  /**
303
- * Per-draw uniform refs (schema name arena byte offset) for
304
- * this slot. Stable during the slot's lifetime; used to re-pack
305
- * the DrawHeader into a fresh staging mirror after the bucket's
306
- * drawHeap GrowBuffer reallocates.
384
+ * Per-draw uniform refs for this slot, indexed by the bucket
385
+ * layout's drawHeaderFields ORDER (−1 = field not populated by
386
+ * this slot's effect). A flat Int32Array instead of a
387
+ * Map<string, number> — at heap scale (hundreds of thousands of
388
+ * draws) the per-draw Map was one of the largest JS-memory items.
389
+ * Stable during the slot's lifetime; used to re-pack the
390
+ * DrawHeader after the drawHeap GrowBuffer reallocates.
307
391
  */
308
- readonly localPerDrawRefs: (Map<string, number> | undefined)[];
392
+ readonly localPerDrawRefs: (Int32Array | undefined)[];
393
+ /** drawHeaderFields name → array index (built once per bucket). */
394
+ readonly fieldIdx: Map<string, number>;
395
+ /** fieldIdx.get("Positions") / ("Normals"), −1 when absent. */
396
+ readonly posFieldIdx: number;
397
+ readonly norFieldIdx: number;
309
398
  /**
310
399
  * Per-local-slot layoutId for the §6 family-merge selector. Stable
311
400
  * during the slot's lifetime; written into the drawHeader's
@@ -412,6 +501,12 @@ export interface HeapDrawSpec {
412
501
  * share a pipeline + bucket.
413
502
  */
414
503
  readonly effect: Effect;
504
+ /**
505
+ * Optional picking id (u32 < 2^24), written INLINE into the
506
+ * drawHeader's `PickId` field (see `INLINE_HEADER_UNIFORMS`) — no
507
+ * pool entry, no per-draw aval.
508
+ */
509
+ readonly pickId?: number;
415
510
  /**
416
511
  * Per-name inputs covering both vertex attributes (e.g. `Positions`,
417
512
  * `Normals`) and uniforms (e.g. `ModelTrafo`, `Color`, `ViewProjTrafo`)
@@ -552,6 +647,10 @@ export interface HeapSceneStats {
552
647
  totalDraws: number;
553
648
  drawBytes: number;
554
649
  geometryBytes: number;
650
+ /** Cumulative count of waste-triggered arena compactions performed
651
+ * (one per chunk per `update` that compacted). Stays 0 when the
652
+ * arena never ratchets past the waste floor. */
653
+ compactions: number;
555
654
  /** §7 derived-uniforms per-frame breakdown (last frame). */
556
655
  derivedPullMs: number;
557
656
  derivedUploadMs: number;
@@ -752,6 +851,127 @@ export interface BuildHeapSceneOptions {
752
851
  * chunks duplicate their pool entries (accepted trade-off).
753
852
  */
754
853
  readonly maxChunkBytes?: number;
854
+ /**
855
+ * Waste-triggered attribute-arena compaction. When enabled (default),
856
+ * `update()` relocates live arena allocations to the front of each chunk
857
+ * once fragmentation waste crosses `compactionWasteFloorBytes` and at least
858
+ * half the extent is wasted — preventing the high-water ratchet that
859
+ * exact-size freelist reuse can't fix under a drifting allocation-size
860
+ * distribution. Pass `false` to disable.
861
+ */
862
+ readonly enableCompaction?: boolean;
863
+ /**
864
+ * Override the fragmentation-waste floor (bytes) below which compaction is
865
+ * skipped. Default `COMPACTION_WASTE_FLOOR_BYTES` (4 MiB). Tests pass a
866
+ * small value to exercise the path without realistic memory pressure.
867
+ */
868
+ readonly compactionWasteFloorBytes?: number;
869
+ /**
870
+ * Pre-size the arena chunks (bytes). When the caller can estimate the
871
+ * scene's total arena footprint up front (e.g. from an asset manifest),
872
+ * passing it here allocates each chunk at its final size immediately —
873
+ * eliminating the pow2-grow copies during ingest, whose old+new-buffer
874
+ * transient is what OOM-kills memory-tight targets (phones). Clamped to
875
+ * the per-chunk cap; harmless to overshoot.
876
+ */
877
+ readonly initialArenaBytes?: number;
878
+ /**
879
+ * Release constant attribute payloads after staging (aardvark's
880
+ * "StageOnce"). When a `HeapDrawSpec` attribute is supplied as a PLAIN
881
+ * value (raw typed array / packed marker — not a real aval), the heap
882
+ * wraps it in a constant holder; a constant is provably never re-read
883
+ * after its arena write (repack fires only on `inputChanged`, which
884
+ * constants never raise; compaction and growth move bytes GPU-side).
885
+ * With this flag the holder drops its payload right after staging, so
886
+ * the GPU arena becomes the ONLY copy of constant geometry — halving a
887
+ * static scene's memory. Real avals (cvals etc.) are never touched:
888
+ * changeable data stays alive by definition. Default false.
889
+ */
890
+ readonly releaseConstantAttributes?: boolean;
891
+ /**
892
+ * Share a `HeapStorage` (arena + dedup pools) across multiple heap scenes.
893
+ * When omitted, the scene creates a private store it owns and disposes.
894
+ * When supplied (via `createHeapStorage`), two or more scenes — e.g. the
895
+ * same geometry rendered by a shadow pass and a color pass with different
896
+ * effects — share one deduped/refcounted store, so common geometry and
897
+ * uniforms are stored ONCE. The caller owns the store's lifetime.
898
+ */
899
+ readonly storage?: HeapStorage;
900
+ }
901
+
902
+ /**
903
+ * The data-placement layer behind a heap scene: the chunked arena (uniform /
904
+ * attribute data + the index buffer) plus the aval-keyed dedup pools. Owns
905
+ * the GPU buffers; one or more `buildHeapScene` calls draw against it. Create
906
+ * with `createHeapStorage` and pass via `BuildHeapSceneOptions.storage` to
907
+ * share geometry/uniforms across heaps (shadow + color pass, picking, …).
908
+ */
909
+ /** A heap scene's hook into a shared store. When a compaction relocates arena
910
+ * regions, the store calls every registered consumer's `applyRemap` so each
911
+ * scene re-seats its OWN refs (drawHeader cells, derived records, partition
912
+ * master, index `_indexStart`). `remap` maps OLD→NEW byte header offset. */
913
+ export interface HeapStorageConsumer {
914
+ applyRemap(chunkIdx: number, remap: ReadonlyMap<number, number>): void;
915
+ }
916
+
917
+ export interface HeapStorage {
918
+ readonly arena: ArenaState;
919
+ readonly pool: UniformPool;
920
+ readonly indexPool: IndexPool;
921
+ /** Register a scene as a consumer (so compaction re-seats its refs). */
922
+ register(consumer: HeapStorageConsumer): IDisposable;
923
+ /**
924
+ * Waste-triggered compaction of the shared arena. Relocates live regions,
925
+ * re-seats the storage-level dedup pools, and notifies every registered
926
+ * consumer to re-seat its scene-level refs. Returns the number of chunks
927
+ * compacted. Must run with pending writes flushable (it flushes first).
928
+ */
929
+ compact(device: GPUDevice, wasteFloorBytes: number, force: boolean): number;
930
+ /** Destroy the arena's GPU buffers. Only call once no scene references it. */
931
+ dispose(): void;
932
+ }
933
+
934
+ export interface HeapStorageOptions {
935
+ /** Per-arena-chunk size cap in bytes. See `BuildHeapSceneOptions.maxChunkBytes`. */
936
+ readonly maxChunkBytes?: number;
937
+ /** Pre-size chunks (bytes) — see `BuildHeapSceneOptions.initialArenaBytes`. */
938
+ readonly initialArenaBytes?: number;
939
+ }
940
+
941
+ /** Create a standalone `HeapStorage`. Pass it to `buildHeapScene` via
942
+ * `opts.storage` to share one arena across multiple heaps. */
943
+ export function createHeapStorage(device: GPUDevice, opts: HeapStorageOptions = {}): HeapStorage {
944
+ void device;
945
+ // Initial capacities are hints; the buffers pow2-grow on demand.
946
+ const arena = buildArenaState(
947
+ device, Math.max(64 * 1024, opts.initialArenaBytes ?? 0), 16 * 1024, "heapStorage",
948
+ GPUBufferUsage.STORAGE, opts.maxChunkBytes,
949
+ );
950
+ const pool = new UniformPool();
951
+ const indexPool = new IndexPool();
952
+ const consumers = new Set<HeapStorageConsumer>();
953
+ return {
954
+ arena, pool, indexPool,
955
+ register(c: HeapStorageConsumer): IDisposable {
956
+ consumers.add(c);
957
+ return { dispose: () => { consumers.delete(c); } };
958
+ },
959
+ compact(dev: GPUDevice, floor: number, force: boolean): number {
960
+ arena.attrs.flush(dev); // GPU buffer current for the copy
961
+ const remaps = arena.attrs.compact(dev, floor, force);
962
+ for (const { chunkIdx, remap } of remaps) {
963
+ // Storage-level dedup pools first (uniform refs); then each scene
964
+ // re-seats its drawHeaders / derive / partition / index `_indexStart`
965
+ // (the index re-seat reads `indexPool.baseFor` at the OLD ref, so the
966
+ // index pool is remapped LAST, after all consumers).
967
+ pool.remapRefs(chunkIdx, remap);
968
+ for (const c of consumers) c.applyRemap(chunkIdx, remap);
969
+ indexPool.remapRefs(chunkIdx, remap);
970
+ }
971
+ return remaps.length;
972
+ },
973
+ dispose(): void { arena.attrs.destroy(); },
974
+ };
755
975
  }
756
976
 
757
977
  export function buildHeapScene(
@@ -781,16 +1001,21 @@ export function buildHeapScene(
781
1001
  }
782
1002
  const depthFormat = sig.depthStencil?.format;
783
1003
 
784
- // ─── Global arena (uniform/attribute data + index buffer) ────────
785
- // Initial capacities are just hints; both buffers pow2-grow on
786
- // demand. Skip per-draw enumeration since aval-keyed sharing makes
787
- // the actual allocated size hard to predict (10K instanced draws
788
- // sharing the same Positions array 1 alloc, not 10K).
789
- const arena = buildArenaState(
790
- device, 64 * 1024, 16 * 1024, "heapScene",
791
- GPUBufferUsage.STORAGE,
792
- opts.maxChunkBytes,
793
- );
1004
+ // ─── Storage (arena + dedup pools) — private or shared ───────────
1005
+ // The chunked arena (uniform/attribute data + index buffer) and the
1006
+ // aval-keyed dedup pools live in a `HeapStorage`. Default: a private
1007
+ // store this scene owns. `opts.storage`: a shared store (the same
1008
+ // geometry/uniforms rendered by multiple heaps e.g. shadow + color
1009
+ // pass are deduped once). The local `arena`/`pool`/`indexPool`
1010
+ // bindings below keep every downstream reference unchanged.
1011
+ const ownsStorage = opts.storage === undefined;
1012
+ const storage = opts.storage ?? createHeapStorage(device, {
1013
+ ...(opts.maxChunkBytes !== undefined ? { maxChunkBytes: opts.maxChunkBytes } : {}),
1014
+ ...(opts.initialArenaBytes !== undefined ? { initialArenaBytes: opts.initialArenaBytes } : {}),
1015
+ });
1016
+ const arena = storage.arena;
1017
+ const pool = storage.pool;
1018
+ const indexPool = storage.indexPool;
794
1019
 
795
1020
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
796
1021
  const drawIdToBucket: (Bucket | undefined)[] = [];
@@ -959,6 +1184,25 @@ export function buildHeapScene(
959
1184
  const p = packerForWgslType(f.uniformWgslType ?? "");
960
1185
  return { dataBytes: p.dataBytes, typeId: p.typeId, length: 1, pack: p.pack };
961
1186
  }
1187
+ // Packed attribute encodings (oct32 unit vectors / C4b colors): one
1188
+ // u32 per element, decoded by the VS's typeId-selected arm. 3× smaller
1189
+ // in the arena AND host-side (the caller keeps the packed source).
1190
+ if (isHeapPackedAttribute(value)) {
1191
+ const pa = value;
1192
+ const wantType = pa.enc === "oct32" ? "vec3<f32>" : "vec4<f32>";
1193
+ if (f.attributeWgslType !== wantType) {
1194
+ throw new Error(
1195
+ `heapScene: ${pa.enc} attribute supplied for '${f.attributeWgslType}' field — ` +
1196
+ `oct32 requires vec3<f32>, c4b requires vec4<f32>`,
1197
+ );
1198
+ }
1199
+ return {
1200
+ dataBytes: pa.data.byteLength,
1201
+ typeId: pa.enc === "oct32" ? ENC_OCT32 : ENC_C4B,
1202
+ length: pa.data.length,
1203
+ pack: packPackedAttr,
1204
+ };
1205
+ }
962
1206
  // attribute-ref: variable-size array; we copy verbatim into the
963
1207
  // arena. The current encoding is V3F_TIGHT (3 f32s per element).
964
1208
  const arr = value as Float32Array;
@@ -972,7 +1216,7 @@ export function buildHeapScene(
972
1216
  dataBytes: arr.byteLength,
973
1217
  typeId: ENC_V3F_TIGHT,
974
1218
  length,
975
- pack: (val, dst, off) => { dst.set(val as Float32Array, off); },
1219
+ pack: packF32Verbatim,
976
1220
  };
977
1221
  }
978
1222
 
@@ -997,6 +1241,39 @@ export function buildHeapScene(
997
1241
  throw new Error(`heapScene: BufferView offset ${offset} > 0 not yet supported`);
998
1242
  }
999
1243
 
1244
+ // Packed encodings by ELEMENT TYPE (the BufferView analogue of the
1245
+ // oct32()/c4b() HeapDrawSpec markers): one u32 per element in the
1246
+ // arena, decoded by the VS typeId arm. Dedup stays aval-keyed like
1247
+ // any other BufferView attribute.
1248
+ const etName = bv.elementType.name;
1249
+ if (etName === "oct32" || etName === "c4b") {
1250
+ const want = etName === "oct32" ? "vec3<f32>" : "vec4<f32>";
1251
+ if (f.attributeWgslType !== want) {
1252
+ throw new Error(
1253
+ `heapScene: ${etName} BufferView requires a ${want} schema attribute (got ${f.attributeWgslType ?? "?"})`,
1254
+ );
1255
+ }
1256
+ const strideP = bv.stride ?? 4;
1257
+ const isBroadcastP = bv.singleValue !== undefined || strideP === 0;
1258
+ if (!isBroadcastP && strideP !== 4) {
1259
+ throw new Error(`heapScene: ${etName} BufferView stride ${strideP} not tight (4) and not a broadcast`);
1260
+ }
1261
+ const ibP = bv.buffer.force(/* allow-force */);
1262
+ if (ibP.kind !== "host") {
1263
+ throw new Error(`heapScene: ${etName} BufferView wraps a native GPUBuffer; not supported in heap path`);
1264
+ }
1265
+ const lengthP = isBroadcastP ? 1 : ibP.sizeBytes / 4;
1266
+ const dataBytesP = 4 * lengthP;
1267
+ return {
1268
+ dataBytes: dataBytesP,
1269
+ typeId: etName === "oct32" ? ENC_OCT32 : ENC_C4B,
1270
+ length: lengthP,
1271
+ // Tight case: the copy limit equals the source payload, so the
1272
+ // static packer (which derives it from the buffer) is exact.
1273
+ pack: isBroadcastP ? packHostPrefix(dataBytesP / 4) : packHostU32Bits,
1274
+ };
1275
+ }
1276
+
1000
1277
  // In-arena element bytes: the storage layout the shader will read
1001
1278
  // from. vec4 is always 16 (read via `heapV4f[idx]`), vec3 is 12
1002
1279
  // (read as 3 f32s), vec2 is 8, scalar is 4. V3f source for a vec4
@@ -1050,15 +1327,7 @@ export function buildHeapScene(
1050
1327
  dataBytes,
1051
1328
  typeId: ENC_V3F_TIGHT,
1052
1329
  length,
1053
- pack: (val, dst, off) => {
1054
- const ibuf = val as IBuffer;
1055
- if (ibuf.kind !== "host") {
1056
- throw new Error("heapScene: BufferView aval flipped to native GPUBuffer; not supported in heap path");
1057
- }
1058
- const src = asFloat32(ibuf.data);
1059
- const limitFloats = dataBytes / 4;
1060
- dst.set(src.subarray(0, limitFloats), off);
1061
- },
1330
+ pack: isBroadcast ? packHostPrefix(dataBytes / 4) : packHostF32,
1062
1331
  };
1063
1332
  }
1064
1333
 
@@ -1091,9 +1360,7 @@ export function buildHeapScene(
1091
1360
  };
1092
1361
  }
1093
1362
 
1094
- // ─── Pools aval-keyed refcounted allocations ────────────────────
1095
- const pool = new UniformPool();
1096
- const indexPool = new IndexPool();
1363
+ // (`pool` / `indexPool` are owned by `storage`, bound above.)
1097
1364
 
1098
1365
  // ─── Adaptive routing: aval marks → repack the pool entry ─────────
1099
1366
  // With pool-managed per-draw uniforms, value changes don't touch
@@ -1157,6 +1424,9 @@ export function buildHeapScene(
1157
1424
  // both. `derivedScene` is undefined when the option is off; nothing
1158
1425
  // else in this file should run when it is. On by default — pass
1159
1426
  // `enableDerivedUniforms: false` to opt out.
1427
+ const enableCompaction = opts.enableCompaction !== false;
1428
+ const releaseConstantAttributes = opts.releaseConstantAttributes === true;
1429
+ const compactionWasteFloor = opts.compactionWasteFloorBytes ?? COMPACTION_WASTE_FLOOR_BYTES;
1160
1430
  const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
1161
1431
  const derivedScene: DerivedUniformsScene | undefined = enableDerivedUniforms
1162
1432
  ? new DerivedUniformsScene(device, {
@@ -1436,8 +1706,9 @@ export function buildHeapScene(
1436
1706
 
1437
1707
  // ─── Family state (§6 family-merge, slice 3c) ─────────────────────
1438
1708
  // Built lazily on the first addDraw batch from the union of all
1439
- // effects in that batch, then frozen. Subsequent addDraws with an
1440
- // unknown effect throwreactive family rebuild is a v2 punt.
1709
+ // effects in that batch. A NEW effect appearing later is registered
1710
+ // on the fly (ensureFamilyForSpec) families are independent per
1711
+ // effect (merge disabled), so the scene can mix effects added over time.
1441
1712
  //
1442
1713
  // Bucket key collapses to (familyId, pipelineState): every effect
1443
1714
  // in the family shares one bucket per pipelineState. The family
@@ -1581,6 +1852,25 @@ export function buildHeapScene(
1581
1852
  familyFor(effect); // throws if unknown
1582
1853
  }
1583
1854
 
1855
+ /**
1856
+ * Reactive family rebuild: register a new effect's family when it first
1857
+ * appears after the initial freeze. Family-merge is disabled, so every
1858
+ * Effect already gets its OWN independent single-member family (its own
1859
+ * shader modules + buckets); a late effect just needs its family compiled
1860
+ * and added — no rebuild of existing families. Idempotent.
1861
+ */
1862
+ function ensureFamilyForSpec(spec: HeapDrawSpec): void {
1863
+ if (!familyBuilt) { buildFamilyFromSpecs([spec]); return; }
1864
+ if (familyByEffectId.has(spec.effect.id)) return;
1865
+ const entry = { attributes: new Set<string>(), uniforms: new Set<string>() };
1866
+ if (spec.instanceAttributes !== undefined) {
1867
+ for (const name of Object.keys(spec.instanceAttributes)) entry.attributes.add(name);
1868
+ }
1869
+ const singleMap = new Map<Effect, { attributes: Set<string>; uniforms: Set<string> }>();
1870
+ singleMap.set(spec.effect, entry);
1871
+ familyByEffectId.set(spec.effect.id, compileFamilyFor([spec.effect], singleMap));
1872
+ }
1873
+
1584
1874
  // ─── id-of helpers ────────────────────────────────────────────────
1585
1875
  const idOf = (s: Effect): string => `effect#${s.id}`;
1586
1876
  const textureIds = new WeakMap<HeapTextureSet, string>();
@@ -1716,7 +2006,9 @@ export function buildHeapScene(
1716
2006
  const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
1717
2007
  entries.push(
1718
2008
  { binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1719
- { binding: 5, resource: { buffer: arena.indices.chunk(bucket.chunkIdx).buffer } },
2009
+ // Index data lives in the attr arena now (the VS storage-decodes it);
2010
+ // bind the same buffer as the gather views.
2011
+ { binding: 5, resource: { buffer: attrsBuf } },
1720
2012
  { binding: 6, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
1721
2013
  );
1722
2014
  s.renderBoundRecordCount = s.recordCount;
@@ -1849,15 +2141,6 @@ export function buildHeapScene(
1849
2141
  atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
1850
2142
  }
1851
2143
  }
1852
- arena.indices.onAnyResize((resizedChunkIdx) => {
1853
- for (const b of buckets) {
1854
- if (b.chunkIdx !== resizedChunkIdx) continue;
1855
- for (let i = 0; i < b.slots.length; i++) {
1856
- b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
1857
- }
1858
- }
1859
- });
1860
-
1861
2144
  // ─── findOrCreateBucket ───────────────────────────────────────────
1862
2145
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
1863
2146
  // Every member effect in the family shares one bucket per
@@ -2416,7 +2699,7 @@ export function buildHeapScene(
2416
2699
  firstIndex: number;
2417
2700
  indexCount: number;
2418
2701
  instanceCount: number;
2419
- perDrawRefs: Map<string, number>;
2702
+ perDrawRefs: Int32Array;
2420
2703
  descriptor: PipelineStateDescriptor;
2421
2704
  /** Filled in below after synthetic combos are registered. */
2422
2705
  targetComboId: number;
@@ -2596,7 +2879,11 @@ export function buildHeapScene(
2596
2879
  if (cpuMigration !== undefined) {
2597
2880
  let totalEmit = 0;
2598
2881
  for (const m of cpuMigration) {
2599
- const uRefs = bucket.uniformOrder!.map(n => m.perDrawRefs.get(n) ?? 0);
2882
+ const uRefs = bucket.uniformOrder!.map(n => {
2883
+ const i = bucket.fieldIdx.get(n);
2884
+ const r = i === undefined ? -1 : m.perDrawRefs[i]!;
2885
+ return r < 0 ? 0 : r;
2886
+ });
2600
2887
  const recIdx = partition.appendRecord(
2601
2888
  m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs,
2602
2889
  );
@@ -2717,6 +3004,8 @@ export function buildHeapScene(
2717
3004
  );
2718
3005
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
2719
3006
 
3007
+ const fieldIdx = new Map<string, number>();
3008
+ layout.drawHeaderFields.forEach((f, i) => fieldIdx.set(f.name, i));
2720
3009
  const bucket: Bucket = {
2721
3010
  label: bk, textures: undefined, layout, chunkIdx,
2722
3011
  slots: [], // populated lazily by ensureSlot per modeKey
@@ -2726,7 +3015,10 @@ export function buildHeapScene(
2726
3015
  headerDirtyMin: Infinity, headerDirtyMax: 0,
2727
3016
  localPosRefs: [], localNorRefs: [],
2728
3017
  localEntries: [], localToDrawId: [],
2729
- localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
3018
+ localPerDrawAvals: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
3019
+ fieldIdx,
3020
+ posFieldIdx: fieldIdx.get("Positions") ?? -1,
3021
+ norFieldIdx: fieldIdx.get("Normals") ?? -1,
2730
3022
  drawSlots: new Set<number>(), dirty: new Set<number>(),
2731
3023
  isAtlasBucket,
2732
3024
  localAtlasReleases: [],
@@ -2796,13 +3088,15 @@ export function buildHeapScene(
2796
3088
  // callers writeBuffer the result to the GPU.
2797
3089
  function packBucketHeader(
2798
3090
  bucket: Bucket, localSlot: number,
2799
- perDrawRefs: ReadonlyMap<string, number>,
3091
+ perDrawRefs: Int32Array,
2800
3092
  layoutId: number,
2801
3093
  ): void {
2802
3094
  const dst = bucket.drawHeaderStaging;
2803
3095
  const u32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length);
2804
3096
  const baseFloat = (localSlot * bucket.layout.drawHeaderBytes) / 4;
2805
- for (const f of bucket.layout.drawHeaderFields) {
3097
+ const fields = bucket.layout.drawHeaderFields;
3098
+ for (let i = 0; i < fields.length; i++) {
3099
+ const f = fields[i]!;
2806
3100
  // texture-ref fields carry inline values (pageRef/formatBits as u32,
2807
3101
  // origin/size as vec2<f32>) and are filled by packAtlasTextureFields.
2808
3102
  if (f.kind === "texture-ref") continue;
@@ -2815,15 +3109,11 @@ export function buildHeapScene(
2815
3109
  u32[fOff] = layoutId >>> 0;
2816
3110
  continue;
2817
3111
  }
2818
- const ref = perDrawRefs.get(f.name);
2819
- if (ref === undefined) {
2820
- // Family-merge: a slot's effect doesn't populate every field of
2821
- // the union; leave the unused slots zero the layoutId switch
2822
- // ensures they're never read by the wrong effect's helper.
2823
- u32[fOff] = 0;
2824
- continue;
2825
- }
2826
- u32[fOff] = ref;
3112
+ const ref = perDrawRefs[i]!;
3113
+ // Family-merge: a slot's effect doesn't populate every field of
3114
+ // the union (−1); leave the unused slots zero — the layoutId
3115
+ // switch ensures they're never read by the wrong effect's helper.
3116
+ u32[fOff] = ref < 0 ? 0 : ref;
2827
3117
  }
2828
3118
  }
2829
3119
 
@@ -2916,6 +3206,7 @@ export function buildHeapScene(
2916
3206
  totalDraws: 0,
2917
3207
  drawBytes: 0,
2918
3208
  geometryBytes: 0,
3209
+ compactions: 0,
2919
3210
  derivedPullMs: 0,
2920
3211
  derivedUploadMs: 0,
2921
3212
  derivedEncodeMs: 0,
@@ -2958,11 +3249,10 @@ export function buildHeapScene(
2958
3249
  // single spec when no batched lazy-build occurred earlier (e.g.
2959
3250
  // direct addDraw at runtime). The aset / array initial-population
2960
3251
  // paths build from the full effect set up front.
2961
- if (!familyBuilt) {
2962
- buildFamilyFromSpecs([spec]);
2963
- } else {
2964
- ensureFamilyKnowsEffect(spec.effect);
2965
- }
3252
+ // Build the family lazily from this spec (first addDraw), or register a
3253
+ // newly-seen effect's family on the fly (reactive rebuild) — supports a
3254
+ // scene mixing multiple effects added over time (tiles + markers + …).
3255
+ ensureFamilyForSpec(spec);
2966
3256
  const perInstanceUniforms = new Set<string>();
2967
3257
  const perInstanceAttributes = spec.instanceAttributes !== undefined
2968
3258
  ? new Set(Object.keys(spec.instanceAttributes))
@@ -2984,16 +3274,47 @@ export function buildHeapScene(
2984
3274
  token: outerTok,
2985
3275
  });
2986
3276
  }
2987
- // §3 chunk routing pick which arena chunk this RO's allocations
2988
- // land in. Preference order:
2989
- // 1. If any of the RO's bindings is an aval that's already
2990
- // allocated in some chunk via the uniform pool, prefer that
2991
- // chunk to avoid duplication.
2992
- // 2. Otherwise, use the newest open chunk (highest chunkIdx).
2993
- // ChunkedArena.alloc spills to the next chunk automatically
2994
- // when this one is full; the spill ends up driving the
2995
- // pool's `finalChunk` selection so all of the RO's
2996
- // allocations land in one chunk consistently.
3277
+ // ─── Group-fit page placement (§3) ───────────────────────────────
3278
+ // A draw's whole group — uniforms + attributes + INDEX data (all one
3279
+ // arena now) must land on ONE chunk/page (the draw binds one buffer).
3280
+ // Estimate the group's arena bytes (exact per-field via the same
3281
+ // placement helpers, ignoring dedup ⇒ an upper bound on the NEW bytes a
3282
+ // chunk must absorb), then place the group on a chunk with room — prefer
3283
+ // one already holding a shared input, else the newest, else open a fresh
3284
+ // page. This replaces the old cross-chunk spill throw, so a scene can
3285
+ // grow past one buffer's cap (WebGPU's storage-buffer binding limit).
3286
+ const fam = familyFor(spec.effect);
3287
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
3288
+ const layout = fam.schema.drawHeaderUnion;
3289
+ const hasIndices = spec.indices !== undefined;
3290
+ const indicesAval = hasIndices ? (asAval(spec.indices!) as aval<Uint32Array>) : undefined;
3291
+ const indexArr = hasIndices ? (readPlain(spec.indices!) as Uint32Array) : undefined;
3292
+ const instanceCount = spec.instanceCount !== undefined
3293
+ ? (readPlain(spec.instanceCount) as number) : 1;
3294
+
3295
+ let estBytes = indexArr !== undefined ? ALIGN16(ALLOC_HEADER_PAD_TO + indexArr.byteLength) : 0;
3296
+ for (const f of layout.drawHeaderFields) {
3297
+ if (f.kind === "texture-ref" || f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name) || !effectFields.has(f.name)) continue;
3298
+ if (ruleForUniform(spec, f.name) !== undefined) {
3299
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + PACKER_MAT4.dataBytes); // §7 derived dummy mat4
3300
+ continue;
3301
+ }
3302
+ const perInstUniform = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
3303
+ const perInstAttr = f.kind === "attribute-ref" && perInstanceAttributes.has(f.name);
3304
+ const provided = perInstAttr ? spec.instanceAttributes![f.name] : spec.inputs[f.name];
3305
+ if (provided === undefined) continue; // the acquire loop throws with a precise message
3306
+ let dataBytes: number;
3307
+ if (f.kind === "attribute-ref" && !perInstUniform && isBufferView(provided)) {
3308
+ dataBytes = bufferViewPlacement(f, provided).dataBytes;
3309
+ } else {
3310
+ const value = asAval(provided as aval<unknown> | unknown).getValue(outerTok);
3311
+ dataBytes = (perInstUniform
3312
+ ? perInstancePlacementFor(f, value, instanceCount)
3313
+ : poolPlacementFor(f, value)).dataBytes;
3314
+ }
3315
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
3316
+ }
3317
+
2997
3318
  let chunkIdx: number;
2998
3319
  {
2999
3320
  let preferred: number | undefined;
@@ -3005,7 +3326,12 @@ export function buildHeapScene(
3005
3326
  if (where !== undefined) { preferred = where; break; }
3006
3327
  }
3007
3328
  }
3008
- chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
3329
+ const fits = (c: number): boolean => arena.attrs.bumpHeadroom(c) >= estBytes;
3330
+ const newest = arena.attrs.chunkCount - 1;
3331
+ chunkIdx =
3332
+ preferred !== undefined && fits(preferred) ? preferred
3333
+ : fits(newest) ? newest
3334
+ : arena.attrs.openPage();
3009
3335
  }
3010
3336
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
3011
3337
  // Phase 5c.3 — every RO carrying a derived-mode rule is registered
@@ -3038,19 +3364,17 @@ export function buildHeapScene(
3038
3364
  // tables every frame from the master.
3039
3365
  const roSlot = bucket.gpuRouted ? bucket.slots[0]! : ensureSlot(bucket, roDescriptor);
3040
3366
  const roSlotIdx = bucket.slots.indexOf(roSlot);
3041
- const fam = familyFor(spec.effect);
3042
- const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
3043
3367
 
3044
- // Indices live in their own INDEX-usage buffer (WebGPU constraint).
3045
- // Aval-keyed: 19K instanced clones of the same mesh share one
3046
- // index allocation + one upload. NON-INDEXED draws (spec.indices
3047
- // omitted) skip the index pool entirely: the drawTable record stores
3048
- // firstIndex = HEAP_NONINDEXED (sentinel) and indexCount = vertexCount,
3049
- // and the megacall prelude uses the local vertex index directly.
3050
- const hasIndices = spec.indices !== undefined;
3051
- const indicesAval = hasIndices ? (asAval(spec.indices!) as aval<Uint32Array>) : undefined;
3368
+ // Indices ride the same arena as attributes/uniforms (the VS storage-
3369
+ // decodes them). Aval-keyed: 19K instanced clones of the same mesh share
3370
+ // one index allocation + one upload. NON-INDEXED draws (spec.indices
3371
+ // omitted) skip the pool: the drawTable record stores firstIndex =
3372
+ // HEAP_NONINDEXED (sentinel) and indexCount = vertexCount, and the
3373
+ // megacall prelude uses the local vertex index directly. `indicesAval` /
3374
+ // `indexArr` were read above for the group estimate; the group landed
3375
+ // wholly on `bucket.chunkIdx`, so this allocation can't spill.
3052
3376
  const idxAlloc = hasIndices
3053
- ? indexPool.acquire(device, arena.indices, indicesAval!, bucket.chunkIdx, readPlain(spec.indices!) as Uint32Array)
3377
+ ? indexPool.acquire(device, arena.attrs, indicesAval!, bucket.chunkIdx, indexArr!)
3054
3378
  : undefined;
3055
3379
  // Emit count per instance + the firstIndex field written to the record.
3056
3380
  // Slice: emit only [firstIndex, firstIndex+indexCount) of the (shared,
@@ -3060,10 +3384,6 @@ export function buildHeapScene(
3060
3384
  const firstIndexField = hasIndices ? (idxAlloc!.firstIndex + (spec.firstIndex ?? 0)) : HEAP_NONINDEXED;
3061
3385
 
3062
3386
  const localSlot = bucket.drawHeap.alloc();
3063
- // Per-RO instancing: read `spec.instanceCount` (defaults to 1).
3064
- const instanceCount = spec.instanceCount !== undefined
3065
- ? readPlain(spec.instanceCount) as number
3066
- : 1;
3067
3387
 
3068
3388
  // Walk the bucket's schema-driven DrawHeader fields. Per-instance
3069
3389
  // attributes pull from `spec.instanceAttributes` and pack into an
@@ -3071,7 +3391,18 @@ export function buildHeapScene(
3071
3391
  // packs as a single value. Both go through the same pool — sharing
3072
3392
  // emerges from aval identity either way.
3073
3393
  const perDrawAvals: aval<unknown>[] = [];
3074
- const perDrawRefs = new Map<string, number>();
3394
+ let perDrawRawFieldIdx: number[] | undefined;
3395
+ const perDrawRefs = new Int32Array(bucket.layout.drawHeaderFields.length).fill(-1);
3396
+ const setRef = (name: string, ref: number): void => {
3397
+ const i = bucket.fieldIdx.get(name);
3398
+ if (i !== undefined) perDrawRefs[i] = ref;
3399
+ };
3400
+ const getRef = (name: string): number | undefined => {
3401
+ const i = bucket.fieldIdx.get(name);
3402
+ if (i === undefined) return undefined;
3403
+ const r = perDrawRefs[i]!;
3404
+ return r < 0 ? undefined : r;
3405
+ };
3075
3406
  {
3076
3407
  const tok = outerTok;
3077
3408
  for (const f of bucket.layout.drawHeaderFields) {
@@ -3083,18 +3414,45 @@ export function buildHeapScene(
3083
3414
  // layoutId branch never reads them, so the slot stays zero.
3084
3415
  if (f.name === "__layoutId") continue;
3085
3416
  if (!effectFields.has(f.name)) continue;
3417
+ // Inline header uniforms (PickId): the header word IS the u32
3418
+ // value — write it via perDrawRefs (packBucketHeader copies
3419
+ // refs verbatim), skip the pool entirely. A provider-supplied
3420
+ // PickId aval still works below as the (slow) fallback.
3421
+ if (INLINE_HEADER_UNIFORMS.has(f.name)) {
3422
+ const iv = f.name === "PickId" ? spec.pickId : undefined;
3423
+ if (iv !== undefined) {
3424
+ setRef(f.name, iv >>> 0);
3425
+ continue;
3426
+ }
3427
+ const provInline = spec.inputs[f.name];
3428
+ if (typeof provInline === "number") {
3429
+ setRef(f.name, provInline >>> 0);
3430
+ continue;
3431
+ }
3432
+ if (provInline === undefined) {
3433
+ // No id at all (picking disabled but the effect still
3434
+ // reads the uniform): 0 = "no pick", a reserved id.
3435
+ setRef(f.name, 0);
3436
+ continue;
3437
+ }
3438
+ // fall through: aval-provided → legacy pooled path
3439
+ }
3086
3440
  // §7: derived-uniform fields are produced by the compute
3087
3441
  // pre-pass. We still allocate an arena slot here so the
3088
3442
  // drawHeader gets a valid ref written by packBucketHeader; §7
3089
3443
  // overwrites the arena data each frame the inputs are dirty.
3090
3444
  if (ruleForUniform(spec, f.name) !== undefined) {
3091
- const dummyAval = AVal.constant<M44d>(M44d.zero);
3092
- const ref = pool.acquire(
3093
- device, arena.attrs, dummyAval, bucket.chunkIdx, M44d.zero,
3094
- PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack,
3445
+ // Raw (aval-less) slot: §7's compute pre-pass overwrites the
3446
+ // data every dirty frame, so a keyed pool entry (placeholder
3447
+ // ConstantVal + Map per draw) bought nothing. The ref lives
3448
+ // in the drawHeader cell (compaction re-seats it there);
3449
+ // removeDraw frees it via `perDrawRawFieldIdx`.
3450
+ const ref = pool.allocRaw(
3451
+ arena.attrs, bucket.chunkIdx,
3452
+ PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1,
3095
3453
  );
3096
- perDrawRefs.set(f.name, ref);
3097
- perDrawAvals.push(dummyAval);
3454
+ setRef(f.name, ref);
3455
+ (perDrawRawFieldIdx ??= []).push(bucket.fieldIdx.get(f.name)!);
3098
3456
  continue;
3099
3457
  }
3100
3458
  const isPerInstanceUniformField =
@@ -3126,11 +3484,37 @@ export function buildHeapScene(
3126
3484
  let av: aval<unknown>;
3127
3485
  let value: unknown;
3128
3486
  let placement: ReturnType<typeof poolPlacementFor>;
3487
+ let releasable: { _v: unknown } | undefined;
3129
3488
  if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
3130
3489
  const bv = provided;
3131
3490
  placement = bufferViewPlacement(f, bv);
3132
3491
  av = bv.buffer as aval<unknown>;
3133
3492
  value = bv.buffer.getValue(tok);
3493
+ } else if (
3494
+ releaseConstantAttributes
3495
+ && f.kind === "attribute-ref"
3496
+ && !isPerInstanceUniformField
3497
+ && !(typeof provided === "object" && provided !== null
3498
+ && typeof (provided as { getValue?: unknown }).getValue === "function")
3499
+ ) {
3500
+ // Plain value (raw array / packed marker) → CONSTANT by definition.
3501
+ // Wrap in a droppable holder: after this field's arena write the GPU
3502
+ // copy is the only copy the heap ever reads again, so the payload is
3503
+ // released (real avals never take this branch — changeable data
3504
+ // stays alive by contract).
3505
+ const holder = {
3506
+ _v: provided as unknown,
3507
+ getValue(): unknown {
3508
+ if (holder._v === null) {
3509
+ throw new Error("heapScene: constant attribute payload was released after staging (releaseConstantAttributes)");
3510
+ }
3511
+ return holder._v;
3512
+ },
3513
+ };
3514
+ releasable = holder;
3515
+ av = holder as unknown as aval<unknown>;
3516
+ value = provided;
3517
+ placement = poolPlacementFor(f, value);
3134
3518
  } else {
3135
3519
  av = asAval(provided as aval<unknown> | unknown);
3136
3520
  value = av.getValue(tok);
@@ -3142,19 +3526,21 @@ export function buildHeapScene(
3142
3526
  device, arena.attrs, av, bucket.chunkIdx, value,
3143
3527
  placement.dataBytes, placement.typeId, placement.length, placement.pack,
3144
3528
  );
3145
- perDrawRefs.set(f.name, ref);
3529
+ if (releasable !== undefined) releasable._v = null; // staged — drop the CPU copy
3530
+ setRef(f.name, ref);
3146
3531
  perDrawAvals.push(av);
3147
3532
  }
3148
3533
  }
3149
3534
 
3150
- bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
3151
- bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
3535
+ bucket.localPosRefs[localSlot] = getRef("Positions");
3536
+ bucket.localNorRefs[localSlot] = getRef("Normals");
3152
3537
  bucket.localEntries[localSlot] = {
3153
3538
  indexCount: emitCount, firstIndex: firstIndexField, instanceCount,
3154
3539
  };
3155
3540
  bucket.localToDrawId[localSlot] = drawId;
3156
3541
  bucket.drawSlots.add(localSlot);
3157
3542
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
3543
+ bucket.localPerDrawRawFieldIdx[localSlot] = perDrawRawFieldIdx;
3158
3544
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
3159
3545
  const layoutId = fam.schema.layoutIdOf.get(spec.effect)!;
3160
3546
  bucket.localLayoutIds[localSlot] = layoutId;
@@ -3264,7 +3650,7 @@ export function buildHeapScene(
3264
3650
  // it) get 0 — the kernel only reads refs through combo fns that
3265
3651
  // gate on `r.comboId`, so an unread 0 is harmless.
3266
3652
  const uniformRefs: number[] = (bucket.uniformOrder ?? []).map(
3267
- name => perDrawRefs.get(name) ?? 0,
3653
+ name => getRef(name) ?? 0,
3268
3654
  );
3269
3655
  partition.appendRecord(localSlot, firstIndexField, emitCount, instanceCount, roComboId, uniformRefs);
3270
3656
  bucket.drawIdToRecord!.set(drawId, recIdx);
@@ -3369,7 +3755,10 @@ export function buildHeapScene(
3369
3755
  }
3370
3756
  const tracker = new ModeKeyTracker(
3371
3757
  spec.pipelineState, sig,
3372
- () => { dirtyModeKeyDrawIds.add(drawId); },
3758
+ // skipSubscribe means the tracker never invokes onDirty itself
3759
+ // (scene-level modeAvalToDrawIds dispatches instead) — a shared
3760
+ // noop avoids one closure per draw.
3761
+ NOOP,
3373
3762
  {
3374
3763
  skipSubscribe: true,
3375
3764
  ...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
@@ -3404,11 +3793,11 @@ export function buildHeapScene(
3404
3793
  const ruleSubset = new Map<string, DerivedRule>();
3405
3794
  const outOffsetByName = new Map<string, number>();
3406
3795
  for (const f of bucket.layout.drawHeaderFields) {
3407
- if (f.name === "__layoutId") continue;
3796
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
3408
3797
  if (!effectFields.has(f.name)) continue;
3409
3798
  const rule = ruleForUniform(spec, f.name);
3410
3799
  if (rule === undefined) continue;
3411
- const ref = perDrawRefs.get(f.name);
3800
+ const ref = getRef(f.name);
3412
3801
  if (ref === undefined) continue;
3413
3802
  ruleSubset.set(f.name, rule);
3414
3803
  outOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
@@ -3434,7 +3823,7 @@ export function buildHeapScene(
3434
3823
  trafoAvals,
3435
3824
  ...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
3436
3825
  hostUniformOffset: (n) => {
3437
- const r = perDrawRefs.get(n);
3826
+ const r = getRef(n);
3438
3827
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
3439
3828
  },
3440
3829
  outputOffset: (n) => outOffsetByName.get(n),
@@ -3545,8 +3934,21 @@ export function buildHeapScene(
3545
3934
  // Release pool entries — refcount drops; if zero, allocation freed.
3546
3935
  const avals = bucket.localPerDrawAvals[localSlot];
3547
3936
  if (avals !== undefined) for (const av of avals) pool.release(arena.attrs, av, bucket.chunkIdx);
3937
+ // Raw §7 slots: the live ref is the drawHeader cell (compaction
3938
+ // keeps it current); free it directly.
3939
+ const rawIdxs = bucket.localPerDrawRawFieldIdx[localSlot];
3940
+ if (rawIdxs !== undefined) {
3941
+ const rawRefs = bucket.localPerDrawRefs[localSlot];
3942
+ if (rawRefs !== undefined) {
3943
+ for (const fi of rawIdxs) {
3944
+ const ref = rawRefs[fi]!;
3945
+ if (ref >= 0) pool.releaseRaw(arena.attrs, bucket.chunkIdx, ref, PACKER_MAT4.dataBytes);
3946
+ }
3947
+ }
3948
+ bucket.localPerDrawRawFieldIdx[localSlot] = undefined;
3949
+ }
3548
3950
  const idxAval = drawIdToIndexAval[drawId];
3549
- if (idxAval !== undefined) indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
3951
+ if (idxAval !== undefined) indexPool.release(arena.attrs, idxAval, bucket.chunkIdx);
3550
3952
  const atlasRel = bucket.localAtlasReleases[localSlot];
3551
3953
  if (atlasRel !== undefined) atlasRel();
3552
3954
  // Drop atlas-aval ref (if any). When the last ref is dropped we
@@ -3616,14 +4018,16 @@ export function buildHeapScene(
3616
4018
  function drainAsetWith(tok: AdaptiveToken): void {
3617
4019
  if (asetReader === undefined) return;
3618
4020
  const delta = asetReader.getChanges(tok);
4021
+ // Process removals before adds. A remove+add in the same
4022
+ // transaction must free its slot / arena region / atlas tile
4023
+ // before the add allocates, so the freelist reuses the just-freed
4024
+ // block instead of growing the high-water toward
4025
+ // live + removed-this-tick. (Matches Aardvark's removals-first
4026
+ // delta discipline.)
4027
+ const adds: HeapDrawSpec[] = [];
3619
4028
  delta.iter((op: { value: HeapDrawSpec; count: number }) => {
3620
4029
  if (op.count > 0) {
3621
- // Re-use the outer evaluateAlways scope's token (caller is
3622
- // already sceneObj) — collapses N nested evaluateAlways into
3623
- // one for a bulk add. Saves a per-RO setUnsafeEvaluationDepth
3624
- // + outputs.add(sceneObj) round trip.
3625
- const id = addDrawImpl(op.value, tok);
3626
- specToDrawId.set(op.value, id);
4030
+ adds.push(op.value);
3627
4031
  } else {
3628
4032
  const id = specToDrawId.get(op.value);
3629
4033
  if (id !== undefined) {
@@ -3632,6 +4036,13 @@ export function buildHeapScene(
3632
4036
  }
3633
4037
  }
3634
4038
  });
4039
+ // Re-use the outer evaluateAlways scope's token (caller is already
4040
+ // sceneObj) — collapses N nested evaluateAlways into one for a bulk
4041
+ // add. Saves a per-RO setUnsafeEvaluationDepth + outputs.add round trip.
4042
+ for (const value of adds) {
4043
+ const id = addDrawImpl(value, tok);
4044
+ specToDrawId.set(value, id);
4045
+ }
3635
4046
  }
3636
4047
 
3637
4048
  // ─── Initial population ───────────────────────────────────────────
@@ -3648,9 +4059,9 @@ export function buildHeapScene(
3648
4059
  asetReader = (initialDraws as aset<HeapDrawSpec>).getReader();
3649
4060
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
3650
4061
  // First drain: snapshot all add-ops, build the family from their
3651
- // effects up front, then run addDraw. Subsequent drains either
3652
- // reuse the frozen family (matching effects) or throw on a new
3653
- // unseen effect (per the v1 frozen-family contract).
4062
+ // effects up front, then run addDraw. Subsequent drains reuse the
4063
+ // family, or register a newly-seen effect's family on the fly
4064
+ // (ensureFamilyForSpec, via addDrawImpl).
3654
4065
  if (!familyBuilt) {
3655
4066
  const reader = asetReader!;
3656
4067
  const delta = reader.getChanges(tok);
@@ -3679,6 +4090,142 @@ export function buildHeapScene(
3679
4090
  });
3680
4091
  }
3681
4092
 
4093
+ // ─── Heap compaction (attribute arena + index buffer) ────────────
4094
+ /**
4095
+ * Compact every arena/index chunk whose fragmentation waste crosses the
4096
+ * floor (or unconditionally when `force`), then re-seat EVERY cached ref so
4097
+ * the relocated allocations stay addressable. Attribute arena:
4098
+ * • uniform-pool entries (`pool.remapRefs`)
4099
+ * • §7 derived-uniform record handles (`derivedScene.remapHostHeap`)
4100
+ * • modes partition master refs (`partitionScene.remapUniformRefs`)
4101
+ * • drawHeader cells + the Pos/Nor caches (re-`packBucketHeader`)
4102
+ * Index buffer (ref = `allocBase + slice`, held in the drawTable / partition
4103
+ * master + IndexPool entry):
4104
+ * • per-draw `indexStart` in the CPU drawTable or partition master
4105
+ * • `localEntries[slot].firstIndex` + IndexPool entry base
4106
+ * Must run with the buffers current (we flush first) and before the
4107
+ * per-bucket header / drawTable flush so the re-seated records upload.
4108
+ */
4109
+ // Per-scene ref re-seat after the shared store relocates regions. Registered
4110
+ // with `storage`; the store calls it (for THIS scene + any others sharing the
4111
+ // store) inside `storage.compact`. Re-seats derived records, partition master
4112
+ // refs, drawHeader cells, and index `_indexStart` — everything this scene
4113
+ // caches that points into the arena. (The storage-level uniform/index pools
4114
+ // are re-seated by the store itself, around this call.)
4115
+ function applyRemap(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
4116
+ if (derivedScene !== undefined) {
4117
+ // §7 handles store DATA offsets (ref + header pad), not header refs.
4118
+ const dataRemap = new Map<number, number>();
4119
+ for (const [o, n] of remap) {
4120
+ dataRemap.set(o + ALLOC_HEADER_PAD_TO, n + ALLOC_HEADER_PAD_TO);
4121
+ }
4122
+ derivedScene.remapHostHeap(chunkIdx, dataRemap);
4123
+ }
4124
+ {
4125
+ for (const bucket of buckets) {
4126
+ if (bucket.chunkIdx !== chunkIdx) continue;
4127
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
4128
+ if (bucket.partitionScene.remapUniformRefs(remap) > 0) {
4129
+ bucket.partitionDirty = true;
4130
+ }
4131
+ }
4132
+ for (const localSlot of bucket.drawSlots) {
4133
+ // (a) Index region re-seat. Index data is an attr-arena region now,
4134
+ // so it relocates in THIS pass. Its draw stores `_indexStart` (an
4135
+ // element offset = allocBase + slice) in the drawTable / partition
4136
+ // master; shift it by the region's element delta. Independent of the
4137
+ // uniform re-seat below (a slot's indices may move while its uniforms
4138
+ // don't), so it runs before the `changed` gate.
4139
+ const drawId = bucket.localToDrawId[localSlot];
4140
+ if (drawId !== undefined) {
4141
+ const iav = drawIdToIndexAval[drawId];
4142
+ const oldRef = iav !== undefined ? indexPool.baseFor(iav, chunkIdx) : undefined;
4143
+ const newRef = oldRef !== undefined ? remap.get(oldRef) : undefined;
4144
+ const entry = bucket.localEntries[localSlot];
4145
+ if (oldRef !== undefined && newRef !== undefined && entry !== undefined) {
4146
+ const newStart = entry.firstIndex + ((newRef - oldRef) >> 2);
4147
+ entry.firstIndex = newStart;
4148
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
4149
+ const recIdx = bucket.drawIdToRecord?.get(drawId);
4150
+ if (recIdx !== undefined) {
4151
+ bucket.partitionScene.setRecordIndexStart(recIdx, newStart);
4152
+ bucket.partitionDirty = true;
4153
+ }
4154
+ } else {
4155
+ const slotIdx = drawIdToSlotIdx[drawId];
4156
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
4157
+ const recIdx = slot?.slotToRecord[localSlot];
4158
+ if (slot !== undefined && recIdx !== undefined && recIdx >= 0) {
4159
+ slot.drawTableShadow![recIdx * RECORD_U32 + 2] = newStart >>> 0;
4160
+ const off = recIdx * RECORD_BYTES;
4161
+ if (off < slot.drawTableDirtyMin) slot.drawTableDirtyMin = off;
4162
+ if (off + RECORD_BYTES > slot.drawTableDirtyMax) slot.drawTableDirtyMax = off + RECORD_BYTES;
4163
+ }
4164
+ }
4165
+ }
4166
+ }
4167
+ // (b) Uniform/attr drawHeader cells (+ Pos/Nor caches).
4168
+ const refs = bucket.localPerDrawRefs[localSlot];
4169
+ if (refs === undefined) continue;
4170
+ let changed = false;
4171
+ for (let i = 0; i < refs.length; i++) {
4172
+ const ref = refs[i]!;
4173
+ if (ref < 0) continue;
4174
+ const fld = bucket.layout.drawHeaderFields[i]!;
4175
+ // Inline header values (PickId/__layoutId) are NOT arena
4176
+ // refs — a numeric collision with an old offset must not
4177
+ // remap them.
4178
+ if (fld.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(fld.name)) continue;
4179
+ const nn = remap.get(ref);
4180
+ if (nn !== undefined) { refs[i] = nn; changed = true; }
4181
+ }
4182
+ if (!changed) continue;
4183
+ const pr = bucket.posFieldIdx >= 0 ? refs[bucket.posFieldIdx]! : -1;
4184
+ const nr = bucket.norFieldIdx >= 0 ? refs[bucket.norFieldIdx]! : -1;
4185
+ bucket.localPosRefs[localSlot] = pr < 0 ? undefined : pr;
4186
+ bucket.localNorRefs[localSlot] = nr < 0 ? undefined : nr;
4187
+ packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
4188
+ if (bucket.isAtlasBucket) {
4189
+ const ts = bucket.localAtlasTextures[localSlot];
4190
+ if (ts !== undefined) packAtlasTextureFields(bucket, localSlot, ts);
4191
+ }
4192
+ const byteOff = localSlot * bucket.layout.drawHeaderBytes;
4193
+ if (byteOff < bucket.headerDirtyMin) bucket.headerDirtyMin = byteOff;
4194
+ const end = byteOff + bucket.layout.drawHeaderBytes;
4195
+ if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
4196
+ }
4197
+ }
4198
+ }
4199
+ }
4200
+
4201
+ // Register this scene as a consumer of the (possibly shared) store, so a
4202
+ // compaction — triggered by THIS scene or any other sharing the store —
4203
+ // re-seats this scene's refs. Disposed in `dispose()`.
4204
+ const storageReg = storage.register({ applyRemap });
4205
+
4206
+ // ─── Compaction (delegates to the shared store) ──────────────────
4207
+ function runCompaction(force: boolean): void {
4208
+ const compacted = storage.compact(device, compactionWasteFloor, force);
4209
+ stats.compactions += compacted;
4210
+ // update() flushes the re-seated drawHeaders via its own per-bucket loop
4211
+ // right after this returns; the forced (test) path runs outside update(),
4212
+ // so it flushes this scene's dirtied headers itself.
4213
+ if (force) {
4214
+ for (const bucket of buckets) {
4215
+ if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
4216
+ device.queue.writeBuffer(
4217
+ bucket.drawHeap.buffer, bucket.headerDirtyMin,
4218
+ bucket.drawHeaderStaging.buffer,
4219
+ bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin,
4220
+ bucket.headerDirtyMax - bucket.headerDirtyMin,
4221
+ );
4222
+ bucket.headerDirtyMin = Infinity;
4223
+ bucket.headerDirtyMax = 0;
4224
+ }
4225
+ }
4226
+ }
4227
+ }
4228
+
3682
4229
  // ─── update / encodeIntoPass / frame / dispose ───────────────────
3683
4230
  /**
3684
4231
  * CPU-side data refresh: drain pending aset deltas, repack any
@@ -3908,7 +4455,17 @@ export function buildHeapScene(
3908
4455
  // initial population for 10K ROs this collapses ~30K calls into
3909
4456
  // ~5 (arena, indices, plus one per bucket).
3910
4457
  arena.attrs.flush(device);
3911
- arena.indices.flush(device);
4458
+
4459
+ // ─── Waste-triggered attribute-arena compaction ─────────────────
4460
+ // Relocate live allocations to the front of each chunk once
4461
+ // fragmentation waste crosses the floor — exact-size freelist reuse
4462
+ // can't reclaim a drifting size distribution, so the arena would
4463
+ // otherwise ratchet to its high-water forever. Runs AFTER the arena
4464
+ // flush (GPU buffer current) and BEFORE the header flush (so the
4465
+ // re-seated drawHeaders upload below). Indices live in a separate
4466
+ // buffer and are not compacted here.
4467
+ if (enableCompaction) runCompaction(false);
4468
+
3912
4469
  for (const bucket of buckets) {
3913
4470
  if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
3914
4471
  device.queue.writeBuffer(
@@ -4112,14 +4669,42 @@ export function buildHeapScene(
4112
4669
  }
4113
4670
 
4114
4671
  function dispose(): void {
4115
- arena.attrs.destroy();
4116
- arena.indices.destroy();
4672
+ // Stop receiving compaction remaps for the shared store.
4673
+ storageReg.dispose();
4674
+ // Shared storage: release this scene's allocations so the shared pool's
4675
+ // refcounts stay correct (another scene may still hold the same regions),
4676
+ // but don't destroy the buffers — the storage owner does that. Private
4677
+ // storage: the whole arena is torn down wholesale, so per-draw release is
4678
+ // unnecessary.
4679
+ if (!ownsStorage) {
4680
+ for (let drawId = 0; drawId < drawIdToBucket.length; drawId++) {
4681
+ if (drawIdToBucket[drawId] !== undefined) removeDraw(drawId);
4682
+ }
4683
+ }
4117
4684
  for (const b of buckets) destroyBucketResources(b);
4685
+ if (ownsStorage) storage.dispose();
4118
4686
  }
4119
4687
 
4120
4688
  // Test-only escape hatch for inspecting megacall bucket state. Not
4121
4689
  // part of the public API surface — keep cast at use-site.
4122
4690
  const _debug = {
4691
+ /** Force a full heap compaction pass (attribute arena + index buffer)
4692
+ * regardless of the waste heuristics (bypasses the floor + 50%-live
4693
+ * gate), re-seat all refs, flush. Returns residual waste afterwards in
4694
+ * bytes (attr bytes + index elements×4; 0 when fully packed). Test-only —
4695
+ * drives relocation deterministically without engineering megabytes of
4696
+ * fragmentation. Callers should `update()` afterward to flush drawTables /
4697
+ * re-dispatch the partition (the render path does this). */
4698
+ forceCompact(): number {
4699
+ runCompaction(true);
4700
+ return arena.attrs.totalWasteBytes();
4701
+ },
4702
+ /** Current arena fragmentation waste (bytes) — covers uniforms,
4703
+ * attributes, and index regions (all one arena now). */
4704
+ attrWasteBytes(): number { return arena.attrs.totalWasteBytes(); },
4705
+ /** Number of arena chunks (pages) currently open. >1 means group
4706
+ * placement rolled past one buffer cap. */
4707
+ pageCount(): number { return arena.attrs.chunkCount; },
4123
4708
  bucketsForTest(): readonly {
4124
4709
  indirectBuf: GPUBuffer | undefined;
4125
4710
  drawTableBuf: GPUBuffer | undefined;
@@ -4180,14 +4765,14 @@ export function buildHeapScene(
4180
4765
  // multi-chunk-aware validator needs per-bucket-chunkIdx
4181
4766
  // staging which is a bigger refactor; assert for now so
4182
4767
  // multi-chunk callers don't get false-passes.
4183
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4768
+ if (arena.attrs.chunkCount > 1) {
4184
4769
  throw new Error(
4185
4770
  "heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
4186
4771
  "Run with the default chunk cap (no second chunk opened) for now.",
4187
4772
  );
4188
4773
  }
4189
4774
  const arenaSize = arena.attrs.chunk(0).buffer.size;
4190
- const indicesSize = arena.indices.chunk(0).buffer.size;
4775
+ const indicesSize = arenaSize; // indices live in the attr arena now
4191
4776
 
4192
4777
  const enc = device.createCommandEncoder({ label: "validateHeap" });
4193
4778
  const stage = (src: GPUBuffer, size: number): GPUBuffer => {
@@ -4200,7 +4785,7 @@ export function buildHeapScene(
4200
4785
  };
4201
4786
 
4202
4787
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
4203
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
4788
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
4204
4789
  type DC = {
4205
4790
  bucket: typeof buckets[number];
4206
4791
  drawHeap: GPUBuffer;
@@ -4249,7 +4834,7 @@ export function buildHeapScene(
4249
4834
  // Track unique attribute-alloc refs we've already inspected so we
4250
4835
  // don't re-validate the same shared alloc 20K times.
4251
4836
  const attrAllocsSeen = new Set<number>();
4252
- const KNOWN_TYPE_IDS = new Set<number>([0, 1, 2, 3]);
4837
+ const KNOWN_TYPE_IDS = new Set<number>([0, 1, 2, 3]);
4253
4838
 
4254
4839
  let bucketIdx = 0;
4255
4840
  for (const dc of dcs) {
@@ -4260,7 +4845,7 @@ export function buildHeapScene(
4260
4845
  for (let slot = 0; slot < recordCount; slot++) {
4261
4846
  const slotOff = slot * stride;
4262
4847
  for (const f of dc.bucket.layout.drawHeaderFields) {
4263
- if (f.name === "__layoutId") continue;
4848
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
4264
4849
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
4265
4850
  const ref = u32[(slotOff + f.byteOffset) >>> 2];
4266
4851
  if (ref === undefined || ref === 0) continue;
@@ -4294,18 +4879,9 @@ export function buildHeapScene(
4294
4879
  ? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
4295
4880
  : "";
4296
4881
  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]}]`;
4297
- // Compare CPU shadow vs GPU read-back. Differ → GPU
4298
- // write corrupted; match → bug is in CPU-side write path
4299
- // OR the alloc never went through pool.acquire.
4300
- const shadowChunk = arena.attrs.chunk(0);
4301
- const shadowU32 = shadowChunk.peekShadowU32(ref, 8);
4302
- const shadowStr = `shadow=[${shadowU32[0]},${shadowU32[1]},${shadowU32[2]},${shadowU32[3]},${shadowU32[4]},${shadowU32[5]},${shadowU32[6]},${shadowU32[7]}]`;
4303
- const cpuMatches = shadowU32[0] === arenaU32[refU32]
4304
- && shadowU32[1] === arenaU32[refU32 + 1]
4305
- && shadowU32[2] === arenaU32[refU32 + 2]
4306
- && shadowU32[3] === arenaU32[refU32 + 3];
4307
- const verdict = cpuMatches ? "GPU=CPU (likely CPU-write bug)" : "GPU≠CPU (likely GPU-write corruption)";
4308
- push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${verdict} ${before}${here} ${shadowStr}`);
4882
+ // (Mirror-less arena: no CPU shadow to cross-check against
4883
+ // the GPU read-back is the only copy.)
4884
+ push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${before}${here}`);
4309
4885
  attrAllocsBad++;
4310
4886
  continue;
4311
4887
  }
@@ -4516,11 +5092,11 @@ export function buildHeapScene(
4516
5092
  const push = (s: string) => { if (issues.length < 30) issues.push(s); };
4517
5093
 
4518
5094
  // §3 v1: chunk-0-only like validateHeap.
4519
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
5095
+ if (arena.attrs.chunkCount > 1) {
4520
5096
  throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
4521
5097
  }
4522
5098
  const arenaSize = arena.attrs.chunk(0).buffer.size;
4523
- const indicesSize = arena.indices.chunk(0).buffer.size;
5099
+ const indicesSize = arenaSize; // indices live in the attr arena now
4524
5100
 
4525
5101
  // Stage all buffers we'll need.
4526
5102
  const enc = device.createCommandEncoder({ label: "simulateDraws" });
@@ -4532,7 +5108,7 @@ export function buildHeapScene(
4532
5108
  return c;
4533
5109
  };
4534
5110
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
4535
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
5111
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
4536
5112
  type DC = {
4537
5113
  bucket: typeof buckets[number];
4538
5114
  drawHeap: GPUBuffer;
@@ -4592,7 +5168,7 @@ export function buildHeapScene(
4592
5168
  for (const dc of dcs) {
4593
5169
  const fs: Field[] = [];
4594
5170
  for (const f of dc.bucket.layout.drawHeaderFields) {
4595
- if (f.name === "__layoutId") continue;
5171
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
4596
5172
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
4597
5173
  const wt = f.kind === "uniform-ref" ? f.uniformWgslType : f.attributeWgslType;
4598
5174
  const eltF =
@@ -4803,11 +5379,11 @@ export function buildHeapScene(
4803
5379
  const allocClaims: { ref: number; bytes: number; owner: string }[] = [];
4804
5380
 
4805
5381
  // §3 v1: chunk-0-only (see validateHeap).
4806
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
5382
+ if (arena.attrs.chunkCount > 1) {
4807
5383
  throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
4808
5384
  }
4809
5385
  const arenaBuf0 = arena.attrs.chunk(0).buffer;
4810
- const indicesBuf0 = arena.indices.chunk(0).buffer;
5386
+ const indicesBuf0 = arena.attrs.chunk(0).buffer;
4811
5387
  // Stage arena + arena.indices once (shared across buckets).
4812
5388
  const arenaCopy = device.createBuffer({
4813
5389
  size: arenaBuf0.size,
@@ -5255,7 +5831,7 @@ export function buildHeapScene(
5255
5831
  { binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
5256
5832
  { binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
5257
5833
  { binding: 2, resource: { buffer: sampleBuf } },
5258
- { binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
5834
+ { binding: 3, resource: { buffer: arena.attrs.chunk(target.chunkIdx).buffer } },
5259
5835
  { binding: 4, resource: { buffer: outBuf } },
5260
5836
  { binding: 5, resource: { buffer: paramBuf } },
5261
5837
  ],
@@ -5278,7 +5854,7 @@ export function buildHeapScene(
5278
5854
  sampleBuf.destroy(); outBuf.destroy(); paramBuf.destroy();
5279
5855
 
5280
5856
  // Read drawTable + indices for CPU expectation.
5281
- const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
5857
+ const idxBuf = arena.attrs.chunk(target.chunkIdx).buffer;
5282
5858
  const indicesCopy2 = device.createBuffer({
5283
5859
  size: idxBuf.size,
5284
5860
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,