@aardworx/wombat.rendering 0.9.13 → 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 (70) 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/derivedUniforms/marker.d.ts +11 -3
  14. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
  15. package/dist/runtime/derivedUniforms/marker.js +10 -5
  16. package/dist/runtime/derivedUniforms/marker.js.map +1 -1
  17. package/dist/runtime/heapScene/growBuffer.d.ts +33 -0
  18. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -0
  19. package/dist/runtime/heapScene/growBuffer.js +76 -0
  20. package/dist/runtime/heapScene/growBuffer.js.map +1 -0
  21. package/dist/runtime/heapScene/packers.d.ts +25 -0
  22. package/dist/runtime/heapScene/packers.d.ts.map +1 -0
  23. package/dist/runtime/heapScene/packers.js +111 -0
  24. package/dist/runtime/heapScene/packers.js.map +1 -0
  25. package/dist/runtime/heapScene/pools.d.ts +181 -0
  26. package/dist/runtime/heapScene/pools.d.ts.map +1 -0
  27. package/dist/runtime/heapScene/pools.js +417 -0
  28. package/dist/runtime/heapScene/pools.js.map +1 -0
  29. package/dist/runtime/heapScene/scanKernel.d.ts +11 -0
  30. package/dist/runtime/heapScene/scanKernel.d.ts.map +1 -0
  31. package/dist/runtime/heapScene/scanKernel.js +181 -0
  32. package/dist/runtime/heapScene/scanKernel.js.map +1 -0
  33. package/dist/runtime/heapScene.d.ts.map +1 -1
  34. package/dist/runtime/heapScene.js +228 -937
  35. package/dist/runtime/heapScene.js.map +1 -1
  36. package/dist/runtime/index.d.ts +4 -0
  37. package/dist/runtime/index.d.ts.map +1 -1
  38. package/dist/runtime/index.js +5 -0
  39. package/dist/runtime/index.js.map +1 -1
  40. package/dist/runtime/pipelineCache/bitfield.d.ts +5 -0
  41. package/dist/runtime/pipelineCache/bitfield.d.ts.map +1 -0
  42. package/dist/runtime/pipelineCache/bitfield.js +201 -0
  43. package/dist/runtime/pipelineCache/bitfield.js.map +1 -0
  44. package/dist/runtime/pipelineCache/cache.d.ts +36 -0
  45. package/dist/runtime/pipelineCache/cache.d.ts.map +1 -0
  46. package/dist/runtime/pipelineCache/cache.js +108 -0
  47. package/dist/runtime/pipelineCache/cache.js.map +1 -0
  48. package/dist/runtime/pipelineCache/descriptor.d.ts +58 -0
  49. package/dist/runtime/pipelineCache/descriptor.d.ts.map +1 -0
  50. package/dist/runtime/pipelineCache/descriptor.js +100 -0
  51. package/dist/runtime/pipelineCache/descriptor.js.map +1 -0
  52. package/dist/runtime/pipelineCache/index.d.ts +5 -0
  53. package/dist/runtime/pipelineCache/index.d.ts.map +1 -0
  54. package/dist/runtime/pipelineCache/index.js +12 -0
  55. package/dist/runtime/pipelineCache/index.js.map +1 -0
  56. package/package.json +1 -1
  57. package/src/runtime/derivedModes/modeKeyCpu.ts +213 -0
  58. package/src/runtime/derivedModes/partition.ts +206 -0
  59. package/src/runtime/derivedModes/slotTable.ts +153 -0
  60. package/src/runtime/derivedUniforms/marker.ts +19 -6
  61. package/src/runtime/heapScene/growBuffer.ts +75 -0
  62. package/src/runtime/heapScene/packers.ts +127 -0
  63. package/src/runtime/heapScene/pools.ts +492 -0
  64. package/src/runtime/heapScene/scanKernel.ts +184 -0
  65. package/src/runtime/heapScene.ts +283 -1064
  66. package/src/runtime/index.ts +40 -0
  67. package/src/runtime/pipelineCache/bitfield.ts +225 -0
  68. package/src/runtime/pipelineCache/cache.ts +129 -0
  69. package/src/runtime/pipelineCache/descriptor.ts +150 -0
  70. package/src/runtime/pipelineCache/index.ts +37 -0
@@ -75,161 +75,28 @@ import {
75
75
  isDerivedRule, STANDARD_DERIVED_RULES, STANDARD_TRAFO_LEAVES, inputsOf,
76
76
  type RoRegistration, type DerivedRule,
77
77
  } from "./derivedUniforms/index.js";
78
-
79
- // ---------------------------------------------------------------------------
80
- // Per-allocation arena layout
81
- // ---------------------------------------------------------------------------
82
-
83
- // Per-allocation header: (u32 typeId, u32 length). typeId is
84
- // (semantic << 16) | encoding. The data region follows the header
85
- // aligned up to 16 bytes (so positions/normals/etc. line up for
86
- // future vec4 reads).
87
- const ALLOC_HEADER_BYTES = 8;
88
- const ALLOC_HEADER_PAD_TO = 16; // data starts header_offset + 16
89
-
90
- // Encoding-tag enum (low 16 bits of typeId).
91
- const ENC_V3F_TIGHT = 1; // tightly-packed array of vec3<f32> (12 B/elt)
92
-
93
- // Semantic-tag enum (high 16 bits of typeId). Optional metadata —
94
- // the shader doesn't branch on this.
95
- const SEM_POSITIONS = 1;
96
- const SEM_NORMALS = 2;
97
-
98
- const ALIGN16 = (n: number) => (n + 15) & ~15;
99
-
100
- function packMat44(m: M44d, dst: Float32Array, off: number): void {
101
- // Zero-alloc flat copy (row-major) straight into the f32 staging
102
- // buffer — `copyTo` does `dst.set(m._data, off)` which narrows f64→f32
103
- // on store, no throwaway `number[]` per call.
104
- m.copyTo(dst, off);
105
- }
106
-
107
- // ─── Layout-driven value packing ────────────────────────────────────
108
- //
109
- // Maps a schema uniform name + its value source to bytes in a staging
110
- // buffer. The bridge between the spec's named JS-side fields (e.g.
111
- // `spec.modelTrafo: Trafo3d`) and the schema's typed uniforms
112
- // ("ModelTrafo" mat4, "ModelTrafoInv" mat4, …). Step 5 generalises
113
- // this to a `spec.uniforms: { [name]: aval }` map; until then we
114
- // hardcode the shape here.
115
-
116
- // ─── Generic packer registry, keyed on WGSL type ────────────────────
117
- //
118
- // Each per-draw uniform comes from the spec as an aval whose JS value
119
- // type is determined by what the user passes. The packer for a given
120
- // WGSL type knows how to turn that JS value into bytes for the arena.
121
- // Step 5: this replaces the per-name `perDrawBinding` switch — the
122
- // spec just supplies `uniforms: { [name]: aval<...> }` and the
123
- // runtime asks the registry "how do I pack a `mat4x4<f32>`".
124
-
125
- /** A packer for one WGSL storage-buffer type. */
126
- interface WgslPacker {
127
- /** Tightly-packed size in bytes of one value (mat4 = 64, vec3 = 12, …). */
128
- readonly dataBytes: number;
129
- readonly typeId: number;
130
- /**
131
- * Pack `val` (the aval's `.getValue(tok)` result) into `dst` at
132
- * float offset `off`. The packer is responsible for handling the
133
- * value type — Trafo3d, M44d, V4f, V3d, V3f, number — coercing as
134
- * needed. Throws on unsupported value shapes.
135
- */
136
- readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
137
- }
138
-
139
- const PACKER_MAT4: WgslPacker = {
140
- dataBytes: 64, typeId: 0,
141
- pack: (val, dst, off) => {
142
- // Accept Trafo3d (uses .forward) or M44d directly.
143
- const m = (val as { forward?: M44d }).forward !== undefined
144
- ? (val as { forward: M44d }).forward
145
- : (val as M44d);
146
- packMat44(m, dst, off);
147
- },
148
- };
149
- const PACKER_VEC4: WgslPacker = {
150
- dataBytes: 16, typeId: 0,
151
- pack: (val, dst, off) => {
152
- const v = val as V4f;
153
- dst[off + 0] = v.x; dst[off + 1] = v.y;
154
- dst[off + 2] = v.z; dst[off + 3] = v.w;
155
- },
156
- };
157
- const PACKER_VEC3: WgslPacker = {
158
- dataBytes: 12, typeId: 0,
159
- pack: (val, dst, off) => {
160
- // V3f or V3d both expose .x/.y/.z; cast through a common shape.
161
- const v = val as { x: number; y: number; z: number };
162
- dst[off + 0] = v.x; dst[off + 1] = v.y; dst[off + 2] = v.z;
163
- },
164
- };
165
- const PACKER_VEC2: WgslPacker = {
166
- dataBytes: 8, typeId: 0,
167
- pack: (val, dst, off) => {
168
- const v = val as { x: number; y: number };
169
- dst[off + 0] = v.x; dst[off + 1] = v.y;
170
- },
171
- };
172
- const PACKER_F32: WgslPacker = {
173
- dataBytes: 4, typeId: 0,
174
- pack: (val, dst, off) => { dst[off] = val as number; },
175
- };
176
- // Integer scalars / vectors. The arena is fronted by a `Float32Array`,
177
- // so writing raw bits has to go through a same-buffer Uint32/Int32 view
178
- // to avoid the lossy `i32 → f32` coercion you'd get from a direct
179
- // `dst[off] = ...` assignment.
180
- function makeIntPacker(
181
- ctor: typeof Uint32Array | typeof Int32Array,
182
- dim: 1 | 2 | 3 | 4,
183
- ): WgslPacker {
184
- const bytes = dim * 4;
185
- if (dim === 1) {
186
- return {
187
- dataBytes: bytes, typeId: 0,
188
- pack: (val, dst, off) => {
189
- new ctor(dst.buffer as ArrayBuffer, dst.byteOffset + off * 4, 1)[0] = val as number;
190
- },
191
- };
192
- }
193
- // Vector: accept {x,y,z,w} components.
194
- return {
195
- dataBytes: bytes, typeId: 0,
196
- pack: (val, dst, off) => {
197
- const view = new ctor(dst.buffer as ArrayBuffer, dst.byteOffset + off * 4, dim);
198
- const v = val as { x: number; y: number; z?: number; w?: number };
199
- view[0] = v.x; view[1] = v.y;
200
- if (dim >= 3) view[2] = v.z!;
201
- if (dim >= 4) view[3] = v.w!;
202
- },
203
- };
204
- }
205
- const PACKER_U32 = makeIntPacker(Uint32Array, 1);
206
- const PACKER_UVEC2 = makeIntPacker(Uint32Array, 2);
207
- const PACKER_UVEC3 = makeIntPacker(Uint32Array, 3);
208
- const PACKER_UVEC4 = makeIntPacker(Uint32Array, 4);
209
- const PACKER_I32 = makeIntPacker(Int32Array, 1);
210
- const PACKER_IVEC2 = makeIntPacker(Int32Array, 2);
211
- const PACKER_IVEC3 = makeIntPacker(Int32Array, 3);
212
- const PACKER_IVEC4 = makeIntPacker(Int32Array, 4);
213
-
214
- function packerForWgslType(wgslType: string): WgslPacker {
215
- switch (wgslType) {
216
- case "mat4x4<f32>": return PACKER_MAT4;
217
- case "vec4<f32>": return PACKER_VEC4;
218
- case "vec3<f32>": return PACKER_VEC3;
219
- case "vec2<f32>": return PACKER_VEC2;
220
- case "f32": return PACKER_F32;
221
- case "u32": return PACKER_U32;
222
- case "vec2<u32>": return PACKER_UVEC2;
223
- case "vec3<u32>": return PACKER_UVEC3;
224
- case "vec4<u32>": return PACKER_UVEC4;
225
- case "i32": return PACKER_I32;
226
- case "vec2<i32>": return PACKER_IVEC2;
227
- case "vec3<i32>": return PACKER_IVEC3;
228
- case "vec4<i32>": return PACKER_IVEC4;
229
- default:
230
- throw new Error(`heapScene: no JS-side packer for WGSL type '${wgslType}'`);
231
- }
232
- }
78
+ import { packerForWgslType, PACKER_MAT4, type WgslPacker } from "./heapScene/packers.js";
79
+ import {
80
+ HEAP_SCAN_WGSL, SCAN_TILE_SIZE, SCAN_WG_SIZE, SCAN_MAX_RECORDS,
81
+ TILE_K, RECORD_U32, RECORD_BYTES,
82
+ } from "./heapScene/scanKernel.js";
83
+ import {
84
+ GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16,
85
+ } from "./heapScene/growBuffer.js";
86
+ import {
87
+ UniformPool, IndexPool, DrawHeap,
88
+ AttributeArena, IndexAllocator, insertSortedFreeBlock,
89
+ buildArenaState, arenaBytes, writeAttribute,
90
+ asAval, isBufferView, asFloat32,
91
+ ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO,
92
+ ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS,
93
+ type ArenaState,
94
+ } from "./heapScene/pools.js";
95
+ import { encodeModeKey } from "./pipelineCache/index.js";
96
+ import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
97
+
98
+ // GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
99
+ // Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
233
100
 
234
101
  // ---------------------------------------------------------------------------
235
102
  // Shared WGSL prelude (struct + bindings + VS)
@@ -268,765 +135,65 @@ export interface HeapGeometry {
268
135
  }
269
136
 
270
137
 
271
- // ---------------------------------------------------------------------------
272
- // Resizable buffer (pow2 grow + GPU-side copy on resize)
273
- // ---------------------------------------------------------------------------
274
138
 
275
- const MIN_BUFFER_BYTES = 64 * 1024;
276
- const POW2 = (n: number): number => {
277
- let p = 1; while (p < n) p <<= 1; return p;
278
- };
279
-
280
- /**
281
- * A GPUBuffer that can grow to next power-of-two on demand. On grow,
282
- * a fresh buffer is created at the new size, the live tail copied
283
- * over via copyBufferToBuffer, and dependents (bind groups, mostly)
284
- * are notified to rebuild via the `onResize` callback.
285
- *
286
- * `usedBytes` is the high-water mark — the runtime advances this as
287
- * it allocates, and `ensureCapacity` grows when required. This
288
- * separates allocation policy from grow policy.
289
- */
290
- class GrowBuffer {
291
- private buf: GPUBuffer;
292
- private cap: number;
293
- private used = 0;
294
- private readonly listeners = new Set<() => void>();
295
- constructor(
296
- private readonly device: GPUDevice,
297
- private readonly label: string,
298
- private readonly usage: GPUBufferUsageFlags,
299
- initialBytes: number,
300
- ) {
301
- this.cap = Math.max(MIN_BUFFER_BYTES, POW2(initialBytes));
302
- this.buf = device.createBuffer({ size: this.cap, usage: usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label });
303
- }
304
- get buffer(): GPUBuffer { return this.buf; }
305
- get capacity(): number { return this.cap; }
306
- get usedBytes(): number { return this.used; }
307
- setUsed(n: number): void { this.used = n; }
308
- onResize(cb: () => void): IDisposable {
309
- this.listeners.add(cb);
310
- return { dispose: () => { this.listeners.delete(cb); } };
311
- }
312
- /** Ensure the buffer is at least `bytes` capacity. Grows by pow2 + copies live tail. */
313
- ensureCapacity(bytes: number): void {
314
- if (bytes <= this.cap) return;
315
- const newCap = POW2(bytes);
316
- const newBuf = this.device.createBuffer({
317
- size: newCap, usage: this.usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, label: this.label,
318
- });
319
- if (this.used > 0) {
320
- const enc = this.device.createCommandEncoder({ label: `${this.label}: grow-copy` });
321
- enc.copyBufferToBuffer(this.buf, 0, newBuf, 0, ALIGN16(this.used));
322
- this.device.queue.submit([enc.finish()]);
323
- }
324
- this.buf.destroy();
325
- this.buf = newBuf;
326
- this.cap = newCap;
327
- for (const cb of this.listeners) cb();
328
- }
329
- destroy(): void { this.buf.destroy(); }
330
- }
331
-
332
- // ---------------------------------------------------------------------------
333
- // UniformPool — aval-keyed refcounted allocations over the AttributeArena
334
139
  // ---------------------------------------------------------------------------
335
-
336
- interface PoolEntry {
337
- /** Byte offset into the arena. Data starts at ref + ALLOC_HEADER_PAD_TO. */
338
- readonly ref: number;
339
- readonly dataBytes: number;
340
- readonly typeId: number;
341
- /** Packer used to refresh the data region on aval marks. */
342
- readonly pack: (val: unknown, dst: Float32Array, off: number) => void;
343
- refcount: number;
344
- }
345
-
346
- /**
347
- * Aval-keyed pool of arena allocations. One allocation per unique
348
- * aval (object identity). Two draws referencing the same aval share
349
- * the allocation; their DrawHeaders carry the same u32 ref. Holds
350
- * uniforms (fixed-size scalars/vectors/matrices) AND attribute arrays
351
- * (variable-size). The caller decides `dataBytes` + `length` per
352
- * acquisition — the pool just keys on aval identity and refcounts.
353
- *
354
- * Sharing emerges from aval identity — no separate "frequency"
355
- * declaration needed. A `cval` shared by all draws → 1 alloc.
356
- * A static positions array shared across instanced draws → 1 alloc.
357
- * Same code path either way.
358
- */
359
- class UniformPool {
360
- // Keyed by `aval<unknown>` *by reference* (a plain JS `Map`). These
361
- // keys are overwhelmingly reactive `cval`s (per-object trafos,
362
- // colours, …) and the hot path is `acquire`/`release` ~once per
363
- // drawHeader field per RO. A content-keyed `HashTable` would buy
364
- // nothing here — reactive avals never compare content-equal — and
365
- // would cost: reactive avals have no `equals`/`getHashCode`, so a
366
- // `HashTable` falls back to a WeakMap-counter identity hash per
367
- // lookup, measurably slower than `Map`'s native hashing. (Constant-
368
- // aval dedup matters where keys are *texture* avals — there the
369
- // `AtlasPool` is content-keyed; constant avals there carry a cached
370
- // hash and a fast `equals`.)
371
- private readonly byAval = new Map<aval<unknown>, PoolEntry>();
372
-
373
- has(aval: aval<unknown>): boolean { return this.byAval.has(aval); }
374
- entry(aval: aval<unknown>): PoolEntry | undefined { return this.byAval.get(aval); }
375
-
376
- /**
377
- * Acquire (or share) an allocation for `aval`. Caller passes the
378
- * pre-read `value` (so the pool doesn't need a token) plus the
379
- * (`dataBytes`, `typeId`, `length`, `pack`) describing how to lay
380
- * it out. If a new allocation is made, the value is packed and
381
- * uploaded immediately.
382
- */
383
- acquire(
384
- device: GPUDevice,
385
- arena: AttributeArena,
386
- aval: aval<unknown>,
387
- value: unknown,
388
- dataBytes: number,
389
- typeId: number,
390
- length: number,
391
- pack: (val: unknown, dst: Float32Array, off: number) => void,
392
- ): number {
393
- const existing = this.byAval.get(aval);
394
- if (existing !== undefined) {
395
- existing.refcount++;
396
- return existing.ref;
397
- }
398
- const ref = arena.alloc(dataBytes);
399
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
400
- const buf = new ArrayBuffer(allocBytes);
401
- const u32 = new Uint32Array(buf);
402
- const f32 = new Float32Array(buf);
403
- u32[0] = typeId;
404
- u32[1] = length;
405
- // stride_bytes (offset 8): bytes per element. Lets the VS decode
406
- // pick V3- vs V4-tight load expressions for vec4 attributes
407
- // (and is informative for everything else).
408
- u32[2] = length > 0 ? Math.floor(dataBytes / length) : 0;
409
- pack(value, f32, ALLOC_HEADER_PAD_TO / 4);
410
- arena.write(ref, new Uint8Array(buf));
411
- void device;
412
- this.byAval.set(aval, { ref, dataBytes, typeId, pack, refcount: 1 });
413
- return ref;
414
- }
415
-
416
- /** Decrement refcount; if zero, free the arena allocation. */
417
- release(arena: AttributeArena, aval: aval<unknown>): void {
418
- const e = this.byAval.get(aval);
419
- if (e === undefined) return;
420
- e.refcount--;
421
- if (e.refcount > 0) return;
422
- arena.release(e.ref, ALIGN16(ALLOC_HEADER_PAD_TO + e.dataBytes));
423
- this.byAval.delete(aval);
424
- }
425
-
426
- /** Re-pack one entry's data region into the arena's CPU shadow. */
427
- repack(device: GPUDevice, arena: AttributeArena, aval: aval<unknown>, val: unknown): void {
428
- const e = this.byAval.get(aval);
429
- if (e === undefined) return;
430
- const dst = new Float32Array(e.dataBytes / 4);
431
- e.pack(val, dst, 0);
432
- arena.write(
433
- e.ref + ALLOC_HEADER_PAD_TO,
434
- new Uint8Array(dst.buffer, dst.byteOffset, e.dataBytes),
435
- );
436
- void device;
437
- }
438
- }
439
-
440
- /**
441
- * Aval-keyed pool over the `IndexAllocator`. Two draws referencing
442
- * the same `Uint32Array` (or aval thereof) share an index range —
443
- * 19K instanced clones of the same mesh share one allocation, one
444
- * upload. Index data is treated as immutable for the aval's
445
- * lifetime: an aval mark won't repack (we'd have to free + re-alloc
446
- * since size changes are likely). Use a fresh aval to swap meshes.
447
- *
448
- * **Value-equality dedup for constant avals (§5b):** when an
449
- * incoming aval has `isConstant === true`, the pool also keys by
450
- * the underlying `ArrayBuffer` tuple `(buffer, byteOffset,
451
- * byteLength)`. Two distinct constant avals wrapping the same
452
- * `Uint32Array` view (or two views over the same backing buffer
453
- * with matching offsets) collapse to one allocation. Hashing
454
- * kilobytes of indices on every acquire would be wasteful; the
455
- * tuple key catches the realistic "one ArrayBuffer shared across
456
- * many aval wrappers" pattern, which is the only one that matters
457
- * for the heap path. Reactive (non-constant) avals fall through
458
- * to identity-only — their content can change and the pool can't
459
- * silently merge them.
460
- */
461
- class IndexPool {
462
- // Per-aval binding. `perAvalCount` tracks acquire/release balance
463
- // for THIS aval; `entry` is the shared allocation (one entry can be
464
- // referenced by many aliasing avals via §5b dedup).
465
- private readonly byAval = new Map<
466
- aval<Uint32Array>,
467
- { entry: IndexPoolEntry; perAvalCount: number }
468
- >();
469
- private readonly byValueKey = new Map<string, IndexPoolEntry>();
470
- // Stable per-ArrayBuffer numeric id for value-key composition.
471
- // WeakMap-backed so buffers GC'd elsewhere drop their entry too.
472
- private readonly bufferIds = new WeakMap<ArrayBufferLike, number>();
473
- private nextBufferId = 1;
474
- private bufferIdOf(buf: ArrayBufferLike): number {
475
- let id = this.bufferIds.get(buf);
476
- if (id === undefined) {
477
- id = this.nextBufferId++;
478
- this.bufferIds.set(buf, id);
479
- }
480
- return id;
481
- }
482
-
483
- acquire(
484
- device: GPUDevice,
485
- indices: IndexAllocator,
486
- aval: aval<Uint32Array>,
487
- arr: Uint32Array,
488
- ): { firstIndex: number; count: number } {
489
- const bound = this.byAval.get(aval);
490
- if (bound !== undefined) {
491
- bound.perAvalCount++;
492
- bound.entry.totalRefcount++;
493
- return { firstIndex: bound.entry.firstIndex, count: bound.entry.count };
494
- }
495
- let valueKey: string | undefined;
496
- if (aval.isConstant) {
497
- valueKey = `${this.bufferIdOf(arr.buffer)}:${arr.byteOffset}:${arr.byteLength}`;
498
- const shared = this.byValueKey.get(valueKey);
499
- if (shared !== undefined) {
500
- shared.totalRefcount++;
501
- this.byAval.set(aval, { entry: shared, perAvalCount: 1 });
502
- return { firstIndex: shared.firstIndex, count: shared.count };
503
- }
504
- }
505
- const firstIndex = indices.alloc(arr.length);
506
- indices.write(
507
- firstIndex * 4,
508
- new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength),
509
- );
510
- void device;
511
- const entry: IndexPoolEntry = { firstIndex, count: arr.length, totalRefcount: 1, valueKey };
512
- this.byAval.set(aval, { entry, perAvalCount: 1 });
513
- if (valueKey !== undefined) this.byValueKey.set(valueKey, entry);
514
- return { firstIndex, count: arr.length };
515
- }
516
-
517
- release(indices: IndexAllocator, aval: aval<Uint32Array>): void {
518
- const bound = this.byAval.get(aval);
519
- if (bound === undefined) return;
520
- bound.perAvalCount--;
521
- bound.entry.totalRefcount--;
522
- if (bound.perAvalCount === 0) this.byAval.delete(aval);
523
- if (bound.entry.totalRefcount > 0) return;
524
- indices.release(bound.entry.firstIndex, bound.entry.count);
525
- if (bound.entry.valueKey !== undefined) {
526
- this.byValueKey.delete(bound.entry.valueKey);
527
- }
528
- }
529
- }
530
-
531
- interface IndexPoolEntry {
532
- firstIndex: number;
533
- count: number;
534
- totalRefcount: number;
535
- valueKey: string | undefined;
536
- }
537
-
538
- // ---------------------------------------------------------------------------
539
- // DrawHeap (slot-indexed) and AttributeArena (byte-bump) allocators
540
- // ---------------------------------------------------------------------------
541
-
542
- /**
543
- * Slot-indexed allocator over a GrowBuffer. `slotBytes` is set per-
544
- * instance — each bucket sizes its DrawHeader from its effect's
545
- * schema, so a bucket whose layout is e.g. 96 B / slot uses a
546
- * DrawHeap with `slotBytes=96`.
547
- */
548
- class DrawHeap {
549
- private free: number[] = [];
550
- private nextSlot = 0;
551
- constructor(private readonly buf: GrowBuffer, private readonly slotBytes: number) {}
552
- get buffer(): GPUBuffer { return this.buf.buffer; }
553
- /** Bytes per slot — caller multiplies by slot index for byte offsets. */
554
- get bytesPerSlot(): number { return this.slotBytes; }
555
- /** High-water mark in bytes (used to size bind-group entry on rebuild). */
556
- get usedBytes(): number { return this.nextSlot * this.slotBytes; }
557
- alloc(): number {
558
- const slot = this.free.length > 0 ? this.free.pop()! : this.nextSlot++;
559
- this.buf.ensureCapacity((slot + 1) * this.slotBytes);
560
- this.buf.setUsed(Math.max(this.buf.usedBytes, (slot + 1) * this.slotBytes));
561
- return slot;
562
- }
563
- release(slot: number): void { this.free.push(slot); }
564
- onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
565
- destroy(): void { this.buf.destroy(); }
566
- }
567
-
568
- /**
569
- * Byte-bump allocator over a GrowBuffer for variable-size attribute
570
- * allocations. Each allocation gets a 16-byte aligned start (8-byte
571
- * (typeId, length) header at the start, data 16 bytes in). Frees go
572
- * onto a list keyed by size for simple first-fit reuse later — for
573
- * now `release` just records the gap and the bump cursor never
574
- * shrinks.
575
- */
576
- class AttributeArena {
577
- private cursor = 0;
578
- // (offset, size) free entries; first-fit reuse not yet implemented.
579
- private freeList: { off: number; size: number }[] = [];
580
- // CPU shadow of the entire GPU buffer. Writes go here first; a
581
- // single `device.queue.writeBuffer` per dirty contiguous range
582
- // lifts them to the GPU at flush time. At the cost of doubling
583
- // host memory we collapse N small writeBuffer calls (10K+ at
584
- // initial population) to 1 per frame.
585
- private shadow: Uint8Array;
586
- private dirtyMin = Infinity;
587
- private dirtyMax = 0;
588
- constructor(private readonly buf: GrowBuffer) {
589
- this.shadow = new Uint8Array(buf.capacity);
590
- buf.onResize(() => {
591
- const grown = new Uint8Array(buf.capacity);
592
- grown.set(this.shadow);
593
- this.shadow = grown;
594
- });
595
- }
596
- get buffer(): GPUBuffer { return this.buf.buffer; }
597
- get capacity(): number { return this.buf.capacity; }
598
- get usedBytes(): number { return this.cursor; }
599
- /**
600
- * Stage `data` to the shadow at byte offset `dst`. Tracks the
601
- * dirty range so `flush(device)` can emit a single writeBuffer
602
- * covering everything dirty since the last flush.
603
- */
604
- write(dst: number, data: Uint8Array): void {
605
- this.shadow.set(data, dst);
606
- if (dst < this.dirtyMin) this.dirtyMin = dst;
607
- const end = dst + data.byteLength;
608
- if (end > this.dirtyMax) this.dirtyMax = end;
609
- }
610
- flush(device: GPUDevice): void {
611
- if (this.dirtyMax <= this.dirtyMin) return;
612
- device.queue.writeBuffer(
613
- this.buf.buffer, this.dirtyMin,
614
- this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin,
615
- this.dirtyMax - this.dirtyMin,
616
- );
617
- this.dirtyMin = Infinity;
618
- this.dirtyMax = 0;
619
- }
620
- /**
621
- * Allocate space for one attribute. Returns the byte ref (offset
622
- * to the header — data lives at ref + 16).
623
- */
624
- alloc(dataBytes: number): number {
625
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
626
- // First-fit reuse from free list.
627
- for (let i = 0; i < this.freeList.length; i++) {
628
- const f = this.freeList[i]!;
629
- if (f.size >= allocBytes) {
630
- const ref = f.off;
631
- if (f.size === allocBytes) this.freeList.splice(i, 1);
632
- else { f.off += allocBytes; f.size -= allocBytes; }
633
- return ref;
634
- }
635
- }
636
- const ref = this.cursor;
637
- this.cursor += allocBytes;
638
- this.buf.ensureCapacity(this.cursor);
639
- this.buf.setUsed(this.cursor);
640
- return ref;
641
- }
642
- release(ref: number, dataBytes: number): void {
643
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + dataBytes);
644
- insertSortedFreeBlock(this.freeList, ref, allocBytes);
645
- }
646
- onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
647
- destroy(): void { this.buf.destroy(); }
648
- }
649
-
650
- /**
651
- * Insert `{off, size}` into a free-list kept sorted by `off`, then
652
- * coalesce with the two immediate neighbours.
653
- *
654
- * The list invariant — sorted, non-overlapping, never-adjacent — is
655
- * preserved across allocs (which take from the front or split a
656
- * block) and releases (this function). The previous implementation
657
- * did `push + Array.prototype.sort + linear coalesce scan`, which is
658
- * O(N log N) per release. Under a 500-RO bulk-remove the sort
659
- * dominated `removeDraw` (~41 ms of self-time in the heap-demo-sg
660
- * toggle profile). Binary-search insert + 2-neighbour merge collapses
661
- * that to O(log N + N-shift), and is principled — the sort never
662
- * actually mattered since we already had the sorted prefix as an
663
- * invariant.
664
- */
665
- function insertSortedFreeBlock(
666
- freeList: { off: number; size: number }[],
667
- off: number,
668
- size: number,
669
- ): void {
670
- // Binary-search for the insertion index (first entry whose off > new).
671
- let lo = 0, hi = freeList.length;
672
- while (lo < hi) {
673
- const mid = (lo + hi) >>> 1;
674
- if (freeList[mid]!.off <= off) lo = mid + 1;
675
- else hi = mid;
676
- }
677
- // lo is the index where the new entry would be inserted.
678
- // Try merging with the predecessor first; if successful, the merged
679
- // block may now be adjacent to its (former) successor too.
680
- const prev = lo > 0 ? freeList[lo - 1] : undefined;
681
- if (prev !== undefined && prev.off + prev.size === off) {
682
- prev.size += size;
683
- // Check forward-merge with what was freeList[lo].
684
- const next = freeList[lo];
685
- if (next !== undefined && prev.off + prev.size === next.off) {
686
- prev.size += next.size;
687
- freeList.splice(lo, 1);
688
- }
689
- return;
690
- }
691
- const next = freeList[lo];
692
- if (next !== undefined && off + size === next.off) {
693
- next.off = off;
694
- next.size += size;
695
- return;
696
- }
697
- freeList.splice(lo, 0, { off, size });
698
- }
699
-
700
- /**
701
- * Element-bump allocator over an index GrowBuffer (units = u32). Each
702
- * draw's index range is allocated as one block; on release the block
703
- * is returned to a free list and can be reused first-fit.
704
- */
705
- class IndexAllocator {
706
- private cursor = 0; // in u32s, not bytes
707
- private freeList: { off: number; size: number }[] = [];
708
- // CPU shadow + dirty range, same shape as AttributeArena. Index
709
- // uploads (one per drawn mesh's index buffer) get coalesced to a
710
- // single writeBuffer per dirty range at flush time.
711
- private shadow: Uint8Array;
712
- private dirtyMin = Infinity;
713
- private dirtyMax = 0;
714
- constructor(private readonly buf: GrowBuffer) {
715
- this.shadow = new Uint8Array(buf.capacity);
716
- buf.onResize(() => {
717
- const grown = new Uint8Array(buf.capacity);
718
- grown.set(this.shadow);
719
- this.shadow = grown;
720
- });
721
- }
722
- get buffer(): GPUBuffer { return this.buf.buffer; }
723
- get usedElements(): number { return this.cursor; }
724
- /** Stage `data` (bytes) at the given byte offset; tracks dirty range. */
725
- write(dstByteOffset: number, data: Uint8Array): void {
726
- this.shadow.set(data, dstByteOffset);
727
- if (dstByteOffset < this.dirtyMin) this.dirtyMin = dstByteOffset;
728
- const end = dstByteOffset + data.byteLength;
729
- if (end > this.dirtyMax) this.dirtyMax = end;
730
- }
731
- flush(device: GPUDevice): void {
732
- if (this.dirtyMax <= this.dirtyMin) return;
733
- device.queue.writeBuffer(
734
- this.buf.buffer, this.dirtyMin,
735
- this.shadow.buffer, this.shadow.byteOffset + this.dirtyMin,
736
- this.dirtyMax - this.dirtyMin,
737
- );
738
- this.dirtyMin = Infinity;
739
- this.dirtyMax = 0;
740
- }
741
- alloc(elements: number): number {
742
- for (let i = 0; i < this.freeList.length; i++) {
743
- const f = this.freeList[i]!;
744
- if (f.size >= elements) {
745
- const off = f.off;
746
- if (f.size === elements) this.freeList.splice(i, 1);
747
- else { f.off += elements; f.size -= elements; }
748
- return off;
749
- }
750
- }
751
- const off = this.cursor;
752
- this.cursor += elements;
753
- this.buf.ensureCapacity(this.cursor * 4);
754
- this.buf.setUsed(this.cursor * 4);
755
- return off;
756
- }
757
- release(off: number, elements: number): void {
758
- insertSortedFreeBlock(this.freeList, off, elements);
759
- }
760
- onResize(cb: () => void): IDisposable { return this.buf.onResize(cb); }
761
- destroy(): void { this.buf.destroy(); }
762
- }
763
-
764
- // ---------------------------------------------------------------------------
765
- // Static initial pack (uses the new allocators)
140
+ // Internal state
766
141
  // ---------------------------------------------------------------------------
767
142
 
768
143
  /**
769
- * Global arena state: attribute / uniform data lives in `attrs`
770
- * (multi-typed-view storage); indices live in `indices` (separate
771
- * INDEX-usage buffer). Per-draw bookkeeping (which arena offsets
772
- * are alive for which draw) now lives entirely in the bucket via
773
- * the UniformPool's refcount + the bucket's per-local-slot arrays.
144
+ * One pipeline-state bucket. Holds the pipeline + a list of global
145
+ * draw-slot indices to emit with it. The bind group it draws against
146
+ * is referenced by `bindGroup` (typically the shared no-textures one
147
+ * or one of the per-texture-set ones).
774
148
  */
775
- interface ArenaState {
776
- readonly attrs: AttributeArena;
777
- readonly indices: IndexAllocator;
778
- }
779
-
780
- function buildArenaState(
781
- device: GPUDevice,
782
- attrBytesHint: number,
783
- idxBytesHint: number,
784
- label: string,
785
- idxExtraUsage: GPUBufferUsageFlags = 0,
786
- ): ArenaState {
787
- const attrs = new AttributeArena(new GrowBuffer(
788
- device, `${label}/attrs`, GPUBufferUsage.STORAGE,
789
- attrBytesHint,
790
- ));
791
- const indices = new IndexAllocator(new GrowBuffer(
792
- device, `${label}/idx`, GPUBufferUsage.INDEX | idxExtraUsage,
793
- idxBytesHint,
794
- ));
795
- return { attrs, indices };
796
- }
797
-
798
- function arenaBytes(arena: ArenaState): number {
799
- return arena.attrs.usedBytes + arena.indices.usedElements * 4;
800
- }
801
-
802
- /** Upload a single attribute — header (typeId, length) + data — into the arena at byte offset `ref`. */
803
- function writeAttribute(
804
- device: GPUDevice, buf: GPUBuffer, ref: number, typeId: number, length: number, data: Float32Array,
805
- ): void {
806
- const allocBytes = ALIGN16(ALLOC_HEADER_PAD_TO + data.byteLength);
807
- const staging = new ArrayBuffer(allocBytes);
808
- const u32 = new Uint32Array(staging);
809
- const f32 = new Float32Array(staging);
810
- u32[0] = typeId;
811
- u32[1] = length;
812
- f32.set(data, ALLOC_HEADER_PAD_TO / 4);
813
- device.queue.writeBuffer(buf, ref, staging, 0, allocBytes);
814
- }
815
-
816
- function asAval<T>(v: aval<T> | T): aval<T> {
817
- return (typeof v === "object" && v !== null && typeof (v as { getValue?: unknown }).getValue === "function")
818
- ? (v as aval<T>)
819
- : AVal.constant(v as T);
820
- }
821
-
822
- /** Heuristic predicate — BufferView has `buffer: aval<IBuffer>` + elementType. */
823
- function isBufferView(v: unknown): v is BufferView {
824
- if (typeof v !== "object" || v === null) return false;
825
- const o = v as { buffer?: unknown; elementType?: unknown };
826
- return typeof o.buffer === "object" && o.buffer !== null
827
- && typeof (o.buffer as { getValue?: unknown }).getValue === "function"
828
- && typeof o.elementType === "object" && o.elementType !== null;
829
- }
830
-
831
149
  /**
832
- * Float32 view over a host-side buffer source. Used by the BufferView
833
- * packer to hand the pool a typed array it can `set()` from.
150
+ * Per-slot state inside a bucket. Today every bucket has exactly ONE
151
+ * slot `bucket.slots.length === 1`. Multi-pipeline buckets (the
152
+ * derived-modes work, see docs/derived-modes.md) grow this to one
153
+ * slot per distinct pipeline state used by the bucket's ROs. Each
154
+ * slot owns its own drawTable + scan buffers + indirect + pipeline.
834
155
  */
835
- function asFloat32(data: HostBufferSource): Float32Array {
836
- if (data instanceof Float32Array) return data;
837
- if (ArrayBuffer.isView(data)) {
838
- return new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4);
839
- }
840
- return new Float32Array(data); // ArrayBuffer
841
- }
842
-
843
- // ---------------------------------------------------------------------------
844
- // Megacall GPU prefix-sum compute shader
845
- // ---------------------------------------------------------------------------
846
-
847
- const SCAN_TILE_SIZE = 512;
848
- const SCAN_WG_SIZE = 256;
849
- const SCAN_MAX_RECORDS = SCAN_TILE_SIZE * SCAN_TILE_SIZE; // numBlocks ≤ TILE_SIZE
850
-
851
- const TILE_K = 64;
852
-
853
- /** drawTable record width: (firstEmit, drawIdx, indexStart, indexCount, instanceCount). */
854
- const RECORD_U32 = 5;
855
- const RECORD_BYTES = RECORD_U32 * 4;
856
-
857
- const HEAP_SCAN_WGSL = `
858
- struct Params {
859
- numRecords: u32,
860
- numBlocks: u32,
861
- _pad0: u32,
862
- _pad1: u32,
863
- };
864
-
865
- struct Record {
866
- firstEmit: u32,
867
- drawIdx: u32,
868
- indexStart: u32,
869
- indexCount: u32,
870
- instanceCount: u32,
871
- };
872
-
873
- @group(0) @binding(0) var<storage, read_write> drawTable: array<Record>;
874
- @group(0) @binding(1) var<storage, read_write> blockSums: array<u32>;
875
- @group(0) @binding(2) var<storage, read_write> blockOffsets: array<u32>;
876
- @group(0) @binding(3) var<storage, read_write> indirect: array<u32>;
877
- @group(0) @binding(4) var<uniform> params: Params;
878
- @group(0) @binding(5) var<storage, read_write> firstDrawInTile: array<u32>;
879
-
880
- const TILE_SIZE: u32 = 512u;
881
- const WG_SIZE: u32 = 256u;
882
- const TILE_K: u32 = 64u;
883
-
884
- var<workgroup> sdata: array<u32, 512>;
885
-
886
- fn blellochScan(tid: u32) {
887
- var offset: u32 = 1u;
888
- for (var d: u32 = TILE_SIZE >> 1u; d > 0u; d = d >> 1u) {
889
- workgroupBarrier();
890
- if (tid < d) {
891
- let ai = offset * (2u * tid + 1u) - 1u;
892
- let bi = offset * (2u * tid + 2u) - 1u;
893
- sdata[bi] = sdata[bi] + sdata[ai];
894
- }
895
- offset = offset * 2u;
896
- }
897
- if (tid == 0u) { sdata[TILE_SIZE - 1u] = 0u; }
898
- for (var d: u32 = 1u; d < TILE_SIZE; d = d * 2u) {
899
- offset = offset >> 1u;
900
- workgroupBarrier();
901
- if (tid < d) {
902
- let ai = offset * (2u * tid + 1u) - 1u;
903
- let bi = offset * (2u * tid + 2u) - 1u;
904
- let t = sdata[ai];
905
- sdata[ai] = sdata[bi];
906
- sdata[bi] = sdata[bi] + t;
907
- }
908
- }
909
- workgroupBarrier();
910
- }
911
-
912
- @compute @workgroup_size(WG_SIZE)
913
- fn scanTile(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
914
- let tid = lid.x;
915
- let blockOff = wgid.x * TILE_SIZE;
916
- let n = params.numRecords;
917
- let i0 = blockOff + tid;
918
- let i1 = blockOff + tid + WG_SIZE;
919
- var v0: u32 = 0u;
920
- var v1: u32 = 0u;
921
- if (i0 < n) { v0 = drawTable[i0].indexCount * drawTable[i0].instanceCount; }
922
- if (i1 < n) { v1 = drawTable[i1].indexCount * drawTable[i1].instanceCount; }
923
- sdata[tid] = v0;
924
- sdata[tid + WG_SIZE] = v1;
925
- workgroupBarrier();
926
- blellochScan(tid);
927
- if (i0 < n) { drawTable[i0].firstEmit = sdata[tid]; }
928
- if (i1 < n) { drawTable[i1].firstEmit = sdata[tid + WG_SIZE]; }
929
- if (tid == WG_SIZE - 1u) {
930
- blockSums[wgid.x] = sdata[tid + WG_SIZE] + v1;
931
- }
932
- }
933
-
934
- @compute @workgroup_size(WG_SIZE)
935
- fn scanBlocks(@builtin(local_invocation_id) lid: vec3<u32>) {
936
- let tid = lid.x;
937
- let n = params.numBlocks;
938
- let i0 = tid;
939
- let i1 = tid + WG_SIZE;
940
- var v0: u32 = 0u;
941
- var v1: u32 = 0u;
942
- if (i0 < n) { v0 = blockSums[i0]; }
943
- if (i1 < n) { v1 = blockSums[i1]; }
944
- sdata[tid] = v0;
945
- sdata[tid + WG_SIZE] = v1;
946
- workgroupBarrier();
947
- blellochScan(tid);
948
- if (i0 < n) { blockOffsets[i0] = sdata[tid]; }
949
- if (i1 < n) { blockOffsets[i1] = sdata[tid + WG_SIZE]; }
950
- workgroupBarrier();
951
- if (tid == 0u) {
952
- if (n > 0u) {
953
- let lastIdx = n - 1u;
954
- let total = blockOffsets[lastIdx] + blockSums[lastIdx];
955
- indirect[0] = total;
956
- } else {
957
- indirect[0] = 0u;
958
- }
959
- indirect[1] = 1u;
960
- indirect[2] = 0u;
961
- indirect[3] = 0u;
962
- }
963
- }
156
+ interface BucketSlot {
157
+ /** The render pipeline this slot draws with. */
158
+ readonly pipeline: GPURenderPipeline;
964
159
 
965
- @compute @workgroup_size(WG_SIZE)
966
- fn addOffsets(@builtin(local_invocation_id) lid: vec3<u32>, @builtin(workgroup_id) wgid: vec3<u32>) {
967
- let tid = lid.x;
968
- let blockOff = wgid.x * TILE_SIZE;
969
- let n = params.numRecords;
970
- let off = blockOffsets[wgid.x];
971
- let i0 = blockOff + tid;
972
- let i1 = blockOff + tid + WG_SIZE;
973
- if (i0 < n) { drawTable[i0].firstEmit = drawTable[i0].firstEmit + off; }
974
- if (i1 < n) { drawTable[i1].firstEmit = drawTable[i1].firstEmit + off; }
975
- }
160
+ // ─── drawTable / megacall state ────────────────────────────────────
161
+ drawTableBuf?: GrowBuffer;
162
+ drawTableShadow?: Uint32Array;
163
+ drawTableDirtyMin: number;
164
+ drawTableDirtyMax: number;
165
+ /** Number of live records routed to this slot (= drawTable length).
166
+ * GPU owns firstEmit / total. */
167
+ recordCount: number;
168
+ /** localSlot recordIdx (or -1). */
169
+ slotToRecord: number[];
170
+ /** recordIdx → localSlot. */
171
+ recordToSlot: number[];
976
172
 
977
- @compute @workgroup_size(WG_SIZE)
978
- fn buildTileIndex(@builtin(global_invocation_id) gid: vec3<u32>) {
979
- let tileIdx = gid.x;
980
- // totalEmit is computed by scanBlocks into indirect[0]; reading it
981
- // from indirect avoids a separate uniform/storage round-trip.
982
- let totalEmit = indirect[0];
983
- let numTiles = (totalEmit + TILE_K - 1u) / TILE_K;
984
- if (tileIdx > numTiles) { return; }
985
- if (params.numRecords == 0u) {
986
- if (tileIdx == 0u) { firstDrawInTile[0] = 0u; }
987
- return;
988
- }
989
- if (tileIdx == numTiles) {
990
- // Sentinel for the open upper bound — the LAST VALID SLOT, not
991
- // numRecords. The render VS uses
992
- // hi = firstDrawInTile[_tileIdx + 1u]
993
- // and the binary search treats hi as INCLUSIVE. If the sentinel
994
- // were numRecords (one past last), the search would drag lo into
995
- // the OOB slot for emits in the last tile, since drawTable reads
996
- // past recordCount return 0 (binding size clamping) and 0 ≤ emit
997
- // is always true. Visible symptom: the LAST few emits in the
998
- // bucket land on slot=numRecords (drawIdx=0, indexCount=0 → /-by-
999
- // zero) → degenerate / cross-RO triangle stitched to slot 0.
1000
- firstDrawInTile[tileIdx] = params.numRecords - 1u;
1001
- return;
1002
- }
1003
- let tileStart = tileIdx * TILE_K;
1004
- var lo: u32 = 0u;
1005
- var hi: u32 = params.numRecords - 1u;
1006
- loop {
1007
- if (lo >= hi) { break; }
1008
- let mid = (lo + hi + 1u) >> 1u;
1009
- if (drawTable[mid].firstEmit <= tileStart) { lo = mid; } else { hi = mid - 1u; }
1010
- }
1011
- firstDrawInTile[tileIdx] = lo;
173
+ // ─── Per-slot scan / indirect buffers ──────────────────────────────
174
+ blockSumsBuf?: GrowBuffer;
175
+ blockOffsetsBuf?: GrowBuffer;
176
+ firstDrawInTileBuf?: GrowBuffer;
177
+ /** CPU sum of indexCounts across live records — drives firstDrawInTileBuf sizing only. */
178
+ totalEmitEstimate: number;
179
+ indirectBuf?: GPUBuffer;
180
+ paramsBuf?: GPUBuffer;
181
+ scanBindGroup?: GPUBindGroup;
182
+ /** numRecords used to size the current render bindGroup; rebuild when it changes. */
183
+ renderBoundRecordCount?: number;
184
+ scanDirty: boolean;
1012
185
  }
1013
- `;
1014
-
1015
- // ---------------------------------------------------------------------------
1016
- // Internal state
1017
- // ---------------------------------------------------------------------------
1018
186
 
1019
- /**
1020
- * One pipeline-state bucket. Holds the pipeline + a list of global
1021
- * draw-slot indices to emit with it. The bind group it draws against
1022
- * is referenced by `bindGroup` (typically the shared no-textures one
1023
- * or one of the per-texture-set ones).
1024
- */
1025
187
  interface Bucket {
1026
188
  readonly label: string;
1027
189
  readonly textures: HeapTextureSet | undefined;
1028
190
  readonly layout: BucketLayout;
1029
- readonly pipeline: GPURenderPipeline;
191
+ /**
192
+ * One entry per distinct pipeline state. Phase 5a: always length 1
193
+ * (per-RO pipeline-state still keyed at the bucket level).
194
+ * Later phases of the derived-modes work let `slots.length > 1`.
195
+ */
196
+ readonly slots: BucketSlot[];
1030
197
  /** Repointed by `rebuildBindGroups` whenever any backing GrowBuffer reallocates. */
1031
198
  bindGroup: GPUBindGroup;
1032
199
 
@@ -1082,30 +249,6 @@ interface Bucket {
1082
249
  /** Local slots whose DrawHeader needs re-pack + writeBuffer next frame. */
1083
250
  readonly dirty: Set<number>;
1084
251
 
1085
- // ─── Megacall state ────────────────────────────────────────────────
1086
- drawTableBuf?: GrowBuffer;
1087
- drawTableShadow?: Uint32Array;
1088
- drawTableDirtyMin: number;
1089
- drawTableDirtyMax: number;
1090
- /** Number of live records (= drawTable length). GPU owns firstEmit / total. */
1091
- recordCount: number;
1092
- /** localSlot → recordIdx (or -1). */
1093
- slotToRecord: number[];
1094
- /** recordIdx → localSlot. */
1095
- recordToSlot: number[];
1096
- /** Per-bucket buffers for the GPU prefix-sum pipeline. */
1097
- blockSumsBuf?: GrowBuffer;
1098
- blockOffsetsBuf?: GrowBuffer;
1099
- firstDrawInTileBuf?: GrowBuffer;
1100
- /** CPU sum of indexCounts across live records — drives firstDrawInTileBuf sizing only. */
1101
- totalEmitEstimate: number;
1102
- indirectBuf?: GPUBuffer;
1103
- paramsBuf?: GPUBuffer;
1104
- scanBindGroup?: GPUBindGroup;
1105
- /** numRecords used to size the current render bindGroup; rebuild when it changes. */
1106
- renderBoundRecordCount?: number;
1107
- scanDirty: boolean;
1108
-
1109
252
  // ─── Atlas-binding state (atlas-variant buckets only) ─────────────
1110
253
  /**
1111
254
  * True when this bucket holds at least one atlas-variant RO.
@@ -1428,6 +571,18 @@ export function buildHeapScene(
1428
571
  const drawIdToLocalSlot: (number | undefined)[] = [];
1429
572
  /** Per-draw index aval — for `indexPool.release` on removeDraw. */
1430
573
  const drawIdToIndexAval: (aval<Uint32Array> | undefined)[] = [];
574
+ /**
575
+ * Original spec retained per drawId so we can re-add the RO into a
576
+ * new bucket when its PipelineState modeKey changes (reactive
577
+ * cullmode flip, etc.). Cheap to retain — specs are mostly aval
578
+ * references.
579
+ */
580
+ const drawIdToSpec: (HeapDrawSpec | undefined)[] = [];
581
+ /** Per-draw ModeKeyTracker — subscribes to PS aval marks, recomputes
582
+ * the modeKey, schedules a rebucket if it changed. */
583
+ const drawIdToModeTracker: (ModeKeyTracker | undefined)[] = [];
584
+ /** drawIds whose PS marked since last update; drained by `update`. */
585
+ const dirtyModeKeyDrawIds = new Set<number>();
1431
586
  let nextDrawId = 0;
1432
587
 
1433
588
  /**
@@ -1853,12 +1008,12 @@ export function buildHeapScene(
1853
1008
  label: `heapScene/${bucket.label}/scanBg`,
1854
1009
  layout: scanBgl,
1855
1010
  entries: [
1856
- { binding: 0, resource: { buffer: bucket.drawTableBuf!.buffer } },
1857
- { binding: 1, resource: { buffer: bucket.blockSumsBuf!.buffer } },
1858
- { binding: 2, resource: { buffer: bucket.blockOffsetsBuf!.buffer } },
1859
- { binding: 3, resource: { buffer: bucket.indirectBuf! } },
1860
- { binding: 4, resource: { buffer: bucket.paramsBuf! } },
1861
- { binding: 5, resource: { buffer: bucket.firstDrawInTileBuf!.buffer } },
1011
+ { binding: 0, resource: { buffer: bucket.slots[0]!.drawTableBuf!.buffer } },
1012
+ { binding: 1, resource: { buffer: bucket.slots[0]!.blockSumsBuf!.buffer } },
1013
+ { binding: 2, resource: { buffer: bucket.slots[0]!.blockOffsetsBuf!.buffer } },
1014
+ { binding: 3, resource: { buffer: bucket.slots[0]!.indirectBuf! } },
1015
+ { binding: 4, resource: { buffer: bucket.slots[0]!.paramsBuf! } },
1016
+ { binding: 5, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
1862
1017
  ],
1863
1018
  });
1864
1019
  }
@@ -2085,29 +1240,10 @@ export function buildHeapScene(
2085
1240
  avalIds.set(av, vid);
2086
1241
  return `c${vid}`;
2087
1242
  };
2088
- const psContentIds = new WeakMap<PipelineState, string>();
2089
- const psIdOf = (ps: PipelineState | undefined): string => {
2090
- if (ps === undefined) return "ps#default";
2091
- const cached = psContentIds.get(ps);
2092
- if (cached !== undefined) return cached;
2093
- const r = ps.rasterizer;
2094
- const parts: string[] = [
2095
- avalIdOf(r.topology),
2096
- avalIdOf(r.cullMode),
2097
- avalIdOf(r.frontFace),
2098
- avalIdOf(r.depthBias),
2099
- ps.depth !== undefined
2100
- ? `d:${avalIdOf(ps.depth.write)}:${avalIdOf(ps.depth.compare)}:${avalIdOf(ps.depth.clamp)}`
2101
- : "d:_",
2102
- ps.stencil !== undefined ? "s:1" : "s:_",
2103
- avalIdOf(ps.blends),
2104
- avalIdOf(ps.alphaToCoverage),
2105
- avalIdOf(ps.blendConstant),
2106
- ];
2107
- const key = `ps#${parts.join("|")}`;
2108
- psContentIds.set(ps, key);
2109
- return key;
2110
- };
1243
+ // (psIdOf removed in Phase 5b — replaced by snapshotDescriptor +
1244
+ // encodeModeKey at the findOrCreateBucket call site, which keys on
1245
+ // the VALUE of the pipeline state rather than aval identity. The
1246
+ // 20k-cvals-with-same-value pathology now collapses correctly.)
2111
1247
 
2112
1248
  /** Resolved (forced) snapshot of the user's PipelineState. */
2113
1249
  interface ResolvedPipelineState {
@@ -2155,7 +1291,7 @@ export function buildHeapScene(
2155
1291
  { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
2156
1292
  ];
2157
1293
  {
2158
- if (bucket.drawTableBuf === undefined) {
1294
+ if (bucket.slots[0]!.drawTableBuf === undefined) {
2159
1295
  throw new Error("heapScene: megacall bucket without drawTableBuf");
2160
1296
  }
2161
1297
  // Bind drawTable with size = recordCount * RECORD_BYTES so the VS
@@ -2163,13 +1299,13 @@ export function buildHeapScene(
2163
1299
  // the live record count — keeps stale tail entries out of the
2164
1300
  // binary search. Minimum one zero-record to satisfy WebGPU non-
2165
1301
  // zero size constraint when the bucket is empty.
2166
- const dtBytes = Math.max(RECORD_BYTES, bucket.recordCount * RECORD_BYTES);
1302
+ const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0]!.recordCount * RECORD_BYTES);
2167
1303
  entries.push(
2168
- { binding: 4, resource: { buffer: bucket.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1304
+ { binding: 4, resource: { buffer: bucket.slots[0]!.drawTableBuf.buffer, offset: 0, size: dtBytes } },
2169
1305
  { binding: 5, resource: { buffer: arena.indices.buffer } },
2170
- { binding: 6, resource: { buffer: bucket.firstDrawInTileBuf!.buffer } },
1306
+ { binding: 6, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
2171
1307
  );
2172
- bucket.renderBoundRecordCount = bucket.recordCount;
1308
+ bucket.slots[0]!.renderBoundRecordCount = bucket.slots[0]!.recordCount;
2173
1309
  }
2174
1310
  // Schema-driven texture + sampler entries. v1 user surface still
2175
1311
  // accepts a single (texture, sampler) pair via HeapTextureSet —
@@ -2307,8 +1443,17 @@ export function buildHeapScene(
2307
1443
  throw new Error("heapScene: findOrCreateBucket called before family build");
2308
1444
  }
2309
1445
  const fam = familyFor(effect);
2310
- const psKey = psIdOf(pipelineState);
2311
- const bk = `family#${fam.schema.id}|${psKey}`;
1446
+ // ─── PS keying — VALUE based, not identity based ───────────────
1447
+ // psIdOf used to hash by aval identity. That meant 20k ROs each
1448
+ // constructed with `cval("back")` for cullMode hashed to 20k
1449
+ // distinct keys → 20k buckets, all rendering with bitwise-
1450
+ // identical state. Now the key is the bitfield-encoded modeKey
1451
+ // (the actual value-set in the descriptor), so identical-value
1452
+ // cvals collapse to ONE bucket. See docs/derived-modes.md and
1453
+ // tests/heap-multi-pipeline-bucket.test.ts.
1454
+ const psDescriptor = snapshotDescriptor(pipelineState, sig);
1455
+ const psModeKey = encodeModeKey(psDescriptor);
1456
+ const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
2312
1457
  const existing = bucketByKey.get(bk);
2313
1458
  if (existing !== undefined) return existing;
2314
1459
  const ps = resolvePipelineState(pipelineState);
@@ -2343,13 +1488,21 @@ export function buildHeapScene(
2343
1488
  );
2344
1489
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
2345
1490
 
1491
+ const slot0: BucketSlot = {
1492
+ pipeline,
1493
+ drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1494
+ recordCount: 0, slotToRecord: [], recordToSlot: [],
1495
+ totalEmitEstimate: 0,
1496
+ scanDirty: false,
1497
+ };
2346
1498
  const bucket: Bucket = {
2347
1499
  // §6 family-merge: family buckets aren't keyed on a specific
2348
1500
  // texture set — atlas placements are addressed per-RO via
2349
1501
  // drawHeader fields (`pageRef` + `formatBits` + `origin` +
2350
1502
  // `size`), and the bucket's atlas-binding ladder is driven by
2351
1503
  // `atlasPool.pagesFor(format)`. Leave `textures` undefined.
2352
- label: bk, textures: undefined, layout, pipeline,
1504
+ label: bk, textures: undefined, layout,
1505
+ slots: [slot0],
2353
1506
  bindGroup: null as unknown as GPUBindGroup,
2354
1507
  drawHeap,
2355
1508
  drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
@@ -2358,10 +1511,6 @@ export function buildHeapScene(
2358
1511
  localEntries: [], localToDrawId: [],
2359
1512
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
2360
1513
  drawSlots: new Set<number>(), dirty: new Set<number>(),
2361
- drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
2362
- recordCount: 0, slotToRecord: [], recordToSlot: [],
2363
- totalEmitEstimate: 0,
2364
- scanDirty: false,
2365
1514
  isAtlasBucket,
2366
1515
  localAtlasReleases: [],
2367
1516
  localAtlasTextures: [],
@@ -2401,30 +1550,30 @@ export function buildHeapScene(
2401
1550
  label: `heapScene/${bk}/params`, size: 16,
2402
1551
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2403
1552
  });
2404
- bucket.drawTableBuf = dtBuf;
2405
- bucket.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
2406
- bucket.blockSumsBuf = blockSumsBuf;
2407
- bucket.blockOffsetsBuf = blockOffsetsBuf;
2408
- bucket.firstDrawInTileBuf = firstDrawInTileBuf;
2409
- bucket.indirectBuf = indirectBuf;
2410
- bucket.paramsBuf = paramsBuf;
1553
+ bucket.slots[0]!.drawTableBuf = dtBuf;
1554
+ bucket.slots[0]!.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
1555
+ bucket.slots[0]!.blockSumsBuf = blockSumsBuf;
1556
+ bucket.slots[0]!.blockOffsetsBuf = blockOffsetsBuf;
1557
+ bucket.slots[0]!.firstDrawInTileBuf = firstDrawInTileBuf;
1558
+ bucket.slots[0]!.indirectBuf = indirectBuf;
1559
+ bucket.slots[0]!.paramsBuf = paramsBuf;
2411
1560
  const ensureScanBuffers = (): void => {
2412
- const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
1561
+ const needBlocks = Math.max(1, Math.ceil(bucket.slots[0]!.recordCount / SCAN_TILE_SIZE));
2413
1562
  blockSumsBuf.ensureCapacity(needBlocks * 4);
2414
1563
  blockOffsetsBuf.ensureCapacity(needBlocks * 4);
2415
1564
  };
2416
1565
  const rebuildScanBg = (): void => {
2417
- bucket.scanBindGroup = buildScanBindGroup(bucket);
1566
+ bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
2418
1567
  };
2419
1568
  dtBuf.onResize(() => {
2420
1569
  const grown = new Uint32Array(dtBuf.capacity / 4);
2421
- grown.set(bucket.drawTableShadow!);
2422
- bucket.drawTableShadow = grown;
1570
+ grown.set(bucket.slots[0]!.drawTableShadow!);
1571
+ bucket.slots[0]!.drawTableShadow = grown;
2423
1572
  ensureScanBuffers();
2424
1573
  bucket.bindGroup = buildBucketBindGroup(bucket);
2425
1574
  rebuildScanBg();
2426
- bucket.drawTableDirtyMin = 0;
2427
- bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
1575
+ bucket.slots[0]!.drawTableDirtyMin = 0;
1576
+ bucket.slots[0]!.drawTableDirtyMax = bucket.slots[0]!.recordCount * RECORD_BYTES;
2428
1577
  });
2429
1578
  blockSumsBuf.onResize(rebuildScanBg);
2430
1579
  blockOffsetsBuf.onResize(rebuildScanBg);
@@ -2432,7 +1581,7 @@ export function buildHeapScene(
2432
1581
  rebuildScanBg();
2433
1582
  bucket.bindGroup = buildBucketBindGroup(bucket);
2434
1583
  });
2435
- bucket.scanBindGroup = buildScanBindGroup(bucket);
1584
+ bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
2436
1585
  }
2437
1586
  bucket.bindGroup = buildBucketBindGroup(bucket);
2438
1587
  drawHeap.onResize(() => {
@@ -2754,8 +1903,8 @@ export function buildHeapScene(
2754
1903
  if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
2755
1904
 
2756
1905
  {
2757
- const dtBuf = bucket.drawTableBuf!;
2758
- const recIdx = bucket.recordCount;
1906
+ const dtBuf = bucket.slots[0]!.drawTableBuf!;
1907
+ const recIdx = bucket.slots[0]!.recordCount;
2759
1908
  if (recIdx >= SCAN_MAX_RECORDS) {
2760
1909
  throw new Error(
2761
1910
  `heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
@@ -2767,30 +1916,42 @@ export function buildHeapScene(
2767
1916
  dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
2768
1917
  // Grow scan-side buffers if recordCount crosses a tile boundary.
2769
1918
  const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
2770
- bucket.blockSumsBuf!.ensureCapacity(needBlocks * 4);
2771
- bucket.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
2772
- const shadow = bucket.drawTableShadow!;
1919
+ bucket.slots[0]!.blockSumsBuf!.ensureCapacity(needBlocks * 4);
1920
+ bucket.slots[0]!.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
1921
+ const shadow = bucket.slots[0]!.drawTableShadow!;
2773
1922
  // firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
2774
1923
  shadow[recIdx * RECORD_U32 + 0] = 0;
2775
1924
  shadow[recIdx * RECORD_U32 + 1] = localSlot;
2776
1925
  shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
2777
1926
  shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
2778
1927
  shadow[recIdx * RECORD_U32 + 4] = instanceCount;
2779
- bucket.recordCount = recIdx + 1;
2780
- bucket.slotToRecord[localSlot] = recIdx;
2781
- bucket.recordToSlot[recIdx] = localSlot;
2782
- if (byteOff < bucket.drawTableDirtyMin) bucket.drawTableDirtyMin = byteOff;
2783
- if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax) bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
2784
- bucket.totalEmitEstimate += idxAlloc.count * instanceCount;
2785
- const newNumTiles = Math.max(1, Math.ceil(bucket.totalEmitEstimate / TILE_K));
2786
- bucket.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
2787
- bucket.scanDirty = true;
1928
+ bucket.slots[0]!.recordCount = recIdx + 1;
1929
+ bucket.slots[0]!.slotToRecord[localSlot] = recIdx;
1930
+ bucket.slots[0]!.recordToSlot[recIdx] = localSlot;
1931
+ if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
1932
+ if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
1933
+ bucket.slots[0]!.totalEmitEstimate += idxAlloc.count * instanceCount;
1934
+ const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0]!.totalEmitEstimate / TILE_K));
1935
+ bucket.slots[0]!.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
1936
+ bucket.slots[0]!.scanDirty = true;
2788
1937
  }
2789
1938
 
2790
1939
  drawIdToBucket[drawId] = bucket;
2791
1940
  drawIdToLocalSlot[drawId] = localSlot;
2792
1941
  drawIdToIndexAval[drawId] = indicesAval;
2793
1942
 
1943
+ // ─── Reactive rebucket: track PS-modeKey changes ────────────────
1944
+ // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
1945
+ // schedule this RO for rebucket on the next update(). Today's
1946
+ // bucket key is the modeKey VALUE (Phase 5b), so changing the
1947
+ // value must move the RO to a different bucket. Without this
1948
+ // tracker the cval flip would be silently ignored.
1949
+ drawIdToSpec[drawId] = spec;
1950
+ const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => {
1951
+ dirtyModeKeyDrawIds.add(drawId);
1952
+ });
1953
+ drawIdToModeTracker[drawId] = tracker;
1954
+
2794
1955
  // ─── §7 derived-uniforms registration ────────────────────────────
2795
1956
  // A uniform binding on this RO is either a value (aval/constant) or a rule —
2796
1957
  // collect the rules (explicit `derivedUniform(...)` values + standard trafo
@@ -2865,13 +2026,13 @@ export function buildHeapScene(
2865
2026
  const removedCount = removedEntry !== undefined
2866
2027
  ? removedEntry.indexCount * removedEntry.instanceCount
2867
2028
  : 0;
2868
- bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
2029
+ bucket.slots[0]!.totalEmitEstimate = Math.max(0, bucket.slots[0]!.totalEmitEstimate - removedCount);
2869
2030
  // Swap-pop: move the last record into the freed slot, decrement
2870
2031
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
2871
2032
  // we only fix (drawIdx, indexStart, indexCount, instanceCount).
2872
- const recIdx = bucket.slotToRecord[localSlot]!;
2873
- const lastRecIdx = bucket.recordCount - 1;
2874
- const shadow = bucket.drawTableShadow!;
2033
+ const recIdx = bucket.slots[0]!.slotToRecord[localSlot]!;
2034
+ const lastRecIdx = bucket.slots[0]!.recordCount - 1;
2035
+ const shadow = bucket.slots[0]!.drawTableShadow!;
2875
2036
  if (recIdx !== lastRecIdx) {
2876
2037
  const dst = recIdx * RECORD_U32;
2877
2038
  const src = lastRecIdx * RECORD_U32;
@@ -2880,17 +2041,17 @@ export function buildHeapScene(
2880
2041
  shadow[dst + 2] = shadow[src + 2]!;
2881
2042
  shadow[dst + 3] = shadow[src + 3]!;
2882
2043
  shadow[dst + 4] = shadow[src + 4]!;
2883
- const movedSlot = bucket.recordToSlot[lastRecIdx]!;
2884
- bucket.slotToRecord[movedSlot] = recIdx;
2885
- bucket.recordToSlot[recIdx] = movedSlot;
2044
+ const movedSlot = bucket.slots[0]!.recordToSlot[lastRecIdx]!;
2045
+ bucket.slots[0]!.slotToRecord[movedSlot] = recIdx;
2046
+ bucket.slots[0]!.recordToSlot[recIdx] = movedSlot;
2886
2047
  const byteOff = recIdx * RECORD_BYTES;
2887
- if (byteOff < bucket.drawTableDirtyMin) bucket.drawTableDirtyMin = byteOff;
2888
- if (byteOff + RECORD_BYTES > bucket.drawTableDirtyMax) bucket.drawTableDirtyMax = byteOff + RECORD_BYTES;
2048
+ if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
2049
+ if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
2889
2050
  }
2890
- bucket.slotToRecord[localSlot] = -1;
2891
- bucket.recordToSlot[lastRecIdx] = -1;
2892
- bucket.recordCount = lastRecIdx;
2893
- bucket.scanDirty = true;
2051
+ bucket.slots[0]!.slotToRecord[localSlot] = -1;
2052
+ bucket.slots[0]!.recordToSlot[lastRecIdx] = -1;
2053
+ bucket.slots[0]!.recordCount = lastRecIdx;
2054
+ bucket.slots[0]!.scanDirty = true;
2894
2055
  }
2895
2056
 
2896
2057
  // Release pool entries — refcount drops; if zero, allocation freed.
@@ -2943,9 +2104,18 @@ export function buildHeapScene(
2943
2104
  drawIdToBucket[drawId] = undefined;
2944
2105
  drawIdToLocalSlot[drawId] = undefined;
2945
2106
  drawIdToIndexAval[drawId] = undefined;
2107
+ drawIdToSpec[drawId] = undefined;
2108
+ const oldTracker = drawIdToModeTracker[drawId];
2109
+ if (oldTracker !== undefined) oldTracker.dispose();
2110
+ drawIdToModeTracker[drawId] = undefined;
2111
+ dirtyModeKeyDrawIds.delete(drawId);
2946
2112
 
2947
2113
  stats.totalDraws--;
2948
2114
  stats.geometryBytes = arenaBytes(arena);
2115
+
2116
+ // GC the bucket if it just emptied (common after reactive
2117
+ // PS rebucket leaves the source bucket with zero live ROs).
2118
+ gcBucket(bucket);
2949
2119
  }
2950
2120
 
2951
2121
  // ─── Aset reader (pull-driven on each frame) ──────────────────────
@@ -3030,6 +2200,35 @@ export function buildHeapScene(
3030
2200
  sceneObj.evaluateAlways(token, (tok) => {
3031
2201
  drainAsetWith(tok);
3032
2202
 
2203
+ // 0. Reactive rebucket: any RO whose PipelineState modeKey
2204
+ // changed since last frame moves to its new bucket.
2205
+ // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
2206
+ // onDirty callbacks (one per leaf aval per RO). Calling
2207
+ // `recompute()` here re-snapshots the descriptor and tells
2208
+ // us whether the modeKey actually changed (vs the aval just
2209
+ // being marked-but-equal). If the key changed, we remove +
2210
+ // re-add the RO via the retained spec; addDraw routes it to
2211
+ // the correct (new) bucket via the value-keyed lookup.
2212
+ if (dirtyModeKeyDrawIds.size > 0) {
2213
+ const dirty = [...dirtyModeKeyDrawIds];
2214
+ dirtyModeKeyDrawIds.clear();
2215
+ for (const drawId of dirty) {
2216
+ const tracker = drawIdToModeTracker[drawId];
2217
+ if (tracker === undefined) continue; // RO removed since mark
2218
+ const oldKey = tracker.modeKey;
2219
+ if (!tracker.recompute()) continue; // value unchanged
2220
+ if (tracker.modeKey === oldKey) continue; // belt + braces
2221
+ const spec = drawIdToSpec[drawId];
2222
+ if (spec === undefined) continue;
2223
+ removeDraw(drawId);
2224
+ const newId = addDrawImpl(spec, tok);
2225
+ // The drawId returned by addDraw is a fresh slot; we don't
2226
+ // attempt to preserve the caller's id mapping in v1. v2
2227
+ // could thread the original id through if needed.
2228
+ void newId;
2229
+ }
2230
+ }
2231
+
3033
2232
  // 1. Pool: re-pack any aval whose value changed since last frame.
3034
2233
  // One writeBuffer per dirty aval, regardless of how many draws
3035
2234
  // reference it — sharing pays off here.
@@ -3125,16 +2324,16 @@ export function buildHeapScene(
3125
2324
  bucket.headerDirtyMin = Infinity;
3126
2325
  bucket.headerDirtyMax = 0;
3127
2326
  }
3128
- if (bucket.drawTableDirtyMax > bucket.drawTableDirtyMin) {
3129
- const shadow = bucket.drawTableShadow!;
2327
+ if (bucket.slots[0]!.drawTableDirtyMax > bucket.slots[0]!.drawTableDirtyMin) {
2328
+ const shadow = bucket.slots[0]!.drawTableShadow!;
3130
2329
  device.queue.writeBuffer(
3131
- bucket.drawTableBuf!.buffer, bucket.drawTableDirtyMin,
2330
+ bucket.slots[0]!.drawTableBuf!.buffer, bucket.slots[0]!.drawTableDirtyMin,
3132
2331
  shadow.buffer,
3133
- shadow.byteOffset + bucket.drawTableDirtyMin,
3134
- bucket.drawTableDirtyMax - bucket.drawTableDirtyMin,
2332
+ shadow.byteOffset + bucket.slots[0]!.drawTableDirtyMin,
2333
+ bucket.slots[0]!.drawTableDirtyMax - bucket.slots[0]!.drawTableDirtyMin,
3135
2334
  );
3136
- bucket.drawTableDirtyMin = Infinity;
3137
- bucket.drawTableDirtyMax = 0;
2335
+ bucket.slots[0]!.drawTableDirtyMin = Infinity;
2336
+ bucket.slots[0]!.drawTableDirtyMax = 0;
3138
2337
  }
3139
2338
  }
3140
2339
  }
@@ -3148,8 +2347,8 @@ export function buildHeapScene(
3148
2347
  let curBg: GPUBindGroup | null = null;
3149
2348
  for (const b of buckets) {
3150
2349
  if (b.bindGroup !== curBg) { pass.setBindGroup(0, b.bindGroup); curBg = b.bindGroup; }
3151
- pass.setPipeline(b.pipeline);
3152
- if (b.recordCount > 0) pass.drawIndirect(b.indirectBuf!, 0);
2350
+ pass.setPipeline(b.slots[0]!.pipeline);
2351
+ if (b.slots[0]!.recordCount > 0) pass.drawIndirect(b.slots[0]!.indirectBuf!, 0);
3153
2352
  }
3154
2353
  }
3155
2354
 
@@ -3190,23 +2389,23 @@ export function buildHeapScene(
3190
2389
  stats.derivedRecords = derivedScene.records.recordCount;
3191
2390
  }
3192
2391
  let anyDirty = false;
3193
- for (const b of buckets) { if (b.scanDirty) { anyDirty = true; break; } }
2392
+ for (const b of buckets) { if (b.slots[0]!.scanDirty) { anyDirty = true; break; } }
3194
2393
  if (!anyDirty) return;
3195
2394
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
3196
2395
  for (const b of buckets) {
3197
- if (!b.scanDirty) continue;
2396
+ if (!b.slots[0]!.scanDirty) continue;
3198
2397
  // Rebuild render bind group if recordCount changed — its
3199
2398
  // drawTable binding is sized to recordCount * 16.
3200
- if (b.renderBoundRecordCount !== b.recordCount) {
2399
+ if (b.slots[0]!.renderBoundRecordCount !== b.slots[0]!.recordCount) {
3201
2400
  b.bindGroup = buildBucketBindGroup(b);
3202
2401
  }
3203
- const numRecords = b.recordCount;
2402
+ const numRecords = b.slots[0]!.recordCount;
3204
2403
  const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
3205
2404
  device.queue.writeBuffer(
3206
- b.paramsBuf!, 0,
2405
+ b.slots[0]!.paramsBuf!, 0,
3207
2406
  new Uint32Array([numRecords, numBlocks, 0, 0]),
3208
2407
  );
3209
- pass.setBindGroup(0, b.scanBindGroup!);
2408
+ pass.setBindGroup(0, b.slots[0]!.scanBindGroup!);
3210
2409
  pass.setPipeline(scanPipeTile!);
3211
2410
  pass.dispatchWorkgroups(numBlocks, 1, 1);
3212
2411
  pass.setPipeline(scanPipeBlocks!);
@@ -3217,11 +2416,11 @@ export function buildHeapScene(
3217
2416
  // must cover the worst-case totalEmit. Each WG handles WG_SIZE
3218
2417
  // tiles; +1 for the sentinel slot.
3219
2418
  const SCAN_WG_SIZE = 256;
3220
- const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
2419
+ const numTilesCap = Math.max(1, Math.ceil(b.slots[0]!.totalEmitEstimate / TILE_K));
3221
2420
  const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
3222
2421
  pass.setPipeline(scanPipeBuildTileIndex!);
3223
2422
  pass.dispatchWorkgroups(tileWgs, 1, 1);
3224
- b.scanDirty = false;
2423
+ b.slots[0]!.scanDirty = false;
3225
2424
  }
3226
2425
  pass.end();
3227
2426
  }
@@ -3259,18 +2458,38 @@ export function buildHeapScene(
3259
2458
  device.queue.submit([enc.finish()]);
3260
2459
  }
3261
2460
 
2461
+ function destroyBucketResources(b: Bucket): void {
2462
+ b.drawHeap.destroy();
2463
+ for (const slot of b.slots) {
2464
+ slot.drawTableBuf?.destroy();
2465
+ slot.blockSumsBuf?.destroy();
2466
+ slot.blockOffsetsBuf?.destroy();
2467
+ slot.firstDrawInTileBuf?.destroy();
2468
+ slot.indirectBuf?.destroy();
2469
+ slot.paramsBuf?.destroy();
2470
+ }
2471
+ }
2472
+
2473
+ /**
2474
+ * Remove an empty bucket from the scene's bucket list + key index +
2475
+ * destroy its GPU resources. Called from removeDraw when an RO's
2476
+ * removal leaves the bucket with zero live slots — common after
2477
+ * reactive PS rebucket leaves the source bucket empty.
2478
+ */
2479
+ function gcBucket(b: Bucket): void {
2480
+ if (b.drawSlots.size !== 0) return;
2481
+ const idx = buckets.indexOf(b);
2482
+ if (idx >= 0) buckets.splice(idx, 1);
2483
+ for (const [k, v] of bucketByKey) {
2484
+ if (v === b) { bucketByKey.delete(k); break; }
2485
+ }
2486
+ destroyBucketResources(b);
2487
+ }
2488
+
3262
2489
  function dispose(): void {
3263
2490
  arena.attrs.destroy();
3264
2491
  arena.indices.destroy();
3265
- for (const b of buckets) {
3266
- b.drawHeap.destroy();
3267
- b.drawTableBuf?.destroy();
3268
- b.blockSumsBuf?.destroy();
3269
- b.blockOffsetsBuf?.destroy();
3270
- b.firstDrawInTileBuf?.destroy();
3271
- b.indirectBuf?.destroy();
3272
- b.paramsBuf?.destroy();
3273
- }
2492
+ for (const b of buckets) destroyBucketResources(b);
3274
2493
  }
3275
2494
 
3276
2495
  // Test-only escape hatch for inspecting megacall bucket state. Not
@@ -3285,11 +2504,11 @@ export function buildHeapScene(
3285
2504
  layout: BucketLayout;
3286
2505
  }[] {
3287
2506
  return buckets.map(b => ({
3288
- indirectBuf: b.indirectBuf,
3289
- drawTableBuf: b.drawTableBuf?.buffer,
3290
- firstDrawInTileBuf: b.firstDrawInTileBuf?.buffer,
3291
- totalEmitEstimate: b.totalEmitEstimate,
3292
- recordCount: b.recordCount,
2507
+ indirectBuf: b.slots[0]!.indirectBuf,
2508
+ drawTableBuf: b.slots[0]!.drawTableBuf?.buffer,
2509
+ firstDrawInTileBuf: b.slots[0]!.firstDrawInTileBuf?.buffer,
2510
+ totalEmitEstimate: b.slots[0]!.totalEmitEstimate,
2511
+ recordCount: b.slots[0]!.recordCount,
3293
2512
  layout: b.layout,
3294
2513
  }));
3295
2514
  },
@@ -3358,18 +2577,18 @@ export function buildHeapScene(
3358
2577
  const TILE_K_LOCAL = 64;
3359
2578
  const dcs: DC[] = [];
3360
2579
  for (const b of buckets) {
3361
- const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
2580
+ const dhSize = Math.min(b.drawHeap.buffer.size, b.slots[0]!.recordCount * b.layout.drawHeaderBytes);
3362
2581
  if (dhSize === 0) continue;
3363
2582
  const dh = stage(b.drawHeap.buffer, dhSize);
3364
- const dt = b.drawTableBuf !== undefined && b.recordCount > 0
3365
- ? stage(b.drawTableBuf.buffer, b.recordCount * RECORD_BYTES)
2583
+ const dt = b.slots[0]!.drawTableBuf !== undefined && b.slots[0]!.recordCount > 0
2584
+ ? stage(b.slots[0]!.drawTableBuf.buffer, b.slots[0]!.recordCount * RECORD_BYTES)
3366
2585
  : undefined;
3367
- const totalEmit = b.totalEmitEstimate;
2586
+ const totalEmit = b.slots[0]!.totalEmitEstimate;
3368
2587
  const numTiles = totalEmit > 0 ? Math.ceil(totalEmit / TILE_K_LOCAL) : 0;
3369
- const fdt = b.firstDrawInTileBuf !== undefined && numTiles > 0
3370
- ? stage(b.firstDrawInTileBuf.buffer, Math.min(b.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
2588
+ const fdt = b.slots[0]!.firstDrawInTileBuf !== undefined && numTiles > 0
2589
+ ? stage(b.slots[0]!.firstDrawInTileBuf.buffer, Math.min(b.slots[0]!.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
3371
2590
  : undefined;
3372
- const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
2591
+ const ind = b.slots[0]!.indirectBuf !== undefined ? stage(b.slots[0]!.indirectBuf, 16) : undefined;
3373
2592
  dcs.push({
3374
2593
  bucket: b, drawHeap: dh, numTiles,
3375
2594
  ...(dt !== undefined ? { drawTable: dt } : {}),
@@ -3401,7 +2620,7 @@ export function buildHeapScene(
3401
2620
  for (const dc of dcs) {
3402
2621
  const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
3403
2622
  const stride = dc.bucket.layout.drawHeaderBytes;
3404
- const recordCount = dc.bucket.recordCount;
2623
+ const recordCount = dc.bucket.slots[0]!.recordCount;
3405
2624
 
3406
2625
  for (let slot = 0; slot < recordCount; slot++) {
3407
2626
  const slotOff = slot * stride;
@@ -3557,7 +2776,7 @@ export function buildHeapScene(
3557
2776
  // ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
3558
2777
  if (dc.indirect !== undefined) {
3559
2778
  const ind = new Uint32Array(dc.indirect.getMappedRange());
3560
- const expectedTotal = dc.bucket.totalEmitEstimate;
2779
+ const expectedTotal = dc.bucket.slots[0]!.totalEmitEstimate;
3561
2780
  const got = ind[0]!;
3562
2781
  if (got !== expectedTotal) {
3563
2782
  push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
@@ -3658,10 +2877,10 @@ export function buildHeapScene(
3658
2877
  };
3659
2878
  const dcs: DC[] = [];
3660
2879
  for (const b of buckets) {
3661
- if (b.recordCount === 0 || b.totalEmitEstimate === 0) continue;
3662
- const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes));
3663
- const dt = stage(b.drawTableBuf!.buffer, b.recordCount * RECORD_BYTES);
3664
- dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.totalEmitEstimate });
2880
+ if (b.slots[0]!.recordCount === 0 || b.slots[0]!.totalEmitEstimate === 0) continue;
2881
+ const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.slots[0]!.recordCount * b.layout.drawHeaderBytes));
2882
+ const dt = stage(b.slots[0]!.drawTableBuf!.buffer, b.slots[0]!.recordCount * RECORD_BYTES);
2883
+ dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.slots[0]!.totalEmitEstimate });
3665
2884
  }
3666
2885
  device.queue.submit([enc.finish()]);
3667
2886
 
@@ -3687,7 +2906,7 @@ export function buildHeapScene(
3687
2906
  const dt = new Uint32Array(dc.drawTable.getMappedRange());
3688
2907
  bucketDt.push(dt);
3689
2908
  bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
3690
- for (let r = 0; r < dc.bucket.recordCount; r++) {
2909
+ for (let r = 0; r < dc.bucket.slots[0]!.recordCount; r++) {
3691
2910
  dc.firstEmit.push(dt[r * RECORD_U32 + 0]!);
3692
2911
  }
3693
2912
  bucketCumEmit.push(totalEmitGlobal);
@@ -3745,7 +2964,7 @@ export function buildHeapScene(
3745
2964
  emitsChecked++;
3746
2965
 
3747
2966
  // Binary search for slot.
3748
- const recCount = dc.bucket.recordCount;
2967
+ const recCount = dc.bucket.slots[0]!.recordCount;
3749
2968
  let lo = 0, hi = recCount - 1;
3750
2969
  while (lo < hi) {
3751
2970
  const mid = (lo + hi + 1) >>> 1;
@@ -3942,9 +3161,9 @@ export function buildHeapScene(
3942
3161
  : new Uint32Array(0);
3943
3162
 
3944
3163
  for (const target of buckets) {
3945
- if (target.recordCount === 0 || target.totalEmitEstimate === 0) { bucketIdx++; continue; }
3946
- const recordCount = target.recordCount;
3947
- const totalEmit = target.totalEmitEstimate;
3164
+ if (target.slots[0]!.recordCount === 0 || target.slots[0]!.totalEmitEstimate === 0) { bucketIdx++; continue; }
3165
+ const recordCount = target.slots[0]!.recordCount;
3166
+ const totalEmit = target.slots[0]!.totalEmitEstimate;
3948
3167
  const totalTris = Math.floor(totalEmit / 3);
3949
3168
  const remainder = totalEmit % 3;
3950
3169
  if (remainder !== 0) {
@@ -3962,7 +3181,7 @@ export function buildHeapScene(
3962
3181
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3963
3182
  });
3964
3183
  const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
3965
- enc0.copyBufferToBuffer(target.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3184
+ enc0.copyBufferToBuffer(target.slots[0]!.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3966
3185
  enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
3967
3186
  device.queue.submit([enc0.finish()]);
3968
3187
  await dtCopy.mapAsync(GPUMapMode.READ);
@@ -4011,7 +3230,7 @@ export function buildHeapScene(
4011
3230
  });
4012
3231
  {
4013
3232
  const enc1 = device.createCommandEncoder();
4014
- enc1.copyBufferToBuffer(target.firstDrawInTileBuf!.buffer, 0, fdtCopy, 0, fdtSize);
3233
+ enc1.copyBufferToBuffer(target.slots[0]!.firstDrawInTileBuf!.buffer, 0, fdtCopy, 0, fdtSize);
4015
3234
  device.queue.submit([enc1.finish()]);
4016
3235
  await fdtCopy.mapAsync(GPUMapMode.READ);
4017
3236
  }
@@ -4166,9 +3385,9 @@ export function buildHeapScene(
4166
3385
  // own bytes).
4167
3386
  bucketIdx = 0;
4168
3387
  for (const target of buckets) {
4169
- if (target.recordCount === 0) { bucketIdx++; continue; }
3388
+ if (target.slots[0]!.recordCount === 0) { bucketIdx++; continue; }
4170
3389
  // Re-stage drawHeap for this bucket.
4171
- const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
3390
+ const dhBytes = target.slots[0]!.recordCount * target.layout.drawHeaderBytes;
4172
3391
  const dhCopy = device.createBuffer({
4173
3392
  size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
4174
3393
  });
@@ -4178,7 +3397,7 @@ export function buildHeapScene(
4178
3397
  await dhCopy.mapAsync(GPUMapMode.READ);
4179
3398
  const dhU32 = new Uint32Array(dhCopy.getMappedRange());
4180
3399
  const stride2 = target.layout.drawHeaderBytes;
4181
- for (let slot = 0; slot < target.recordCount; slot++) {
3400
+ for (let slot = 0; slot < target.slots[0]!.recordCount; slot++) {
4182
3401
  for (const f of target.layout.drawHeaderFields) {
4183
3402
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
4184
3403
  const ref = dhU32[(slot * stride2 + f.byteOffset) >>> 2];
@@ -4238,11 +3457,11 @@ export function buildHeapScene(
4238
3457
  let gpuMismatches = 0;
4239
3458
  const push = (s: string) => { if (issues.length < 30) issues.push(s); };
4240
3459
 
4241
- const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
3460
+ const target = buckets.find(b => b.slots[0]!.recordCount > 0 && b.slots[0]!.totalEmitEstimate > 0);
4242
3461
  if (target === undefined) return { emitsChecked: 0, gpuMismatches: 0, issues };
4243
3462
 
4244
- const recordCount = target.recordCount;
4245
- const totalEmit = target.totalEmitEstimate;
3463
+ const recordCount = target.slots[0]!.recordCount;
3464
+ const totalEmit = target.slots[0]!.totalEmitEstimate;
4246
3465
  const sampleCount = Math.min(samples, totalEmit);
4247
3466
  const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
4248
3467
 
@@ -4256,7 +3475,7 @@ export function buildHeapScene(
4256
3475
  });
4257
3476
  {
4258
3477
  const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
4259
- enc0.copyBufferToBuffer(target.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3478
+ enc0.copyBufferToBuffer(target.slots[0]!.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
4260
3479
  device.queue.submit([enc0.finish()]);
4261
3480
  await dtCopy.mapAsync(GPUMapMode.READ);
4262
3481
  }
@@ -4360,8 +3579,8 @@ export function buildHeapScene(
4360
3579
  const bg = device.createBindGroup({
4361
3580
  layout: bgl,
4362
3581
  entries: [
4363
- { binding: 0, resource: { buffer: target.drawTableBuf!.buffer } },
4364
- { binding: 1, resource: { buffer: target.firstDrawInTileBuf!.buffer } },
3582
+ { binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
3583
+ { binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
4365
3584
  { binding: 2, resource: { buffer: sampleBuf } },
4366
3585
  { binding: 3, resource: { buffer: arena.indices.buffer } },
4367
3586
  { binding: 4, resource: { buffer: outBuf } },