@aardworx/wombat.rendering 0.9.14 → 0.9.16

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 +62 -0
  2. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
  3. package/dist/runtime/derivedModes/modeKeyCpu.js +225 -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 +346 -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 +251 -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 +397 -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,13 @@ 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";
58
+ import { addMarkingCallback } from "@aardworx/wombat.adaptive";
843
59
  export function buildHeapScene(device, sig, initialDraws, opts = {}) {
844
60
  const atlasPool = opts.atlasPool;
845
61
  const colorAttachmentName = sig.colorNames[0];
@@ -872,6 +88,55 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
872
88
  const drawIdToLocalSlot = [];
873
89
  /** Per-draw index aval — for `indexPool.release` on removeDraw. */
874
90
  const drawIdToIndexAval = [];
91
+ /**
92
+ * Original spec retained per drawId so we can re-add the RO into a
93
+ * new bucket when its PipelineState modeKey changes (reactive
94
+ * cullmode flip, etc.). Cheap to retain — specs are mostly aval
95
+ * references.
96
+ */
97
+ const drawIdToSpec = [];
98
+ /** Per-draw ModeKeyTracker — recomputes the modeKey on demand. Does
99
+ * NOT install per-instance marking callbacks; subscriptions are
100
+ * scene-level (deduped by aval identity below). */
101
+ const drawIdToModeTracker = [];
102
+ /** drawIds whose PS marked since last update; drained by `update`. */
103
+ const dirtyModeKeyDrawIds = new Set();
104
+ /**
105
+ * Scene-level mode-aval subscription dedupe. One IDisposable per
106
+ * unique leaf aval, plus a Set of dependent drawIds. When the aval
107
+ * marks, all drawIds in the set become dirty in O(1) per RO instead
108
+ * of installing N separate addMarkingCallbacks. The big win for the
109
+ * shared-cullCval case (20k ROs subscribing to one cval → 1 callback
110
+ * instead of 20k).
111
+ */
112
+ const modeAvalToDrawIds = new Map();
113
+ const modeAvalCallbacks = new Map();
114
+ function subscribeModeLeaf(av, drawId) {
115
+ let set = modeAvalToDrawIds.get(av);
116
+ if (set === undefined) {
117
+ set = new Set();
118
+ modeAvalToDrawIds.set(av, set);
119
+ modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
120
+ for (const id of set)
121
+ dirtyModeKeyDrawIds.add(id);
122
+ }));
123
+ }
124
+ set.add(drawId);
125
+ }
126
+ function unsubscribeModeLeaf(av, drawId) {
127
+ const set = modeAvalToDrawIds.get(av);
128
+ if (set === undefined)
129
+ return;
130
+ set.delete(drawId);
131
+ if (set.size === 0) {
132
+ modeAvalToDrawIds.delete(av);
133
+ const cb = modeAvalCallbacks.get(av);
134
+ if (cb !== undefined) {
135
+ cb.dispose();
136
+ modeAvalCallbacks.delete(av);
137
+ }
138
+ }
139
+ }
875
140
  let nextDrawId = 0;
876
141
  /**
877
142
  * Unwrap an aval to its inner value, or pass through a plain value.
@@ -1246,12 +511,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1246
511
  label: `heapScene/${bucket.label}/scanBg`,
1247
512
  layout: scanBgl,
1248
513
  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 } },
514
+ { binding: 0, resource: { buffer: bucket.slots[0].drawTableBuf.buffer } },
515
+ { binding: 1, resource: { buffer: bucket.slots[0].blockSumsBuf.buffer } },
516
+ { binding: 2, resource: { buffer: bucket.slots[0].blockOffsetsBuf.buffer } },
517
+ { binding: 3, resource: { buffer: bucket.slots[0].indirectBuf } },
518
+ { binding: 4, resource: { buffer: bucket.slots[0].paramsBuf } },
519
+ { binding: 5, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } },
1255
520
  ],
1256
521
  });
1257
522
  }
@@ -1458,31 +723,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1458
723
  avalIds.set(av, vid);
1459
724
  return `c${vid}`;
1460
725
  };
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
726
  function resolvePipelineState(ps) {
1487
727
  if (ps === undefined) {
1488
728
  return {
@@ -1521,7 +761,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1521
761
  { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
1522
762
  ];
1523
763
  {
1524
- if (bucket.drawTableBuf === undefined) {
764
+ if (bucket.slots[0].drawTableBuf === undefined) {
1525
765
  throw new Error("heapScene: megacall bucket without drawTableBuf");
1526
766
  }
1527
767
  // Bind drawTable with size = recordCount * RECORD_BYTES so the VS
@@ -1529,9 +769,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1529
769
  // the live record count — keeps stale tail entries out of the
1530
770
  // binary search. Minimum one zero-record to satisfy WebGPU non-
1531
771
  // 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;
772
+ const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0].recordCount * RECORD_BYTES);
773
+ 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 } });
774
+ bucket.slots[0].renderBoundRecordCount = bucket.slots[0].recordCount;
1535
775
  }
1536
776
  // Schema-driven texture + sampler entries. v1 user surface still
1537
777
  // accepts a single (texture, sampler) pair via HeapTextureSet —
@@ -1656,8 +896,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1656
896
  throw new Error("heapScene: findOrCreateBucket called before family build");
1657
897
  }
1658
898
  const fam = familyFor(effect);
1659
- const psKey = psIdOf(pipelineState);
1660
- const bk = `family#${fam.schema.id}|${psKey}`;
899
+ // ─── PS keying — VALUE based, not identity based ───────────────
900
+ // psIdOf used to hash by aval identity. That meant 20k ROs each
901
+ // constructed with `cval("back")` for cullMode hashed to 20k
902
+ // distinct keys → 20k buckets, all rendering with bitwise-
903
+ // identical state. Now the key is the bitfield-encoded modeKey
904
+ // (the actual value-set in the descriptor), so identical-value
905
+ // cvals collapse to ONE bucket. See docs/derived-modes.md and
906
+ // tests/heap-multi-pipeline-bucket.test.ts.
907
+ const psDescriptor = snapshotDescriptor(pipelineState, sig);
908
+ const psModeKey = encodeModeKey(psDescriptor);
909
+ const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
1661
910
  const existing = bucketByKey.get(bk);
1662
911
  if (existing !== undefined)
1663
912
  return existing;
@@ -1686,13 +935,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1686
935
  // refs).
1687
936
  const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
1688
937
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
938
+ const slot0 = {
939
+ pipeline,
940
+ drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
941
+ recordCount: 0, slotToRecord: [], recordToSlot: [],
942
+ totalEmitEstimate: 0,
943
+ scanDirty: false,
944
+ };
1689
945
  const bucket = {
1690
946
  // §6 family-merge: family buckets aren't keyed on a specific
1691
947
  // texture set — atlas placements are addressed per-RO via
1692
948
  // drawHeader fields (`pageRef` + `formatBits` + `origin` +
1693
949
  // `size`), and the bucket's atlas-binding ladder is driven by
1694
950
  // `atlasPool.pagesFor(format)`. Leave `textures` undefined.
1695
- label: bk, textures: undefined, layout, pipeline,
951
+ label: bk, textures: undefined, layout,
952
+ slots: [slot0],
1696
953
  bindGroup: null,
1697
954
  drawHeap,
1698
955
  drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
@@ -1701,10 +958,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1701
958
  localEntries: [], localToDrawId: [],
1702
959
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
1703
960
  drawSlots: new Set(), dirty: new Set(),
1704
- drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1705
- recordCount: 0, slotToRecord: [], recordToSlot: [],
1706
- totalEmitEstimate: 0,
1707
- scanDirty: false,
1708
961
  isAtlasBucket,
1709
962
  localAtlasReleases: [],
1710
963
  localAtlasTextures: [],
@@ -1728,30 +981,30 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1728
981
  label: `heapScene/${bk}/params`, size: 16,
1729
982
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1730
983
  });
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;
984
+ bucket.slots[0].drawTableBuf = dtBuf;
985
+ bucket.slots[0].drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
986
+ bucket.slots[0].blockSumsBuf = blockSumsBuf;
987
+ bucket.slots[0].blockOffsetsBuf = blockOffsetsBuf;
988
+ bucket.slots[0].firstDrawInTileBuf = firstDrawInTileBuf;
989
+ bucket.slots[0].indirectBuf = indirectBuf;
990
+ bucket.slots[0].paramsBuf = paramsBuf;
1738
991
  const ensureScanBuffers = () => {
1739
- const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
992
+ const needBlocks = Math.max(1, Math.ceil(bucket.slots[0].recordCount / SCAN_TILE_SIZE));
1740
993
  blockSumsBuf.ensureCapacity(needBlocks * 4);
1741
994
  blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1742
995
  };
1743
996
  const rebuildScanBg = () => {
1744
- bucket.scanBindGroup = buildScanBindGroup(bucket);
997
+ bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
1745
998
  };
1746
999
  dtBuf.onResize(() => {
1747
1000
  const grown = new Uint32Array(dtBuf.capacity / 4);
1748
- grown.set(bucket.drawTableShadow);
1749
- bucket.drawTableShadow = grown;
1001
+ grown.set(bucket.slots[0].drawTableShadow);
1002
+ bucket.slots[0].drawTableShadow = grown;
1750
1003
  ensureScanBuffers();
1751
1004
  bucket.bindGroup = buildBucketBindGroup(bucket);
1752
1005
  rebuildScanBg();
1753
- bucket.drawTableDirtyMin = 0;
1754
- bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
1006
+ bucket.slots[0].drawTableDirtyMin = 0;
1007
+ bucket.slots[0].drawTableDirtyMax = bucket.slots[0].recordCount * RECORD_BYTES;
1755
1008
  });
1756
1009
  blockSumsBuf.onResize(rebuildScanBg);
1757
1010
  blockOffsetsBuf.onResize(rebuildScanBg);
@@ -1759,7 +1012,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1759
1012
  rebuildScanBg();
1760
1013
  bucket.bindGroup = buildBucketBindGroup(bucket);
1761
1014
  });
1762
- bucket.scanBindGroup = buildScanBindGroup(bucket);
1015
+ bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
1763
1016
  }
1764
1017
  bucket.bindGroup = buildBucketBindGroup(bucket);
1765
1018
  drawHeap.onResize(() => {
@@ -2059,8 +1312,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2059
1312
  if (end > bucket.headerDirtyMax)
2060
1313
  bucket.headerDirtyMax = end;
2061
1314
  {
2062
- const dtBuf = bucket.drawTableBuf;
2063
- const recIdx = bucket.recordCount;
1315
+ const dtBuf = bucket.slots[0].drawTableBuf;
1316
+ const recIdx = bucket.slots[0].recordCount;
2064
1317
  if (recIdx >= SCAN_MAX_RECORDS) {
2065
1318
  throw new Error(`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
2066
1319
  `extend the scan to multi-level if you need more`);
@@ -2070,30 +1323,43 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2070
1323
  dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
2071
1324
  // Grow scan-side buffers if recordCount crosses a tile boundary.
2072
1325
  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;
1326
+ bucket.slots[0].blockSumsBuf.ensureCapacity(needBlocks * 4);
1327
+ bucket.slots[0].blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1328
+ const shadow = bucket.slots[0].drawTableShadow;
2076
1329
  // firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
2077
1330
  shadow[recIdx * RECORD_U32 + 0] = 0;
2078
1331
  shadow[recIdx * RECORD_U32 + 1] = localSlot;
2079
1332
  shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
2080
1333
  shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
2081
1334
  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;
1335
+ bucket.slots[0].recordCount = recIdx + 1;
1336
+ bucket.slots[0].slotToRecord[localSlot] = recIdx;
1337
+ bucket.slots[0].recordToSlot[recIdx] = localSlot;
1338
+ if (byteOff < bucket.slots[0].drawTableDirtyMin)
1339
+ bucket.slots[0].drawTableDirtyMin = byteOff;
1340
+ if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
1341
+ bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
1342
+ bucket.slots[0].totalEmitEstimate += idxAlloc.count * instanceCount;
1343
+ const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0].totalEmitEstimate / TILE_K));
1344
+ bucket.slots[0].firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
1345
+ bucket.slots[0].scanDirty = true;
2093
1346
  }
2094
1347
  drawIdToBucket[drawId] = bucket;
2095
1348
  drawIdToLocalSlot[drawId] = localSlot;
2096
1349
  drawIdToIndexAval[drawId] = indicesAval;
1350
+ // ─── Reactive rebucket: track PS-modeKey changes ────────────────
1351
+ // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
1352
+ // schedule this RO for rebucket on the next update(). Today's
1353
+ // bucket key is the modeKey VALUE (Phase 5b), so changing the
1354
+ // value must move the RO to a different bucket. Without this
1355
+ // tracker the cval flip would be silently ignored.
1356
+ drawIdToSpec[drawId] = spec;
1357
+ // skipSubscribe: this tracker doesn't install its own callbacks.
1358
+ // The scene-level `modeAvalToDrawIds` does it once per unique aval
1359
+ // (the 20k-ROs-share-one-cullCval optimization).
1360
+ const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => { dirtyModeKeyDrawIds.add(drawId); }, { skipSubscribe: true });
1361
+ drawIdToModeTracker[drawId] = tracker;
1362
+ tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
2097
1363
  // ─── §7 derived-uniforms registration ────────────────────────────
2098
1364
  // A uniform binding on this RO is either a value (aval/constant) or a rule —
2099
1365
  // collect the rules (explicit `derivedUniform(...)` values + standard trafo
@@ -2173,13 +1439,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2173
1439
  const removedCount = removedEntry !== undefined
2174
1440
  ? removedEntry.indexCount * removedEntry.instanceCount
2175
1441
  : 0;
2176
- bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
1442
+ bucket.slots[0].totalEmitEstimate = Math.max(0, bucket.slots[0].totalEmitEstimate - removedCount);
2177
1443
  // Swap-pop: move the last record into the freed slot, decrement
2178
1444
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
2179
1445
  // we only fix (drawIdx, indexStart, indexCount, instanceCount).
2180
- const recIdx = bucket.slotToRecord[localSlot];
2181
- const lastRecIdx = bucket.recordCount - 1;
2182
- const shadow = bucket.drawTableShadow;
1446
+ const recIdx = bucket.slots[0].slotToRecord[localSlot];
1447
+ const lastRecIdx = bucket.slots[0].recordCount - 1;
1448
+ const shadow = bucket.slots[0].drawTableShadow;
2183
1449
  if (recIdx !== lastRecIdx) {
2184
1450
  const dst = recIdx * RECORD_U32;
2185
1451
  const src = lastRecIdx * RECORD_U32;
@@ -2188,19 +1454,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2188
1454
  shadow[dst + 2] = shadow[src + 2];
2189
1455
  shadow[dst + 3] = shadow[src + 3];
2190
1456
  shadow[dst + 4] = shadow[src + 4];
2191
- const movedSlot = bucket.recordToSlot[lastRecIdx];
2192
- bucket.slotToRecord[movedSlot] = recIdx;
2193
- bucket.recordToSlot[recIdx] = movedSlot;
1457
+ const movedSlot = bucket.slots[0].recordToSlot[lastRecIdx];
1458
+ bucket.slots[0].slotToRecord[movedSlot] = recIdx;
1459
+ bucket.slots[0].recordToSlot[recIdx] = movedSlot;
2194
1460
  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;
1461
+ if (byteOff < bucket.slots[0].drawTableDirtyMin)
1462
+ bucket.slots[0].drawTableDirtyMin = byteOff;
1463
+ if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
1464
+ bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
2199
1465
  }
2200
- bucket.slotToRecord[localSlot] = -1;
2201
- bucket.recordToSlot[lastRecIdx] = -1;
2202
- bucket.recordCount = lastRecIdx;
2203
- bucket.scanDirty = true;
1466
+ bucket.slots[0].slotToRecord[localSlot] = -1;
1467
+ bucket.slots[0].recordToSlot[lastRecIdx] = -1;
1468
+ bucket.slots[0].recordCount = lastRecIdx;
1469
+ bucket.slots[0].scanDirty = true;
2204
1470
  }
2205
1471
  // Release pool entries — refcount drops; if zero, allocation freed.
2206
1472
  const avals = bucket.localPerDrawAvals[localSlot];
@@ -2254,8 +1520,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2254
1520
  drawIdToBucket[drawId] = undefined;
2255
1521
  drawIdToLocalSlot[drawId] = undefined;
2256
1522
  drawIdToIndexAval[drawId] = undefined;
1523
+ drawIdToSpec[drawId] = undefined;
1524
+ const oldTracker = drawIdToModeTracker[drawId];
1525
+ if (oldTracker !== undefined) {
1526
+ oldTracker.forEachLeaf((av) => unsubscribeModeLeaf(av, drawId));
1527
+ oldTracker.dispose();
1528
+ }
1529
+ drawIdToModeTracker[drawId] = undefined;
1530
+ dirtyModeKeyDrawIds.delete(drawId);
2257
1531
  stats.totalDraws--;
2258
1532
  stats.geometryBytes = arenaBytes(arena);
1533
+ // GC the bucket if it just emptied (common after reactive
1534
+ // PS rebucket leaves the source bucket with zero live ROs).
1535
+ gcBucket(bucket);
2259
1536
  }
2260
1537
  // ─── Aset reader (pull-driven on each frame) ──────────────────────
2261
1538
  let asetReader;
@@ -2345,6 +1622,114 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2345
1622
  let totalDirtyBytes = 0;
2346
1623
  sceneObj.evaluateAlways(token, (tok) => {
2347
1624
  drainAsetWith(tok);
1625
+ // 0. Reactive rebucket: any RO whose PipelineState modeKey
1626
+ // changed since last frame moves to its new bucket.
1627
+ // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
1628
+ // onDirty callbacks (one per leaf aval per RO). Calling
1629
+ // `recompute()` here re-snapshots the descriptor and tells
1630
+ // us whether the modeKey actually changed (vs the aval just
1631
+ // being marked-but-equal). If the key changed, we remove +
1632
+ // re-add the RO via the retained spec; addDraw routes it to
1633
+ // the correct (new) bucket via the value-keyed lookup.
1634
+ if (dirtyModeKeyDrawIds.size > 0) {
1635
+ const dirty = [...dirtyModeKeyDrawIds];
1636
+ dirtyModeKeyDrawIds.clear();
1637
+ // Bucket-level fast path: group dirty drawIds by bucket. If
1638
+ // ALL of a bucket's ROs are dirty (typical SG case where one
1639
+ // shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
1640
+ // wrapping 20k leaves), the whole bucket transitions together.
1641
+ // Rebuild the bucket's pipeline + rename in bucketByKey,
1642
+ // O(1) work instead of N remove+add cycles.
1643
+ const byBucket = new Map();
1644
+ for (const drawId of dirty) {
1645
+ const b = drawIdToBucket[drawId];
1646
+ if (b === undefined)
1647
+ continue; // RO removed since mark
1648
+ let arr = byBucket.get(b);
1649
+ if (arr === undefined) {
1650
+ arr = [];
1651
+ byBucket.set(b, arr);
1652
+ }
1653
+ arr.push(drawId);
1654
+ }
1655
+ for (const [bucket, ids] of byBucket) {
1656
+ // First recompute every dirty tracker so .modeKey is fresh.
1657
+ // Filter out ones whose value didn't actually change.
1658
+ const changed = [];
1659
+ for (const drawId of ids) {
1660
+ const tracker = drawIdToModeTracker[drawId];
1661
+ if (tracker === undefined)
1662
+ continue;
1663
+ const oldKey = tracker.modeKey;
1664
+ if (!tracker.recompute())
1665
+ continue;
1666
+ if (tracker.modeKey === oldKey)
1667
+ continue;
1668
+ changed.push(drawId);
1669
+ }
1670
+ if (changed.length === 0)
1671
+ continue;
1672
+ // Try the all-together fast path.
1673
+ const wholeBucket = changed.length === bucket.drawSlots.size;
1674
+ if (wholeBucket) {
1675
+ const repId = changed[0];
1676
+ const repTracker = drawIdToModeTracker[repId];
1677
+ const newKey = repTracker.modeKey;
1678
+ // Compute the new bucketByKey string; we need the family.
1679
+ const sample = drawIdToSpec[repId];
1680
+ const fam = familyFor(sample.effect);
1681
+ const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
1682
+ const existing = bucketByKey.get(newBk);
1683
+ if (existing === undefined) {
1684
+ // No conflict — rename + rebuild pipeline in place.
1685
+ // Find this bucket's current key by linear scan
1686
+ // (buckets ≤ ~100 typically, so this is cheap).
1687
+ let oldBk;
1688
+ for (const [k, v] of bucketByKey) {
1689
+ if (v === bucket) {
1690
+ oldBk = k;
1691
+ break;
1692
+ }
1693
+ }
1694
+ if (oldBk !== undefined)
1695
+ bucketByKey.delete(oldBk);
1696
+ bucketByKey.set(newBk, bucket);
1697
+ // Build the new pipeline with the snapshotted PS.
1698
+ const desc = repTracker.descriptor;
1699
+ const { pipelineLayout } = getBgl(bucket.layout, bucket.isAtlasBucket);
1700
+ const newPipeline = device.createRenderPipeline({
1701
+ label: `heapScene/${newBk}/pipeline`,
1702
+ layout: pipelineLayout,
1703
+ vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
1704
+ fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
1705
+ primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
1706
+ ...(depthFormat !== undefined && desc.depth !== undefined
1707
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
1708
+ : depthFormat !== undefined
1709
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
1710
+ : {}),
1711
+ });
1712
+ // Swap pipeline reference on the bucket's single slot.
1713
+ // (Multi-slot Phase 5c would update one slot at a time.)
1714
+ bucket.slots[0].pipeline = newPipeline;
1715
+ continue; // done with this bucket
1716
+ }
1717
+ // Conflict: another bucket already owns newBk. Fall through
1718
+ // to per-RO rebucket below, which will merge into `existing`.
1719
+ }
1720
+ // Slow path — per-RO rebucket (used when partial transition
1721
+ // OR when a merge with an existing destination bucket is
1722
+ // required).
1723
+ for (const drawId of changed) {
1724
+ const spec = drawIdToSpec[drawId];
1725
+ if (spec === undefined)
1726
+ continue;
1727
+ removeDraw(drawId);
1728
+ const newId = addDrawImpl(spec, tok);
1729
+ void newId;
1730
+ }
1731
+ }
1732
+ }
2348
1733
  // 1. Pool: re-pack any aval whose value changed since last frame.
2349
1734
  // One writeBuffer per dirty aval, regardless of how many draws
2350
1735
  // reference it — sharing pays off here.
@@ -2441,11 +1826,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2441
1826
  bucket.headerDirtyMin = Infinity;
2442
1827
  bucket.headerDirtyMax = 0;
2443
1828
  }
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;
1829
+ if (bucket.slots[0].drawTableDirtyMax > bucket.slots[0].drawTableDirtyMin) {
1830
+ const shadow = bucket.slots[0].drawTableShadow;
1831
+ 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);
1832
+ bucket.slots[0].drawTableDirtyMin = Infinity;
1833
+ bucket.slots[0].drawTableDirtyMax = 0;
2449
1834
  }
2450
1835
  }
2451
1836
  }
@@ -2461,9 +1846,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2461
1846
  pass.setBindGroup(0, b.bindGroup);
2462
1847
  curBg = b.bindGroup;
2463
1848
  }
2464
- pass.setPipeline(b.pipeline);
2465
- if (b.recordCount > 0)
2466
- pass.drawIndirect(b.indirectBuf, 0);
1849
+ pass.setPipeline(b.slots[0].pipeline);
1850
+ if (b.slots[0].recordCount > 0)
1851
+ pass.drawIndirect(b.slots[0].indirectBuf, 0);
2467
1852
  }
2468
1853
  }
2469
1854
  /**
@@ -2505,7 +1890,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2505
1890
  }
2506
1891
  let anyDirty = false;
2507
1892
  for (const b of buckets) {
2508
- if (b.scanDirty) {
1893
+ if (b.slots[0].scanDirty) {
2509
1894
  anyDirty = true;
2510
1895
  break;
2511
1896
  }
@@ -2514,17 +1899,17 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2514
1899
  return;
2515
1900
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
2516
1901
  for (const b of buckets) {
2517
- if (!b.scanDirty)
1902
+ if (!b.slots[0].scanDirty)
2518
1903
  continue;
2519
1904
  // Rebuild render bind group if recordCount changed — its
2520
1905
  // drawTable binding is sized to recordCount * 16.
2521
- if (b.renderBoundRecordCount !== b.recordCount) {
1906
+ if (b.slots[0].renderBoundRecordCount !== b.slots[0].recordCount) {
2522
1907
  b.bindGroup = buildBucketBindGroup(b);
2523
1908
  }
2524
- const numRecords = b.recordCount;
1909
+ const numRecords = b.slots[0].recordCount;
2525
1910
  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);
1911
+ device.queue.writeBuffer(b.slots[0].paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
1912
+ pass.setBindGroup(0, b.slots[0].scanBindGroup);
2528
1913
  pass.setPipeline(scanPipeTile);
2529
1914
  pass.dispatchWorkgroups(numBlocks, 1, 1);
2530
1915
  pass.setPipeline(scanPipeBlocks);
@@ -2535,11 +1920,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2535
1920
  // must cover the worst-case totalEmit. Each WG handles WG_SIZE
2536
1921
  // tiles; +1 for the sentinel slot.
2537
1922
  const SCAN_WG_SIZE = 256;
2538
- const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
1923
+ const numTilesCap = Math.max(1, Math.ceil(b.slots[0].totalEmitEstimate / TILE_K));
2539
1924
  const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
2540
1925
  pass.setPipeline(scanPipeBuildTileIndex);
2541
1926
  pass.dispatchWorkgroups(tileWgs, 1, 1);
2542
- b.scanDirty = false;
1927
+ b.slots[0].scanDirty = false;
2543
1928
  }
2544
1929
  pass.end();
2545
1930
  }
@@ -2572,29 +1957,53 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2572
1957
  pass.end();
2573
1958
  device.queue.submit([enc.finish()]);
2574
1959
  }
1960
+ function destroyBucketResources(b) {
1961
+ b.drawHeap.destroy();
1962
+ for (const slot of b.slots) {
1963
+ slot.drawTableBuf?.destroy();
1964
+ slot.blockSumsBuf?.destroy();
1965
+ slot.blockOffsetsBuf?.destroy();
1966
+ slot.firstDrawInTileBuf?.destroy();
1967
+ slot.indirectBuf?.destroy();
1968
+ slot.paramsBuf?.destroy();
1969
+ }
1970
+ }
1971
+ /**
1972
+ * Remove an empty bucket from the scene's bucket list + key index +
1973
+ * destroy its GPU resources. Called from removeDraw when an RO's
1974
+ * removal leaves the bucket with zero live slots — common after
1975
+ * reactive PS rebucket leaves the source bucket empty.
1976
+ */
1977
+ function gcBucket(b) {
1978
+ if (b.drawSlots.size !== 0)
1979
+ return;
1980
+ const idx = buckets.indexOf(b);
1981
+ if (idx >= 0)
1982
+ buckets.splice(idx, 1);
1983
+ for (const [k, v] of bucketByKey) {
1984
+ if (v === b) {
1985
+ bucketByKey.delete(k);
1986
+ break;
1987
+ }
1988
+ }
1989
+ destroyBucketResources(b);
1990
+ }
2575
1991
  function dispose() {
2576
1992
  arena.attrs.destroy();
2577
1993
  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
- }
1994
+ for (const b of buckets)
1995
+ destroyBucketResources(b);
2587
1996
  }
2588
1997
  // Test-only escape hatch for inspecting megacall bucket state. Not
2589
1998
  // part of the public API surface — keep cast at use-site.
2590
1999
  const _debug = {
2591
2000
  bucketsForTest() {
2592
2001
  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,
2002
+ indirectBuf: b.slots[0].indirectBuf,
2003
+ drawTableBuf: b.slots[0].drawTableBuf?.buffer,
2004
+ firstDrawInTileBuf: b.slots[0].firstDrawInTileBuf?.buffer,
2005
+ totalEmitEstimate: b.slots[0].totalEmitEstimate,
2006
+ recordCount: b.slots[0].recordCount,
2598
2007
  layout: b.layout,
2599
2008
  }));
2600
2009
  },
@@ -2639,19 +2048,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2639
2048
  const TILE_K_LOCAL = 64;
2640
2049
  const dcs = [];
2641
2050
  for (const b of buckets) {
2642
- const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
2051
+ const dhSize = Math.min(b.drawHeap.buffer.size, b.slots[0].recordCount * b.layout.drawHeaderBytes);
2643
2052
  if (dhSize === 0)
2644
2053
  continue;
2645
2054
  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)
2055
+ const dt = b.slots[0].drawTableBuf !== undefined && b.slots[0].recordCount > 0
2056
+ ? stage(b.slots[0].drawTableBuf.buffer, b.slots[0].recordCount * RECORD_BYTES)
2648
2057
  : undefined;
2649
- const totalEmit = b.totalEmitEstimate;
2058
+ const totalEmit = b.slots[0].totalEmitEstimate;
2650
2059
  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))
2060
+ const fdt = b.slots[0].firstDrawInTileBuf !== undefined && numTiles > 0
2061
+ ? stage(b.slots[0].firstDrawInTileBuf.buffer, Math.min(b.slots[0].firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
2653
2062
  : undefined;
2654
- const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
2063
+ const ind = b.slots[0].indirectBuf !== undefined ? stage(b.slots[0].indirectBuf, 16) : undefined;
2655
2064
  dcs.push({
2656
2065
  bucket: b, drawHeap: dh, numTiles,
2657
2066
  ...(dt !== undefined ? { drawTable: dt } : {}),
@@ -2684,7 +2093,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2684
2093
  for (const dc of dcs) {
2685
2094
  const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
2686
2095
  const stride = dc.bucket.layout.drawHeaderBytes;
2687
- const recordCount = dc.bucket.recordCount;
2096
+ const recordCount = dc.bucket.slots[0].recordCount;
2688
2097
  for (let slot = 0; slot < recordCount; slot++) {
2689
2098
  const slotOff = slot * stride;
2690
2099
  for (const f of dc.bucket.layout.drawHeaderFields) {
@@ -2836,7 +2245,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2836
2245
  // ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
2837
2246
  if (dc.indirect !== undefined) {
2838
2247
  const ind = new Uint32Array(dc.indirect.getMappedRange());
2839
- const expectedTotal = dc.bucket.totalEmitEstimate;
2248
+ const expectedTotal = dc.bucket.slots[0].totalEmitEstimate;
2840
2249
  const got = ind[0];
2841
2250
  if (got !== expectedTotal) {
2842
2251
  push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
@@ -2926,11 +2335,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2926
2335
  const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
2927
2336
  const dcs = [];
2928
2337
  for (const b of buckets) {
2929
- if (b.recordCount === 0 || b.totalEmitEstimate === 0)
2338
+ if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
2930
2339
  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 });
2340
+ const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.slots[0].recordCount * b.layout.drawHeaderBytes));
2341
+ const dt = stage(b.slots[0].drawTableBuf.buffer, b.slots[0].recordCount * RECORD_BYTES);
2342
+ dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.slots[0].totalEmitEstimate });
2934
2343
  }
2935
2344
  device.queue.submit([enc.finish()]);
2936
2345
  await arenaCopy.mapAsync(GPUMapMode.READ);
@@ -2954,7 +2363,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2954
2363
  const dt = new Uint32Array(dc.drawTable.getMappedRange());
2955
2364
  bucketDt.push(dt);
2956
2365
  bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
2957
- for (let r = 0; r < dc.bucket.recordCount; r++) {
2366
+ for (let r = 0; r < dc.bucket.slots[0].recordCount; r++) {
2958
2367
  dc.firstEmit.push(dt[r * RECORD_U32 + 0]);
2959
2368
  }
2960
2369
  bucketCumEmit.push(totalEmitGlobal);
@@ -3003,7 +2412,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3003
2412
  }
3004
2413
  emitsChecked++;
3005
2414
  // Binary search for slot.
3006
- const recCount = dc.bucket.recordCount;
2415
+ const recCount = dc.bucket.slots[0].recordCount;
3007
2416
  let lo = 0, hi = recCount - 1;
3008
2417
  while (lo < hi) {
3009
2418
  const mid = (lo + hi + 1) >>> 1;
@@ -3199,12 +2608,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3199
2608
  ? new Uint32Array(indicesCopy.getMappedRange())
3200
2609
  : new Uint32Array(0);
3201
2610
  for (const target of buckets) {
3202
- if (target.recordCount === 0 || target.totalEmitEstimate === 0) {
2611
+ if (target.slots[0].recordCount === 0 || target.slots[0].totalEmitEstimate === 0) {
3203
2612
  bucketIdx++;
3204
2613
  continue;
3205
2614
  }
3206
- const recordCount = target.recordCount;
3207
- const totalEmit = target.totalEmitEstimate;
2615
+ const recordCount = target.slots[0].recordCount;
2616
+ const totalEmit = target.slots[0].totalEmitEstimate;
3208
2617
  const totalTris = Math.floor(totalEmit / 3);
3209
2618
  const remainder = totalEmit % 3;
3210
2619
  if (remainder !== 0) {
@@ -3221,7 +2630,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3221
2630
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3222
2631
  });
3223
2632
  const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
3224
- enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
2633
+ enc0.copyBufferToBuffer(target.slots[0].drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3225
2634
  enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
3226
2635
  device.queue.submit([enc0.finish()]);
3227
2636
  await dtCopy.mapAsync(GPUMapMode.READ);
@@ -3270,7 +2679,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3270
2679
  });
3271
2680
  {
3272
2681
  const enc1 = device.createCommandEncoder();
3273
- enc1.copyBufferToBuffer(target.firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
2682
+ enc1.copyBufferToBuffer(target.slots[0].firstDrawInTileBuf.buffer, 0, fdtCopy, 0, fdtSize);
3274
2683
  device.queue.submit([enc1.finish()]);
3275
2684
  await fdtCopy.mapAsync(GPUMapMode.READ);
3276
2685
  }
@@ -3425,12 +2834,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3425
2834
  // own bytes).
3426
2835
  bucketIdx = 0;
3427
2836
  for (const target of buckets) {
3428
- if (target.recordCount === 0) {
2837
+ if (target.slots[0].recordCount === 0) {
3429
2838
  bucketIdx++;
3430
2839
  continue;
3431
2840
  }
3432
2841
  // Re-stage drawHeap for this bucket.
3433
- const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
2842
+ const dhBytes = target.slots[0].recordCount * target.layout.drawHeaderBytes;
3434
2843
  const dhCopy = device.createBuffer({
3435
2844
  size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3436
2845
  });
@@ -3440,7 +2849,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3440
2849
  await dhCopy.mapAsync(GPUMapMode.READ);
3441
2850
  const dhU32 = new Uint32Array(dhCopy.getMappedRange());
3442
2851
  const stride2 = target.layout.drawHeaderBytes;
3443
- for (let slot = 0; slot < target.recordCount; slot++) {
2852
+ for (let slot = 0; slot < target.slots[0].recordCount; slot++) {
3444
2853
  for (const f of target.layout.drawHeaderFields) {
3445
2854
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref")
3446
2855
  continue;
@@ -3500,11 +2909,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3500
2909
  let gpuMismatches = 0;
3501
2910
  const push = (s) => { if (issues.length < 30)
3502
2911
  issues.push(s); };
3503
- const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
2912
+ const target = buckets.find(b => b.slots[0].recordCount > 0 && b.slots[0].totalEmitEstimate > 0);
3504
2913
  if (target === undefined)
3505
2914
  return { emitsChecked: 0, gpuMismatches: 0, issues };
3506
- const recordCount = target.recordCount;
3507
- const totalEmit = target.totalEmitEstimate;
2915
+ const recordCount = target.slots[0].recordCount;
2916
+ const totalEmit = target.slots[0].totalEmitEstimate;
3508
2917
  const sampleCount = Math.min(samples, totalEmit);
3509
2918
  const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
3510
2919
  // CPU's firstEmit is GPU-computed — must read it back from the
@@ -3517,7 +2926,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3517
2926
  });
3518
2927
  {
3519
2928
  const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
3520
- enc0.copyBufferToBuffer(target.drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
2929
+ enc0.copyBufferToBuffer(target.slots[0].drawTableBuf.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3521
2930
  device.queue.submit([enc0.finish()]);
3522
2931
  await dtCopy.mapAsync(GPUMapMode.READ);
3523
2932
  }
@@ -3621,8 +3030,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3621
3030
  const bg = device.createBindGroup({
3622
3031
  layout: bgl,
3623
3032
  entries: [
3624
- { binding: 0, resource: { buffer: target.drawTableBuf.buffer } },
3625
- { binding: 1, resource: { buffer: target.firstDrawInTileBuf.buffer } },
3033
+ { binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
3034
+ { binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
3626
3035
  { binding: 2, resource: { buffer: sampleBuf } },
3627
3036
  { binding: 3, resource: { buffer: arena.indices.buffer } },
3628
3037
  { binding: 4, resource: { buffer: outBuf } },