@aardworx/wombat.rendering 0.19.24 → 0.19.25

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