@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
@@ -75,161 +75,29 @@ 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
+ import { addMarkingCallback } from "@aardworx/wombat.adaptive";
98
+
99
+ // GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
100
+ // Packers (WGSL-type JS-value arena bytes) live in ./heapScene/packers.
233
101
 
234
102
  // ---------------------------------------------------------------------------
235
103
  // Shared WGSL prelude (struct + bindings + VS)
@@ -268,765 +136,65 @@ export interface HeapGeometry {
268
136
  }
269
137
 
270
138
 
271
- // ---------------------------------------------------------------------------
272
- // Resizable buffer (pow2 grow + GPU-side copy on resize)
273
- // ---------------------------------------------------------------------------
274
-
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
139
 
332
140
  // ---------------------------------------------------------------------------
333
- // UniformPool — aval-keyed refcounted allocations over the AttributeArena
334
- // ---------------------------------------------------------------------------
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
141
+ // Internal state
540
142
  // ---------------------------------------------------------------------------
541
143
 
542
144
  /**
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.
145
+ * One pipeline-state bucket. Holds the pipeline + a list of global
146
+ * draw-slot indices to emit with it. The bind group it draws against
147
+ * is referenced by `bindGroup` (typically the shared no-textures one
148
+ * or one of the per-texture-set ones).
704
149
  */
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)
766
- // ---------------------------------------------------------------------------
767
-
768
150
  /**
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.
151
+ * Per-slot state inside a bucket. Today every bucket has exactly ONE
152
+ * slot `bucket.slots.length === 1`. Multi-pipeline buckets (the
153
+ * derived-modes work, see docs/derived-modes.md) grow this to one
154
+ * slot per distinct pipeline state used by the bucket's ROs. Each
155
+ * slot owns its own drawTable + scan buffers + indirect + pipeline.
774
156
  */
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
- /**
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.
834
- */
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
- }
157
+ interface BucketSlot {
158
+ /** The render pipeline this slot draws with. */
159
+ readonly pipeline: GPURenderPipeline;
964
160
 
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
- }
161
+ // ─── drawTable / megacall state ────────────────────────────────────
162
+ drawTableBuf?: GrowBuffer;
163
+ drawTableShadow?: Uint32Array;
164
+ drawTableDirtyMin: number;
165
+ drawTableDirtyMax: number;
166
+ /** Number of live records routed to this slot (= drawTable length).
167
+ * GPU owns firstEmit / total. */
168
+ recordCount: number;
169
+ /** localSlot recordIdx (or -1). */
170
+ slotToRecord: number[];
171
+ /** recordIdx → localSlot. */
172
+ recordToSlot: number[];
976
173
 
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;
174
+ // ─── Per-slot scan / indirect buffers ──────────────────────────────
175
+ blockSumsBuf?: GrowBuffer;
176
+ blockOffsetsBuf?: GrowBuffer;
177
+ firstDrawInTileBuf?: GrowBuffer;
178
+ /** CPU sum of indexCounts across live records — drives firstDrawInTileBuf sizing only. */
179
+ totalEmitEstimate: number;
180
+ indirectBuf?: GPUBuffer;
181
+ paramsBuf?: GPUBuffer;
182
+ scanBindGroup?: GPUBindGroup;
183
+ /** numRecords used to size the current render bindGroup; rebuild when it changes. */
184
+ renderBoundRecordCount?: number;
185
+ scanDirty: boolean;
1012
186
  }
