@aardworx/wombat.rendering 0.9.27 → 0.9.29

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.
@@ -88,6 +88,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
88
88
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
89
89
  const drawIdToBucket = [];
90
90
  const drawIdToLocalSlot = [];
91
+ /** Per-draw slot index within its bucket. Phase 5c.2: each bucket
92
+ * holds multiple BucketSlots (one per realized modeKey); this map
93
+ * tells each removeDraw / record write which slot to touch. */
94
+ const drawIdToSlotIdx = [];
91
95
  /** Per-draw index aval — for `indexPool.release` on removeDraw. */
92
96
  const drawIdToIndexAval = [];
93
97
  /**
@@ -519,19 +523,20 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
519
523
  compute: { module: scanModule, entryPoint: "buildTileIndex" },
520
524
  });
521
525
  }
522
- function buildScanBindGroup(bucket) {
526
+ function buildScanBindGroup(bucket, slotIdx = 0) {
523
527
  if (scanBgl === undefined)
524
528
  throw new Error("heapScene: scan BGL missing");
529
+ const s = bucket.slots[slotIdx];
525
530
  return device.createBindGroup({
526
- label: `heapScene/${bucket.label}/scanBg`,
531
+ label: `heapScene/${bucket.label}/slot${slotIdx}/scanBg`,
527
532
  layout: scanBgl,
528
533
  entries: [
529
- { binding: 0, resource: { buffer: bucket.slots[0].drawTableBuf.buffer } },
530
- { binding: 1, resource: { buffer: bucket.slots[0].blockSumsBuf.buffer } },
531
- { binding: 2, resource: { buffer: bucket.slots[0].blockOffsetsBuf.buffer } },
532
- { binding: 3, resource: { buffer: bucket.slots[0].indirectBuf } },
533
- { binding: 4, resource: { buffer: bucket.slots[0].paramsBuf } },
534
- { binding: 5, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } },
534
+ { binding: 0, resource: { buffer: s.drawTableBuf.buffer } },
535
+ { binding: 1, resource: { buffer: s.blockSumsBuf.buffer } },
536
+ { binding: 2, resource: { buffer: s.blockOffsetsBuf.buffer } },
537
+ { binding: 3, resource: { buffer: s.indirectBuf } },
538
+ { binding: 4, resource: { buffer: s.paramsBuf } },
539
+ { binding: 5, resource: { buffer: s.firstDrawInTileBuf.buffer } },
535
540
  ],
536
541
  });
537
542
  }
@@ -797,7 +802,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
797
802
  // pipelines via noTexBgl/texBgl; the bind-group itself binds this
798
803
  // bucket's drawHeap (binding 1) + its globals UBO (binding 0) +
799
804
  // the global arena's heapF32 view (binding 2) + textures if any.
800
- function buildBucketBindGroup(bucket) {
805
+ function buildBucketBindGroup(bucket, slotIdx = 0) {
801
806
  // heapU32 / heapF32 / heapV4f are different typed views of the
802
807
  // SAME global arena GPUBuffer (emscripten-style aliasing). The
803
808
  // WGSL prelude declares one binding per view; the shader picks
@@ -809,7 +814,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
809
814
  { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
810
815
  ];
811
816
  {
812
- if (bucket.slots[0].drawTableBuf === undefined) {
817
+ const s = bucket.slots[slotIdx];
818
+ if (s.drawTableBuf === undefined) {
813
819
  throw new Error("heapScene: megacall bucket without drawTableBuf");
814
820
  }
815
821
  // Bind drawTable with size = recordCount * RECORD_BYTES so the VS
@@ -817,9 +823,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
817
823
  // the live record count — keeps stale tail entries out of the
818
824
  // binary search. Minimum one zero-record to satisfy WebGPU non-
819
825
  // zero size constraint when the bucket is empty.
820
- const dtBytes = Math.max(RECORD_BYTES, bucket.slots[0].recordCount * RECORD_BYTES);
821
- entries.push({ binding: 4, resource: { buffer: bucket.slots[0].drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: bucket.slots[0].firstDrawInTileBuf.buffer } });
822
- bucket.slots[0].renderBoundRecordCount = bucket.slots[0].recordCount;
826
+ const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
827
+ entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
828
+ s.renderBoundRecordCount = s.recordCount;
823
829
  }
824
830
  // Schema-driven texture + sampler entries. v1 user surface still
825
831
  // accepts a single (texture, sampler) pair via HeapTextureSet —
@@ -902,36 +908,29 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
902
908
  // `atlasPool.pagesFor(format)` when building bind groups. Buckets
903
909
  // carry strong refcount handles (`localAtlasReleases`) that drive
904
910
  // `pool.release` on removeDraw.
905
- // When the global arena reallocates, every bucket's bind group
906
- // needs rebuilding (its binding 2 buffer reference is stale).
911
+ /** Rebuild every slot's render bind group across every bucket.
912
+ * Used when a global resource (arena.attrs, arena.indices, atlas
913
+ * page set) reallocates and invalidates every bind group at once. */
914
+ function rebuildAllBindGroups(filterAtlasOnly = false) {
915
+ for (const b of buckets) {
916
+ if (filterAtlasOnly && !b.isAtlasBucket)
917
+ continue;
918
+ for (let i = 0; i < b.slots.length; i++) {
919
+ b.slots[i].bindGroup = buildBucketBindGroup(b, i);
920
+ }
921
+ }
922
+ }
907
923
  arena.attrs.onResize(() => {
908
- for (const b of buckets)
909
- b.bindGroup = buildBucketBindGroup(b);
910
- // §7 dispatcher targets arena.attrs.buffer; rebind on grow.
924
+ rebuildAllBindGroups();
911
925
  if (derivedScene !== undefined)
912
926
  derivedScene.rebindMainHeap(arena.attrs.buffer);
913
927
  });
914
- // Same when the atlas pool allocates a fresh page — its slot in the
915
- // BGL ladder transitions from placeholder to the real GPUTexture.
916
- // Every atlas bucket rebuilds (cheap; just N+N+1 entries each).
917
928
  if (atlasPool !== undefined) {
918
929
  for (const f of ATLAS_PAGE_FORMATS) {
919
- atlasPool.onPageAdded(f, () => {
920
- for (const b of buckets) {
921
- if (b.isAtlasBucket)
922
- b.bindGroup = buildBucketBindGroup(b);
923
- }
924
- });
930
+ atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
925
931
  }
926
932
  }
927
- // indexStorage is bound at slot 5 from arena.indices, so a grow there
928
- // also invalidates every bucket's bind group.
929
- {
930
- arena.indices.onResize(() => {
931
- for (const b of buckets)
932
- b.bindGroup = buildBucketBindGroup(b);
933
- });
934
- }
933
+ arena.indices.onResize(() => rebuildAllBindGroups());
935
934
  // ─── findOrCreateBucket ───────────────────────────────────────────
936
935
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
937
936
  // Every member effect in the family shares one bucket per
@@ -939,6 +938,75 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
939
938
  // right per-effect helper at draw time. Atlas-binding shape follows
940
939
  // from `family.drawHeaderUnion.atlasTextureBindings.size > 0`.
941
940
  void texIdOf; // retained for future per-bucket diagnostics
941
+ /**
942
+ * Allocate a brand-new BucketSlot inside `bucket` covering
943
+ * `descriptor` (modeKey = encode(descriptor)). Builds the per-slot
944
+ * GPU resources: pipeline (via cache), drawTable + scan buffers +
945
+ * indirect + paramsBuf, and the slot's render/scan bind groups
946
+ * (the latter rebuilt on resize). The bucket's drawHeap +
947
+ * texture bindings are shared across slots.
948
+ */
949
+ function createSlot(bucket, descriptor) {
950
+ const fam = familyForBucket.get(bucket);
951
+ const pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, descriptor);
952
+ const modeKey = encodeModeKey(descriptor);
953
+ const slotIdx = bucket.slots.length;
954
+ const slotLabel = `${bucket.label}/slot${slotIdx}`;
955
+ const dtBuf = new GrowBuffer(device, `heapScene/${slotLabel}/drawTable`, GPUBufferUsage.STORAGE, 1024);
956
+ const blockSumsBuf = new GrowBuffer(device, `heapScene/${slotLabel}/blockSums`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
957
+ const blockOffsetsBuf = new GrowBuffer(device, `heapScene/${slotLabel}/blockOffsets`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
958
+ const firstDrawInTileBuf = new GrowBuffer(device, `heapScene/${slotLabel}/firstDrawInTile`, GPUBufferUsage.STORAGE, 128);
959
+ const indirectBuf = device.createBuffer({
960
+ label: `heapScene/${slotLabel}/indirect`, size: 16,
961
+ usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
962
+ });
963
+ device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
964
+ const paramsBuf = device.createBuffer({
965
+ label: `heapScene/${slotLabel}/params`, size: 16,
966
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
967
+ });
968
+ const slot = {
969
+ pipeline, modeKey,
970
+ drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
971
+ recordCount: 0, slotToRecord: [], recordToSlot: [],
972
+ totalEmitEstimate: 0, scanDirty: false,
973
+ drawTableBuf: dtBuf,
974
+ drawTableShadow: new Uint32Array(dtBuf.capacity / 4),
975
+ blockSumsBuf, blockOffsetsBuf, firstDrawInTileBuf,
976
+ indirectBuf, paramsBuf,
977
+ };
978
+ bucket.slots.push(slot);
979
+ const thisSlotIdx = bucket.slots.length - 1;
980
+ const ensureScanBuffers = () => {
981
+ const needBlocks = Math.max(1, Math.ceil(slot.recordCount / SCAN_TILE_SIZE));
982
+ blockSumsBuf.ensureCapacity(needBlocks * 4);
983
+ blockOffsetsBuf.ensureCapacity(needBlocks * 4);
984
+ };
985
+ const rebuildScanBg = () => {
986
+ slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
987
+ };
988
+ dtBuf.onResize(() => {
989
+ const grown = new Uint32Array(dtBuf.capacity / 4);
990
+ grown.set(slot.drawTableShadow);
991
+ slot.drawTableShadow = grown;
992
+ ensureScanBuffers();
993
+ slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
994
+ rebuildScanBg();
995
+ slot.drawTableDirtyMin = 0;
996
+ slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
997
+ });
998
+ blockSumsBuf.onResize(rebuildScanBg);
999
+ blockOffsetsBuf.onResize(rebuildScanBg);
1000
+ firstDrawInTileBuf.onResize(() => {
1001
+ rebuildScanBg();
1002
+ slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
1003
+ });
1004
+ slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
1005
+ slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
1006
+ return slot;
1007
+ }
1008
+ /** Side map from Bucket → family. Populated by findOrCreateBucket. */
1009
+ const familyForBucket = new WeakMap();
942
1010
  function findOrCreateBucket(effect, _textures, pipelineState,
943
1011
  /**
944
1012
  * Optional precomputed descriptor. addDrawImpl passes this when
@@ -951,62 +1019,23 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
951
1019
  throw new Error("heapScene: findOrCreateBucket called before family build");
952
1020
  }
953
1021
  const fam = familyFor(effect);
954
- // ─── PS keying VALUE based, not identity based ───────────────
955
- // psIdOf used to hash by aval identity. That meant 20k ROs each
956
- // constructed with `cval("back")` for cullMode hashed to 20k
957
- // distinct keys 20k buckets, all rendering with bitwise-
958
- // identical state. Now the key is the bitfield-encoded modeKey
959
- // (the actual value-set in the descriptor), so identical-value
960
- // cvals collapse to ONE bucket. See docs/derived-modes.md and
961
- // tests/heap-multi-pipeline-bucket.test.ts.
962
- const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
963
- const psModeKey = encodeModeKey(psDescriptor);
964
- const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
1022
+ // Phase 5c.2: bucket key drops modeKey. All ROs with the same
1023
+ // (effect, textureSet) share one bucket; per-modeKey slots live
1024
+ // inside the bucket and route records via `ensureSlot`. Pipeline
1025
+ // pre-warm + the scene-level pipelineByModeKey cache mean every
1026
+ // realized slot lookup hits the cache.
1027
+ const bk = `family#${fam.schema.id}`;
965
1028
  const existing = bucketByKey.get(bk);
966
1029
  if (existing !== undefined)
967
1030
  return existing;
968
- // If a precomputed descriptor is supplied (rule-evaluated path),
969
- // use its post-rule axis values so the pipeline reflects what the
970
- // RO actually wants. Otherwise fall through to PS-aval forcing.
971
- const ps = precomputedDescriptor !== undefined
972
- ? {
973
- topology: precomputedDescriptor.topology,
974
- cullMode: precomputedDescriptor.cullMode,
975
- frontFace: precomputedDescriptor.frontFace,
976
- ...(precomputedDescriptor.depth !== undefined
977
- ? { depth: { write: precomputedDescriptor.depth.write, compare: precomputedDescriptor.depth.compare } }
978
- : {}),
979
- }
980
- : resolvePipelineState(pipelineState);
981
1031
  const layout = fam.schema.drawHeaderUnion;
982
1032
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
983
- // Pipeline lookup-or-create through the scene-level cache so
984
- // subsequent buckets / fast-path renames with the same descriptor
985
- // get the same GPU pipeline object (no duplicate
986
- // createRenderPipeline calls).
987
- const descForPipeline = precomputedDescriptor ?? psDescriptor;
988
- const pipeline = getOrCreatePipeline(fam, layout, isAtlasBucket, descForPipeline);
989
- // Per-bucket DrawHeader buffer (every uniform / attribute is a u32
990
- // ref into the global arena; the per-bucket buffer just holds the
991
- // refs).
992
1033
  const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
993
1034
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
994
- const slot0 = {
995
- pipeline,
996
- drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
997
- recordCount: 0, slotToRecord: [], recordToSlot: [],
998
- totalEmitEstimate: 0,
999
- scanDirty: false,
1000
- };
1001
1035
  const bucket = {
1002
- // §6 family-merge: family buckets aren't keyed on a specific
1003
- // texture set — atlas placements are addressed per-RO via
1004
- // drawHeader fields (`pageRef` + `formatBits` + `origin` +
1005
- // `size`), and the bucket's atlas-binding ladder is driven by
1006
- // `atlasPool.pagesFor(format)`. Leave `textures` undefined.
1007
1036
  label: bk, textures: undefined, layout,
1008
- slots: [slot0],
1009
- bindGroup: null,
1037
+ slots: [], // populated lazily by ensureSlot per modeKey
1038
+ bindGroup: null, // legacy field; slot.bindGroup is the real one
1010
1039
  drawHeap,
1011
1040
  drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
1012
1041
  headerDirtyMin: Infinity, headerDirtyMax: 0,
@@ -1019,76 +1048,47 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1019
1048
  localAtlasTextures: [],
1020
1049
  localAtlasArrIdx: [],
1021
1050
  };
1022
- {
1023
- const dtBuf = new GrowBuffer(device, `heapScene/${bk}/drawTable`, GPUBufferUsage.STORAGE, 1024);
1024
- const blockSumsBuf = new GrowBuffer(device, `heapScene/${bk}/blockSums`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
1025
- const blockOffsetsBuf = new GrowBuffer(device, `heapScene/${bk}/blockOffsets`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
1026
- // 32 u32 = 128 bytes is the floor; pow2-grown by ensureCapacity
1027
- // as totalEmitEstimate grows.
1028
- const firstDrawInTileBuf = new GrowBuffer(device, `heapScene/${bk}/firstDrawInTile`, GPUBufferUsage.STORAGE, 128);
1029
- const indirectBuf = device.createBuffer({
1030
- label: `heapScene/${bk}/indirect`, size: 16,
1031
- usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
1032
- });
1033
- // Initialize to (0, 1, 0, 0) so an unscanned/empty bucket draws
1034
- // nothing safely.
1035
- device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
1036
- const paramsBuf = device.createBuffer({
1037
- label: `heapScene/${bk}/params`, size: 16,
1038
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1039
- });
1040
- bucket.slots[0].drawTableBuf = dtBuf;
1041
- bucket.slots[0].drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
1042
- bucket.slots[0].blockSumsBuf = blockSumsBuf;
1043
- bucket.slots[0].blockOffsetsBuf = blockOffsetsBuf;
1044
- bucket.slots[0].firstDrawInTileBuf = firstDrawInTileBuf;
1045
- bucket.slots[0].indirectBuf = indirectBuf;
1046
- bucket.slots[0].paramsBuf = paramsBuf;
1047
- const ensureScanBuffers = () => {
1048
- const needBlocks = Math.max(1, Math.ceil(bucket.slots[0].recordCount / SCAN_TILE_SIZE));
1049
- blockSumsBuf.ensureCapacity(needBlocks * 4);
1050
- blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1051
- };
1052
- const rebuildScanBg = () => {
1053
- bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
1054
- };
1055
- dtBuf.onResize(() => {
1056
- const grown = new Uint32Array(dtBuf.capacity / 4);
1057
- grown.set(bucket.slots[0].drawTableShadow);
1058
- bucket.slots[0].drawTableShadow = grown;
1059
- ensureScanBuffers();
1060
- bucket.bindGroup = buildBucketBindGroup(bucket);
1061
- rebuildScanBg();
1062
- bucket.slots[0].drawTableDirtyMin = 0;
1063
- bucket.slots[0].drawTableDirtyMax = bucket.slots[0].recordCount * RECORD_BYTES;
1064
- });
1065
- blockSumsBuf.onResize(rebuildScanBg);
1066
- blockOffsetsBuf.onResize(rebuildScanBg);
1067
- firstDrawInTileBuf.onResize(() => {
1068
- rebuildScanBg();
1069
- bucket.bindGroup = buildBucketBindGroup(bucket);
1070
- });
1071
- bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
1072
- }
1073
- bucket.bindGroup = buildBucketBindGroup(bucket);
1051
+ familyForBucket.set(bucket, fam);
1074
1052
  drawHeap.onResize(() => {
1075
1053
  bucket.drawHeaderStaging = new Float32Array(drawHeapBuf.capacity / 4);
1076
- // GPU-side data was copied by the GrowBuffer's resize, but our
1077
- // CPU mirror is fresh — mark all live local slots dirty so
1078
- // their headers get re-packed and re-uploaded next frame.
1079
1054
  for (const s of bucket.drawSlots)
1080
1055
  bucket.dirty.add(s);
1081
- bucket.bindGroup = buildBucketBindGroup(bucket);
1082
- // §7's main heap is arena.attrs.buffer (not drawHeap) — the
1083
- // arena.attrs.onResize handler at the scene level handles its
1084
- // rebind. drawHeap resize doesn't touch §7.
1056
+ // Rebuild every slot's render bind group (binding 1 = drawHeap
1057
+ // points at the new buffer).
1058
+ for (let i = 0; i < bucket.slots.length; i++) {
1059
+ bucket.slots[i].bindGroup = buildBucketBindGroup(bucket, i);
1060
+ }
1085
1061
  });
1086
- // §7 has one scene-wide records buffer + dispatcher (all buckets
1087
- // target arena.attrs.buffer), so nothing per-bucket to set up here.
1062
+ // Ensure the bucket has a slot for the supplied descriptor at
1063
+ // bucket-creation time we eagerly seed slot 0 from the caller's
1064
+ // descriptor so the first addDraw doesn't pay a slot-create cost.
1065
+ const seedDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
1066
+ createSlot(bucket, seedDescriptor);
1088
1067
  buckets.push(bucket);
1089
1068
  bucketByKey.set(bk, bucket);
1090
1069
  return bucket;
1091
1070
  }
1071
+ /**
1072
+ * Return the existing BucketSlot for `descriptor` inside `bucket`,
1073
+ * or create one on miss. The mode-flip hot path calls this when an
1074
+ * RO's tracker.modeKey lands on a slot that doesn't exist yet.
1075
+ */
1076
+ function ensureSlot(bucket, descriptor) {
1077
+ const wanted = encodeModeKey(descriptor);
1078
+ for (const s of bucket.slots) {
1079
+ if (s.modeKey === wanted)
1080
+ return s;
1081
+ }
1082
+ return createSlot(bucket, descriptor);
1083
+ }
1084
+ /** Find an RO's slot by drawId; null if the drawId is dead. */
1085
+ function slotForDrawId(drawId) {
1086
+ const idx = drawIdToSlotIdx[drawId];
1087
+ const bucket = drawIdToBucket[drawId];
1088
+ if (idx === undefined || bucket === undefined)
1089
+ return null;
1090
+ return bucket.slots[idx] ?? null;
1091
+ }
1092
1092
  // ─── Per-bucket header writer (refs only, post step 3) ────────────
1093
1093
  // The DrawHeader is now a flat list of u32 refs into the arena —
1094
1094
  // values themselves live in arena allocations the pool manages.
@@ -1201,6 +1201,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1201
1201
  // ─── Stats (declared early so addDraw/removeDraw can mutate it) ───
1202
1202
  const stats = {
1203
1203
  groups: 0,
1204
+ slotCount: 0,
1204
1205
  totalDraws: 0,
1205
1206
  drawBytes: 0,
1206
1207
  geometryBytes: 0,
@@ -1210,6 +1211,15 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1210
1211
  derivedRecords: 0,
1211
1212
  };
1212
1213
  Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
1214
+ Object.defineProperty(stats, "slotCount", {
1215
+ get: () => {
1216
+ let n = 0;
1217
+ for (const b of buckets)
1218
+ n += b.slots.length;
1219
+ return n;
1220
+ },
1221
+ configurable: true,
1222
+ });
1213
1223
  // ─── addDraw / removeDraw ─────────────────────────────────────────
1214
1224
  // Public addDraw wrapper. Establishes a sceneObj.evaluateAlways
1215
1225
  // scope so external callers (no batched outer eval) still register
@@ -1258,6 +1268,15 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1258
1268
  });
1259
1269
  }
1260
1270
  const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
1271
+ // Phase 5c.2: route this RO to the bucket's slot covering its
1272
+ // current descriptor. ensureSlot creates a new slot on miss; the
1273
+ // pipeline lookup hits the scene-level cache.
1274
+ const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
1275
+ const roSlot = ensureSlot(bucket, roDescriptor);
1276
+ const roSlotIdx = bucket.slots.indexOf(roSlot);
1277
+ if (spec.modeRules !== undefined && drawId < 8) {
1278
+ console.debug(`[heapScene] addDraw drawId=${drawId} bucket=${bucket.label} slotIdx=${roSlotIdx} cullMode=${roDescriptor.cullMode}`);
1279
+ }
1261
1280
  const fam = familyFor(spec.effect);
1262
1281
  const effectFields = fam.fieldsForEffect.get(spec.effect.id);
1263
1282
  // Indices live in their own INDEX-usage buffer (WebGPU constraint).
@@ -1384,8 +1403,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1384
1403
  if (end > bucket.headerDirtyMax)
1385
1404
  bucket.headerDirtyMax = end;
1386
1405
  {
1387
- const dtBuf = bucket.slots[0].drawTableBuf;
1388
- const recIdx = bucket.slots[0].recordCount;
1406
+ const dtBuf = roSlot.drawTableBuf;
1407
+ const recIdx = roSlot.recordCount;
1389
1408
  if (recIdx >= SCAN_MAX_RECORDS) {
1390
1409
  throw new Error(`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
1391
1410
  `extend the scan to multi-level if you need more`);
@@ -1395,29 +1414,30 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1395
1414
  dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
1396
1415
  // Grow scan-side buffers if recordCount crosses a tile boundary.
1397
1416
  const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
1398
- bucket.slots[0].blockSumsBuf.ensureCapacity(needBlocks * 4);
1399
- bucket.slots[0].blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1400
- const shadow = bucket.slots[0].drawTableShadow;
1417
+ roSlot.blockSumsBuf.ensureCapacity(needBlocks * 4);
1418
+ roSlot.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1419
+ const shadow = roSlot.drawTableShadow;
1401
1420
  // firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
1402
1421
  shadow[recIdx * RECORD_U32 + 0] = 0;
1403
1422
  shadow[recIdx * RECORD_U32 + 1] = localSlot;
1404
1423
  shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
1405
1424
  shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
1406
1425
  shadow[recIdx * RECORD_U32 + 4] = instanceCount;
1407
- bucket.slots[0].recordCount = recIdx + 1;
1408
- bucket.slots[0].slotToRecord[localSlot] = recIdx;
1409
- bucket.slots[0].recordToSlot[recIdx] = localSlot;
1410
- if (byteOff < bucket.slots[0].drawTableDirtyMin)
1411
- bucket.slots[0].drawTableDirtyMin = byteOff;
1412
- if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
1413
- bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
1414
- bucket.slots[0].totalEmitEstimate += idxAlloc.count * instanceCount;
1415
- const newNumTiles = Math.max(1, Math.ceil(bucket.slots[0].totalEmitEstimate / TILE_K));
1416
- bucket.slots[0].firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
1417
- bucket.slots[0].scanDirty = true;
1426
+ roSlot.recordCount = recIdx + 1;
1427
+ roSlot.slotToRecord[localSlot] = recIdx;
1428
+ roSlot.recordToSlot[recIdx] = localSlot;
1429
+ if (byteOff < roSlot.drawTableDirtyMin)
1430
+ roSlot.drawTableDirtyMin = byteOff;
1431
+ if (byteOff + RECORD_BYTES > roSlot.drawTableDirtyMax)
1432
+ roSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
1433
+ roSlot.totalEmitEstimate += idxAlloc.count * instanceCount;
1434
+ const newNumTiles = Math.max(1, Math.ceil(roSlot.totalEmitEstimate / TILE_K));
1435
+ roSlot.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
1436
+ roSlot.scanDirty = true;
1418
1437
  }
1419
1438
  drawIdToBucket[drawId] = bucket;
1420
1439
  drawIdToLocalSlot[drawId] = localSlot;
1440
+ drawIdToSlotIdx[drawId] = roSlotIdx;
1421
1441
  drawIdToIndexAval[drawId] = indicesAval;
1422
1442
  // ─── Reactive rebucket: track PS-modeKey changes ────────────────
1423
1443
  // When any mode-axis aval marks (e.g. cullCval.value = 'front'),
@@ -1565,17 +1585,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1565
1585
  }
1566
1586
  }
1567
1587
  {
1588
+ const slotIdx = drawIdToSlotIdx[drawId];
1589
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
1568
1590
  const removedEntry = bucket.localEntries[localSlot];
1569
1591
  const removedCount = removedEntry !== undefined
1570
1592
  ? removedEntry.indexCount * removedEntry.instanceCount
1571
1593
  : 0;
1572
- bucket.slots[0].totalEmitEstimate = Math.max(0, bucket.slots[0].totalEmitEstimate - removedCount);
1594
+ slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate - removedCount);
1573
1595
  // Swap-pop: move the last record into the freed slot, decrement
1574
1596
  // recordCount. firstEmit is GPU-rewritten by the next scan, so
1575
1597
  // we only fix (drawIdx, indexStart, indexCount, instanceCount).
1576
- const recIdx = bucket.slots[0].slotToRecord[localSlot];
1577
- const lastRecIdx = bucket.slots[0].recordCount - 1;
1578
- const shadow = bucket.slots[0].drawTableShadow;
1598
+ const recIdx = slot.slotToRecord[localSlot];
1599
+ const lastRecIdx = slot.recordCount - 1;
1600
+ const shadow = slot.drawTableShadow;
1579
1601
  if (recIdx !== lastRecIdx) {
1580
1602
  const dst = recIdx * RECORD_U32;
1581
1603
  const src = lastRecIdx * RECORD_U32;
@@ -1584,19 +1606,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1584
1606
  shadow[dst + 2] = shadow[src + 2];
1585
1607
  shadow[dst + 3] = shadow[src + 3];
1586
1608
  shadow[dst + 4] = shadow[src + 4];
1587
- const movedSlot = bucket.slots[0].recordToSlot[lastRecIdx];
1588
- bucket.slots[0].slotToRecord[movedSlot] = recIdx;
1589
- bucket.slots[0].recordToSlot[recIdx] = movedSlot;
1609
+ const movedSlot = slot.recordToSlot[lastRecIdx];
1610
+ slot.slotToRecord[movedSlot] = recIdx;
1611
+ slot.recordToSlot[recIdx] = movedSlot;
1590
1612
  const byteOff = recIdx * RECORD_BYTES;
1591
- if (byteOff < bucket.slots[0].drawTableDirtyMin)
1592
- bucket.slots[0].drawTableDirtyMin = byteOff;
1593
- if (byteOff + RECORD_BYTES > bucket.slots[0].drawTableDirtyMax)
1594
- bucket.slots[0].drawTableDirtyMax = byteOff + RECORD_BYTES;
1595
- }
1596
- bucket.slots[0].slotToRecord[localSlot] = -1;
1597
- bucket.slots[0].recordToSlot[lastRecIdx] = -1;
1598
- bucket.slots[0].recordCount = lastRecIdx;
1599
- bucket.slots[0].scanDirty = true;
1613
+ if (byteOff < slot.drawTableDirtyMin)
1614
+ slot.drawTableDirtyMin = byteOff;
1615
+ if (byteOff + RECORD_BYTES > slot.drawTableDirtyMax)
1616
+ slot.drawTableDirtyMax = byteOff + RECORD_BYTES;
1617
+ }
1618
+ slot.slotToRecord[localSlot] = -1;
1619
+ slot.recordToSlot[lastRecIdx] = -1;
1620
+ slot.recordCount = lastRecIdx;
1621
+ slot.scanDirty = true;
1600
1622
  }
1601
1623
  // Release pool entries — refcount drops; if zero, allocation freed.
1602
1624
  const avals = bucket.localPerDrawAvals[localSlot];
@@ -1649,6 +1671,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1649
1671
  bucket.drawHeap.release(localSlot);
1650
1672
  drawIdToBucket[drawId] = undefined;
1651
1673
  drawIdToLocalSlot[drawId] = undefined;
1674
+ drawIdToSlotIdx[drawId] = undefined;
1652
1675
  drawIdToIndexAval[drawId] = undefined;
1653
1676
  drawIdToSpec[drawId] = undefined;
1654
1677
  const oldTracker = drawIdToModeTracker[drawId];
@@ -1768,104 +1791,91 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1768
1791
  if (dirtyModeKeyDrawIds.size > 0) {
1769
1792
  const dirty = [...dirtyModeKeyDrawIds];
1770
1793
  dirtyModeKeyDrawIds.clear();
1771
- // Bucket-level fast path: group dirty drawIds by bucket. If
1772
- // ALL of a bucket's ROs are dirty (typical SG case where one
1773
- // shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
1774
- // wrapping 20k leaves), the whole bucket transitions together.
1775
- // Rebuild the bucket's pipeline + rename in bucketByKey,
1776
- // O(1) work instead of N remove+add cycles.
1777
- const byBucket = new Map();
1794
+ // Phase 5c.2 reslot pass. For each dirty drawId whose
1795
+ // tracker.modeKey changed:
1796
+ // 1. Find its bucket + current slot.
1797
+ // 2. ensureSlot for the new modeKey (within the same bucket).
1798
+ // 3. Move the record from oldSlot.drawTable to
1799
+ // newSlot.drawTable (swap-pop + push). Bucket-shared
1800
+ // drawHeap + pool entries stay put.
1801
+ // 4. Update drawIdToSlotIdx.
1802
+ // No bucket rename, no createRenderPipeline (pre-warm + cache
1803
+ // mean ensureSlot is a hit). No removeDraw+addDraw arena
1804
+ // churn.
1778
1805
  for (const drawId of dirty) {
1779
- const b = drawIdToBucket[drawId];
1780
- if (b === undefined)
1781
- continue; // RO removed since mark
1782
- let arr = byBucket.get(b);
1783
- if (arr === undefined) {
1784
- arr = [];
1785
- byBucket.set(b, arr);
1786
- }
1787
- arr.push(drawId);
1788
- }
1789
- const renames = [];
1790
- const slowChanged = []; // ROs going through per-RO
1791
- for (const [bucket, ids] of byBucket) {
1792
- const changed = [];
1793
- for (const drawId of ids) {
1794
- const tracker = drawIdToModeTracker[drawId];
1795
- if (tracker === undefined)
1796
- continue;
1797
- const oldKey = tracker.modeKey;
1798
- if (!tracker.recompute())
1799
- continue;
1800
- if (tracker.modeKey === oldKey)
1801
- continue;
1802
- changed.push(drawId);
1803
- }
1804
- if (changed.length === 0)
1806
+ const tracker = drawIdToModeTracker[drawId];
1807
+ if (tracker === undefined)
1805
1808
  continue;
1806
- const wholeBucket = changed.length === bucket.drawSlots.size;
1807
- if (wholeBucket) {
1808
- const repId = changed[0];
1809
- const repTracker = drawIdToModeTracker[repId];
1810
- const sample = drawIdToSpec[repId];
1811
- const fam = familyFor(sample.effect);
1812
- const newKey = repTracker.modeKey;
1813
- const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
1814
- let oldBk;
1815
- for (const [k, v] of bucketByKey) {
1816
- if (v === bucket) {
1817
- oldBk = k;
1818
- break;
1819
- }
1820
- }
1821
- if (oldBk !== undefined && oldBk !== newBk) {
1822
- renames.push({ bucket, repId, newKey, newBk, oldBk });
1823
- continue;
1824
- }
1825
- if (oldBk === newBk)
1826
- continue; // shouldn't happen but safe
1827
- }
1828
- // Partial transition: per-RO slow path.
1829
- for (const c of changed)
1830
- slowChanged.push(c);
1831
- }
1832
- if (renames.length > 0) {
1833
- // Phase 1: remove all old keys. After this, the renames' new
1834
- // keys are free unless they collide with a NON-rename
1835
- // bucket's current key.
1836
- for (const r of renames)
1837
- bucketByKey.delete(r.oldBk);
1838
- // Phase 2: assign new keys, building new pipelines. If a
1839
- // collision still exists after phase 1 (against a stale
1840
- // bucket that's NOT being renamed), restore old key + fall
1841
- // back to per-RO for that bucket's records.
1842
- for (const r of renames) {
1843
- if (bucketByKey.has(r.newBk)) {
1844
- // Collision with a stale bucket. Restore + flag for slow.
1845
- bucketByKey.set(r.oldBk, r.bucket);
1846
- for (const id of r.bucket.drawSlots)
1847
- slowChanged.push(id);
1848
- continue;
1849
- }
1850
- bucketByKey.set(r.newBk, r.bucket);
1851
- const tracker = drawIdToModeTracker[r.repId];
1852
- const sample = drawIdToSpec[r.repId];
1853
- const desc = tracker.descriptor;
1854
- // Cache hit: reuse an already-built pipeline. Cache miss
1855
- // builds + stashes; future flips back to this descriptor
1856
- // hit the cache.
1857
- const newPipeline = getOrCreatePipeline(familyFor(sample.effect), r.bucket.layout, r.bucket.isAtlasBucket, desc);
1858
- r.bucket.slots[0].pipeline = newPipeline;
1859
- }
1860
- }
1861
- // Slow per-RO rebucket for the leftover cases.
1862
- for (const drawId of slowChanged) {
1863
- const spec = drawIdToSpec[drawId];
1864
- if (spec === undefined)
1809
+ const oldKey = tracker.modeKey;
1810
+ if (!tracker.recompute())
1811
+ continue;
1812
+ if (tracker.modeKey === oldKey)
1813
+ continue;
1814
+ const bucket = drawIdToBucket[drawId];
1815
+ const localSlot = drawIdToLocalSlot[drawId];
1816
+ const oldSlotIdx = drawIdToSlotIdx[drawId];
1817
+ if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined)
1865
1818
  continue;
1866
- removeDraw(drawId);
1867
- const newId = addDrawImpl(spec, tok);
1868
- void newId;
1819
+ const newSlot = ensureSlot(bucket, tracker.descriptor);
1820
+ const newSlotIdx = bucket.slots.indexOf(newSlot);
1821
+ if (newSlotIdx === oldSlotIdx)
1822
+ continue;
1823
+ // Swap-pop from old slot's drawTable.
1824
+ const oldSlot = bucket.slots[oldSlotIdx];
1825
+ const entry = bucket.localEntries[localSlot];
1826
+ const emit = entry !== undefined ? entry.indexCount * entry.instanceCount : 0;
1827
+ const oldRecIdx = oldSlot.slotToRecord[localSlot];
1828
+ const lastRecIdx = oldSlot.recordCount - 1;
1829
+ const oldShadow = oldSlot.drawTableShadow;
1830
+ if (oldRecIdx !== lastRecIdx) {
1831
+ const dst = oldRecIdx * RECORD_U32;
1832
+ const src = lastRecIdx * RECORD_U32;
1833
+ oldShadow[dst + 0] = 0;
1834
+ oldShadow[dst + 1] = oldShadow[src + 1];
1835
+ oldShadow[dst + 2] = oldShadow[src + 2];
1836
+ oldShadow[dst + 3] = oldShadow[src + 3];
1837
+ oldShadow[dst + 4] = oldShadow[src + 4];
1838
+ const movedLocal = oldSlot.recordToSlot[lastRecIdx];
1839
+ oldSlot.slotToRecord[movedLocal] = oldRecIdx;
1840
+ oldSlot.recordToSlot[oldRecIdx] = movedLocal;
1841
+ const off = oldRecIdx * RECORD_BYTES;
1842
+ if (off < oldSlot.drawTableDirtyMin)
1843
+ oldSlot.drawTableDirtyMin = off;
1844
+ if (off + RECORD_BYTES > oldSlot.drawTableDirtyMax)
1845
+ oldSlot.drawTableDirtyMax = off + RECORD_BYTES;
1846
+ }
1847
+ oldSlot.slotToRecord[localSlot] = -1;
1848
+ oldSlot.recordToSlot[lastRecIdx] = -1;
1849
+ oldSlot.recordCount = lastRecIdx;
1850
+ oldSlot.totalEmitEstimate = Math.max(0, oldSlot.totalEmitEstimate - emit);
1851
+ oldSlot.scanDirty = true;
1852
+ // Push into new slot's drawTable.
1853
+ const newRecIdx = newSlot.recordCount;
1854
+ const dtBuf = newSlot.drawTableBuf;
1855
+ const byteOff = newRecIdx * RECORD_BYTES;
1856
+ dtBuf.ensureCapacity(byteOff + RECORD_BYTES);
1857
+ dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
1858
+ const needBlocks = Math.max(1, Math.ceil((newRecIdx + 1) / SCAN_TILE_SIZE));
1859
+ newSlot.blockSumsBuf.ensureCapacity(needBlocks * 4);
1860
+ newSlot.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1861
+ const newShadow = newSlot.drawTableShadow;
1862
+ newShadow[newRecIdx * RECORD_U32 + 0] = 0;
1863
+ newShadow[newRecIdx * RECORD_U32 + 1] = localSlot;
1864
+ newShadow[newRecIdx * RECORD_U32 + 2] = entry !== undefined ? entry.firstIndex : 0;
1865
+ newShadow[newRecIdx * RECORD_U32 + 3] = entry !== undefined ? entry.indexCount : 0;
1866
+ newShadow[newRecIdx * RECORD_U32 + 4] = entry !== undefined ? entry.instanceCount : 0;
1867
+ newSlot.recordCount = newRecIdx + 1;
1868
+ newSlot.slotToRecord[localSlot] = newRecIdx;
1869
+ newSlot.recordToSlot[newRecIdx] = localSlot;
1870
+ if (byteOff < newSlot.drawTableDirtyMin)
1871
+ newSlot.drawTableDirtyMin = byteOff;
1872
+ if (byteOff + RECORD_BYTES > newSlot.drawTableDirtyMax)
1873
+ newSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
1874
+ newSlot.totalEmitEstimate += emit;
1875
+ const newNumTiles = Math.max(1, Math.ceil(newSlot.totalEmitEstimate / TILE_K));
1876
+ newSlot.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
1877
+ newSlot.scanDirty = true;
1878
+ drawIdToSlotIdx[drawId] = newSlotIdx;
1869
1879
  }
1870
1880
  }
1871
1881
  // 1. Pool: re-pack any aval whose value changed since last frame.
@@ -1980,13 +1990,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1980
1990
  function encodeIntoPass(pass) {
1981
1991
  let curBg = null;
1982
1992
  for (const b of buckets) {
1983
- if (b.bindGroup !== curBg) {
1984
- pass.setBindGroup(0, b.bindGroup);
1985
- curBg = b.bindGroup;
1993
+ // Phase 5c.2: iterate the bucket's slots, drawIndirect per
1994
+ // non-empty slot. Empty slots draw zero (skip — saves the
1995
+ // setPipeline/setBindGroup overhead).
1996
+ for (const slot of b.slots) {
1997
+ if (slot.recordCount === 0)
1998
+ continue;
1999
+ const bg = slot.bindGroup;
2000
+ if (bg !== undefined && bg !== curBg) {
2001
+ pass.setBindGroup(0, bg);
2002
+ curBg = bg;
2003
+ }
2004
+ pass.setPipeline(slot.pipeline);
2005
+ if (slot.indirectBuf !== undefined)
2006
+ pass.drawIndirect(slot.indirectBuf, 0);
1986
2007
  }
1987
- pass.setPipeline(b.slots[0].pipeline);
1988
- if (b.slots[0].recordCount > 0)
1989
- pass.drawIndirect(b.slots[0].indirectBuf, 0);
1990
2008
  }
1991
2009
  }
1992
2010
  /**
@@ -2061,42 +2079,44 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2061
2079
  stats.derivedRecords = derivedScene.records.recordCount;
2062
2080
  }
2063
2081
  let anyDirty = false;
2064
- for (const b of buckets) {
2065
- if (b.slots[0].scanDirty) {
2066
- anyDirty = true;
2067
- break;
2082
+ outer: for (const b of buckets) {
2083
+ for (const s of b.slots) {
2084
+ if (s.scanDirty) {
2085
+ anyDirty = true;
2086
+ break outer;
2087
+ }
2068
2088
  }
2069
2089
  }
2070
2090
  if (!anyDirty)
2071
2091
  return;
2072
2092
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
2073
2093
  for (const b of buckets) {
2074
- if (!b.slots[0].scanDirty)
2075
- continue;
2076
- // Rebuild render bind group if recordCount changed — its
2077
- // drawTable binding is sized to recordCount * 16.
2078
- if (b.slots[0].renderBoundRecordCount !== b.slots[0].recordCount) {
2079
- b.bindGroup = buildBucketBindGroup(b);
2080
- }
2081
- const numRecords = b.slots[0].recordCount;
2082
- const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2083
- device.queue.writeBuffer(b.slots[0].paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
2084
- pass.setBindGroup(0, b.slots[0].scanBindGroup);
2085
- pass.setPipeline(scanPipeTile);
2086
- pass.dispatchWorkgroups(numBlocks, 1, 1);
2087
- pass.setPipeline(scanPipeBlocks);
2088
- pass.dispatchWorkgroups(1, 1, 1);
2089
- pass.setPipeline(scanPipeAdd);
2090
- pass.dispatchWorkgroups(numBlocks, 1, 1);
2091
- // numTiles is computed on GPU from indirect[0]; CPU dispatch
2092
- // must cover the worst-case totalEmit. Each WG handles WG_SIZE
2093
- // tiles; +1 for the sentinel slot.
2094
- const SCAN_WG_SIZE = 256;
2095
- const numTilesCap = Math.max(1, Math.ceil(b.slots[0].totalEmitEstimate / TILE_K));
2096
- const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
2097
- pass.setPipeline(scanPipeBuildTileIndex);
2098
- pass.dispatchWorkgroups(tileWgs, 1, 1);
2099
- b.slots[0].scanDirty = false;
2094
+ for (let i = 0; i < b.slots.length; i++) {
2095
+ const slot = b.slots[i];
2096
+ if (!slot.scanDirty)
2097
+ continue;
2098
+ // Rebuild render bind group if recordCount changed — drawTable
2099
+ // binding is sized to recordCount * RECORD_BYTES.
2100
+ if (slot.renderBoundRecordCount !== slot.recordCount) {
2101
+ slot.bindGroup = buildBucketBindGroup(b, i);
2102
+ }
2103
+ const numRecords = slot.recordCount;
2104
+ const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2105
+ device.queue.writeBuffer(slot.paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
2106
+ pass.setBindGroup(0, slot.scanBindGroup);
2107
+ pass.setPipeline(scanPipeTile);
2108
+ pass.dispatchWorkgroups(numBlocks, 1, 1);
2109
+ pass.setPipeline(scanPipeBlocks);
2110
+ pass.dispatchWorkgroups(1, 1, 1);
2111
+ pass.setPipeline(scanPipeAdd);
2112
+ pass.dispatchWorkgroups(numBlocks, 1, 1);
2113
+ const SCAN_WG_SIZE = 256;
2114
+ const numTilesCap = Math.max(1, Math.ceil(slot.totalEmitEstimate / TILE_K));
2115
+ const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
2116
+ pass.setPipeline(scanPipeBuildTileIndex);
2117
+ pass.dispatchWorkgroups(tileWgs, 1, 1);
2118
+ slot.scanDirty = false;
2119
+ }
2100
2120
  }
2101
2121
  pass.end();
2102
2122
  }