@aardworx/wombat.rendering 0.9.27 → 0.9.28

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.
@@ -157,8 +157,13 @@ export interface HeapGeometry {
157
157
  * slot owns its own drawTable + scan buffers + indirect + pipeline.
158
158
  */
159
159
  interface BucketSlot {
160
- /** The render pipeline this slot draws with. */
161
- readonly pipeline: GPURenderPipeline;
160
+ /** The render pipeline this slot draws with. Replaced when a rule
161
+ * output changes for this slot (typically via the pipeline cache). */
162
+ pipeline: GPURenderPipeline;
163
+ /** modeKey this slot covers — the bitfield-encoded descriptor. */
164
+ modeKey: bigint;
165
+ /** Render bind group (drawTable + firstDrawInTile are per-slot). */
166
+ bindGroup?: GPUBindGroup;
162
167
 
163
168
  // ─── drawTable / megacall state ────────────────────────────────────
164
169
  drawTableBuf?: GrowBuffer;
@@ -379,6 +384,11 @@ export interface HeapDrawSpec {
379
384
 
380
385
  export interface HeapSceneStats {
381
386
  readonly groups: number;
387
+ /** Total pipeline slots across all buckets — one per realized
388
+ * (effect, textureSet, modeKey) combination. Phase 5c.2 brings
389
+ * this divergence: groups counts (effect, textureSet) only,
390
+ * slotCount counts the per-bucket modeKey splits. */
391
+ readonly slotCount: number;
382
392
  totalDraws: number;
383
393
  drawBytes: number;
384
394
  geometryBytes: number;
@@ -604,6 +614,10 @@ export function buildHeapScene(
604
614
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
605
615
  const drawIdToBucket: (Bucket | undefined)[] = [];
606
616
  const drawIdToLocalSlot: (number | undefined)[] = [];
617
+ /** Per-draw slot index within its bucket. Phase 5c.2: each bucket
618
+ * holds multiple BucketSlots (one per realized modeKey); this map
619
+ * tells each removeDraw / record write which slot to touch. */
620
+ const drawIdToSlotIdx: (number | undefined)[] = [];
607
621
  /** Per-draw index aval — for `indexPool.release` on removeDraw. */
608
622
  const drawIdToIndexAval: (aval<Uint32Array> | undefined)[] = [];
609
623
  /**
@@ -1085,18 +1099,19 @@ export function buildHeapScene(
1085
1099
  });
1086
1100
  }
1087
1101
 
1088
- function buildScanBindGroup(bucket: Bucket): GPUBindGroup {
1102
+ function buildScanBindGroup(bucket: Bucket, slotIdx = 0): GPUBindGroup {
1089
1103
  if (scanBgl === undefined) throw new Error("heapScene: scan BGL missing");
1104
+ const s = bucket.slots[slotIdx]!;
1090
1105
  return device.createBindGroup({
1091
- label: `heapScene/${bucket.label}/scanBg`,
1106
+ label: `heapScene/${bucket.label}/slot${slotIdx}/scanBg`,
1092
1107
  layout: scanBgl,
1093
1108
  entries: [
1094
- { binding: 0, resource: { buffer: bucket.slots[0]!.drawTableBuf!.buffer } },
1095
- { binding: 1, resource: { buffer: bucket.slots[0]!.blockSumsBuf!.buffer } },
1096
- { binding: 2, resource: { buffer: bucket.slots[0]!.blockOffsetsBuf!.buffer } },
1097
- { binding: 3, resource: { buffer: bucket.slots[0]!.indirectBuf! } },
1098
- { binding: 4, resource: { buffer: bucket.slots[0]!.paramsBuf! } },
1099
- { binding: 5, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
1109
+ { binding: 0, resource: { buffer: s.drawTableBuf!.buffer } },
1110
+ { binding: 1, resource: { buffer: s.blockSumsBuf!.buffer } },
1111
+ { binding: 2, resource: { buffer: s.blockOffsetsBuf!.buffer } },
1112
+ { binding: 3, resource: { buffer: s.indirectBuf! } },
1113
+ { binding: 4, resource: { buffer: s.paramsBuf! } },
1114
+ { binding: 5, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
1100
1115
  ],
1101
1116
  });
1102
1117
  }
@@ -1401,7 +1416,7 @@ export function buildHeapScene(
1401
1416
  // pipelines via noTexBgl/texBgl; the bind-group itself binds this
1402
1417
  // bucket's drawHeap (binding 1) + its globals UBO (binding 0) +
1403
1418
  // the global arena's heapF32 view (binding 2) + textures if any.
1404
- function buildBucketBindGroup(bucket: Bucket): GPUBindGroup {
1419
+ function buildBucketBindGroup(bucket: Bucket, slotIdx = 0): GPUBindGroup {
1405
1420
  // heapU32 / heapF32 / heapV4f are different typed views of the
1406
1421
  // SAME global arena GPUBuffer (emscripten-style aliasing). The
1407
1422
  // WGSL prelude declares one binding per view; the shader picks
@@ -1413,7 +1428,8 @@ export function buildHeapScene(
1413
1428
  { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
1414
1429
  ];
1415
1430
  {
1416
- if (bucket.slots[0]!.drawTableBuf === undefined) {
1431
+ const s = bucket.slots[slotIdx]!;
1432
+ if (s.drawTableBuf === undefined) {
1417
1433
  throw new Error("heapScene: megacall bucket without drawTableBuf");
1418
1434
  }
1419
1435
  // Bind drawTable with size = recordCount * RECORD_BYTES so the VS
@@ -1421,13 +1437,13 @@ export function buildHeapScene(
1421
1437
  // the live record count — keeps stale tail entries out of the
1422
1438
  // binary search. Minimum one zero-record to satisfy WebGPU non-
1423
1439
  // zero size constraint when the bucket is empty.
1424
- const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0]!.recordCount * RECORD_BYTES);
1440
+ const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
1425
1441
  entries.push(
1426
- { binding: 4, resource: { buffer: bucket.slots[0]!.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1442
+ { binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1427
1443
  { binding: 5, resource: { buffer: arena.indices.buffer } },
1428
- { binding: 6, resource: { buffer: bucket.slots[0]!.firstDrawInTileBuf!.buffer } },
1444
+ { binding: 6, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
1429
1445
  );
1430
- bucket.slots[0]!.renderBoundRecordCount = bucket.slots[0]!.recordCount;
1446
+ s.renderBoundRecordCount = s.recordCount;
1431
1447
  }
1432
1448
  // Schema-driven texture + sampler entries. v1 user surface still
1433
1449
  // accepts a single (texture, sampler) pair via HeapTextureSet —
@@ -1522,32 +1538,28 @@ export function buildHeapScene(
1522
1538
  // carry strong refcount handles (`localAtlasReleases`) that drive
1523
1539
  // `pool.release` on removeDraw.
1524
1540
 
1525
- // When the global arena reallocates, every bucket's bind group
1526
- // needs rebuilding (its binding 2 buffer reference is stale).
1541
+ /** Rebuild every slot's render bind group across every bucket.
1542
+ * Used when a global resource (arena.attrs, arena.indices, atlas
1543
+ * page set) reallocates and invalidates every bind group at once. */
1544
+ function rebuildAllBindGroups(filterAtlasOnly = false): void {
1545
+ for (const b of buckets) {
1546
+ if (filterAtlasOnly && !b.isAtlasBucket) continue;
1547
+ for (let i = 0; i < b.slots.length; i++) {
1548
+ b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
1549
+ }
1550
+ }
1551
+ }
1552
+
1527
1553
  arena.attrs.onResize(() => {
1528
- for (const b of buckets) b.bindGroup = buildBucketBindGroup(b);
1529
- // §7 dispatcher targets arena.attrs.buffer; rebind on grow.
1554
+ rebuildAllBindGroups();
1530
1555
  if (derivedScene !== undefined) derivedScene.rebindMainHeap(arena.attrs.buffer);
1531
1556
  });
1532
- // Same when the atlas pool allocates a fresh page — its slot in the
1533
- // BGL ladder transitions from placeholder to the real GPUTexture.
1534
- // Every atlas bucket rebuilds (cheap; just N+N+1 entries each).
1535
1557
  if (atlasPool !== undefined) {
1536
1558
  for (const f of ATLAS_PAGE_FORMATS) {
1537
- atlasPool.onPageAdded(f, () => {
1538
- for (const b of buckets) {
1539
- if (b.isAtlasBucket) b.bindGroup = buildBucketBindGroup(b);
1540
- }
1541
- });
1559
+ atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
1542
1560
  }
1543
1561
  }
1544
- // indexStorage is bound at slot 5 from arena.indices, so a grow there
1545
- // also invalidates every bucket's bind group.
1546
- {
1547
- arena.indices.onResize(() => {
1548
- for (const b of buckets) b.bindGroup = buildBucketBindGroup(b);
1549
- });
1550
- }
1562
+ arena.indices.onResize(() => rebuildAllBindGroups());
1551
1563
 
1552
1564
  // ─── findOrCreateBucket ───────────────────────────────────────────
1553
1565
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
@@ -1556,6 +1568,95 @@ export function buildHeapScene(
1556
1568
  // right per-effect helper at draw time. Atlas-binding shape follows
1557
1569
  // from `family.drawHeaderUnion.atlasTextureBindings.size > 0`.
1558
1570
  void texIdOf; // retained for future per-bucket diagnostics
1571
+ /**
1572
+ * Allocate a brand-new BucketSlot inside `bucket` covering
1573
+ * `descriptor` (modeKey = encode(descriptor)). Builds the per-slot
1574
+ * GPU resources: pipeline (via cache), drawTable + scan buffers +
1575
+ * indirect + paramsBuf, and the slot's render/scan bind groups
1576
+ * (the latter rebuilt on resize). The bucket's drawHeap +
1577
+ * texture bindings are shared across slots.
1578
+ */
1579
+ function createSlot(bucket: Bucket, descriptor: PipelineStateDescriptor): BucketSlot {
1580
+ const fam = familyForBucket.get(bucket)!;
1581
+ const pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, descriptor);
1582
+ const modeKey = encodeModeKey(descriptor);
1583
+ const slotIdx = bucket.slots.length;
1584
+ const slotLabel = `${bucket.label}/slot${slotIdx}`;
1585
+
1586
+ const dtBuf = new GrowBuffer(
1587
+ device, `heapScene/${slotLabel}/drawTable`,
1588
+ GPUBufferUsage.STORAGE, 1024,
1589
+ );
1590
+ const blockSumsBuf = new GrowBuffer(
1591
+ device, `heapScene/${slotLabel}/blockSums`,
1592
+ GPUBufferUsage.STORAGE,
1593
+ 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
1594
+ );
1595
+ const blockOffsetsBuf = new GrowBuffer(
1596
+ device, `heapScene/${slotLabel}/blockOffsets`,
1597
+ GPUBufferUsage.STORAGE,
1598
+ 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
1599
+ );
1600
+ const firstDrawInTileBuf = new GrowBuffer(
1601
+ device, `heapScene/${slotLabel}/firstDrawInTile`,
1602
+ GPUBufferUsage.STORAGE, 128,
1603
+ );
1604
+ const indirectBuf = device.createBuffer({
1605
+ label: `heapScene/${slotLabel}/indirect`, size: 16,
1606
+ usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
1607
+ });
1608
+ device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
1609
+ const paramsBuf = device.createBuffer({
1610
+ label: `heapScene/${slotLabel}/params`, size: 16,
1611
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1612
+ });
1613
+
1614
+ const slot: BucketSlot = {
1615
+ pipeline, modeKey,
1616
+ drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1617
+ recordCount: 0, slotToRecord: [], recordToSlot: [],
1618
+ totalEmitEstimate: 0, scanDirty: false,
1619
+ drawTableBuf: dtBuf,
1620
+ drawTableShadow: new Uint32Array(dtBuf.capacity / 4),
1621
+ blockSumsBuf, blockOffsetsBuf, firstDrawInTileBuf,
1622
+ indirectBuf, paramsBuf,
1623
+ };
1624
+ bucket.slots.push(slot);
1625
+ const thisSlotIdx = bucket.slots.length - 1;
1626
+
1627
+ const ensureScanBuffers = (): void => {
1628
+ const needBlocks = Math.max(1, Math.ceil(slot.recordCount / SCAN_TILE_SIZE));
1629
+ blockSumsBuf.ensureCapacity(needBlocks * 4);
1630
+ blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1631
+ };
1632
+ const rebuildScanBg = (): void => {
1633
+ slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
1634
+ };
1635
+ dtBuf.onResize(() => {
1636
+ const grown = new Uint32Array(dtBuf.capacity / 4);
1637
+ grown.set(slot.drawTableShadow!);
1638
+ slot.drawTableShadow = grown;
1639
+ ensureScanBuffers();
1640
+ slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
1641
+ rebuildScanBg();
1642
+ slot.drawTableDirtyMin = 0;
1643
+ slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
1644
+ });
1645
+ blockSumsBuf.onResize(rebuildScanBg);
1646
+ blockOffsetsBuf.onResize(rebuildScanBg);
1647
+ firstDrawInTileBuf.onResize(() => {
1648
+ rebuildScanBg();
1649
+ slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
1650
+ });
1651
+ slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
1652
+ slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
1653
+
1654
+ return slot;
1655
+ }
1656
+
1657
+ /** Side map from Bucket → family. Populated by findOrCreateBucket. */
1658
+ const familyForBucket = new WeakMap<Bucket, ReturnType<typeof familyFor>>();
1659
+
1559
1660
  function findOrCreateBucket(
1560
1661
  effect: Effect,
1561
1662
  _textures: HeapTextureSet | undefined,
@@ -1572,67 +1673,27 @@ export function buildHeapScene(
1572
1673
  throw new Error("heapScene: findOrCreateBucket called before family build");
1573
1674
  }
1574
1675
  const fam = familyFor(effect);
1575
- // ─── PS keying VALUE based, not identity based ───────────────
1576
- // psIdOf used to hash by aval identity. That meant 20k ROs each
1577
- // constructed with `cval("back")` for cullMode hashed to 20k
1578
- // distinct keys 20k buckets, all rendering with bitwise-
1579
- // identical state. Now the key is the bitfield-encoded modeKey
1580
- // (the actual value-set in the descriptor), so identical-value
1581
- // cvals collapse to ONE bucket. See docs/derived-modes.md and
1582
- // tests/heap-multi-pipeline-bucket.test.ts.
1583
- const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
1584
- const psModeKey = encodeModeKey(psDescriptor);
1585
- const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
1676
+ // Phase 5c.2: bucket key drops modeKey. All ROs with the same
1677
+ // (effect, textureSet) share one bucket; per-modeKey slots live
1678
+ // inside the bucket and route records via `ensureSlot`. Pipeline
1679
+ // pre-warm + the scene-level pipelineByModeKey cache mean every
1680
+ // realized slot lookup hits the cache.
1681
+ const bk = `family#${fam.schema.id}`;
1586
1682
  const existing = bucketByKey.get(bk);
1587
1683
  if (existing !== undefined) return existing;
1588
- // If a precomputed descriptor is supplied (rule-evaluated path),
1589
- // use its post-rule axis values so the pipeline reflects what the
1590
- // RO actually wants. Otherwise fall through to PS-aval forcing.
1591
- const ps: ResolvedPipelineState = precomputedDescriptor !== undefined
1592
- ? {
1593
- topology: precomputedDescriptor.topology,
1594
- cullMode: precomputedDescriptor.cullMode,
1595
- frontFace: precomputedDescriptor.frontFace,
1596
- ...(precomputedDescriptor.depth !== undefined
1597
- ? { depth: { write: precomputedDescriptor.depth.write, compare: precomputedDescriptor.depth.compare } }
1598
- : {}),
1599
- }
1600
- : resolvePipelineState(pipelineState);
1601
1684
 
1602
1685
  const layout: BucketLayout = fam.schema.drawHeaderUnion;
1603
1686
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
1604
- // Pipeline lookup-or-create through the scene-level cache so
1605
- // subsequent buckets / fast-path renames with the same descriptor
1606
- // get the same GPU pipeline object (no duplicate
1607
- // createRenderPipeline calls).
1608
- const descForPipeline: PipelineStateDescriptor = precomputedDescriptor ?? psDescriptor;
1609
- const pipeline = getOrCreatePipeline(fam, layout, isAtlasBucket, descForPipeline);
1610
-
1611
- // Per-bucket DrawHeader buffer (every uniform / attribute is a u32
1612
- // ref into the global arena; the per-bucket buffer just holds the
1613
- // refs).
1614
1687
  const drawHeapBuf = new GrowBuffer(
1615
1688
  device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE,
1616
1689
  Math.max(layout.drawHeaderBytes, 64),
1617
1690
  );
1618
1691
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
1619
1692
 
1620
- const slot0: BucketSlot = {
1621
- pipeline,
1622
- drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1623
- recordCount: 0, slotToRecord: [], recordToSlot: [],
1624
- totalEmitEstimate: 0,
1625
- scanDirty: false,
1626
- };
1627
1693
  const bucket: Bucket = {
1628
- // §6 family-merge: family buckets aren't keyed on a specific
1629
- // texture set — atlas placements are addressed per-RO via
1630
- // drawHeader fields (`pageRef` + `formatBits` + `origin` +
1631
- // `size`), and the bucket's atlas-binding ladder is driven by
1632
- // `atlasPool.pagesFor(format)`. Leave `textures` undefined.
1633
1694
  label: bk, textures: undefined, layout,
1634
- slots: [slot0],
1635
- bindGroup: null as unknown as GPUBindGroup,
1695
+ slots: [], // populated lazily by ensureSlot per modeKey
1696
+ bindGroup: null as unknown as GPUBindGroup, // legacy field; slot.bindGroup is the real one
1636
1697
  drawHeap,
1637
1698
  drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
1638
1699
  headerDirtyMin: Infinity, headerDirtyMax: 0,
@@ -1645,92 +1706,49 @@ export function buildHeapScene(
1645
1706
  localAtlasTextures: [],
1646
1707
  localAtlasArrIdx: [],
1647
1708
  };
1648
- {
1649
- const dtBuf = new GrowBuffer(
1650
- device, `heapScene/${bk}/drawTable`,
1651
- GPUBufferUsage.STORAGE,
1652
- 1024,
1653
- );
1654
- const blockSumsBuf = new GrowBuffer(
1655
- device, `heapScene/${bk}/blockSums`,
1656
- GPUBufferUsage.STORAGE,
1657
- 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
1658
- );
1659
- const blockOffsetsBuf = new GrowBuffer(
1660
- device, `heapScene/${bk}/blockOffsets`,
1661
- GPUBufferUsage.STORAGE,
1662
- 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
1663
- );
1664
- // 32 u32 = 128 bytes is the floor; pow2-grown by ensureCapacity
1665
- // as totalEmitEstimate grows.
1666
- const firstDrawInTileBuf = new GrowBuffer(
1667
- device, `heapScene/${bk}/firstDrawInTile`,
1668
- GPUBufferUsage.STORAGE,
1669
- 128,
1670
- );
1671
- const indirectBuf = device.createBuffer({
1672
- label: `heapScene/${bk}/indirect`, size: 16,
1673
- usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
1674
- });
1675
- // Initialize to (0, 1, 0, 0) so an unscanned/empty bucket draws
1676
- // nothing safely.
1677
- device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
1678
- const paramsBuf = device.createBuffer({
1679
- label: `heapScene/${bk}/params`, size: 16,
1680
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1681
- });
1682
- bucket.slots[0]!.drawTableBuf = dtBuf;
1683
- bucket.slots[0]!.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
1684
- bucket.slots[0]!.blockSumsBuf = blockSumsBuf;
1685
- bucket.slots[0]!.blockOffsetsBuf = blockOffsetsBuf;
1686
- bucket.slots[0]!.firstDrawInTileBuf = firstDrawInTileBuf;
1687
- bucket.slots[0]!.indirectBuf = indirectBuf;
1688
- bucket.slots[0]!.paramsBuf = paramsBuf;
1689
- const ensureScanBuffers = (): void => {
1690
- const needBlocks = Math.max(1, Math.ceil(bucket.slots[0]!.recordCount / SCAN_TILE_SIZE));
1691
- blockSumsBuf.ensureCapacity(needBlocks * 4);
1692
- blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1693
- };
1694
- const rebuildScanBg = (): void => {
1695
- bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
1696
- };
1697
- dtBuf.onResize(() => {
1698
- const grown = new Uint32Array(dtBuf.capacity / 4);
1699
- grown.set(bucket.slots[0]!.drawTableShadow!);
1700
- bucket.slots[0]!.drawTableShadow = grown;
1701
- ensureScanBuffers();
1702
- bucket.bindGroup = buildBucketBindGroup(bucket);
1703
- rebuildScanBg();
1704
- bucket.slots[0]!.drawTableDirtyMin = 0;
1705
- bucket.slots[0]!.drawTableDirtyMax = bucket.slots[0]!.recordCount * RECORD_BYTES;
1706
- });
1707
- blockSumsBuf.onResize(rebuildScanBg);
1708
- blockOffsetsBuf.onResize(rebuildScanBg);
1709
- firstDrawInTileBuf.onResize(() => {
1710
- rebuildScanBg();
1711
- bucket.bindGroup = buildBucketBindGroup(bucket);
1712
- });
1713
- bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
1714
- }
1715
- bucket.bindGroup = buildBucketBindGroup(bucket);
1709
+ familyForBucket.set(bucket, fam);
1710
+
1716
1711
  drawHeap.onResize(() => {
1717
1712
  bucket.drawHeaderStaging = new Float32Array(drawHeapBuf.capacity / 4);
1718
- // GPU-side data was copied by the GrowBuffer's resize, but our
1719
- // CPU mirror is fresh — mark all live local slots dirty so
1720
- // their headers get re-packed and re-uploaded next frame.
1721
1713
  for (const s of bucket.drawSlots) bucket.dirty.add(s);
1722
- bucket.bindGroup = buildBucketBindGroup(bucket);
1723
- // §7's main heap is arena.attrs.buffer (not drawHeap) — the
1724
- // arena.attrs.onResize handler at the scene level handles its
1725
- // rebind. drawHeap resize doesn't touch §7.
1714
+ // Rebuild every slot's render bind group (binding 1 = drawHeap
1715
+ // points at the new buffer).
1716
+ for (let i = 0; i < bucket.slots.length; i++) {
1717
+ bucket.slots[i]!.bindGroup = buildBucketBindGroup(bucket, i);
1718
+ }
1726
1719
  });
1727
- // §7 has one scene-wide records buffer + dispatcher (all buckets
1728
- // target arena.attrs.buffer), so nothing per-bucket to set up here.
1720
+
1721
+ // Ensure the bucket has a slot for the supplied descriptor — at
1722
+ // bucket-creation time we eagerly seed slot 0 from the caller's
1723
+ // descriptor so the first addDraw doesn't pay a slot-create cost.
1724
+ const seedDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
1725
+ createSlot(bucket, seedDescriptor);
1726
+
1729
1727
  buckets.push(bucket);
1730
1728
  bucketByKey.set(bk, bucket);
1731
1729
  return bucket;
1732
1730
  }
1733
1731
 
1732
+ /**
1733
+ * Return the existing BucketSlot for `descriptor` inside `bucket`,
1734
+ * or create one on miss. The mode-flip hot path calls this when an
1735
+ * RO's tracker.modeKey lands on a slot that doesn't exist yet.
1736
+ */
1737
+ function ensureSlot(bucket: Bucket, descriptor: PipelineStateDescriptor): BucketSlot {
1738
+ const wanted = encodeModeKey(descriptor);
1739
+ for (const s of bucket.slots) {
1740
+ if (s.modeKey === wanted) return s;
1741
+ }
1742
+ return createSlot(bucket, descriptor);
1743
+ }
1744
+ /** Find an RO's slot by drawId; null if the drawId is dead. */
1745
+ function slotForDrawId(drawId: number): BucketSlot | null {
1746
+ const idx = drawIdToSlotIdx[drawId];
1747
+ const bucket = drawIdToBucket[drawId];
1748
+ if (idx === undefined || bucket === undefined) return null;
1749
+ return bucket.slots[idx] ?? null;
1750
+ }
1751
+
1734
1752
  // ─── Per-bucket header writer (refs only, post step 3) ────────────
1735
1753
  // The DrawHeader is now a flat list of u32 refs into the arena —
1736
1754
  // values themselves live in arena allocations the pool manages.
@@ -1854,6 +1872,7 @@ export function buildHeapScene(
1854
1872
  // ─── Stats (declared early so addDraw/removeDraw can mutate it) ───
1855
1873
  const stats: HeapSceneStats = {
1856
1874
  groups: 0,
1875
+ slotCount: 0,
1857
1876
  totalDraws: 0,
1858
1877
  drawBytes: 0,
1859
1878
  geometryBytes: 0,
@@ -1863,6 +1882,14 @@ export function buildHeapScene(
1863
1882
  derivedRecords: 0,
1864
1883
  };
1865
1884
  Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
1885
+ Object.defineProperty(stats, "slotCount", {
1886
+ get: () => {
1887
+ let n = 0;
1888
+ for (const b of buckets) n += b.slots.length;
1889
+ return n;
1890
+ },
1891
+ configurable: true,
1892
+ });
1866
1893
 
1867
1894
  // ─── addDraw / removeDraw ─────────────────────────────────────────
1868
1895
  // Public addDraw wrapper. Establishes a sceneObj.evaluateAlways
@@ -1912,6 +1939,12 @@ export function buildHeapScene(
1912
1939
  });
1913
1940
  }
1914
1941
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
1942
+ // Phase 5c.2: route this RO to the bucket's slot covering its
1943
+ // current descriptor. ensureSlot creates a new slot on miss; the
1944
+ // pipeline lookup hits the scene-level cache.
1945
+ const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
1946
+ const roSlot = ensureSlot(bucket, roDescriptor);
1947
+ const roSlotIdx = bucket.slots.indexOf(roSlot);
1915
1948
  const fam = familyFor(spec.effect);
1916
1949
  const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
1917
1950
 
@@ -2049,8 +2082,8 @@ export function buildHeapScene(
2049
2082
  if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
2050
2083
 
2051
2084
  {
2052
- const dtBuf = bucket.slots[0]!.drawTableBuf!;
2053
- const recIdx = bucket.slots[0]!.recordCount;
2085
+ const dtBuf = roSlot.drawTableBuf!;
2086
+ const recIdx = roSlot.recordCount;
2054
2087
  if (recIdx >= SCAN_MAX_RECORDS) {
2055
2088
  throw new Error(
2056
2089
  `heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
@@ -2062,28 +2095,29 @@ export function buildHeapScene(
2062
2095
  dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
2063
2096
  // Grow scan-side buffers if recordCount crosses a tile boundary.
2064
2097
  const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
2065
- bucket.slots[0]!.blockSumsBuf!.ensureCapacity(needBlocks * 4);
2066
- bucket.slots[0]!.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
2067
- const shadow = bucket.slots[0]!.drawTableShadow!;
2098
+ roSlot.blockSumsBuf!.ensureCapacity(needBlocks * 4);
2099
+ roSlot.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
2100
+ const shadow = roSlot.drawTableShadow!;
2068
2101
  // firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
2069
2102
  shadow[recIdx * RECORD_U32 + 0] = 0;
2070
2103
  shadow[recIdx * RECORD_U32 + 1] = localSlot;
2071
2104
  shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
2072
2105
  shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
2073
2106
  shadow[recIdx * RECORD_U32 + 4] = instanceCount;
2074
- bucket.slots[0]!.recordCount = recIdx + 1;
2075
- bucket.slots[0]!.slotToRecord[localSlot] = recIdx;
2076
- bucket.slots[0]!.recordToSlot[recIdx] = localSlot;
2077
- if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
2078
- if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
2079
- bucket.slots[0]!.totalEmitEstimate += idxAlloc.count * instanceCount;
2080
- const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0]!.totalEmitEstimate / TILE_K));
2081
- bucket.slots[0]!.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
2082
- bucket.slots[0]!.scanDirty = true;
2107
+ roSlot.recordCount = recIdx + 1;
2108
+ roSlot.slotToRecord[localSlot] = recIdx;
2109
+ roSlot.recordToSlot[recIdx] = localSlot;
2110
+ if (byteOff < roSlot.drawTableDirtyMin) roSlot.drawTableDirtyMin = byteOff;
2111
+ if (byteOff + RECORD_BYTES > roSlot.drawTableDirtyMax) roSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
2112
+ roSlot.totalEmitEstimate += idxAlloc.count * instanceCount;
2113
+ const newNumTiles = Math.max(1, Math.ceil(roSlot.totalEmitEstimate / TILE_K));
2114
+ roSlot.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
2115
+ roSlot.scanDirty = true;
2083
2116
  }
2084
2117
 
2085
2118
  drawIdToBucket[drawId] = bucket;
2086
2119
  drawIdToLocalSlot[drawId] = localSlot;
2120
+ drawIdToSlotIdx[drawId] = roSlotIdx;
2087
2121
  drawIdToIndexAval[drawId] = indicesAval;
2088
2122
 
2089
2123
  // ─── Reactive rebucket: track PS-modeKey changes ────────────────
@@ -2231,17 +2265,19 @@ export function buildHeapScene(
2231
2265
  }
2232
2266
  }
2233
2267
  {
2268
+ const slotIdx = drawIdToSlotIdx[drawId];
2269
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx]! : bucket.slots[0]!;
2234
2270
  const removedEntry = bucket.localEntries[localSlot];
2235
2271
  const removedCount = removedEntry !== undefined
2236
2272
  ? removedEntry.indexCount * removedEntry.instanceCount
2237
2273
  : 0;
2238
- bucket.slots[0]!.totalEmitEstimate = Math.max(0, bucket.slots[0]!.totalEmitEstimate - removedCount);
2274
+ slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate - removedCount);
2239
2275
  // Swap-pop: move the last record into the freed slot, decrement
2240
2276
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
2241
2277
  // we only fix (drawIdx, indexStart, indexCount, instanceCount).
2242
- const recIdx = bucket.slots[0]!.slotToRecord[localSlot]!;
2243
- const lastRecIdx = bucket.slots[0]!.recordCount - 1;
2244
- const shadow = bucket.slots[0]!.drawTableShadow!;
2278
+ const recIdx = slot.slotToRecord[localSlot]!;
2279
+ const lastRecIdx = slot.recordCount - 1;
2280
+ const shadow = slot.drawTableShadow!;
2245
2281
  if (recIdx !== lastRecIdx) {
2246
2282
  const dst = recIdx * RECORD_U32;
2247
2283
  const src = lastRecIdx * RECORD_U32;
@@ -2250,17 +2286,17 @@ export function buildHeapScene(
2250
2286
  shadow[dst + 2] = shadow[src + 2]!;
2251
2287
  shadow[dst + 3] = shadow[src + 3]!;
2252
2288
  shadow[dst + 4] = shadow[src + 4]!;
2253
- const movedSlot = bucket.slots[0]!.recordToSlot[lastRecIdx]!;
2254
- bucket.slots[0]!.slotToRecord[movedSlot] = recIdx;
2255
- bucket.slots[0]!.recordToSlot[recIdx] = movedSlot;
2289
+ const movedSlot = slot.recordToSlot[lastRecIdx]!;
2290
+ slot.slotToRecord[movedSlot] = recIdx;
2291
+ slot.recordToSlot[recIdx] = movedSlot;
2256
2292
  const byteOff = recIdx * RECORD_BYTES;
2257
- if (byteOff < bucket.slots[0]!.drawTableDirtyMin) bucket.slots[0]!.drawTableDirtyMin = byteOff;
2258
- if (byteOff + RECORD_BYTES > bucket.slots[0]!.drawTableDirtyMax) bucket.slots[0]!.drawTableDirtyMax = byteOff + RECORD_BYTES;
2293
+ if (byteOff < slot.drawTableDirtyMin) slot.drawTableDirtyMin = byteOff;
2294
+ if (byteOff + RECORD_BYTES > slot.drawTableDirtyMax) slot.drawTableDirtyMax = byteOff + RECORD_BYTES;
2259
2295
  }
2260
- bucket.slots[0]!.slotToRecord[localSlot] = -1;
2261
- bucket.slots[0]!.recordToSlot[lastRecIdx] = -1;
2262
- bucket.slots[0]!.recordCount = lastRecIdx;
2263
- bucket.slots[0]!.scanDirty = true;
2296
+ slot.slotToRecord[localSlot] = -1;
2297
+ slot.recordToSlot[lastRecIdx] = -1;
2298
+ slot.recordCount = lastRecIdx;
2299
+ slot.scanDirty = true;
2264
2300
  }
2265
2301
 
2266
2302
  // Release pool entries — refcount drops; if zero, allocation freed.
@@ -2312,6 +2348,7 @@ export function buildHeapScene(
2312
2348
 
2313
2349
  drawIdToBucket[drawId] = undefined;
2314
2350
  drawIdToLocalSlot[drawId] = undefined;
2351
+ drawIdToSlotIdx[drawId] = undefined;
2315
2352
  drawIdToIndexAval[drawId] = undefined;
2316
2353
  drawIdToSpec[drawId] = undefined;
2317
2354
  const oldTracker = drawIdToModeTracker[drawId];
@@ -2429,113 +2466,88 @@ export function buildHeapScene(
2429
2466
  const dirty = [...dirtyModeKeyDrawIds];
2430
2467
  dirtyModeKeyDrawIds.clear();
2431
2468
 
2432
- // Bucket-level fast path: group dirty drawIds by bucket. If
2433
- // ALL of a bucket's ROs are dirty (typical SG case where one
2434
- // shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
2435
- // wrapping 20k leaves), the whole bucket transitions together.
2436
- // Rebuild the bucket's pipeline + rename in bucketByKey,
2437
- // O(1) work instead of N remove+add cycles.
2438
- const byBucket = new Map<Bucket, number[]>();
2439
- for (const drawId of dirty) {
2440
- const b = drawIdToBucket[drawId];
2441
- if (b === undefined) continue; // RO removed since mark
2442
- let arr = byBucket.get(b);
2443
- if (arr === undefined) { arr = []; byBucket.set(b, arr); }
2444
- arr.push(drawId);
2445
- }
2469
+ // Phase 5c.2 reslot pass. For each dirty drawId whose
2470
+ // tracker.modeKey changed:
2471
+ // 1. Find its bucket + current slot.
2472
+ // 2. ensureSlot for the new modeKey (within the same bucket).
2473
+ // 3. Move the record from oldSlot.drawTable to
2474
+ // newSlot.drawTable (swap-pop + push). Bucket-shared
2475
+ // drawHeap + pool entries stay put.
2476
+ // 4. Update drawIdToSlotIdx.
2477
+ // No bucket rename, no createRenderPipeline (pre-warm + cache
2478
+ // mean ensureSlot is a hit). No removeDraw+addDraw arena
2479
+ // churn.
2446
2480
 
2447
- // Two-phase rebucket: collect all proposed renames first, then
2448
- // commit them atomically. Avoids the "two buckets swap names"
2449
- // conflict (e.g. cullModeC flip with both 'back' and 'front'
2450
- // buckets present — A wants 'front', B wants 'back', they
2451
- // collide). After phase 1 every fast-path bucket is removed
2452
- // from bucketByKey, freeing both keys for phase 2's atomic
2453
- // re-insertion. Slow per-RO rebuckets handle anything that
2454
- // still conflicts (rare).
2455
- type Rename = {
2456
- bucket: Bucket;
2457
- repId: number;
2458
- newKey: bigint;
2459
- newBk: string;
2460
- oldBk: string;
2461
- };
2462
- const renames: Rename[] = [];
2463
- const slowChanged: number[] = []; // ROs going through per-RO
2464
-
2465
- for (const [bucket, ids] of byBucket) {
2466
- const changed: number[] = [];
2467
- for (const drawId of ids) {
2468
- const tracker = drawIdToModeTracker[drawId];
2469
- if (tracker === undefined) continue;
2470
- const oldKey = tracker.modeKey;
2471
- if (!tracker.recompute()) continue;
2472
- if (tracker.modeKey === oldKey) continue;
2473
- changed.push(drawId);
2474
- }
2475
- if (changed.length === 0) continue;
2476
-
2477
- const wholeBucket = changed.length === bucket.drawSlots.size;
2478
- if (wholeBucket) {
2479
- const repId = changed[0]!;
2480
- const repTracker = drawIdToModeTracker[repId]!;
2481
- const sample = drawIdToSpec[repId]!;
2482
- const fam = familyFor(sample.effect);
2483
- const newKey = repTracker.modeKey;
2484
- const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
2485
- let oldBk: string | undefined;
2486
- for (const [k, v] of bucketByKey) {
2487
- if (v === bucket) { oldBk = k; break; }
2488
- }
2489
- if (oldBk !== undefined && oldBk !== newBk) {
2490
- renames.push({ bucket, repId, newKey, newBk, oldBk });
2491
- continue;
2492
- }
2493
- if (oldBk === newBk) continue; // shouldn't happen but safe
2494
- }
2495
- // Partial transition: per-RO slow path.
2496
- for (const c of changed) slowChanged.push(c);
2497
- }
2498
-
2499
- if (renames.length > 0) {
2500
- // Phase 1: remove all old keys. After this, the renames' new
2501
- // keys are free unless they collide with a NON-rename
2502
- // bucket's current key.
2503
- for (const r of renames) bucketByKey.delete(r.oldBk);
2504
- // Phase 2: assign new keys, building new pipelines. If a
2505
- // collision still exists after phase 1 (against a stale
2506
- // bucket that's NOT being renamed), restore old key + fall
2507
- // back to per-RO for that bucket's records.
2508
- for (const r of renames) {
2509
- if (bucketByKey.has(r.newBk)) {
2510
- // Collision with a stale bucket. Restore + flag for slow.
2511
- bucketByKey.set(r.oldBk, r.bucket);
2512
- for (const id of r.bucket.drawSlots) slowChanged.push(id);
2513
- continue;
2514
- }
2515
- bucketByKey.set(r.newBk, r.bucket);
2516
- const tracker = drawIdToModeTracker[r.repId]!;
2517
- const sample = drawIdToSpec[r.repId]!;
2518
- const desc = tracker.descriptor;
2519
- // Cache hit: reuse an already-built pipeline. Cache miss
2520
- // builds + stashes; future flips back to this descriptor
2521
- // hit the cache.
2522
- const newPipeline = getOrCreatePipeline(
2523
- familyFor(sample.effect),
2524
- r.bucket.layout,
2525
- r.bucket.isAtlasBucket,
2526
- desc,
2527
- );
2528
- (r.bucket.slots[0] as { pipeline: GPURenderPipeline }).pipeline = newPipeline;
2481
+ for (const drawId of dirty) {
2482
+ const tracker = drawIdToModeTracker[drawId];
2483
+ if (tracker === undefined) continue;
2484
+ const oldKey = tracker.modeKey;
2485
+ if (!tracker.recompute()) continue;
2486
+ if (tracker.modeKey === oldKey) continue;
2487
+
2488
+ const bucket = drawIdToBucket[drawId];
2489
+ const localSlot = drawIdToLocalSlot[drawId];
2490
+ const oldSlotIdx = drawIdToSlotIdx[drawId];
2491
+ if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined) continue;
2492
+
2493
+ const newSlot = ensureSlot(bucket, tracker.descriptor);
2494
+ const newSlotIdx = bucket.slots.indexOf(newSlot);
2495
+ if (newSlotIdx === oldSlotIdx) continue;
2496
+
2497
+ // Swap-pop from old slot's drawTable.
2498
+ const oldSlot = bucket.slots[oldSlotIdx]!;
2499
+ const entry = bucket.localEntries[localSlot];
2500
+ const emit = entry !== undefined ? entry.indexCount * entry.instanceCount : 0;
2501
+ const oldRecIdx = oldSlot.slotToRecord[localSlot]!;
2502
+ const lastRecIdx = oldSlot.recordCount - 1;
2503
+ const oldShadow = oldSlot.drawTableShadow!;
2504
+ if (oldRecIdx !== lastRecIdx) {
2505
+ const dst = oldRecIdx * RECORD_U32;
2506
+ const src = lastRecIdx * RECORD_U32;
2507
+ oldShadow[dst + 0] = 0;
2508
+ oldShadow[dst + 1] = oldShadow[src + 1]!;
2509
+ oldShadow[dst + 2] = oldShadow[src + 2]!;
2510
+ oldShadow[dst + 3] = oldShadow[src + 3]!;
2511
+ oldShadow[dst + 4] = oldShadow[src + 4]!;
2512
+ const movedLocal = oldSlot.recordToSlot[lastRecIdx]!;
2513
+ oldSlot.slotToRecord[movedLocal] = oldRecIdx;
2514
+ oldSlot.recordToSlot[oldRecIdx] = movedLocal;
2515
+ const off = oldRecIdx * RECORD_BYTES;
2516
+ if (off < oldSlot.drawTableDirtyMin) oldSlot.drawTableDirtyMin = off;
2517
+ if (off + RECORD_BYTES > oldSlot.drawTableDirtyMax) oldSlot.drawTableDirtyMax = off + RECORD_BYTES;
2529
2518
  }
2530
- }
2531
-
2532
- // Slow per-RO rebucket for the leftover cases.
2533
- for (const drawId of slowChanged) {
2534
- const spec = drawIdToSpec[drawId];
2535
- if (spec === undefined) continue;
2536
- removeDraw(drawId);
2537
- const newId = addDrawImpl(spec, tok);
2538
- void newId;
2519
+ oldSlot.slotToRecord[localSlot] = -1;
2520
+ oldSlot.recordToSlot[lastRecIdx] = -1;
2521
+ oldSlot.recordCount = lastRecIdx;
2522
+ oldSlot.totalEmitEstimate = Math.max(0, oldSlot.totalEmitEstimate - emit);
2523
+ oldSlot.scanDirty = true;
2524
+
2525
+ // Push into new slot's drawTable.
2526
+ const newRecIdx = newSlot.recordCount;
2527
+ const dtBuf = newSlot.drawTableBuf!;
2528
+ const byteOff = newRecIdx * RECORD_BYTES;
2529
+ dtBuf.ensureCapacity(byteOff + RECORD_BYTES);
2530
+ dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
2531
+ const needBlocks = Math.max(1, Math.ceil((newRecIdx + 1) / SCAN_TILE_SIZE));
2532
+ newSlot.blockSumsBuf!.ensureCapacity(needBlocks * 4);
2533
+ newSlot.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
2534
+ const newShadow = newSlot.drawTableShadow!;
2535
+ newShadow[newRecIdx * RECORD_U32 + 0] = 0;
2536
+ newShadow[newRecIdx * RECORD_U32 + 1] = localSlot;
2537
+ newShadow[newRecIdx * RECORD_U32 + 2] = entry !== undefined ? entry.firstIndex : 0;
2538
+ newShadow[newRecIdx * RECORD_U32 + 3] = entry !== undefined ? entry.indexCount : 0;
2539
+ newShadow[newRecIdx * RECORD_U32 + 4] = entry !== undefined ? entry.instanceCount : 0;
2540
+ newSlot.recordCount = newRecIdx + 1;
2541
+ newSlot.slotToRecord[localSlot] = newRecIdx;
2542
+ newSlot.recordToSlot[newRecIdx] = localSlot;
2543
+ if (byteOff < newSlot.drawTableDirtyMin) newSlot.drawTableDirtyMin = byteOff;
2544
+ if (byteOff + RECORD_BYTES > newSlot.drawTableDirtyMax) newSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
2545
+ newSlot.totalEmitEstimate += emit;
2546
+ const newNumTiles = Math.max(1, Math.ceil(newSlot.totalEmitEstimate / TILE_K));
2547
+ newSlot.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
2548
+ newSlot.scanDirty = true;
2549
+
2550
+ drawIdToSlotIdx[drawId] = newSlotIdx;
2539
2551
  }
2540
2552
  }
2541
2553
 
@@ -2656,9 +2668,19 @@ export function buildHeapScene(
2656
2668
  function encodeIntoPass(pass: GPURenderPassEncoder): void {
2657
2669
  let curBg: GPUBindGroup | null = null;
2658
2670
  for (const b of buckets) {
2659
- if (b.bindGroup !== curBg) { pass.setBindGroup(0, b.bindGroup); curBg = b.bindGroup; }
2660
- pass.setPipeline(b.slots[0]!.pipeline);
2661
- if (b.slots[0]!.recordCount > 0) pass.drawIndirect(b.slots[0]!.indirectBuf!, 0);
2671
+ // Phase 5c.2: iterate the bucket's slots, drawIndirect per
2672
+ // non-empty slot. Empty slots draw zero (skip — saves the
2673
+ // setPipeline/setBindGroup overhead).
2674
+ for (const slot of b.slots) {
2675
+ if (slot.recordCount === 0) continue;
2676
+ const bg = slot.bindGroup;
2677
+ if (bg !== undefined && bg !== curBg) {
2678
+ pass.setBindGroup(0, bg);
2679
+ curBg = bg;
2680
+ }
2681
+ pass.setPipeline(slot.pipeline);
2682
+ if (slot.indirectBuf !== undefined) pass.drawIndirect(slot.indirectBuf, 0);
2683
+ }
2662
2684
  }
2663
2685
  }
2664
2686
 
@@ -2734,38 +2756,42 @@ export function buildHeapScene(
2734
2756
  stats.derivedRecords = derivedScene.records.recordCount;
2735
2757
  }
2736
2758
  let anyDirty = false;
2737
- for (const b of buckets) { if (b.slots[0]!.scanDirty) { anyDirty = true; break; } }
2759
+ outer: for (const b of buckets) {
2760
+ for (const s of b.slots) {
2761
+ if (s.scanDirty) { anyDirty = true; break outer; }
2762
+ }
2763
+ }
2738
2764
  if (!anyDirty) return;
2739
2765
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
2740
2766
  for (const b of buckets) {
2741
- if (!b.slots[0]!.scanDirty) continue;
2742
- // Rebuild render bind group if recordCount changed — its
2743
- // drawTable binding is sized to recordCount * 16.
2744
- if (b.slots[0]!.renderBoundRecordCount !== b.slots[0]!.recordCount) {
2745
- b.bindGroup = buildBucketBindGroup(b);
2767
+ for (let i = 0; i < b.slots.length; i++) {
2768
+ const slot = b.slots[i]!;
2769
+ if (!slot.scanDirty) continue;
2770
+ // Rebuild render bind group if recordCount changed — drawTable
2771
+ // binding is sized to recordCount * RECORD_BYTES.
2772
+ if (slot.renderBoundRecordCount !== slot.recordCount) {
2773
+ slot.bindGroup = buildBucketBindGroup(b, i);
2774
+ }
2775
+ const numRecords = slot.recordCount;
2776
+ const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2777
+ device.queue.writeBuffer(
2778
+ slot.paramsBuf!, 0,
2779
+ new Uint32Array([numRecords, numBlocks, 0, 0]),
2780
+ );
2781
+ pass.setBindGroup(0, slot.scanBindGroup!);
2782
+ pass.setPipeline(scanPipeTile!);
2783
+ pass.dispatchWorkgroups(numBlocks, 1, 1);
2784
+ pass.setPipeline(scanPipeBlocks!);
2785
+ pass.dispatchWorkgroups(1, 1, 1);
2786
+ pass.setPipeline(scanPipeAdd!);
2787
+ pass.dispatchWorkgroups(numBlocks, 1, 1);
2788
+ const SCAN_WG_SIZE = 256;
2789
+ const numTilesCap = Math.max(1, Math.ceil(slot.totalEmitEstimate / TILE_K));
2790
+ const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
2791
+ pass.setPipeline(scanPipeBuildTileIndex!);
2792
+ pass.dispatchWorkgroups(tileWgs, 1, 1);
2793
+ slot.scanDirty = false;
2746
2794
  }
2747
- const numRecords = b.slots[0]!.recordCount;
2748
- const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2749
- device.queue.writeBuffer(
2750
- b.slots[0]!.paramsBuf!, 0,
2751
- new Uint32Array([numRecords, numBlocks, 0, 0]),
2752
- );
2753
- pass.setBindGroup(0, b.slots[0]!.scanBindGroup!);
2754
- pass.setPipeline(scanPipeTile!);
2755
- pass.dispatchWorkgroups(numBlocks, 1, 1);
2756
- pass.setPipeline(scanPipeBlocks!);
2757
- pass.dispatchWorkgroups(1, 1, 1);
2758
- pass.setPipeline(scanPipeAdd!);
2759
- pass.dispatchWorkgroups(numBlocks, 1, 1);
2760
- // numTiles is computed on GPU from indirect[0]; CPU dispatch
2761
- // must cover the worst-case totalEmit. Each WG handles WG_SIZE
2762
- // tiles; +1 for the sentinel slot.
2763
- const SCAN_WG_SIZE = 256;
2764
- const numTilesCap = Math.max(1, Math.ceil(b.slots[0]!.totalEmitEstimate / TILE_K));
2765
- const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
2766
- pass.setPipeline(scanPipeBuildTileIndex!);
2767
- pass.dispatchWorkgroups(tileWgs, 1, 1);
2768
- b.slots[0]!.scanDirty = false;
2769
2795
  }
2770
2796
  pass.end();
2771
2797
  }