1013
- `;
1014
187
 
1015
- // ---------------------------------------------------------------------------
1016
- // Internal state
1017
- // ---------------------------------------------------------------------------
1018
-
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
188
  interface Bucket {
1026
189
  readonly label: string;
1027
190
  readonly textures: HeapTextureSet | undefined;
1028
191
  readonly layout: BucketLayout;
1029
- readonly pipeline: GPURenderPipeline;
192
+ /**
193
+ * One entry per distinct pipeline state. Phase 5a: always length 1
194
+ * (per-RO pipeline-state still keyed at the bucket level).
195
+ * Later phases of the derived-modes work let `slots.length > 1`.
196
+ */
197
+ readonly slots: BucketSlot[];
1030
198
  /** Repointed by `rebuildBindGroups` whenever any backing GrowBuffer reallocates. */
1031
199
  bindGroup: GPUBindGroup;
1032
200
 
@@ -1082,30 +250,6 @@ interface Bucket {
1082
250
  /** Local slots whose DrawHeader needs re-pack + writeBuffer next frame. */
1083
251
  readonly dirty: Set<number>;
1084
252
 
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
253
  // ─── Atlas-binding state (atlas-variant buckets only) ─────────────
1110
254
  /**
1111
255
  * True when this bucket holds at least one atlas-variant RO.
@@ -1428,6 +572,52 @@ export function buildHeapScene(
1428
572
  const drawIdToLocalSlot: (number | undefined)[] = [];
1429
573
  /** Per-draw index aval — for `indexPool.release` on removeDraw. */
1430
574
  const drawIdToIndexAval: (aval<Uint32Array> | undefined)[] = [];
575
+ /**
576
+ * Original spec retained per drawId so we can re-add the RO into a
577
+ * new bucket when its PipelineState modeKey changes (reactive
578
+ * cullmode flip, etc.). Cheap to retain — specs are mostly aval
579
+ * references.
580
+ */
581
+ const drawIdToSpec: (HeapDrawSpec | undefined)[] = [];
582
+ /** Per-draw ModeKeyTracker — recomputes the modeKey on demand. Does
583
+ * NOT install per-instance marking callbacks; subscriptions are
584
+ * scene-level (deduped by aval identity below). */
585
+ const drawIdToModeTracker: (ModeKeyTracker | undefined)[] = [];
586
+ /** drawIds whose PS marked since last update; drained by `update`. */
587
+ const dirtyModeKeyDrawIds = new Set<number>();
588
+ /**
589
+ * Scene-level mode-aval subscription dedupe. One IDisposable per
590
+ * unique leaf aval, plus a Set of dependent drawIds. When the aval
591
+ * marks, all drawIds in the set become dirty in O(1) per RO instead
592
+ * of installing N separate addMarkingCallbacks. The big win for the
593
+ * shared-cullCval case (20k ROs subscribing to one cval → 1 callback
594
+ * instead of 20k).
595
+ */
596
+ const modeAvalToDrawIds = new Map<aval<unknown>, Set<number>>();
597
+ const modeAvalCallbacks = new Map<aval<unknown>, IDisposable>();
598
+
599
+ function subscribeModeLeaf(av: aval<unknown>, drawId: number): void {
600
+ let set = modeAvalToDrawIds.get(av);
601
+ if (set === undefined) {
602
+ set = new Set<number>();
603
+ modeAvalToDrawIds.set(av, set);
604
+ modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
605
+ for (const id of set!) dirtyModeKeyDrawIds.add(id);
606
+ }));
607
+ }
608
+ set.add(drawId);
609
+ }
610
+
611
+ function unsubscribeModeLeaf(av: aval<unknown>, drawId: number): void {
612
+ const set = modeAvalToDrawIds.get(av);
613
+ if (set === undefined) return;
614
+ set.delete(drawId);
615
+ if (set.size === 0) {
616
+ modeAvalToDrawIds.delete(av);
617
+ const cb = modeAvalCallbacks.get(av);
618
+ if (cb !== undefined) { cb.dispose(); modeAvalCallbacks.delete(av); }
619
+ }
620
+ }
1431
621
  let nextDrawId = 0;
1432
622
 
1433
623
  /**
@@ -1853,12 +1043,12 @@ export function buildHeapScene(
1853
1043
  label: `heapScene/${bucket.label}/scanBg`,
1854
1044
  layout: scanBgl,
1855
1045
  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 } },
1046
+ { binding: 0, resource: { buffer: bucket.slots[0]!.drawTableBuf!.buffer } },
1047
+ { binding: 1, resource: { buffer: bucket.slots[0]!.blockSumsBuf!.buffer } },
1048
+ { binding: 2, resource: { buffer: bucket.slots[0]!.blockOffsetsBuf!.buffer } },
1049
+ { binding: 3, resource: { buffer: bucket.slots[0]!.indirectBuf! } },
1050
+ { binding: 4, resource: { buffer: bucket.slots[0]!.paramsBuf! } },
1051
+ { binding: 5, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
1862
1052
  ],
1863
1053
  });
1864
1054
  }
@@ -2085,29 +1275,10 @@ export function buildHeapScene(
2085
1275
  avalIds.set(av, vid);
2086
1276
  return `c${vid}`;
2087
1277
  };
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
- };
1278
+ // (psIdOf removed in Phase 5b — replaced by snapshotDescriptor +
1279
+ // encodeModeKey at the findOrCreateBucket call site, which keys on
1280
+ // the VALUE of the pipeline state rather than aval identity. The
1281
+ // 20k-cvals-with-same-value pathology now collapses correctly.)
2111
1282
 
2112
1283
  /** Resolved (forced) snapshot of the user's PipelineState. */
2113
1284
  interface ResolvedPipelineState {
@@ -2155,7 +1326,7 @@ export function buildHeapScene(
2155
1326
  { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
2156
1327
  ];
2157
1328
  {
2158
- if (bucket.drawTableBuf === undefined) {
1329
+ if (bucket.slots[0]!.drawTableBuf === undefined) {
2159
1330
  throw new Error("heapScene: megacall bucket without drawTableBuf");
2160
1331
  }
2161
1332
  // Bind drawTable with size = recordCount * RECORD_BYTES so the VS
@@ -2163,13 +1334,13 @@ export function buildHeapScene(
2163
1334
  // the live record count — keeps stale tail entries out of the
2164
1335
  // binary search. Minimum one zero-record to satisfy WebGPU non-
2165
1336
  // zero size constraint when the bucket is empty.
2166
- const dtBytes = Math.max(RECORD_BYTES, bucket.recordCount * RECORD_BYTES);
1337
+ const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0]!.recordCount * RECORD_BYTES);
2167
1338
  entries.push(
2168
- { binding: 4, resource: { buffer: bucket.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1339
+ { binding: 4, resource: { buffer: bucket.slots[0]!.drawTableBuf.buffer, offset: 0, size: dtBytes } },
2169
1340
  { binding: 5, resource: { buffer: arena.indices.buffer } },
2170
- { binding: 6, resource: { buffer: bucket.firstDrawInTileBuf!.buffer } },
1341
+ { binding: 6, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
2171
1342
  );
2172
- bucket.renderBoundRecordCount = bucket.recordCount;
1343
+ bucket.slots[0]!.renderBoundRecordCount = bucket.slots[0]!.recordCount;
2173
1344
  }
2174
1345
  // Schema-driven texture + sampler entries. v1 user surface still
2175
1346
  // accepts a single (texture, sampler) pair via HeapTextureSet —
@@ -2307,8 +1478,17 @@ export function buildHeapScene(
2307
1478
  throw new Error("heapScene: findOrCreateBucket called before family build");
2308
1479
  }
2309
1480
  const fam = familyFor(effect);
2310
- const psKey = psIdOf(pipelineState);
2311
- const bk = `family#${fam.schema.id}|${psKey}`;
1481
+ // ─── PS keying — VALUE based, not identity based ───────────────
1482
+ // psIdOf used to hash by aval identity. That meant 20k ROs each
1483
+ // constructed with `cval("back")` for cullMode hashed to 20k
1484
+ // distinct keys → 20k buckets, all rendering with bitwise-
1485
+ // identical state. Now the key is the bitfield-encoded modeKey
1486
+ // (the actual value-set in the descriptor), so identical-value
1487
+ // cvals collapse to ONE bucket. See docs/derived-modes.md and
1488
+ // tests/heap-multi-pipeline-bucket.test.ts.
1489
+ const psDescriptor = snapshotDescriptor(pipelineState, sig);
1490
+ const psModeKey = encodeModeKey(psDescriptor);
1491
+ const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
2312
1492
  const existing = bucketByKey.get(bk);
2313
1493
  if (existing !== undefined) return existing;
2314
1494
  const ps = resolvePipelineState(pipelineState);
@@ -2343,13 +1523,21 @@ export function buildHeapScene(
2343
1523
  );
2344
1524
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
2345
1525
 
1526
+ const slot0: BucketSlot = {
1527
+ pipeline,
1528
+ drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1529
+ recordCount: 0, slotToRecord: [], recordToSlot: [],
1530
+ totalEmitEstimate: 0,
1531
+ scanDirty: false,
1532
+ };
2346
1533
  const bucket: Bucket = {
2347
1534
  // §6 family-merge: family buckets aren't keyed on a specific
2348
1535
  // texture set — atlas placements are addressed per-RO via
2349
1536
  // drawHeader fields (`pageRef` + `formatBits` + `origin` +
2350
1537
  // `size`), and the bucket's atlas-binding ladder is driven by
2351
1538
  // `atlasPool.pagesFor(format)`. Leave `textures` undefined.
2352
- label: bk, textures: undefined, layout, pipeline,
1539
+ label: bk, textures: undefined, layout,
1540
+ slots: [slot0],
2353
1541
  bindGroup: null as unknown as GPUBindGroup,
2354
1542
  drawHeap,
2355
1543
  drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
@@ -2358,10 +1546,6 @@ export function buildHeapScene(
2358
1546
  localEntries: [], localToDrawId: [],
2359
1547
  localPerDrawAvals: [], localPerDrawRefs: [], localLayoutIds: [],
2360
1548
  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
1549
  isAtlasBucket,
2366
1550
  localAtlasReleases: [],
2367
1551
  localAtlasTextures: [],
@@ -2401,30 +1585,30 @@ export function buildHeapScene(
2401
1585
  label: `heapScene/${bk}/params`, size: 16,
2402
1586
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2403
1587
  });
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;
1588
+ bucket.slots[0]!.drawTableBuf = dtBuf;
1589
+ bucket.slots[0]!.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
1590
+ bucket.slots[0]!.blockSumsBuf = blockSumsBuf;
1591
+ bucket.slots[0]!.blockOffsetsBuf = blockOffsetsBuf;
1592
+ bucket.slots[0]!.firstDrawInTileBuf = firstDrawInTileBuf;
1593
+ bucket.slots[0]!.indirectBuf = indirectBuf;
1594
+ bucket.slots[0]!.paramsBuf = paramsBuf;
2411
1595
  const ensureScanBuffers = (): void => {
2412
- const needBlocks = Math.max(1, Math.ceil(bucket.recordCount / SCAN_TILE_SIZE));
1596
+ const needBlocks = Math.max(1, Math.ceil(bucket.slots[0]!.recordCount / SCAN_TILE_SIZE));
2413
1597
  blockSumsBuf.ensureCapacity(needBlocks * 4);
2414
1598
  blockOffsetsBuf.ensureCapacity(needBlocks * 4);
2415
1599
  };
2416
1600
  const rebuildScanBg = (): void => {
2417
- bucket.scanBindGroup = buildScanBindGroup(bucket);
1601
+ bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
2418
1602
  };
2419
1603
  dtBuf.onResize(() => {
2420
1604
  const grown = new Uint32Array(dtBuf.capacity / 4);
2421
- grown.set(bucket.drawTableShadow!);
2422
- bucket.drawTableShadow = grown;
1605
+ grown.set(bucket.slots[0]!.drawTableShadow!);
1606
+ bucket.slots[0]!.drawTableShadow = grown;
2423
1607
  ensureScanBuffers();
2424
1608
  bucket.bindGroup = buildBucketBindGroup(bucket);
2425
1609
  rebuildScanBg();
2426
- bucket.drawTableDirtyMin = 0;
2427
- bucket.drawTableDirtyMax = bucket.recordCount * RECORD_BYTES;
1610
+ bucket.slots[0]!.drawTableDirtyMin = 0;
1611
+ bucket.slots[0]!.drawTableDirtyMax = bucket.slots[0]!.recordCount * RECORD_BYTES;
2428
1612
  });
2429
1613
  blockSumsBuf.onResize(rebuildScanBg);
2430
1614
  blockOffsetsBuf.onResize(rebuildScanBg);
@@ -2432,7 +1616,7 @@ export function buildHeapScene(
2432
1616
  rebuildScanBg();
2433
1617
  bucket.bindGroup = buildBucketBindGroup(bucket);
2434
1618
  });
2435
- bucket.scanBindGroup = buildScanBindGroup(bucket);
1619
+ bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
2436
1620
  }
2437
1621
  bucket.bindGroup = buildBucketBindGroup(bucket);
2438
1622
  drawHeap.onResize(() => {
@@ -2754,8 +1938,8 @@ export function buildHeapScene(
2754
1938
  if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
2755
1939
 
2756
1940
  {
2757
- const dtBuf = bucket.drawTableBuf!;
2758
- const recIdx = bucket.recordCount;
1941
+ const dtBuf = bucket.slots[0]!.drawTableBuf!;
1942
+ const recIdx = bucket.slots[0]!.recordCount;
2759
1943
  if (recIdx >= SCAN_MAX_RECORDS) {
2760
1944
  throw new Error(
2761
1945
  `heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
@@ -2767,30 +1951,48 @@ export function buildHeapScene(
2767
1951
  dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
2768
1952
  // Grow scan-side buffers if recordCount crosses a tile boundary.
2769
1953
  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!;
1954
+ bucket.slots[0]!.blockSumsBuf!.ensureCapacity(needBlocks * 4);
1955
+ bucket.slots[0]!.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
1956
+ const shadow = bucket.slots[0]!.drawTableShadow!;
2773
1957
  // firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
2774
1958
  shadow[recIdx * RECORD_U32 + 0] = 0;
2775
1959
  shadow[recIdx * RECORD_U32 + 1] = localSlot;
2776
1960
  shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
2777
1961
  shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
2778
1962
  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;
1963
+ bucket.slots[0]!.recordCount = recIdx + 1;
1964
+ bucket.slots[0]!.slotToRecord[localSlot] = recIdx;
1965
+ bucket.slots[0]!.recordToSlot[recIdx] = localSlot;
1966
+ if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
1967
+ if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
1968
+ bucket.slots[0]!.totalEmitEstimate += idxAlloc.count * instanceCount;
1969
+ const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0]!.totalEmitEstimate / TILE_K));
1970
+ bucket.slots[0]!.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
1971
+ bucket.slots[0]!.scanDirty = true;
2788
1972
  }
2789
1973
 
2790
1974
  drawIdToBucket[drawId] = bucket;
2791
1975
  drawIdToLocalSlot[drawId] = localSlot;
2792
1976
  drawIdToIndexAval[drawId] = indicesAval;
2793
1977
 
1978
+ // ─── Reactive rebucket: track PS-modeKey changes ────────────────
1979
+ // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
1980
+ // schedule this RO for rebucket on the next update(). Today's
1981
+ // bucket key is the modeKey VALUE (Phase 5b), so changing the
1982
+ // value must move the RO to a different bucket. Without this
1983
+ // tracker the cval flip would be silently ignored.
1984
+ drawIdToSpec[drawId] = spec;
1985
+ // skipSubscribe: this tracker doesn't install its own callbacks.
1986
+ // The scene-level `modeAvalToDrawIds` does it once per unique aval
1987
+ // (the 20k-ROs-share-one-cullCval optimization).
1988
+ const tracker = new ModeKeyTracker(
1989
+ spec.pipelineState, sig,
1990
+ () => { dirtyModeKeyDrawIds.add(drawId); },
1991
+ { skipSubscribe: true },
1992
+ );
1993
+ drawIdToModeTracker[drawId] = tracker;
1994
+ tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
1995
+
2794
1996
  // ─── §7 derived-uniforms registration ────────────────────────────
2795
1997
  // A uniform binding on this RO is either a value (aval/constant) or a rule —
2796
1998
  // collect the rules (explicit `derivedUniform(...)` values + standard trafo
@@ -2865,13 +2067,13 @@ export function buildHeapScene(
2865
2067
  const removedCount = removedEntry !== undefined
2866
2068
  ? removedEntry.indexCount * removedEntry.instanceCount
2867
2069
  : 0;
2868
- bucket.totalEmitEstimate = Math.max(0, bucket.totalEmitEstimate - removedCount);
2070
+ bucket.slots[0]!.totalEmitEstimate = Math.max(0, bucket.slots[0]!.totalEmitEstimate - removedCount);
2869
2071
  // Swap-pop: move the last record into the freed slot, decrement
2870
2072
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
2871
2073
  // we only fix (drawIdx, indexStart, indexCount, instanceCount).
2872
- const recIdx = bucket.slotToRecord[localSlot]!;
2873
- const lastRecIdx = bucket.recordCount - 1;
2874
- const shadow = bucket.drawTableShadow!;
2074
+ const recIdx = bucket.slots[0]!.slotToRecord[localSlot]!;
2075
+ const lastRecIdx = bucket.slots[0]!.recordCount - 1;
2076
+ const shadow = bucket.slots[0]!.drawTableShadow!;
2875
2077
  if (recIdx !== lastRecIdx) {
2876
2078
  const dst = recIdx * RECORD_U32;
2877
2079
  const src = lastRecIdx * RECORD_U32;
@@ -2880,17 +2082,17 @@ export function buildHeapScene(
2880
2082
  shadow[dst + 2] = shadow[src + 2]!;
2881
2083
  shadow[dst + 3] = shadow[src + 3]!;
2882
2084
  shadow[dst + 4] = shadow[src + 4]!;
2883
- const movedSlot = bucket.recordToSlot[lastRecIdx]!;
2884
- bucket.slotToRecord[movedSlot] = recIdx;
2885
- bucket.recordToSlot[recIdx] = movedSlot;
2085
+ const movedSlot = bucket.slots[0]!.recordToSlot[lastRecIdx]!;
2086
+ bucket.slots[0]!.slotToRecord[movedSlot] = recIdx;
2087
+ bucket.slots[0]!.recordToSlot[recIdx] = movedSlot;
2886
2088
  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;
2089
+ if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
2090
+ if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
2889
2091
  }
2890
- bucket.slotToRecord[localSlot] = -1;
2891
- bucket.recordToSlot[lastRecIdx] = -1;
2892
- bucket.recordCount = lastRecIdx;
2893
- bucket.scanDirty = true;
2092
+ bucket.slots[0]!.slotToRecord[localSlot] = -1;
2093
+ bucket.slots[0]!.recordToSlot[lastRecIdx] = -1;
2094
+ bucket.slots[0]!.recordCount = lastRecIdx;
2095
+ bucket.slots[0]!.scanDirty = true;
2894
2096
  }
2895
2097
 
2896
2098
  // Release pool entries — refcount drops; if zero, allocation freed.
@@ -2943,9 +2145,21 @@ export function buildHeapScene(
2943
2145
  drawIdToBucket[drawId] = undefined;
2944
2146
  drawIdToLocalSlot[drawId] = undefined;
2945
2147
  drawIdToIndexAval[drawId] = undefined;
2148
+ drawIdToSpec[drawId] = undefined;
2149
+ const oldTracker = drawIdToModeTracker[drawId];
2150
+ if (oldTracker !== undefined) {
2151
+ oldTracker.forEachLeaf((av) => unsubscribeModeLeaf(av, drawId));
2152
+ oldTracker.dispose();
2153
+ }
2154
+ drawIdToModeTracker[drawId] = undefined;
2155
+ dirtyModeKeyDrawIds.delete(drawId);
2946
2156
 
2947
2157
  stats.totalDraws--;
2948
2158
  stats.geometryBytes = arenaBytes(arena);
2159
+
2160
+ // GC the bucket if it just emptied (common after reactive
2161
+ // PS rebucket leaves the source bucket with zero live ROs).
2162
+ gcBucket(bucket);
2949
2163
  }
2950
2164
 
2951
2165
  // ─── Aset reader (pull-driven on each frame) ──────────────────────
@@ -3030,6 +2244,105 @@ export function buildHeapScene(
3030
2244
  sceneObj.evaluateAlways(token, (tok) => {
3031
2245
  drainAsetWith(tok);
3032
2246
 
2247
+ // 0. Reactive rebucket: any RO whose PipelineState modeKey
2248
+ // changed since last frame moves to its new bucket.
2249
+ // `dirtyModeKeyDrawIds` was populated by ModeKeyTracker's
2250
+ // onDirty callbacks (one per leaf aval per RO). Calling
2251
+ // `recompute()` here re-snapshots the descriptor and tells
2252
+ // us whether the modeKey actually changed (vs the aval just
2253
+ // being marked-but-equal). If the key changed, we remove +
2254
+ // re-add the RO via the retained spec; addDraw routes it to
2255
+ // the correct (new) bucket via the value-keyed lookup.
2256
+ if (dirtyModeKeyDrawIds.size > 0) {
2257
+ const dirty = [...dirtyModeKeyDrawIds];
2258
+ dirtyModeKeyDrawIds.clear();
2259
+
2260
+ // Bucket-level fast path: group dirty drawIds by bucket. If
2261
+ // ALL of a bucket's ROs are dirty (typical SG case where one
2262
+ // shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
2263
+ // wrapping 20k leaves), the whole bucket transitions together.
2264
+ // Rebuild the bucket's pipeline + rename in bucketByKey,
2265
+ // O(1) work instead of N remove+add cycles.
2266
+ const byBucket = new Map<Bucket, number[]>();
2267
+ for (const drawId of dirty) {
2268
+ const b = drawIdToBucket[drawId];
2269
+ if (b === undefined) continue; // RO removed since mark
2270
+ let arr = byBucket.get(b);
2271
+ if (arr === undefined) { arr = []; byBucket.set(b, arr); }
2272
+ arr.push(drawId);
2273
+ }
2274
+
2275
+ for (const [bucket, ids] of byBucket) {
2276
+ // First recompute every dirty tracker so .modeKey is fresh.
2277
+ // Filter out ones whose value didn't actually change.
2278
+ const changed: number[] = [];
2279
+ for (const drawId of ids) {
2280
+ const tracker = drawIdToModeTracker[drawId];
2281
+ if (tracker === undefined) continue;
2282
+ const oldKey = tracker.modeKey;
2283
+ if (!tracker.recompute()) continue;
2284
+ if (tracker.modeKey === oldKey) continue;
2285
+ changed.push(drawId);
2286
+ }
2287
+ if (changed.length === 0) continue;
2288
+
2289
+ // Try the all-together fast path.
2290
+ const wholeBucket = changed.length === bucket.drawSlots.size;
2291
+ if (wholeBucket) {
2292
+ const repId = changed[0]!;
2293
+ const repTracker = drawIdToModeTracker[repId]!;
2294
+ const newKey = repTracker.modeKey;
2295
+ // Compute the new bucketByKey string; we need the family.
2296
+ const sample = drawIdToSpec[repId]!;
2297
+ const fam = familyFor(sample.effect);
2298
+ const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
2299
+ const existing = bucketByKey.get(newBk);
2300
+ if (existing === undefined) {
2301
+ // No conflict — rename + rebuild pipeline in place.
2302
+ // Find this bucket's current key by linear scan
2303
+ // (buckets ≤ ~100 typically, so this is cheap).
2304
+ let oldBk: string | undefined;
2305
+ for (const [k, v] of bucketByKey) {
2306
+ if (v === bucket) { oldBk = k; break; }
2307
+ }
2308
+ if (oldBk !== undefined) bucketByKey.delete(oldBk);
2309
+ bucketByKey.set(newBk, bucket);
2310
+ // Build the new pipeline with the snapshotted PS.
2311
+ const desc = repTracker.descriptor;
2312
+ const { pipelineLayout } = getBgl(bucket.layout, bucket.isAtlasBucket);
2313
+ const newPipeline = device.createRenderPipeline({
2314
+ label: `heapScene/${newBk}/pipeline`,
2315
+ layout: pipelineLayout,
2316
+ vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
2317
+ fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
2318
+ primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
2319
+ ...(depthFormat !== undefined && desc.depth !== undefined
2320
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
2321
+ : depthFormat !== undefined
2322
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" as GPUCompareFunction } }
2323
+ : {}),
2324
+ });
2325
+ // Swap pipeline reference on the bucket's single slot.
2326
+ // (Multi-slot Phase 5c would update one slot at a time.)
2327
+ (bucket.slots[0] as { pipeline: GPURenderPipeline }).pipeline = newPipeline;
2328
+ continue; // done with this bucket
2329
+ }
2330
+ // Conflict: another bucket already owns newBk. Fall through
2331
+ // to per-RO rebucket below, which will merge into `existing`.
2332
+ }
2333
+ // Slow path — per-RO rebucket (used when partial transition
2334
+ // OR when a merge with an existing destination bucket is
2335
+ // required).
2336
+ for (const drawId of changed) {
2337
+ const spec = drawIdToSpec[drawId];
2338
+ if (spec === undefined) continue;
2339
+ removeDraw(drawId);
2340
+ const newId = addDrawImpl(spec, tok);
2341
+ void newId;
2342
+ }
2343
+ }
2344
+ }
2345
+
3033
2346
  // 1. Pool: re-pack any aval whose value changed since last frame.
3034
2347
  // One writeBuffer per dirty aval, regardless of how many draws
3035
2348
  // reference it — sharing pays off here.
@@ -3125,16 +2438,16 @@ export function buildHeapScene(
3125
2438
  bucket.headerDirtyMin = Infinity;
3126
2439
  bucket.headerDirtyMax = 0;
3127
2440
  }
3128
- if (bucket.drawTableDirtyMax > bucket.drawTableDirtyMin) {
3129
- const shadow = bucket.drawTableShadow!;
2441
+ if (bucket.slots[0]!.drawTableDirtyMax > bucket.slots[0]!.drawTableDirtyMin) {
2442
+ const shadow = bucket.slots[0]!.drawTableShadow!;
3130
2443
  device.queue.writeBuffer(
3131
- bucket.drawTableBuf!.buffer, bucket.drawTableDirtyMin,
2444
+ bucket.slots[0]!.drawTableBuf!.buffer, bucket.slots[0]!.drawTableDirtyMin,
3132
2445
  shadow.buffer,
3133
- shadow.byteOffset + bucket.drawTableDirtyMin,
3134
- bucket.drawTableDirtyMax - bucket.drawTableDirtyMin,
2446
+ shadow.byteOffset + bucket.slots[0]!.drawTableDirtyMin,
2447
+ bucket.slots[0]!.drawTableDirtyMax - bucket.slots[0]!.drawTableDirtyMin,
3135
2448
  );
3136
- bucket.drawTableDirtyMin = Infinity;
3137
- bucket.drawTableDirtyMax = 0;
2449
+ bucket.slots[0]!.drawTableDirtyMin = Infinity;
2450
+ bucket.slots[0]!.drawTableDirtyMax = 0;
3138
2451
  }
3139
2452
  }
3140
2453
  }
@@ -3148,8 +2461,8 @@ export function buildHeapScene(
3148
2461
  let curBg: GPUBindGroup | null = null;
3149
2462
  for (const b of buckets) {
3150
2463
  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);
2464
+ pass.setPipeline(b.slots[0]!.pipeline);
2465
+ if (b.slots[0]!.recordCount > 0) pass.drawIndirect(b.slots[0]!.indirectBuf!, 0);
3153
2466
  }
3154
2467
  }
3155
2468
 
@@ -3190,23 +2503,23 @@ export function buildHeapScene(
3190
2503
  stats.derivedRecords = derivedScene.records.recordCount;
3191
2504
  }
3192
2505
  let anyDirty = false;
3193
- for (const b of buckets) { if (b.scanDirty) { anyDirty = true; break; } }
2506
+ for (const b of buckets) { if (b.slots[0]!.scanDirty) { anyDirty = true; break; } }
3194
2507
  if (!anyDirty) return;
3195
2508
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
3196
2509
  for (const b of buckets) {
3197
- if (!b.scanDirty) continue;
2510
+ if (!b.slots[0]!.scanDirty) continue;
3198
2511
  // Rebuild render bind group if recordCount changed — its
3199
2512
  // drawTable binding is sized to recordCount * 16.
3200
- if (b.renderBoundRecordCount !== b.recordCount) {
2513
+ if (b.slots[0]!.renderBoundRecordCount !== b.slots[0]!.recordCount) {
3201
2514
  b.bindGroup = buildBucketBindGroup(b);
3202
2515
  }
3203
- const numRecords = b.recordCount;
2516
+ const numRecords = b.slots[0]!.recordCount;
3204
2517
  const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
3205
2518
  device.queue.writeBuffer(
3206
- b.paramsBuf!, 0,
2519
+ b.slots[0]!.paramsBuf!, 0,
3207
2520
  new Uint32Array([numRecords, numBlocks, 0, 0]),
3208
2521
  );
3209
- pass.setBindGroup(0, b.scanBindGroup!);
2522
+ pass.setBindGroup(0, b.slots[0]!.scanBindGroup!);
3210
2523
  pass.setPipeline(scanPipeTile!);
3211
2524
  pass.dispatchWorkgroups(numBlocks, 1, 1);
3212
2525
  pass.setPipeline(scanPipeBlocks!);
@@ -3217,11 +2530,11 @@ export function buildHeapScene(
3217
2530
  // must cover the worst-case totalEmit. Each WG handles WG_SIZE
3218
2531
  // tiles; +1 for the sentinel slot.
3219
2532
  const SCAN_WG_SIZE = 256;
3220
- const numTilesCap = Math.max(1, Math.ceil(b.totalEmitEstimate / TILE_K));
2533
+ const numTilesCap = Math.max(1, Math.ceil(b.slots[0]!.totalEmitEstimate / TILE_K));
3221
2534
  const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
3222
2535
  pass.setPipeline(scanPipeBuildTileIndex!);
3223
2536
  pass.dispatchWorkgroups(tileWgs, 1, 1);
3224
- b.scanDirty = false;
2537
+ b.slots[0]!.scanDirty = false;
3225
2538
  }
3226
2539
  pass.end();
3227
2540
  }
@@ -3259,18 +2572,38 @@ export function buildHeapScene(
3259
2572
  device.queue.submit([enc.finish()]);
3260
2573
  }
3261
2574
 
2575
+ function destroyBucketResources(b: Bucket): void {
2576
+ b.drawHeap.destroy();
2577
+ for (const slot of b.slots) {
2578
+ slot.drawTableBuf?.destroy();
2579
+ slot.blockSumsBuf?.destroy();
2580
+ slot.blockOffsetsBuf?.destroy();
2581
+ slot.firstDrawInTileBuf?.destroy();
2582
+ slot.indirectBuf?.destroy();
2583
+ slot.paramsBuf?.destroy();
2584
+ }
2585
+ }
2586
+
2587
+ /**
2588
+ * Remove an empty bucket from the scene's bucket list + key index +
2589
+ * destroy its GPU resources. Called from removeDraw when an RO's
2590
+ * removal leaves the bucket with zero live slots — common after
2591
+ * reactive PS rebucket leaves the source bucket empty.
2592
+ */
2593
+ function gcBucket(b: Bucket): void {
2594
+ if (b.drawSlots.size !== 0) return;
2595
+ const idx = buckets.indexOf(b);
2596
+ if (idx >= 0) buckets.splice(idx, 1);
2597
+ for (const [k, v] of bucketByKey) {
2598
+ if (v === b) { bucketByKey.delete(k); break; }
2599
+ }
2600
+ destroyBucketResources(b);
2601
+ }
2602
+
3262
2603
  function dispose(): void {
3263
2604
  arena.attrs.destroy();
3264
2605
  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
- }
2606
+ for (const b of buckets) destroyBucketResources(b);
3274
2607
  }
3275
2608
 
3276
2609
  // Test-only escape hatch for inspecting megacall bucket state. Not
@@ -3285,11 +2618,11 @@ export function buildHeapScene(
3285
2618
  layout: BucketLayout;
3286
2619
  }[] {
3287
2620
  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,
2621
+ indirectBuf: b.slots[0]!.indirectBuf,
2622
+ drawTableBuf: b.slots[0]!.drawTableBuf?.buffer,
2623
+ firstDrawInTileBuf: b.slots[0]!.firstDrawInTileBuf?.buffer,
2624
+ totalEmitEstimate: b.slots[0]!.totalEmitEstimate,
2625
+ recordCount: b.slots[0]!.recordCount,
3293
2626
  layout: b.layout,
3294
2627
  }));
3295
2628
  },
@@ -3358,18 +2691,18 @@ export function buildHeapScene(
3358
2691
  const TILE_K_LOCAL = 64;
3359
2692
  const dcs: DC[] = [];
3360
2693
  for (const b of buckets) {
3361
- const dhSize = Math.min(b.drawHeap.buffer.size, b.recordCount * b.layout.drawHeaderBytes);
2694
+ const dhSize = Math.min(b.drawHeap.buffer.size, b.slots[0]!.recordCount * b.layout.drawHeaderBytes);
3362
2695
  if (dhSize === 0) continue;
3363
2696
  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)
2697
+ const dt = b.slots[0]!.drawTableBuf !== undefined && b.slots[0]!.recordCount > 0
2698
+ ? stage(b.slots[0]!.drawTableBuf.buffer, b.slots[0]!.recordCount * RECORD_BYTES)
3366
2699
  : undefined;
3367
- const totalEmit = b.totalEmitEstimate;
2700
+ const totalEmit = b.slots[0]!.totalEmitEstimate;
3368
2701
  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))
2702
+ const fdt = b.slots[0]!.firstDrawInTileBuf !== undefined && numTiles > 0
2703
+ ? stage(b.slots[0]!.firstDrawInTileBuf.buffer, Math.min(b.slots[0]!.firstDrawInTileBuf.buffer.size, (numTiles + 1) * 4))
3371
2704
  : undefined;
3372
- const ind = b.indirectBuf !== undefined ? stage(b.indirectBuf, 16) : undefined;
2705
+ const ind = b.slots[0]!.indirectBuf !== undefined ? stage(b.slots[0]!.indirectBuf, 16) : undefined;
3373
2706
  dcs.push({
3374
2707
  bucket: b, drawHeap: dh, numTiles,
3375
2708
  ...(dt !== undefined ? { drawTable: dt } : {}),
@@ -3401,7 +2734,7 @@ export function buildHeapScene(
3401
2734
  for (const dc of dcs) {
3402
2735
  const u32 = new Uint32Array(dc.drawHeap.getMappedRange());
3403
2736
  const stride = dc.bucket.layout.drawHeaderBytes;
3404
- const recordCount = dc.bucket.recordCount;
2737
+ const recordCount = dc.bucket.slots[0]!.recordCount;
3405
2738
 
3406
2739
  for (let slot = 0; slot < recordCount; slot++) {
3407
2740
  const slotOff = slot * stride;
@@ -3557,7 +2890,7 @@ export function buildHeapScene(
3557
2890
  // ── 6. indirect[0] (totalEmit) must match CPU prefix sum.
3558
2891
  if (dc.indirect !== undefined) {
3559
2892
  const ind = new Uint32Array(dc.indirect.getMappedRange());
3560
- const expectedTotal = dc.bucket.totalEmitEstimate;
2893
+ const expectedTotal = dc.bucket.slots[0]!.totalEmitEstimate;
3561
2894
  const got = ind[0]!;
3562
2895
  if (got !== expectedTotal) {
3563
2896
  push(`bucket#${bucketIdx} indirect[0]=${got} ≠ expected totalEmit=${expectedTotal}`);
@@ -3658,10 +2991,10 @@ export function buildHeapScene(
3658
2991
  };
3659
2992
  const dcs: DC[] = [];
3660
2993
  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 });
2994
+ if (b.slots[0]!.recordCount === 0 || b.slots[0]!.totalEmitEstimate === 0) continue;
2995
+ const dh = stage(b.drawHeap.buffer, Math.min(b.drawHeap.buffer.size, b.slots[0]!.recordCount * b.layout.drawHeaderBytes));
2996
+ const dt = stage(b.slots[0]!.drawTableBuf!.buffer, b.slots[0]!.recordCount * RECORD_BYTES);
2997
+ dcs.push({ bucket: b, drawHeap: dh, drawTable: dt, firstEmit: [], totalEmit: b.slots[0]!.totalEmitEstimate });
3665
2998
  }
3666
2999
  device.queue.submit([enc.finish()]);
3667
3000
 
@@ -3687,7 +3020,7 @@ export function buildHeapScene(
3687
3020
  const dt = new Uint32Array(dc.drawTable.getMappedRange());
3688
3021
  bucketDt.push(dt);
3689
3022
  bucketHdr.push(new Uint32Array(dc.drawHeap.getMappedRange()));
3690
- for (let r = 0; r < dc.bucket.recordCount; r++) {
3023
+ for (let r = 0; r < dc.bucket.slots[0]!.recordCount; r++) {
3691
3024
  dc.firstEmit.push(dt[r * RECORD_U32 + 0]!);
3692
3025
  }
3693
3026
  bucketCumEmit.push(totalEmitGlobal);
@@ -3745,7 +3078,7 @@ export function buildHeapScene(
3745
3078
  emitsChecked++;
3746
3079
 
3747
3080
  // Binary search for slot.
3748
- const recCount = dc.bucket.recordCount;
3081
+ const recCount = dc.bucket.slots[0]!.recordCount;
3749
3082
  let lo = 0, hi = recCount - 1;
3750
3083
  while (lo < hi) {
3751
3084
  const mid = (lo + hi + 1) >>> 1;
@@ -3942,9 +3275,9 @@ export function buildHeapScene(
3942
3275
  : new Uint32Array(0);
3943
3276
 
3944
3277
  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;
3278
+ if (target.slots[0]!.recordCount === 0 || target.slots[0]!.totalEmitEstimate === 0) { bucketIdx++; continue; }
3279
+ const recordCount = target.slots[0]!.recordCount;
3280
+ const totalEmit = target.slots[0]!.totalEmitEstimate;
3948
3281
  const totalTris = Math.floor(totalEmit / 3);
3949
3282
  const remainder = totalEmit % 3;
3950
3283
  if (remainder !== 0) {
@@ -3962,7 +3295,7 @@ export function buildHeapScene(
3962
3295
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3963
3296
  });
3964
3297
  const enc0 = device.createCommandEncoder({ label: "checkTriangleCoherence.dt" });
3965
- enc0.copyBufferToBuffer(target.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3298
+ enc0.copyBufferToBuffer(target.slots[0]!.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3966
3299
  enc0.copyBufferToBuffer(target.drawHeap.buffer, 0, dhCopy, 0, recordCount * target.layout.drawHeaderBytes);
3967
3300
  device.queue.submit([enc0.finish()]);
3968
3301
  await dtCopy.mapAsync(GPUMapMode.READ);
@@ -4011,7 +3344,7 @@ export function buildHeapScene(
4011
3344
  });
4012
3345
  {
4013
3346
  const enc1 = device.createCommandEncoder();
4014
- enc1.copyBufferToBuffer(target.firstDrawInTileBuf!.buffer, 0, fdtCopy, 0, fdtSize);
3347
+ enc1.copyBufferToBuffer(target.slots[0]!.firstDrawInTileBuf!.buffer, 0, fdtCopy, 0, fdtSize);
4015
3348
  device.queue.submit([enc1.finish()]);
4016
3349
  await fdtCopy.mapAsync(GPUMapMode.READ);
4017
3350
  }
@@ -4166,9 +3499,9 @@ export function buildHeapScene(
4166
3499
  // own bytes).
4167
3500
  bucketIdx = 0;
4168
3501
  for (const target of buckets) {
4169
- if (target.recordCount === 0) { bucketIdx++; continue; }
3502
+ if (target.slots[0]!.recordCount === 0) { bucketIdx++; continue; }
4170
3503
  // Re-stage drawHeap for this bucket.
4171
- const dhBytes = target.recordCount * target.layout.drawHeaderBytes;
3504
+ const dhBytes = target.slots[0]!.recordCount * target.layout.drawHeaderBytes;
4172
3505
  const dhCopy = device.createBuffer({
4173
3506
  size: dhBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
4174
3507
  });
@@ -4178,7 +3511,7 @@ export function buildHeapScene(
4178
3511
  await dhCopy.mapAsync(GPUMapMode.READ);
4179
3512
  const dhU32 = new Uint32Array(dhCopy.getMappedRange());
4180
3513
  const stride2 = target.layout.drawHeaderBytes;
4181
- for (let slot = 0; slot < target.recordCount; slot++) {
3514
+ for (let slot = 0; slot < target.slots[0]!.recordCount; slot++) {
4182
3515
  for (const f of target.layout.drawHeaderFields) {
4183
3516
  if (f.kind !== "uniform-ref" && f.kind !== "attribute-ref") continue;
4184
3517
  const ref = dhU32[(slot * stride2 + f.byteOffset) >>> 2];
@@ -4238,11 +3571,11 @@ export function buildHeapScene(
4238
3571
  let gpuMismatches = 0;
4239
3572
  const push = (s: string) => { if (issues.length < 30) issues.push(s); };
4240
3573
 
4241
- const target = buckets.find(b => b.recordCount > 0 && b.totalEmitEstimate > 0);
3574
+ const target = buckets.find(b => b.slots[0]!.recordCount > 0 && b.slots[0]!.totalEmitEstimate > 0);
4242
3575
  if (target === undefined) return { emitsChecked: 0, gpuMismatches: 0, issues };
4243
3576
 
4244
- const recordCount = target.recordCount;
4245
- const totalEmit = target.totalEmitEstimate;
3577
+ const recordCount = target.slots[0]!.recordCount;
3578
+ const totalEmit = target.slots[0]!.totalEmitEstimate;
4246
3579
  const sampleCount = Math.min(samples, totalEmit);
4247
3580
  const stride = Math.max(1, Math.floor(totalEmit / sampleCount));
4248
3581
 
@@ -4256,7 +3589,7 @@ export function buildHeapScene(
4256
3589
  });
4257
3590
  {
4258
3591
  const enc0 = device.createCommandEncoder({ label: "probeBinarySearch.dtCopy" });
4259
- enc0.copyBufferToBuffer(target.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
3592
+ enc0.copyBufferToBuffer(target.slots[0]!.drawTableBuf!.buffer, 0, dtCopy, 0, recordCount * RECORD_BYTES);
4260
3593
  device.queue.submit([enc0.finish()]);
4261
3594
  await dtCopy.mapAsync(GPUMapMode.READ);
4262
3595
  }
@@ -4360,8 +3693,8 @@ export function buildHeapScene(
4360
3693
  const bg = device.createBindGroup({
4361
3694
  layout: bgl,
4362
3695
  entries: [
4363
- { binding: 0, resource: { buffer: target.drawTableBuf!.buffer } },
4364
- { binding: 1, resource: { buffer: target.firstDrawInTileBuf!.buffer } },
3696
+ { binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
3697
+ { binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
4365
3698
  { binding: 2, resource: { buffer: sampleBuf } },
4366
3699
  { binding: 3, resource: { buffer: arena.indices.buffer } },
4367
3700
  { binding: 4, resource: { buffer: outBuf } },