@aardworx/wombat.rendering 0.9.33 → 0.18.1

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 (95) hide show
  1. package/dist/runtime/derivedModes/kernelCodegen.d.ts +90 -0
  2. package/dist/runtime/derivedModes/kernelCodegen.d.ts.map +1 -0
  3. package/dist/runtime/derivedModes/kernelCodegen.js +623 -0
  4. package/dist/runtime/derivedModes/kernelCodegen.js.map +1 -0
  5. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
  6. package/dist/runtime/derivedModes/modeKeyCpu.js +23 -67
  7. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
  8. package/dist/runtime/derivedModes/partitionDispatcher.d.ts +79 -0
  9. package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -0
  10. package/dist/runtime/derivedModes/partitionDispatcher.js +303 -0
  11. package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -0
  12. package/dist/runtime/derivedModes/partitionKernel.d.ts +12 -0
  13. package/dist/runtime/derivedModes/partitionKernel.d.ts.map +1 -0
  14. package/dist/runtime/derivedModes/partitionKernel.js +127 -0
  15. package/dist/runtime/derivedModes/partitionKernel.js.map +1 -0
  16. package/dist/runtime/derivedModes/partitionKernelLayout.d.ts +4 -0
  17. package/dist/runtime/derivedModes/partitionKernelLayout.d.ts.map +1 -0
  18. package/dist/runtime/derivedModes/partitionKernelLayout.js +24 -0
  19. package/dist/runtime/derivedModes/partitionKernelLayout.js.map +1 -0
  20. package/dist/runtime/derivedModes/rule.d.ts +38 -86
  21. package/dist/runtime/derivedModes/rule.d.ts.map +1 -1
  22. package/dist/runtime/derivedModes/rule.js +34 -74
  23. package/dist/runtime/derivedModes/rule.js.map +1 -1
  24. package/dist/runtime/derivedUniforms/dispatch.d.ts +10 -8
  25. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  26. package/dist/runtime/derivedUniforms/dispatch.js +31 -18
  27. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  28. package/dist/runtime/derivedUniforms/marker.d.ts +90 -11
  29. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
  30. package/dist/runtime/derivedUniforms/marker.js +161 -15
  31. package/dist/runtime/derivedUniforms/marker.js.map +1 -1
  32. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +25 -6
  33. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  34. package/dist/runtime/derivedUniforms/sceneIntegration.js +61 -20
  35. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  36. package/dist/runtime/heapEligibility.d.ts.map +1 -1
  37. package/dist/runtime/heapEligibility.js +22 -2
  38. package/dist/runtime/heapEligibility.js.map +1 -1
  39. package/dist/runtime/heapScene/chunkedArena.d.ts +76 -0
  40. package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -0
  41. package/dist/runtime/heapScene/chunkedArena.js +223 -0
  42. package/dist/runtime/heapScene/chunkedArena.js.map +1 -0
  43. package/dist/runtime/heapScene/freelist.d.ts +50 -0
  44. package/dist/runtime/heapScene/freelist.d.ts.map +1 -0
  45. package/dist/runtime/heapScene/freelist.js +160 -0
  46. package/dist/runtime/heapScene/freelist.js.map +1 -0
  47. package/dist/runtime/heapScene/growBuffer.d.ts +23 -2
  48. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -1
  49. package/dist/runtime/heapScene/growBuffer.js +35 -3
  50. package/dist/runtime/heapScene/growBuffer.js.map +1 -1
  51. package/dist/runtime/heapScene/pools.d.ts +65 -42
  52. package/dist/runtime/heapScene/pools.d.ts.map +1 -1
  53. package/dist/runtime/heapScene/pools.js +206 -134
  54. package/dist/runtime/heapScene/pools.js.map +1 -1
  55. package/dist/runtime/heapScene.d.ts +12 -0
  56. package/dist/runtime/heapScene.d.ts.map +1 -1
  57. package/dist/runtime/heapScene.js +938 -85
  58. package/dist/runtime/heapScene.js.map +1 -1
  59. package/dist/runtime/hybridScene.d.ts +7 -0
  60. package/dist/runtime/hybridScene.d.ts.map +1 -1
  61. package/dist/runtime/hybridScene.js +1 -0
  62. package/dist/runtime/hybridScene.js.map +1 -1
  63. package/dist/runtime/index.d.ts +4 -3
  64. package/dist/runtime/index.d.ts.map +1 -1
  65. package/dist/runtime/index.js +4 -3
  66. package/dist/runtime/index.js.map +1 -1
  67. package/dist/runtime/renderTask.d.ts +5 -0
  68. package/dist/runtime/renderTask.d.ts.map +1 -1
  69. package/dist/runtime/renderTask.js +1 -0
  70. package/dist/runtime/renderTask.js.map +1 -1
  71. package/dist/runtime/runtime.d.ts +6 -0
  72. package/dist/runtime/runtime.d.ts.map +1 -1
  73. package/dist/runtime/runtime.js +1 -0
  74. package/dist/runtime/runtime.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/runtime/derivedModes/kernelCodegen.ts +673 -0
  77. package/src/runtime/derivedModes/modeKeyCpu.ts +22 -60
  78. package/src/runtime/derivedModes/partitionDispatcher.ts +349 -0
  79. package/src/runtime/derivedModes/partitionKernelLayout.ts +25 -0
  80. package/src/runtime/derivedModes/rule.ts +66 -145
  81. package/src/runtime/derivedUniforms/dispatch.ts +48 -21
  82. package/src/runtime/derivedUniforms/marker.ts +210 -16
  83. package/src/runtime/derivedUniforms/sceneIntegration.ts +67 -20
  84. package/src/runtime/heapEligibility.ts +22 -1
  85. package/src/runtime/heapScene/chunkedArena.ts +238 -0
  86. package/src/runtime/heapScene/freelist.ts +161 -0
  87. package/src/runtime/heapScene/growBuffer.ts +39 -2
  88. package/src/runtime/heapScene/pools.ts +231 -143
  89. package/src/runtime/heapScene.ts +1119 -81
  90. package/src/runtime/hybridScene.ts +8 -0
  91. package/src/runtime/index.ts +12 -9
  92. package/src/runtime/renderTask.ts +6 -0
  93. package/src/runtime/runtime.ts +7 -0
  94. package/src/runtime/derivedModes/gpuDispatcher.ts +0 -226
  95. package/src/runtime/derivedModes/gpuKernel.ts +0 -98
@@ -85,7 +85,7 @@ import {
85
85
  } from "./heapScene/growBuffer.js";
86
86
  import {
87
87
  UniformPool, IndexPool, DrawHeap,
88
- AttributeArena, IndexAllocator, insertSortedFreeBlock,
88
+ AttributeArena, IndexAllocator,
89
89
  buildArenaState, arenaBytes, writeAttribute,
90
90
  asAval, isBufferView, asFloat32,
91
91
  ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO,
@@ -94,7 +94,21 @@ import {
94
94
  } from "./heapScene/pools.js";
95
95
  import { encodeModeKey, type PipelineStateDescriptor } from "./pipelineCache/index.js";
96
96
  import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
97
+ import { GpuPartitionScene } from "./derivedModes/partitionDispatcher.js";
98
+ import {
99
+ emitPartitionKernel,
100
+ substituteReadInputInStmt,
101
+ collectUniformReadsInStmt,
102
+ rewriteOutputsToSlotIndices,
103
+ } from "./derivedModes/kernelCodegen.js";
104
+ import { type DerivedModeRule, type ModeAxis } from "./derivedModes/rule.js";
105
+ import type { CullMode, FrontFace, Topology } from "./pipelineCache/index.js";
97
106
  import { addMarkingCallback } from "@aardworx/wombat.adaptive";
107
+ import {
108
+ analyseOutputSet, evaluateStructural, evaluateStructuralSet,
109
+ stableStringify,
110
+ type Expr, type Stmt,
111
+ } from "@aardworx/wombat.shader/ir";
98
112
 
99
113
  // GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
100
114
  // Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
@@ -160,6 +174,10 @@ interface BucketSlot {
160
174
  pipeline: GPURenderPipeline;
161
175
  /** modeKey this slot covers — the bitfield-encoded descriptor. */
162
176
  modeKey: bigint;
177
+ /** Snapshotted full descriptor the slot was created with. Used by
178
+ * CPU→GPU promotion to synthesise per-distinct-descriptor combos
179
+ * for pre-existing CPU-routed ROs. */
180
+ cpuDescriptor?: PipelineStateDescriptor;
163
181
  /** Render bind group (drawTable + firstDrawInTile are per-slot). */
164
182
  bindGroup?: GPUBindGroup;
165
183
 
@@ -190,10 +208,57 @@ interface BucketSlot {
190
208
  scanDirty: boolean;
191
209
  }
192
210
 
211
+ /**
212
+ * One distinct derived-mode rule registered on a GPU-routed bucket,
213
+ * keyed by axis. Multiple ROs in the same bucket may carry different
214
+ * rules across different axes; each axis maintains its own
215
+ * `axisRuleId` namespace. A bucket-unique `comboId` (a chosen
216
+ * tuple of (axis → axisRuleId, or "no rule") per RO) is baked into
217
+ * master-pool records and selects the right composer fn in the
218
+ * partition kernel.
219
+ */
220
+ interface AxisRuleEntry {
221
+ readonly axis: ModeAxis;
222
+ readonly axisRuleId: number;
223
+ readonly contentHash: string;
224
+ readonly rule: DerivedModeRule<ModeAxis>;
225
+ readonly body: Stmt;
226
+ /** Last declared u32 we re-specialised against — drives the
227
+ * dispatch-time "needs re-specialise?" check. */
228
+ lastDeclaredU32: number | undefined;
229
+ }
230
+
231
+ /**
232
+ * One combination of rules carried by ROs in a GPU-routed bucket.
233
+ * A combo specifies at most one rule per axis (axes absent from the
234
+ * map fall back to baseDescriptor at a compile-time-constant per-
235
+ * axis index). Master records store `comboId` — the partition
236
+ * kernel emits one `combo_<id>` fn per combo composing per-axis
237
+ * indices into a global slot index via mixed-radix encoding.
238
+ */
239
+ interface ComboEntry {
240
+ readonly comboId: number;
241
+ readonly comboKey: string;
242
+ /** axis → axisRuleId for axes this combo has a rule on. */
243
+ readonly axisRules: ReadonlyMap<ModeAxis, number>;
244
+ /** axis → explicit fixed value for axes the combo has no rule on
245
+ * but wants to override baseDescriptor's value (e.g. pre-existing
246
+ * CPU-routed ROs whose pipelineState differs from baseDescriptor
247
+ * on a non-ruled axis). Axes absent from both maps fall back to
248
+ * baseDescriptor at codegen time. */
249
+ readonly axisFixedValues: ReadonlyMap<ModeAxis, unknown>;
250
+ }
251
+
193
252
  interface Bucket {
194
253
  readonly label: string;
195
254
  readonly textures: HeapTextureSet | undefined;
196
255
  readonly layout: BucketLayout;
256
+ /** §3: which arena chunk this bucket's ROs live in. The bucket's
257
+ * bind groups reference `arena.attrs.chunk(chunkIdx).buffer` and
258
+ * `arena.indices.chunk(chunkIdx).buffer`. Two ROs sharing an
259
+ * effect that landed in different chunks live in DIFFERENT
260
+ * buckets — encode emits a draw per (effect, chunkIdx). */
261
+ readonly chunkIdx: number;
197
262
  /**
198
263
  * One entry per distinct pipeline state. Phase 5a: always length 1
199
264
  * (per-RO pipeline-state still keyed at the bucket level).
@@ -276,6 +341,60 @@ interface Bucket {
276
341
  * for slots without an atlas source aval.
277
342
  */
278
343
  readonly localAtlasArrIdx: (number | undefined)[];
344
+
345
+ // ─── Phase 5c.3 GPU-routed bucket state ─────────────────────────
346
+ /**
347
+ * True when this bucket's first RO carried a GPU-flavoured mode
348
+ * rule (e.g. `gpuFlipCullByDeterminant`). The partition kernel
349
+ * decides per-record routing each frame; CPU never reslots.
350
+ */
351
+ gpuRouted: boolean;
352
+ /**
353
+ * Per-bucket partition dispatcher. Owns the master record buffer
354
+ * + per-slot atomic counters + params. Slot draw buffers are
355
+ * externally owned (point at `bucket.slots[i].drawTableBuf.buffer`).
356
+ */
357
+ partitionScene: GpuPartitionScene | undefined;
358
+ /**
359
+ * drawId → master record index (for swap-pop on removeDraw).
360
+ */
361
+ drawIdToRecord: Map<number, number> | undefined;
362
+ /**
363
+ * master record index → drawId (for fixup when swap-pop moves the
364
+ * tail record into the freed slot).
365
+ */
366
+ recordToDrawId: number[] | undefined;
367
+ /**
368
+ * Per-axis distinct rules registered on this bucket, keyed by
369
+ * content hash (axis + expr.id + declared identity). `axisRuleId`
370
+ * is unique within an axis. ROs carry an arbitrary subset of axis
371
+ * rules; combos (below) dedupe distinct subsets across ROs.
372
+ */
373
+ rulesByAxis: Map<ModeAxis, Map<string, AxisRuleEntry>> | undefined;
374
+ /** Per-axis ordered list of registered rule hashes; index =
375
+ * `axisRuleId` for that axis. */
376
+ axisRuleOrder: Map<ModeAxis, string[]> | undefined;
377
+ /** Bucket-wide ordered list of arena uniform names read by any
378
+ * rule body. Each entry maps to a `ref<i>: u32` field in the
379
+ * master-record uniform tail; appendRecord packs them per RO. */
380
+ uniformOrder: string[] | undefined;
381
+ uniformIdx: Map<string, number> | undefined;
382
+ /** Distinct combos appearing on ROs in this bucket. */
383
+ combosByKey: Map<string, ComboEntry> | undefined;
384
+ /** `comboId` → comboKey. Index = comboId. */
385
+ comboOrder: string[] | undefined;
386
+ /** drawId → comboId. Master records carry the comboId; this is
387
+ * the CPU-side mirror used when an RO is removed. */
388
+ drawIdToComboId: Map<number, number> | undefined;
389
+ /** Seed PipelineStateDescriptor (the descriptor of the first GPU-
390
+ * routed RO). Used as the base for slot descriptors. */
391
+ baseDescriptor: PipelineStateDescriptor | undefined;
392
+ /**
393
+ * Has the partition kernel's inputs (records or rule deps)
394
+ * changed since last dispatch? Set true on addRO / removeDraw /
395
+ * any tracker-leaf mark. Reset after dispatch.
396
+ */
397
+ partitionDirty: boolean;
279
398
  }
280
399
 
281
400
  // ---------------------------------------------------------------------------
@@ -570,6 +689,18 @@ export interface BuildHeapSceneOptions {
570
689
  * effect's `spec.inputs[name]` like any other uniform.
571
690
  */
572
691
  readonly enableDerivedUniforms?: boolean;
692
+ /**
693
+ * §3 — per-arena-chunk size cap in bytes. When omitted, falls
694
+ * back to `min(device.limits.maxStorageBufferBindingSize,
695
+ * DEFAULT_MAX_BUFFER_BYTES = 256 MB)` so chunks grow as far as
696
+ * the hardware allows. Demos / tests can pass a small value
697
+ * (e.g. 4 MB) to exercise multi-chunk routing without realistic
698
+ * workload pressure. Each chunk lives in its own GPUBuffer;
699
+ * buckets bind exactly one chunk and emit one draw call per
700
+ * (effect, chunkIdx). Shared-aval avals that land in different
701
+ * chunks duplicate their pool entries (accepted trade-off).
702
+ */
703
+ readonly maxChunkBytes?: number;
573
704
  }
574
705
 
575
706
  export function buildHeapScene(
@@ -607,6 +738,7 @@ export function buildHeapScene(
607
738
  const arena = buildArenaState(
608
739
  device, 64 * 1024, 16 * 1024, "heapScene",
609
740
  GPUBufferUsage.STORAGE,
741
+ opts.maxChunkBytes,
610
742
  );
611
743
 
612
744
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
@@ -867,7 +999,7 @@ export function buildHeapScene(
867
999
  override inputChanged(_t: unknown, o: IAdaptiveObject): void {
868
1000
  // Pool avals are stored as `aval<unknown>`; the IAdaptiveObject
869
1001
  // identity matches.
870
- if (pool.has(o as unknown as aval<unknown>)) {
1002
+ if (pool.hasAny(o as unknown as aval<unknown>)) {
871
1003
  allocDirty.add(o as unknown as aval<unknown>);
872
1004
  return;
873
1005
  }
@@ -892,11 +1024,21 @@ export function buildHeapScene(
892
1024
  // `enableDerivedUniforms: false` to opt out.
893
1025
  const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
894
1026
  const derivedScene: DerivedUniformsScene | undefined = enableDerivedUniforms
895
- ? new DerivedUniformsScene(device, arena.attrs.buffer, {
1027
+ ? new DerivedUniformsScene(device, {
896
1028
  // Larger initial capacity reduces grow-during-first-frame churn.
897
1029
  initialConstituentSlots: 4096,
898
1030
  })
899
1031
  : undefined;
1032
+ // §3: register each arena chunk's main-heap buffer with the
1033
+ // derived-uniforms scene; refresh the binding when chunks open or
1034
+ // a chunk's GrowBuffer reallocates.
1035
+ if (derivedScene !== undefined) {
1036
+ const wireChunk = (idx: number): void => {
1037
+ derivedScene.setMainHeapForChunk(idx, () => arena.attrs.chunk(idx).buffer);
1038
+ };
1039
+ for (let i = 0; i < arena.attrs.chunkCount; i++) wireChunk(i);
1040
+ arena.attrs.onChunkAdded(wireChunk);
1041
+ }
900
1042
  /** Per-RO §7 registration handles, keyed by global drawId.
901
1043
  * Drained on removeDraw to release slots + records. */
902
1044
  const derivedByDrawId = new Map<number, RoRegistration>();
@@ -1412,11 +1554,12 @@ export function buildHeapScene(
1412
1554
  // SAME global arena GPUBuffer (emscripten-style aliasing). The
1413
1555
  // WGSL prelude declares one binding per view; the shader picks
1414
1556
  // whichever matches its read.
1557
+ const attrsBuf = arena.attrs.chunk(bucket.chunkIdx).buffer;
1415
1558
  const entries: GPUBindGroupEntry[] = [
1416
- { binding: 0, resource: { buffer: arena.attrs.buffer } }, // heapU32
1559
+ { binding: 0, resource: { buffer: attrsBuf } }, // heapU32
1417
1560
  { binding: 1, resource: { buffer: bucket.drawHeap.buffer } }, // headersU32
1418
- { binding: 2, resource: { buffer: arena.attrs.buffer } }, // heapF32
1419
- { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
1561
+ { binding: 2, resource: { buffer: attrsBuf } }, // heapF32
1562
+ { binding: 3, resource: { buffer: attrsBuf } }, // heapV4f
1420
1563
  ];
1421
1564
  {
1422
1565
  const s = bucket.slots[slotIdx]!;
@@ -1431,7 +1574,7 @@ export function buildHeapScene(
1431
1574
  const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
1432
1575
  entries.push(
1433
1576
  { binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
1434
- { binding: 5, resource: { buffer: arena.indices.buffer } },
1577
+ { binding: 5, resource: { buffer: arena.indices.chunk(bucket.chunkIdx).buffer } },
1435
1578
  { binding: 6, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
1436
1579
  );
1437
1580
  s.renderBoundRecordCount = s.recordCount;
@@ -1541,16 +1684,37 @@ export function buildHeapScene(
1541
1684
  }
1542
1685
  }
1543
1686
 
1544
- arena.attrs.onResize(() => {
1545
- rebuildAllBindGroups();
1546
- if (derivedScene !== undefined) derivedScene.rebindMainHeap(arena.attrs.buffer);
1687
+ arena.attrs.onAnyResize((resizedChunkIdx) => {
1688
+ // Only rebuild bind groups for buckets that bind THIS chunk —
1689
+ // a resize in chunk K doesn't affect a bucket reading chunk N≠K.
1690
+ for (const b of buckets) {
1691
+ if (b.chunkIdx !== resizedChunkIdx) continue;
1692
+ for (let i = 0; i < b.slots.length; i++) {
1693
+ b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
1694
+ }
1695
+ if (b.gpuRouted && b.partitionScene !== undefined) {
1696
+ b.partitionScene.rebindSlotDrawBufs(b.slots.map(s => s.drawTableBuf!.buffer));
1697
+ }
1698
+ }
1699
+ // §7: each chunk's main-heap getter is registered via
1700
+ // `derivedScene.setMainHeapForChunk` (at construction + on
1701
+ // onChunkAdded). The getter returns the chunk's current
1702
+ // GPUBuffer; the dispatcher's bind groups consult it per
1703
+ // dispatch, so no explicit rebind needed here.
1547
1704
  });
1548
1705
  if (atlasPool !== undefined) {
1549
1706
  for (const f of ATLAS_PAGE_FORMATS) {
1550
1707
  atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
1551
1708
  }
1552
1709
  }
1553
- arena.indices.onResize(() => rebuildAllBindGroups());
1710
+ arena.indices.onAnyResize((resizedChunkIdx) => {
1711
+ for (const b of buckets) {
1712
+ if (b.chunkIdx !== resizedChunkIdx) continue;
1713
+ for (let i = 0; i < b.slots.length; i++) {
1714
+ b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
1715
+ }
1716
+ }
1717
+ });
1554
1718
 
1555
1719
  // ─── findOrCreateBucket ───────────────────────────────────────────
1556
1720
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
@@ -1604,6 +1768,7 @@ export function buildHeapScene(
1604
1768
 
1605
1769
  const slot: BucketSlot = {
1606
1770
  pipeline, modeKey,
1771
+ cpuDescriptor: descriptor,
1607
1772
  drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
1608
1773
  recordCount: 0, slotToRecord: [], recordToSlot: [],
1609
1774
  totalEmitEstimate: 0, scanDirty: false,
@@ -1632,6 +1797,11 @@ export function buildHeapScene(
1632
1797
  rebuildScanBg();
1633
1798
  slot.drawTableDirtyMin = 0;
1634
1799
  slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
1800
+ // Phase 5c.3: partition's bind group references every slot's
1801
+ // drawTable. When ANY slot's GPUBuffer reallocates we rebind all.
1802
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
1803
+ bucket.partitionScene.rebindSlotDrawBufs(bucket.slots.map(s => s.drawTableBuf!.buffer));
1804
+ }
1635
1805
  });
1636
1806
  blockSumsBuf.onResize(rebuildScanBg);
1637
1807
  blockOffsetsBuf.onResize(rebuildScanBg);
@@ -1648,10 +1818,721 @@ export function buildHeapScene(
1648
1818
  /** Side map from Bucket → family. Populated by findOrCreateBucket. */
1649
1819
  const familyForBucket = new WeakMap<Bucket, ReturnType<typeof familyFor>>();
1650
1820
 
1821
+ /** Canonical axis order — every part of the GPU routing path uses
1822
+ * this ordering so the kernel's params layout, the per-slot mixed-
1823
+ * radix mode key, and the declared-aval lookup stay in lockstep. */
1824
+ const AXIS_ORDER: ReadonlyArray<ModeAxis> = [
1825
+ "cull", "frontFace", "topology",
1826
+ "depthCompare", "depthWrite", "alphaToCoverage",
1827
+ ];
1828
+
1829
+ function collectRules(modeRules: NonNullable<HeapDrawSpec["modeRules"]>): DerivedModeRule<ModeAxis>[] {
1830
+ const out: DerivedModeRule<ModeAxis>[] = [];
1831
+ for (const axis of AXIS_ORDER) {
1832
+ const r = (modeRules as Record<string, DerivedModeRule<ModeAxis> | undefined>)[axis];
1833
+ if (r !== undefined) out.push(r);
1834
+ }
1835
+ return out;
1836
+ }
1837
+
1838
+ /** Apply a per-axis enum value to a `PipelineStateDescriptor` and
1839
+ * return the patched copy. Used to materialise one descriptor per
1840
+ * slot (= one per cartesian-product point across the rule domains). */
1841
+ function patchDescriptor(
1842
+ base: PipelineStateDescriptor,
1843
+ axis: ModeAxis,
1844
+ value: unknown,
1845
+ ): PipelineStateDescriptor {
1846
+ switch (axis) {
1847
+ case "cull": return { ...base, cullMode: value as CullMode };
1848
+ case "frontFace": return { ...base, frontFace: value as FrontFace };
1849
+ case "topology": {
1850
+ const topology = value as Topology;
1851
+ return { ...base, topology, stripIndexFormat: stripFormatForTopology(topology) };
1852
+ }
1853
+ case "depthCompare":
1854
+ return base.depth !== undefined
1855
+ ? { ...base, depth: { ...base.depth, compare: value as GPUCompareFunction } }
1856
+ : base;
1857
+ case "depthWrite":
1858
+ return base.depth !== undefined
1859
+ ? { ...base, depth: { ...base.depth, write: value as boolean } }
1860
+ : base;
1861
+ case "alphaToCoverage": return { ...base, alphaToCoverage: value as boolean };
1862
+ case "blend": {
1863
+ // Blend is per-attachment; apply the resolved AttachmentBlend
1864
+ // to attachment 0 (the main color target). Multi-attachment
1865
+ // structured rules can extend this when needed.
1866
+ const blend = value as import("./pipelineCache/index.js").AttachmentBlend;
1867
+ const atts = base.attachments.slice();
1868
+ if (atts.length === 0) atts.push(blend);
1869
+ else atts[0] = blend;
1870
+ return { ...base, attachments: atts };
1871
+ }
1872
+ default: return base;
1873
+ }
1874
+ }
1875
+
1876
+ function stripFormatForTopology(t: Topology): GPUIndexFormat | undefined {
1877
+ return t === "line-strip" || t === "triangle-strip" ? "uint32" : undefined;
1878
+ }
1879
+
1880
+ /** Per-axis enum order. The rule body produces u32 indices into
1881
+ * this array, and the heap renderer uses the same order to map
1882
+ * resolved-set integers back to pipeline descriptor values. The
1883
+ * ordering MUST match the bitfield encoder in pipelineCache. */
1884
+ const AXIS_ENUM_TABLE: { readonly [A in ModeAxis]: ReadonlyArray<unknown> } = {
1885
+ cull: ["none", "front", "back"],
1886
+ frontFace: ["ccw", "cw"],
1887
+ topology: ["point-list", "line-list", "line-strip", "triangle-list", "triangle-strip"],
1888
+ depthCompare: ["never", "less", "equal", "less-equal", "greater", "not-equal", "greater-equal", "always"],
1889
+ depthWrite: [false, true],
1890
+ alphaToCoverage: [false, true],
1891
+ // Blend has no canonical enum table — the rule's `resolve` /
1892
+ // `values` callback MUST be supplied so the heap renderer knows
1893
+ // how to map u32 outputs to AttachmentBlend objects.
1894
+ blend: [],
1895
+ };
1896
+
1897
+ /**
1898
+ * Map a resolved slot value (from `evaluateStructuralSet`) to the
1899
+ * axis-shape value used for descriptor patching.
1900
+ *
1901
+ * - If the resolved value is a JS object: it's already the full
1902
+ * axis value (e.g. an `AttachmentBlend` from a blend rule's
1903
+ * object-literal return) — use it directly.
1904
+ * - If it's a number: for enum axes, look up the canonical
1905
+ * table; for structured axes without a table, pass through.
1906
+ */
1907
+ function resolveAxisValue<A extends ModeAxis>(
1908
+ rule: DerivedModeRule<A>,
1909
+ value: unknown,
1910
+ ): unknown {
1911
+ if (typeof value === "object" && value !== null) return value;
1912
+ if (typeof value === "number") {
1913
+ const table = AXIS_ENUM_TABLE[rule.axis];
1914
+ if (table.length > 0) {
1915
+ const v = table[value];
1916
+ if (v !== undefined) return v;
1917
+ }
1918
+ return value;
1919
+ }
1920
+ return value;
1921
+ }
1922
+
1923
+ /**
1924
+ * Resolve an axis's `declared` value to a u32 index.
1925
+ *
1926
+ * - omitted: `0` (the rule body either doesn't reference `declared`
1927
+ * or wants the canonical zero of its axis as the default).
1928
+ * - number: passed through directly (e.g. `declared: 2` for cull "back").
1929
+ * - aval / string / boolean: looked up in the canonical enum table.
1930
+ */
1931
+ function resolveDeclaredU32<A extends ModeAxis>(rule: DerivedModeRule<A>, tok: AdaptiveToken): number {
1932
+ const d = rule.declared;
1933
+ if (d === undefined) return 0;
1934
+ const v = (typeof d === "object" && d !== null && "getValue" in (d as object))
1935
+ ? (d as aval<unknown>).getValue(tok)
1936
+ : d;
1937
+ if (typeof v === "number") return v >>> 0;
1938
+ const table = AXIS_ENUM_TABLE[rule.axis];
1939
+ if (table.length === 0) {
1940
+ throw new Error(
1941
+ `heapScene/gpuRouting: axis '${rule.axis}' has no canonical enum table; ` +
1942
+ `pass \`declared\` as a u32 index or omit it`,
1943
+ );
1944
+ }
1945
+ const i = table.indexOf(v);
1946
+ if (i < 0) {
1947
+ throw new Error(
1948
+ `heapScene/gpuRouting: declared value '${String(v)}' for axis '${rule.axis}' ` +
1949
+ `not in canonical enum table ${JSON.stringify(table)}`,
1950
+ );
1951
+ }
1952
+ return i;
1953
+ }
1954
+
1955
+ /** Extract the rule body Stmt from a RuleExpr's Module. The plugin
1956
+ * emits one `Entry` ValueDef whose body is the user's closure body
1957
+ * (with the synthetic compute-stage scaffolding stripped). */
1958
+ function ruleBodyOf(rule: DerivedModeRule<ModeAxis>): Stmt {
1959
+ const tmpl = rule.expr.template as { values: ReadonlyArray<{ kind: string; entry?: { body: Stmt } }> };
1960
+ for (const v of tmpl.values) {
1961
+ if (v.kind === "Entry" && v.entry !== undefined) return v.entry.body;
1962
+ }
1963
+ throw new Error(
1964
+ `heapScene/gpuRouting: rule for axis '${rule.axis}' has no Entry in its template (id=${rule.expr.id})`,
1965
+ );
1966
+ }
1967
+
1968
+ /**
1969
+ * Specialise a rule for the current declared u32 value:
1970
+ * 1. Bake `declared` as a Const in the rule body (so the kernel
1971
+ * has no per-dispatch uniform — declared marks trigger a
1972
+ * re-specialise + cached-kernel swap).
1973
+ * 2. Run `analyseOutputSet` to collect distinct return Exprs.
1974
+ * 3. `evaluateStructuralSet` folds each Expr to a JS value (a
1975
+ * number for enum axes; a full object for structured axes
1976
+ * like blend). Distinct values define the slot count.
1977
+ * 4. Build a stableStringify-keyed `exprToSlot` map.
1978
+ * 5. Rewrite the body so every ReturnValue emits a Const u32
1979
+ * slot index.
1980
+ * 6. emitPartitionKernel produces the WGSL.
1981
+ */
1982
+ /** WeakMap-based stable identity for aval objects. Two `cval`s
1983
+ * have different identity; the same cval observed twice has the
1984
+ * same identity. Used in `ruleContentHash` so rules differing
1985
+ * only in their `declared` aval are recognised as distinct. */
1986
+ const avalIdentityMap = new WeakMap<object, number>();
1987
+ let nextAvalIdentity = 0;
1988
+ function avalIdentity(av: object): number {
1989
+ let id = avalIdentityMap.get(av);
1990
+ if (id === undefined) {
1991
+ id = ++nextAvalIdentity;
1992
+ avalIdentityMap.set(av, id);
1993
+ }
1994
+ return id;
1995
+ }
1996
+
1997
+ /** Content hash for rule identity within a bucket. Two rules
1998
+ * hashing the same can share a `ruleId` and one body; rules
1999
+ * hashing differently get distinct ids. */
2000
+ function ruleContentHash(rule: DerivedModeRule<ModeAxis>): string {
2001
+ const dec = rule.declared;
2002
+ let dKey: string;
2003
+ if (dec === undefined) {
2004
+ dKey = "u";
2005
+ } else if (typeof dec === "object" && dec !== null && "getValue" in (dec as object)) {
2006
+ dKey = `a${avalIdentity(dec as object)}`;
2007
+ } else {
2008
+ dKey = `v${String(dec)}`;
2009
+ }
2010
+ return `${rule.axis}|${rule.expr.id}|${dKey}`;
2011
+ }
2012
+
2013
+ /** Read baseDescriptor's value for a given axis (the value used by
2014
+ * combos that have no rule on that axis). */
2015
+ function axisValueOfDesc(desc: PipelineStateDescriptor, axis: ModeAxis): unknown {
2016
+ switch (axis) {
2017
+ case "cull": return desc.cullMode;
2018
+ case "frontFace": return desc.frontFace;
2019
+ case "topology": return desc.topology;
2020
+ case "depthCompare": return desc.depth?.compare;
2021
+ case "depthWrite": return desc.depth?.write;
2022
+ case "alphaToCoverage": return desc.alphaToCoverage;
2023
+ case "blend": return desc.attachments[0];
2024
+ }
2025
+ }
2026
+
2027
+ /**
2028
+ * Specialise the WHOLE bucket — N-D cartesian over per-axis
2029
+ * unions:
2030
+ * 1. Per (axis, rule): bake declared as Const, analyse the body,
2031
+ * `evaluateStructuralSet` to fold each return Expr to a JS
2032
+ * value. Collect the per-axis union (dedup by stableStringify,
2033
+ * sorted deterministically). If any combo lacks a rule on
2034
+ * this axis, also include baseDesc's value for that axis.
2035
+ * 2. Active axes = axes with ≥1 registered rule, in AXIS_ORDER.
2036
+ * Cardinalities Cₐ = |axisValues[a]|; totalSlots = ∏ Cₐ.
2037
+ * 3. Per axis-rule: rewrite the body so every `ReturnValue`
2038
+ * emits a `Const u32` PER-AXIS index (into axisValues[axis]).
2039
+ * 4. Per combo: assemble a ComboCodegenSpec (per-axis source =
2040
+ * rule or compile-time const index).
2041
+ * 5. Emit the kernel.
2042
+ *
2043
+ * Returns the cartesian slot descriptors + the kernel WGSL.
2044
+ */
2045
+ function specialiseBucket(
2046
+ bucket: Bucket,
2047
+ ): { slotDescs: ReadonlyArray<PipelineStateDescriptor>; kernelWGSL: string } {
2048
+ const Tu32 = { kind: "Int", signed: false, width: 32 } as const;
2049
+ const rulesByAxis = bucket.rulesByAxis!;
2050
+ const axisRuleOrder = bucket.axisRuleOrder!;
2051
+ const combosByKey = bucket.combosByKey!;
2052
+ const comboOrder = bucket.comboOrder!;
2053
+ if (comboOrder.length === 0) {
2054
+ throw new Error("heapScene/gpuRouting: specialiseBucket called with no registered combos");
2055
+ }
2056
+ const base = bucket.baseDescriptor!;
2057
+ // An axis is "active" (gets cartesian-product cardinality) if it
2058
+ // has at least one rule, OR any combo overrides baseDesc with a
2059
+ // distinct fixed value. Both grow the per-axis value union.
2060
+ const baseAxisValues = new Map<ModeAxis, unknown>(AXIS_ORDER.map(a => [a, axisValueOfDesc(base, a)] as const));
2061
+ const activeAxes: ModeAxis[] = AXIS_ORDER.filter(a => {
2062
+ if ((rulesByAxis.get(a)?.size ?? 0) > 0) return true;
2063
+ const baseVal = baseAxisValues.get(a);
2064
+ const baseKey = stableStringify(baseVal);
2065
+ for (const k of comboOrder) {
2066
+ const c = combosByKey.get(k)!;
2067
+ const fixed = c.axisFixedValues.get(a);
2068
+ if (fixed !== undefined && stableStringify(fixed) !== baseKey) return true;
2069
+ }
2070
+ return false;
2071
+ });
2072
+
2073
+ // Step 1: per-axis-rule analyse + collect per-axis union.
2074
+ interface PerRule {
2075
+ entry: AxisRuleEntry;
2076
+ bakedBody: Stmt;
2077
+ resolved: ReadonlyArray<unknown>;
2078
+ declaredU32: number;
2079
+ }
2080
+ const perAxisRules = new Map<ModeAxis, PerRule[]>();
2081
+ for (const axis of activeAxes) {
2082
+ const list: PerRule[] = [];
2083
+ for (const hash of axisRuleOrder.get(axis)!) {
2084
+ const entry = rulesByAxis.get(axis)!.get(hash)!;
2085
+ const declaredU32 = resolveDeclaredU32(entry.rule, AdaptiveToken.top);
2086
+ const declaredConst: Expr = {
2087
+ kind: "Const", type: Tu32 as never,
2088
+ value: { kind: "Int", value: declaredU32 >>> 0, signed: false } as never,
2089
+ } as Expr;
2090
+ const bakedBody = substituteReadInputInStmt(entry.body, "Uniform", "declared", declaredConst);
2091
+ const symbolic = analyseOutputSet(bakedBody);
2092
+ const env = new Map<string, number>([["declared", declaredU32]]);
2093
+ const { resolved, unresolvedCount } = evaluateStructuralSet(symbolic, env, new Map());
2094
+ let outputs = resolved.slice();
2095
+ if (outputs.length === 0 || unresolvedCount > 0) {
2096
+ const table = AXIS_ENUM_TABLE[entry.rule.axis];
2097
+ if (table.length > 0 && unresolvedCount > 0) {
2098
+ outputs = table.map((_, i) => i);
2099
+ } else if (outputs.length === 0) {
2100
+ throw new Error(
2101
+ `heapScene/gpuRouting: rule on axis '${entry.rule.axis}' could not be ` +
2102
+ `statically resolved AND the axis has no canonical enum table. ` +
2103
+ `Simplify the rule body so analyseOutputSet can fold each branch.`,
2104
+ );
2105
+ }
2106
+ }
2107
+ list.push({ entry, bakedBody, resolved: outputs, declaredU32 });
2108
+ entry.lastDeclaredU32 = declaredU32;
2109
+ }
2110
+ perAxisRules.set(axis, list);
2111
+ }
2112
+
2113
+ // Per axis: union of resolved values across rules + each combo's
2114
+ // effective value when it has no rule on this axis. Combos that
2115
+ // declare an explicit `axisFixedValues[axis]` contribute that
2116
+ // value; combos without an entry default to baseDesc.value.
2117
+ interface AxisInfo {
2118
+ readonly axis: ModeAxis;
2119
+ readonly values: ReadonlyArray<unknown>; // resolved axis values, sorted
2120
+ readonly keyToIdx: ReadonlyMap<string, number>; // stableStringify(value) → idx
2121
+ readonly baseValue: unknown; // baseDesc's value for this axis
2122
+ readonly baseIdx: number; // index of baseValue in values
2123
+ }
2124
+ const axisInfos: AxisInfo[] = [];
2125
+ for (const axis of activeAxes) {
2126
+ const list = perAxisRules.get(axis) ?? [];
2127
+ const dummyRule = { axis } as DerivedModeRule<ModeAxis>;
2128
+ const byKey = new Map<string, unknown>();
2129
+ for (const p of list) {
2130
+ for (const v of p.resolved) {
2131
+ const av = resolveAxisValue(dummyRule, v);
2132
+ byKey.set(stableStringify(av), av);
2133
+ }
2134
+ }
2135
+ const baseValue = baseAxisValues.get(axis);
2136
+ // Include every combo's effective value for this axis (so
2137
+ // const-source idx lookups always succeed).
2138
+ for (const k of comboOrder) {
2139
+ const c = combosByKey.get(k)!;
2140
+ if (c.axisRules.has(axis)) continue;
2141
+ const effective = c.axisFixedValues.has(axis) ? c.axisFixedValues.get(axis) : baseValue;
2142
+ byKey.set(stableStringify(effective), effective);
2143
+ }
2144
+ const sortedKeys = [...byKey.keys()].sort();
2145
+ const values = sortedKeys.map(k => byKey.get(k)!);
2146
+ const keyToIdx = new Map(sortedKeys.map((k, i) => [k, i] as const));
2147
+ const baseIdx = keyToIdx.get(stableStringify(baseValue)) ?? 0;
2148
+ axisInfos.push({ axis, values, keyToIdx, baseValue, baseIdx });
2149
+ }
2150
+
2151
+ // Step 2: cartesian shape.
2152
+ const cards = axisInfos.map(a => a.values.length);
2153
+ const totalSlots = cards.reduce((m, n) => m * n, 1);
2154
+
2155
+ // Step 3: rewrite each axis-rule body to emit per-axis indices.
2156
+ const ruleSpecs: import("./derivedModes/kernelCodegen.js").RuleCodegenSpec[] = [];
2157
+ for (const info of axisInfos) {
2158
+ const list = perAxisRules.get(info.axis)!;
2159
+ const dummyRule = { axis: info.axis } as DerivedModeRule<ModeAxis>;
2160
+ for (const p of list) {
2161
+ const env = new Map<string, number>([["declared", p.declaredU32]]);
2162
+ const exprToAxisIdx = (e: Expr): number | undefined => {
2163
+ const v = evaluateStructural(e, env, new Map());
2164
+ if (v === undefined) return undefined;
2165
+ const av = resolveAxisValue(dummyRule, v);
2166
+ return info.keyToIdx.get(stableStringify(av));
2167
+ };
2168
+ const rewrittenBody = rewriteOutputsToSlotIndices(p.bakedBody, exprToAxisIdx);
2169
+ ruleSpecs.push({
2170
+ axis: info.axis,
2171
+ axisRuleId: p.entry.axisRuleId,
2172
+ body: rewrittenBody,
2173
+ intrinsics: new Map(),
2174
+ helpersWGSL: "",
2175
+ inputUniforms: ["ModelTrafo"],
2176
+ });
2177
+ }
2178
+ }
2179
+
2180
+ // Step 4: per-combo axis sources. Rule axes → rule source.
2181
+ // Non-rule axes → const idx of (axisFixedValues.get(axis)
2182
+ // ?? baseValue) within the axis's sorted union.
2183
+ const comboSpecs: import("./derivedModes/kernelCodegen.js").ComboCodegenSpec[] = [];
2184
+ for (const comboKey of comboOrder) {
2185
+ const combo = combosByKey.get(comboKey)!;
2186
+ const axes = axisInfos.map(info => {
2187
+ const axisRuleId = combo.axisRules.get(info.axis);
2188
+ if (axisRuleId !== undefined) {
2189
+ return {
2190
+ axis: info.axis,
2191
+ cardinality: info.values.length,
2192
+ source: { kind: "rule" as const, axisRuleId },
2193
+ };
2194
+ }
2195
+ const effective = combo.axisFixedValues.has(info.axis)
2196
+ ? combo.axisFixedValues.get(info.axis)
2197
+ : info.baseValue;
2198
+ const idx = info.keyToIdx.get(stableStringify(effective)) ?? info.baseIdx;
2199
+ return {
2200
+ axis: info.axis,
2201
+ cardinality: info.values.length,
2202
+ source: { kind: "const" as const, idx },
2203
+ };
2204
+ });
2205
+ comboSpecs.push({ comboId: combo.comboId, axes });
2206
+ }
2207
+
2208
+ // Step 5: enumerate cartesian → slot descriptors (mixed-radix
2209
+ // decode in the same axis order the kernel uses).
2210
+ const slotDescs: PipelineStateDescriptor[] = [];
2211
+ for (let slotIdx = 0; slotIdx < totalSlots; slotIdx++) {
2212
+ let desc = base;
2213
+ let rem = slotIdx;
2214
+ // Match kernel: strides[i] = ∏_{j>i} cards[j]. Decode:
2215
+ // idx[i] = (slotIdx / strides[i]) % cards[i].
2216
+ const strides: number[] = new Array(axisInfos.length).fill(1);
2217
+ for (let i = axisInfos.length - 2; i >= 0; i--) strides[i] = strides[i + 1]! * cards[i + 1]!;
2218
+ for (let i = 0; i < axisInfos.length; i++) {
2219
+ const info = axisInfos[i]!;
2220
+ const ai = Math.floor(rem / strides[i]!) % cards[i]!;
2221
+ rem -= ai * strides[i]!;
2222
+ desc = patchDescriptor(desc, info.axis, info.values[ai]);
2223
+ }
2224
+ slotDescs.push(desc);
2225
+ }
2226
+
2227
+ const kernelWGSL = emitPartitionKernel({
2228
+ rules: ruleSpecs,
2229
+ combos: comboSpecs,
2230
+ totalSlots,
2231
+ uniformOrder: bucket.uniformOrder ?? [],
2232
+ });
2233
+ return { slotDescs, kernelWGSL };
2234
+ }
2235
+
2236
+ /** Build pipeline + slot for each cartesian slot descriptor. */
2237
+ function ensureSlotsForResolved(
2238
+ bucket: Bucket,
2239
+ slotDescs: ReadonlyArray<PipelineStateDescriptor>,
2240
+ ): void {
2241
+ const fam = familyForBucket.get(bucket)!;
2242
+ for (let slotIdx = 0; slotIdx < slotDescs.length; slotIdx++) {
2243
+ const desc = slotDescs[slotIdx]!;
2244
+ const key = encodeModeKey(desc);
2245
+ if (slotIdx < bucket.slots.length) {
2246
+ const slot = bucket.slots[slotIdx]!;
2247
+ if (slot.modeKey !== key) {
2248
+ slot.pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, desc);
2249
+ slot.modeKey = key;
2250
+ }
2251
+ } else {
2252
+ createSlot(bucket, desc);
2253
+ }
2254
+ }
2255
+ }
2256
+
2257
+ /** Register a combo (set of axis rules, ≤ one per axis) on a GPU-
2258
+ * routed bucket (or promote the bucket on first call). Returns
2259
+ * the bucket-unique `comboId` to bake into master-pool records. */
2260
+ function registerCombo(
2261
+ bucket: Bucket,
2262
+ axisRules: ReadonlyArray<DerivedModeRule<ModeAxis>>,
2263
+ baseDesc: PipelineStateDescriptor,
2264
+ ): number {
2265
+ // CPU→GPU promotion: capture pre-existing CPU-routed ROs so they
2266
+ // can be migrated into the master pool after the partition is up.
2267
+ // Each RO is tagged with its CPU descriptor (read off the slot's
2268
+ // `cpuDescriptor` snapshot); we synthesise a const-source combo
2269
+ // per distinct descriptor below, with `axisFixedValues` set on
2270
+ // any axis the descriptor differs from baseDescriptor on.
2271
+ interface CpuMigration {
2272
+ localSlot: number;
2273
+ drawId: number;
2274
+ firstIndex: number;
2275
+ indexCount: number;
2276
+ instanceCount: number;
2277
+ perDrawRefs: Map<string, number>;
2278
+ descriptor: PipelineStateDescriptor;
2279
+ /** Filled in below after synthetic combos are registered. */
2280
+ targetComboId: number;
2281
+ }
2282
+ let cpuMigration: CpuMigration[] | undefined;
2283
+ if (!bucket.gpuRouted && bucket.drawSlots.size > 0) {
2284
+ cpuMigration = [];
2285
+ for (const ls of bucket.drawSlots) {
2286
+ const e = bucket.localEntries[ls]!;
2287
+ const drawId = bucket.localToDrawId[ls]!;
2288
+ const slotIdx = drawIdToSlotIdx[drawId];
2289
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : undefined;
2290
+ const descriptor = slot?.cpuDescriptor;
2291
+ if (descriptor === undefined) {
2292
+ throw new Error(
2293
+ `heapScene/gpuRouting: pre-existing CPU RO (drawId=${drawId}, localSlot=${ls}) ` +
2294
+ `has no captured descriptor — bucket promotion cannot synthesise a migration combo.`,
2295
+ );
2296
+ }
2297
+ cpuMigration.push({
2298
+ localSlot: ls,
2299
+ drawId,
2300
+ firstIndex: e.firstIndex,
2301
+ indexCount: e.indexCount,
2302
+ instanceCount: e.instanceCount,
2303
+ perDrawRefs: bucket.localPerDrawRefs[ls]!,
2304
+ descriptor,
2305
+ targetComboId: 0,
2306
+ });
2307
+ }
2308
+ }
2309
+ if (!bucket.gpuRouted) {
2310
+ bucket.baseDescriptor = baseDesc;
2311
+ bucket.rulesByAxis = new Map();
2312
+ bucket.axisRuleOrder = new Map();
2313
+ bucket.uniformOrder = [];
2314
+ bucket.uniformIdx = new Map();
2315
+ bucket.combosByKey = new Map();
2316
+ bucket.comboOrder = [];
2317
+ bucket.drawIdToComboId = new Map();
2318
+ bucket.drawIdToRecord = new Map();
2319
+ bucket.recordToDrawId = [];
2320
+ // Synthesise one combo per distinct pre-existing CPU
2321
+ // descriptor. Each carries `axisFixedValues` on axes where the
2322
+ // descriptor differs from baseDescriptor; specialiseBucket
2323
+ // expands the per-axis union to include each variant value
2324
+ // and emits a const-source combo fn that routes to the right
2325
+ // cartesian slot.
2326
+ if (cpuMigration !== undefined) {
2327
+ const baseVals = new Map<ModeAxis, unknown>(AXIS_ORDER.map(a => [a, axisValueOfDesc(baseDesc, a)] as const));
2328
+ const synthByKey = new Map<string, number>();
2329
+ for (const m of cpuMigration) {
2330
+ const axisFixedValues = new Map<ModeAxis, unknown>();
2331
+ const keyParts: string[] = [];
2332
+ for (const a of AXIS_ORDER) {
2333
+ const v = axisValueOfDesc(m.descriptor, a);
2334
+ const baseKey = stableStringify(baseVals.get(a));
2335
+ const vKey = stableStringify(v);
2336
+ if (vKey !== baseKey) {
2337
+ axisFixedValues.set(a, v);
2338
+ keyParts.push(`${a}:_:fixed:${vKey}`);
2339
+ } else {
2340
+ keyParts.push(`${a}:_`);
2341
+ }
2342
+ }
2343
+ const comboKey = keyParts.join("|");
2344
+ let comboId = synthByKey.get(comboKey);
2345
+ if (comboId === undefined) {
2346
+ comboId = bucket.comboOrder.length;
2347
+ bucket.combosByKey.set(comboKey, {
2348
+ comboId, comboKey,
2349
+ axisRules: new Map(),
2350
+ axisFixedValues,
2351
+ });
2352
+ bucket.comboOrder.push(comboKey);
2353
+ synthByKey.set(comboKey, comboId);
2354
+ }
2355
+ m.targetComboId = comboId;
2356
+ }
2357
+ }
2358
+ }
2359
+ // Per-axis: dedupe rule by content hash; assign axisRuleId.
2360
+ const perAxisRuleId = new Map<ModeAxis, number>();
2361
+ let respec = false;
2362
+ for (const rule of axisRules) {
2363
+ if (perAxisRuleId.has(rule.axis)) {
2364
+ throw new Error(
2365
+ `heapScene/gpuRouting: combo has two rules on the same axis '${rule.axis}'`,
2366
+ );
2367
+ }
2368
+ const hash = ruleContentHash(rule);
2369
+ let axisMap = bucket.rulesByAxis!.get(rule.axis);
2370
+ if (axisMap === undefined) {
2371
+ axisMap = new Map();
2372
+ bucket.rulesByAxis!.set(rule.axis, axisMap);
2373
+ bucket.axisRuleOrder!.set(rule.axis, []);
2374
+ }
2375
+ let entry = axisMap.get(hash);
2376
+ if (entry === undefined) {
2377
+ const order = bucket.axisRuleOrder!.get(rule.axis)!;
2378
+ const body = ruleBodyOf(rule);
2379
+ entry = {
2380
+ axis: rule.axis,
2381
+ axisRuleId: order.length,
2382
+ contentHash: hash,
2383
+ rule,
2384
+ body,
2385
+ lastDeclaredU32: undefined,
2386
+ };
2387
+ axisMap.set(hash, entry);
2388
+ order.push(hash);
2389
+ // Discover the arena uniforms this rule reads — append unseen
2390
+ // ones to the bucket-wide uniformOrder so master records
2391
+ // carry refs for them. `declared` is a CPU-baked Const, not
2392
+ // an arena uniform; skip it.
2393
+ for (const uName of collectUniformReadsInStmt(body)) {
2394
+ if (uName === "declared") continue;
2395
+ if (!bucket.uniformIdx!.has(uName)) {
2396
+ const idx = bucket.uniformOrder!.length;
2397
+ bucket.uniformOrder!.push(uName);
2398
+ bucket.uniformIdx!.set(uName, idx);
2399
+ }
2400
+ }
2401
+ respec = true;
2402
+ }
2403
+ perAxisRuleId.set(rule.axis, entry.axisRuleId);
2404
+ }
2405
+ // Combo key = canonical-axis-order list of (axis, contentHash).
2406
+ const comboKeyParts: string[] = [];
2407
+ for (const axis of AXIS_ORDER) {
2408
+ const axisRuleId = perAxisRuleId.get(axis);
2409
+ if (axisRuleId === undefined) {
2410
+ comboKeyParts.push(`${axis}:_`);
2411
+ } else {
2412
+ comboKeyParts.push(`${axis}:${bucket.axisRuleOrder!.get(axis)![axisRuleId]!}`);
2413
+ }
2414
+ }
2415
+ const comboKey = comboKeyParts.join("|");
2416
+ let combo = bucket.combosByKey!.get(comboKey);
2417
+ if (combo === undefined) {
2418
+ const comboId = bucket.comboOrder!.length;
2419
+ const axisRules = new Map<ModeAxis, number>();
2420
+ for (const [axis, id] of perAxisRuleId) axisRules.set(axis, id);
2421
+ combo = { comboId, comboKey, axisRules, axisFixedValues: new Map() };
2422
+ bucket.combosByKey!.set(comboKey, combo);
2423
+ bucket.comboOrder!.push(comboKey);
2424
+ respec = true;
2425
+ }
2426
+ // First time gpuRouted? Build the partition scene now.
2427
+ if (!bucket.gpuRouted) {
2428
+ const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
2429
+ ensureSlotsForResolved(bucket, slotDescs);
2430
+ const partition = new GpuPartitionScene(device, `${bucket.label}/partition`, {
2431
+ totalSlots: slotDescs.length,
2432
+ slotDrawBufs: bucket.slots.map(s => s.drawTableBuf!.buffer),
2433
+ kernelWGSL,
2434
+ initialRecords: Math.max(16, (cpuMigration?.length ?? 0) + 16),
2435
+ numUniforms: bucket.uniformOrder!.length,
2436
+ });
2437
+ bucket.gpuRouted = true;
2438
+ bucket.partitionScene = partition;
2439
+ bucket.partitionDirty = true;
2440
+ // Migrate any captured CPU records into the master pool, each
2441
+ // using the synthetic combo registered for its CPU descriptor.
2442
+ if (cpuMigration !== undefined) {
2443
+ let totalEmit = 0;
2444
+ for (const m of cpuMigration) {
2445
+ const uRefs = bucket.uniformOrder!.map(n => m.perDrawRefs.get(n) ?? 0);
2446
+ const recIdx = partition.appendRecord(
2447
+ m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs,
2448
+ );
2449
+ bucket.drawIdToRecord!.set(m.drawId, recIdx);
2450
+ bucket.recordToDrawId![recIdx] = m.drawId;
2451
+ bucket.drawIdToComboId!.set(m.drawId, m.targetComboId);
2452
+ totalEmit += m.indexCount * m.instanceCount;
2453
+ }
2454
+ const numRecords = partition.numRecords;
2455
+ const recBytes = numRecords * RECORD_BYTES;
2456
+ const needBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2457
+ for (const s of bucket.slots) {
2458
+ s.drawTableBuf!.ensureCapacity(recBytes);
2459
+ s.drawTableBuf!.setUsed(Math.max(s.drawTableBuf!.usedBytes, recBytes));
2460
+ s.blockSumsBuf!.ensureCapacity(needBlocks * 4);
2461
+ s.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
2462
+ s.recordCount = numRecords;
2463
+ s.totalEmitEstimate += totalEmit;
2464
+ const newNumTiles = Math.max(1, Math.ceil(s.totalEmitEstimate / TILE_K));
2465
+ s.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
2466
+ s.scanDirty = true;
2467
+ }
2468
+ }
2469
+ } else if (respec) {
2470
+ const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
2471
+ ensureSlotsForResolved(bucket, slotDescs);
2472
+ const newCount = bucket.slots.length;
2473
+ const draws = bucket.slots.map(s => s.drawTableBuf!.buffer);
2474
+ if (newCount > bucket.partitionScene!.totalSlots) {
2475
+ bucket.partitionScene!.growSlots(newCount, draws);
2476
+ } else {
2477
+ bucket.partitionScene!.rebindSlotDrawBufs(draws);
2478
+ }
2479
+ // Bucket-wide uniformOrder may have grown (a new rule reads a
2480
+ // previously-unseen uniform). Widen master records before
2481
+ // rebuilding the kernel — the WGSL struct width changes too.
2482
+ if (bucket.uniformOrder!.length > bucket.partitionScene!.numUniforms) {
2483
+ bucket.partitionScene!.growUniforms(bucket.uniformOrder!.length);
2484
+ }
2485
+ bucket.partitionScene!.rebuildKernel(kernelWGSL);
2486
+ bucket.partitionDirty = true;
2487
+ }
2488
+ return combo.comboId;
2489
+ }
2490
+
2491
+ /**
2492
+ * Per-frame partition dispatch. Detects per-rule `declared`
2493
+ * changes and re-specialises the whole bucket if any rule's
2494
+ * declared value differs from what was used at the last
2495
+ * dispatch (cheap — analysis runs on cached IR).
2496
+ */
2497
+ function dispatchPartition(bucket: Bucket, enc: GPUCommandEncoder, tok: AdaptiveToken): void {
2498
+ if (!bucket.gpuRouted || bucket.partitionScene === undefined) return;
2499
+ let needRespec = false;
2500
+ outer: for (const axisMap of bucket.rulesByAxis!.values()) {
2501
+ for (const entry of axisMap.values()) {
2502
+ const declaredU32 = resolveDeclaredU32(entry.rule, tok);
2503
+ if (declaredU32 !== entry.lastDeclaredU32) { needRespec = true; break outer; }
2504
+ }
2505
+ }
2506
+ if (needRespec) {
2507
+ const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
2508
+ ensureSlotsForResolved(bucket, slotDescs);
2509
+ const newCount = bucket.slots.length;
2510
+ const draws = bucket.slots.map(s => s.drawTableBuf!.buffer);
2511
+ if (newCount > bucket.partitionScene.totalSlots) {
2512
+ bucket.partitionScene.growSlots(newCount, draws);
2513
+ } else {
2514
+ bucket.partitionScene.rebindSlotDrawBufs(draws);
2515
+ }
2516
+ bucket.partitionScene.rebuildKernel(kernelWGSL);
2517
+ }
2518
+ bucket.partitionScene.flush();
2519
+ bucket.partitionScene.dispatch(arena.attrs.chunk(bucket.chunkIdx).buffer, enc);
2520
+ for (let i = 0; i < bucket.slots.length; i++) {
2521
+ enc.copyBufferToBuffer(
2522
+ bucket.partitionScene.slotCountBufs[i]!, 0,
2523
+ bucket.slots[i]!.paramsBuf!, 0, 4,
2524
+ );
2525
+ bucket.slots[i]!.scanDirty = true;
2526
+ }
2527
+ bucket.partitionDirty = false;
2528
+ }
2529
+
2530
+
1651
2531
  function findOrCreateBucket(
1652
2532
  effect: Effect,
1653
2533
  _textures: HeapTextureSet | undefined,
1654
2534
  pipelineState: PipelineState | undefined,
2535
+ chunkIdx: number,
1655
2536
  /**
1656
2537
  * Optional precomputed descriptor. addDrawImpl passes this when
1657
2538
  * the spec carries `modeRules` so the bucket key reflects the
@@ -1664,12 +2545,13 @@ export function buildHeapScene(
1664
2545
  throw new Error("heapScene: findOrCreateBucket called before family build");
1665
2546
  }
1666
2547
  const fam = familyFor(effect);
1667
- // Phase 5c.2: bucket key drops modeKey. All ROs with the same
1668
- // (effect, textureSet) share one bucket; per-modeKey slots live
1669
- // inside the bucket and route records via `ensureSlot`. Pipeline
1670
- // pre-warm + the scene-level pipelineByModeKey cache mean every
1671
- // realized slot lookup hits the cache.
1672
- const bk = `family#${fam.schema.id}`;
2548
+ // §3: bucket key includes `chunkIdx` two ROs with the same
2549
+ // effect/family that landed in different arena chunks live in
2550
+ // separate buckets so each bucket can bind exactly one chunk's
2551
+ // GPUBuffers in its draw call. The shaders are unchanged
2552
+ // (refs are byte offsets within whichever chunk's buffer is
2553
+ // currently bound).
2554
+ const bk = `family#${fam.schema.id}/c${chunkIdx}`;
1673
2555
  const existing = bucketByKey.get(bk);
1674
2556
  if (existing !== undefined) return existing;
1675
2557
 
@@ -1682,7 +2564,7 @@ export function buildHeapScene(
1682
2564
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
1683
2565
 
1684
2566
  const bucket: Bucket = {
1685
- label: bk, textures: undefined, layout,
2567
+ label: bk, textures: undefined, layout, chunkIdx,
1686
2568
  slots: [], // populated lazily by ensureSlot per modeKey
1687
2569
  bindGroup: null as unknown as GPUBindGroup, // legacy field; slot.bindGroup is the real one
1688
2570
  drawHeap,
@@ -1696,6 +2578,19 @@ export function buildHeapScene(
1696
2578
  localAtlasReleases: [],
1697
2579
  localAtlasTextures: [],
1698
2580
  localAtlasArrIdx: [],
2581
+ gpuRouted: false,
2582
+ partitionScene: undefined,
2583
+ drawIdToRecord: undefined,
2584
+ recordToDrawId: undefined,
2585
+ rulesByAxis: undefined,
2586
+ axisRuleOrder: undefined,
2587
+ uniformOrder: undefined,
2588
+ uniformIdx: undefined,
2589
+ combosByKey: undefined,
2590
+ comboOrder: undefined,
2591
+ drawIdToComboId: undefined,
2592
+ baseDescriptor: undefined,
2593
+ partitionDirty: false,
1699
2594
  };
1700
2595
  familyForBucket.set(bucket, fam);
1701
2596
 
@@ -1929,12 +2824,59 @@ export function buildHeapScene(
1929
2824
  token: outerTok,
1930
2825
  });
1931
2826
  }
1932
- const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
2827
+ // §3 chunk routing pick which arena chunk this RO's allocations
2828
+ // land in. Preference order:
2829
+ // 1. If any of the RO's bindings is an aval that's already
2830
+ // allocated in some chunk via the uniform pool, prefer that
2831
+ // chunk to avoid duplication.
2832
+ // 2. Otherwise, use the newest open chunk (highest chunkIdx).
2833
+ // ChunkedArena.alloc spills to the next chunk automatically
2834
+ // when this one is full; the spill ends up driving the
2835
+ // pool's `finalChunk` selection so all of the RO's
2836
+ // allocations land in one chunk consistently.
2837
+ let chunkIdx: number;
2838
+ {
2839
+ let preferred: number | undefined;
2840
+ for (const [, val] of Object.entries(spec.inputs)) {
2841
+ const av = (typeof val === "object" && val !== null && "getValue" in (val as object))
2842
+ ? (val as aval<unknown>) : undefined;
2843
+ if (av !== undefined) {
2844
+ const where = pool.firstChunkContaining(av);
2845
+ if (where !== undefined) { preferred = where; break; }
2846
+ }
2847
+ }
2848
+ chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
2849
+ }
2850
+ const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
2851
+ // Phase 5c.3 — every RO carrying a derived-mode rule is registered
2852
+ // on the bucket (lazy-promoting it to GPU-routed on the first
2853
+ // call). ROs may carry DIFFERENT rules even when they share the
2854
+ // bucket's `(effect, textureSet)` — each gets its own `ruleId`
2855
+ // baked into the master-pool record and the partition kernel's
2856
+ // dispatch switch picks the right body per record.
2857
+ const collectedRules = spec.modeRules !== undefined ? collectRules(spec.modeRules) : [];
2858
+ const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
2859
+ let roComboId = 0;
2860
+ if (collectedRules.length > 0) {
2861
+ roComboId = registerCombo(bucket, collectedRules, roDescriptor);
2862
+ } else if (bucket.gpuRouted) {
2863
+ // An unruled RO joining an already-GPU-routed bucket needs a
2864
+ // real combo to land on. Register the trivial all-fixed combo
2865
+ // (every axis falls back to baseDescriptor) so this record's
2866
+ // `comboId` doesn't silently alias to combo 0 (which is
2867
+ // whichever combo was registered first — almost never the
2868
+ // "no rules" intent).
2869
+ roComboId = registerCombo(bucket, [], roDescriptor);
2870
+ }
1933
2871
  // Phase 5c.2: route this RO to the bucket's slot covering its
1934
2872
  // current descriptor. ensureSlot creates a new slot on miss; the
1935
- // pipeline lookup hits the scene-level cache.
1936
- const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
1937
- const roSlot = ensureSlot(bucket, roDescriptor);
2873
+ // pipeline lookup hits the scene-level cache. For GPU-routed
2874
+ // buckets the slot decision is made each frame on the GPU; we
2875
+ // still pin the RO to slot 0 (declared) CPU-side so existing
2876
+ // accounting paths (slotToRecord, dirty bookkeeping) keep
2877
+ // working. The partition kernel overwrites both slots' draw
2878
+ // tables every frame from the master.
2879
+ const roSlot = bucket.gpuRouted ? bucket.slots[0]! : ensureSlot(bucket, roDescriptor);
1938
2880
  const roSlotIdx = bucket.slots.indexOf(roSlot);
1939
2881
  const fam = familyFor(spec.effect);
1940
2882
  const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
@@ -1944,7 +2886,7 @@ export function buildHeapScene(
1944
2886
  // index allocation + one upload.
1945
2887
  const indicesAval = asAval(spec.indices) as aval<Uint32Array>;
1946
2888
  const indicesArr = readPlain(spec.indices) as Uint32Array;
1947
- const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, indicesArr);
2889
+ const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, bucket.chunkIdx, indicesArr);
1948
2890
 
1949
2891
  const localSlot = bucket.drawHeap.alloc();
1950
2892
  // Per-RO instancing: read `spec.instanceCount` (defaults to 1).
@@ -1977,7 +2919,7 @@ export function buildHeapScene(
1977
2919
  if (ruleForUniform(spec, f.name) !== undefined) {
1978
2920
  const dummyAval = AVal.constant<M44d>(M44d.zero);
1979
2921
  const ref = pool.acquire(
1980
- device, arena.attrs, dummyAval, M44d.zero,
2922
+ device, arena.attrs, dummyAval, bucket.chunkIdx, M44d.zero,
1981
2923
  PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack,
1982
2924
  );
1983
2925
  perDrawRefs.set(f.name, ref);
@@ -2026,7 +2968,7 @@ export function buildHeapScene(
2026
2968
  : poolPlacementFor(f, value);
2027
2969
  }
2028
2970
  const ref = pool.acquire(
2029
- device, arena.attrs, av, value,
2971
+ device, arena.attrs, av, bucket.chunkIdx, value,
2030
2972
  placement.dataBytes, placement.typeId, placement.length, placement.pack,
2031
2973
  );
2032
2974
  perDrawRefs.set(f.name, ref);
@@ -2072,7 +3014,53 @@ export function buildHeapScene(
2072
3014
  const end = byteOff + bucket.layout.drawHeaderBytes;
2073
3015
  if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
2074
3016
 
2075
- {
3017
+ if (bucket.gpuRouted) {
3018
+ // Phase 5c.3 GPU routing: master pool is authoritative.
3019
+ // Both slot[0] and slot[1] get the SAME accounting (counts,
3020
+ // emit estimates, buffer capacities) — partition overwrites
3021
+ // their GPU drawTables every frame, so per-slot CPU shadows
3022
+ // are irrelevant. We still grow their buffers in lockstep so
3023
+ // the scan dispatch shape covers every possible record.
3024
+ const partition = bucket.partitionScene!;
3025
+ const recIdx = partition.numRecords;
3026
+ if (recIdx >= SCAN_MAX_RECORDS) {
3027
+ throw new Error(
3028
+ `heapScene: GPU-routed bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS})`,
3029
+ );
3030
+ }
3031
+ // Pack the bucket's per-RO uniform refs (arena byte offsets) in
3032
+ // the same order as `bucket.uniformOrder`. ROs whose perDrawRefs
3033
+ // don't carry a given name (e.g. their effect doesn't declare
3034
+ // it) get 0 — the kernel only reads refs through combo fns that
3035
+ // gate on `r.comboId`, so an unread 0 is harmless.
3036
+ const uniformRefs: number[] = (bucket.uniformOrder ?? []).map(
3037
+ name => perDrawRefs.get(name) ?? 0,
3038
+ );
3039
+ partition.appendRecord(localSlot, idxAlloc.firstIndex, idxAlloc.count, instanceCount, roComboId, uniformRefs);
3040
+ bucket.drawIdToRecord!.set(drawId, recIdx);
3041
+ bucket.recordToDrawId![recIdx] = drawId;
3042
+ bucket.drawIdToComboId!.set(drawId, roComboId);
3043
+ // Master grew? Invalidate partition's bind group so it picks
3044
+ // up the new masterBuf. (rebindSlotDrawBufs nulls bindGroup.)
3045
+ // grow() inside appendRecord already does this when capacity
3046
+ // is exceeded; nothing to do here.
3047
+ const numRecords = recIdx + 1;
3048
+ const recBytes = numRecords * RECORD_BYTES;
3049
+ const needBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
3050
+ const emitDelta = idxAlloc.count * instanceCount;
3051
+ for (const s of bucket.slots) {
3052
+ s.drawTableBuf!.ensureCapacity(recBytes);
3053
+ s.drawTableBuf!.setUsed(Math.max(s.drawTableBuf!.usedBytes, recBytes));
3054
+ s.blockSumsBuf!.ensureCapacity(needBlocks * 4);
3055
+ s.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
3056
+ s.recordCount = numRecords;
3057
+ s.totalEmitEstimate += emitDelta;
3058
+ const newNumTiles = Math.max(1, Math.ceil(s.totalEmitEstimate / TILE_K));
3059
+ s.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
3060
+ s.scanDirty = true;
3061
+ }
3062
+ bucket.partitionDirty = true;
3063
+ } else {
2076
3064
  const dtBuf = roSlot.drawTableBuf!;
2077
3065
  const recIdx = roSlot.recordCount;
2078
3066
  if (recIdx >= SCAN_MAX_RECORDS) {
@@ -2144,34 +3132,18 @@ export function buildHeapScene(
2144
3132
  drawIdToModeTracker[drawId] = tracker;
2145
3133
  tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
2146
3134
 
2147
- // ─── GPU-eval mode rule registration (Task 2 Phase 5) ────────────
2148
- // If spec.modeRules.cull is GPU-flavoured, register the RO with
2149
- // the scene-wide dispatcher. Provide the arena byte offset of the
2150
- // RO's input uniform so the kernel reads from the right slot.
2151
- {
2152
- const cullRule = spec.modeRules?.cull;
2153
- const gpuRule = cullRule?.gpu;
2154
- if (gpuRule !== undefined) {
2155
- // Subscribe to the declared aval (if reactive) so the cull-
2156
- // mode flip propagates through the rule's CPU fallback
2157
- // recompute path.
2158
- if (typeof gpuRule.declared !== "string") {
2159
- subscribeModeLeaf(gpuRule.declared as aval<unknown>, drawId);
2160
- }
2161
- }
2162
-
2163
- // ─── Pipeline pre-warm for derived-mode rules (5c.2) ──────────
2164
- // Enumerate the rule's possible outputs and pre-build the
2165
- // matching pipelines in the scene-level cache. Future cullModeC
2166
- // flips become cache hits — zero `createRenderPipeline` calls
2167
- // on the hot path.
2168
- if (cullRule !== undefined && cullRule.domain !== undefined && tracker.descriptor !== undefined) {
2169
- const fam = familyFor(spec.effect);
2170
- const baseDesc = tracker.descriptor;
2171
- for (const mode of cullRule.domain) {
2172
- if (mode === baseDesc.cullMode) continue; // already built
2173
- const variantDesc: PipelineStateDescriptor = { ...baseDesc, cullMode: mode };
2174
- getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, variantDesc);
3135
+ // ─── Derived-mode rule registration ────────────────────────────
3136
+ // Subscribe to each rule's `declared` aval so the bucket's
3137
+ // partition kernel re-dispatches when it changes. Slot pipelines
3138
+ // for every (axis, declared) combination are pre-built eagerly
3139
+ // in `initGpuRouting` (one per cartesian-product point), so
3140
+ // declared flips are pipeline-cache hits.
3141
+ if (spec.modeRules !== undefined) {
3142
+ for (const rule of Object.values(spec.modeRules) as Array<DerivedModeRule<ModeAxis> | undefined>) {
3143
+ if (rule === undefined) continue;
3144
+ const d = rule.declared;
3145
+ if (typeof d === "object" && d !== null && "getValue" in (d as object)) {
3146
+ subscribeModeLeaf(d as aval<unknown>, drawId);
2175
3147
  }
2176
3148
  }
2177
3149
  }
@@ -2213,16 +3185,13 @@ export function buildHeapScene(
2213
3185
  const reg = registerRoDerivations(derivedScene, {}, {
2214
3186
  rules: ruleSubset,
2215
3187
  trafoAvals,
2216
- // A non-trafo rule leaf reads the uniform's data straight from this RO's
2217
- // drawHeader region in the arena (= its pool ref + the alloc-header pad).
2218
- // Only resolvable if the uniform was actually packed (i.e. it's a drawHeader
2219
- // field of the effect too) — undefined otherwise, which `resolveSource` rejects.
2220
3188
  hostUniformOffset: (n) => {
2221
3189
  const r = perDrawRefs.get(n);
2222
3190
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
2223
3191
  },
2224
3192
  outputOffset: (n) => outOffsetByName.get(n),
2225
3193
  drawHeaderBaseByte: 0,
3194
+ chunkIdx: bucket.chunkIdx,
2226
3195
  });
2227
3196
  derivedByDrawId.set(drawId, reg);
2228
3197
  }
@@ -2245,7 +3214,36 @@ export function buildHeapScene(
2245
3214
  derivedByDrawId.delete(drawId);
2246
3215
  }
2247
3216
  }
2248
- {
3217
+ if (bucket.gpuRouted) {
3218
+ const partition = bucket.partitionScene!;
3219
+ const recIdx = bucket.drawIdToRecord!.get(drawId);
3220
+ const removedEntry = bucket.localEntries[localSlot];
3221
+ const removedCount = removedEntry !== undefined
3222
+ ? removedEntry.indexCount * removedEntry.instanceCount
3223
+ : 0;
3224
+ if (recIdx !== undefined) {
3225
+ const moved = partition.removeRecord(recIdx);
3226
+ if (moved >= 0) {
3227
+ // The record at `moved` (the old tail) now lives at recIdx.
3228
+ // Fix maps for the moved drawId.
3229
+ const movedDrawId = bucket.recordToDrawId![moved]!;
3230
+ bucket.drawIdToRecord!.set(movedDrawId, recIdx);
3231
+ bucket.recordToDrawId![recIdx] = movedDrawId;
3232
+ bucket.recordToDrawId![moved] = -1;
3233
+ } else {
3234
+ bucket.recordToDrawId![recIdx] = -1;
3235
+ }
3236
+ bucket.drawIdToRecord!.delete(drawId);
3237
+ }
3238
+ // Mirror the count + emit-estimate decrement across every slot
3239
+ // (both share the master's accounting).
3240
+ for (const s of bucket.slots) {
3241
+ s.recordCount = partition.numRecords;
3242
+ s.totalEmitEstimate = Math.max(0, s.totalEmitEstimate - removedCount);
3243
+ s.scanDirty = true;
3244
+ }
3245
+ bucket.partitionDirty = true;
3246
+ } else {
2249
3247
  const slotIdx = drawIdToSlotIdx[drawId];
2250
3248
  const slot = slotIdx !== undefined ? bucket.slots[slotIdx]! : bucket.slots[0]!;
2251
3249
  const removedEntry = bucket.localEntries[localSlot];
@@ -2282,9 +3280,9 @@ export function buildHeapScene(
2282
3280
 
2283
3281
  // Release pool entries — refcount drops; if zero, allocation freed.
2284
3282
  const avals = bucket.localPerDrawAvals[localSlot];
2285
- if (avals !== undefined) for (const av of avals) pool.release(arena.attrs, av);
3283
+ if (avals !== undefined) for (const av of avals) pool.release(arena.attrs, av, bucket.chunkIdx);
2286
3284
  const idxAval = drawIdToIndexAval[drawId];
2287
- if (idxAval !== undefined) indexPool.release(arena.indices, idxAval);
3285
+ if (idxAval !== undefined) indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
2288
3286
  const atlasRel = bucket.localAtlasReleases[localSlot];
2289
3287
  if (atlasRel !== undefined) atlasRel();
2290
3288
  // Drop atlas-aval ref (if any). When the last ref is dropped we
@@ -2458,11 +3456,19 @@ export function buildHeapScene(
2458
3456
  for (const drawId of dirty) {
2459
3457
  const tracker = drawIdToModeTracker[drawId];
2460
3458
  if (tracker === undefined) continue;
3459
+ const bucket = drawIdToBucket[drawId];
3460
+ // Phase 5c.3: GPU-routed buckets never reslot CPU-side.
3461
+ // The partition kernel handles re-routing per frame.
3462
+ // Just mark the bucket so the next encodeComputePrep
3463
+ // dispatches partition.
3464
+ if (bucket !== undefined && bucket.gpuRouted) {
3465
+ bucket.partitionDirty = true;
3466
+ continue;
3467
+ }
2461
3468
  const oldKey = tracker.modeKey;
2462
3469
  if (!tracker.recompute()) continue;
2463
3470
  if (tracker.modeKey === oldKey) continue;
2464
3471
 
2465
- const bucket = drawIdToBucket[drawId];
2466
3472
  const localSlot = drawIdToLocalSlot[drawId];
2467
3473
  const oldSlotIdx = drawIdToSlotIdx[drawId];
2468
3474
  if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined) continue;
@@ -2534,8 +3540,7 @@ export function buildHeapScene(
2534
3540
  if (allocDirty.size > 0) {
2535
3541
  for (const av of allocDirty) {
2536
3542
  pool.repack(device, arena.attrs, av, av.getValue(tok));
2537
- const e = pool.entry(av);
2538
- if (e !== undefined) totalDirtyBytes += e.dataBytes;
3543
+ totalDirtyBytes += pool.totalDataBytes(av);
2539
3544
  }
2540
3545
  allocDirty.clear();
2541
3546
  }
@@ -2625,6 +3630,9 @@ export function buildHeapScene(
2625
3630
  }
2626
3631
  // Phase 5c.2: each slot has its own drawTable + shadow. Upload
2627
3632
  // the dirty range of every slot, not just slots[0].
3633
+ // Phase 5c.3: GPU-routed buckets skip this entirely — the
3634
+ // partition kernel writes slot drawTables directly each frame.
3635
+ if (bucket.gpuRouted) continue;
2628
3636
  for (const slot of bucket.slots) {
2629
3637
  if (slot.drawTableDirtyMax <= slot.drawTableDirtyMin) continue;
2630
3638
  const shadow = slot.drawTableShadow!;
@@ -2698,15 +3706,24 @@ export function buildHeapScene(
2698
3706
  stats.derivedPullMs = _t1 - _t0;
2699
3707
  stats.derivedUploadMs = _t2 - _t1;
2700
3708
  stats.derivedEncodeMs = _t3 - _t2;
2701
- stats.derivedRecords = derivedScene.records.recordCount;
3709
+ stats.derivedRecords = derivedScene.totalRecordCount;
2702
3710
  }
2703
3711
  let anyDirty = false;
2704
3712
  outer: for (const b of buckets) {
3713
+ if (b.gpuRouted && b.partitionDirty) { anyDirty = true; break outer; }
2705
3714
  for (const s of b.slots) {
2706
3715
  if (s.scanDirty) { anyDirty = true; break outer; }
2707
3716
  }
2708
3717
  }
2709
3718
  if (!anyDirty) return;
3719
+ // Phase 5c.3: per GPU-routed bucket — dispatch partition (its own
3720
+ // compute pass), then copyBufferToBuffer slot counts into scan
3721
+ // paramsBuf. Must happen BEFORE the shared scan pass below.
3722
+ for (const b of buckets) {
3723
+ if (b.gpuRouted && b.partitionDirty) {
3724
+ dispatchPartition(b, enc, token);
3725
+ }
3726
+ }
2710
3727
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
2711
3728
  for (const b of buckets) {
2712
3729
  for (let i = 0; i < b.slots.length; i++) {
@@ -2867,8 +3884,18 @@ export function buildHeapScene(
2867
3884
  let tilesChecked = 0, tilesBad = 0;
2868
3885
  let vidChecks = 0, vidBad = 0;
2869
3886
  const push = (s: string) => { if (issues.length < 60) issues.push(s); };
2870
- const arenaSize = arena.attrs.buffer.size;
2871
- const indicesSize = arena.indices.buffer.size;
3887
+ // §3 v1: validateHeap currently only diagnoses chunk 0 — a
3888
+ // multi-chunk-aware validator needs per-bucket-chunkIdx
3889
+ // staging which is a bigger refactor; assert for now so
3890
+ // multi-chunk callers don't get false-passes.
3891
+ if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
3892
+ throw new Error(
3893
+ "heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
3894
+ "Run with the default chunk cap (no second chunk opened) for now.",
3895
+ );
3896
+ }
3897
+ const arenaSize = arena.attrs.chunk(0).buffer.size;
3898
+ const indicesSize = arena.indices.chunk(0).buffer.size;
2872
3899
 
2873
3900
  const enc = device.createCommandEncoder({ label: "validateHeap" });
2874
3901
  const stage = (src: GPUBuffer, size: number): GPUBuffer => {
@@ -2880,8 +3907,8 @@ export function buildHeapScene(
2880
3907
  return c;
2881
3908
  };
2882
3909
 
2883
- const arenaCopy = stage(arena.attrs.buffer, arenaSize);
2884
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
3910
+ const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3911
+ const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
2885
3912
  type DC = {
2886
3913
  bucket: typeof buckets[number];
2887
3914
  drawHeap: GPUBuffer;
@@ -3170,8 +4197,12 @@ export function buildHeapScene(
3170
4197
  let emitsChecked = 0;
3171
4198
  const push = (s: string) => { if (issues.length < 30) issues.push(s); };
3172
4199
 
3173
- const arenaSize = arena.attrs.buffer.size;
3174
- const indicesSize = arena.indices.buffer.size;
4200
+ // §3 v1: chunk-0-only like validateHeap.
4201
+ if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4202
+ throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
4203
+ }
4204
+ const arenaSize = arena.attrs.chunk(0).buffer.size;
4205
+ const indicesSize = arena.indices.chunk(0).buffer.size;
3175
4206
 
3176
4207
  // Stage all buffers we'll need.
3177
4208
  const enc = device.createCommandEncoder({ label: "simulateDraws" });
@@ -3182,8 +4213,8 @@ export function buildHeapScene(
3182
4213
  enc.copyBufferToBuffer(src, 0, c, 0, size);
3183
4214
  return c;
3184
4215
  };
3185
- const arenaCopy = stage(arena.attrs.buffer, arenaSize);
3186
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
4216
+ const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
4217
+ const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
3187
4218
  type DC = {
3188
4219
  bucket: typeof buckets[number];
3189
4220
  drawHeap: GPUBuffer;
@@ -3453,19 +4484,25 @@ export function buildHeapScene(
3453
4484
  // cross-RO triangle symptom.
3454
4485
  const allocClaims: { ref: number; bytes: number; owner: string }[] = [];
3455
4486
 
4487
+ // §3 v1: chunk-0-only (see validateHeap).
4488
+ if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
4489
+ throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
4490
+ }
4491
+ const arenaBuf0 = arena.attrs.chunk(0).buffer;
4492
+ const indicesBuf0 = arena.indices.chunk(0).buffer;
3456
4493
  // Stage arena + arena.indices once (shared across buckets).
3457
4494
  const arenaCopy = device.createBuffer({
3458
- size: arena.attrs.buffer.size,
4495
+ size: arenaBuf0.size,
3459
4496
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3460
4497
  });
3461
- const indicesCopy = arena.indices.buffer.size > 0 ? device.createBuffer({
3462
- size: arena.indices.buffer.size,
4498
+ const indicesCopy = indicesBuf0.size > 0 ? device.createBuffer({
4499
+ size: indicesBuf0.size,
3463
4500
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3464
4501
  }) : undefined;
3465
4502
  {
3466
4503
  const enc = device.createCommandEncoder({ label: "checkTriangleCoherence.arena" });
3467
- enc.copyBufferToBuffer(arena.attrs.buffer, 0, arenaCopy, 0, arena.attrs.buffer.size);
3468
- if (indicesCopy !== undefined) enc.copyBufferToBuffer(arena.indices.buffer, 0, indicesCopy, 0, arena.indices.buffer.size);
4504
+ enc.copyBufferToBuffer(arenaBuf0, 0, arenaCopy, 0, arenaBuf0.size);
4505
+ if (indicesCopy !== undefined) enc.copyBufferToBuffer(indicesBuf0, 0, indicesCopy, 0, indicesBuf0.size);
3469
4506
  device.queue.submit([enc.finish()]);
3470
4507
  }
3471
4508
  await arenaCopy.mapAsync(GPUMapMode.READ);
@@ -3898,7 +4935,7 @@ export function buildHeapScene(
3898
4935
  { binding: 0, resource: { buffer: target.slots[0]!.drawTableBuf!.buffer } },
3899
4936
  { binding: 1, resource: { buffer: target.slots[0]!.firstDrawInTileBuf!.buffer } },
3900
4937
  { binding: 2, resource: { buffer: sampleBuf } },
3901
- { binding: 3, resource: { buffer: arena.indices.buffer } },
4938
+ { binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
3902
4939
  { binding: 4, resource: { buffer: outBuf } },
3903
4940
  { binding: 5, resource: { buffer: paramBuf } },
3904
4941
  ],
@@ -3921,12 +4958,13 @@ export function buildHeapScene(
3921
4958
  sampleBuf.destroy(); outBuf.destroy(); paramBuf.destroy();
3922
4959
 
3923
4960
  // Read drawTable + indices for CPU expectation.
4961
+ const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
3924
4962
  const indicesCopy2 = device.createBuffer({
3925
- size: arena.indices.buffer.size,
4963
+ size: idxBuf.size,
3926
4964
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3927
4965
  });
3928
4966
  const enc2 = device.createCommandEncoder();
3929
- enc2.copyBufferToBuffer(arena.indices.buffer, 0, indicesCopy2, 0, arena.indices.buffer.size);
4967
+ enc2.copyBufferToBuffer(idxBuf, 0, indicesCopy2, 0, idxBuf.size);
3930
4968
  device.queue.submit([enc2.finish()]);
3931
4969
  await indicesCopy2.mapAsync(GPUMapMode.READ);
3932
4970
  const indicesU32 = new Uint32Array(indicesCopy2.getMappedRange().slice(0));