@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
@@ -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;
@@ -272,6 +350,14 @@ interface Bucket {
272
350
  readonly slots: BucketSlot[];
273
351
  /** Repointed by `rebuildBindGroups` whenever any backing GrowBuffer reallocates. */
274
352
  bindGroup: GPUBindGroup;
353
+ /** Scene-shared user storage buffers (layout.storageBindings), set on the
354
+ * first addDraw that supplies them. */
355
+ storageBuffers: ReadonlyMap<string, aval<IBuffer>> | undefined;
356
+ /** Set when a storage aval marks (e.g. the OIT head buffer reallocating on
357
+ * resize) — update() rebuilds this bucket's slot bind groups. */
358
+ storageDirty: boolean;
359
+ /** Disposers for the storage avals' marking subscriptions. */
360
+ storageSubs: (() => void)[];
275
361
 
276
362
  // Per-bucket buffers
277
363
  readonly drawHeap: DrawHeap;
@@ -299,13 +385,24 @@ interface Bucket {
299
385
  * keyed by aval identity.
300
386
  */
301
387
  readonly localPerDrawAvals: (aval<unknown>[] | undefined)[];
388
+ /** Field indices (into `layout.drawHeaderFields`) whose drawHeader
389
+ * cells hold RAW arena refs (§7 output slots, no pool entry). */
390
+ readonly localPerDrawRawFieldIdx: (number[] | undefined)[];
302
391
  /**
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.
392
+ * Per-draw uniform refs for this slot, indexed by the bucket
393
+ * layout's drawHeaderFields ORDER (−1 = field not populated by
394
+ * this slot's effect). A flat Int32Array instead of a
395
+ * Map<string, number> — at heap scale (hundreds of thousands of
396
+ * draws) the per-draw Map was one of the largest JS-memory items.
397
+ * Stable during the slot's lifetime; used to re-pack the
398
+ * DrawHeader after the drawHeap GrowBuffer reallocates.
307
399
  */
308
- readonly localPerDrawRefs: (Map<string, number> | undefined)[];
400
+ readonly localPerDrawRefs: (Int32Array | undefined)[];
401
+ /** drawHeaderFields name → array index (built once per bucket). */
402
+ readonly fieldIdx: Map<string, number>;
403
+ /** fieldIdx.get("Positions") / ("Normals"), −1 when absent. */
404
+ readonly posFieldIdx: number;
405
+ readonly norFieldIdx: number;
309
406
  /**
310
407
  * Per-local-slot layoutId for the §6 family-merge selector. Stable
311
408
  * during the slot's lifetime; written into the drawHeader's
@@ -412,6 +509,12 @@ export interface HeapDrawSpec {
412
509
  * share a pipeline + bucket.
413
510
  */
414
511
  readonly effect: Effect;
512
+ /**
513
+ * Optional picking id (u32 < 2^24), written INLINE into the
514
+ * drawHeader's `PickId` field (see `INLINE_HEADER_UNIFORMS`) — no
515
+ * pool entry, no per-draw aval.
516
+ */
517
+ readonly pickId?: number;
415
518
  /**
416
519
  * Per-name inputs covering both vertex attributes (e.g. `Positions`,
417
520
  * `Normals`) and uniforms (e.g. `ModelTrafo`, `Color`, `ViewProjTrafo`)
@@ -484,6 +587,14 @@ export interface HeapDrawSpec {
484
587
  * alongside the effect id.
485
588
  */
486
589
  readonly textures?: HeapTextureSet;
590
+ /**
591
+ * Scene-shared storage buffers for effects that declare
592
+ * `var<storage>` bindings (e.g. the OIT node pool injected by the
593
+ * transparency task). Bound ONCE at bucket level — every RO in a
594
+ * bucket must supply the SAME avals (they are scene-level by
595
+ * construction). Buffers must be GPU-backed IBuffers.
596
+ */
597
+ readonly storageBuffers?: ReadonlyMap<string, aval<IBuffer>>;
487
598
  /**
488
599
  * Optional pipeline state (rasterizer / depth / blend). Two specs
489
600
  * sharing the SAME `PipelineState` object share a bucket; distinct
@@ -552,6 +663,10 @@ export interface HeapSceneStats {
552
663
  totalDraws: number;
553
664
  drawBytes: number;
554
665
  geometryBytes: number;
666
+ /** Cumulative count of waste-triggered arena compactions performed
667
+ * (one per chunk per `update` that compacted). Stays 0 when the
668
+ * arena never ratchets past the waste floor. */
669
+ compactions: number;
555
670
  /** §7 derived-uniforms per-frame breakdown (last frame). */
556
671
  derivedPullMs: number;
557
672
  derivedUploadMs: number;
@@ -752,6 +867,127 @@ export interface BuildHeapSceneOptions {
752
867
  * chunks duplicate their pool entries (accepted trade-off).
753
868
  */
754
869
  readonly maxChunkBytes?: number;
870
+ /**
871
+ * Waste-triggered attribute-arena compaction. When enabled (default),
872
+ * `update()` relocates live arena allocations to the front of each chunk
873
+ * once fragmentation waste crosses `compactionWasteFloorBytes` and at least
874
+ * half the extent is wasted — preventing the high-water ratchet that
875
+ * exact-size freelist reuse can't fix under a drifting allocation-size
876
+ * distribution. Pass `false` to disable.
877
+ */
878
+ readonly enableCompaction?: boolean;
879
+ /**
880
+ * Override the fragmentation-waste floor (bytes) below which compaction is
881
+ * skipped. Default `COMPACTION_WASTE_FLOOR_BYTES` (4 MiB). Tests pass a
882
+ * small value to exercise the path without realistic memory pressure.
883
+ */
884
+ readonly compactionWasteFloorBytes?: number;
885
+ /**
886
+ * Pre-size the arena chunks (bytes). When the caller can estimate the
887
+ * scene's total arena footprint up front (e.g. from an asset manifest),
888
+ * passing it here allocates each chunk at its final size immediately —
889
+ * eliminating the pow2-grow copies during ingest, whose old+new-buffer
890
+ * transient is what OOM-kills memory-tight targets (phones). Clamped to
891
+ * the per-chunk cap; harmless to overshoot.
892
+ */
893
+ readonly initialArenaBytes?: number;
894
+ /**
895
+ * Release constant attribute payloads after staging (aardvark's
896
+ * "StageOnce"). When a `HeapDrawSpec` attribute is supplied as a PLAIN
897
+ * value (raw typed array / packed marker — not a real aval), the heap
898
+ * wraps it in a constant holder; a constant is provably never re-read
899
+ * after its arena write (repack fires only on `inputChanged`, which
900
+ * constants never raise; compaction and growth move bytes GPU-side).
901
+ * With this flag the holder drops its payload right after staging, so
902
+ * the GPU arena becomes the ONLY copy of constant geometry — halving a
903
+ * static scene's memory. Real avals (cvals etc.) are never touched:
904
+ * changeable data stays alive by definition. Default false.
905
+ */
906
+ readonly releaseConstantAttributes?: boolean;
907
+ /**
908
+ * Share a `HeapStorage` (arena + dedup pools) across multiple heap scenes.
909
+ * When omitted, the scene creates a private store it owns and disposes.
910
+ * When supplied (via `createHeapStorage`), two or more scenes — e.g. the
911
+ * same geometry rendered by a shadow pass and a color pass with different
912
+ * effects — share one deduped/refcounted store, so common geometry and
913
+ * uniforms are stored ONCE. The caller owns the store's lifetime.
914
+ */
915
+ readonly storage?: HeapStorage;
916
+ }
917
+
918
+ /**
919
+ * The data-placement layer behind a heap scene: the chunked arena (uniform /
920
+ * attribute data + the index buffer) plus the aval-keyed dedup pools. Owns
921
+ * the GPU buffers; one or more `buildHeapScene` calls draw against it. Create
922
+ * with `createHeapStorage` and pass via `BuildHeapSceneOptions.storage` to
923
+ * share geometry/uniforms across heaps (shadow + color pass, picking, …).
924
+ */
925
+ /** A heap scene's hook into a shared store. When a compaction relocates arena
926
+ * regions, the store calls every registered consumer's `applyRemap` so each
927
+ * scene re-seats its OWN refs (drawHeader cells, derived records, partition
928
+ * master, index `_indexStart`). `remap` maps OLD→NEW byte header offset. */
929
+ export interface HeapStorageConsumer {
930
+ applyRemap(chunkIdx: number, remap: ReadonlyMap<number, number>): void;
931
+ }
932
+
933
+ export interface HeapStorage {
934
+ readonly arena: ArenaState;
935
+ readonly pool: UniformPool;
936
+ readonly indexPool: IndexPool;
937
+ /** Register a scene as a consumer (so compaction re-seats its refs). */
938
+ register(consumer: HeapStorageConsumer): IDisposable;
939
+ /**
940
+ * Waste-triggered compaction of the shared arena. Relocates live regions,
941
+ * re-seats the storage-level dedup pools, and notifies every registered
942
+ * consumer to re-seat its scene-level refs. Returns the number of chunks
943
+ * compacted. Must run with pending writes flushable (it flushes first).
944
+ */
945
+ compact(device: GPUDevice, wasteFloorBytes: number, force: boolean): number;
946
+ /** Destroy the arena's GPU buffers. Only call once no scene references it. */
947
+ dispose(): void;
948
+ }
949
+
950
+ export interface HeapStorageOptions {
951
+ /** Per-arena-chunk size cap in bytes. See `BuildHeapSceneOptions.maxChunkBytes`. */
952
+ readonly maxChunkBytes?: number;
953
+ /** Pre-size chunks (bytes) — see `BuildHeapSceneOptions.initialArenaBytes`. */
954
+ readonly initialArenaBytes?: number;
955
+ }
956
+
957
+ /** Create a standalone `HeapStorage`. Pass it to `buildHeapScene` via
958
+ * `opts.storage` to share one arena across multiple heaps. */
959
+ export function createHeapStorage(device: GPUDevice, opts: HeapStorageOptions = {}): HeapStorage {
960
+ void device;
961
+ // Initial capacities are hints; the buffers pow2-grow on demand.
962
+ const arena = buildArenaState(
963
+ device, Math.max(64 * 1024, opts.initialArenaBytes ?? 0), 16 * 1024, "heapStorage",
964
+ GPUBufferUsage.STORAGE, opts.maxChunkBytes,
965
+ );
966
+ const pool = new UniformPool();
967
+ const indexPool = new IndexPool();
968
+ const consumers = new Set<HeapStorageConsumer>();
969
+ return {
970
+ arena, pool, indexPool,
971
+ register(c: HeapStorageConsumer): IDisposable {
972
+ consumers.add(c);
973
+ return { dispose: () => { consumers.delete(c); } };
974
+ },
975
+ compact(dev: GPUDevice, floor: number, force: boolean): number {
976
+ arena.attrs.flush(dev); // GPU buffer current for the copy
977
+ const remaps = arena.attrs.compact(dev, floor, force);
978
+ for (const { chunkIdx, remap } of remaps) {
979
+ // Storage-level dedup pools first (uniform refs); then each scene
980
+ // re-seats its drawHeaders / derive / partition / index `_indexStart`
981
+ // (the index re-seat reads `indexPool.baseFor` at the OLD ref, so the
982
+ // index pool is remapped LAST, after all consumers).
983
+ pool.remapRefs(chunkIdx, remap);
984
+ for (const c of consumers) c.applyRemap(chunkIdx, remap);
985
+ indexPool.remapRefs(chunkIdx, remap);
986
+ }
987
+ return remaps.length;
988
+ },
989
+ dispose(): void { arena.attrs.destroy(); },
990
+ };
755
991
  }
756
992
 
757
993
  export function buildHeapScene(
@@ -781,16 +1017,21 @@ export function buildHeapScene(
781
1017
  }
782
1018
  const depthFormat = sig.depthStencil?.format;
783
1019
 
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
- );
1020
+ // ─── Storage (arena + dedup pools) — private or shared ───────────
1021
+ // The chunked arena (uniform/attribute data + index buffer) and the
1022
+ // aval-keyed dedup pools live in a `HeapStorage`. Default: a private
1023
+ // store this scene owns. `opts.storage`: a shared store (the same
1024
+ // geometry/uniforms rendered by multiple heaps e.g. shadow + color
1025
+ // pass are deduped once). The local `arena`/`pool`/`indexPool`
1026
+ // bindings below keep every downstream reference unchanged.
1027
+ const ownsStorage = opts.storage === undefined;
1028
+ const storage = opts.storage ?? createHeapStorage(device, {
1029
+ ...(opts.maxChunkBytes !== undefined ? { maxChunkBytes: opts.maxChunkBytes } : {}),
1030
+ ...(opts.initialArenaBytes !== undefined ? { initialArenaBytes: opts.initialArenaBytes } : {}),
1031
+ });
1032
+ const arena = storage.arena;
1033
+ const pool = storage.pool;
1034
+ const indexPool = storage.indexPool;
794
1035
 
795
1036
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
796
1037
  const drawIdToBucket: (Bucket | undefined)[] = [];
@@ -959,6 +1200,25 @@ export function buildHeapScene(
959
1200
  const p = packerForWgslType(f.uniformWgslType ?? "");
960
1201
  return { dataBytes: p.dataBytes, typeId: p.typeId, length: 1, pack: p.pack };
961
1202
  }
1203
+ // Packed attribute encodings (oct32 unit vectors / C4b colors): one
1204
+ // u32 per element, decoded by the VS's typeId-selected arm. 3× smaller
1205
+ // in the arena AND host-side (the caller keeps the packed source).
1206
+ if (isHeapPackedAttribute(value)) {
1207
+ const pa = value;
1208
+ const wantType = pa.enc === "oct32" ? "vec3<f32>" : "vec4<f32>";
1209
+ if (f.attributeWgslType !== wantType) {
1210
+ throw new Error(
1211
+ `heapScene: ${pa.enc} attribute supplied for '${f.attributeWgslType}' field — ` +
1212
+ `oct32 requires vec3<f32>, c4b requires vec4<f32>`,
1213
+ );
1214
+ }
1215
+ return {
1216
+ dataBytes: pa.data.byteLength,
1217
+ typeId: pa.enc === "oct32" ? ENC_OCT32 : ENC_C4B,
1218
+ length: pa.data.length,
1219
+ pack: packPackedAttr,
1220
+ };
1221
+ }
962
1222
  // attribute-ref: variable-size array; we copy verbatim into the
963
1223
  // arena. The current encoding is V3F_TIGHT (3 f32s per element).
964
1224
  const arr = value as Float32Array;
@@ -972,7 +1232,7 @@ export function buildHeapScene(
972
1232
  dataBytes: arr.byteLength,
973
1233
  typeId: ENC_V3F_TIGHT,
974
1234
  length,
975
- pack: (val, dst, off) => { dst.set(val as Float32Array, off); },
1235
+ pack: packF32Verbatim,
976
1236
  };
977
1237
  }
978
1238
 
@@ -997,6 +1257,39 @@ export function buildHeapScene(
997
1257
  throw new Error(`heapScene: BufferView offset ${offset} > 0 not yet supported`);
998
1258
  }
999
1259
 
1260
+ // Packed encodings by ELEMENT TYPE (the BufferView analogue of the
1261
+ // oct32()/c4b() HeapDrawSpec markers): one u32 per element in the
1262
+ // arena, decoded by the VS typeId arm. Dedup stays aval-keyed like
1263
+ // any other BufferView attribute.
1264
+ const etName = bv.elementType.name;
1265
+ if (etName === "oct32" || etName === "c4b") {
1266
+ const want = etName === "oct32" ? "vec3<f32>" : "vec4<f32>";
1267
+ if (f.attributeWgslType !== want) {
1268
+ throw new Error(
1269
+ `heapScene: ${etName} BufferView requires a ${want} schema attribute (got ${f.attributeWgslType ?? "?"})`,
1270
+ );
1271
+ }
1272
+ const strideP = bv.stride ?? 4;
1273
+ const isBroadcastP = bv.singleValue !== undefined || strideP === 0;
1274
+ if (!isBroadcastP && strideP !== 4) {
1275
+ throw new Error(`heapScene: ${etName} BufferView stride ${strideP} not tight (4) and not a broadcast`);
1276
+ }
1277
+ const ibP = bv.buffer.force(/* allow-force */);
1278
+ if (ibP.kind !== "host") {
1279
+ throw new Error(`heapScene: ${etName} BufferView wraps a native GPUBuffer; not supported in heap path`);
1280
+ }
1281
+ const lengthP = isBroadcastP ? 1 : ibP.sizeBytes / 4;
1282
+ const dataBytesP = 4 * lengthP;
1283
+ return {
1284
+ dataBytes: dataBytesP,
1285
+ typeId: etName === "oct32" ? ENC_OCT32 : ENC_C4B,
1286
+ length: lengthP,
1287
+ // Tight case: the copy limit equals the source payload, so the
1288
+ // static packer (which derives it from the buffer) is exact.
1289
+ pack: isBroadcastP ? packHostPrefix(dataBytesP / 4) : packHostU32Bits,
1290
+ };
1291
+ }
1292
+
1000
1293
  // In-arena element bytes: the storage layout the shader will read
1001
1294
  // from. vec4 is always 16 (read via `heapV4f[idx]`), vec3 is 12
1002
1295
  // (read as 3 f32s), vec2 is 8, scalar is 4. V3f source for a vec4
@@ -1050,15 +1343,7 @@ export function buildHeapScene(
1050
1343
  dataBytes,
1051
1344
  typeId: ENC_V3F_TIGHT,
1052
1345
  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
- },
1346
+ pack: isBroadcast ? packHostPrefix(dataBytes / 4) : packHostF32,
1062
1347
  };
1063
1348
  }
1064
1349
 
@@ -1091,9 +1376,7 @@ export function buildHeapScene(
1091
1376
  };
1092
1377
  }
1093
1378
 
1094
- // ─── Pools aval-keyed refcounted allocations ────────────────────
1095
- const pool = new UniformPool();
1096
- const indexPool = new IndexPool();
1379
+ // (`pool` / `indexPool` are owned by `storage`, bound above.)
1097
1380
 
1098
1381
  // ─── Adaptive routing: aval marks → repack the pool entry ─────────
1099
1382
  // With pool-managed per-draw uniforms, value changes don't touch
@@ -1157,6 +1440,9 @@ export function buildHeapScene(
1157
1440
  // both. `derivedScene` is undefined when the option is off; nothing
1158
1441
  // else in this file should run when it is. On by default — pass
1159
1442
  // `enableDerivedUniforms: false` to opt out.
1443
+ const enableCompaction = opts.enableCompaction !== false;
1444
+ const releaseConstantAttributes = opts.releaseConstantAttributes === true;
1445
+ const compactionWasteFloor = opts.compactionWasteFloorBytes ?? COMPACTION_WASTE_FLOOR_BYTES;
1160
1446
  const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
1161
1447
  const derivedScene: DerivedUniformsScene | undefined = enableDerivedUniforms
1162
1448
  ? new DerivedUniformsScene(device, {
@@ -1246,6 +1532,19 @@ export function buildHeapScene(
1246
1532
  // that don't yet point at an allocated AtlasPage. WebGPU rejects
1247
1533
  // bind-group entries with `undefined` resources, so every slot in
1248
1534
  // the N-wide ladder must hold a valid view at all times.
1535
+ // 4-byte placeholder storage buffer — binds user-storage slots on the
1536
+ // seed bind group built before the first addDraw delivers the real
1537
+ // buffers (rebuilt immediately after).
1538
+ let storagePlaceholder: GPUBuffer | undefined;
1539
+ function getStoragePlaceholder(): GPUBuffer {
1540
+ if (storagePlaceholder === undefined) {
1541
+ storagePlaceholder = device.createBuffer({
1542
+ label: "heapScene/storagePlaceholder", size: 4, usage: GPUBufferUsage.STORAGE,
1543
+ });
1544
+ }
1545
+ return storagePlaceholder;
1546
+ }
1547
+
1249
1548
  let atlasPlaceholders: Map<AtlasPageFormat, GPUTextureView> | undefined;
1250
1549
  function getAtlasPlaceholder(format: AtlasPageFormat): GPUTextureView {
1251
1550
  if (atlasPlaceholders === undefined) {
@@ -1276,6 +1575,7 @@ export function buildHeapScene(
1276
1575
  const key =
1277
1576
  `t${layout.textureBindings.map(t => t.wgslType).join(",")}` +
1278
1577
  `|s${layout.samplerBindings.map(s => s.wgslType).join(",")}` +
1578
+ `|sb${layout.storageBindings.map(b => `${b.binding}${b.access === "read_write" ? "w" : "r"}`).join(",")}` +
1279
1579
  `|a${withAtlasArrays ? 1 : 0}`;
1280
1580
  let e = bglCache.get(key);
1281
1581
  if (e !== undefined) return e;
@@ -1306,6 +1606,14 @@ export function buildHeapScene(
1306
1606
  sampler: { type: s.wgslType === "sampler_comparison" ? "comparison" : "filtering" },
1307
1607
  });
1308
1608
  }
1609
+ for (const sb of layout.storageBindings) {
1610
+ // FRAGMENT only: WebGPU forbids writable storage in vertex stages,
1611
+ // and the OIT writers are fragment-side anyway.
1612
+ entries.push({
1613
+ binding: sb.binding, visibility: GPUShaderStage.FRAGMENT,
1614
+ buffer: { type: sb.access === "read_write" ? "storage" : "read-only-storage" },
1615
+ });
1616
+ }
1309
1617
  if (withAtlasArrays) {
1310
1618
  // 2N + 1 entries: N linear textures, N srgb textures, 1 sampler.
1311
1619
  for (let i = 0; i < ATLAS_ARRAY_SIZE; i++) {
@@ -1418,11 +1726,31 @@ export function buildHeapScene(
1418
1726
  const cached = pipelineByModeKey.get(cacheKey);
1419
1727
  if (cached !== undefined) return cached;
1420
1728
  const { pipelineLayout } = getBgl(layout, isAtlasBucket);
1729
+ // Per-descriptor color targets: blend + write mask come from the RO's
1730
+ // PipelineState (`desc.attachments`, ordered by signature colorNames —
1731
+ // the same values the modeKey encodes). Using the bare scene-level
1732
+ // `colorTargets` here silently dropped blends/write-masks from every
1733
+ // heap pipeline: transparent heap leaves painted opaque, and the OIT
1734
+ // pick passes' write-mask-0 "Colors" leaked color onto the composite.
1735
+ const targets: GPUColorTargetState[] = colorTargets.map((t, i) => {
1736
+ const a = desc.attachments[i];
1737
+ if (a === undefined) return { format: t.format };
1738
+ return {
1739
+ format: t.format,
1740
+ writeMask: a.writeMask,
1741
+ ...(a.enabled
1742
+ ? { blend: {
1743
+ color: { operation: a.color.operation, srcFactor: a.color.srcFactor, dstFactor: a.color.dstFactor },
1744
+ alpha: { operation: a.alpha.operation, srcFactor: a.alpha.srcFactor, dstFactor: a.alpha.dstFactor },
1745
+ } }
1746
+ : {}),
1747
+ };
1748
+ });
1421
1749
  const pipeline = device.createRenderPipeline({
1422
1750
  label: `heapScene/cached/${cacheKey}`,
1423
1751
  layout: pipelineLayout,
1424
1752
  vertex: { module: fam.vsModule, entryPoint: fam.vsEntryName, buffers: [] },
1425
- fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets: colorTargets },
1753
+ fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets },
1426
1754
  primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
1427
1755
  ...(depthFormat !== undefined && desc.depth !== undefined
1428
1756
  ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
@@ -1436,8 +1764,9 @@ export function buildHeapScene(
1436
1764
 
1437
1765
  // ─── Family state (§6 family-merge, slice 3c) ─────────────────────
1438
1766
  // 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.
1767
+ // effects in that batch. A NEW effect appearing later is registered
1768
+ // on the fly (ensureFamilyForSpec) families are independent per
1769
+ // effect (merge disabled), so the scene can mix effects added over time.
1441
1770
  //
1442
1771
  // Bucket key collapses to (familyId, pipelineState): every effect
1443
1772
  // in the family shares one bucket per pipelineState. The family
@@ -1581,6 +1910,25 @@ export function buildHeapScene(
1581
1910
  familyFor(effect); // throws if unknown
1582
1911
  }
1583
1912
 
1913
+ /**
1914
+ * Reactive family rebuild: register a new effect's family when it first
1915
+ * appears after the initial freeze. Family-merge is disabled, so every
1916
+ * Effect already gets its OWN independent single-member family (its own
1917
+ * shader modules + buckets); a late effect just needs its family compiled
1918
+ * and added — no rebuild of existing families. Idempotent.
1919
+ */
1920
+ function ensureFamilyForSpec(spec: HeapDrawSpec): void {
1921
+ if (!familyBuilt) { buildFamilyFromSpecs([spec]); return; }
1922
+ if (familyByEffectId.has(spec.effect.id)) return;
1923
+ const entry = { attributes: new Set<string>(), uniforms: new Set<string>() };
1924
+ if (spec.instanceAttributes !== undefined) {
1925
+ for (const name of Object.keys(spec.instanceAttributes)) entry.attributes.add(name);
1926
+ }
1927
+ const singleMap = new Map<Effect, { attributes: Set<string>; uniforms: Set<string> }>();
1928
+ singleMap.set(spec.effect, entry);
1929
+ familyByEffectId.set(spec.effect.id, compileFamilyFor([spec.effect], singleMap));
1930
+ }
1931
+
1584
1932
  // ─── id-of helpers ────────────────────────────────────────────────
1585
1933
  const idOf = (s: Effect): string => `effect#${s.id}`;
1586
1934
  const textureIds = new WeakMap<HeapTextureSet, string>();
@@ -1716,7 +2064,9 @@ export function buildHeapScene(
1716
2064
  const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
1717
2065
  entries.push(
1718
2066
  { binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1719
- { binding: 5, resource: { buffer: arena.indices.chunk(bucket.chunkIdx).buffer } },
2067
+ // Index data lives in the attr arena now (the VS storage-decodes it);
2068
+ // bind the same buffer as the gather views.
2069
+ { binding: 5, resource: { buffer: attrsBuf } },
1720
2070
  { binding: 6, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
1721
2071
  );
1722
2072
  s.renderBoundRecordCount = s.recordCount;
@@ -1774,6 +2124,23 @@ export function buildHeapScene(
1774
2124
  // here — the atlas slots come from the per-format page tracking
1775
2125
  // below.
1776
2126
  }
2127
+ for (const sb of bucket.layout.storageBindings) {
2128
+ const av = bucket.storageBuffers?.get(sb.name);
2129
+ if (av === undefined) {
2130
+ // Buffers arrive with the first addDraw; the seed slot built at
2131
+ // bucket creation is rebuilt right after they land. Bind a
2132
+ // 4-byte placeholder so the seed bind group validates.
2133
+ entries.push({ binding: sb.binding, resource: { buffer: getStoragePlaceholder() } });
2134
+ continue;
2135
+ }
2136
+ const ib = av.force(/* allow-force */);
2137
+ if (ib.kind !== "gpu") {
2138
+ throw new Error(
2139
+ `heapScene: user storage buffer '${sb.name}' must be a GPU-backed IBuffer`,
2140
+ );
2141
+ }
2142
+ entries.push({ binding: sb.binding, resource: { buffer: ib.buffer } });
2143
+ }
1777
2144
  if (bucket.isAtlasBucket) {
1778
2145
  if (atlasPool === undefined) {
1779
2146
  throw new Error(
@@ -1849,15 +2216,6 @@ export function buildHeapScene(
1849
2216
  atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
1850
2217
  }
1851
2218
  }
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
2219
  // ─── findOrCreateBucket ───────────────────────────────────────────
1862
2220
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
1863
2221
  // Every member effect in the family shares one bucket per
@@ -2416,7 +2774,7 @@ export function buildHeapScene(
2416
2774
  firstIndex: number;
2417
2775
  indexCount: number;
2418
2776
  instanceCount: number;
2419
- perDrawRefs: Map<string, number>;
2777
+ perDrawRefs: Int32Array;
2420
2778
  descriptor: PipelineStateDescriptor;
2421
2779
  /** Filled in below after synthetic combos are registered. */
2422
2780
  targetComboId: number;
@@ -2596,7 +2954,11 @@ export function buildHeapScene(
2596
2954
  if (cpuMigration !== undefined) {
2597
2955
  let totalEmit = 0;
2598
2956
  for (const m of cpuMigration) {
2599
- const uRefs = bucket.uniformOrder!.map(n => m.perDrawRefs.get(n) ?? 0);
2957
+ const uRefs = bucket.uniformOrder!.map(n => {
2958
+ const i = bucket.fieldIdx.get(n);
2959
+ const r = i === undefined ? -1 : m.perDrawRefs[i]!;
2960
+ return r < 0 ? 0 : r;
2961
+ });
2600
2962
  const recIdx = partition.appendRecord(
2601
2963
  m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs,
2602
2964
  );
@@ -2717,8 +3079,11 @@ export function buildHeapScene(
2717
3079
  );
2718
3080
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
2719
3081
 
3082
+ const fieldIdx = new Map<string, number>();
3083
+ layout.drawHeaderFields.forEach((f, i) => fieldIdx.set(f.name, i));
2720
3084
  const bucket: Bucket = {
2721
3085
  label: bk, textures: undefined, layout, chunkIdx,
3086
+ storageBuffers: undefined, storageDirty: false, storageSubs: [],
2722
3087
  slots: [], // populated lazily by ensureSlot per modeKey
2723
3088
  bindGroup: null as unknown as GPUBindGroup, // legacy field; slot.bindGroup is the real one
2724
3089
  drawHeap,
@@ -2726,7 +3091,10 @@ export function buildHeapScene(
2726
3091
  headerDirtyMin: Infinity, headerDirtyMax: 0,
2727
3092
  localPosRefs: [], localNorRefs: [],
2728
3093
  localEntries: [], localToDrawId: [],
2729
- localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
3094
+ localPerDrawAvals: [], localPerDrawRawFieldIdx: [], localPerDrawRefs: [], localLayoutIds: [],
3095
+ fieldIdx,
3096
+ posFieldIdx: fieldIdx.get("Positions") ?? -1,
3097
+ norFieldIdx: fieldIdx.get("Normals") ?? -1,
2730
3098
  drawSlots: new Set<number>(), dirty: new Set<number>(),
2731
3099
  isAtlasBucket,
2732
3100
  localAtlasReleases: [],
@@ -2796,13 +3164,15 @@ export function buildHeapScene(
2796
3164
  // callers writeBuffer the result to the GPU.
2797
3165
  function packBucketHeader(
2798
3166
  bucket: Bucket, localSlot: number,
2799
- perDrawRefs: ReadonlyMap<string, number>,
3167
+ perDrawRefs: Int32Array,
2800
3168
  layoutId: number,
2801
3169
  ): void {
2802
3170
  const dst = bucket.drawHeaderStaging;
2803
3171
  const u32 = new Uint32Array(dst.buffer, dst.byteOffset, dst.length);
2804
3172
  const baseFloat = (localSlot * bucket.layout.drawHeaderBytes) / 4;
2805
- for (const f of bucket.layout.drawHeaderFields) {
3173
+ const fields = bucket.layout.drawHeaderFields;
3174
+ for (let i = 0; i < fields.length; i++) {
3175
+ const f = fields[i]!;
2806
3176
  // texture-ref fields carry inline values (pageRef/formatBits as u32,
2807
3177
  // origin/size as vec2<f32>) and are filled by packAtlasTextureFields.
2808
3178
  if (f.kind === "texture-ref") continue;
@@ -2815,15 +3185,11 @@ export function buildHeapScene(
2815
3185
  u32[fOff] = layoutId >>> 0;
2816
3186
  continue;
2817
3187
  }
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;
3188
+ const ref = perDrawRefs[i]!;
3189
+ // Family-merge: a slot's effect doesn't populate every field of
3190
+ // the union (−1); leave the unused slots zero — the layoutId
3191
+ // switch ensures they're never read by the wrong effect's helper.
3192
+ u32[fOff] = ref < 0 ? 0 : ref;
2827
3193
  }
2828
3194
  }
2829
3195
 
@@ -2916,6 +3282,7 @@ export function buildHeapScene(
2916
3282
  totalDraws: 0,
2917
3283
  drawBytes: 0,
2918
3284
  geometryBytes: 0,
3285
+ compactions: 0,
2919
3286
  derivedPullMs: 0,
2920
3287
  derivedUploadMs: 0,
2921
3288
  derivedEncodeMs: 0,
@@ -2958,11 +3325,10 @@ export function buildHeapScene(
2958
3325
  // single spec when no batched lazy-build occurred earlier (e.g.
2959
3326
  // direct addDraw at runtime). The aset / array initial-population
2960
3327
  // paths build from the full effect set up front.
2961
- if (!familyBuilt) {
2962
- buildFamilyFromSpecs([spec]);
2963
- } else {
2964
- ensureFamilyKnowsEffect(spec.effect);
2965
- }
3328
+ // Build the family lazily from this spec (first addDraw), or register a
3329
+ // newly-seen effect's family on the fly (reactive rebuild) — supports a
3330
+ // scene mixing multiple effects added over time (tiles + markers + …).
3331
+ ensureFamilyForSpec(spec);
2966
3332
  const perInstanceUniforms = new Set<string>();
2967
3333
  const perInstanceAttributes = spec.instanceAttributes !== undefined
2968
3334
  ? new Set(Object.keys(spec.instanceAttributes))
@@ -2984,16 +3350,47 @@ export function buildHeapScene(
2984
3350
  token: outerTok,
2985
3351
  });
2986
3352
  }
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.
3353
+ // ─── Group-fit page placement (§3) ───────────────────────────────
3354
+ // A draw's whole group — uniforms + attributes + INDEX data (all one
3355
+ // arena now) must land on ONE chunk/page (the draw binds one buffer).
3356
+ // Estimate the group's arena bytes (exact per-field via the same
3357
+ // placement helpers, ignoring dedup ⇒ an upper bound on the NEW bytes a
3358
+ // chunk must absorb), then place the group on a chunk with room — prefer
3359
+ // one already holding a shared input, else the newest, else open a fresh
3360
+ // page. This replaces the old cross-chunk spill throw, so a scene can
3361
+ // grow past one buffer's cap (WebGPU's storage-buffer binding limit).
3362
+ const fam = familyFor(spec.effect);
3363
+ const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
3364
+ const layout = fam.schema.drawHeaderUnion;
3365
+ const hasIndices = spec.indices !== undefined;
3366
+ const indicesAval = hasIndices ? (asAval(spec.indices!) as aval<Uint32Array>) : undefined;
3367
+ const indexArr = hasIndices ? (readPlain(spec.indices!) as Uint32Array) : undefined;
3368
+ const instanceCount = spec.instanceCount !== undefined
3369
+ ? (readPlain(spec.instanceCount) as number) : 1;
3370
+
3371
+ let estBytes = indexArr !== undefined ? ALIGN16(ALLOC_HEADER_PAD_TO + indexArr.byteLength) : 0;
3372
+ for (const f of layout.drawHeaderFields) {
3373
+ if (f.kind === "texture-ref" || f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name) || !effectFields.has(f.name)) continue;
3374
+ if (ruleForUniform(spec, f.name) !== undefined) {
3375
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + PACKER_MAT4.dataBytes); // §7 derived dummy mat4
3376
+ continue;
3377
+ }
3378
+ const perInstUniform = f.kind === "uniform-ref" && perInstanceUniforms.has(f.name);
3379
+ const perInstAttr = f.kind === "attribute-ref" && perInstanceAttributes.has(f.name);
3380
+ const provided = perInstAttr ? spec.instanceAttributes![f.name] : spec.inputs[f.name];
3381
+ if (provided === undefined) continue; // the acquire loop throws with a precise message
3382
+ let dataBytes: number;
3383
+ if (f.kind === "attribute-ref" && !perInstUniform && isBufferView(provided)) {
3384
+ dataBytes = bufferViewPlacement(f, provided).dataBytes;
3385
+ } else {
3386
+ const value = asAval(provided as aval<unknown> | unknown).getValue(outerTok);
3387
+ dataBytes = (perInstUniform
3388
+ ? perInstancePlacementFor(f, value, instanceCount)
3389
+ : poolPlacementFor(f, value)).dataBytes;
3390
+ }
3391
+ estBytes += ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
3392
+ }
3393
+
2997
3394
  let chunkIdx: number;
2998
3395
  {
2999
3396
  let preferred: number | undefined;
@@ -3005,9 +3402,36 @@ export function buildHeapScene(
3005
3402
  if (where !== undefined) { preferred = where; break; }
3006
3403
  }
3007
3404
  }
3008
- chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
3405
+ const fits = (c: number): boolean => arena.attrs.bumpHeadroom(c) >= estBytes;
3406
+ const newest = arena.attrs.chunkCount - 1;
3407
+ chunkIdx =
3408
+ preferred !== undefined && fits(preferred) ? preferred
3409
+ : fits(newest) ? newest
3410
+ : arena.attrs.openPage();
3009
3411
  }
3010
3412
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
3413
+ // User storage buffers (layout.storageBindings): bucket-level, set once.
3414
+ if (bucket.layout.storageBindings.length > 0 && bucket.storageBuffers === undefined) {
3415
+ if (spec.storageBuffers === undefined) {
3416
+ throw new Error(
3417
+ `heapScene: bucket '${bucket.label}' needs storage buffers ` +
3418
+ `(${bucket.layout.storageBindings.map(b => b.name).join(", ")}) but spec.storageBuffers is undefined`,
3419
+ );
3420
+ }
3421
+ bucket.storageBuffers = spec.storageBuffers;
3422
+ // A storage aval marking (e.g. the OIT head buffer reallocating on a
3423
+ // canvas resize) invalidates every slot bind group of this bucket.
3424
+ for (const av of spec.storageBuffers.values()) {
3425
+ const disposer = (av as unknown as { addMarkingCallback?: (cb: () => void) => () => void })
3426
+ .addMarkingCallback?.(() => { bucket.storageDirty = true; });
3427
+ if (disposer !== undefined) bucket.storageSubs.push(disposer);
3428
+ }
3429
+ // Slots seeded before the buffers were known carry incomplete bind
3430
+ // groups — rebuild them now.
3431
+ for (let i = 0; i < bucket.slots.length; i++) {
3432
+ bucket.slots[i]!.bindGroup = buildBucketBindGroup(bucket, i);
3433
+ }
3434
+ }
3011
3435
  // Phase 5c.3 — every RO carrying a derived-mode rule is registered
3012
3436
  // on the bucket (lazy-promoting it to GPU-routed on the first
3013
3437
  // call). ROs may carry DIFFERENT rules even when they share the
@@ -3038,19 +3462,17 @@ export function buildHeapScene(
3038
3462
  // tables every frame from the master.
3039
3463
  const roSlot = bucket.gpuRouted ? bucket.slots[0]! : ensureSlot(bucket, roDescriptor);
3040
3464
  const roSlotIdx = bucket.slots.indexOf(roSlot);
3041
- const fam = familyFor(spec.effect);
3042
- const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
3043
3465
 
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;
3466
+ // Indices ride the same arena as attributes/uniforms (the VS storage-
3467
+ // decodes them). Aval-keyed: 19K instanced clones of the same mesh share
3468
+ // one index allocation + one upload. NON-INDEXED draws (spec.indices
3469
+ // omitted) skip the pool: the drawTable record stores firstIndex =
3470
+ // HEAP_NONINDEXED (sentinel) and indexCount = vertexCount, and the
3471
+ // megacall prelude uses the local vertex index directly. `indicesAval` /
3472
+ // `indexArr` were read above for the group estimate; the group landed
3473
+ // wholly on `bucket.chunkIdx`, so this allocation can't spill.
3052
3474
  const idxAlloc = hasIndices
3053
- ? indexPool.acquire(device, arena.indices, indicesAval!, bucket.chunkIdx, readPlain(spec.indices!) as Uint32Array)
3475
+ ? indexPool.acquire(device, arena.attrs, indicesAval!, bucket.chunkIdx, indexArr!)
3054
3476
  : undefined;
3055
3477
  // Emit count per instance + the firstIndex field written to the record.
3056
3478
  // Slice: emit only [firstIndex, firstIndex+indexCount) of the (shared,
@@ -3060,10 +3482,6 @@ export function buildHeapScene(
3060
3482
  const firstIndexField = hasIndices ? (idxAlloc!.firstIndex + (spec.firstIndex ?? 0)) : HEAP_NONINDEXED;
3061
3483
 
3062
3484
  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
3485
 
3068
3486
  // Walk the bucket's schema-driven DrawHeader fields. Per-instance
3069
3487
  // attributes pull from `spec.instanceAttributes` and pack into an
@@ -3071,7 +3489,18 @@ export function buildHeapScene(
3071
3489
  // packs as a single value. Both go through the same pool — sharing
3072
3490
  // emerges from aval identity either way.
3073
3491
  const perDrawAvals: aval<unknown>[] = [];
3074
- const perDrawRefs = new Map<string, number>();
3492
+ let perDrawRawFieldIdx: number[] | undefined;
3493
+ const perDrawRefs = new Int32Array(bucket.layout.drawHeaderFields.length).fill(-1);
3494
+ const setRef = (name: string, ref: number): void => {
3495
+ const i = bucket.fieldIdx.get(name);
3496
+ if (i !== undefined) perDrawRefs[i] = ref;
3497
+ };
3498
+ const getRef = (name: string): number | undefined => {
3499
+ const i = bucket.fieldIdx.get(name);
3500
+ if (i === undefined) return undefined;
3501
+ const r = perDrawRefs[i]!;
3502
+ return r < 0 ? undefined : r;
3503
+ };
3075
3504
  {
3076
3505
  const tok = outerTok;
3077
3506
  for (const f of bucket.layout.drawHeaderFields) {
@@ -3083,18 +3512,45 @@ export function buildHeapScene(
3083
3512
  // layoutId branch never reads them, so the slot stays zero.
3084
3513
  if (f.name === "__layoutId") continue;
3085
3514
  if (!effectFields.has(f.name)) continue;
3515
+ // Inline header uniforms (PickId): the header word IS the u32
3516
+ // value — write it via perDrawRefs (packBucketHeader copies
3517
+ // refs verbatim), skip the pool entirely. A provider-supplied
3518
+ // PickId aval still works below as the (slow) fallback.
3519
+ if (INLINE_HEADER_UNIFORMS.has(f.name)) {
3520
+ const iv = f.name === "PickId" ? spec.pickId : undefined;
3521
+ if (iv !== undefined) {
3522
+ setRef(f.name, iv >>> 0);
3523
+ continue;
3524
+ }
3525
+ const provInline = spec.inputs[f.name];
3526
+ if (typeof provInline === "number") {
3527
+ setRef(f.name, provInline >>> 0);
3528
+ continue;
3529
+ }
3530
+ if (provInline === undefined) {
3531
+ // No id at all (picking disabled but the effect still
3532
+ // reads the uniform): 0 = "no pick", a reserved id.
3533
+ setRef(f.name, 0);
3534
+ continue;
3535
+ }
3536
+ // fall through: aval-provided → legacy pooled path
3537
+ }
3086
3538
  // §7: derived-uniform fields are produced by the compute
3087
3539
  // pre-pass. We still allocate an arena slot here so the
3088
3540
  // drawHeader gets a valid ref written by packBucketHeader; §7
3089
3541
  // overwrites the arena data each frame the inputs are dirty.
3090
3542
  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,
3543
+ // Raw (aval-less) slot: §7's compute pre-pass overwrites the
3544
+ // data every dirty frame, so a keyed pool entry (placeholder
3545
+ // ConstantVal + Map per draw) bought nothing. The ref lives
3546
+ // in the drawHeader cell (compaction re-seats it there);
3547
+ // removeDraw frees it via `perDrawRawFieldIdx`.
3548
+ const ref = pool.allocRaw(
3549
+ arena.attrs, bucket.chunkIdx,
3550
+ PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1,
3095
3551
  );
3096
- perDrawRefs.set(f.name, ref);
3097
- perDrawAvals.push(dummyAval);
3552
+ setRef(f.name, ref);
3553
+ (perDrawRawFieldIdx ??= []).push(bucket.fieldIdx.get(f.name)!);
3098
3554
  continue;
3099
3555
  }
3100
3556
  const isPerInstanceUniformField =
@@ -3126,11 +3582,37 @@ export function buildHeapScene(
3126
3582
  let av: aval<unknown>;
3127
3583
  let value: unknown;
3128
3584
  let placement: ReturnType<typeof poolPlacementFor>;
3585
+ let releasable: { _v: unknown } | undefined;
3129
3586
  if (f.kind === "attribute-ref" && !isPerInstanceUniformField && isBufferView(provided)) {
3130
3587
  const bv = provided;
3131
3588
  placement = bufferViewPlacement(f, bv);
3132
3589
  av = bv.buffer as aval<unknown>;
3133
3590
  value = bv.buffer.getValue(tok);
3591
+ } else if (
3592
+ releaseConstantAttributes
3593
+ && f.kind === "attribute-ref"
3594
+ && !isPerInstanceUniformField
3595
+ && !(typeof provided === "object" && provided !== null
3596
+ && typeof (provided as { getValue?: unknown }).getValue === "function")
3597
+ ) {
3598
+ // Plain value (raw array / packed marker) → CONSTANT by definition.
3599
+ // Wrap in a droppable holder: after this field's arena write the GPU
3600
+ // copy is the only copy the heap ever reads again, so the payload is
3601
+ // released (real avals never take this branch — changeable data
3602
+ // stays alive by contract).
3603
+ const holder = {
3604
+ _v: provided as unknown,
3605
+ getValue(): unknown {
3606
+ if (holder._v === null) {
3607
+ throw new Error("heapScene: constant attribute payload was released after staging (releaseConstantAttributes)");
3608
+ }
3609
+ return holder._v;
3610
+ },
3611
+ };
3612
+ releasable = holder;
3613
+ av = holder as unknown as aval<unknown>;
3614
+ value = provided;
3615
+ placement = poolPlacementFor(f, value);
3134
3616
  } else {
3135
3617
  av = asAval(provided as aval<unknown> | unknown);
3136
3618
  value = av.getValue(tok);
@@ -3142,19 +3624,21 @@ export function buildHeapScene(
3142
3624
  device, arena.attrs, av, bucket.chunkIdx, value,
3143
3625
  placement.dataBytes, placement.typeId, placement.length, placement.pack,
3144
3626
  );
3145
- perDrawRefs.set(f.name, ref);
3627
+ if (releasable !== undefined) releasable._v = null; // staged — drop the CPU copy
3628
+ setRef(f.name, ref);
3146
3629
  perDrawAvals.push(av);
3147
3630
  }
3148
3631
  }
3149
3632
 
3150
- bucket.localPosRefs[localSlot] = perDrawRefs.get("Positions");
3151
- bucket.localNorRefs[localSlot] = perDrawRefs.get("Normals");
3633
+ bucket.localPosRefs[localSlot] = getRef("Positions");
3634
+ bucket.localNorRefs[localSlot] = getRef("Normals");
3152
3635
  bucket.localEntries[localSlot] = {
3153
3636
  indexCount: emitCount, firstIndex: firstIndexField, instanceCount,
3154
3637
  };
3155
3638
  bucket.localToDrawId[localSlot] = drawId;
3156
3639
  bucket.drawSlots.add(localSlot);
3157
3640
  bucket.localPerDrawAvals[localSlot] = perDrawAvals;
3641
+ bucket.localPerDrawRawFieldIdx[localSlot] = perDrawRawFieldIdx;
3158
3642
  bucket.localPerDrawRefs[localSlot] = perDrawRefs;
3159
3643
  const layoutId = fam.schema.layoutIdOf.get(spec.effect)!;
3160
3644
  bucket.localLayoutIds[localSlot] = layoutId;
@@ -3264,7 +3748,7 @@ export function buildHeapScene(
3264
3748
  // it) get 0 — the kernel only reads refs through combo fns that
3265
3749
  // gate on `r.comboId`, so an unread 0 is harmless.
3266
3750
  const uniformRefs: number[] = (bucket.uniformOrder ?? []).map(
3267
- name => perDrawRefs.get(name) ?? 0,
3751
+ name => getRef(name) ?? 0,
3268
3752
  );
3269
3753
  partition.appendRecord(localSlot, firstIndexField, emitCount, instanceCount, roComboId, uniformRefs);
3270
3754
  bucket.drawIdToRecord!.set(drawId, recIdx);
@@ -3369,7 +3853,10 @@ export function buildHeapScene(
3369
3853
  }
3370
3854
  const tracker = new ModeKeyTracker(
3371
3855
  spec.pipelineState, sig,
3372
- () => { dirtyModeKeyDrawIds.add(drawId); },
3856
+ // skipSubscribe means the tracker never invokes onDirty itself
3857
+ // (scene-level modeAvalToDrawIds dispatches instead) — a shared
3858
+ // noop avoids one closure per draw.
3859
+ NOOP,
3373
3860
  {
3374
3861
  skipSubscribe: true,
3375
3862
  ...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
@@ -3404,11 +3891,11 @@ export function buildHeapScene(
3404
3891
  const ruleSubset = new Map<string, DerivedRule>();
3405
3892
  const outOffsetByName = new Map<string, number>();
3406
3893
  for (const f of bucket.layout.drawHeaderFields) {
3407
- if (f.name === "__layoutId") continue;
3894
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
3408
3895
  if (!effectFields.has(f.name)) continue;
3409
3896
  const rule = ruleForUniform(spec, f.name);
3410
3897
  if (rule === undefined) continue;
3411
- const ref = perDrawRefs.get(f.name);
3898
+ const ref = getRef(f.name);
3412
3899
  if (ref === undefined) continue;
3413
3900
  ruleSubset.set(f.name, rule);
3414
3901
  outOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
@@ -3434,7 +3921,7 @@ export function buildHeapScene(
3434
3921
  trafoAvals,
3435
3922
  ...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
3436
3923
  hostUniformOffset: (n) => {
3437
- const r = perDrawRefs.get(n);
3924
+ const r = getRef(n);
3438
3925
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
3439
3926
  },
3440
3927
  outputOffset: (n) => outOffsetByName.get(n),
@@ -3545,8 +4032,21 @@ export function buildHeapScene(
3545
4032
  // Release pool entries — refcount drops; if zero, allocation freed.
3546
4033
  const avals = bucket.localPerDrawAvals[localSlot];
3547
4034
  if (avals !== undefined) for (const av of avals) pool.release(arena.attrs, av, bucket.chunkIdx);
4035
+ // Raw §7 slots: the live ref is the drawHeader cell (compaction
4036
+ // keeps it current); free it directly.
4037
+ const rawIdxs = bucket.localPerDrawRawFieldIdx[localSlot];
4038
+ if (rawIdxs !== undefined) {
4039
+ const rawRefs = bucket.localPerDrawRefs[localSlot];
4040
+ if (rawRefs !== undefined) {
4041
+ for (const fi of rawIdxs) {
4042
+ const ref = rawRefs[fi]!;
4043
+ if (ref >= 0) pool.releaseRaw(arena.attrs, bucket.chunkIdx, ref, PACKER_MAT4.dataBytes);
4044
+ }
4045
+ }
4046
+ bucket.localPerDrawRawFieldIdx[localSlot] = undefined;
4047
+ }
3548
4048
  const idxAval = drawIdToIndexAval[drawId];
3549
- if (idxAval !== undefined) indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
4049
+ if (idxAval !== undefined) indexPool.release(arena.attrs, idxAval, bucket.chunkIdx);
3550
4050
  const atlasRel = bucket.localAtlasReleases[localSlot];
3551
4051
  if (atlasRel !== undefined) atlasRel();
3552
4052
  // Drop atlas-aval ref (if any). When the last ref is dropped we
@@ -3616,14 +4116,16 @@ export function buildHeapScene(
3616
4116
  function drainAsetWith(tok: AdaptiveToken): void {
3617
4117
  if (asetReader === undefined) return;
3618
4118
  const delta = asetReader.getChanges(tok);
4119
+ // Process removals before adds. A remove+add in the same
4120
+ // transaction must free its slot / arena region / atlas tile
4121
+ // before the add allocates, so the freelist reuses the just-freed
4122
+ // block instead of growing the high-water toward
4123
+ // live + removed-this-tick. (Matches Aardvark's removals-first
4124
+ // delta discipline.)
4125
+ const adds: HeapDrawSpec[] = [];
3619
4126
  delta.iter((op: { value: HeapDrawSpec; count: number }) => {
3620
4127
  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);
4128
+ adds.push(op.value);
3627
4129
  } else {
3628
4130
  const id = specToDrawId.get(op.value);
3629
4131
  if (id !== undefined) {
@@ -3632,6 +4134,13 @@ export function buildHeapScene(
3632
4134
  }
3633
4135
  }
3634
4136
  });
4137
+ // Re-use the outer evaluateAlways scope's token (caller is already
4138
+ // sceneObj) — collapses N nested evaluateAlways into one for a bulk
4139
+ // add. Saves a per-RO setUnsafeEvaluationDepth + outputs.add round trip.
4140
+ for (const value of adds) {
4141
+ const id = addDrawImpl(value, tok);
4142
+ specToDrawId.set(value, id);
4143
+ }
3635
4144
  }
3636
4145
 
3637
4146
  // ─── Initial population ───────────────────────────────────────────
@@ -3648,9 +4157,9 @@ export function buildHeapScene(
3648
4157
  asetReader = (initialDraws as aset<HeapDrawSpec>).getReader();
3649
4158
  sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
3650
4159
  // 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).
4160
+ // effects up front, then run addDraw. Subsequent drains reuse the
4161
+ // family, or register a newly-seen effect's family on the fly
4162
+ // (ensureFamilyForSpec, via addDrawImpl).
3654
4163
  if (!familyBuilt) {
3655
4164
  const reader = asetReader!;
3656
4165
  const delta = reader.getChanges(tok);
@@ -3679,6 +4188,142 @@ export function buildHeapScene(
3679
4188
  });
3680
4189
  }
3681
4190
 
4191
+ // ─── Heap compaction (attribute arena + index buffer) ────────────
4192
+ /**
4193
+ * Compact every arena/index chunk whose fragmentation waste crosses the
4194
+ * floor (or unconditionally when `force`), then re-seat EVERY cached ref so
4195
+ * the relocated allocations stay addressable. Attribute arena:
4196
+ * • uniform-pool entries (`pool.remapRefs`)
4197
+ * • §7 derived-uniform record handles (`derivedScene.remapHostHeap`)
4198
+ * • modes partition master refs (`partitionScene.remapUniformRefs`)
4199
+ * • drawHeader cells + the Pos/Nor caches (re-`packBucketHeader`)
4200
+ * Index buffer (ref = `allocBase + slice`, held in the drawTable / partition
4201
+ * master + IndexPool entry):
4202
+ * • per-draw `indexStart` in the CPU drawTable or partition master
4203
+ * • `localEntries[slot].firstIndex` + IndexPool entry base
4204
+ * Must run with the buffers current (we flush first) and before the
4205
+ * per-bucket header / drawTable flush so the re-seated records upload.
4206
+ */
4207
+ // Per-scene ref re-seat after the shared store relocates regions. Registered
4208
+ // with `storage`; the store calls it (for THIS scene + any others sharing the
4209
+ // store) inside `storage.compact`. Re-seats derived records, partition master
4210
+ // refs, drawHeader cells, and index `_indexStart` — everything this scene
4211
+ // caches that points into the arena. (The storage-level uniform/index pools
4212
+ // are re-seated by the store itself, around this call.)
4213
+ function applyRemap(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
4214
+ if (derivedScene !== undefined) {
4215
+ // §7 handles store DATA offsets (ref + header pad), not header refs.
4216
+ const dataRemap = new Map<number, number>();
4217
+ for (const [o, n] of remap) {
4218
+ dataRemap.set(o + ALLOC_HEADER_PAD_TO, n + ALLOC_HEADER_PAD_TO);
4219
+ }
4220
+ derivedScene.remapHostHeap(chunkIdx, dataRemap);
4221
+ }
4222
+ {
4223
+ for (const bucket of buckets) {
4224
+ if (bucket.chunkIdx !== chunkIdx) continue;
4225
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
4226
+ if (bucket.partitionScene.remapUniformRefs(remap) > 0) {
4227
+ bucket.partitionDirty = true;
4228
+ }
4229
+ }
4230
+ for (const localSlot of bucket.drawSlots) {
4231
+ // (a) Index region re-seat. Index data is an attr-arena region now,
4232
+ // so it relocates in THIS pass. Its draw stores `_indexStart` (an
4233
+ // element offset = allocBase + slice) in the drawTable / partition
4234
+ // master; shift it by the region's element delta. Independent of the
4235
+ // uniform re-seat below (a slot's indices may move while its uniforms
4236
+ // don't), so it runs before the `changed` gate.
4237
+ const drawId = bucket.localToDrawId[localSlot];
4238
+ if (drawId !== undefined) {
4239
+ const iav = drawIdToIndexAval[drawId];
4240
+ const oldRef = iav !== undefined ? indexPool.baseFor(iav, chunkIdx) : undefined;
4241
+ const newRef = oldRef !== undefined ? remap.get(oldRef) : undefined;
4242
+ const entry = bucket.localEntries[localSlot];
4243
+ if (oldRef !== undefined && newRef !== undefined && entry !== undefined) {
4244
+ const newStart = entry.firstIndex + ((newRef - oldRef) >> 2);
4245
+ entry.firstIndex = newStart;
4246
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
4247
+ const recIdx = bucket.drawIdToRecord?.get(drawId);
4248
+ if (recIdx !== undefined) {
4249
+ bucket.partitionScene.setRecordIndexStart(recIdx, newStart);
4250
+ bucket.partitionDirty = true;
4251
+ }
4252
+ } else {
4253
+ const slotIdx = drawIdToSlotIdx[drawId];
4254
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
4255
+ const recIdx = slot?.slotToRecord[localSlot];
4256
+ if (slot !== undefined && recIdx !== undefined && recIdx >= 0) {
4257
+ slot.drawTableShadow![recIdx * RECORD_U32 + 2] = newStart >>> 0;
4258
+ const off = recIdx * RECORD_BYTES;
4259
+ if (off < slot.drawTableDirtyMin) slot.drawTableDirtyMin = off;
4260
+ if (off + RECORD_BYTES > slot.drawTableDirtyMax) slot.drawTableDirtyMax = off + RECORD_BYTES;
4261
+ }
4262
+ }
4263
+ }
4264
+ }
4265
+ // (b) Uniform/attr drawHeader cells (+ Pos/Nor caches).
4266
+ const refs = bucket.localPerDrawRefs[localSlot];
4267
+ if (refs === undefined) continue;
4268
+ let changed = false;
4269
+ for (let i = 0; i < refs.length; i++) {
4270
+ const ref = refs[i]!;
4271
+ if (ref < 0) continue;
4272
+ const fld = bucket.layout.drawHeaderFields[i]!;
4273
+ // Inline header values (PickId/__layoutId) are NOT arena
4274
+ // refs — a numeric collision with an old offset must not
4275
+ // remap them.
4276
+ if (fld.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(fld.name)) continue;
4277
+ const nn = remap.get(ref);
4278
+ if (nn !== undefined) { refs[i] = nn; changed = true; }
4279
+ }
4280
+ if (!changed) continue;
4281
+ const pr = bucket.posFieldIdx >= 0 ? refs[bucket.posFieldIdx]! : -1;
4282
+ const nr = bucket.norFieldIdx >= 0 ? refs[bucket.norFieldIdx]! : -1;
4283
+ bucket.localPosRefs[localSlot] = pr < 0 ? undefined : pr;
4284
+ bucket.localNorRefs[localSlot] = nr < 0 ? undefined : nr;
4285
+ packBucketHeader(bucket, localSlot, refs, bucket.localLayoutIds[localSlot] ?? 0);
4286
+ if (bucket.isAtlasBucket) {
4287
+ const ts = bucket.localAtlasTextures[localSlot];
4288
+ if (ts !== undefined) packAtlasTextureFields(bucket, localSlot, ts);
4289
+ }
4290
+ const byteOff = localSlot * bucket.layout.drawHeaderBytes;
4291
+ if (byteOff < bucket.headerDirtyMin) bucket.headerDirtyMin = byteOff;
4292
+ const end = byteOff + bucket.layout.drawHeaderBytes;
4293
+ if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
4294
+ }
4295
+ }
4296
+ }
4297
+ }
4298
+
4299
+ // Register this scene as a consumer of the (possibly shared) store, so a
4300
+ // compaction — triggered by THIS scene or any other sharing the store —
4301
+ // re-seats this scene's refs. Disposed in `dispose()`.
4302
+ const storageReg = storage.register({ applyRemap });
4303
+
4304
+ // ─── Compaction (delegates to the shared store) ──────────────────
4305
+ function runCompaction(force: boolean): void {
4306
+ const compacted = storage.compact(device, compactionWasteFloor, force);
4307
+ stats.compactions += compacted;
4308
+ // update() flushes the re-seated drawHeaders via its own per-bucket loop
4309
+ // right after this returns; the forced (test) path runs outside update(),
4310
+ // so it flushes this scene's dirtied headers itself.
4311
+ if (force) {
4312
+ for (const bucket of buckets) {
4313
+ if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
4314
+ device.queue.writeBuffer(
4315
+ bucket.drawHeap.buffer, bucket.headerDirtyMin,
4316
+ bucket.drawHeaderStaging.buffer,
4317
+ bucket.drawHeaderStaging.byteOffset + bucket.headerDirtyMin,
4318
+ bucket.headerDirtyMax - bucket.headerDirtyMin,
4319
+ );
4320
+ bucket.headerDirtyMin = Infinity;
4321
+ bucket.headerDirtyMax = 0;
4322
+ }
4323
+ }
4324
+ }
4325
+ }
4326
+
3682
4327
  // ─── update / encodeIntoPass / frame / dispose ───────────────────
3683
4328
  /**
3684
4329
  * CPU-side data refresh: drain pending aset deltas, repack any
@@ -3692,6 +4337,16 @@ export function buildHeapScene(
3692
4337
  sceneObj.evaluateAlways(token, (tok) => {
3693
4338
  drainAsetWith(tok);
3694
4339
 
4340
+ // User storage buffers that marked (e.g. the OIT head buffer
4341
+ // reallocating on a resize) invalidate the bucket's bind groups.
4342
+ for (const b of buckets) {
4343
+ if (!b.storageDirty) continue;
4344
+ b.storageDirty = false;
4345
+ for (let i = 0; i < b.slots.length; i++) {
4346
+ b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
4347
+ }
4348
+ }
4349
+
3695
4350
  // 0. Reactive rebucket: any RO whose PipelineState modeKey
3696
4351
  // changed since last frame moves to its new bucket.
3697
4352
  // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
@@ -3908,7 +4563,17 @@ export function buildHeapScene(
3908
4563
  // initial population for 10K ROs this collapses ~30K calls into
3909
4564
  // ~5 (arena, indices, plus one per bucket).
3910
4565
  arena.attrs.flush(device);
3911
- arena.indices.flush(device);
4566
+
4567
+ // ─── Waste-triggered attribute-arena compaction ─────────────────
4568
+ // Relocate live allocations to the front of each chunk once
4569
+ // fragmentation waste crosses the floor — exact-size freelist reuse
4570
+ // can't reclaim a drifting size distribution, so the arena would
4571
+ // otherwise ratchet to its high-water forever. Runs AFTER the arena
4572
+ // flush (GPU buffer current) and BEFORE the header flush (so the
4573
+ // re-seated drawHeaders upload below). Indices live in a separate
4574
+ // buffer and are not compacted here.
4575
+ if (enableCompaction) runCompaction(false);
4576
+
3912
4577
  for (const bucket of buckets) {
3913
4578
  if (bucket.headerDirtyMax > bucket.headerDirtyMin) {
3914
4579
  device.queue.writeBuffer(
@@ -4112,14 +4777,42 @@ export function buildHeapScene(
4112
4777
  }
4113
4778
 
4114
4779
  function dispose(): void {
4115
- arena.attrs.destroy();
4116
- arena.indices.destroy();
4780
+ // Stop receiving compaction remaps for the shared store.
4781
+ storageReg.dispose();
4782
+ // Shared storage: release this scene's allocations so the shared pool's
4783
+ // refcounts stay correct (another scene may still hold the same regions),
4784
+ // but don't destroy the buffers — the storage owner does that. Private
4785
+ // storage: the whole arena is torn down wholesale, so per-draw release is
4786
+ // unnecessary.
4787
+ if (!ownsStorage) {
4788
+ for (let drawId = 0; drawId < drawIdToBucket.length; drawId++) {
4789
+ if (drawIdToBucket[drawId] !== undefined) removeDraw(drawId);
4790
+ }
4791
+ }
4117
4792
  for (const b of buckets) destroyBucketResources(b);
4793
+ if (ownsStorage) storage.dispose();
4118
4794
  }
4119
4795
 
4120
4796
  // Test-only escape hatch for inspecting megacall bucket state. Not
4121
4797
  // part of the public API surface — keep cast at use-site.
4122
4798
  const _debug = {
4799
+ /** Force a full heap compaction pass (attribute arena + index buffer)
4800
+ * regardless of the waste heuristics (bypasses the floor + 50%-live
4801
+ * gate), re-seat all refs, flush. Returns residual waste afterwards in
4802
+ * bytes (attr bytes + index elements×4; 0 when fully packed). Test-only —
4803
+ * drives relocation deterministically without engineering megabytes of
4804
+ * fragmentation. Callers should `update()` afterward to flush drawTables /
4805
+ * re-dispatch the partition (the render path does this). */
4806
+ forceCompact(): number {
4807
+ runCompaction(true);
4808
+ return arena.attrs.totalWasteBytes();
4809
+ },
4810
+ /** Current arena fragmentation waste (bytes) — covers uniforms,
4811
+ * attributes, and index regions (all one arena now). */
4812
+ attrWasteBytes(): number { return arena.attrs.totalWasteBytes(); },
4813
+ /** Number of arena chunks (pages) currently open. >1 means group
4814
+ * placement rolled past one buffer cap. */
4815
+ pageCount(): number { return arena.attrs.chunkCount; },
4123
4816
  bucketsForTest(): readonly {
4124
4817
  indirectBuf: GPUBuffer | undefined;
4125
4818
  drawTableBuf: GPUBuffer | undefined;
@@ -4180,14 +4873,14 @@ export function buildHeapScene(
4180
4873
  // multi-chunk-aware validator needs per-bucket-chunkIdx
4181
4874
  // staging which is a bigger refactor; assert for now so
4182
4875
  // multi-chunk callers don't get false-passes.
4183
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4876
+ if (arena.attrs.chunkCount > 1) {
4184
4877
  throw new Error(
4185
4878
  "heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
4186
4879
  "Run with the default chunk cap (no second chunk opened) for now.",
4187
4880
  );
4188
4881
  }
4189
4882
  const arenaSize = arena.attrs.chunk(0).buffer.size;
4190
- const indicesSize = arena.indices.chunk(0).buffer.size;
4883
+ const indicesSize = arenaSize; // indices live in the attr arena now
4191
4884
 
4192
4885
  const enc = device.createCommandEncoder({ label: "validateHeap" });
4193
4886
  const stage = (src: GPUBuffer, size: number): GPUBuffer => {
@@ -4200,7 +4893,7 @@ export function buildHeapScene(
4200
4893
  };
4201
4894
 
4202
4895
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
4203
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
4896
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
4204
4897
  type DC = {
4205
4898
  bucket: typeof buckets[number];
4206
4899
  drawHeap: GPUBuffer;
@@ -4249,7 +4942,7 @@ export function buildHeapScene(
4249
4942
  // Track unique attribute-alloc refs we've already inspected so we
4250
4943
  // don't re-validate the same shared alloc 20K times.
4251
4944
  const attrAllocsSeen = new Set<number>();
4252
- const KNOWN_TYPE_IDS = new Set<number>([0, 1, 2, 3]);
4945
+ const KNOWN_TYPE_IDS = new Set<number>([0, 1, 2, 3]);
4253
4946
 
4254
4947
  let bucketIdx = 0;
4255
4948
  for (const dc of dcs) {
@@ -4260,7 +4953,7 @@ export function buildHeapScene(
4260
4953
  for (let slot = 0; slot < recordCount; slot++) {
4261
4954
  const slotOff = slot * stride;
4262
4955
  for (const f of dc.bucket.layout.drawHeaderFields) {
4263
- if (f.name === "__layoutId") continue;
4956
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
4264
4957
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
4265
4958
  const ref = u32[(slotOff + f.byteOffset) >>> 2];
4266
4959
  if (ref === undefined || ref === 0) continue;
@@ -4294,18 +4987,9 @@ export function buildHeapScene(
4294
4987
  ? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
4295
4988
  : "";
4296
4989
  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}`);
4990
+ // (Mirror-less arena: no CPU shadow to cross-check against
4991
+ // the GPU read-back is the only copy.)
4992
+ push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${before}${here}`);
4309
4993
  attrAllocsBad++;
4310
4994
  continue;
4311
4995
  }
@@ -4516,11 +5200,11 @@ export function buildHeapScene(
4516
5200
  const push = (s: string) => { if (issues.length < 30) issues.push(s); };
4517
5201
 
4518
5202
  // §3 v1: chunk-0-only like validateHeap.
4519
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
5203
+ if (arena.attrs.chunkCount > 1) {
4520
5204
  throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
4521
5205
  }
4522
5206
  const arenaSize = arena.attrs.chunk(0).buffer.size;
4523
- const indicesSize = arena.indices.chunk(0).buffer.size;
5207
+ const indicesSize = arenaSize; // indices live in the attr arena now
4524
5208
 
4525
5209
  // Stage all buffers we'll need.
4526
5210
  const enc = device.createCommandEncoder({ label: "simulateDraws" });
@@ -4532,7 +5216,7 @@ export function buildHeapScene(
4532
5216
  return c;
4533
5217
  };
4534
5218
  const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
4535
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
5219
+ const indicesCopy = arenaCopy; // same buffer; _indexStart is an element offset into it
4536
5220
  type DC = {
4537
5221
  bucket: typeof buckets[number];
4538
5222
  drawHeap: GPUBuffer;
@@ -4592,7 +5276,7 @@ export function buildHeapScene(
4592
5276
  for (const dc of dcs) {
4593
5277
  const fs: Field[] = [];
4594
5278
  for (const f of dc.bucket.layout.drawHeaderFields) {
4595
- if (f.name === "__layoutId") continue;
5279
+ if (f.name === "__layoutId" || INLINE_HEADER_UNIFORMS.has(f.name)) continue;
4596
5280
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
4597
5281
  const wt = f.kind === "uniform-ref" ? f.uniformWgslType : f.attributeWgslType;
4598
5282
  const eltF =
@@ -4803,11 +5487,11 @@ export function buildHeapScene(
4803
5487
  const allocClaims: { ref: number; bytes: number; owner: string }[] = [];
4804
5488
 
4805
5489
  // §3 v1: chunk-0-only (see validateHeap).
4806
- if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
5490
+ if (arena.attrs.chunkCount > 1) {
4807
5491
  throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
4808
5492
  }
4809
5493
  const arenaBuf0 = arena.attrs.chunk(0).buffer;
4810
- const indicesBuf0 = arena.indices.chunk(0).buffer;
5494
+ const indicesBuf0 = arena.attrs.chunk(0).buffer;
4811
5495
  // Stage arena + arena.indices once (shared across buckets).
4812
5496
  const arenaCopy = device.createBuffer({
4813
5497
  size: arenaBuf0.size,
@@ -5255,7 +5939,7 @@ export function buildHeapScene(
5255
5939
  { binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
5256
5940
  { binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
5257
5941
  { binding: 2, resource: { buffer: sampleBuf } },
5258
- { binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
5942
+ { binding: 3, resource: { buffer: arena.attrs.chunk(target.chunkIdx).buffer } },
5259
5943
  { binding: 4, resource: { buffer: outBuf } },
5260
5944
  { binding: 5, resource: { buffer: paramBuf } },
5261
5945
  ],
@@ -5278,7 +5962,7 @@ export function buildHeapScene(
5278
5962
  sampleBuf.destroy(); outBuf.destroy(); paramBuf.destroy();
5279
5963
 
5280
5964
  // Read drawTable + indices for CPU expectation.
5281
- const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
5965
+ const idxBuf = arena.attrs.chunk(target.chunkIdx).buffer;
5282
5966
  const indicesCopy2 = device.createBuffer({
5283
5967
  size: idxBuf.size,
5284
5968
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,