@aardworx/wombat.rendering 0.9.33 → 0.18.1

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 (95) hide show
  1. package/dist/runtime/derivedModes/kernelCodegen.d.ts +90 -0
  2. package/dist/runtime/derivedModes/kernelCodegen.d.ts.map +1 -0
  3. package/dist/runtime/derivedModes/kernelCodegen.js +623 -0
  4. package/dist/runtime/derivedModes/kernelCodegen.js.map +1 -0
  5. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  6. package/dist/runtime/derivedModes/modeKeyCpu.js +23 -67
  7. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  8. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +79 -0
  9. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -0
  10. package/dist/runtime/derivedModes/partitionDispatcher.js +303 -0
  11. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -0
  12. package/dist/runtime/derivedModes/partitionKernel.d.ts +12 -0
  13. package/dist/runtime/derivedModes/partitionKernel.d.ts.map +1 -0
  14. package/dist/runtime/derivedModes/partitionKernel.js +127 -0
  15. package/dist/runtime/derivedModes/partitionKernel.js.map +1 -0
  16. package/dist/runtime/derivedModes/partitionKernelLayout.d.ts +4 -0
  17. package/dist/runtime/derivedModes/partitionKernelLayout.d.ts.map +1 -0
  18. package/dist/runtime/derivedModes/partitionKernelLayout.js +24 -0
  19. package/dist/runtime/derivedModes/partitionKernelLayout.js.map +1 -0
  20. package/dist/runtime/derivedModes/rule.d.ts +38 -86
  21. package/dist/runtime/derivedModes/rule.d.ts.map +1 -1
  22. package/dist/runtime/derivedModes/rule.js +34 -74
  23. package/dist/runtime/derivedModes/rule.js.map +1 -1
  24. package/dist/runtime/derivedUniforms/dispatch.d.ts +10 -8
  25. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  26. package/dist/runtime/derivedUniforms/dispatch.js +31 -18
  27. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  28. package/dist/runtime/derivedUniforms/marker.d.ts +90 -11
  29. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
  30. package/dist/runtime/derivedUniforms/marker.js +161 -15
  31. package/dist/runtime/derivedUniforms/marker.js.map +1 -1
  32. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +25 -6
  33. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  34. package/dist/runtime/derivedUniforms/sceneIntegration.js +61 -20
  35. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  36. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  37. package/dist/runtime/heapEligibility.js +22 -2
  38. package/dist/runtime/heapEligibility.js.map +1 -1
  39. package/dist/runtime/heapScene/chunkedArena.d.ts +76 -0
  40. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -0
  41. package/dist/runtime/heapScene/chunkedArena.js +223 -0
  42. package/dist/runtime/heapScene/chunkedArena.js.map +1 -0
  43. package/dist/runtime/heapScene/freelist.d.ts +50 -0
  44. package/dist/runtime/heapScene/freelist.d.ts.map +1 -0
  45. package/dist/runtime/heapScene/freelist.js +160 -0
  46. package/dist/runtime/heapScene/freelist.js.map +1 -0
  47. package/dist/runtime/heapScene/growBuffer.d.ts +23 -2
  48. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -1
  49. package/dist/runtime/heapScene/growBuffer.js +35 -3
  50. package/dist/runtime/heapScene/growBuffer.js.map +1 -1
  51. package/dist/runtime/heapScene/pools.d.ts +65 -42
  52. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  53. package/dist/runtime/heapScene/pools.js +206 -134
  54. package/dist/runtime/heapScene/pools.js.map +1 -1
  55. package/dist/runtime/heapScene.d.ts +12 -0
  56. package/dist/runtime/heapScene.d.ts.map +1 -1
  57. package/dist/runtime/heapScene.js +938 -85
  58. package/dist/runtime/heapScene.js.map +1 -1
  59. package/dist/runtime/hybridScene.d.ts +7 -0
  60. package/dist/runtime/hybridScene.d.ts.map +1 -1
  61. package/dist/runtime/hybridScene.js +1 -0
  62. package/dist/runtime/hybridScene.js.map +1 -1
  63. package/dist/runtime/index.d.ts +4 -3
  64. package/dist/runtime/index.d.ts.map +1 -1
  65. package/dist/runtime/index.js +4 -3
  66. package/dist/runtime/index.js.map +1 -1
  67. package/dist/runtime/renderTask.d.ts +5 -0
  68. package/dist/runtime/renderTask.d.ts.map +1 -1
  69. package/dist/runtime/renderTask.js +1 -0
  70. package/dist/runtime/renderTask.js.map +1 -1
  71. package/dist/runtime/runtime.d.ts +6 -0
  72. package/dist/runtime/runtime.d.ts.map +1 -1
  73. package/dist/runtime/runtime.js +1 -0
  74. package/dist/runtime/runtime.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/runtime/derivedModes/kernelCodegen.ts +673 -0
  77. package/src/runtime/derivedModes/modeKeyCpu.ts +22 -60
  78. package/src/runtime/derivedModes/partitionDispatcher.ts +349 -0
  79. package/src/runtime/derivedModes/partitionKernelLayout.ts +25 -0
  80. package/src/runtime/derivedModes/rule.ts +66 -145
  81. package/src/runtime/derivedUniforms/dispatch.ts +48 -21
  82. package/src/runtime/derivedUniforms/marker.ts +210 -16
  83. package/src/runtime/derivedUniforms/sceneIntegration.ts +67 -20
  84. package/src/runtime/heapEligibility.ts +22 -1
  85. package/src/runtime/heapScene/chunkedArena.ts +238 -0
  86. package/src/runtime/heapScene/freelist.ts +161 -0
  87. package/src/runtime/heapScene/growBuffer.ts +39 -2
  88. package/src/runtime/heapScene/pools.ts +231 -143
  89. package/src/runtime/heapScene.ts +1119 -81
  90. package/src/runtime/hybridScene.ts +8 -0
  91. package/src/runtime/index.ts +12 -9
  92. package/src/runtime/renderTask.ts +6 -0
  93. package/src/runtime/runtime.ts +7 -0
  94. package/src/runtime/derivedModes/gpuDispatcher.ts +0 -226
  95. 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,147 @@ 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
+ // The alloc may have spilled into a different chunk if `chunkIdx`
116
+ // was full. Honour wherever it landed — addRO's chunk-routing
117
+ // commits the RO to the spill chunk too.
118
+ const finalChunk = r.chunkIdx;
98
119
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
99
120
  const buf = new ArrayBuffer(allocBytes);
100
121
  const u32 = new Uint32Array(buf);
101
122
  const f32 = new Float32Array(buf);
102
123
  u32[0] = typeId;
103
124
  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
125
  u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
107
126
  pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
108
- arena.write(ref, new Uint8Array(buf));
127
+ arena.write(finalChunk, r.off, new Uint8Array(buf));
109
128
  void device;
110
- this.byAval.set(av, { ref, dataBytes, typeId, pack, refcount: 1 });
111
- return ref;
129
+ if (byChunk === undefined) {
130
+ byChunk = new Map();
131
+ this.byAval.set(av, byChunk);
132
+ }
133
+ byChunk.set(finalChunk, {
134
+ chunkIdx: finalChunk, ref: r.off, dataBytes, typeId, pack, refcount: 1,
135
+ });
136
+ return r.off;
112
137
  }
113
138
 
114
- /** Decrement refcount; if zero, free the arena allocation. */
115
- release(arena: AttributeArena, av: aval<unknown>): void {
116
- const e = this.byAval.get(av);
139
+ release(arena: ChunkedAttributeArena, av: aval<unknown>, chunkIdx: number): void {
140
+ const byChunk = this.byAval.get(av);
141
+ const e = byChunk?.get(chunkIdx);
117
142
  if (e === undefined) return;
118
143
  e.refcount--;
119
144
  if (e.refcount > 0) return;
120
- arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
121
- this.byAval.delete(av);
145
+ arena.release(e.chunkIdx, e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
146
+ byChunk!.delete(chunkIdx);
147
+ if (byChunk!.size === 0) this.byAval.delete(av);
122
148
  }
123
149
 
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
- );
150
+ /** Re-pack one entry's data region into every chunk that holds
151
+ * this aval. When an aval is shared across N chunks (each chunk
152
+ * has its own duplicate alloc), every duplicate needs the
153
+ * refresh so all the chunks' ROs see the new value. */
154
+ repack(
155
+ device: GPUDevice,
156
+ arena: ChunkedAttributeArena,
157
+ av: aval<unknown>,
158
+ val: unknown,
159
+ ): void {
160
+ const byChunk = this.byAval.get(av);
161
+ if (byChunk === undefined) return;
162
+ let dst: Float32Array | undefined;
163
+ for (const e of byChunk.values()) {
164
+ if (dst === undefined || dst.length !== e.dataBytes / 4) {
165
+ dst = new Float32Array(e.dataBytes / 4);
166
+ }
167
+ e.pack(val, dst, 0);
168
+ arena.write(
169
+ e.chunkIdx,
170
+ e.ref + ALLOC_HEADER_PAD_TO,
171
+ new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes),
172
+ );
173
+ }
134
174
  void device;
135
175
  }
