@aardworx/wombat.rendering 0.19.23 → 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 (101) 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/dispatch.d.ts +5 -1
  22. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  23. package/dist/runtime/derivedUniforms/dispatch.js +28 -13
  24. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  25. package/dist/runtime/derivedUniforms/records.d.ts +15 -0
  26. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -1
  27. package/dist/runtime/derivedUniforms/records.js +81 -14
  28. package/dist/runtime/derivedUniforms/records.js.map +1 -1
  29. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +6 -0
  30. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  31. package/dist/runtime/derivedUniforms/sceneIntegration.js +9 -0
  32. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  33. package/dist/runtime/flattenTree.d.ts.map +1 -1
  34. package/dist/runtime/flattenTree.js +46 -2
  35. package/dist/runtime/flattenTree.js.map +1 -1
  36. package/dist/runtime/heapAdapter.d.ts +3 -1
  37. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  38. package/dist/runtime/heapAdapter.js +42 -4
  39. package/dist/runtime/heapAdapter.js.map +1 -1
  40. package/dist/runtime/heapDecoder.d.ts.map +1 -1
  41. package/dist/runtime/heapDecoder.js +8 -3
  42. package/dist/runtime/heapDecoder.js.map +1 -1
  43. package/dist/runtime/heapEffect.d.ts +14 -1
  44. package/dist/runtime/heapEffect.d.ts.map +1 -1
  45. package/dist/runtime/heapEffect.js +13 -1
  46. package/dist/runtime/heapEffect.js.map +1 -1
  47. package/dist/runtime/heapEffectIR.d.ts.map +1 -1
  48. package/dist/runtime/heapEffectIR.js +18 -9
  49. package/dist/runtime/heapEffectIR.js.map +1 -1
  50. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  51. package/dist/runtime/heapEligibility.js +64 -12
  52. package/dist/runtime/heapEligibility.js.map +1 -1
  53. package/dist/runtime/heapIrBuilders.d.ts +8 -0
  54. package/dist/runtime/heapIrBuilders.d.ts.map +1 -1
  55. package/dist/runtime/heapIrBuilders.js +47 -2
  56. package/dist/runtime/heapIrBuilders.js.map +1 -1
  57. package/dist/runtime/heapScene/chunkedArena.d.ts +20 -28
  58. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -1
  59. package/dist/runtime/heapScene/chunkedArena.js +28 -91
  60. package/dist/runtime/heapScene/chunkedArena.js.map +1 -1
  61. package/dist/runtime/heapScene/freelist.d.ts +1 -1
  62. package/dist/runtime/heapScene/freelist.js +3 -3
  63. package/dist/runtime/heapScene/freelist.js.map +1 -1
  64. package/dist/runtime/heapScene/pools.d.ts +103 -59
  65. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  66. package/dist/runtime/heapScene/pools.js +335 -194
  67. package/dist/runtime/heapScene/pools.js.map +1 -1
  68. package/dist/runtime/heapScene.d.ts +116 -1
  69. package/dist/runtime/heapScene.d.ts.map +1 -1
  70. package/dist/runtime/heapScene.js +581 -125
  71. package/dist/runtime/heapScene.js.map +1 -1
  72. package/dist/runtime/hybridScene.d.ts.map +1 -1
  73. package/dist/runtime/hybridScene.js +19 -3
  74. package/dist/runtime/hybridScene.js.map +1 -1
  75. package/dist/runtime/index.d.ts +1 -1
  76. package/dist/runtime/index.d.ts.map +1 -1
  77. package/dist/runtime/index.js +1 -1
  78. package/dist/runtime/index.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/core/elementType.ts +10 -0
  81. package/src/core/provider.ts +56 -28
  82. package/src/core/renderObject.ts +9 -0
  83. package/src/resources/preparedRenderObject.ts +22 -3
  84. package/src/runtime/derivedModes/modeKeyCpu.ts +26 -11
  85. package/src/runtime/derivedModes/partitionDispatcher.ts +30 -0
  86. package/src/runtime/derivedUniforms/dispatch.ts +26 -10
  87. package/src/runtime/derivedUniforms/records.ts +70 -15
  88. package/src/runtime/derivedUniforms/sceneIntegration.ts +10 -0
  89. package/src/runtime/flattenTree.ts +47 -2
  90. package/src/runtime/heapAdapter.ts +40 -5
  91. package/src/runtime/heapDecoder.ts +8 -3
  92. package/src/runtime/heapEffect.ts +15 -1
  93. package/src/runtime/heapEffectIR.ts +18 -9
  94. package/src/runtime/heapEligibility.ts +39 -12
  95. package/src/runtime/heapIrBuilders.ts +50 -2
  96. package/src/runtime/heapScene/chunkedArena.ts +29 -94
  97. package/src/runtime/heapScene/freelist.ts +3 -3
  98. package/src/runtime/heapScene/pools.ts +340 -218
  99. package/src/runtime/heapScene.ts +717 -141
  100. package/src/runtime/hybridScene.ts +20 -3
  101. package/src/runtime/index.ts +5 -0
@@ -3,11 +3,11 @@
3
3
  // classes here are tightly interleaved:
4
4
  //
5
5
  // AttributeArena — byte-bump over a STORAGE GrowBuffer
6
- // IndexAllocator — element-bump over an INDEX GrowBuffer
6
+ // (index arrays are AttributeArena regions too — VS storage-decodes them)
7
7
  // DrawHeap — slot-indexed allocator over a STORAGE GrowBuffer
8
8
  // (one slot per RO drawHeader)
9
9
  // UniformPool — aval-keyed refcounted alloc on top of AttributeArena
10
- // IndexPool — aval-keyed refcounted alloc on top of IndexAllocator,
10
+ // IndexPool — aval-keyed refcounted alloc on top of AttributeArena,
11
11
  // with value-tuple dedup for constant avals (§5b).
12
12
  //
13
13
  // Plus the per-allocation header constants used by all of them and
@@ -21,8 +21,7 @@ import type { BufferView } from "../../core/bufferView.js";
21
21
  import type { HostBufferSource } from "../../core/buffer.js";
22
22
  import { GrowBuffer, ALIGN16, DEFAULT_MAX_BUFFER_BYTES } from "./growBuffer.js";
23
23
  import { Freelist } from "./freelist.js";
24
- import { DirtyRanges } from "./dirtyRanges.js";
25
- import { ChunkedAttributeArena, ChunkedIndexAllocator } from "./chunkedArena.js";
24
+ import { ChunkedAttributeArena } from "./chunkedArena.js";
26
25
 
27
26
  // ─── Debug toggles ─────────────────────────────────────────────────────
28
27
 
@@ -43,8 +42,17 @@ const DEBUG_ALLOC_OVERLAP: boolean =
43
42
  export const ALLOC_HEADER_BYTES = 8;
44
43
  export const ALLOC_HEADER_PAD_TO = 16;
45
44
 
46
- /** Encoding-tag enum (low 16 bits of typeId). */
45
+ /** Default fragmentation-waste floor (bytes) below which `AttributeArena`
46
+ * declines to compact — relocating a few KB isn't worth a GPU copy +
47
+ * ref re-seat. Matches Aardvark's 4 MiB compaction floor. */
48
+ export const COMPACTION_WASTE_FLOOR_BYTES = 4 * 1024 * 1024;
49
+
50
+ /** Encoding-tag enum (low 16 bits of typeId). The generated VS decode
51
+ * branches on this per field (2-arm select; header word 0 shares the
52
+ * cache line with the broadcast length in word 1). */
47
53
  export const ENC_V3F_TIGHT = 1; // tightly-packed array of vec3<f32> (12 B/elt)
54
+ export const ENC_OCT32 = 2; // oct-packed unit vectors, 2×unorm16 in one u32 (4 B/elt)
55
+ export const ENC_C4B = 3; // RGBA8-unorm colors, one u32/elt → unpack4x8unorm (4 B/elt)
48
56
 
49
57
  /** Semantic-tag enum (high 16 bits of typeId). Optional metadata —
50
58
  * the shader doesn't branch on this. */
@@ -61,7 +69,10 @@ export const SEM_NORMALS = 2;
61
69
  */
62
70
  interface PoolEntry {
63
71
  readonly chunkIdx: number;
64
- readonly ref: number;
72
+ /** Byte offset within the chunk's GPUBuffer. Mutated by
73
+ * `UniformPool.remapRefs` when a waste-triggered arena compaction
74
+ * relocates the allocation. */
75
+ ref: number;
65
76
  readonly dataBytes: number;
66
77
  readonly typeId: number;
67
78
  readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
@@ -77,34 +88,107 @@ interface PoolEntry {
77
88
  * an aval still share the underlying allocation.
78
89
  */
79
90
  export class UniformPool {
80
- private readonly byAval = new Map<aval<unknown>, Map<number, PoolEntry>>();
91
+ /** Value is a single `PoolEntry` (the overwhelmingly common case —
92
+ * one aval, one chunk) or a `Map<chunkIdx, PoolEntry>` once the
93
+ * same aval is allocated in a second chunk. The flat form saves a
94
+ * whole inner `Map` per aval, which at heap scale (several avals
95
+ * per draw) is megabytes of JS heap. */
96
+ private readonly byAval = new Map<aval<unknown>, PoolEntry | Map<number, PoolEntry>>();
97
+
98
+ private getEntry(av: aval<unknown>, chunkIdx: number): PoolEntry | undefined {
99
+ const v = this.byAval.get(av);
100
+ if (v === undefined) return undefined;
101
+ if (v instanceof Map) return v.get(chunkIdx);
102
+ return v.chunkIdx === chunkIdx ? v : undefined;
103
+ }
104
+ private setEntry(av: aval<unknown>, e: PoolEntry): void {
105
+ const v = this.byAval.get(av);
106
+ if (v === undefined) { this.byAval.set(av, e); return; }
107
+ if (v instanceof Map) { v.set(e.chunkIdx, e); return; }
108
+ const m = new Map<number, PoolEntry>();
109
+ m.set(v.chunkIdx, v);
110
+ m.set(e.chunkIdx, e);
111
+ this.byAval.set(av, m);
112
+ }
113
+ private deleteEntry(av: aval<unknown>, chunkIdx: number): void {
114
+ const v = this.byAval.get(av);
115
+ if (v === undefined) return;
116
+ if (v instanceof Map) {
117
+ v.delete(chunkIdx);
118
+ if (v.size === 1) {
119
+ this.byAval.set(av, v.values().next().value as PoolEntry);
120
+ } else if (v.size === 0) this.byAval.delete(av);
121
+ return;
122
+ }
123
+ if (v.chunkIdx === chunkIdx) this.byAval.delete(av);
124
+ }
125
+ private *entriesOf(av: aval<unknown>): IterableIterator<PoolEntry> {
126
+ const v = this.byAval.get(av);
127
+ if (v === undefined) return;
128
+ if (v instanceof Map) yield* v.values();
129
+ else yield v;
130
+ }
81
131
 
82
132
  has(av: aval<unknown>, chunkIdx: number): boolean {
83
- return this.byAval.get(av)?.has(chunkIdx) ?? false;
133
+ return this.getEntry(av, chunkIdx) !== undefined;
84
134
  }
85
135
  /** True when the pool holds at least one entry for `av` in any
86
136
  * chunk. Used by the scene's `inputChanged` dispatch to decide
87
137
  * whether a marking aval is one of our tracked allocations. */
88
138
  hasAny(av: aval<unknown>): boolean {
89
- return (this.byAval.get(av)?.size ?? 0) > 0;
139
+ const v = this.byAval.get(av);
140
+ return v !== undefined && (!(v instanceof Map) || v.size > 0);
90
141
  }
91
142
  entry(av: aval<unknown>, chunkIdx: number): PoolEntry | undefined {
92
- return this.byAval.get(av)?.get(chunkIdx);
143
+ return this.getEntry(av, chunkIdx);
93
144
  }
94
145
  /** First chunk this aval is allocated in, if any. Used by `addRO`
95
146
  * to prefer co-locating a new RO with its already-allocated
96
147
  * shared inputs (avoids unnecessary duplication). */
97
148
  firstChunkContaining(av: aval<unknown>): number | undefined {
98
- const byChunk = this.byAval.get(av);
99
- if (byChunk === undefined || byChunk.size === 0) return undefined;
100
- return byChunk.keys().next().value as number;
149
+ for (const e of this.entriesOf(av)) return e.chunkIdx;
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Unkeyed allocation for slots the GPU overwrites every dirty frame
155
+ * (§7 derived-uniform outputs). No aval, no `byAval` entry, no
156
+ * sharing — the caller keeps the ref in its drawHeader cell (which
157
+ * the compaction remap re-seats) and frees it with `releaseRaw`.
158
+ * Avoids a placeholder `AVal.constant` + pool entry per draw.
159
+ */
160
+ allocRaw(
161
+ arena: ChunkedAttributeArena,
162
+ chunkIdx: number,
163
+ dataBytes: number,
164
+ typeId: number,
165
+ length: number,
166
+ ): number {
167
+ const r = arena.alloc(dataBytes, chunkIdx);
168
+ if (r.chunkIdx !== chunkIdx) {
169
+ throw new Error(
170
+ `UniformPool.allocRaw: allocator spilled from chunk ${chunkIdx} to chunk ${r.chunkIdx} ` +
171
+ `(${dataBytes} bytes). Caller's bucket is bound to chunk ${chunkIdx}.`,
172
+ );
173
+ }
174
+ const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
175
+ const buf = new ArrayBuffer(allocBytes);
176
+ const u32 = new Uint32Array(buf);
177
+ u32[0] = typeId;
178
+ u32[1] = length;
179
+ u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
180
+ arena.write(chunkIdx, r.off, new Uint8Array(buf));
181
+ return r.off;
182
+ }
183
+ releaseRaw(arena: ChunkedAttributeArena, chunkIdx: number, ref: number, dataBytes: number): void {
184
+ arena.release(chunkIdx, ref, dataBytes);
101
185
  }
102
186
 
103
187
  /**
104
188
  * Acquire (or share) an allocation for `aval` in `chunkIdx`.
105
189
  * Returns the byte offset within that chunk's GPUBuffer. If a
106
190
  * fresh allocation is made, the value is packed + uploaded
107
- * immediately into the chunk's CPU shadow.
191
+ * immediately to the chunk's GPU buffer (mirror-less).
108
192
  */
109
193
  acquire(
110
194
  device: GPUDevice,
@@ -117,8 +201,7 @@ export class UniformPool {
117
201
  length: number,
118
202
  pack: (val: unknown, dst: Float32Array, off: number) => void,
119
203
  ): number {
120
- let byChunk = this.byAval.get(av);
121
- const existing = byChunk?.get(chunkIdx);
204
+ const existing = this.getEntry(av, chunkIdx);
122
205
  if (existing !== undefined) {
123
206
  existing.refcount++;
124
207
  return existing.ref;
@@ -152,19 +235,14 @@ export class UniformPool {
152
235
  pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
153
236
  arena.write(finalChunk, r.off, new Uint8Array(buf));
154
237
  void device;
155
- if (byChunk === undefined) {
156
- byChunk = new Map();
157
- this.byAval.set(av, byChunk);
158
- }
159
- byChunk.set(finalChunk, {
238
+ this.setEntry(av, {
160
239
  chunkIdx: finalChunk, ref: r.off, dataBytes, typeId, pack, refcount: 1,
161
240
  });
162
241
  return r.off;
163
242
  }
164
243
 
165
244
  release(arena: ChunkedAttributeArena, av: aval<unknown>, chunkIdx: number): void {
166
- const byChunk = this.byAval.get(av);
167
- const e = byChunk?.get(chunkIdx);
245
+ const e = this.getEntry(av, chunkIdx);
168
246
  if (e === undefined) return;
169
247
  e.refcount--;
170
248
  if (e.refcount > 0) return;
@@ -179,8 +257,20 @@ export class UniformPool {
179
257
  // 16 B inside the previous alloc → overlapping allocations →
180
258
  // garbage `typeId`/`length` in the attribute header.
181
259
  arena.release(e.chunkIdx, e.ref, e.dataBytes);
182
- byChunk!.delete(chunkIdx);
183
- if (byChunk!.size === 0) this.byAval.delete(av);
260
+ this.deleteEntry(av, chunkIdx);
261
+ }
262
+
263
+ /** Re-seat every entry's `ref` in `chunkIdx` after a waste-triggered
264
+ * arena compaction relocated allocations. `remap` maps OLD→NEW byte
265
+ * offset. Entries whose ref didn't move are left as-is. O(entries). */
266
+ remapRefs(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
267
+ if (remap.size === 0) return;
268
+ for (const v of this.byAval.values()) {
269
+ const e = v instanceof Map ? v.get(chunkIdx) : (v.chunkIdx === chunkIdx ? v : undefined);
270
+ if (e === undefined) continue;
271
+ const nn = remap.get(e.ref);
272
+ if (nn !== undefined) e.ref = nn;
273
+ }
184
274
  }
185
275
 
186
276
  /** Re-pack one entry's data region into every chunk that holds
@@ -193,10 +283,8 @@ export class UniformPool {
193
283
  av: aval<unknown>,
194
284
  val: unknown,
195
285
  ): void {
196
- const byChunk = this.byAval.get(av);
197
- if (byChunk === undefined) return;
198
286
  let dst: Float32Array | undefined;
199
- for (const e of byChunk.values()) {
287
+ for (const e of this.entriesOf(av)) {
200
288
  if (dst === undefined || dst.length !== e.dataBytes / 4) {
201
289
  dst = new Float32Array(e.dataBytes / 4);
202
290
  }
@@ -212,10 +300,8 @@ export class UniformPool {
212
300
  /** Total bytes touched by `repack` for this aval — sum across
213
301
  * chunks. Used by the dirty-bytes diagnostics. */
214
302
  totalDataBytes(av: aval<unknown>): number {
215
- const byChunk = this.byAval.get(av);
216
- if (byChunk === undefined) return 0;
217
303
  let s = 0;
218
- for (const e of byChunk.values()) s += e.dataBytes;
304
+ for (const e of this.entriesOf(av)) s += e.dataBytes;
219
305
  return s;
220
306
  }
221
307
  }
@@ -223,7 +309,7 @@ export class UniformPool {
223
309
  // ─── IndexPool ─────────────────────────────────────────────────────────
224
310
 
225
311
  /**
226
- * Aval-keyed pool over the `IndexAllocator`. Two draws referencing
312
+ * Aval-keyed pool over the (attribute) arena. Two draws referencing
227
313
  * the same `Uint32Array` (or aval thereof) share an index range —
228
314
  * 19K instanced clones of the same mesh share one allocation, one
229
315
  * upload.
@@ -236,11 +322,40 @@ export class UniformPool {
236
322
  */
237
323
  export class IndexPool {
238
324
  /** Per-aval, per-chunk bookkeeping. Same aval acquired in two
239
- * chunks gets two entries (accepted §3 duplication). */
325
+ * chunks gets two entries (accepted §3 duplication). Value is a
326
+ * single binding (common case) or a Map<chunkIdx, binding> once
327
+ * the aval spans chunks — same flat-form saving as UniformPool. */
240
328
  private readonly byAval = new Map<
241
329
  aval<Uint32Array>,
242
- Map<number, { entry: IndexPoolEntry; perAvalCount: number }>
330
+ IndexBinding | Map<number, IndexBinding>
243
331
  >();
332
+
333
+ private getBinding(av: aval<Uint32Array>, chunkIdx: number): IndexBinding | undefined {
334
+ const v = this.byAval.get(av);
335
+ if (v === undefined) return undefined;
336
+ if (v instanceof Map) return v.get(chunkIdx);
337
+ return v.entry.chunkIdx === chunkIdx ? v : undefined;
338
+ }
339
+ private setBinding(av: aval<Uint32Array>, b: IndexBinding): void {
340
+ const v = this.byAval.get(av);
341
+ if (v === undefined) { this.byAval.set(av, b); return; }
342
+ if (v instanceof Map) { v.set(b.entry.chunkIdx, b); return; }
343
+ const m = new Map<number, IndexBinding>();
344
+ m.set(v.entry.chunkIdx, v);
345
+ m.set(b.entry.chunkIdx, b);
346
+ this.byAval.set(av, m);
347
+ }
348
+ private deleteBinding(av: aval<Uint32Array>, chunkIdx: number): void {
349
+ const v = this.byAval.get(av);
350
+ if (v === undefined) return;
351
+ if (v instanceof Map) {
352
+ v.delete(chunkIdx);
353
+ if (v.size === 1) this.byAval.set(av, v.values().next().value as IndexBinding);
354
+ else if (v.size === 0) this.byAval.delete(av);
355
+ return;
356
+ }
357
+ if (v.entry.chunkIdx === chunkIdx) this.byAval.delete(av);
358
+ }
244
359
  /** Per-chunk constant-aval value-dedup (§5b in-chunk). */
245
360
  private readonly byValueKey = new Map<string, IndexPoolEntry>();
246
361
  private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
@@ -255,20 +370,20 @@ export class IndexPool {
255
370
  }
256
371
 
257
372
  firstChunkContaining(av: aval<Uint32Array>): number | undefined {
258
- const byChunk = this.byAval.get(av);
259
- if (byChunk === undefined || byChunk.size === 0) return undefined;
260
- return byChunk.keys().next().value as number;
373
+ const v = this.byAval.get(av);
374
+ if (v === undefined) return undefined;
375
+ if (v instanceof Map) return v.size > 0 ? (v.keys().next().value as number) : undefined;
376
+ return v.entry.chunkIdx;
261
377
  }
262
378
 
263
379
  acquire(
264
380
  device: GPUDevice,
265
- indices: ChunkedIndexAllocator,
381
+ arena: ChunkedAttributeArena,
266
382
  av: aval<Uint32Array>,
267
383
  chunkIdx: number,
268
384
  arr: Uint32Array,
269
385
  ): { chunkIdx: number; firstIndex: number; count: number } {
270
- let byChunk = this.byAval.get(av);
271
- const bound = byChunk?.get(chunkIdx);
386
+ const bound = this.getBinding(av, chunkIdx);
272
387
  if (bound !== undefined) {
273
388
  bound.perAvalCount++;
274
389
  bound.entry.totalRefcount++;
@@ -278,59 +393,92 @@ export class IndexPool {
278
393
  if (av.isConstant) {
279
394
  // Value-dedup is scoped per chunk — two constant avals in the
280
395
  // same chunk collapse to one allocation; two constant avals in
281
- // DIFFERENT chunks each get their own (we can't share across
282
- // chunks since the index range lives in the chunk's GPUBuffer).
396
+ // DIFFERENT chunks each get their own (the index range lives in
397
+ // the chunk's GPUBuffer).
283
398
  valueKey = `${chunkIdx}:${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
284
399
  const shared = this.byValueKey.get(valueKey);
285
400
  if (shared !== undefined) {
286
401
  shared.totalRefcount++;
287
- if (byChunk === undefined) {
288
- byChunk = new Map();
289
- this.byAval.set(av, byChunk);
290
- }
291
- byChunk.set(chunkIdx, { entry: shared, perAvalCount: 1 });
402
+ this.setBinding(av, { entry: shared, perAvalCount: 1 });
292
403
  return { chunkIdx: shared.chunkIdx, firstIndex: shared.firstIndex, count: shared.count };
293
404
  }
294
405
  }
295
- const r = indices.alloc(arr.length, chunkIdx);
296
- indices.write(
297
- r.chunkIdx, r.off * 4,
298
- new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength),
299
- );
406
+ // Index data lives in the SAME arena as attributes/uniforms (the heap VS
407
+ // storage-decodes indices — `indexStorage[_indexStart + i]` — so they need
408
+ // no separate index buffer). Allocate a normal header+data region; the
409
+ // raw u32 indices sit at `ref + 16`, so the element offset the drawTable
410
+ // stores is `(ref + 16) >> 2`. The header is unread by index gathers but
411
+ // kept for a uniform region layout (and so compaction treats it like any
412
+ // other region).
413
+ const byteLen = arr.byteLength;
414
+ const r = arena.alloc(byteLen, chunkIdx);
415
+ const dataOff = r.off + ALLOC_HEADER_PAD_TO;
416
+ const hdr = new Uint32Array([0, arr.length, 4, 0]); // (typeId, length, strideBytes, pad)
417
+ arena.write(r.chunkIdx, r.off, new Uint8Array(hdr.buffer));
418
+ arena.write(r.chunkIdx, dataOff, new Uint8Array(arr.buffer, arr.byteOffset, byteLen));
300
419
  void device;
420
+ const firstIndex = dataOff >>> 2;
301
421
  const entry: IndexPoolEntry = {
302
- chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length,
422
+ chunkIdx: r.chunkIdx, ref: r.off, firstIndex, count: arr.length,
303
423
  totalRefcount: 1, valueKey,
304
424
  };
305
- if (byChunk === undefined) {
306
- byChunk = new Map();
307
- this.byAval.set(av, byChunk);
308
- }
309
- byChunk.set(r.chunkIdx, { entry, perAvalCount: 1 });
425
+ this.setBinding(av, { entry, perAvalCount: 1 });
310
426
  if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
311
- return { chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length };
427
+ return { chunkIdx: r.chunkIdx, firstIndex, count: arr.length };
428
+ }
429
+
430
+ /** The allocation's byte header offset (`ref`) of `av`'s entry in `chunkIdx`,
431
+ * or undefined if not allocated there. The heap compaction pass looks the
432
+ * region up in the arena remap by this ref to compute the draw's new
433
+ * `_indexStart`. */
434
+ baseFor(av: aval<Uint32Array>, chunkIdx: number): number | undefined {
435
+ return this.getBinding(av, chunkIdx)?.entry.ref;
436
+ }
437
+
438
+ /** Re-seat every entry after a compaction relocated its region. `remap` maps
439
+ * OLD→NEW byte header offset (`ref`); `firstIndex` is recomputed from the
440
+ * new ref. Entries are shared by value-dedup, so each is visited once. */
441
+ remapRefs(chunkIdx: number, remap: ReadonlyMap<number, number>): void {
442
+ if (remap.size === 0) return;
443
+ const seen = new Set<IndexPoolEntry>();
444
+ for (const v of this.byAval.values()) {
445
+ const bound = v instanceof Map ? v.get(chunkIdx) : (v.entry.chunkIdx === chunkIdx ? v : undefined);
446
+ if (bound === undefined || seen.has(bound.entry)) continue;
447
+ seen.add(bound.entry);
448
+ const nn = remap.get(bound.entry.ref);
449
+ if (nn !== undefined) {
450
+ bound.entry.ref = nn;
451
+ bound.entry.firstIndex = (nn + ALLOC_HEADER_PAD_TO) >>> 2;
452
+ }
453
+ }
312
454
  }
313
455
 
314
- release(indices: ChunkedIndexAllocator, av: aval<Uint32Array>, chunkIdx: number): void {
315
- const byChunk = this.byAval.get(av);
316
- const bound = byChunk?.get(chunkIdx);
456
+ release(arena: ChunkedAttributeArena, av: aval<Uint32Array>, chunkIdx: number): void {
457
+ const bound = this.getBinding(av, chunkIdx);
317
458
  if (bound === undefined) return;
318
459
  bound.perAvalCount--;
319
460
  bound.entry.totalRefcount--;
320
- if (bound.perAvalCount === 0) {
321
- byChunk!.delete(chunkIdx);
322
- if (byChunk!.size === 0) this.byAval.delete(av);
323
- }
461
+ if (bound.perAvalCount === 0) this.deleteBinding(av, chunkIdx);
324
462
  if (bound.entry.totalRefcount > 0) return;
325
- indices.release(bound.entry.chunkIdx, bound.entry.firstIndex, bound.entry.count);
463
+ arena.release(bound.entry.chunkIdx, bound.entry.ref, bound.entry.count * 4);
326
464
  if (bound.entry.valueKey !== undefined) {
327
465
  this.byValueKey.delete(bound.entry.valueKey);
328
466
  }
329
467
  }
330
468
  }
331
469
 
470
+ interface IndexBinding {
471
+ entry: IndexPoolEntry;
472
+ perAvalCount: number;
473
+ }
474
+
332
475
  interface IndexPoolEntry {
333
476
  chunkIdx: number;
477
+ /** Byte header offset of the region in the (attribute) arena — the unit the
478
+ * compaction remap is keyed by. */
479
+ ref: number;
480
+ /** Element (u32) offset to the index DATA (`(ref + 16) >> 2`) — the value
481
+ * the drawTable's `_indexStart` stores. */
334
482
  firstIndex: number;
335
483
  count: number;
336
484
  totalRefcount: number;
@@ -387,52 +535,40 @@ export class DrawHeap {
387
535
  */
388
536
  export class AttributeArena {
389
537
  private cursor = 0;
538
+ /** Sum of `allocBytes` over all live allocations. `cursor - liveBytes`
539
+ * is the fragmentation waste below the high-water mark — what
540
+ * `compact()` reclaims. */
541
+ private liveBytes = 0;
390
542
  private readonly freelist = new Freelist();
391
543
  /** DEBUG: tracks every live allocation by start byte → size. Set
392
544
  * on alloc, cleared on release. Used by `assertNoOverlap()` to
393
545
  * detect allocator returning overlapping ranges. Enabled in
394
546
  * development; cheap (one Map insert/delete per alloc). */
395
547
  private readonly liveAllocs = new Map<number, number>();
396
- /** CPU shadow of the entire GPU buffer; writes go here first then
397
- * flush() emits one writeBuffer per dirty contiguous range. A
398
- * single min..max span would re-upload nearly the whole arena on
399
- * scattered edits see dirtyRanges.ts. */
400
- private shadow: Uint8Array;
401
- private readonly dirty = new DirtyRanges();
402
- constructor(private readonly buf: GrowBuffer) {
403
- this.shadow = new Uint8Array(buf.capacity);
404
- buf.onResize(() => {
405
- const grown = new Uint8Array(buf.capacity);
406
- grown.set(this.shadow);
407
- this.shadow = grown;
408
- });
409
- }
548
+ // MIRROR-LESS: no CPU shadow of the arena. Writes go straight to
549
+ // `queue.writeBuffer` (queue-ordered with submits, so they serialize
550
+ // correctly against GrowBuffer's grow-copy and the compaction bounce
551
+ // both of which preserve contents GPU-side). This halves the heap's
552
+ // host-memory footprint: the source-of-truth for values is the user's
553
+ // avals (they must stay alive — changeability is the point), and the
554
+ // GPU buffer is the render copy; a third full-size CPU mirror bought
555
+ // only dirty-range batching + debug readback comparisons.
556
+ constructor(
557
+ private readonly device: GPUDevice,
558
+ private readonly buf: GrowBuffer,
559
+ ) {}
410
560
  get buffer(): GPUBuffer { return this.buf.buffer; }
411
561
  get capacity(): number { return this.buf.capacity; }
412
562
  get usedBytes(): number { return this.cursor; }
413
- /** Read 4 u32s from the CPU shadow starting at byte `off`. Used by
414
- * the validator to compare against the GPU's read-back values:
415
- * if CPU shadow is correct but GPU shows garbage → the corruption
416
- * came from a GPU-side write (a compute kernel writing to a wrong
417
- * byte). If both differ from what pool.acquire wrote → CPU-side
418
- * write path is the culprit. */
419
- peekShadowU32(off: number, count: number): Uint32Array {
420
- return new Uint32Array(this.shadow.buffer, this.shadow.byteOffset + off, count);
421
- }
422
563
  write(dst: number, data: Uint8Array): void {
423
- this.shadow.set(data, dst);
424
- this.dirty.add(dst, dst + data.byteLength);
425
- }
426
- flush(device: GPUDevice): void {
427
- if (this.dirty.isEmpty) return;
428
- this.dirty.drain((start, end) => {
429
- device.queue.writeBuffer(
430
- this.buf.buffer, start,
431
- this.shadow.buffer, this.shadow.byteOffset + start,
432
- end - start,
433
- );
434
- });
564
+ // Immediate upload; Chrome's queue staging does the batching. The
565
+ // write targets the CURRENT buffer handle — a later grow copies it
566
+ // forward (queue-ordered), so ordering stays correct.
567
+ this.device.queue.writeBuffer(this.buf.buffer, dst, data as BufferSource);
435
568
  }
569
+ /** No-op — kept for call-site compatibility (mirror-less arena uploads
570
+ * in `write`). */
571
+ flush(_device: GPUDevice): void {}
436
572
  /**
437
573
  * Allocate space for one attribute. Returns the byte ref (offset
438
574
  * to the header — data lives at ref + 16).
@@ -446,6 +582,7 @@ export class AttributeArena {
446
582
  const reused = this.freelist.alloc(allocBytes);
447
583
  if (reused !== undefined) {
448
584
  this.recordAlloc(reused, allocBytes, "freelist");
585
+ this.liveBytes += allocBytes;
449
586
  return reused;
450
587
  }
451
588
  const next = this.cursor + allocBytes;
@@ -455,6 +592,7 @@ export class AttributeArena {
455
592
  this.buf.setUsed(next);
456
593
  const ref = next - allocBytes;
457
594
  this.recordAlloc(ref, allocBytes, "bump");
595
+ this.liveBytes += allocBytes;
458
596
  return ref;
459
597
  }
460
598
  alloc(dataBytes: number): number {
@@ -484,6 +622,7 @@ export class AttributeArena {
484
622
  );
485
623
  }
486
624
  this.liveAllocs.delete(ref);
625
+ this.liveBytes -= allocBytes;
487
626
  this.freelist.release(ref, allocBytes);
488
627
  // §5: shrink the bump cursor back if release exposed a free
489
628
  // tail touching it (cascading — `freelist.release` may have
@@ -514,111 +653,92 @@ export class AttributeArena {
514
653
  }
515
654
  this.liveAllocs.set(off, size);
516
655
  }
517
- onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
518
- destroy(): void { this.buf.destroy(); }
519
- }
520
-
521
- // ─── IndexAllocator (element-bump) ─────────────────────────────────────
656
+ /** Bytes the bump cursor can still grow into before hitting the chunk cap
657
+ * (ignores freelist holes a conservative lower bound on free space, used
658
+ * by group placement to decide whether a draw's group fits here). */
659
+ get bumpHeadroom(): number { return this.buf.maxCapacity - this.cursor; }
660
+ /** Live bytes (sum of all live allocations, header+pad included). */
661
+ get liveByteCount(): number { return this.liveBytes; }
662
+ /** Fragmentation waste below the high-water mark: free bytes the tail-
663
+ * reclaim couldn't recover because they sit between live allocations. */
664
+ get wasteBytes(): number { return this.cursor - this.liveBytes; }
522
665
 
523
- /**
524
- * Element-bump allocator over an index GrowBuffer (units = u32). Each
525
- * draw's index range is allocated as one block; on release the block
526
- * is returned to a Freelist and can be reused best-fit.
527
- */
528
- export class IndexAllocator {
529
- private cursor = 0; // in u32s, not bytes
530
- private readonly freelist = new Freelist();
531
- /** DEBUG: live index allocations tracked by start element. */
532
- private readonly liveAllocs = new Map<number, number>();
533
- private shadow: Uint8Array;
534
- private readonly dirty = new DirtyRanges();
535
- constructor(private readonly buf: GrowBuffer) {
536
- this.shadow = new Uint8Array(buf.capacity);
537
- buf.onResize(() => {
538
- const grown = new Uint8Array(buf.capacity);
539
- grown.set(this.shadow);
540
- this.shadow = grown;
541
- });
542
- }
543
- get buffer(): GPUBuffer { return this.buf.buffer; }
544
- get usedElements(): number { return this.cursor; }
545
- write(dstByteOffset: number, data: Uint8Array): void {
546
- this.shadow.set(data, dstByteOffset);
547
- this.dirty.add(dstByteOffset, dstByteOffset + data.byteLength);
548
- }
549
- flush(device: GPUDevice): void {
550
- if (this.dirty.isEmpty) return;
551
- this.dirty.drain((start, end) => {
552
- device.queue.writeBuffer(
553
- this.buf.buffer, start,
554
- this.shadow.buffer, this.shadow.byteOffset + start,
555
- end - start,
556
- );
557
- });
558
- }
559
- /** Non-throwing alloc — returns `undefined` when the request
560
- * would exceed the underlying GrowBuffer's `maxCapacity` cap.
561
- * Used by `ChunkedIndexAllocator` to probe before spilling. */
562
- tryAlloc(elements: number): number | undefined {
563
- const reused = this.freelist.alloc(elements);
564
- if (reused !== undefined) {
565
- this.recordAlloc(reused, elements, "freelist");
566
- return reused;
666
+ /**
667
+ * Waste-triggered compaction. Relocates live allocations to the front of
668
+ * the buffer, eliminating the high-water ratchet that exact-size freelist
669
+ * reuse alone can't fix (a drifting allocation-size distribution never
670
+ * reuses its holes and grows toward the high-water forever).
671
+ *
672
+ * The byte move happens GPU-side via `copyBufferToBuffer` through a scratch
673
+ * buffer no CPU→GPU re-upload — and is mirrored into the CPU shadow so it
674
+ * stays authoritative for future partial writes. Same-buffer copies can't
675
+ * overlap, hence the scratch round-trip.
676
+ *
677
+ * Returns a map of OLD→NEW byte offset for every allocation that moved; the
678
+ * caller must re-seat every cached ref (drawHeader cells, uniform-pool
679
+ * entries, derived-uniform handles, partition master refs). Returns an
680
+ * empty map when it declines to compact (waste below `wasteFloorBytes` or
681
+ * less than half the extent wasted).
682
+ *
683
+ * Precondition: the arena's pending shadow writes must already be flushed
684
+ * to the GPU buffer (the GPU copy reads the live buffer). O(live).
685
+ */
686
+ compact(device: GPUDevice, wasteFloorBytes: number, force = false): Map<number, number> {
687
+ const waste = this.cursor - this.liveBytes;
688
+ if (!force && (this.liveBytes * 2 >= this.cursor || waste < wasteFloorBytes)) {
689
+ return new Map();
567
690
  }
568
- const nextElts = this.cursor + elements;
569
- if (nextElts * 4 > this.buf.maxCapacity) return undefined;
570
- this.cursor = nextElts;
571
- this.buf.ensureCapacity(nextElts * 4);
572
- this.buf.setUsed(nextElts * 4);
573
- const off = nextElts - elements;
574
- this.recordAlloc(off, elements, "bump");
575
- return off;
576
- }
577
- private recordAlloc(off: number, size: number, source: string): void {
578
- if (DEBUG_ALLOC_OVERLAP) {
579
- for (const [liveOff, liveSize] of this.liveAllocs) {
580
- if (off < liveOff + liveSize && liveOff < off + size) {
581
- throw new Error(
582
- `IndexAllocator.tryAlloc(${source}): returned [${off},${off + size}) ` +
583
- `(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
584
- `(size=${liveSize}). Index allocator is handing out shared memory!`,
585
- );
586
- }
691
+ // Live allocations sorted by current offset.
692
+ const live = [...this.liveAllocs.entries()].sort((a, b) => a[0] - b[0]);
693
+ // Assign packed offsets (bump from 0); build the OLD→NEW remap and the
694
+ // source-contiguous runs to copy. A run is a maximal set of live blocks
695
+ // adjacent in the source; under the bump it lands dest-contiguous too, so
696
+ // the compacted prefix [0, newExtent) is fully covered by the runs.
697
+ const remap = new Map<number, number>();
698
+ const runs: { srcStart: number; dstStart: number; len: number }[] = [];
699
+ let w = 0;
700
+ let cur: { srcStart: number; dstStart: number; len: number } | undefined;
701
+ for (const [off, size] of live) {
702
+ if (w !== off) remap.set(off, w);
703
+ if (cur !== undefined && off === cur.srcStart + cur.len) {
704
+ cur.len += size;
705
+ } else {
706
+ cur = { srcStart: off, dstStart: w, len: size };
707
+ runs.push(cur);
587
708
  }
709
+ w += size;
588
710
  }
589
- this.liveAllocs.set(off, size);
590
- }
591
- alloc(elements: number): number {
592
- const r = this.tryAlloc(elements);
593
- if (r === undefined) {
594
- throw new Error(`IndexAllocator: allocation of ${elements} elements exceeds chunk cap`);
595
- }
596
- return r;
597
- }
598
- release(off: number, elements: number): void {
599
- const tracked = this.liveAllocs.get(off);
600
- if (tracked === undefined) {
601
- throw new Error(
602
- `IndexAllocator.release: off=${off} (elements=${elements}) was not in ` +
603
- `liveAllocs — double-free or release for an alloc that never happened. ` +
604
- `Live alloc count=${this.liveAllocs.size}.`,
605
- );
606
- }
607
- if (tracked !== elements) {
608
- throw new Error(
609
- `IndexAllocator.release: size mismatch at off=${off} — recorded=${tracked} ` +
610
- `but release elements=${elements}. Will corrupt the freelist.`,
611
- );
612
- }
613
- this.liveAllocs.delete(off);
614
- this.freelist.release(off, elements);
615
- while (true) {
616
- const top = this.freelist.takeBlockEndingAt(this.cursor);
617
- if (top === undefined) break;
618
- this.cursor = top.off;
619
- this.buf.setUsed(this.cursor * 4);
711
+ const newExtent = w;
712
+ if (remap.size === 0) return remap; // already tight
713
+
714
+ // GPU move: arena → scratch (compacted), then scratch → arena[0,newExtent).
715
+ // Both copies are between distinct buffers, so no overlap constraint.
716
+ const scratch = device.createBuffer({
717
+ label: "AttributeArena/compact-scratch",
718
+ size: newExtent,
719
+ usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
720
+ });
721
+ const enc = device.createCommandEncoder({ label: "AttributeArena/compact" });
722
+ for (const r of runs) {
723
+ enc.copyBufferToBuffer(this.buf.buffer, r.srcStart, scratch, r.dstStart, r.len);
620
724
  }
725
+ enc.copyBufferToBuffer(scratch, 0, this.buf.buffer, 0, newExtent);
726
+ device.queue.submit([enc.finish()]);
727
+ scratch.destroy();
728
+
729
+ // Rebuild allocator bookkeeping: live allocs at their new offsets, no free
730
+ // blocks (everything is now contiguous), cursor at the new high-water.
731
+ // (Mirror-less: the GPU bounce above IS the move — nothing else to sync.)
732
+ const moved: Array<[number, number]> = [];
733
+ for (const [off, size] of live) moved.push([remap.get(off) ?? off, size]);
734
+ this.liveAllocs.clear();
735
+ for (const [o, s] of moved) this.liveAllocs.set(o, s);
736
+ this.freelist.clear();
737
+ this.cursor = newExtent;
738
+ this.buf.setUsed(newExtent);
739
+ return remap;
621
740
  }
741
+
622
742
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
623
743
  destroy(): void { this.buf.destroy(); }
624
744
  }
@@ -626,17 +746,18 @@ export class IndexAllocator {
626
746
  // ─── ArenaState + helpers ──────────────────────────────────────────────
627
747
 
628
748
  /**
629
- * Global arena state: attribute / uniform data lives in `attrs`
630
- * (multi-typed-view storage); indices live in `indices` (separate
631
- * INDEX-usage buffer). Both are chunked: each chunk owns a separate
632
- * GPUBuffer + Freelist. Refs that downstream code stores are byte
633
- * offsets within a chunk; the chunk identity is carried alongside
634
- * (via bucket.chunkIdx in heapScene) and made implicit at draw
635
- * time by which chunk's bind group is active.
749
+ * Global arena state: ONE chunked arena holds all heap data (per-draw
750
+ * uniforms, attributes, and index arrays) as multi-typed views over the same
751
+ * buffers. Each chunk owns a separate GPUBuffer + Freelist. Refs that
752
+ * downstream code stores are byte offsets within a chunk; the chunk identity
753
+ * is carried alongside (via `bucket.chunkIdx`) and made implicit at draw time
754
+ * by which chunk's bind group is active.
636
755
  */
637
756
  export interface ArenaState {
638
- readonly attrs: ChunkedAttributeArena;
639
- readonly indices: ChunkedIndexAllocator;
757
+ /** The single arena holding ALL heap data — per-draw uniforms, vertex /
758
+ * instance attributes, AND index arrays (the heap VS storage-decodes
759
+ * indices, so they need no separate index buffer). */
760
+ readonly attrs: ChunkedAttributeArena;
640
761
  }
641
762
 
642
763
  export function buildArenaState(
@@ -655,19 +776,20 @@ export function buildArenaState(
655
776
  // exercise multi-chunk routing).
656
777
  const adapterCap = device.limits.maxStorageBufferBindingSize;
657
778
  const cap = maxChunkBytes ?? Math.min(adapterCap, DEFAULT_MAX_BUFFER_BYTES);
779
+ void idxBytesHint; void idxExtraUsage; // legacy params (separate index buffer removed)
780
+ // Clamp the pre-size hint to the per-chunk cap: GrowBuffer RAISES its max
781
+ // to fit a larger initial size, which would mint an oversized page (and
782
+ // §7 derived-record handles cap page offsets at 2^29 B = 512 MB). A
783
+ // multi-page scene simply pre-sizes each page at the cap as it opens.
658
784
  const attrs = new ChunkedAttributeArena(
659
- device, `${label}/attrs`, GPUBufferUsage.STORAGE,
660
- attrBytesHint, cap,
661
- );
662
- const indices = new ChunkedIndexAllocator(
663
- device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
664
- idxBytesHint, cap,
785
+ device, `${label}/arena`, GPUBufferUsage.STORAGE,
786
+ Math.min(attrBytesHint, cap), cap,
665
787
  );
666
- return { attrs, indices };
788
+ return { attrs };
667
789
  }
668
790
 
669
791
  export function arenaBytes(arena: ArenaState): number {
670
- return arena.attrs.totalUsedBytes() + arena.indices.totalUsedElements() * 4;
792
+ return arena.attrs.totalUsedBytes();
671
793
  }
672
794
 
673
795
  /** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */