@aardworx/wombat.rendering 0.9.14 → 0.9.15

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 (65) hide show
  1. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +45 -0
  2. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
  3. package/dist/runtime/derivedModes/modeKeyCpu.js +179 -0
  4. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -0
  5. package/dist/runtime/derivedModes/partition.d.ts +36 -0
  6. package/dist/runtime/derivedModes/partition.d.ts.map +1 -0
  7. package/dist/runtime/derivedModes/partition.js +170 -0
  8. package/dist/runtime/derivedModes/partition.js.map +1 -0
  9. package/dist/runtime/derivedModes/slotTable.d.ts +70 -0
  10. package/dist/runtime/derivedModes/slotTable.d.ts.map +1 -0
  11. package/dist/runtime/derivedModes/slotTable.js +130 -0
  12. package/dist/runtime/derivedModes/slotTable.js.map +1 -0
  13. package/dist/runtime/heapScene/growBuffer.d.ts +33 -0
  14. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -0
  15. package/dist/runtime/heapScene/growBuffer.js +76 -0
  16. package/dist/runtime/heapScene/growBuffer.js.map +1 -0
  17. package/dist/runtime/heapScene/packers.d.ts +25 -0
  18. package/dist/runtime/heapScene/packers.d.ts.map +1 -0
  19. package/dist/runtime/heapScene/packers.js +111 -0
  20. package/dist/runtime/heapScene/packers.js.map +1 -0
  21. package/dist/runtime/heapScene/pools.d.ts +181 -0
  22. package/dist/runtime/heapScene/pools.d.ts.map +1 -0
  23. package/dist/runtime/heapScene/pools.js +417 -0
  24. package/dist/runtime/heapScene/pools.js.map +1 -0
  25. package/dist/runtime/heapScene/scanKernel.d.ts +11 -0
  26. package/dist/runtime/heapScene/scanKernel.d.ts.map +1 -0
  27. package/dist/runtime/heapScene/scanKernel.js +181 -0
  28. package/dist/runtime/heapScene/scanKernel.js.map +1 -0
  29. package/dist/runtime/heapScene.d.ts.map +1 -1
  30. package/dist/runtime/heapScene.js +228 -937
  31. package/dist/runtime/heapScene.js.map +1 -1
  32. package/dist/runtime/index.d.ts +4 -0
  33. package/dist/runtime/index.d.ts.map +1 -1
  34. package/dist/runtime/index.js +5 -0
  35. package/dist/runtime/index.js.map +1 -1
  36. package/dist/runtime/pipelineCache/bitfield.d.ts +5 -0
  37. package/dist/runtime/pipelineCache/bitfield.d.ts.map +1 -0
  38. package/dist/runtime/pipelineCache/bitfield.js +201 -0
  39. package/dist/runtime/pipelineCache/bitfield.js.map +1 -0
  40. package/dist/runtime/pipelineCache/cache.d.ts +36 -0
  41. package/dist/runtime/pipelineCache/cache.d.ts.map +1 -0
  42. package/dist/runtime/pipelineCache/cache.js +108 -0
  43. package/dist/runtime/pipelineCache/cache.js.map +1 -0
  44. package/dist/runtime/pipelineCache/descriptor.d.ts +58 -0
  45. package/dist/runtime/pipelineCache/descriptor.d.ts.map +1 -0
  46. package/dist/runtime/pipelineCache/descriptor.js +100 -0
  47. package/dist/runtime/pipelineCache/descriptor.js.map +1 -0
  48. package/dist/runtime/pipelineCache/index.d.ts +5 -0
  49. package/dist/runtime/pipelineCache/index.d.ts.map +1 -0
  50. package/dist/runtime/pipelineCache/index.js +12 -0
  51. package/dist/runtime/pipelineCache/index.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/runtime/derivedModes/modeKeyCpu.ts +213 -0
  54. package/src/runtime/derivedModes/partition.ts +206 -0
  55. package/src/runtime/derivedModes/slotTable.ts +153 -0
  56. package/src/runtime/heapScene/growBuffer.ts +75 -0
  57. package/src/runtime/heapScene/packers.ts +127 -0
  58. package/src/runtime/heapScene/pools.ts +492 -0
  59. package/src/runtime/heapScene/scanKernel.ts +184 -0
  60. package/src/runtime/heapScene.ts +283 -1064
  61. package/src/runtime/index.ts +40 -0
  62. package/src/runtime/pipelineCache/bitfield.ts +225 -0
  63. package/src/runtime/pipelineCache/cache.ts +129 -0
  64. package/src/runtime/pipelineCache/descriptor.ts +150 -0
  65. package/src/runtime/pipelineCache/index.ts +37 -0
@@ -49,797 +49,12 @@ import { buildShaderFamily, compileShaderFamily, } from "./heapShaderFamily.js";
49
49
  import { ATLAS_PAGE_FORMATS, atlasFormatIndex, } from "./textureAtlas/atlasPool.js";
50
50
  import { ATLAS_ARRAY_SIZE, ATLAS_LINEAR_BINDING_BASE, ATLAS_SRGB_BINDING_BASE, ATLAS_SAMPLER_BINDING, } from "./heapEffect.js";
51
51
  import { DerivedUniformsScene, registerRoDerivations, deregisterRoDerivations, isDerivedRule, STANDARD_DERIVED_RULES, STANDARD_TRAFO_LEAVES, inputsOf, } from "./derivedUniforms/index.js";
52
- // ---------------------------------------------------------------------------
53
- // Per-allocation arena layout
54
- // ---------------------------------------------------------------------------
55
- // Per-allocation header: (u32 typeId, u32 length). typeId is
56
- // (semantic << 16) | encoding. The data region follows the header
57
- // aligned up to 16 bytes (so positions/normals/etc. line up for
58
- // future vec4 reads).
59
- const ALLOC_HEADER_BYTES = 8;
60
- const ALLOC_HEADER_PAD_TO = 16; // data starts header_offset + 16
61
- // Encoding-tag enum (low 16 bits of typeId).
62
- const ENC_V3F_TIGHT = 1; // tightly-packed array of vec3<f32> (12 B/elt)
63
- // Semantic-tag enum (high 16 bits of typeId). Optional metadata —
64
- // the shader doesn't branch on this.
65
- const SEM_POSITIONS = 1;
66
- const SEM_NORMALS = 2;
67
- const ALIGN16 = (n) => (n + 15) & ~15;
68
- function packMat44(m, dst, off) {
69
- // Zero-alloc flat copy (row-major) straight into the f32 staging
70
- // buffer — `copyTo` does `dst.set(m._data, off)` which narrows f64→f32
71
- // on store, no throwaway `number[]` per call.
72
- m.copyTo(dst, off);
73
- }
74
- const PACKER_MAT4 = {
75
- dataBytes: 64, typeId: 0,
76
- pack: (val, dst, off) => {
77
- // Accept Trafo3d (uses .forward) or M44d directly.
78
- const m = val.forward !== undefined
79
- ? val.forward
80
- : val;
81
- packMat44(m, dst, off);
82
- },
83
- };
84
- const PACKER_VEC4 = {
85
- dataBytes: 16, typeId: 0,
86
- pack: (val, dst, off) => {
87
- const v = val;
88
- dst[off + 0] = v.x;
89
- dst[off + 1] = v.y;
90
- dst[off + 2] = v.z;
91
- dst[off + 3] = v.w;
92
- },
93
- };
94
- const PACKER_VEC3 = {
95
- dataBytes: 12, typeId: 0,
96
- pack: (val, dst, off) => {
97
- // V3f or V3d both expose .x/.y/.z; cast through a common shape.
98
- const v = val;
99
- dst[off + 0] = v.x;
100
- dst[off + 1] = v.y;
101
- dst[off + 2] = v.z;
102
- },
103
- };
104
- const PACKER_VEC2 = {
105
- dataBytes: 8, typeId: 0,
106
- pack: (val, dst, off) => {
107
- const v = val;
108
- dst[off + 0] = v.x;
109
- dst[off + 1] = v.y;
110
- },
111
- };
112
- const PACKER_F32 = {
113
- dataBytes: 4, typeId: 0,
114
- pack: (val, dst, off) => { dst[off] = val; },
115
- };
116
- // Integer scalars / vectors. The arena is fronted by a `Float32Array`,
117
- // so writing raw bits has to go through a same-buffer Uint32/Int32 view
118
- // to avoid the lossy `i32 → f32` coercion you'd get from a direct
119
- // `dst[off] = ...` assignment.
120
- function makeIntPacker(ctor, dim) {
121
- const bytes = dim * 4;
122
- if (dim === 1) {
123
- return {
124
- dataBytes: bytes, typeId: 0,
125
- pack: (val, dst, off) => {
126
- new ctor(dst.buffer, dst.byteOffset + off * 4, 1)[0] = val;
127
- },
128
- };
129
- }
130
- // Vector: accept {x,y,z,w} components.
131
- return {
132
- dataBytes: bytes, typeId: 0,
133
- pack: (val, dst, off) => {
134
- const view = new ctor(dst.buffer, dst.byteOffset + off * 4, dim);
135
- const v = val;
136
- view[0] = v.x;
137
- view[1] = v.y;
138
- if (dim >= 3)
139
- view[2] = v.z;
140
- if (dim >= 4)
141
- view[3] = v.w;
142
- },
143
- };
144
- }
145
- const PACKER_U32 = makeIntPacker(Uint32Array, 1);
146
- const PACKER_UVEC2 = makeIntPacker(Uint32Array, 2);
147
- const PACKER_UVEC3 = makeIntPacker(Uint32Array, 3);
148
- const PACKER_UVEC4 = makeIntPacker(Uint32Array, 4);
149
- const PACKER_I32 = makeIntPacker(Int32Array, 1);
150
- const PACKER_IVEC2 = makeIntPacker(Int32Array, 2);
151
- const PACKER_IVEC3 = makeIntPacker(Int32Array, 3);
152
- const PACKER_IVEC4 = makeIntPacker(Int32Array, 4);
153
- function packerForWgslType(wgslType) {
154
- switch (wgslType) {
155
- case "mat4x4<f32>": return PACKER_MAT4;
156
- case "vec4<f32>": return PACKER_VEC4;
157
- case "vec3<f32>": return PACKER_VEC3;
158
- case "vec2<f32>": return PACKER_VEC2;
159
- case "f32": return PACKER_F32;
160
- case "u32": return PACKER_U32;
161
- case "vec2<u32>": return PACKER_UVEC2;
162
- case "vec3<u32>": return PACKER_UVEC3;
163
- case "vec4<u32>": return PACKER_UVEC4;
164
- case "i32": return PACKER_I32;
165
- case "vec2<i32>": return PACKER_IVEC2;
166
- case "vec3<i32>": return PACKER_IVEC3;
167
- case "vec4<i32>": return PACKER_IVEC4;
168
- default:
169
- throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
170
- }
171
- }
172
- // ---------------------------------------------------------------------------
173
- // Resizable buffer (pow2 grow + GPU-side copy on resize)
174
- // ---------------------------------------------------------------------------
175
- const MIN_BUFFER_BYTES = 64 * 1024;
176
- const POW2 = (n) => {
177
- let p = 1;
178
- while (p < n)
179
- p <<= 1;
180
- return p;
181
- };
182
- /**
183
- * A GPUBuffer that can grow to next power-of-two on demand. On grow,
184
- * a fresh buffer is created at the new size, the live tail copied
185
- * over via copyBufferToBuffer, and dependents (bind groups, mostly)
186
- * are notified to rebuild via the `onResize` callback.
187
- *
188
- * `usedBytes` is the high-water mark — the runtime advances this as
189
- * it allocates, and `ensureCapacity` grows when required. This
190
- * separates allocation policy from grow policy.
191
- */
192
- class GrowBuffer {
193
- device;
194
- label;
195
- usage;
196
- buf;
197
- cap;
198
- used = 0;
199
- listeners = new Set();
200
- constructor(device, label, usage, initialBytes) {
201
- this.device = device;
202
- this.label = label;
203
- this.usage = usage;
204
- this.cap = Math.max(MIN_BUFFER_BYTES, POW2(initialBytes));
205
- this.buf = device.createBuffer({ size: this.cap, usage: usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label });
206
- }
207
- get buffer() { return this.buf; }
208
- get capacity() { return this.cap; }
209
- get usedBytes() { return this.used; }
210
- setUsed(n) { this.used = n; }
211
- onResize(cb) {
212
- this.listeners.add(cb);
213
- return { dispose: () => { this.listeners.delete(cb); } };
214
- }
215
- /** Ensure the buffer is at least `bytes` capacity. Grows by pow2 + copies live tail. */
216
- ensureCapacity(bytes) {
217
- if (bytes <= this.cap)
218
- return;
219
- const newCap = POW2(bytes);
220
- const newBuf = this.device.createBuffer({
221
- size: newCap, usage: this.usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label: this.label,
222
- });
223
- if (this.used > 0) {
224
- const enc = this.device.createCommandEncoder({ label: `${this.label}: grow-copy` });
225
- enc.copyBufferToBuffer(this.buf, 0, newBuf, 0, ALIGN16(this.used));
226
- this.device.queue.submit([enc.finish()]);
227
- }
228
- this.buf.destroy();
229
- this.buf = newBuf;
230
- this.cap = newCap;
231
- for (const cb of this.listeners)
232
- cb();
233
- }
234
- destroy() { this.buf.destroy(); }
235
- }
236
- /**
237
- * Aval-keyed pool of arena allocations. One allocation per unique
238
- * aval (object identity). Two draws referencing the same aval share
239
- * the allocation; their DrawHeaders carry the same u32 ref. Holds
240
- * uniforms (fixed-size scalars/vectors/matrices) AND attribute arrays
241
- * (variable-size). The caller decides `dataBytes` + `length` per
242
- * acquisition — the pool just keys on aval identity and refcounts.
243
- *
244
- * Sharing emerges from aval identity — no separate "frequency"
245
- * declaration needed. A `cval` shared by all draws → 1 alloc.
246
- * A static positions array shared across instanced draws → 1 alloc.
247
- * Same code path either way.
248
- */
249
- class UniformPool {
250
- // Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
251
- // keys are overwhelmingly reactive `cval`s (per-object trafos,
252
- // colours, …) and the hot path is `acquire`/`release` ~once per
253
- // drawHeader field per RO. A content-keyed `HashTable` would buy
254
- // nothing here — reactive avals never compare content-equal — and
255
- // would cost: reactive avals have no `equals`/`getHashCode`, so a
256
- // `HashTable` falls back to a WeakMap-counter identity hash per
257
- // lookup, measurably slower than `Map`'s native hashing. (Constant-
258
- // aval dedup matters where keys are *texture* avals — there the
259
- // `AtlasPool` is content-keyed; constant avals there carry a cached
260
- // hash and a fast `equals`.)
261
- byAval = new Map();
262
- has(aval) { return this.byAval.has(aval); }
263
- entry(aval) { return this.byAval.get(aval); }
264
- /**
265
- * Acquire (or share) an allocation for `aval`. Caller passes the
266
- * pre-read `value` (so the pool doesn't need a token) plus the
267
- * (`dataBytes`, `typeId`, `length`, `pack`) describing how to lay
268
- * it out. If a new allocation is made, the value is packed and
269
- * uploaded immediately.
270
- */
271
- acquire(device, arena, aval, value, dataBytes, typeId, length, pack) {
272
- const existing = this.byAval.get(aval);
273
- if (existing !== undefined) {
274
- existing.refcount++;
275
- return existing.ref;
276
- }
277
- const ref = arena.alloc(dataBytes);
278
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
279
- const buf = new ArrayBuffer(allocBytes);
280
- const u32 = new Uint32Array(buf);
281
- const f32 = new Float32Array(buf);
282
- u32[0] = typeId;
283
- u32[1] = length;
284
- // stride_bytes (offset 8): bytes per element. Lets the VS decode
285
- // pick V3- vs V4-tight load expressions for vec4 attributes
286
- // (and is informative for everything else).
287
- u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
288
- pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
289
- arena.write(ref, new Uint8Array(buf));
290
- void device;
291
- this.byAval.set(aval, { ref, dataBytes, typeId, pack, refcount: 1 });
292
- return ref;
293
- }
294
- /** Decrement refcount; if zero, free the arena allocation. */
295
- release(arena, aval) {
296
- const e = this.byAval.get(aval);
297
- if (e === undefined)
298
- return;
299
- e.refcount--;
300
- if (e.refcount > 0)
301
- return;
302
- arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
303
- this.byAval.delete(aval);
304
- }
305
- /** Re-pack one entry's data region into the arena's CPU shadow. */
306
- repack(device, arena, aval, val) {
307
- const e = this.byAval.get(aval);
308
- if (e === undefined)
309
- return;
310
- const dst = new Float32Array(e.dataBytes / 4);
311
- e.pack(val, dst, 0);
312
- arena.write(e.ref + ALLOC_HEADER_PAD_TO, new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes));
313
- void device;
314
- }
315
- }
316
- /**
317
- * Aval-keyed pool over the `IndexAllocator`. Two draws referencing
318
- * the same `Uint32Array` (or aval thereof) share an index range —
319
- * 19K instanced clones of the same mesh share one allocation, one
320
- * upload. Index data is treated as immutable for the aval's
321
- * lifetime: an aval mark won't repack (we'd have to free + re-alloc
322
- * since size changes are likely). Use a fresh aval to swap meshes.
323
- *
324
- * **Value-equality dedup for constant avals (§5b):** when an
325
- * incoming aval has `isConstant === true`, the pool also keys by
326
- * the underlying `ArrayBuffer` tuple `(buffer, byteOffset,
327
- * byteLength)`. Two distinct constant avals wrapping the same
328
- * `Uint32Array` view (or two views over the same backing buffer
329
- * with matching offsets) collapse to one allocation. Hashing
330
- * kilobytes of indices on every acquire would be wasteful; the
331
- * tuple key catches the realistic "one ArrayBuffer shared across
332
- * many aval wrappers" pattern, which is the only one that matters
333
- * for the heap path. Reactive (non-constant) avals fall through
334
- * to identity-only — their content can change and the pool can't
335
- * silently merge them.
336
- */
337
- class IndexPool {
338
- // Per-aval binding. `perAvalCount` tracks acquire/release balance
339
- // for THIS aval; `entry` is the shared allocation (one entry can be
340
- // referenced by many aliasing avals via §5b dedup).
341
- byAval = new Map();
342
- byValueKey = new Map();
343
- // Stable per-ArrayBuffer numeric id for value-key composition.
344
- // WeakMap-backed so buffers GC'd elsewhere drop their entry too.
345
- bufferIds = new WeakMap();
346
- nextBufferId = 1;
347
- bufferIdOf(buf) {
348
- let id = this.bufferIds.get(buf);
349
- if (id === undefined) {
350
- id = this.nextBufferId++;
351
- this.bufferIds.set(buf, id);
352
- }
353
- return id;
354
- }
355
- acquire(device, indices, aval, arr) {
356
- const bound = this.byAval.get(aval);
357
- if (bound !== undefined) {
358
- bound.perAvalCount++;
359
- bound.entry.totalRefcount++;
360
- return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
361
- }
362
- let valueKey;
363
- if (aval.isConstant) {
364
- valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
365
- const shared = this.byValueKey.get(valueKey);
366
- if (shared !== undefined) {
367
- shared.totalRefcount++;
368
- this.byAval.set(aval, { entry: shared, perAvalCount: 1 });
369
- return { firstIndex: shared.firstIndex, count: shared.count };
370
- }
371
- }
372
- const firstIndex = indices.alloc(arr.length);
373
- indices.write(firstIndex * 4, new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
374
- void device;
375
- const entry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
376
- this.byAval.set(aval, { entry, perAvalCount: 1 });
377
- if (valueKey !== undefined)
378
- this.byValueKey.set(valueKey, entry);
379
- return { firstIndex, count: arr.length };
380
- }
381
- release(indices, aval) {
382
- const bound = this.byAval.get(aval);
383
- if (bound === undefined)
384
- return;
385
- bound.perAvalCount--;
386
- bound.entry.totalRefcount--;
387
- if (bound.perAvalCount === 0)
388
- this.byAval.delete(aval);
389
- if (bound.entry.totalRefcount > 0)
390
- return;
391
- indices.release(bound.entry.firstIndex, bound.entry.count);
392
- if (bound.entry.valueKey !== undefined) {
393
- this.byValueKey.delete(bound.entry.valueKey);
394
- }
395
- }
396
- }
397
- // ---------------------------------------------------------------------------
398
- // DrawHeap (slot-indexed) and AttributeArena (byte-bump) allocators
399
- // ---------------------------------------------------------------------------
400
- /**
401
- * Slot-indexed allocator over a GrowBuffer. `slotBytes` is set per-
402
- * instance — each bucket sizes its DrawHeader from its effect's
403
- * schema, so a bucket whose layout is e.g. 96 B / slot uses a
404
- * DrawHeap with `slotBytes=96`.
405
- */
406
- class DrawHeap {
407
- buf;
408
- slotBytes;
409
- free = [];
410
- nextSlot = 0;
411
- constructor(buf, slotBytes) {
412
- this.buf = buf;
413
- this.slotBytes = slotBytes;
414
- }
415
- get buffer() { return this.buf.buffer; }
416
- /** Bytes per slot — caller multiplies by slot index for byte offsets. */
417
- get bytesPerSlot() { return this.slotBytes; }
418
- /** High-water mark in bytes (used to size bind-group entry on rebuild). */
419
- get usedBytes() { return this.nextSlot * this.slotBytes; }
420
- alloc() {
421
- const slot = this.free.length > 0 ? this.free.pop() : this.nextSlot++;
422
- this.buf.ensureCapacity((slot + 1) * this.slotBytes);
423
- this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
424
- return slot;
425
- }
426
- release(slot) { this.free.push(slot); }
427
- onResize(cb) { return this.buf.onResize(cb); }
428
- destroy() { this.buf.destroy(); }
429
- }
430
- /**
431
- * Byte-bump allocator over a GrowBuffer for variable-size attribute
432
- * allocations. Each allocation gets a 16-byte aligned start (8-byte
433
- * (typeId, length) header at the start, data 16 bytes in). Frees go
434
- * onto a list keyed by size for simple first-fit reuse later — for
435
- * now `release` just records the gap and the bump cursor never
436
- * shrinks.
437
- */
438
- class AttributeArena {
439
- buf;
440
- cursor = 0;
441
- // (offset, size) free entries; first-fit reuse not yet implemented.
442
- freeList = [];
443
- // CPU shadow of the entire GPU buffer. Writes go here first; a
444
- // single `device.queue.writeBuffer` per dirty contiguous range
445
- // lifts them to the GPU at flush time. At the cost of doubling
446
- // host memory we collapse N small writeBuffer calls (10K+ at
447
- // initial population) to 1 per frame.
448
- shadow;
449
- dirtyMin = Infinity;
450
- dirtyMax = 0;
451
- constructor(buf) {
452
- this.buf = buf;
453
- this.shadow = new Uint8Array(buf.capacity);
454
- buf.onResize(() => {
455
- const grown = new Uint8Array(buf.capacity);
456
- grown.set(this.shadow);
457
- this.shadow = grown;
458
- });
459
- }
460
- get buffer() { return this.buf.buffer; }
461
- get capacity() { return this.buf.capacity; }
462
- get usedBytes() { return this.cursor; }
463
- /**
464
- * Stage `data` to the shadow at byte offset `dst`. Tracks the
465
- * dirty range so `flush(device)` can emit a single writeBuffer
466
- * covering everything dirty since the last flush.
467
- */
468
- write(dst, data) {
469
- this.shadow.set(data, dst);
470
- if (dst < this.dirtyMin)
471
- this.dirtyMin = dst;
472
- const end = dst + data.byteLength;
473
- if (end > this.dirtyMax)
474
- this.dirtyMax = end;
475
- }
476
- flush(device) {
477
- if (this.dirtyMax <= this.dirtyMin)
478
- return;
479
- device.queue.writeBuffer(this.buf.buffer, this.dirtyMin, this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin, this.dirtyMax - this.dirtyMin);
480
- this.dirtyMin = Infinity;
481
- this.dirtyMax = 0;
482
- }
483
- /**
484
- * Allocate space for one attribute. Returns the byte ref (offset
485
- * to the header — data lives at ref + 16).
486
- */
487
- alloc(dataBytes) {
488
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
489
- // First-fit reuse from free list.
490
- for (let i = 0; i < this.freeList.length; i++) {
491
- const f = this.freeList[i];
492
- if (f.size >= allocBytes) {
493
- const ref = f.off;
494
- if (f.size === allocBytes)
495
- this.freeList.splice(i, 1);
496
- else {
497
- f.off += allocBytes;
498
- f.size -= allocBytes;
499
- }
500
- return ref;
501
- }
502
- }
503
- const ref = this.cursor;
504
- this.cursor += allocBytes;
505
- this.buf.ensureCapacity(this.cursor);
506
- this.buf.setUsed(this.cursor);
507
- return ref;
508
- }
509
- release(ref, dataBytes) {
510
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
511
- insertSortedFreeBlock(this.freeList, ref, allocBytes);
512
- }
513
- onResize(cb) { return this.buf.onResize(cb); }
514
- destroy() { this.buf.destroy(); }
515
- }
516
- /**
517
- * Insert `{off, size}` into a free-list kept sorted by `off`, then
518
- * coalesce with the two immediate neighbours.
519
- *
520
- * The list invariant — sorted, non-overlapping, never-adjacent — is
521
- * preserved across allocs (which take from the front or split a
522
- * block) and releases (this function). The previous implementation
523
- * did `push + Array.prototype.sort + linear coalesce scan`, which is
524
- * O(N log N) per release. Under a 500-RO bulk-remove the sort
525
- * dominated `removeDraw` (~41 ms of self-time in the heap-demo-sg
526
- * toggle profile). Binary-search insert + 2-neighbour merge collapses
527
- * that to O(log N + N-shift), and is principled — the sort never
528
- * actually mattered since we already had the sorted prefix as an
529
- * invariant.
530
- */
531
- function insertSortedFreeBlock(freeList, off, size) {
532
- // Binary-search for the insertion index (first entry whose off > new).
533
- let lo = 0, hi = freeList.length;
534
- while (lo < hi) {
535
- const mid = (lo + hi) >>> 1;
536
- if (freeList[mid].off <= off)
537
- lo = mid + 1;
538
- else
539
- hi = mid;
540
- }
541
- // lo is the index where the new entry would be inserted.
542
- // Try merging with the predecessor first; if successful, the merged
543
- // block may now be adjacent to its (former) successor too.
544
- const prev = lo > 0 ? freeList[lo - 1] : undefined;
545
- if (prev !== undefined && prev.off + prev.size === off) {
546
- prev.size += size;
547
- // Check forward-merge with what was freeList[lo].
548
- const next = freeList[lo];
549
- if (next !== undefined && prev.off + prev.size === next.off) {
550
- prev.size += next.size;
551
- freeList.splice(lo, 1);
552
- }
553
- return;
554
- }
555
- const next = freeList[lo];
556
- if (next !== undefined && off + size === next.off) {
557
- next.off = off;
558
- next.size += size;
559
- return;
560
- }
561
- freeList.splice(lo, 0, { off, size });
562
- }
563
- /**
564
- * Element-bump allocator over an index GrowBuffer (units = u32). Each
565
- * draw's index range is allocated as one block; on release the block
566
- * is returned to a free list and can be reused first-fit.
567
- */
568
- class IndexAllocator {
569
- buf;
570
- cursor = 0; // in u32s, not bytes
571
- freeList = [];
572
- // CPU shadow + dirty range, same shape as AttributeArena. Index
573
- // uploads (one per drawn mesh's index buffer) get coalesced to a
574
- // single writeBuffer per dirty range at flush time.
575
- shadow;
576
- dirtyMin = Infinity;
577
- dirtyMax = 0;
578
- constructor(buf) {
579
- this.buf = buf;
580
- this.shadow = new Uint8Array(buf.capacity);
581
- buf.onResize(() => {
582
- const grown = new Uint8Array(buf.capacity);
583
- grown.set(this.shadow);
584
- this.shadow = grown;
585
- });
586
- }
587
- get buffer() { return this.buf.buffer; }
588
- get usedElements() { return this.cursor; }
589
- /** Stage `data` (bytes) at the given byte offset; tracks dirty range. */
590
- write(dstByteOffset, data) {
591
- this.shadow.set(data, dstByteOffset);
592
- if (dstByteOffset < this.dirtyMin)
593
- this.dirtyMin = dstByteOffset;
594
- const end = dstByteOffset + data.byteLength;
595
- if (end > this.dirtyMax)
596
- this.dirtyMax = end;
597
- }
598
- flush(device) {
599
- if (this.dirtyMax <= this.dirtyMin)
600
- return;
601
- device.queue.writeBuffer(this.buf.buffer, this.dirtyMin, this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin, this.dirtyMax - this.dirtyMin);
602
- this.dirtyMin = Infinity;
603
- this.dirtyMax = 0;
604
- }
605
- alloc(elements) {
606
- for (let i = 0; i < this.freeList.length; i++) {
607
- const f = this.freeList[i];
608
- if (f.size >= elements) {
609
- const off = f.off;
610
- if (f.size === elements)
611
- this.freeList.splice(i, 1);
612
- else {
613
- f.off += elements;
614
- f.size -= elements;
615
- }
616
- return off;
617
- }
618
- }
619
- const off = this.cursor;
620
- this.cursor += elements;
621
- this.buf.ensureCapacity(this.cursor * 4);
622
- this.buf.setUsed(this.cursor * 4);
623
- return off;
624
- }
625
- release(off, elements) {
626
- insertSortedFreeBlock(this.freeList, off, elements);
627
- }
628
- onResize(cb) { return this.buf.onResize(cb); }
629
- destroy() { this.buf.destroy(); }
630
- }
631
- function buildArenaState(device, attrBytesHint, idxBytesHint, label, idxExtraUsage = 0) {
632
- const attrs = new AttributeArena(new GrowBuffer(device, `${label}/attrs`, GPUBufferUsage.STORAGE, attrBytesHint));
633
- const indices = new IndexAllocator(new GrowBuffer(device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage, idxBytesHint));
634
- return { attrs, indices };
635
- }
636
- function arenaBytes(arena) {
637
- return arena.attrs.usedBytes + arena.indices.usedElements * 4;
638
- }
639
- /** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
640
- function writeAttribute(device, buf, ref, typeId, length, data) {
641
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + data.byteLength);
642
- const staging = new ArrayBuffer(allocBytes);
643
- const u32 = new Uint32Array(staging);
644
- const f32 = new Float32Array(staging);
645
- u32[0] = typeId;
646
- u32[1] = length;
647
- f32.set(data, ALLOC_HEADER_PAD_TO / 4);
648
- device.queue.writeBuffer(buf, ref, staging, 0, allocBytes);
649
- }
650
- function asAval(v) {
651
- return (typeof v === "object" && v !== null && typeof v.getValue === "function")
652
- ? v
653
- : AVal.constant(v);
654
- }
655
- /** Heuristic predicate — BufferView has `buffer: aval<IBuffer>` + elementType. */
656
- function isBufferView(v) {
657
- if (typeof v !== "object" || v === null)
658
- return false;
659
- const o = v;
660
- return typeof o.buffer === "object" && o.buffer !== null
661
- && typeof o.buffer.getValue === "function"
662
- && typeof o.elementType === "object" && o.elementType !== null;
663
- }
664
- /**
665
- * Float32 view over a host-side buffer source. Used by the BufferView
666
- * packer to hand the pool a typed array it can `set()` from.
667
- */
668
- function asFloat32(data) {
669
- if (data instanceof Float32Array)
670
- return data;
671
- if (ArrayBuffer.isView(data)) {
672
- return new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4);
673
- }
674
- return new Float32Array(data); // ArrayBuffer
675
- }
676
- // ---------------------------------------------------------------------------
677
- // Megacall GPU prefix-sum compute shader
678
- // ---------------------------------------------------------------------------
679
- const SCAN_TILE_SIZE = 512;
680
- const SCAN_WG_SIZE = 256;
681
- const SCAN_MAX_RECORDS = SCAN_TILE_SIZE * SCAN_TILE_SIZE; // numBlocks ≤ TILE_SIZE
682
- const TILE_K = 64;
683
- /** drawTable record width: (firstEmit, drawIdx, indexStart, indexCount, instanceCount). */
684
- const RECORD_U32 = 5;
685
- const RECORD_BYTES = RECORD_U32 * 4;
686
- const HEAP_SCAN_WGSL = `
687
- struct Params {
688
- numRecords: u32,
689
- numBlocks: u32,
690
- _pad0: u32,
691
- _pad1: u32,
692
- };
693
-
694
- struct Record {
695
- firstEmit: u32,
696
- drawIdx: u32,
697
- indexStart: u32,
698
- indexCount: u32,
699
- instanceCount: u32,
700
- };
701
-
702
- @group(0) @binding(0) var<storage, read_write> drawTable: array<Record>;
703
- @group(0) @binding(1) var<storage, read_write> blockSums: array<u32>;
704
- @group(0) @binding(2) var<storage, read_write> blockOffsets: array<u32>;
705
- @group(0) @binding(3) var<storage, read_write> indirect: array<u32>;
706
- @group(0) @binding(4) var<uniform> params: Params;
707
- @group(0) @binding(5) var<storage, read_write> firstDrawInTile: array<u32>;
708
-
709
- const TILE_SIZE: u32 = 512u;
710
- const WG_SIZE: u32 = 256u;
711
- const TILE_K: u32 = 64u;
712
-
713
- var<workgroup> sdata: array<u32, 512>;
714
-
715
- fn blellochScan(tid: u32) {
716
- var offset: u32 = 1u;
717
- for (var d: u32 = TILE_SIZE >> 1u; d > 0u; d = d >> 1u) {
718
- workgroupBarrier();
719
- if (tid < d) {
720
- let ai = offset * (2u * tid + 1u) - 1u;
721
- let bi = offset * (2u * tid + 2u) - 1u;
722
- sdata[bi] = sdata[bi] + sdata[ai];
723
- }
724
- offset = offset * 2u;
725
- }
726
- if (tid == 0u) { sdata[TILE_SIZE - 1u] = 0u; }
727
- for (var d: u32 = 1u; d < TILE_SIZE; d = d * 2u) {
728
- offset = offset >> 1u;
729
- workgroupBarrier();
730
- if (tid < d) {
731
- let ai = offset * (2u * tid + 1u) - 1u;
732
- let bi = offset * (2u * tid + 2u) - 1u;
733
- let t = sdata[ai];
734
- sdata[ai] = sdata[bi];
735
- sdata[bi] = sdata[bi] + t;
736
- }
737
- }
738
- workgroupBarrier();
739
- }
740
-
741
- @compute @workgroup_size(WG_SIZE)
742
- fn scanTile(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
743
- let tid = lid.x;
744
- let blockOff = wgid.x * TILE_SIZE;
745
- let n = params.numRecords;
746
- let i0 = blockOff + tid;
747
- let i1 = blockOff + tid + WG_SIZE;
748
- var v0: u32 = 0u;
749
- var v1: u32 = 0u;
750
- if (i0 < n) { v0 = drawTable[i0].indexCount * drawTable[i0].instanceCount; }
751
- if (i1 < n) { v1 = drawTable[i1].indexCount * drawTable[i1].instanceCount; }
752
- sdata[tid] = v0;
753
- sdata[tid + WG_SIZE] = v1;
754
- workgroupBarrier();
755
- blellochScan(tid);
756
- if (i0 < n) { drawTable[i0].firstEmit = sdata[tid]; }
757
- if (i1 < n) { drawTable[i1].firstEmit = sdata[tid + WG_SIZE]; }
758
- if (tid == WG_SIZE - 1u) {
759
- blockSums[wgid.x] = sdata[tid + WG_SIZE] + v1;
760
- }
761
- }
762
-
763
- @compute @workgroup_size(WG_SIZE)
764
- fn scanBlocks(@builtin(local_invocation_id) lid: vec3<u32>) {
765
- let tid = lid.x;
766
- let n = params.numBlocks;
767
- let i0 = tid;
768
- let i1 = tid + WG_SIZE;
769
- var v0: u32 = 0u;
770
- var v1: u32 = 0u;
771
- if (i0 < n) { v0 = blockSums[i0]; }
772
- if (i1 < n) { v1 = blockSums[i1]; }
773
- sdata[tid] = v0;
774
- sdata[tid + WG_SIZE] = v1;
775
- workgroupBarrier();
776
- blellochScan(tid);
777
- if (i0 < n) { blockOffsets[i0] = sdata[tid]; }
778
- if (i1 < n) { blockOffsets[i1] = sdata[tid + WG_SIZE]; }
779
- workgroupBarrier();
780
- if (tid == 0u) {
781
- if (n > 0u) {
782
- let lastIdx = n - 1u;
783
- let total = blockOffsets[lastIdx] + blockSums[lastIdx];
784
- indirect[0] = total;
785
- } else {
786
- indirect[0] = 0u;
787
- }
788
- indirect[1] = 1u;
789
- indirect[2] = 0u;
790
- indirect[3] = 0u;
791
- }
792
- }
793
-
794
- @compute @workgroup_size(WG_SIZE)
795
- fn addOffsets(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
796
- let tid = lid.x;
797
- let blockOff = wgid.x * TILE_SIZE;
798
- let n = params.numRecords;
799
- let off = blockOffsets[wgid.x];
800
- let i0 = blockOff + tid;
801
- let i1 = blockOff + tid + WG_SIZE;
802
- if (i0 < n) { drawTable[i0].firstEmit = drawTable[i0].firstEmit + off; }
803
- if (i1 < n) { drawTable[i1].firstEmit = drawTable[i1].firstEmit + off; }
804
- }
805
-
806
- @compute @workgroup_size(WG_SIZE)
807
- fn buildTileIndex(@builtin(global_invocation_id) gid: vec3<u32>) {
808
- let tileIdx = gid.x;
809
- // totalEmit is computed by scanBlocks into indirect[0]; reading it
810
- // from indirect avoids a separate uniform/storage round-trip.
811
- let totalEmit = indirect[0];
812
- let numTiles = (totalEmit + TILE_K - 1u) / TILE_K;
813
- if (tileIdx > numTiles) { return; }
814
- if (params.numRecords == 0u) {
815
- if (tileIdx == 0u) { firstDrawInTile[0] = 0u; }
816
- return;
817
- }
818
- if (tileIdx == numTiles) {
819
- // Sentinel for the open upper bound — the LAST VALID SLOT, not
820
- // numRecords. The render VS uses
821
- // hi = firstDrawInTile[_tileIdx + 1u]
822
- // and the binary search treats hi as INCLUSIVE. If the sentinel
823
- // were numRecords (one past last), the search would drag lo into
824
- // the OOB slot for emits in the last tile, since drawTable reads
825
- // past recordCount return 0 (binding size clamping) and 0 ≤ emit
826
- // is always true. Visible symptom: the LAST few emits in the
827
- // bucket land on slot=numRecords (drawIdx=0, indexCount=0 → /-by-
828
- // zero) → degenerate / cross-RO triangle stitched to slot 0.
829
- firstDrawInTile[tileIdx] = params.numRecords - 1u;
830
- return;
831
- }
832
- let tileStart = tileIdx * TILE_K;
833
- var lo: u32 = 0u;
834
- var hi: u32 = params.numRecords - 1u;
835
- loop {
836
- if (lo >= hi) { break; }
837
- let mid = (lo + hi + 1u) >> 1u;
838
- if (drawTable[mid].firstEmit <= tileStart) { lo = mid; } else { hi = mid - 1u; }
839
- }
840
- firstDrawInTile[tileIdx] = lo;
841
- }
842
- `;
52
+ import { packerForWgslType, PACKER_MAT4 } from "./heapScene/packers.js";
53
+ import { HEAP_SCAN_WGSL, SCAN_TILE_SIZE, SCAN_WG_SIZE, SCAN_MAX_RECORDS, TILE_K, RECORD_U32, RECORD_BYTES, } from "./heapScene/scanKernel.js";
54
+ import { GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16, } from "./heapScene/growBuffer.js";
55
+ import { UniformPool, IndexPool, DrawHeap, AttributeArena, IndexAllocator, insertSortedFreeBlock, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS, } from "./heapScene/pools.js";
56
+ import { encodeModeKey } from "./pipelineCache/index.js";
57
+ import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
843
58
  export function buildHeapScene(device, sig, initialDraws, opts = {}) {
844
59
  const atlasPool = opts.atlasPool;
845
60
  const colorAttachmentName = sig.colorNames[0];
@@ -872,6 +87,18 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
872
87
  const drawIdToLocalSlot = [];
873
88
  /** Per-draw index aval — for `indexPool.release` on removeDraw. */
874
89
  const drawIdToIndexAval = [];
90
+ /**
91
+ * Original spec retained per drawId so we can re-add the RO into a
92
+ * new bucket when its PipelineState modeKey changes (reactive
93
+ * cullmode flip, etc.). Cheap to retain — specs are mostly aval
94
+ * references.
95
+ */
96
+ const drawIdToSpec = [];
97
+ /** Per-draw ModeKeyTracker — subscribes to PS aval marks, recomputes
98
+ * the modeKey, schedules a rebucket if it changed. */
99
+ const drawIdToModeTracker = [];
100
+ /** drawIds whose PS marked since last update; drained by `update`. */
101
+ const dirtyModeKeyDrawIds = new Set();
875
102
  let nextDrawId = 0;
876
103
  /**
877
104
  * Unwrap an aval to its inner value, or pass through a plain value.
@@ -1246,12 +473,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1246
473
  label: `heapScene/${bucket.label}/scanBg`,
1247
474
  layout: scanBgl,
1248
475
  entries: [
1249
- { binding: 0, resource: { buffer: bucket.drawTableBuf.buffer } },
1250
- { binding: 1, resource: { buffer: bucket.blockSumsBuf.buffer } },
1251
- { binding: 2, resource: { buffer: bucket.blockOffsetsBuf.buffer } },
1252
- { binding: 3, resource: { buffer: bucket.indirectBuf } },
1253
- { binding: 4, resource: { buffer: bucket.paramsBuf } },
1254
- { binding: 5, resource: { buffer: bucket.firstDrawInTileBuf.buffer } },
476
+ { binding: 0, resource: { buffer: bucket.slots[0].drawTableBuf.buffer } },
477
+ { binding: 1, resource: { buffer: bucket.slots[0].blockSumsBuf.buffer } },
478
+ { binding: 2, resource: { buffer: bucket.slots[0].blockOffsetsBuf.buffer } },
479
+ { binding: 3, resource: { buffer: bucket.slots[0].indirectBuf } },
480
+ { binding: 4, resource: { buffer: bucket.slots[0].paramsBuf } },
481
+ { binding: 5, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } },
1255
482
  ],
1256
483
  });
1257
484
  }
@@ -1458,31 +685,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1458
685
  avalIds.set(av, vid);
1459
686
  return `c${vid}`;
1460
687
  };
1461
- const psContentIds = new WeakMap();
1462
- const psIdOf = (ps) => {
1463
- if (ps === undefined)
1464
- return "ps#default";
1465
- const cached = psContentIds.get(ps);
1466
- if (cached !== undefined)
1467
- return cached;
1468
- const r = ps.rasterizer;
1469
- const parts = [
1470
- avalIdOf(r.topology),
1471
- avalIdOf(r.cullMode),
1472
- avalIdOf(r.frontFace),
1473
- avalIdOf(r.depthBias),
1474
- ps.depth !== undefined
1475
- ? `d:${avalIdOf(ps.depth.write)}:${avalIdOf(ps.depth.compare)}:${avalIdOf(ps.depth.clamp)}`
1476
- : "d:_",
1477
- ps.stencil !== undefined ? "s:1" : "s:_",
1478
- avalIdOf(ps.blends),
1479
- avalIdOf(ps.alphaToCoverage),
1480
- avalIdOf(ps.blendConstant),
1481
- ];
1482
- const key = `ps#${parts.join("|")}`;
1483
- psContentIds.set(ps, key);
1484
- return key;
1485
- };
1486
688
  function resolvePipelineState(ps) {
1487
689
  if (ps === undefined) {
1488
690
  return {
@@ -1521,7 +723,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1521
723
  { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
1522
724
  ];
1523
725
  {
1524
- if (bucket.drawTableBuf === undefined) {
726
+ if (bucket.slots[0].drawTableBuf === undefined) {
1525
727
  throw new Error("heapScene: megacall bucket without drawTableBuf");
1526
728
  }
1527
729
  // Bind drawTable with size = recordCount * RECORD_BYTES so the VS
@@ -1529,9 +731,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1529
731
  // the live record count — keeps stale tail entries out of the
1530
732
  // binary search. Minimum one zero-record to satisfy WebGPU non-
1531
733
  // zero size constraint when the bucket is empty.
1532
- const dtBytes = Math.max(RECORD_BYTES, bucket.recordCount * RECORD_BYTES);
1533
- entries.push({ binding: 4, resource: { buffer: bucket.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: bucket.firstDrawInTileBuf.buffer } });
1534
- bucket.renderBoundRecordCount = bucket.recordCount;
734
+ const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0].recordCount * RECORD_BYTES);
735
+ entries.push({ binding: 4, resource: { buffer: bucket.slots[0].drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } });
736
+ bucket.slots[0].renderBoundRecordCount = bucket.slots[0].recordCount;
1535
737
  }
1536
738
  // Schema-driven texture + sampler entries. v1 user surface still
1537
739
  // accepts a single (texture, sampler) pair via HeapTextureSet —
@@ -1656,8 +858,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1656
858
  throw new Error("heapScene: findOrCreateBucket called before family build");
1657
859
  }
1658
860
  const fam = familyFor(effect);
1659
- const psKey = psIdOf(pipelineState);
1660
- const bk = `family#${fam.schema.id}|${psKey}`;
861
+ // ─── PS keying — VALUE based, not identity based ───────────────
862
+ // psIdOf used to hash by aval identity. That meant 20k ROs each
863
+ // constructed with `cval("back")` for cullMode hashed to 20k
864
+ // distinct keys → 20k buckets, all rendering with bitwise-
865
+ // identical state. Now the key is the bitfield-encoded modeKey
866
+ // (the actual value-set in the descriptor), so identical-value
867
+ // cvals collapse to ONE bucket. See docs/derived-modes.md and
868
+ // tests/heap-multi-pipeline-bucket.test.ts.
869
+ const psDescriptor = snapshotDescriptor(pipelineState, sig);
870
+ const psModeKey = encodeModeKey(psDescriptor);
871
+ const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
1661
872
  const existing = bucketByKey.get(bk);
1662
873
  if (existing !== undefined)
1663
874
  return existing;
@@ -1686,13 +897,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1686
897
  // refs).
1687
898
  const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
1688
899
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
900
+ const slot0 = {
901
+ pipeline,
902
+ drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
903
+ recordCount: 0, slotToRecord: [], recordToSlot: [],
904
+ totalEmitEstimate: 0,
905
+ scanDirty: false,
906
+ };
1689
907
  const bucket = {
1690
908
  // §6 family-merge: family buckets aren't keyed on a specific
1691
909
  // texture set — atlas placements are addressed per-RO via
1692
910
  // drawHeader fields (`pageRef` + `formatBits` + `origin` +
1693
911
  // `size`), and the bucket's atlas-binding ladder is driven by
1694
912
  // `atlasPool.pagesFor(format)`. Leave `textures` undefined.
1695
- label: bk, textures: undefined, layout, pipeline,
913
+ label: bk, textures: undefined, layout,
914
+ slots: [slot0],
1696
915
  bindGroup: null,
1697
916
  drawHeap,
1698
917
  drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
@@ -1701,10 +920,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1701
920
  localEntries: [], localToDrawId: [],
1702
921
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
1703
922
  drawSlots: new Set(), dirty: new Set(),
1704
- drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1705
- recordCount: 0, slotToRecord: [], recordToSlot: [],
1706
- totalEmitEstimate: 0,
1707
- scanDirty: false,
1708
923
  isAtlasBucket,
1709
924
  localAtlasReleases: [],
1710
925
  localAtlasTextures: [],
@@ -1728,30 +943,30 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1728
943
  label: `heapScene/${bk}/params`, size: 16,
1729
944
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1730
945
  });
1731
- bucket.drawTableBuf = dtBuf;
1732
- bucket.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
1733
- bucket.blockSumsBuf = blockSumsBuf;
1734
- bucket.blockOffsetsBuf = blockOffsetsBuf;
1735
- bucket.firstDrawInTileBuf = firstDrawInTileBuf;
1736
- bucket.indirectBuf = indirectBuf;
1737
- bucket.paramsBuf = paramsBuf;
946
+ bucket.slots[0].drawTableBuf = dtBuf;
947
+ bucket.slots[0].drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
948
+ bucket.slots[0].blockSumsBuf = blockSumsBuf;
949
+ bucket.slots[0].blockOffsetsBuf = blockOffsetsBuf;
950
+ bucket.slots[0].firstDrawInTileBuf = firstDrawInTileBuf;
951
+ bucket.slots[0].indirectBuf = indirectBuf;
952
+ bucket.slots[0].paramsBuf = paramsBuf;
1738
953
  const ensureScanBuffers = () => {
1739
- const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
954
+ const needBlocks = Math.max(1, Math.ceil(bucket.slots[0].recordCount / SCAN_TILE_SIZE));
1740
955
  blockSumsBuf.ensureCapacity(needBlocks * 4);
1741
956
  blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1742
957
  };
1743
958
  const rebuildScanBg = () => {
1744
- bucket.scanBindGroup = buildScanBindGroup(bucket);
959
+ bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
1745
960
  };
1746
961
  dtBuf.onResize(() => {
1747
962
  const grown = new Uint32Array(dtBuf.capacity / 4);
1748
- grown.set(bucket.drawTableShadow);
1749
- bucket.drawTableShadow = grown;
963
+ grown.set(bucket.slots[0].drawTableShadow);
964
+ bucket.slots[0].drawTableShadow = grown;
1750
965
  ensureScanBuffers();
1751
966
  bucket.bindGroup = buildBucketBindGroup(bucket);
1752
967
  rebuildScanBg();
1753
- bucket.drawTableDirtyMin = 0;
1754
- bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
968
+ bucket.slots[0].drawTableDirtyMin = 0;
969
+ bucket.slots[0].drawTableDirtyMax = bucket.slots[0].recordCount * RECORD_BYTES;
1755
970
  });
1756
971
  blockSumsBuf.onResize(rebuildScanBg);
1757
972
  blockOffsetsBuf.onResize(rebuildScanBg);
@@ -1759,7 +974,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1759
974
  rebuildScanBg();
1760
975
  bucket.bindGroup = buildBucketBindGroup(bucket);
1761
976
  });
1762
- bucket.scanBindGroup = buildScanBindGroup(bucket);
977
+ bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
1763
978
  }
1764
979
  bucket.bindGroup = buildBucketBindGroup(bucket);
1765
980
  drawHeap.onResize(() => {
@@ -2059,8 +1274,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2059
1274
  if (end > bucket.headerDirtyMax)
2060
1275
  bucket.headerDirtyMax = end;
2061
1276
  {
2062
- const dtBuf = bucket.drawTableBuf;
2063
- const recIdx = bucket.recordCount;
1277
+ const dtBuf = bucket.slots[0].drawTableBuf;
1278
+ const recIdx = bucket.slots[0].recordCount;
2064
1279
  if (recIdx >= SCAN_MAX_RECORDS) {
2065
1280
  throw new Error(`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
2066
1281
  `extend the scan to multi-level if you need more`);
@@ -2070,30 +1285,41 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2070
1285
  dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
2071
1286
  // Grow scan-side buffers if recordCount crosses a tile boundary.
2072
1287
  const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
2073
- bucket.blockSumsBuf.ensureCapacity(needBlocks * 4);
2074
- bucket.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
2075
- const shadow = bucket.drawTableShadow;
1288
+ bucket.slots[0].blockSumsBuf.ensureCapacity(needBlocks * 4);
1289
+ bucket.slots[0].blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1290
+ const shadow = bucket.slots[0].drawTableShadow;
2076
1291
  // firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
2077
1292
  shadow[recIdx * RECORD_U32 + 0] = 0;
2078
1293
  shadow[recIdx * RECORD_U32 + 1] = localSlot;
2079
1294
  shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
2080
1295
  shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
2081
1296
  shadow[recIdx * RECORD_U32 + 4] = instanceCount;
2082
- bucket.recordCount = recIdx + 1;
2083
- bucket.slotToRecord[localSlot] = recIdx;
2084
- bucket.recordToSlot[recIdx] = localSlot;
2085
- if (byteOff < bucket.drawTableDirtyMin)
2086
- bucket.drawTableDirtyMin = byteOff;
2087
- if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax)
2088
- bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
2089
- bucket.totalEmitEstimate += idxAlloc.count * instanceCount;
2090
- const newNumTiles = Math.max(1, Math.ceil(bucket.totalEmitEstimate / TILE_K));
2091
- bucket.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
2092
- bucket.scanDirty = true;
1297
+ bucket.slots[0].recordCount = recIdx + 1;
1298
+ bucket.slots[0].slotToRecord[localSlot] = recIdx;
1299
+ bucket.slots[0].recordToSlot[recIdx] = localSlot;
1300
+ if (byteOff < bucket.slots[0].drawTableDirtyMin)
1301
+ bucket.slots[0].drawTableDirtyMin = byteOff;
1302
+ if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
1303
+ bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
1304
+ bucket.slots[0].totalEmitEstimate += idxAlloc.count * instanceCount;
1305
+ const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0].totalEmitEstimate / TILE_K));
1306
+ bucket.slots[0].firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
1307
+ bucket.slots[0].scanDirty = true;
2093
1308
  }
2094
1309
  drawIdToBucket[drawId] = bucket;
2095
1310
  drawIdToLocalSlot[drawId] = localSlot;
2096
1311
  drawIdToIndexAval[drawId] = indicesAval;
1312
+ // ─── Reactive rebucket: track PS-modeKey changes ────────────────
1313
+ // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
1314
+ // schedule this RO for rebucket on the next update(). Today's
1315
+ // bucket key is the modeKey VALUE (Phase 5b), so changing the
1316
+ // value must move the RO to a different bucket. Without this
1317
+ // tracker the cval flip would be silently ignored.
1318
+ drawIdToSpec[drawId] = spec;
1319
+ const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => {
1320
+ dirtyModeKeyDrawIds.add(drawId);
1321
+ });
1322
+ drawIdToModeTracker[drawId] = tracker;
2097
1323
  // ─── §7 derived-uniforms registration ────────────────────────────
2098
1324
  // A uniform binding on this RO is either a value (aval/constant) or a rule —
2099
1325
  // collect the rules (explicit `derivedUniform(...)` values + standard trafo
@@ -2173,13 +1399,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2173
1399
  const removedCount = removedEntry !== undefined
2174
1400
  ? removedEntry.indexCount * removedEntry.instanceCount
2175
1401
  : 0;
2176
- bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
1402
+ bucket.slots[0].totalEmitEstimate = Math.max(0, bucket.slots[0].totalEmitEstimate - removedCount);
2177
1403
  // Swap-pop: move the last record into the freed slot, decrement
2178
1404
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
2179
1405
  // we only fix (drawIdx, indexStart, indexCount, instanceCount).
2180
- const recIdx = bucket.slotToRecord[localSlot];
2181
- const lastRecIdx = bucket.recordCount - 1;
2182
- const shadow = bucket.drawTableShadow;
1406
+ const recIdx = bucket.slots[0].slotToRecord[localSlot];
1407
+ const lastRecIdx = bucket.slots[0].recordCount - 1;
1408
+ const shadow = bucket.slots[0].drawTableShadow;
2183
1409
  if (recIdx !== lastRecIdx) {
2184
1410
  const dst = recIdx * RECORD_U32;
2185
1411
  const src = lastRecIdx * RECORD_U32;
@@ -2188,19 +1414,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2188
1414
  shadow[dst + 2] = shadow[src + 2];
2189
1415
  shadow[dst + 3] = shadow[src + 3];
2190
1416
  shadow[dst + 4] = shadow[src + 4];
2191
- const movedSlot = bucket.recordToSlot[lastRecIdx];
2192
- bucket.slotToRecord[movedSlot] = recIdx;
2193
- bucket.recordToSlot[recIdx] = movedSlot;
1417
+ const movedSlot = bucket.slots[0].recordToSlot[lastRecIdx];
1418
+ bucket.slots[0].slotToRecord[movedSlot] = recIdx;
1419
+ bucket.slots[0].recordToSlot[recIdx] = movedSlot;
2194
1420
  const byteOff = recIdx * RECORD_BYTES;
2195
- if (byteOff < bucket.drawTableDirtyMin)
2196
- bucket.drawTableDirtyMin = byteOff;
2197
- if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax)
2198
- bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
1421
+ if (byteOff < bucket.slots[0].drawTableDirtyMin)
1422
+ bucket.slots[0].drawTableDirtyMin = byteOff;
1423
+ if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
1424
+ bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
2199
1425
  }
2200
- bucket.slotToRecord[localSlot] = -1;
2201
- bucket.recordToSlot[lastRecIdx] = -1;
2202
- bucket.recordCount = lastRecIdx;
2203
- bucket.scanDirty = true;
1426
+ bucket.slots[0].slotToRecord[localSlot] = -1;
1427
+ bucket.slots[0].recordToSlot[lastRecIdx] = -1;
1428
+ bucket.slots[0].recordCount = lastRecIdx;
1429
+ bucket.slots[0].scanDirty = true;
2204
1430
  }
2205
1431
  // Release pool entries — refcount drops; if zero, allocation freed.
2206
1432
  const avals = bucket.localPerDrawAvals[localSlot];
@@ -2254,8 +1480,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2254
1480
  drawIdToBucket[drawId] = undefined;
2255
1481
  drawIdToLocalSlot[drawId] = undefined;
2256
1482
  drawIdToIndexAval[drawId] = undefined;
1483
+ drawIdToSpec[drawId] = undefined;
1484
+ const oldTracker = drawIdToModeTracker[drawId];
1485
+ if (oldTracker !== undefined)
1486
+ oldTracker.dispose();
1487
+ drawIdToModeTracker[drawId] = undefined;
1488
+ dirtyModeKeyDrawIds.delete(drawId);
2257
1489
  stats.totalDraws--;
2258
1490
  stats.geometryBytes = arenaBytes(arena);
1491
+ // GC the bucket if it just emptied (common after reactive
1492
+ // PS rebucket leaves the source bucket with zero live ROs).
1493
+ gcBucket(bucket);
2259
1494
  }
2260
1495
  // ─── Aset reader (pull-driven on each frame) ──────────────────────
2261
1496
  let asetReader;
@@ -2345,6 +1580,38 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2345
1580
  let totalDirtyBytes = 0;
2346
1581
  sceneObj.evaluateAlways(token, (tok) => {
2347
1582
  drainAsetWith(tok);
1583
+ // 0. Reactive rebucket: any RO whose PipelineState modeKey
1584
+ // changed since last frame moves to its new bucket.
1585
+ // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
1586
+ // onDirty callbacks (one per leaf aval per RO). Calling
1587
+ // `recompute()` here re-snapshots the descriptor and tells
1588
+ // us whether the modeKey actually changed (vs the aval just
1589
+ // being marked-but-equal). If the key changed, we remove +
1590
+ // re-add the RO via the retained spec; addDraw routes it to
1591
+ // the correct (new) bucket via the value-keyed lookup.
1592
+ if (dirtyModeKeyDrawIds.size > 0) {
1593
+ const dirty = [...dirtyModeKeyDrawIds];
1594
+ dirtyModeKeyDrawIds.clear();
1595
+ for (const drawId of dirty) {
1596
+ const tracker = drawIdToModeTracker[drawId];
1597
+ if (tracker === undefined)
1598
+ continue; // RO removed since mark
1599
+ const oldKey = tracker.modeKey;
1600
+ if (!tracker.recompute())
1601
+ continue; // value unchanged
1602
+ if (tracker.modeKey === oldKey)
1603
+ continue; // belt + braces
1604
+ const spec = drawIdToSpec[drawId];
1605
+ if (spec === undefined)
1606
+ continue;
1607
+ removeDraw(drawId);
1608
+ const newId = addDrawImpl(spec, tok);
1609
+ // The drawId returned by addDraw is a fresh slot; we don't
1610
+ // attempt to preserve the caller's id mapping in v1. v2
1611
+ // could thread the original id through if needed.
1612
+ void newId;
1613
+ }
1614
+ }
2348
1615
  // 1. Pool: re-pack any aval whose value changed since last frame.
2349
1616
  // One writeBuffer per dirty aval, regardless of how many draws
2350
1617
  // reference it — sharing pays off here.
@@ -2441,11 +1708,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2441
1708
  bucket.headerDirtyMin = Infinity;
2442
1709
  bucket.headerDirtyMax = 0;
2443
1710
  }
2444
- if (bucket.drawTableDirtyMax > bucket.drawTableDirtyMin) {
2445
- const shadow = bucket.drawTableShadow;
2446
- device.queue.writeBuffer(bucket.drawTableBuf.buffer, bucket.drawTableDirtyMin, shadow.buffer, shadow.byteOffset + bucket.drawTableDirtyMin, bucket.drawTableDirtyMax - bucket.drawTableDirtyMin);
2447
- bucket.drawTableDirtyMin = Infinity;
2448
- bucket.drawTableDirtyMax = 0;
1711
+ if (bucket.slots[0].drawTableDirtyMax > bucket.slots[0].drawTableDirtyMin) {
1712
+ const shadow = bucket.slots[0].drawTableShadow;
1713
+ device.queue.writeBuffer(bucket.slots[0].drawTableBuf.buffer, bucket.slots[0].drawTableDirtyMin, shadow.buffer, shadow.byteOffset + bucket.slots[0].drawTableDirtyMin, bucket.slots[0].drawTableDirtyMax - bucket.slots[0].drawTableDirtyMin);
1714
+ bucket.slots[0].drawTableDirtyMin = Infinity;
1715
+ bucket.slots[0].drawTableDirtyMax = 0;
2449
1716
  }
2450
1717
  }
2451
1718
  }
@@ -2461,9 +1728,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2461
1728
  pass.setBindGroup(0, b.bindGroup);
2462
1729
  curBg = b.bindGroup;
2463
1730
  }
2464
- pass.setPipeline(b.pipeline);
2465
- if (b.recordCount > 0)
2466
- pass.drawIndirect(b.indirectBuf, 0);
1731
+ pass.setPipeline(b.slots[0].pipeline);
1732
+ if (b.slots[0].recordCount > 0)
1733
+ pass.drawIndirect(b.slots[0].indirectBuf, 0);
2467
1734
  }
2468
1735
  }
2469
1736
  /**
@@ -2505,7 +1772,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2505
1772
  }
2506
1773
  let anyDirty = false;
2507
1774
  for (const b of buckets) {
2508
- if (b.scanDirty) {
1775
+ if (b.slots[0].scanDirty) {
2509
1776
  anyDirty = true;
2510
1777
  break;
2511
1778
  }
@@ -2514,17 +1781,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2514
1781
  return;
2515
1782
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
2516
1783
  for (const b of buckets) {
2517
- if (!b.scanDirty)
1784
+ if (!b.slots[0].scanDirty)
2518
1785
  continue;
2519
1786
  // Rebuild render bind group if recordCount changed — its
2520
1787
  // drawTable binding is sized to recordCount * 16.
2521
- if (b.renderBoundRecordCount !== b.recordCount) {
1788
+ if (b.slots[0].renderBoundRecordCount !== b.slots[0].recordCount) {
2522
1789
  b.bindGroup = buildBucketBindGroup(b);
2523
1790
  }
2524
- const numRecords = b.recordCount;
1791
+ const numRecords = b.slots[0].recordCount;
2525
1792
  const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2526
- device.queue.writeBuffer(b.paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
2527
- pass.setBindGroup(0, b.scanBindGroup);
1793
+ device.queue.writeBuffer(b.slots[0].paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
1794
+ pass.setBindGroup(0, b.slots[0].scanBindGroup);
2528
1795
  pass.setPipeline(scanPipeTile);
2529
1796
  pass.dispatchWorkgroups(numBlocks, 1, 1);
2530
1797
  pass.setPipeline(scanPipeBlocks);
@@ -2535,11 +1802,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2535
1802
  // must cover the worst-case totalEmit. Each WG handles WG_SIZE
2536
1803
  // tiles; +1 for the sentinel slot.
2537
1804
  const SCAN_WG_SIZE = 256;
2538
- const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
1805
+ const numTilesCap = Math.max(1, Math.ceil(b.slots[0].totalEmitEstimate / TILE_K));
2539
1806
  const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
2540
1807
  pass.setPipeline(scanPipeBuildTileIndex);
2541
1808
  pass.dispatchWorkgroups(tileWgs, 1, 1);
2542
- b.scanDirty = false;
1809
+ b.slots[0].scanDirty = false;
2543
1810
  }
2544
1811
  pass.end();
2545
1812
  }
@@ -2572,29 +1839,53 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2572
1839
  pass.end();
2573
1840
  device.queue.submit([enc.finish()]);
2574
1841
  }
1842
+ function destroyBucketResources(b) {
1843
+ b.drawHeap.destroy();
1844
+ for (const slot of b.slots) {
1845
+ slot.drawTableBuf?.destroy();
1846
+ slot.blockSumsBuf?.destroy();
1847
+ slot.blockOffsetsBuf?.destroy();
1848
+ slot.firstDrawInTileBuf?.destroy();
1849
+ slot.indirectBuf?.destroy();
1850
+ slot.paramsBuf?.destroy();
1851
+ }
1852
+ }
1853
+ /**
1854
+ * Remove an empty bucket from the scene's bucket list + key index +
1855
+ * destroy its GPU resources. Called from removeDraw when an RO's
1856
+ * removal leaves the bucket with zero live slots — common after
1857
+ * reactive PS rebucket leaves the source bucket empty.
1858
+ */
1859
+ function gcBucket(b) {
1860
+ if (b.drawSlots.size !== 0)
1861
+ return;
1862
+ const idx = buckets.indexOf(b);
1863
+ if (idx >= 0)
1864
+ buckets.splice(idx, 1);
1865
+ for (const [k, v] of bucketByKey) {
1866
+ if (v === b) {
1867
+ bucketByKey.delete(k);
1868
+ break;
1869
+ }
1870
+ }
1871
+ destroyBucketResources(b);
1872
+ }
2575
1873
  function dispose() {
2576
1874
  arena.attrs.destroy();
2577
1875
  arena.indices.destroy();
2578
- for (const b of buckets) {
2579
- b.drawHeap.destroy();
2580
- b.drawTableBuf?.destroy();
2581
- b.blockSumsBuf?.destroy();
2582
- b.blockOffsetsBuf?.destroy();
2583
- b.firstDrawInTileBuf?.destroy();
2584
- b.indirectBuf?.destroy();
2585
- b.paramsBuf?.destroy();
2586
- }
1876
+ for (const b of buckets)
1877
+ destroyBucketResources(b);
2587
1878
  }
2588
1879
  // Test-only escape hatch for inspecting megacall bucket state. Not
2589
1880
  // part of the public API surface — keep cast at use-site.
2590
1881
  const _debug = {
2591
1882
  bucketsForTest() {
2592
1883
  return buckets.map(b => ({
2593
- indirectBuf: b.indirectBuf,
2594
- drawTableBuf: b.drawTableBuf?.buffer,
2595
- firstDrawInTileBuf: b.firstDrawInTileBuf?.buffer,
2596
- totalEmitEstimate: b.totalEmitEstimate,
2597
- recordCount: b.recordCount,
1884
+ indirectBuf: b.slots[0].indirectBuf,
1885
+ drawTableBuf: b.slots[0].drawTableBuf?.buffer,
1886
+ firstDrawInTileBuf: b.slots[0].firstDrawInTileBuf?.buffer,
1887
+ totalEmitEstimate: b.slots[0].totalEmitEstimate,
1888
+ recordCount: b.slots[0].recordCount,
2598
1889
  layout: b.layout,
2599
1890
  }));
2600
1891
  },
@@ -2639,19 +1930,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2639
1930
  const TILE_K_LOCAL = 64;
2640
1931
  const dcs = [];
2641
1932
  for (const b of buckets) {
2642
- const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
1933
+ const dhSize = Math.min(b.drawHeap.buffer.size, b.slots[0].recordCount * b.layout.drawHeaderBytes);
2643
1934
  if (dhSize === 0)
2644
1935
  continue;
2645
1936
  const dh = stage(b.drawHeap.buffer, dhSize);
2646
- const dt = b.drawTableBuf !== undefined && b.recordCount > 0
2647
- ? stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES)
1937
+ const dt = b.slots[0].drawTableBuf !== undefined && b.slots[0].recordCount > 0
1938
+ ? stage(b.slots[0].drawTableBuf.buffer, b.slots[0].recordCount * RECORD_BYTES)
2648
1939
  : undefined;
2649
- const totalEmit = b.totalEmitEstimate;
1940
+ const totalEmit = b.slots[0].totalEmitEstimate;
2650
1941
  const numTiles = totalEmit > 0 ? Math.ceil(totalEmit / TILE_K_LOCAL) : 0;
2651
- const fdt = b.firstDrawInTileBuf !== undefined && numTiles > 0
2652
- ? stage(b.firstDrawInTileBuf.buffer, Math.min(b.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
1942
+ const fdt = b.slots[0].firstDrawInTileBuf !== undefined && numTiles > 0
1943
+ ? stage(b.slots[0].firstDrawInTileBuf.buffer, Math.min(b.slots[0].firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
2653
1944
  : undefined;
2654
- const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
1945
+ const ind = b.slots[0].indirectBuf !== undefined ? stage(b.slots[0].indirectBuf, 16) : undefined;
2655
1946
  dcs.push({
2656
1947
  bucket: b, drawHeap: dh, numTiles,
2657
1948
  ...(dt !== undefined ? { drawTable: dt } : {}),
@@ -2684,7 +1975,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2684
1975
  for (const dc of dcs) {
2685
1976
  const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
2686
1977
  const stride = dc.bucket.layout.drawHeaderBytes;
2687
- const recordCount = dc.bucket.recordCount;
1978
+ const recordCount = dc.bucket.slots[0].recordCount;
2688
1979
  for (let slot = 0; slot < recordCount; slot++) {
2689
1980
  const slotOff = slot * stride;
2690
1981
  for (const f of dc.bucket.layout.drawHeaderFields) {
@@ -2836,7 +2127,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2836
2127
  // ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
2837
2128
  if (dc.indirect !== undefined) {
2838
2129
  const ind = new Uint32Array(dc.indirect.getMappedRange());
2839
- const expectedTotal = dc.bucket.totalEmitEstimate;
2130
+ const expectedTotal = dc.bucket.slots[0].totalEmitEstimate;
2840
2131
  const got = ind[0];
2841
2132
  if (got !== expectedTotal) {
2842
2133
  push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
@@ -2926,11 +2217,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2926
2217
  const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
2927
2218
  const dcs = [];
2928
2219
  for (const b of buckets) {
2929
- if (b.recordCount === 0 || b.totalEmitEstimate === 0)
2220
+ if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
2930
2221
  continue;
2931
- const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes));
2932
- const dt = stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES);
2933
- dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.totalEmitEstimate });
2222
+ const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.slots[0].recordCount * b.layout.drawHeaderBytes));
2223
+ const dt = stage(b.slots[0].drawTableBuf.buffer, b.slots[0].recordCount * RECORD_BYTES);
2224
+ dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.slots[0].totalEmitEstimate });
2934
2225
  }
2935
2226
  device.queue.submit([enc.finish()]);
2936
2227
  await arenaCopy.mapAsync(GPUMapMode.READ);
@@ -2954,7 +2245,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2954
2245
  const dt = new Uint32Array(dc.drawTable.getMappedRange());
2955
2246
  bucketDt.push(dt);
2956
2247
  bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
2957
- for (let r = 0; r < dc.bucket.recordCount; r++) {
2248
+ for (let r = 0; r < dc.bucket.slots[0].recordCount; r++) {
2958
2249
  dc.firstEmit.push(dt[r * RECORD_U32 + 0]);
2959
2250
  }
2960
2251
  bucketCumEmit.push(totalEmitGlobal);
@@ -3003,7 +2294,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3003
2294
  }
3004
2295
  emitsChecked++;
3005
2296
  // Binary search for slot.
3006
- const recCount = dc.bucket.recordCount;
2297
+ const recCount = dc.bucket.slots[0].recordCount;
3007
2298
  let lo = 0, hi = recCount - 1;
3008
2299
  while (lo < hi) {
3009
2300
  const mid = (lo + hi + 1) >>> 1;
@@ -3199,12 +2490,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3199
2490
  ? new Uint32Array(indicesCopy.getMappedRange())
3200
2491
  : new Uint32Array(0);
3201
2492
  for (const target of buckets) {
3202
- if (target.recordCount === 0 || target.totalEmitEstimate === 0) {
2493
+ if (target.slots[0].recordCount === 0 || target.slots[0].totalEmitEstimate === 0) {
3203
2494
  bucketIdx++;
3204
2495
  continue;
3205
2496
  }
3206
- const recordCount = target.recordCount;
3207
- const totalEmit = target.totalEmitEstimate;
2497
+ const recordCount = target.slots[0].recordCount;
2498
+ const totalEmit = target.slots[0].totalEmitEstimate;
3208
2499
  const totalTris = Math.floor(totalEmit / 3);
3209
2500
  const remainder = totalEmit % 3;
3210
2501
  if (remainder !== 0) {
@@ -3221,7 +2512,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3221
2512
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3222
2513
  });
3223
2514
  const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
3224
- enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
2515
+ enc0.copyBufferToBuffer(target.slots[0].drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3225
2516
  enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
3226
2517
  device.queue.submit([enc0.finish()]);
3227
2518
  await dtCopy.mapAsync(GPUMapMode.READ);
@@ -3270,7 +2561,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3270
2561
  });
3271
2562
  {
3272
2563
  const enc1 = device.createCommandEncoder();
3273
- enc1.copyBufferToBuffer(target.firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
2564
+ enc1.copyBufferToBuffer(target.slots[0].firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
3274
2565
  device.queue.submit([enc1.finish()]);
3275
2566
  await fdtCopy.mapAsync(GPUMapMode.READ);
3276
2567
  }
@@ -3425,12 +2716,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3425
2716
  // own bytes).
3426
2717
  bucketIdx = 0;
3427
2718
  for (const target of buckets) {
3428
- if (target.recordCount === 0) {
2719
+ if (target.slots[0].recordCount === 0) {
3429
2720
  bucketIdx++;
3430
2721
  continue;
3431
2722
  }
3432
2723
  // Re-stage drawHeap for this bucket.
3433
- const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
2724
+ const dhBytes = target.slots[0].recordCount * target.layout.drawHeaderBytes;
3434
2725
  const dhCopy = device.createBuffer({
3435
2726
  size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3436
2727
  });
@@ -3440,7 +2731,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3440
2731
  await dhCopy.mapAsync(GPUMapMode.READ);
3441
2732
  const dhU32 = new Uint32Array(dhCopy.getMappedRange());
3442
2733
  const stride2 = target.layout.drawHeaderBytes;
3443
- for (let slot = 0; slot < target.recordCount; slot++) {
2734
+ for (let slot = 0; slot < target.slots[0].recordCount; slot++) {
3444
2735
  for (const f of target.layout.drawHeaderFields) {
3445
2736
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
3446
2737
  continue;
@@ -3500,11 +2791,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3500
2791
  let gpuMismatches = 0;
3501
2792
  const push = (s) => { if (issues.length < 30)
3502
2793
  issues.push(s); };
3503
- const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
2794
+ const target = buckets.find(b => b.slots[0].recordCount > 0 && b.slots[0].totalEmitEstimate > 0);
3504
2795
  if (target === undefined)
3505
2796
  return { emitsChecked: 0, gpuMismatches: 0, issues };
3506
- const recordCount = target.recordCount;
3507
- const totalEmit = target.totalEmitEstimate;
2797
+ const recordCount = target.slots[0].recordCount;
2798
+ const totalEmit = target.slots[0].totalEmitEstimate;
3508
2799
  const sampleCount = Math.min(samples, totalEmit);
3509
2800
  const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
3510
2801
  // CPU's firstEmit is GPU-computed — must read it back from the
@@ -3517,7 +2808,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3517
2808
  });
3518
2809
  {
3519
2810
  const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
3520
- enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
2811
+ enc0.copyBufferToBuffer(target.slots[0].drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3521
2812
  device.queue.submit([enc0.finish()]);
3522
2813
  await dtCopy.mapAsync(GPUMapMode.READ);
3523
2814
  }
@@ -3621,8 +2912,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3621
2912
  const bg = device.createBindGroup({
3622
2913
  layout: bgl,
3623
2914
  entries: [
3624
- { binding: 0, resource: { buffer: target.drawTableBuf.buffer } },
3625
- { binding: 1, resource: { buffer: target.firstDrawInTileBuf.buffer } },
2915
+ { binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
2916
+ { binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
3626
2917
  { binding: 2, resource: { buffer: sampleBuf } },
3627
2918
  { binding: 3, resource: { buffer: arena.indices.buffer } },
3628
2919
  { binding: 4, resource: { buffer: outBuf } },