176
+ /** Total bytes touched by `repack` for this aval — sum across
177
+ * chunks. Used by the dirty-bytes diagnostics. */
178
+ totalDataBytes(av: aval<unknown>): number {
179
+ const byChunk = this.byAval.get(av);
180
+ if (byChunk === undefined) return 0;
181
+ let s = 0;
182
+ for (const e of byChunk.values()) s += e.dataBytes;
183
+ return s;
184
+ }
136
185
  }
137
186
 
138
187
  // ─── IndexPool ─────────────────────────────────────────────────────────
@@ -150,10 +199,13 @@ export class UniformPool {
150
199
  * `Uint32Array` view collapse to one allocation.
151
200
  */
152
201
  export class IndexPool {
202
+ /** Per-aval, per-chunk bookkeeping. Same aval acquired in two
203
+ * chunks gets two entries (accepted §3 duplication). */
153
204
  private readonly byAval = new Map<
154
205
  aval<Uint32Array>,
155
- { entry: IndexPoolEntry; perAvalCount: number }
206
+ Map<number, { entry: IndexPoolEntry; perAvalCount: number }>
156
207
  >();
208
+ /** Per-chunk constant-aval value-dedup (§5b in-chunk). */
157
209
  private readonly byValueKey = new Map<string, IndexPoolEntry>();
158
210
  private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
159
211
  private nextBufferId = 1;
@@ -166,48 +218,75 @@ export class IndexPool {
166
218
  return id;
167
219
  }
168
220
 
221
+ firstChunkContaining(av: aval<Uint32Array>): number | undefined {
222
+ const byChunk = this.byAval.get(av);
223
+ if (byChunk === undefined || byChunk.size === 0) return undefined;
224
+ return byChunk.keys().next().value as number;
225
+ }
226
+
169
227
  acquire(
170
228
  device: GPUDevice,
171
- indices: IndexAllocator,
229
+ indices: ChunkedIndexAllocator,
172
230
  av: aval<Uint32Array>,
231
+ chunkIdx: number,
173
232
  arr: Uint32Array,
174
- ): { firstIndex: number; count: number } {
175
- const bound = this.byAval.get(av);
233
+ ): { chunkIdx: number; firstIndex: number; count: number } {
234
+ let byChunk = this.byAval.get(av);
235
+ const bound = byChunk?.get(chunkIdx);
176
236
  if (bound !== undefined) {
177
237
  bound.perAvalCount++;
178
238
  bound.entry.totalRefcount++;
179
- return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
239
+ return { chunkIdx: bound.entry.chunkIdx, firstIndex: bound.entry.firstIndex, count: bound.entry.count };
180
240
  }
181
241
  let valueKey: string | undefined;
182
242
  if (av.isConstant) {
183
- valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
243
+ // Value-dedup is scoped per chunk — two constant avals in the
244
+ // same chunk collapse to one allocation; two constant avals in
245
+ // DIFFERENT chunks each get their own (we can't share across
246
+ // chunks since the index range lives in the chunk's GPUBuffer).
247
+ valueKey = `${chunkIdx}:${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
184
248
  const shared = this.byValueKey.get(valueKey);
185
249
  if (shared !== undefined) {
186
250
  shared.totalRefcount++;
187
- this.byAval.set(av, { entry: shared, perAvalCount: 1 });
188
- return { firstIndex: shared.firstIndex, count: shared.count };
251
+ if (byChunk === undefined) {
252
+ byChunk = new Map();
253
+ this.byAval.set(av, byChunk);
254
+ }
255
+ byChunk.set(chunkIdx, { entry: shared, perAvalCount: 1 });
256
+ return { chunkIdx: shared.chunkIdx, firstIndex: shared.firstIndex, count: shared.count };
189
257
  }
190
258
  }
191
- const firstIndex = indices.alloc(arr.length);
259
+ const r = indices.alloc(arr.length, chunkIdx);
192
260
  indices.write(
193
- firstIndex * 4,
261
+ r.chunkIdx, r.off * 4,
194
262
  new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength),
195
263
  );
196
264
  void device;
197
- const entry: IndexPoolEntry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
198
- this.byAval.set(av, { entry, perAvalCount: 1 });
265
+ const entry: IndexPoolEntry = {
266
+ chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length,
267
+ totalRefcount: 1, valueKey,
268
+ };
269
+ if (byChunk === undefined) {
270
+ byChunk = new Map();
271
+ this.byAval.set(av, byChunk);
272
+ }
273
+ byChunk.set(r.chunkIdx, { entry, perAvalCount: 1 });
199
274
  if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
200
- return { firstIndex, count: arr.length };
275
+ return { chunkIdx: r.chunkIdx, firstIndex: r.off, count: arr.length };
201
276
  }
202
277
 
203
- release(indices: IndexAllocator, av: aval<Uint32Array>): void {
204
- const bound = this.byAval.get(av);
278
+ release(indices: ChunkedIndexAllocator, av: aval<Uint32Array>, chunkIdx: number): void {
279
+ const byChunk = this.byAval.get(av);
280
+ const bound = byChunk?.get(chunkIdx);
205
281
  if (bound === undefined) return;
206
282
  bound.perAvalCount--;
207
283
  bound.entry.totalRefcount--;
208
- if (bound.perAvalCount === 0) this.byAval.delete(av);
284
+ if (bound.perAvalCount === 0) {
285
+ byChunk!.delete(chunkIdx);
286
+ if (byChunk!.size === 0) this.byAval.delete(av);
287
+ }
209
288
  if (bound.entry.totalRefcount > 0) return;
210
- indices.release(bound.entry.firstIndex, bound.entry.count);
289
+ indices.release(bound.entry.chunkIdx, bound.entry.firstIndex, bound.entry.count);
211
290
  if (bound.entry.valueKey !== undefined) {
212
291
  this.byValueKey.delete(bound.entry.valueKey);
213
292
  }
@@ -215,6 +294,7 @@ export class IndexPool {
215
294
  }
216
295
 
217
296
  interface IndexPoolEntry {
297
+ chunkIdx: number;
218
298
  firstIndex: number;
219
299
  count: number;
220
300
  totalRefcount: number;
@@ -259,7 +339,7 @@ export class DrawHeap {
259
339
  */
260
340
  export class AttributeArena {
261
341
  private cursor = 0;
262
- private freeList: { off: number; size: number }[] = [];
342
+ private readonly freelist = new Freelist();
263
343
  /** CPU shadow of the entire GPU buffer; writes go here first then
264
344
  * flush() emits one writeBuffer per dirty contiguous range. */
265
345
  private shadow: Uint8Array;
@@ -296,76 +376,58 @@ export class AttributeArena {
296
376
  * Allocate space for one attribute. Returns the byte ref (offset
297
377
  * to the header — data lives at ref + 16).
298
378
  */
299
- alloc(dataBytes: number): number {
379
+ /** Non-throwing alloc returns `undefined` when the request
380
+ * would exceed the underlying GrowBuffer's `maxCapacity` cap.
381
+ * Used by `ChunkedAttributeArena` to probe before spilling to
382
+ * the next chunk. */
383
+ tryAlloc(dataBytes: number): number | undefined {
300
384
  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
- }
385
+ const reused = this.freelist.alloc(allocBytes);
386
+ if (reused !== undefined) return reused;
387
+ const next = this.cursor + allocBytes;
388
+ if (next > this.buf.maxCapacity) return undefined;
389
+ this.cursor = next;
390
+ this.buf.ensureCapacity(next);
391
+ this.buf.setUsed(next);
392
+ return next - allocBytes;
393
+ }
394
+ alloc(dataBytes: number): number {
395
+ const r = this.tryAlloc(dataBytes);
396
+ if (r === undefined) {
397
+ throw new Error(
398
+ `AttributeArena: allocation of ${dataBytes}B (with header alignment) exceeds chunk cap`,
399
+ );
310
400
  }
311
- const ref = this.cursor;
312
- this.cursor += allocBytes;
313
- this.buf.ensureCapacity(this.cursor);
314
- this.buf.setUsed(this.cursor);
315
- return ref;
401
+ return r;
316
402
  }
317
403
  release(ref: number, dataBytes: number): void {
318
404
  const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
319
- insertSortedFreeBlock(this.freeList, ref, allocBytes);
405
+ this.freelist.release(ref, allocBytes);
406
+ // §5: shrink the bump cursor back if release exposed a free
407
+ // tail touching it (cascading — `freelist.release` may have
408
+ // coalesced into a single block ending at `cursor`). Reclaims
409
+ // the high-watermark over time in long-lived high-churn scenes.
410
+ while (true) {
411
+ const top = this.freelist.takeBlockEndingAt(this.cursor);
412
+ if (top === undefined) break;
413
+ this.cursor = top.off;
414
+ this.buf.setUsed(this.cursor);
415
+ }
320
416
  }
321
417
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
322
418
  destroy(): void { this.buf.destroy(); }
323
419
  }
324
420
 
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);
347
- }
348
- return;
349
- }
350
- const next = freeList[lo];
351
- if (next !== undefined && off + size === next.off) {
352
- next.off = off;
353
- next.size += size;
354
- return;
355
- }
356
- freeList.splice(lo, 0, { off, size });
357
- }
358
-
359
421
  // ─── IndexAllocator (element-bump) ─────────────────────────────────────
360
422
 
361
423
  /**
362
424
  * Element-bump allocator over an index GrowBuffer (units = u32). Each
363
425
  * 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.
426
+ * is returned to a Freelist and can be reused best-fit.
365
427
  */
366
428
  export class IndexAllocator {
367
429
  private cursor = 0; // in u32s, not bytes
368
- private freeList: { off: number; size: number }[] = [];
430
+ private readonly freelist = new Freelist();
369
431
  private shadow: Uint8Array;
370
432
  private dirtyMin = Infinity;
371
433
  private dirtyMax = 0;
@@ -395,24 +457,37 @@ export class IndexAllocator {
395
457
  this.dirtyMin = Infinity;
396
458
  this.dirtyMax = 0;
397
459
  }
460
+ /** Non-throwing alloc — returns `undefined` when the request
461
+ * would exceed the underlying GrowBuffer's `maxCapacity` cap.
462
+ * Used by `ChunkedIndexAllocator` to probe before spilling. */
463
+ tryAlloc(elements: number): number | undefined {
464
+ const reused = this.freelist.alloc(elements);
465
+ if (reused !== undefined) return reused;
466
+ const nextElts = this.cursor + elements;
467
+ if (nextElts * 4 > this.buf.maxCapacity) return undefined;
468
+ this.cursor = nextElts;
469
+ this.buf.ensureCapacity(nextElts * 4);
470
+ this.buf.setUsed(nextElts * 4);
471
+ return nextElts - elements;
472
+ }
398
473
  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
- }
474
+ const r = this.tryAlloc(elements);
475
+ if (r === undefined) {
476
+ throw new Error(`IndexAllocator: allocation of ${elements} elements exceeds chunk cap`);
407
477
  }
408
- const off = this.cursor;
409
- this.cursor += elements;
410
- this.buf.ensureCapacity(this.cursor * 4);
411
- this.buf.setUsed(this.cursor * 4);
412
- return off;
478
+ return r;
413
479
  }
414
480
  release(off: number, elements: number): void {
415
- insertSortedFreeBlock(this.freeList, off, elements);
481
+ this.freelist.release(off, elements);
482
+ // §5: cursor-shrink mirror of AttributeArena.release. Cursor
483
+ // is in u32 elements here, and setUsed on the GrowBuffer
484
+ // expects bytes — multiply by 4.
485
+ while (true) {
486
+ const top = this.freelist.takeBlockEndingAt(this.cursor);
487
+ if (top === undefined) break;
488
+ this.cursor = top.off;
489
+ this.buf.setUsed(this.cursor * 4);
490
+ }
416
491
  }
417
492
  onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
418
493
  destroy(): void { this.buf.destroy(); }
@@ -423,11 +498,15 @@ export class IndexAllocator {
423
498
  /**
424
499
  * Global arena state: attribute / uniform data lives in `attrs`
425
500
  * (multi-typed-view storage); indices live in `indices` (separate
426
- * INDEX-usage buffer).
501
+ * INDEX-usage buffer). Both are chunked: each chunk owns a separate
502
+ * GPUBuffer + Freelist. Refs that downstream code stores are byte
503
+ * offsets within a chunk; the chunk identity is carried alongside
504
+ * (via bucket.chunkIdx in heapScene) and made implicit at draw
505
+ * time by which chunk's bind group is active.
427
506
  */
428
507
  export interface ArenaState {
429
- readonly attrs: AttributeArena;
430
- readonly indices: IndexAllocator;
508
+ readonly attrs: ChunkedAttributeArena;
509
+ readonly indices: ChunkedIndexAllocator;
431
510
  }
432
511
 
433
512
  export function buildArenaState(
@@ -436,20 +515,29 @@ export function buildArenaState(
436
515
  idxBytesHint: number,
437
516
  label: string,
438
517
  idxExtraUsage: GPUBufferUsageFlags = 0,
518
+ maxChunkBytes: number | undefined = undefined,
439
519
  ): ArenaState {
440
- const attrs = new AttributeArena(new GrowBuffer(
520
+ // §3: cap chunk size at min(adapter's maxStorageBufferBindingSize,
521
+ // GrowBuffer's DEFAULT_MAX_BUFFER_BYTES). Lets chunks grow as far
522
+ // as the hardware lets us (typically 2 GiB+ on desktop, ≥ 256 MB
523
+ // on mobile/integrated) while keeping a sensible internal ceiling.
524
+ // Caller can override (the heap-demo uses a tiny 4 MB cap to
525
+ // exercise multi-chunk routing).
526
+ const adapterCap = device.limits.maxStorageBufferBindingSize;
527
+ const cap = maxChunkBytes ?? Math.min(adapterCap, DEFAULT_MAX_BUFFER_BYTES);
528
+ const attrs = new ChunkedAttributeArena(
441
529
  device, `${label}/attrs`, GPUBufferUsage.STORAGE,
442
- attrBytesHint,
443
- ));
444
- const indices = new IndexAllocator(new GrowBuffer(
530
+ attrBytesHint, cap,
531
+ );
532
+ const indices = new ChunkedIndexAllocator(
445
533
  device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
446
- idxBytesHint,
447
- ));
534
+ idxBytesHint, cap,
535
+ );
448
536
  return { attrs, indices };
449
537
  }
450
538
 
451
539
  export function arenaBytes(arena: ArenaState): number {
452
- return arena.attrs.usedBytes + arena.indices.usedElements * 4;
540
+ return arena.attrs.totalUsedBytes() + arena.indices.totalUsedElements() * 4;
453
541
  }
454
542
 
455
543
  /** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */