@aardworx/wombat.rendering 0.9.33 → 0.19.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 (107) hide show
  1. package/dist/core/renderObject.d.ts +16 -0
  2. package/dist/core/renderObject.d.ts.map +1 -1
  3. package/dist/runtime/derivedModes/kernelCodegen.d.ts +90 -0
  4. package/dist/runtime/derivedModes/kernelCodegen.d.ts.map +1 -0
  5. package/dist/runtime/derivedModes/kernelCodegen.js +623 -0
  6. package/dist/runtime/derivedModes/kernelCodegen.js.map +1 -0
  7. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  8. package/dist/runtime/derivedModes/modeKeyCpu.js +23 -67
  9. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  10. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +79 -0
  11. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -0
  12. package/dist/runtime/derivedModes/partitionDispatcher.js +303 -0
  13. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -0
  14. package/dist/runtime/derivedModes/partitionKernel.d.ts +12 -0
  15. package/dist/runtime/derivedModes/partitionKernel.d.ts.map +1 -0
  16. package/dist/runtime/derivedModes/partitionKernel.js +127 -0
  17. package/dist/runtime/derivedModes/partitionKernel.js.map +1 -0
  18. package/dist/runtime/derivedModes/partitionKernelLayout.d.ts +4 -0
  19. package/dist/runtime/derivedModes/partitionKernelLayout.d.ts.map +1 -0
  20. package/dist/runtime/derivedModes/partitionKernelLayout.js +24 -0
  21. package/dist/runtime/derivedModes/partitionKernelLayout.js.map +1 -0
  22. package/dist/runtime/derivedModes/rule.d.ts +38 -86
  23. package/dist/runtime/derivedModes/rule.d.ts.map +1 -1
  24. package/dist/runtime/derivedModes/rule.js +34 -74
  25. package/dist/runtime/derivedModes/rule.js.map +1 -1
  26. package/dist/runtime/derivedUniforms/dispatch.d.ts +10 -8
  27. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  28. package/dist/runtime/derivedUniforms/dispatch.js +31 -18
  29. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  30. package/dist/runtime/derivedUniforms/marker.d.ts +90 -11
  31. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
  32. package/dist/runtime/derivedUniforms/marker.js +161 -15
  33. package/dist/runtime/derivedUniforms/marker.js.map +1 -1
  34. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +25 -6
  35. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  36. package/dist/runtime/derivedUniforms/sceneIntegration.js +61 -20
  37. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  38. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  39. package/dist/runtime/heapAdapter.js +17 -0
  40. package/dist/runtime/heapAdapter.js.map +1 -1
  41. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  42. package/dist/runtime/heapEligibility.js +22 -2
  43. package/dist/runtime/heapEligibility.js.map +1 -1
  44. package/dist/runtime/heapScene/chunkedArena.d.ts +76 -0
  45. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -0
  46. package/dist/runtime/heapScene/chunkedArena.js +223 -0
  47. package/dist/runtime/heapScene/chunkedArena.js.map +1 -0
  48. package/dist/runtime/heapScene/freelist.d.ts +55 -0
  49. package/dist/runtime/heapScene/freelist.d.ts.map +1 -0
  50. package/dist/runtime/heapScene/freelist.js +188 -0
  51. package/dist/runtime/heapScene/freelist.js.map +1 -0
  52. package/dist/runtime/heapScene/growBuffer.d.ts +23 -2
  53. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -1
  54. package/dist/runtime/heapScene/growBuffer.js +35 -3
  55. package/dist/runtime/heapScene/growBuffer.js.map +1 -1
  56. package/dist/runtime/heapScene/pools.d.ts +84 -42
  57. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  58. package/dist/runtime/heapScene/pools.js +309 -134
  59. package/dist/runtime/heapScene/pools.js.map +1 -1
  60. package/dist/runtime/heapScene.d.ts +24 -0
  61. package/dist/runtime/heapScene.d.ts.map +1 -1
  62. package/dist/runtime/heapScene.js +1158 -92
  63. package/dist/runtime/heapScene.js.map +1 -1
  64. package/dist/runtime/hybridScene.d.ts +15 -0
  65. package/dist/runtime/hybridScene.d.ts.map +1 -1
  66. package/dist/runtime/hybridScene.js +16 -0
  67. package/dist/runtime/hybridScene.js.map +1 -1
  68. package/dist/runtime/index.d.ts +4 -3
  69. package/dist/runtime/index.d.ts.map +1 -1
  70. package/dist/runtime/index.js +4 -3
  71. package/dist/runtime/index.js.map +1 -1
  72. package/dist/runtime/renderTask.d.ts +5 -0
  73. package/dist/runtime/renderTask.d.ts.map +1 -1
  74. package/dist/runtime/renderTask.js +1 -0
  75. package/dist/runtime/renderTask.js.map +1 -1
  76. package/dist/runtime/runtime.d.ts +6 -0
  77. package/dist/runtime/runtime.d.ts.map +1 -1
  78. package/dist/runtime/runtime.js +1 -0
  79. package/dist/runtime/runtime.js.map +1 -1
  80. package/dist/runtime/textureAtlas/atlasPool.d.ts +12 -0
  81. package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
  82. package/dist/runtime/textureAtlas/atlasPool.js +20 -0
  83. package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/core/renderObject.ts +16 -0
  86. package/src/runtime/derivedModes/kernelCodegen.ts +673 -0
  87. package/src/runtime/derivedModes/modeKeyCpu.ts +22 -60
  88. package/src/runtime/derivedModes/partitionDispatcher.ts +349 -0
  89. package/src/runtime/derivedModes/partitionKernelLayout.ts +25 -0
  90. package/src/runtime/derivedModes/rule.ts +66 -145
  91. package/src/runtime/derivedUniforms/dispatch.ts +48 -21
  92. package/src/runtime/derivedUniforms/marker.ts +210 -16
  93. package/src/runtime/derivedUniforms/sceneIntegration.ts +67 -20
  94. package/src/runtime/heapAdapter.ts +17 -0
  95. package/src/runtime/heapEligibility.ts +22 -1
  96. package/src/runtime/heapScene/chunkedArena.ts +238 -0
  97. package/src/runtime/heapScene/freelist.ts +196 -0
  98. package/src/runtime/heapScene/growBuffer.ts +39 -2
  99. package/src/runtime/heapScene/pools.ts +350 -143
  100. package/src/runtime/heapScene.ts +1350 -88
  101. package/src/runtime/hybridScene.ts +31 -0
  102. package/src/runtime/index.ts +12 -9
  103. package/src/runtime/renderTask.ts +6 -0
  104. package/src/runtime/runtime.ts +7 -0
  105. package/src/runtime/textureAtlas/atlasPool.ts +19 -0
  106. package/src/runtime/derivedModes/gpuDispatcher.ts +0 -226
  107. package/src/runtime/derivedModes/gpuKernel.ts +0 -98
@@ -19,7 +19,9 @@ import { AVal } from "@aardworx/wombat.adaptive";
19
19
  import type { aval, IDisposable } from "@aardworx/wombat.adaptive";
20
20
  import type { BufferView } from "../../core/bufferView.js";
21
21
  import type { HostBufferSource } from "../../core/buffer.js";
22
- import { GrowBuffer, ALIGN16 } from "./growBuffer.js";
22
+ import { GrowBuffer, ALIGN16, DEFAULT_MAX_BUFFER_BYTES } from "./growBuffer.js";
23
+ import { Freelist } from "./freelist.js";
24
+ import { ChunkedAttributeArena, ChunkedIndexAllocator } from "./chunkedArena.js";
23
25
 
24
26
  // ─── Per-allocation header layout ──────────────────────────────────────
25
27
 
@@ -39,100 +41,171 @@ export const SEM_NORMALS = 2;
39
41
 
40
42
  // ─── UniformPool ───────────────────────────────────────────────────────
41
43
 
44
+ /**
45
+ * One pool entry tracks a single (aval, chunkIdx) pair. Refs are
46
+ * byte offsets within the chunk's GPUBuffer; chunkIdx is implicit
47
+ * in which chunk's bind group is active at draw time (shaders
48
+ * never decode it).
49
+ */
42
50
  interface PoolEntry {
43
- /** Byte offset into the arena. Data starts at ref + ALLOC_HEADER_PAD_TO. */
51
+ readonly chunkIdx: number;
44
52
  readonly ref: number;
45
53
  readonly dataBytes: number;
46
54
  readonly typeId: number;
47
- /** Packer used to refresh the data region on aval marks. */
48
55
  readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
49
56
  refcount: number;
50
57
  }
51
58
 
52
59
  /**
53
- * Aval-keyed pool of arena allocations. One allocation per unique
54
- * aval (object identity). Two draws referencing the same aval share
55
- * the allocation; their DrawHeaders carry the same u32 ref. Holds
56
- * uniforms (fixed-size scalars/vectors/matrices) AND attribute arrays
57
- * (variable-size). The caller decides `dataBytes` + `length` per
58
- * acquisition the pool just keys on aval identity and refcounts.
59
- *
60
- * Sharing emerges from aval identity — no separate "frequency"
61
- * declaration needed. A `cval` shared by all draws → 1 alloc.
62
- * A static positions array shared across instanced draws → 1 alloc.
63
- * Same code path either way.
60
+ * `(aval, chunkIdx)`-keyed pool of chunked-arena allocations. An
61
+ * aval that's referenced from multiple chunks (e.g. two ROs sharing
62
+ * a uniform but landing in different chunks) gets one entry per
63
+ * chunk accepted duplication cost of the multi-draw-call
64
+ * per-chunk render path (§3). Within a single chunk, ROs sharing
65
+ * an aval still share the underlying allocation.
64
66
  */
65
67
  export class UniformPool {
66
- // Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
67
- // keys are overwhelmingly reactive `cval`s (per-object trafos,
68
- // colours, …) and the hot path is `acquire`/`release` ~once per
69
- // drawHeader field per RO.
70
- private readonly byAval = new Map<aval<unknown>, PoolEntry>();
68
+ private readonly byAval = new Map<aval<unknown>, Map<number, PoolEntry>>();
71
69
 
72
- has(av: aval<unknown>): boolean { return this.byAval.has(av); }
73
- entry(av: aval<unknown>): PoolEntry | undefined { return this.byAval.get(av); }
70
+ has(av: aval<unknown>, chunkIdx: number): boolean {
71
+ return this.byAval.get(av)?.has(chunkIdx) ?? false;
72
+ }
73
+ /** True when the pool holds at least one entry for `av` in any
74
+ * chunk. Used by the scene's `inputChanged` dispatch to decide
75
+ * whether a marking aval is one of our tracked allocations. */
76
+ hasAny(av: aval<unknown>): boolean {
77
+ return (this.byAval.get(av)?.size ?? 0) > 0;
78
+ }
79
+ entry(av: aval<unknown>, chunkIdx: number): PoolEntry | undefined {
80
+ return this.byAval.get(av)?.get(chunkIdx);
81
+ }
82
+ /** First chunk this aval is allocated in, if any. Used by `addRO`
83
+ * to prefer co-locating a new RO with its already-allocated
84
+ * shared inputs (avoids unnecessary duplication). */
85
+ firstChunkContaining(av: aval<unknown>): number | undefined {
86
+ const byChunk = this.byAval.get(av);
87
+ if (byChunk === undefined || byChunk.size === 0) return undefined;
88
+ return byChunk.keys().next().value as number;
89
+ }
74
90
 
75
91
  /**
76
- * Acquire (or share) an allocation for `aval`. Caller passes the
77
- * pre-read `value` (so the pool doesn't need a token) plus the
78
- * (`dataBytes`, `typeId`, `length`, `pack`) describing how to lay
79
- * it out. If a new allocation is made, the value is packed and
80
- * uploaded immediately.
92
+ * Acquire (or share) an allocation for `aval` in `chunkIdx`.
93
+ * Returns the byte offset within that chunk's GPUBuffer. If a
94
+ * fresh allocation is made, the value is packed + uploaded
95
+ * immediately into the chunk's CPU shadow.
81
96
  */
82
97
  acquire(
83
98
  device: GPUDevice,
84
- arena: AttributeArena,
99
+ arena: ChunkedAttributeArena,
85
100
  av: aval<unknown>,
101
+ chunkIdx: number,
86
102
  value: unknown,
87
103
  dataBytes: number,
88
104
  typeId: number,
89
105
  length: number,
90
106
  pack: (val: unknown, dst: Float32Array, off: number) => void,
91
107
  ): number {
92
- const existing = this.byAval.get(av);
108
+ let byChunk = this.byAval.get(av);
109
+ const existing = byChunk?.get(chunkIdx);
93
110
  if (existing !== undefined) {
94
111
  existing.refcount++;
95
112
  return existing.ref;
96
113
  }
97
- const ref = arena.alloc(dataBytes);
114
+ const r = arena.alloc(dataBytes, chunkIdx);
115
+ // `arena.alloc` may spill to a different chunk if `chunkIdx`'s
116
+ // GrowBuffer is at its maxCapacity. Callers (heapScene.addRO)
117
+ // bind a bucket's bind groups to a single chunk's buffer — a
118
+ // silent spill makes the bucket's drawHeader refs point into
119
+ // the wrong chunk's buffer (garbage reads / typeId corruption).
120
+ //
121
+ // Hard-fail with diagnostic info instead of returning quietly.
122
+ // The right long-term fix is for addRO to pre-probe the chunk,
123
+ // open a new bucket bound to the spill chunk, and re-route; but
124
+ // until that lands, a clear error beats unexplained artefacts.
125
+ if (r.chunkIdx !== chunkIdx) {
126
+ throw new Error(
127
+ `UniformPool.acquire: allocator spilled from chunk ${chunkIdx} to chunk ${r.chunkIdx} ` +
128
+ `(${dataBytes} bytes). Caller's bucket is bound to chunk ${chunkIdx}; this would silently ` +
129
+ `corrupt the drawHeader→arena reads. Open a new bucket bound to chunk ${r.chunkIdx} instead.`,
130
+ );
131
+ }
132
+ const finalChunk = r.chunkIdx;
98
133
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
99
134
  const buf = new ArrayBuffer(allocBytes);
100
135
  const u32 = new Uint32Array(buf);
101
136
  const f32 = new Float32Array(buf);
102
137
  u32[0] = typeId;
103
138
  u32[1] = length;
104
- // stride_bytes (offset 8): bytes per element. Lets the VS decode
105
- // pick V3- vs V4-tight load expressions for vec4 attributes.
106
139
  u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
107
140
  pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
108
- arena.write(ref, new Uint8Array(buf));
141
+ arena.write(finalChunk, r.off, new Uint8Array(buf));
109
142
  void device;
110
- this.byAval.set(av, { ref, dataBytes, typeId, pack, refcount: 1 });
111
- return ref;
143
+ if (byChunk === undefined) {
144
+ byChunk = new Map();
145
+ this.byAval.set(av, byChunk);
146
+ }
147
+ byChunk.set(finalChunk, {
148
+ chunkIdx: finalChunk, ref: r.off, dataBytes, typeId, pack, refcount: 1,
149
+ });
150
+ return r.off;
112
151
  }
113
152
 
114
- /** Decrement refcount; if zero, free the arena allocation. */
115
- release(arena: AttributeArena, av: aval<unknown>): void {
116
- const e = this.byAval.get(av);
153
+ release(arena: ChunkedAttributeArena, av: aval<unknown>, chunkIdx: number): void {
154
+ const byChunk = this.byAval.get(av);
155
+ const e = byChunk?.get(chunkIdx);
117
156
  if (e === undefined) return;
118
157
  e.refcount--;
119
158
  if (e.refcount > 0) return;
120
- arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
121
- this.byAval.delete(av);
159
+ // BUG FIX: arena.release's third arg is `dataBytes` (raw), and the
160
+ // chunk's AttributeArena.release internally adds the header padding
161
+ // via `ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes)`. We MUST pass the
162
+ // raw data size here — passing pre-aligned `allocBytes` (the old
163
+ // code) double-pads, freeing 16 extra bytes per release. The freed
164
+ // surplus then gets coalesced with the neighbouring scalar-uniform
165
+ // allocs (any 32 B u32/f32) into a single combined block, and the
166
+ // next attribute alloc handed out from that pool ends up STARTING
167
+ // 16 B inside the previous alloc → overlapping allocations →
168
+ // garbage `typeId`/`length` in the attribute header.
169
+ arena.release(e.chunkIdx, e.ref, e.dataBytes);
170
+ byChunk!.delete(chunkIdx);
171
+ if (byChunk!.size === 0) this.byAval.delete(av);
122
172
  }
123
173
 
124
- /** Re-pack one entry's data region into the arena's CPU shadow. */
125
- repack(device: GPUDevice, arena: AttributeArena, av: aval<unknown>, val: unknown): void {
126
- const e = this.byAval.get(av);
127
- if (e === undefined) return;
128
- const dst = new Float32Array(e.dataBytes / 4);
129
- e.pack(val, dst, 0);
130
- arena.write(
131
- e.ref + ALLOC_HEADER_PAD_TO,
132
- new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes),
133
- );
174
+ /** Re-pack one entry's data region into every chunk that holds
175
+ * this aval. When an aval is shared across N chunks (each chunk
176
+ * has its own duplicate alloc), every duplicate needs the
177
+ * refresh so all the chunks' ROs see the new value. */
178
+ repack(
179
+ device: GPUDevice,
180
+ arena: ChunkedAttributeArena,
181
+ av: aval<unknown>,
182
+ val: unknown,
183
+ ): void {
184
+ const byChunk = this.byAval.get(av);
185
+ if (byChunk === undefined) return;
186
+ let dst: Float32Array | undefined;
187
+ for (const e of byChunk.values()) {
188
+ if (dst === undefined || dst.length !== e.dataBytes / 4) {
189
+ dst = new Float32Array(e.dataBytes / 4);
190
+ }
191
+ e.pack(val, dst, 0);
192
+ arena.write(
193
+ e.chunkIdx,
194
+ e.ref + ALLOC_HEADER_PAD_TO,
195
+ new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes),
196
+ );
197
+ }
134
198
  void device;
135
199
  }
200
+ /** Total bytes touched by `repack` for this aval — sum across
201
+ * chunks. Used by the dirty-bytes diagnostics. */
202
+ totalDataBytes(av: aval<unknown>): number {
203
+ const byChunk = this.byAval.get(av);
204
+ if (byChunk === undefined) return 0;
205
+ let s = 0;
206
+ for (const e of byChunk.values()) s += e.dataBytes;
207
+ return s;
208
+ }
136
209
  }
137
210
 
138
211
  // ─── IndexPool ─────────────────────────────────────────────────────────
@@ -150,10 +223,13 @@ export class UniformPool {
150
223
  * `Uint32Array` view collapse to one allocation.
151
224
  */
152
225
  export class IndexPool {
226
+ /** Per-aval, per-chunk bookkeeping. Same aval acquired in two
227
+ * chunks gets two entries (accepted §3 duplication). */
153
228
  private readonly byAval = new Map<
154
229
  aval<Uint32Array>,
155
- { entry: IndexPoolEntry; perAvalCount: number }
230
+ Map<number, { entry: IndexPoolEntry; perAvalCount: number }>
156
231
  >();
232
+ /** Per-chunk constant-aval value-dedup (§5b in-chunk). */
157
233
  private readonly byValueKey = new Map<string, IndexPoolEntry>();
158
234
  private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
159
235
  private nextBufferId = 1;
@@ -166,48 +242,75 @@ export class IndexPool {
166
242
  return id;
167
243
  }
168
244
 
245
+ firstChunkContaining(av: aval<Uint32Array>): number | undefined {
246
+ const byChunk = this.byAval.get(av);
247
+ if (byChunk === undefined || byChunk.size === 0) return undefined;
248
+ return byChunk.keys().next().value as number;
249
+ }
250
+
169
251
  acquire(
170
252
  device: GPUDevice,
171
- indices: IndexAllocator,
253
+ indices: ChunkedIndexAllocator,
172
254
  av: aval<Uint32Array>,
255
+ chunkIdx: number,
173
256
  arr: Uint32Array,
174
- ): { firstIndex: number; count: number } {
175
- const bound = this.byAval.get(av);
257
+ ): { chunkIdx: number; firstIndex: number; count: number } {
258
+ let byChunk = this.byAval.get(av);
259
+ const bound = byChunk?.get(chunkIdx);
176
260
  if (bound !== undefined) {
177
261
  bound.perAvalCount++;
178
262
  bound.entry.totalRefcount++;
179
- return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
263
+ return { chunkIdx: bound.entry.chunkIdx, firstIndex: bound.entry.firstIndex, count: bound.entry.count };
180
264
  }
181
265
  let valueKey: string | undefined;
182
266
  if (av.isConstant) {
183
- valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
267
+ // Value-dedup is scoped per chunk — two constant avals in the
268
+ // same chunk collapse to one allocation; two constant avals in
269
+ // DIFFERENT chunks each get their own (we can't share across
270
+ // chunks since the index range lives in the chunk's GPUBuffer).
271
+ valueKey = `${chunkIdx}:${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
184
272
  const shared = this.byValueKey.get(valueKey);
185
273
  if (shared !== undefined) {
186
274
  shared.totalRefcount++;
187
- this.byAval.set(av, { entry: shared, perAvalCount: 1 });
188
- return { firstIndex: shared.firstIndex, count: shared.count };
275
+ if (byChunk === undefined) {
276
+ byChunk = new Map();
277
+ this.byAval.set(av, byChunk);
278
+ }
279
+ byChunk.set(chunkIdx, { entry: shared, perAvalCount: 1 });
280
+ return { chunkIdx: shared.chunkIdx, firstIndex: shared.firstIndex, count: shared.count };
189
281
  }
190
282
  }
191
- const firstIndex = indices.alloc(arr.length);
283
+ const r = indices.alloc(arr.length, chunkIdx);
192
284
  indices.write(
193
- firstIndex * 4,
285
+ r.chunkIdx, r.off * 4,
194
286
  new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength),
195
287
  );
196
288
  void device;
197
- const entry: IndexPoolEntry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
198
- this.byAval.set(av, { entry, perAvalCount: 1 });
289
+ const entry: IndexPoolEntry = {
290
+ chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length,
291
+ totalRefcount: 1, valueKey,
292
+ };
293
+ if (byChunk === undefined) {
294
+ byChunk = new Map();
295
+ this.byAval.set(av, byChunk);
296
+ }
297
+ byChunk.set(r.chunkIdx, { entry, perAvalCount: 1 });
199
298
  if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
200
- return { firstIndex, count: arr.length };
299
+ return { chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length };
201
300
  }
202
301
 
203
- release(indices: IndexAllocator, av: aval<Uint32Array>): void {
204
- const bound = this.byAval.get(av);
302
+ release(indices: ChunkedIndexAllocator, av: aval<Uint32Array>, chunkIdx: number): void {
303
+ const byChunk = this.byAval.get(av);
304
+ const bound = byChunk?.get(chunkIdx);
205
305
  if (bound === undefined) return;
206
306
  bound.perAvalCount--;
207
307
  bound.entry.totalRefcount--;
208
- if (bound.perAvalCount === 0) this.byAval.delete(av);
308
+ if (bound.perAvalCount === 0) {
309
+ byChunk!.delete(chunkIdx);
310
+ if (byChunk!.size === 0) this.byAval.delete(av);
311
+ }
209
312
  if (bound.entry.totalRefcount > 0) return;
210
- indices.release(bound.entry.firstIndex, bound.entry.count);
313
+ indices.release(bound.entry.chunkIdx, bound.entry.firstIndex, bound.entry.count);
211
314
  if (bound.entry.valueKey !== undefined) {
212
315
  this.byValueKey.delete(bound.entry.valueKey);
213
316
  }
@@ -215,6 +318,7 @@ export class IndexPool {
215
318
  }
216
319
 
217
320
  interface IndexPoolEntry {
321
+ chunkIdx: number;
218
322
  firstIndex: number;
219
323
  count: number;
220
324
  totalRefcount: number;
@@ -232,6 +336,8 @@ interface IndexPoolEntry {
232
336
  export class DrawHeap {
233
337
  private free: number[] = [];
234
338
  private nextSlot = 0;
339
+ /** DEBUG: tracks slot live/free state. true = live (in use). */
340
+ private readonly live = new Set<number>();
235
341
  constructor(private readonly buf: GrowBuffer, private readonly slotBytes: number) {}
236
342
  get buffer(): GPUBuffer { return this.buf.buffer; }
237
343
  /** Bytes per slot — caller multiplies by slot index for byte offsets. */
@@ -240,11 +346,21 @@ export class DrawHeap {
240
346
  get usedBytes(): number { return this.nextSlot * this.slotBytes; }
241
347
  alloc(): number {
242
348
  const slot = this.free.length > 0 ? this.free.pop()! : this.nextSlot++;
349
+ if (this.live.has(slot)) {
350
+ throw new Error(`DrawHeap.alloc: returned already-live slot ${slot}`);
351
+ }
352
+ this.live.add(slot);
243
353
  this.buf.ensureCapacity((slot + 1) * this.slotBytes);
244
354
  this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
245
355
  return slot;
246
356
  }
247
- release(slot: number): void { this.free.push(slot); }
357
+ release(slot: number): void {
358
+ if (!this.live.has(slot)) {
359
+ throw new Error(`DrawHeap.release: slot ${slot} was not live — double-release or release-of-never-allocated.`);
360
+ }
361
+ this.live.delete(slot);
362
+ this.free.push(slot);
363
+ }
248
364
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
249
365
  destroy(): void { this.buf.destroy(); }
250
366
  }
@@ -259,7 +375,12 @@ export class DrawHeap {
259
375
  */
260
376
  export class AttributeArena {
261
377
  private cursor = 0;
262
- private freeList: { off: number; size: number }[] = [];
378
+ private readonly freelist = new Freelist();
379
+ /** DEBUG: tracks every live allocation by start byte → size. Set
380
+ * on alloc, cleared on release. Used by `assertNoOverlap()` to
381
+ * detect allocator returning overlapping ranges. Enabled in
382
+ * development; cheap (one Map insert/delete per alloc). */
383
+ private readonly liveAllocs = new Map<number, number>();
263
384
  /** CPU shadow of the entire GPU buffer; writes go here first then
264
385
  * flush() emits one writeBuffer per dirty contiguous range. */
265
386
  private shadow: Uint8Array;
@@ -276,6 +397,15 @@ export class AttributeArena {
276
397
  get buffer(): GPUBuffer { return this.buf.buffer; }
277
398
  get capacity(): number { return this.buf.capacity; }
278
399
  get usedBytes(): number { return this.cursor; }
400
+ /** Read 4 u32s from the CPU shadow starting at byte `off`. Used by
401
+ * the validator to compare against the GPU's read-back values:
402
+ * if CPU shadow is correct but GPU shows garbage → the corruption
403
+ * came from a GPU-side write (a compute kernel writing to a wrong
404
+ * byte). If both differ from what pool.acquire wrote → CPU-side
405
+ * write path is the culprit. */
406
+ peekShadowU32(off: number, count: number): Uint32Array {
407
+ return new Uint32Array(this.shadow.buffer, this.shadow.byteOffset + off, count);
408
+ }
279
409
  write(dst: number, data: Uint8Array): void {
280
410
  this.shadow.set(data, dst);
281
411
  if (dst < this.dirtyMin) this.dirtyMin = dst;
@@ -296,64 +426,84 @@ export class AttributeArena {
296
426
  * Allocate space for one attribute. Returns the byte ref (offset
297
427
  * to the header — data lives at ref + 16).
298
428
  */
299
- alloc(dataBytes: number): number {
429
+ /** Non-throwing alloc returns `undefined` when the request
430
+ * would exceed the underlying GrowBuffer's `maxCapacity` cap.
431
+ * Used by `ChunkedAttributeArena` to probe before spilling to
432
+ * the next chunk. */
433
+ tryAlloc(dataBytes: number): number | undefined {
300
434
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
301
- // First-fit reuse from free list.
302
- for (let i = 0; i < this.freeList.length; i++) {
303
- const f = this.freeList[i]!;
304
- if (f.size >= allocBytes) {
305
- const ref = f.off;
306
- if (f.size === allocBytes) this.freeList.splice(i, 1);
307
- else { f.off += allocBytes; f.size -= allocBytes; }
308
- return ref;
309
- }
435
+ const reused = this.freelist.alloc(allocBytes);
436
+ if (reused !== undefined) {
437
+ this.recordAlloc(reused, allocBytes, "freelist");
438
+ return reused;
310
439
  }
311
- const ref = this.cursor;
312
- this.cursor += allocBytes;
313
- this.buf.ensureCapacity(this.cursor);
314
- this.buf.setUsed(this.cursor);
440
+ const next = this.cursor + allocBytes;
441
+ if (next > this.buf.maxCapacity) return undefined;
442
+ this.cursor = next;
443
+ this.buf.ensureCapacity(next);
444
+ this.buf.setUsed(next);
445
+ const ref = next - allocBytes;
446
+ this.recordAlloc(ref, allocBytes, "bump");
315
447
  return ref;
316
448
  }
449
+ alloc(dataBytes: number): number {
450
+ const r = this.tryAlloc(dataBytes);
451
+ if (r === undefined) {
452
+ throw new Error(
453
+ `AttributeArena: allocation of ${dataBytes}B (with header alignment) exceeds chunk cap`,
454
+ );
455
+ }
456
+ return r;
457
+ }
317
458
  release(ref: number, dataBytes: number): void {
318
459
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
319
- insertSortedFreeBlock(this.freeList, ref, allocBytes);
320
- }
321
- onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
322
- destroy(): void { this.buf.destroy(); }
323
- }
324
-
325
- /**
326
- * Insert `{off, size}` into a free-list kept sorted by `off`, then
327
- * coalesce with the two immediate neighbours.
328
- */
329
- export function insertSortedFreeBlock(
330
- freeList: { off: number; size: number }[],
331
- off: number,
332
- size: number,
333
- ): void {
334
- let lo = 0, hi = freeList.length;
335
- while (lo < hi) {
336
- const mid = (lo + hi) >>> 1;
337
- if (freeList[mid]!.off <= off) lo = mid + 1;
338
- else hi = mid;
339
- }
340
- const prev = lo > 0 ? freeList[lo - 1] : undefined;
341
- if (prev !== undefined && prev.off + prev.size === off) {
342
- prev.size += size;
343
- const next = freeList[lo];
344
- if (next !== undefined && prev.off + prev.size === next.off) {
345
- prev.size += next.size;
346
- freeList.splice(lo, 1);
460
+ const tracked = this.liveAllocs.get(ref);
461
+ if (tracked === undefined) {
462
+ throw new Error(
463
+ `AttributeArena.release: ref=${ref} (size=${allocBytes}) was not in liveAllocs — ` +
464
+ `double-free or release for an alloc that never happened. ` +
465
+ `Live alloc count=${this.liveAllocs.size}.`,
466
+ );
467
+ }
468
+ if (tracked !== allocBytes) {
469
+ throw new Error(
470
+ `AttributeArena.release: size mismatch at ref=${ref} — recorded=${tracked} but ` +
471
+ `release args→ allocBytes=${allocBytes} (dataBytes=${dataBytes}). ` +
472
+ `Caller is releasing the wrong size; this WILL corrupt the freelist via over- or under-shrinking.`,
473
+ );
474
+ }
475
+ this.liveAllocs.delete(ref);
476
+ this.freelist.release(ref, allocBytes);
477
+ // §5: shrink the bump cursor back if release exposed a free
478
+ // tail touching it (cascading `freelist.release` may have
479
+ // coalesced into a single block ending at `cursor`). Reclaims
480
+ // the high-watermark over time in long-lived high-churn scenes.
481
+ while (true) {
482
+ const top = this.freelist.takeBlockEndingAt(this.cursor);
483
+ if (top === undefined) break;
484
+ this.cursor = top.off;
485
+ this.buf.setUsed(this.cursor);
347
486
  }
348
- return;
349
487
  }
350
- const next = freeList[lo];
351
- if (next !== undefined && off + size === next.off) {
352
- next.off = off;
353
- next.size += size;
354
- return;
488
+ /** Throws if the just-returned alloc overlaps any live allocation. */
489
+ private recordAlloc(off: number, size: number, source: string): void {
490
+ // Check against every existing live alloc — O(N). Cheap while N
491
+ // is small (per-tile demos run with ~hundreds of allocs). Wire
492
+ // off behind an env-flag if it ever becomes a hot path.
493
+ for (const [liveOff, liveSize] of this.liveAllocs) {
494
+ // [a, a+sa) vs [b, b+sb) overlap iff a < b+sb && b < a+sa
495
+ if (off < liveOff + liveSize && liveOff < off + size) {
496
+ throw new Error(
497
+ `AttributeArena.tryAlloc(${source}): returned [${off},${off + size}) ` +
498
+ `(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
499
+ `(size=${liveSize}). Allocator is handing out shared memory!`,
500
+ );
501
+ }
502
+ }
503
+ this.liveAllocs.set(off, size);
355
504
  }
356
- freeList.splice(lo, 0, { off, size });
505
+ onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
506
+ destroy(): void { this.buf.destroy(); }
357
507
  }
358
508
 
359
509
  // ─── IndexAllocator (element-bump) ─────────────────────────────────────
@@ -361,11 +511,13 @@ export function insertSortedFreeBlock(
361
511
  /**
362
512
  * Element-bump allocator over an index GrowBuffer (units = u32). Each
363
513
  * draw's index range is allocated as one block; on release the block
364
- * is returned to a free list and can be reused first-fit.
514
+ * is returned to a Freelist and can be reused best-fit.
365
515
  */
366
516
  export class IndexAllocator {
367
517
  private cursor = 0; // in u32s, not bytes
368
- private freeList: { off: number; size: number }[] = [];
518
+ private readonly freelist = new Freelist();
519
+ /** DEBUG: live index allocations tracked by start element. */
520
+ private readonly liveAllocs = new Map<number, number>();
369
521
  private shadow: Uint8Array;
370
522
  private dirtyMin = Infinity;
371
523
  private dirtyMax = 0;
@@ -395,24 +547,66 @@ export class IndexAllocator {
395
547
  this.dirtyMin = Infinity;
396
548
  this.dirtyMax = 0;
397
549
  }
398
- alloc(elements: number): number {
399
- for (let i = 0; i < this.freeList.length; i++) {
400
- const f = this.freeList[i]!;
401
- if (f.size >= elements) {
402
- const off = f.off;
403
- if (f.size === elements) this.freeList.splice(i, 1);
404
- else { f.off += elements; f.size -= elements; }
405
- return off;
406
- }
550
+ /** Non-throwing alloc returns `undefined` when the request
551
+ * would exceed the underlying GrowBuffer's `maxCapacity` cap.
552
+ * Used by `ChunkedIndexAllocator` to probe before spilling. */
553
+ tryAlloc(elements: number): number | undefined {
554
+ const reused = this.freelist.alloc(elements);
555
+ if (reused !== undefined) {
556
+ this.recordAlloc(reused, elements, "freelist");
557
+ return reused;
407
558
  }
408
- const off = this.cursor;
409
- this.cursor += elements;
410
- this.buf.ensureCapacity(this.cursor * 4);
411
- this.buf.setUsed(this.cursor * 4);
559
+ const nextElts = this.cursor + elements;
560
+ if (nextElts * 4 > this.buf.maxCapacity) return undefined;
561
+ this.cursor = nextElts;
562
+ this.buf.ensureCapacity(nextElts * 4);
563
+ this.buf.setUsed(nextElts * 4);
564
+ const off = nextElts - elements;
565
+ this.recordAlloc(off, elements, "bump");
412
566
  return off;
413
567
  }
568
+ private recordAlloc(off: number, size: number, source: string): void {
569
+ for (const [liveOff, liveSize] of this.liveAllocs) {
570
+ if (off < liveOff + liveSize && liveOff < off + size) {
571
+ throw new Error(
572
+ `IndexAllocator.tryAlloc(${source}): returned [${off},${off + size}) ` +
573
+ `(size=${size}) overlaps live alloc at [${liveOff},${liveOff + liveSize}) ` +
574
+ `(size=${liveSize}). Index allocator is handing out shared memory!`,
575
+ );
576
+ }
577
+ }
578
+ this.liveAllocs.set(off, size);
579
+ }
580
+ alloc(elements: number): number {
581
+ const r = this.tryAlloc(elements);
582
+ if (r === undefined) {
583
+ throw new Error(`IndexAllocator: allocation of ${elements} elements exceeds chunk cap`);
584
+ }
585
+ return r;
586
+ }
414
587
  release(off: number, elements: number): void {
415
- insertSortedFreeBlock(this.freeList, off, elements);
588
+ const tracked = this.liveAllocs.get(off);
589
+ if (tracked === undefined) {
590
+ throw new Error(
591
+ `IndexAllocator.release: off=${off} (elements=${elements}) was not in ` +
592
+ `liveAllocs — double-free or release for an alloc that never happened. ` +
593
+ `Live alloc count=${this.liveAllocs.size}.`,
594
+ );
595
+ }
596
+ if (tracked !== elements) {
597
+ throw new Error(
598
+ `IndexAllocator.release: size mismatch at off=${off} — recorded=${tracked} ` +
599
+ `but release elements=${elements}. Will corrupt the freelist.`,
600
+ );
601
+ }
602
+ this.liveAllocs.delete(off);
603
+ this.freelist.release(off, elements);
604
+ while (true) {
605
+ const top = this.freelist.takeBlockEndingAt(this.cursor);
606
+ if (top === undefined) break;
607
+ this.cursor = top.off;
608
+ this.buf.setUsed(this.cursor * 4);
609
+ }
416
610
  }
417
611
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
418
612
  destroy(): void { this.buf.destroy(); }
@@ -423,11 +617,15 @@ export class IndexAllocator {
423
617
  /**
424
618
  * Global arena state: attribute / uniform data lives in `attrs`
425
619
  * (multi-typed-view storage); indices live in `indices` (separate
426
- * INDEX-usage buffer).
620
+ * INDEX-usage buffer). Both are chunked: each chunk owns a separate
621
+ * GPUBuffer + Freelist. Refs that downstream code stores are byte
622
+ * offsets within a chunk; the chunk identity is carried alongside
623
+ * (via bucket.chunkIdx in heapScene) and made implicit at draw
624
+ * time by which chunk's bind group is active.
427
625
  */
428
626
  export interface ArenaState {
429
- readonly attrs: AttributeArena;
430
- readonly indices: IndexAllocator;
627
+ readonly attrs: ChunkedAttributeArena;
628
+ readonly indices: ChunkedIndexAllocator;
431
629
  }
432
630
 
433
631
  export function buildArenaState(
@@ -436,20 +634,29 @@ export function buildArenaState(
436
634
  idxBytesHint: number,
437
635
  label: string,
438
636
  idxExtraUsage: GPUBufferUsageFlags = 0,
637
+ maxChunkBytes: number | undefined = undefined,
439
638
  ): ArenaState {
440
- const attrs = new AttributeArena(new GrowBuffer(
639
+ // §3: cap chunk size at min(adapter's maxStorageBufferBindingSize,
640
+ // GrowBuffer's DEFAULT_MAX_BUFFER_BYTES). Lets chunks grow as far
641
+ // as the hardware lets us (typically 2 GiB+ on desktop, ≥ 256 MB
642
+ // on mobile/integrated) while keeping a sensible internal ceiling.
643
+ // Caller can override (the heap-demo uses a tiny 4 MB cap to
644
+ // exercise multi-chunk routing).
645
+ const adapterCap = device.limits.maxStorageBufferBindingSize;
646
+ const cap = maxChunkBytes ?? Math.min(adapterCap, DEFAULT_MAX_BUFFER_BYTES);
647
+ const attrs = new ChunkedAttributeArena(
441
648
  device, `${label}/attrs`, GPUBufferUsage.STORAGE,
442
- attrBytesHint,
443
- ));
444
- const indices = new IndexAllocator(new GrowBuffer(
649
+ attrBytesHint, cap,
650
+ );
651
+ const indices = new ChunkedIndexAllocator(
445
652
  device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
446
- idxBytesHint,
447
- ));
653
+ idxBytesHint, cap,
654
+ );
448
655
  return { attrs, indices };
449
656
  }
450
657
 
451
658
  export function arenaBytes(arena: ArenaState): number {
452
- return arena.attrs.usedBytes + arena.indices.usedElements * 4;
659
+ return arena.attrs.totalUsedBytes() + arena.indices.totalUsedElements() * 4;
453
660
  }
454
661
 
455
662
  /** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */