@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
@@ -52,10 +52,14 @@ import { DerivedUniformsScene, registerRoDerivations, deregisterRoDerivations, i
52
52
  import { packerForWgslType, PACKER_MAT4 } from "./heapScene/packers.js";
53
53
  import { HEAP_SCAN_WGSL, SCAN_TILE_SIZE, SCAN_WG_SIZE, SCAN_MAX_RECORDS, TILE_K, RECORD_U32, RECORD_BYTES, } from "./heapScene/scanKernel.js";
54
54
  import { GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16, } from "./heapScene/growBuffer.js";
55
- import { UniformPool, IndexPool, DrawHeap, AttributeArena, IndexAllocator, insertSortedFreeBlock, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS, } from "./heapScene/pools.js";
55
+ import { UniformPool, IndexPool, DrawHeap, AttributeArena, IndexAllocator, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS, } from "./heapScene/pools.js";
56
56
  import { encodeModeKey } from "./pipelineCache/index.js";
57
57
  import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
58
+ import { GpuPartitionScene } from "./derivedModes/partitionDispatcher.js";
59
+ import { emitPartitionKernel, substituteReadInputInStmt, collectUniformReadsInStmt, rewriteOutputsToSlotIndices, } from "./derivedModes/kernelCodegen.js";
60
+ import {} from "./derivedModes/rule.js";
58
61
  import { addMarkingCallback } from "@aardworx/wombat.adaptive";
62
+ import { analyseOutputSet, evaluateStructural, evaluateStructuralSet, stableStringify, } from "@aardworx/wombat.shader/ir";
59
63
  export function buildHeapScene(device, sig, initialDraws, opts = {}) {
60
64
  const atlasPool = opts.atlasPool;
61
65
  const colorAttachmentName = sig.colorNames[0];
@@ -82,7 +86,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
82
86
  // demand. Skip per-draw enumeration since aval-keyed sharing makes
83
87
  // the actual allocated size hard to predict (10K instanced draws
84
88
  // sharing the same Positions array → 1 alloc, not 10K).
85
- const arena = buildArenaState(device, 64 * 1024, 16 * 1024, "heapScene", GPUBufferUsage.STORAGE);
89
+ const arena = buildArenaState(device, 64 * 1024, 16 * 1024, "heapScene", GPUBufferUsage.STORAGE, opts.maxChunkBytes);
86
90
  // ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
87
91
  const drawIdToBucket = [];
88
92
  const drawIdToLocalSlot = [];
@@ -307,7 +311,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
307
311
  inputChanged(_t, o) {
308
312
  // Pool avals are stored as `aval<unknown>`; the IAdaptiveObject
309
313
  // identity matches.
310
- if (pool.has(o)) {
314
+ if (pool.hasAny(o)) {
311
315
  allocDirty.add(o);
312
316
  return;
313
317
  }
@@ -331,11 +335,22 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
331
335
  // `enableDerivedUniforms: false` to opt out.
332
336
  const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
333
337
  const derivedScene = enableDerivedUniforms
334
- ? new DerivedUniformsScene(device, arena.attrs.buffer, {
338
+ ? new DerivedUniformsScene(device, {
335
339
  // Larger initial capacity reduces grow-during-first-frame churn.
336
340
  initialConstituentSlots: 4096,
337
341
  })
338
342
  : undefined;
343
+ // §3: register each arena chunk's main-heap buffer with the
344
+ // derived-uniforms scene; refresh the binding when chunks open or
345
+ // a chunk's GrowBuffer reallocates.
346
+ if (derivedScene !== undefined) {
347
+ const wireChunk = (idx) => {
348
+ derivedScene.setMainHeapForChunk(idx, () => arena.attrs.chunk(idx).buffer);
349
+ };
350
+ for (let i = 0; i < arena.attrs.chunkCount; i++)
351
+ wireChunk(i);
352
+ arena.attrs.onChunkAdded(wireChunk);
353
+ }
339
354
  /** Per-RO §7 registration handles, keyed by global drawId.
340
355
  * Drained on removeDraw to release slots + records. */
341
356
  const derivedByDrawId = new Map();
@@ -798,11 +813,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
798
813
  // SAME global arena GPUBuffer (emscripten-style aliasing). The
799
814
  // WGSL prelude declares one binding per view; the shader picks
800
815
  // whichever matches its read.
816
+ const attrsBuf = arena.attrs.chunk(bucket.chunkIdx).buffer;
801
817
  const entries = [
802
- { binding: 0, resource: { buffer: arena.attrs.buffer } }, // heapU32
818
+ { binding: 0, resource: { buffer: attrsBuf } }, // heapU32
803
819
  { binding: 1, resource: { buffer: bucket.drawHeap.buffer } }, // headersU32
804
- { binding: 2, resource: { buffer: arena.attrs.buffer } }, // heapF32
805
- { binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
820
+ { binding: 2, resource: { buffer: attrsBuf } }, // heapF32
821
+ { binding: 3, resource: { buffer: attrsBuf } }, // heapV4f
806
822
  ];
807
823
  {
808
824
  const s = bucket.slots[slotIdx];
@@ -815,7 +831,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
815
831
  // binary search. Minimum one zero-record to satisfy WebGPU non-
816
832
  // zero size constraint when the bucket is empty.
817
833
  const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
818
- 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 } });
834
+ entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.chunk(bucket.chunkIdx).buffer } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
819
835
  s.renderBoundRecordCount = s.recordCount;
820
836
  }
821
837
  // Schema-driven texture + sampler entries. v1 user surface still
@@ -911,17 +927,39 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
911
927
  }
912
928
  }
913
929
  }
914
- arena.attrs.onResize(() => {
915
- rebuildAllBindGroups();
916
- if (derivedScene !== undefined)
917
- derivedScene.rebindMainHeap(arena.attrs.buffer);
930
+ arena.attrs.onAnyResize((resizedChunkIdx) => {
931
+ // Only rebuild bind groups for buckets that bind THIS chunk —
932
+ // a resize in chunk K doesn't affect a bucket reading chunk N≠K.
933
+ for (const b of buckets) {
934
+ if (b.chunkIdx !== resizedChunkIdx)
935
+ continue;
936
+ for (let i = 0; i < b.slots.length; i++) {
937
+ b.slots[i].bindGroup = buildBucketBindGroup(b, i);
938
+ }
939
+ if (b.gpuRouted && b.partitionScene !== undefined) {
940
+ b.partitionScene.rebindSlotDrawBufs(b.slots.map(s => s.drawTableBuf.buffer));
941
+ }
942
+ }
943
+ // §7: each chunk's main-heap getter is registered via
944
+ // `derivedScene.setMainHeapForChunk` (at construction + on
945
+ // onChunkAdded). The getter returns the chunk's current
946
+ // GPUBuffer; the dispatcher's bind groups consult it per
947
+ // dispatch, so no explicit rebind needed here.
918
948
  });
919
949
  if (atlasPool !== undefined) {
920
950
  for (const f of ATLAS_PAGE_FORMATS) {
921
951
  atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
922
952
  }
923
953
  }
924
- arena.indices.onResize(() => rebuildAllBindGroups());
954
+ arena.indices.onAnyResize((resizedChunkIdx) => {
955
+ for (const b of buckets) {
956
+ if (b.chunkIdx !== resizedChunkIdx)
957
+ continue;
958
+ for (let i = 0; i < b.slots.length; i++) {
959
+ b.slots[i].bindGroup = buildBucketBindGroup(b, i);
960
+ }
961
+ }
962
+ });
925
963
  // ─── findOrCreateBucket ───────────────────────────────────────────
926
964
  // Slice 3c: the bucket key collapses to (familyId, pipelineState).
927
965
  // Every member effect in the family shares one bucket per
@@ -958,6 +996,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
958
996
  });
959
997
  const slot = {
960
998
  pipeline, modeKey,
999
+ cpuDescriptor: descriptor,
961
1000
  drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
962
1001
  recordCount: 0, slotToRecord: [], recordToSlot: [],
963
1002
  totalEmitEstimate: 0, scanDirty: false,
@@ -985,6 +1024,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
985
1024
  rebuildScanBg();
986
1025
  slot.drawTableDirtyMin = 0;
987
1026
  slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
1027
+ // Phase 5c.3: partition's bind group references every slot's
1028
+ // drawTable. When ANY slot's GPUBuffer reallocates we rebind all.
1029
+ if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
1030
+ bucket.partitionScene.rebindSlotDrawBufs(bucket.slots.map(s => s.drawTableBuf.buffer));
1031
+ }
988
1032
  });
989
1033
  blockSumsBuf.onResize(rebuildScanBg);
990
1034
  blockOffsetsBuf.onResize(rebuildScanBg);
@@ -998,7 +1042,654 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
998
1042
  }
999
1043
  /** Side map from Bucket → family. Populated by findOrCreateBucket. */
1000
1044
  const familyForBucket = new WeakMap();
1001
- function findOrCreateBucket(effect, _textures, pipelineState,
1045
+ /** Canonical axis order — every part of the GPU routing path uses
1046
+ * this ordering so the kernel's params layout, the per-slot mixed-
1047
+ * radix mode key, and the declared-aval lookup stay in lockstep. */
1048
+ const AXIS_ORDER = [
1049
+ "cull", "frontFace", "topology",
1050
+ "depthCompare", "depthWrite", "alphaToCoverage",
1051
+ ];
1052
+ function collectRules(modeRules) {
1053
+ const out = [];
1054
+ for (const axis of AXIS_ORDER) {
1055
+ const r = modeRules[axis];
1056
+ if (r !== undefined)
1057
+ out.push(r);
1058
+ }
1059
+ return out;
1060
+ }
1061
+ /** Apply a per-axis enum value to a `PipelineStateDescriptor` and
1062
+ * return the patched copy. Used to materialise one descriptor per
1063
+ * slot (= one per cartesian-product point across the rule domains). */
1064
+ function patchDescriptor(base, axis, value) {
1065
+ switch (axis) {
1066
+ case "cull": return { ...base, cullMode: value };
1067
+ case "frontFace": return { ...base, frontFace: value };
1068
+ case "topology": {
1069
+ const topology = value;
1070
+ return { ...base, topology, stripIndexFormat: stripFormatForTopology(topology) };
1071
+ }
1072
+ case "depthCompare":
1073
+ return base.depth !== undefined
1074
+ ? { ...base, depth: { ...base.depth, compare: value } }
1075
+ : base;
1076
+ case "depthWrite":
1077
+ return base.depth !== undefined
1078
+ ? { ...base, depth: { ...base.depth, write: value } }
1079
+ : base;
1080
+ case "alphaToCoverage": return { ...base, alphaToCoverage: value };
1081
+ case "blend": {
1082
+ // Blend is per-attachment; apply the resolved AttachmentBlend
1083
+ // to attachment 0 (the main color target). Multi-attachment
1084
+ // structured rules can extend this when needed.
1085
+ const blend = value;
1086
+ const atts = base.attachments.slice();
1087
+ if (atts.length === 0)
1088
+ atts.push(blend);
1089
+ else
1090
+ atts[0] = blend;
1091
+ return { ...base, attachments: atts };
1092
+ }
1093
+ default: return base;
1094
+ }
1095
+ }
1096
+ function stripFormatForTopology(t) {
1097
+ return t === "line-strip" || t === "triangle-strip" ? "uint32" : undefined;
1098
+ }
1099
+ /** Per-axis enum order. The rule body produces u32 indices into
1100
+ * this array, and the heap renderer uses the same order to map
1101
+ * resolved-set integers back to pipeline descriptor values. The
1102
+ * ordering MUST match the bitfield encoder in pipelineCache. */
1103
+ const AXIS_ENUM_TABLE = {
1104
+ cull: ["none", "front", "back"],
1105
+ frontFace: ["ccw", "cw"],
1106
+ topology: ["point-list", "line-list", "line-strip", "triangle-list", "triangle-strip"],
1107
+ depthCompare: ["never", "less", "equal", "less-equal", "greater", "not-equal", "greater-equal", "always"],
1108
+ depthWrite: [false, true],
1109
+ alphaToCoverage: [false, true],
1110
+ // Blend has no canonical enum table — the rule's `resolve` /
1111
+ // `values` callback MUST be supplied so the heap renderer knows
1112
+ // how to map u32 outputs to AttachmentBlend objects.
1113
+ blend: [],
1114
+ };
1115
+ /**
1116
+ * Map a resolved slot value (from `evaluateStructuralSet`) to the
1117
+ * axis-shape value used for descriptor patching.
1118
+ *
1119
+ * - If the resolved value is a JS object: it's already the full
1120
+ * axis value (e.g. an `AttachmentBlend` from a blend rule's
1121
+ * object-literal return) — use it directly.
1122
+ * - If it's a number: for enum axes, look up the canonical
1123
+ * table; for structured axes without a table, pass through.
1124
+ */
1125
+ function resolveAxisValue(rule, value) {
1126
+ if (typeof value === "object" && value !== null)
1127
+ return value;
1128
+ if (typeof value === "number") {
1129
+ const table = AXIS_ENUM_TABLE[rule.axis];
1130
+ if (table.length > 0) {
1131
+ const v = table[value];
1132
+ if (v !== undefined)
1133
+ return v;
1134
+ }
1135
+ return value;
1136
+ }
1137
+ return value;
1138
+ }
1139
+ /**
1140
+ * Resolve an axis's `declared` value to a u32 index.
1141
+ *
1142
+ * - omitted: `0` (the rule body either doesn't reference `declared`
1143
+ * or wants the canonical zero of its axis as the default).
1144
+ * - number: passed through directly (e.g. `declared: 2` for cull "back").
1145
+ * - aval / string / boolean: looked up in the canonical enum table.
1146
+ */
1147
+ function resolveDeclaredU32(rule, tok) {
1148
+ const d = rule.declared;
1149
+ if (d === undefined)
1150
+ return 0;
1151
+ const v = (typeof d === "object" && d !== null && "getValue" in d)
1152
+ ? d.getValue(tok)
1153
+ : d;
1154
+ if (typeof v === "number")
1155
+ return v >>> 0;
1156
+ const table = AXIS_ENUM_TABLE[rule.axis];
1157
+ if (table.length === 0) {
1158
+ throw new Error(`heapScene/gpuRouting: axis '${rule.axis}' has no canonical enum table; ` +
1159
+ `pass \`declared\` as a u32 index or omit it`);
1160
+ }
1161
+ const i = table.indexOf(v);
1162
+ if (i < 0) {
1163
+ throw new Error(`heapScene/gpuRouting: declared value '${String(v)}' for axis '${rule.axis}' ` +
1164
+ `not in canonical enum table ${JSON.stringify(table)}`);
1165
+ }
1166
+ return i;
1167
+ }
1168
+ /** Extract the rule body Stmt from a RuleExpr's Module. The plugin
1169
+ * emits one `Entry` ValueDef whose body is the user's closure body
1170
+ * (with the synthetic compute-stage scaffolding stripped). */
1171
+ function ruleBodyOf(rule) {
1172
+ const tmpl = rule.expr.template;
1173
+ for (const v of tmpl.values) {
1174
+ if (v.kind === "Entry" && v.entry !== undefined)
1175
+ return v.entry.body;
1176
+ }
1177
+ throw new Error(`heapScene/gpuRouting: rule for axis '${rule.axis}' has no Entry in its template (id=${rule.expr.id})`);
1178
+ }
1179
+ /**
1180
+ * Specialise a rule for the current declared u32 value:
1181
+ * 1. Bake `declared` as a Const in the rule body (so the kernel
1182
+ * has no per-dispatch uniform — declared marks trigger a
1183
+ * re-specialise + cached-kernel swap).
1184
+ * 2. Run `analyseOutputSet` to collect distinct return Exprs.
1185
+ * 3. `evaluateStructuralSet` folds each Expr to a JS value (a
1186
+ * number for enum axes; a full object for structured axes
1187
+ * like blend). Distinct values define the slot count.
1188
+ * 4. Build a stableStringify-keyed `exprToSlot` map.
1189
+ * 5. Rewrite the body so every ReturnValue emits a Const u32
1190
+ * slot index.
1191
+ * 6. emitPartitionKernel produces the WGSL.
1192
+ */
1193
+ /** WeakMap-based stable identity for aval objects. Two `cval`s
1194
+ * have different identity; the same cval observed twice has the
1195
+ * same identity. Used in `ruleContentHash` so rules differing
1196
+ * only in their `declared` aval are recognised as distinct. */
1197
+ const avalIdentityMap = new WeakMap();
1198
+ let nextAvalIdentity = 0;
1199
+ function avalIdentity(av) {
1200
+ let id = avalIdentityMap.get(av);
1201
+ if (id === undefined) {
1202
+ id = ++nextAvalIdentity;
1203
+ avalIdentityMap.set(av, id);
1204
+ }
1205
+ return id;
1206
+ }
1207
+ /** Content hash for rule identity within a bucket. Two rules
1208
+ * hashing the same can share a `ruleId` and one body; rules
1209
+ * hashing differently get distinct ids. */
1210
+ function ruleContentHash(rule) {
1211
+ const dec = rule.declared;
1212
+ let dKey;
1213
+ if (dec === undefined) {
1214
+ dKey = "u";
1215
+ }
1216
+ else if (typeof dec === "object" && dec !== null && "getValue" in dec) {
1217
+ dKey = `a${avalIdentity(dec)}`;
1218
+ }
1219
+ else {
1220
+ dKey = `v${String(dec)}`;
1221
+ }
1222
+ return `${rule.axis}|${rule.expr.id}|${dKey}`;
1223
+ }
1224
+ /** Read baseDescriptor's value for a given axis (the value used by
1225
+ * combos that have no rule on that axis). */
1226
+ function axisValueOfDesc(desc, axis) {
1227
+ switch (axis) {
1228
+ case "cull": return desc.cullMode;
1229
+ case "frontFace": return desc.frontFace;
1230
+ case "topology": return desc.topology;
1231
+ case "depthCompare": return desc.depth?.compare;
1232
+ case "depthWrite": return desc.depth?.write;
1233
+ case "alphaToCoverage": return desc.alphaToCoverage;
1234
+ case "blend": return desc.attachments[0];
1235
+ }
1236
+ }
1237
+ /**
1238
+ * Specialise the WHOLE bucket — N-D cartesian over per-axis
1239
+ * unions:
1240
+ * 1. Per (axis, rule): bake declared as Const, analyse the body,
1241
+ * `evaluateStructuralSet` to fold each return Expr to a JS
1242
+ * value. Collect the per-axis union (dedup by stableStringify,
1243
+ * sorted deterministically). If any combo lacks a rule on
1244
+ * this axis, also include baseDesc's value for that axis.
1245
+ * 2. Active axes = axes with ≥1 registered rule, in AXIS_ORDER.
1246
+ * Cardinalities Cₐ = |axisValues[a]|; totalSlots = ∏ Cₐ.
1247
+ * 3. Per axis-rule: rewrite the body so every `ReturnValue`
1248
+ * emits a `Const u32` PER-AXIS index (into axisValues[axis]).
1249
+ * 4. Per combo: assemble a ComboCodegenSpec (per-axis source =
1250
+ * rule or compile-time const index).
1251
+ * 5. Emit the kernel.
1252
+ *
1253
+ * Returns the cartesian slot descriptors + the kernel WGSL.
1254
+ */
1255
+ function specialiseBucket(bucket) {
1256
+ const Tu32 = { kind: "Int", signed: false, width: 32 };
1257
+ const rulesByAxis = bucket.rulesByAxis;
1258
+ const axisRuleOrder = bucket.axisRuleOrder;
1259
+ const combosByKey = bucket.combosByKey;
1260
+ const comboOrder = bucket.comboOrder;
1261
+ if (comboOrder.length === 0) {
1262
+ throw new Error("heapScene/gpuRouting: specialiseBucket called with no registered combos");
1263
+ }
1264
+ const base = bucket.baseDescriptor;
1265
+ // An axis is "active" (gets cartesian-product cardinality) if it
1266
+ // has at least one rule, OR any combo overrides baseDesc with a
1267
+ // distinct fixed value. Both grow the per-axis value union.
1268
+ const baseAxisValues = new Map(AXIS_ORDER.map(a => [a, axisValueOfDesc(base, a)]));
1269
+ const activeAxes = AXIS_ORDER.filter(a => {
1270
+ if ((rulesByAxis.get(a)?.size ?? 0) > 0)
1271
+ return true;
1272
+ const baseVal = baseAxisValues.get(a);
1273
+ const baseKey = stableStringify(baseVal);
1274
+ for (const k of comboOrder) {
1275
+ const c = combosByKey.get(k);
1276
+ const fixed = c.axisFixedValues.get(a);
1277
+ if (fixed !== undefined && stableStringify(fixed) !== baseKey)
1278
+ return true;
1279
+ }
1280
+ return false;
1281
+ });
1282
+ const perAxisRules = new Map();
1283
+ for (const axis of activeAxes) {
1284
+ const list = [];
1285
+ for (const hash of axisRuleOrder.get(axis)) {
1286
+ const entry = rulesByAxis.get(axis).get(hash);
1287
+ const declaredU32 = resolveDeclaredU32(entry.rule, AdaptiveToken.top);
1288
+ const declaredConst = {
1289
+ kind: "Const", type: Tu32,
1290
+ value: { kind: "Int", value: declaredU32 >>> 0, signed: false },
1291
+ };
1292
+ const bakedBody = substituteReadInputInStmt(entry.body, "Uniform", "declared", declaredConst);
1293
+ const symbolic = analyseOutputSet(bakedBody);
1294
+ const env = new Map([["declared", declaredU32]]);
1295
+ const { resolved, unresolvedCount } = evaluateStructuralSet(symbolic, env, new Map());
1296
+ let outputs = resolved.slice();
1297
+ if (outputs.length === 0 || unresolvedCount > 0) {
1298
+ const table = AXIS_ENUM_TABLE[entry.rule.axis];
1299
+ if (table.length > 0 && unresolvedCount > 0) {
1300
+ outputs = table.map((_, i) => i);
1301
+ }
1302
+ else if (outputs.length === 0) {
1303
+ throw new Error(`heapScene/gpuRouting: rule on axis '${entry.rule.axis}' could not be ` +
1304
+ `statically resolved AND the axis has no canonical enum table. ` +
1305
+ `Simplify the rule body so analyseOutputSet can fold each branch.`);
1306
+ }
1307
+ }
1308
+ list.push({ entry, bakedBody, resolved: outputs, declaredU32 });
1309
+ entry.lastDeclaredU32 = declaredU32;
1310
+ }
1311
+ perAxisRules.set(axis, list);
1312
+ }
1313
+ const axisInfos = [];
1314
+ for (const axis of activeAxes) {
1315
+ const list = perAxisRules.get(axis) ?? [];
1316
+ const dummyRule = { axis };
1317
+ const byKey = new Map();
1318
+ for (const p of list) {
1319
+ for (const v of p.resolved) {
1320
+ const av = resolveAxisValue(dummyRule, v);
1321
+ byKey.set(stableStringify(av), av);
1322
+ }
1323
+ }
1324
+ const baseValue = baseAxisValues.get(axis);
1325
+ // Include every combo's effective value for this axis (so
1326
+ // const-source idx lookups always succeed).
1327
+ for (const k of comboOrder) {
1328
+ const c = combosByKey.get(k);
1329
+ if (c.axisRules.has(axis))
1330
+ continue;
1331
+ const effective = c.axisFixedValues.has(axis) ? c.axisFixedValues.get(axis) : baseValue;
1332
+ byKey.set(stableStringify(effective), effective);
1333
+ }
1334
+ const sortedKeys = [...byKey.keys()].sort();
1335
+ const values = sortedKeys.map(k => byKey.get(k));
1336
+ const keyToIdx = new Map(sortedKeys.map((k, i) => [k, i]));
1337
+ const baseIdx = keyToIdx.get(stableStringify(baseValue)) ?? 0;
1338
+ axisInfos.push({ axis, values, keyToIdx, baseValue, baseIdx });
1339
+ }
1340
+ // Step 2: cartesian shape.
1341
+ const cards = axisInfos.map(a => a.values.length);
1342
+ const totalSlots = cards.reduce((m, n) => m * n, 1);
1343
+ // Step 3: rewrite each axis-rule body to emit per-axis indices.
1344
+ const ruleSpecs = [];
1345
+ for (const info of axisInfos) {
1346
+ const list = perAxisRules.get(info.axis);
1347
+ const dummyRule = { axis: info.axis };
1348
+ for (const p of list) {
1349
+ const env = new Map([["declared", p.declaredU32]]);
1350
+ const exprToAxisIdx = (e) => {
1351
+ const v = evaluateStructural(e, env, new Map());
1352
+ if (v === undefined)
1353
+ return undefined;
1354
+ const av = resolveAxisValue(dummyRule, v);
1355
+ return info.keyToIdx.get(stableStringify(av));
1356
+ };
1357
+ const rewrittenBody = rewriteOutputsToSlotIndices(p.bakedBody, exprToAxisIdx);
1358
+ ruleSpecs.push({
1359
+ axis: info.axis,
1360
+ axisRuleId: p.entry.axisRuleId,
1361
+ body: rewrittenBody,
1362
+ intrinsics: new Map(),
1363
+ helpersWGSL: "",
1364
+ inputUniforms: ["ModelTrafo"],
1365
+ });
1366
+ }
1367
+ }
1368
+ // Step 4: per-combo axis sources. Rule axes → rule source.
1369
+ // Non-rule axes → const idx of (axisFixedValues.get(axis)
1370
+ // ?? baseValue) within the axis's sorted union.
1371
+ const comboSpecs = [];
1372
+ for (const comboKey of comboOrder) {
1373
+ const combo = combosByKey.get(comboKey);
1374
+ const axes = axisInfos.map(info => {
1375
+ const axisRuleId = combo.axisRules.get(info.axis);
1376
+ if (axisRuleId !== undefined) {
1377
+ return {
1378
+ axis: info.axis,
1379
+ cardinality: info.values.length,
1380
+ source: { kind: "rule", axisRuleId },
1381
+ };
1382
+ }
1383
+ const effective = combo.axisFixedValues.has(info.axis)
1384
+ ? combo.axisFixedValues.get(info.axis)
1385
+ : info.baseValue;
1386
+ const idx = info.keyToIdx.get(stableStringify(effective)) ?? info.baseIdx;
1387
+ return {
1388
+ axis: info.axis,
1389
+ cardinality: info.values.length,
1390
+ source: { kind: "const", idx },
1391
+ };
1392
+ });
1393
+ comboSpecs.push({ comboId: combo.comboId, axes });
1394
+ }
1395
+ // Step 5: enumerate cartesian → slot descriptors (mixed-radix
1396
+ // decode in the same axis order the kernel uses).
1397
+ const slotDescs = [];
1398
+ for (let slotIdx = 0; slotIdx < totalSlots; slotIdx++) {
1399
+ let desc = base;
1400
+ let rem = slotIdx;
1401
+ // Match kernel: strides[i] = ∏_{j>i} cards[j]. Decode:
1402
+ // idx[i] = (slotIdx / strides[i]) % cards[i].
1403
+ const strides = new Array(axisInfos.length).fill(1);
1404
+ for (let i = axisInfos.length - 2; i >= 0; i--)
1405
+ strides[i] = strides[i + 1] * cards[i + 1];
1406
+ for (let i = 0; i < axisInfos.length; i++) {
1407
+ const info = axisInfos[i];
1408
+ const ai = Math.floor(rem / strides[i]) % cards[i];
1409
+ rem -= ai * strides[i];
1410
+ desc = patchDescriptor(desc, info.axis, info.values[ai]);
1411
+ }
1412
+ slotDescs.push(desc);
1413
+ }
1414
+ const kernelWGSL = emitPartitionKernel({
1415
+ rules: ruleSpecs,
1416
+ combos: comboSpecs,
1417
+ totalSlots,
1418
+ uniformOrder: bucket.uniformOrder ?? [],
1419
+ });
1420
+ return { slotDescs, kernelWGSL };
1421
+ }
1422
+ /** Build pipeline + slot for each cartesian slot descriptor. */
1423
+ function ensureSlotsForResolved(bucket, slotDescs) {
1424
+ const fam = familyForBucket.get(bucket);
1425
+ for (let slotIdx = 0; slotIdx < slotDescs.length; slotIdx++) {
1426
+ const desc = slotDescs[slotIdx];
1427
+ const key = encodeModeKey(desc);
1428
+ if (slotIdx < bucket.slots.length) {
1429
+ const slot = bucket.slots[slotIdx];
1430
+ if (slot.modeKey !== key) {
1431
+ slot.pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, desc);
1432
+ slot.modeKey = key;
1433
+ }
1434
+ }
1435
+ else {
1436
+ createSlot(bucket, desc);
1437
+ }
1438
+ }
1439
+ }
1440
+ /** Register a combo (set of axis rules, ≤ one per axis) on a GPU-
1441
+ * routed bucket (or promote the bucket on first call). Returns
1442
+ * the bucket-unique `comboId` to bake into master-pool records. */
1443
+ function registerCombo(bucket, axisRules, baseDesc) {
1444
+ let cpuMigration;
1445
+ if (!bucket.gpuRouted && bucket.drawSlots.size > 0) {
1446
+ cpuMigration = [];
1447
+ for (const ls of bucket.drawSlots) {
1448
+ const e = bucket.localEntries[ls];
1449
+ const drawId = bucket.localToDrawId[ls];
1450
+ const slotIdx = drawIdToSlotIdx[drawId];
1451
+ const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : undefined;
1452
+ const descriptor = slot?.cpuDescriptor;
1453
+ if (descriptor === undefined) {
1454
+ throw new Error(`heapScene/gpuRouting: pre-existing CPU RO (drawId=${drawId}, localSlot=${ls}) ` +
1455
+ `has no captured descriptor — bucket promotion cannot synthesise a migration combo.`);
1456
+ }
1457
+ cpuMigration.push({
1458
+ localSlot: ls,
1459
+ drawId,
1460
+ firstIndex: e.firstIndex,
1461
+ indexCount: e.indexCount,
1462
+ instanceCount: e.instanceCount,
1463
+ perDrawRefs: bucket.localPerDrawRefs[ls],
1464
+ descriptor,
1465
+ targetComboId: 0,
1466
+ });
1467
+ }
1468
+ }
1469
+ if (!bucket.gpuRouted) {
1470
+ bucket.baseDescriptor = baseDesc;
1471
+ bucket.rulesByAxis = new Map();
1472
+ bucket.axisRuleOrder = new Map();
1473
+ bucket.uniformOrder = [];
1474
+ bucket.uniformIdx = new Map();
1475
+ bucket.combosByKey = new Map();
1476
+ bucket.comboOrder = [];
1477
+ bucket.drawIdToComboId = new Map();
1478
+ bucket.drawIdToRecord = new Map();
1479
+ bucket.recordToDrawId = [];
1480
+ // Synthesise one combo per distinct pre-existing CPU
1481
+ // descriptor. Each carries `axisFixedValues` on axes where the
1482
+ // descriptor differs from baseDescriptor; specialiseBucket
1483
+ // expands the per-axis union to include each variant value
1484
+ // and emits a const-source combo fn that routes to the right
1485
+ // cartesian slot.
1486
+ if (cpuMigration !== undefined) {
1487
+ const baseVals = new Map(AXIS_ORDER.map(a => [a, axisValueOfDesc(baseDesc, a)]));
1488
+ const synthByKey = new Map();
1489
+ for (const m of cpuMigration) {
1490
+ const axisFixedValues = new Map();
1491
+ const keyParts = [];
1492
+ for (const a of AXIS_ORDER) {
1493
+ const v = axisValueOfDesc(m.descriptor, a);
1494
+ const baseKey = stableStringify(baseVals.get(a));
1495
+ const vKey = stableStringify(v);
1496
+ if (vKey !== baseKey) {
1497
+ axisFixedValues.set(a, v);
1498
+ keyParts.push(`${a}:_:fixed:${vKey}`);
1499
+ }
1500
+ else {
1501
+ keyParts.push(`${a}:_`);
1502
+ }
1503
+ }
1504
+ const comboKey = keyParts.join("|");
1505
+ let comboId = synthByKey.get(comboKey);
1506
+ if (comboId === undefined) {
1507
+ comboId = bucket.comboOrder.length;
1508
+ bucket.combosByKey.set(comboKey, {
1509
+ comboId, comboKey,
1510
+ axisRules: new Map(),
1511
+ axisFixedValues,
1512
+ });
1513
+ bucket.comboOrder.push(comboKey);
1514
+ synthByKey.set(comboKey, comboId);
1515
+ }
1516
+ m.targetComboId = comboId;
1517
+ }
1518
+ }
1519
+ }
1520
+ // Per-axis: dedupe rule by content hash; assign axisRuleId.
1521
+ const perAxisRuleId = new Map();
1522
+ let respec = false;
1523
+ for (const rule of axisRules) {
1524
+ if (perAxisRuleId.has(rule.axis)) {
1525
+ throw new Error(`heapScene/gpuRouting: combo has two rules on the same axis '${rule.axis}'`);
1526
+ }
1527
+ const hash = ruleContentHash(rule);
1528
+ let axisMap = bucket.rulesByAxis.get(rule.axis);
1529
+ if (axisMap === undefined) {
1530
+ axisMap = new Map();
1531
+ bucket.rulesByAxis.set(rule.axis, axisMap);
1532
+ bucket.axisRuleOrder.set(rule.axis, []);
1533
+ }
1534
+ let entry = axisMap.get(hash);
1535
+ if (entry === undefined) {
1536
+ const order = bucket.axisRuleOrder.get(rule.axis);
1537
+ const body = ruleBodyOf(rule);
1538
+ entry = {
1539
+ axis: rule.axis,
1540
+ axisRuleId: order.length,
1541
+ contentHash: hash,
1542
+ rule,
1543
+ body,
1544
+ lastDeclaredU32: undefined,
1545
+ };
1546
+ axisMap.set(hash, entry);
1547
+ order.push(hash);
1548
+ // Discover the arena uniforms this rule reads — append unseen
1549
+ // ones to the bucket-wide uniformOrder so master records
1550
+ // carry refs for them. `declared` is a CPU-baked Const, not
1551
+ // an arena uniform; skip it.
1552
+ for (const uName of collectUniformReadsInStmt(body)) {
1553
+ if (uName === "declared")
1554
+ continue;
1555
+ if (!bucket.uniformIdx.has(uName)) {
1556
+ const idx = bucket.uniformOrder.length;
1557
+ bucket.uniformOrder.push(uName);
1558
+ bucket.uniformIdx.set(uName, idx);
1559
+ }
1560
+ }
1561
+ respec = true;
1562
+ }
1563
+ perAxisRuleId.set(rule.axis, entry.axisRuleId);
1564
+ }
1565
+ // Combo key = canonical-axis-order list of (axis, contentHash).
1566
+ const comboKeyParts = [];
1567
+ for (const axis of AXIS_ORDER) {
1568
+ const axisRuleId = perAxisRuleId.get(axis);
1569
+ if (axisRuleId === undefined) {
1570
+ comboKeyParts.push(`${axis}:_`);
1571
+ }
1572
+ else {
1573
+ comboKeyParts.push(`${axis}:${bucket.axisRuleOrder.get(axis)[axisRuleId]}`);
1574
+ }
1575
+ }
1576
+ const comboKey = comboKeyParts.join("|");
1577
+ let combo = bucket.combosByKey.get(comboKey);
1578
+ if (combo === undefined) {
1579
+ const comboId = bucket.comboOrder.length;
1580
+ const axisRules = new Map();
1581
+ for (const [axis, id] of perAxisRuleId)
1582
+ axisRules.set(axis, id);
1583
+ combo = { comboId, comboKey, axisRules, axisFixedValues: new Map() };
1584
+ bucket.combosByKey.set(comboKey, combo);
1585
+ bucket.comboOrder.push(comboKey);
1586
+ respec = true;
1587
+ }
1588
+ // First time gpuRouted? Build the partition scene now.
1589
+ if (!bucket.gpuRouted) {
1590
+ const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
1591
+ ensureSlotsForResolved(bucket, slotDescs);
1592
+ const partition = new GpuPartitionScene(device, `${bucket.label}/partition`, {
1593
+ totalSlots: slotDescs.length,
1594
+ slotDrawBufs: bucket.slots.map(s => s.drawTableBuf.buffer),
1595
+ kernelWGSL,
1596
+ initialRecords: Math.max(16, (cpuMigration?.length ?? 0) + 16),
1597
+ numUniforms: bucket.uniformOrder.length,
1598
+ });
1599
+ bucket.gpuRouted = true;
1600
+ bucket.partitionScene = partition;
1601
+ bucket.partitionDirty = true;
1602
+ // Migrate any captured CPU records into the master pool, each
1603
+ // using the synthetic combo registered for its CPU descriptor.
1604
+ if (cpuMigration !== undefined) {
1605
+ let totalEmit = 0;
1606
+ for (const m of cpuMigration) {
1607
+ const uRefs = bucket.uniformOrder.map(n => m.perDrawRefs.get(n) ?? 0);
1608
+ const recIdx = partition.appendRecord(m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs);
1609
+ bucket.drawIdToRecord.set(m.drawId, recIdx);
1610
+ bucket.recordToDrawId[recIdx] = m.drawId;
1611
+ bucket.drawIdToComboId.set(m.drawId, m.targetComboId);
1612
+ totalEmit += m.indexCount * m.instanceCount;
1613
+ }
1614
+ const numRecords = partition.numRecords;
1615
+ const recBytes = numRecords * RECORD_BYTES;
1616
+ const needBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
1617
+ for (const s of bucket.slots) {
1618
+ s.drawTableBuf.ensureCapacity(recBytes);
1619
+ s.drawTableBuf.setUsed(Math.max(s.drawTableBuf.usedBytes, recBytes));
1620
+ s.blockSumsBuf.ensureCapacity(needBlocks * 4);
1621
+ s.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
1622
+ s.recordCount = numRecords;
1623
+ s.totalEmitEstimate += totalEmit;
1624
+ const newNumTiles = Math.max(1, Math.ceil(s.totalEmitEstimate / TILE_K));
1625
+ s.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
1626
+ s.scanDirty = true;
1627
+ }
1628
+ }
1629
+ }
1630
+ else if (respec) {
1631
+ const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
1632
+ ensureSlotsForResolved(bucket, slotDescs);
1633
+ const newCount = bucket.slots.length;
1634
+ const draws = bucket.slots.map(s => s.drawTableBuf.buffer);
1635
+ if (newCount > bucket.partitionScene.totalSlots) {
1636
+ bucket.partitionScene.growSlots(newCount, draws);
1637
+ }
1638
+ else {
1639
+ bucket.partitionScene.rebindSlotDrawBufs(draws);
1640
+ }
1641
+ // Bucket-wide uniformOrder may have grown (a new rule reads a
1642
+ // previously-unseen uniform). Widen master records before
1643
+ // rebuilding the kernel — the WGSL struct width changes too.
1644
+ if (bucket.uniformOrder.length > bucket.partitionScene.numUniforms) {
1645
+ bucket.partitionScene.growUniforms(bucket.uniformOrder.length);
1646
+ }
1647
+ bucket.partitionScene.rebuildKernel(kernelWGSL);
1648
+ bucket.partitionDirty = true;
1649
+ }
1650
+ return combo.comboId;
1651
+ }
1652
+ /**
1653
+ * Per-frame partition dispatch. Detects per-rule `declared`
1654
+ * changes and re-specialises the whole bucket if any rule's
1655
+ * declared value differs from what was used at the last
1656
+ * dispatch (cheap — analysis runs on cached IR).
1657
+ */
1658
+ function dispatchPartition(bucket, enc, tok) {
1659
+ if (!bucket.gpuRouted || bucket.partitionScene === undefined)
1660
+ return;
1661
+ let needRespec = false;
1662
+ outer: for (const axisMap of bucket.rulesByAxis.values()) {
1663
+ for (const entry of axisMap.values()) {
1664
+ const declaredU32 = resolveDeclaredU32(entry.rule, tok);
1665
+ if (declaredU32 !== entry.lastDeclaredU32) {
1666
+ needRespec = true;
1667
+ break outer;
1668
+ }
1669
+ }
1670
+ }
1671
+ if (needRespec) {
1672
+ const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
1673
+ ensureSlotsForResolved(bucket, slotDescs);
1674
+ const newCount = bucket.slots.length;
1675
+ const draws = bucket.slots.map(s => s.drawTableBuf.buffer);
1676
+ if (newCount > bucket.partitionScene.totalSlots) {
1677
+ bucket.partitionScene.growSlots(newCount, draws);
1678
+ }
1679
+ else {
1680
+ bucket.partitionScene.rebindSlotDrawBufs(draws);
1681
+ }
1682
+ bucket.partitionScene.rebuildKernel(kernelWGSL);
1683
+ }
1684
+ bucket.partitionScene.flush();
1685
+ bucket.partitionScene.dispatch(arena.attrs.chunk(bucket.chunkIdx).buffer, enc);
1686
+ for (let i = 0; i < bucket.slots.length; i++) {
1687
+ enc.copyBufferToBuffer(bucket.partitionScene.slotCountBufs[i], 0, bucket.slots[i].paramsBuf, 0, 4);
1688
+ bucket.slots[i].scanDirty = true;
1689
+ }
1690
+ bucket.partitionDirty = false;
1691
+ }
1692
+ function findOrCreateBucket(effect, _textures, pipelineState, chunkIdx,
1002
1693
  /**
1003
1694
  * Optional precomputed descriptor. addDrawImpl passes this when
1004
1695
  * the spec carries `modeRules` so the bucket key reflects the
@@ -1010,12 +1701,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1010
1701
  throw new Error("heapScene: findOrCreateBucket called before family build");
1011
1702
  }
1012
1703
  const fam = familyFor(effect);
1013
- // Phase 5c.2: bucket key drops modeKey. All ROs with the same
1014
- // (effect, textureSet) share one bucket; per-modeKey slots live
1015
- // inside the bucket and route records via `ensureSlot`. Pipeline
1016
- // pre-warm + the scene-level pipelineByModeKey cache mean every
1017
- // realized slot lookup hits the cache.
1018
- const bk = `family#${fam.schema.id}`;
1704
+ // §3: bucket key includes `chunkIdx` two ROs with the same
1705
+ // effect/family that landed in different arena chunks live in
1706
+ // separate buckets so each bucket can bind exactly one chunk's
1707
+ // GPUBuffers in its draw call. The shaders are unchanged
1708
+ // (refs are byte offsets within whichever chunk's buffer is
1709
+ // currently bound).
1710
+ const bk = `family#${fam.schema.id}/c${chunkIdx}`;
1019
1711
  const existing = bucketByKey.get(bk);
1020
1712
  if (existing !== undefined)
1021
1713
  return existing;
@@ -1024,7 +1716,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1024
1716
  const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
1025
1717
  const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
1026
1718
  const bucket = {
1027
- label: bk, textures: undefined, layout,
1719
+ label: bk, textures: undefined, layout, chunkIdx,
1028
1720
  slots: [], // populated lazily by ensureSlot per modeKey
1029
1721
  bindGroup: null, // legacy field; slot.bindGroup is the real one
1030
1722
  drawHeap,
@@ -1038,6 +1730,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1038
1730
  localAtlasReleases: [],
1039
1731
  localAtlasTextures: [],
1040
1732
  localAtlasArrIdx: [],
1733
+ gpuRouted: false,
1734
+ partitionScene: undefined,
1735
+ drawIdToRecord: undefined,
1736
+ recordToDrawId: undefined,
1737
+ rulesByAxis: undefined,
1738
+ axisRuleOrder: undefined,
1739
+ uniformOrder: undefined,
1740
+ uniformIdx: undefined,
1741
+ combosByKey: undefined,
1742
+ comboOrder: undefined,
1743
+ drawIdToComboId: undefined,
1744
+ baseDescriptor: undefined,
1745
+ partitionDirty: false,
1041
1746
  };
1042
1747
  familyForBucket.set(bucket, fam);
1043
1748
  drawHeap.onResize(() => {
@@ -1258,12 +1963,63 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1258
1963
  token: outerTok,
1259
1964
  });
1260
1965
  }
1261
- const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
1966
+ // §3 chunk routing pick which arena chunk this RO's allocations
1967
+ // land in. Preference order:
1968
+ // 1. If any of the RO's bindings is an aval that's already
1969
+ // allocated in some chunk via the uniform pool, prefer that
1970
+ // chunk to avoid duplication.
1971
+ // 2. Otherwise, use the newest open chunk (highest chunkIdx).
1972
+ // ChunkedArena.alloc spills to the next chunk automatically
1973
+ // when this one is full; the spill ends up driving the
1974
+ // pool's `finalChunk` selection so all of the RO's
1975
+ // allocations land in one chunk consistently.
1976
+ let chunkIdx;
1977
+ {
1978
+ let preferred;
1979
+ for (const [, val] of Object.entries(spec.inputs)) {
1980
+ const av = (typeof val === "object" && val !== null && "getValue" in val)
1981
+ ? val : undefined;
1982
+ if (av !== undefined) {
1983
+ const where = pool.firstChunkContaining(av);
1984
+ if (where !== undefined) {
1985
+ preferred = where;
1986
+ break;
1987
+ }
1988
+ }
1989
+ }
1990
+ chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
1991
+ }
1992
+ const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
1993
+ // Phase 5c.3 — every RO carrying a derived-mode rule is registered
1994
+ // on the bucket (lazy-promoting it to GPU-routed on the first
1995
+ // call). ROs may carry DIFFERENT rules even when they share the
1996
+ // bucket's `(effect, textureSet)` — each gets its own `ruleId`
1997
+ // baked into the master-pool record and the partition kernel's
1998
+ // dispatch switch picks the right body per record.
1999
+ const collectedRules = spec.modeRules !== undefined ? collectRules(spec.modeRules) : [];
2000
+ const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
2001
+ let roComboId = 0;
2002
+ if (collectedRules.length > 0) {
2003
+ roComboId = registerCombo(bucket, collectedRules, roDescriptor);
2004
+ }
2005
+ else if (bucket.gpuRouted) {
2006
+ // An unruled RO joining an already-GPU-routed bucket needs a
2007
+ // real combo to land on. Register the trivial all-fixed combo
2008
+ // (every axis falls back to baseDescriptor) so this record's
2009
+ // `comboId` doesn't silently alias to combo 0 (which is
2010
+ // whichever combo was registered first — almost never the
2011
+ // "no rules" intent).
2012
+ roComboId = registerCombo(bucket, [], roDescriptor);
2013
+ }
1262
2014
  // Phase 5c.2: route this RO to the bucket's slot covering its
1263
2015
  // current descriptor. ensureSlot creates a new slot on miss; the
1264
- // pipeline lookup hits the scene-level cache.
1265
- const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
1266
- const roSlot = ensureSlot(bucket, roDescriptor);
2016
+ // pipeline lookup hits the scene-level cache. For GPU-routed
2017
+ // buckets the slot decision is made each frame on the GPU; we
2018
+ // still pin the RO to slot 0 (declared) CPU-side so existing
2019
+ // accounting paths (slotToRecord, dirty bookkeeping) keep
2020
+ // working. The partition kernel overwrites both slots' draw
2021
+ // tables every frame from the master.
2022
+ const roSlot = bucket.gpuRouted ? bucket.slots[0] : ensureSlot(bucket, roDescriptor);
1267
2023
  const roSlotIdx = bucket.slots.indexOf(roSlot);
1268
2024
  const fam = familyFor(spec.effect);
1269
2025
  const effectFields = fam.fieldsForEffect.get(spec.effect.id);
@@ -1272,7 +2028,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1272
2028
  // index allocation + one upload.
1273
2029
  const indicesAval = asAval(spec.indices);
1274
2030
  const indicesArr = readPlain(spec.indices);
1275
- const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, indicesArr);
2031
+ const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, bucket.chunkIdx, indicesArr);
1276
2032
  const localSlot = bucket.drawHeap.alloc();
1277
2033
  // Per-RO instancing: read `spec.instanceCount` (defaults to 1).
1278
2034
  const instanceCount = spec.instanceCount !== undefined
@@ -1305,7 +2061,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1305
2061
  // overwrites the arena data each frame the inputs are dirty.
1306
2062
  if (ruleForUniform(spec, f.name) !== undefined) {
1307
2063
  const dummyAval = AVal.constant(M44d.zero);
1308
- const ref = pool.acquire(device, arena.attrs, dummyAval, M44d.zero, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack);
2064
+ const ref = pool.acquire(device, arena.attrs, dummyAval, bucket.chunkIdx, M44d.zero, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack);
1309
2065
  perDrawRefs.set(f.name, ref);
1310
2066
  perDrawAvals.push(dummyAval);
1311
2067
  continue;
@@ -1347,7 +2103,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1347
2103
  ? perInstancePlacementFor(f, value, instanceCount)
1348
2104
  : poolPlacementFor(f, value);
1349
2105
  }
1350
- const ref = pool.acquire(device, arena.attrs, av, value, placement.dataBytes, placement.typeId, placement.length, placement.pack);
2106
+ const ref = pool.acquire(device, arena.attrs, av, bucket.chunkIdx, value, placement.dataBytes, placement.typeId, placement.length, placement.pack);
1351
2107
  perDrawRefs.set(f.name, ref);
1352
2108
  perDrawAvals.push(av);
1353
2109
  }
@@ -1390,7 +2146,50 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1390
2146
  const end = byteOff + bucket.layout.drawHeaderBytes;
1391
2147
  if (end > bucket.headerDirtyMax)
1392
2148
  bucket.headerDirtyMax = end;
1393
- {
2149
+ if (bucket.gpuRouted) {
2150
+ // Phase 5c.3 GPU routing: master pool is authoritative.
2151
+ // Both slot[0] and slot[1] get the SAME accounting (counts,
2152
+ // emit estimates, buffer capacities) — partition overwrites
2153
+ // their GPU drawTables every frame, so per-slot CPU shadows
2154
+ // are irrelevant. We still grow their buffers in lockstep so
2155
+ // the scan dispatch shape covers every possible record.
2156
+ const partition = bucket.partitionScene;
2157
+ const recIdx = partition.numRecords;
2158
+ if (recIdx >= SCAN_MAX_RECORDS) {
2159
+ throw new Error(`heapScene: GPU-routed bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS})`);
2160
+ }
2161
+ // Pack the bucket's per-RO uniform refs (arena byte offsets) in
2162
+ // the same order as `bucket.uniformOrder`. ROs whose perDrawRefs
2163
+ // don't carry a given name (e.g. their effect doesn't declare
2164
+ // it) get 0 — the kernel only reads refs through combo fns that
2165
+ // gate on `r.comboId`, so an unread 0 is harmless.
2166
+ const uniformRefs = (bucket.uniformOrder ?? []).map(name => perDrawRefs.get(name) ?? 0);
2167
+ partition.appendRecord(localSlot, idxAlloc.firstIndex, idxAlloc.count, instanceCount, roComboId, uniformRefs);
2168
+ bucket.drawIdToRecord.set(drawId, recIdx);
2169
+ bucket.recordToDrawId[recIdx] = drawId;
2170
+ bucket.drawIdToComboId.set(drawId, roComboId);
2171
+ // Master grew? Invalidate partition's bind group so it picks
2172
+ // up the new masterBuf. (rebindSlotDrawBufs nulls bindGroup.)
2173
+ // grow() inside appendRecord already does this when capacity
2174
+ // is exceeded; nothing to do here.
2175
+ const numRecords = recIdx + 1;
2176
+ const recBytes = numRecords * RECORD_BYTES;
2177
+ const needBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
2178
+ const emitDelta = idxAlloc.count * instanceCount;
2179
+ for (const s of bucket.slots) {
2180
+ s.drawTableBuf.ensureCapacity(recBytes);
2181
+ s.drawTableBuf.setUsed(Math.max(s.drawTableBuf.usedBytes, recBytes));
2182
+ s.blockSumsBuf.ensureCapacity(needBlocks * 4);
2183
+ s.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
2184
+ s.recordCount = numRecords;
2185
+ s.totalEmitEstimate += emitDelta;
2186
+ const newNumTiles = Math.max(1, Math.ceil(s.totalEmitEstimate / TILE_K));
2187
+ s.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
2188
+ s.scanDirty = true;
2189
+ }
2190
+ bucket.partitionDirty = true;
2191
+ }
2192
+ else {
1394
2193
  const dtBuf = roSlot.drawTableBuf;
1395
2194
  const recIdx = roSlot.recordCount;
1396
2195
  if (recIdx >= SCAN_MAX_RECORDS) {
@@ -1455,34 +2254,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1455
2254
  });
1456
2255
  drawIdToModeTracker[drawId] = tracker;
1457
2256
  tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
1458
- // ─── GPU-eval mode rule registration (Task 2 Phase 5) ────────────
1459
- // If spec.modeRules.cull is GPU-flavoured, register the RO with
1460
- // the scene-wide dispatcher. Provide the arena byte offset of the
1461
- // RO's input uniform so the kernel reads from the right slot.
1462
- {
1463
- const cullRule = spec.modeRules?.cull;
1464
- const gpuRule = cullRule?.gpu;
1465
- if (gpuRule !== undefined) {
1466
- // Subscribe to the declared aval (if reactive) so the cull-
1467
- // mode flip propagates through the rule's CPU fallback
1468
- // recompute path.
1469
- if (typeof gpuRule.declared !== "string") {
1470
- subscribeModeLeaf(gpuRule.declared, drawId);
1471
- }
1472
- }
1473
- // ─── Pipeline pre-warm for derived-mode rules (5c.2) ──────────
1474
- // Enumerate the rule's possible outputs and pre-build the
1475
- // matching pipelines in the scene-level cache. Future cullModeC
1476
- // flips become cache hits — zero `createRenderPipeline` calls
1477
- // on the hot path.
1478
- if (cullRule !== undefined && cullRule.domain !== undefined && tracker.descriptor !== undefined) {
1479
- const fam = familyFor(spec.effect);
1480
- const baseDesc = tracker.descriptor;
1481
- for (const mode of cullRule.domain) {
1482
- if (mode === baseDesc.cullMode)
1483
- continue; // already built
1484
- const variantDesc = { ...baseDesc, cullMode: mode };
1485
- getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, variantDesc);
2257
+ // ─── Derived-mode rule registration ────────────────────────────
2258
+ // Subscribe to each rule's `declared` aval so the bucket's
2259
+ // partition kernel re-dispatches when it changes. Slot pipelines
2260
+ // for every (axis, declared) combination are pre-built eagerly
2261
+ // in `initGpuRouting` (one per cartesian-product point), so
2262
+ // declared flips are pipeline-cache hits.
2263
+ if (spec.modeRules !== undefined) {
2264
+ for (const rule of Object.values(spec.modeRules)) {
2265
+ if (rule === undefined)
2266
+ continue;
2267
+ const d = rule.declared;
2268
+ if (typeof d === "object" && d !== null && "getValue" in d) {
2269
+ subscribeModeLeaf(d, drawId);
1486
2270
  }
1487
2271
  }
1488
2272
  }
@@ -1529,16 +2313,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1529
2313
  const reg = registerRoDerivations(derivedScene, {}, {
1530
2314
  rules: ruleSubset,
1531
2315
  trafoAvals,
1532
- // A non-trafo rule leaf reads the uniform's data straight from this RO's
1533
- // drawHeader region in the arena (= its pool ref + the alloc-header pad).
1534
- // Only resolvable if the uniform was actually packed (i.e. it's a drawHeader
1535
- // field of the effect too) — undefined otherwise, which `resolveSource` rejects.
1536
2316
  hostUniformOffset: (n) => {
1537
2317
  const r = perDrawRefs.get(n);
1538
2318
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
1539
2319
  },
1540
2320
  outputOffset: (n) => outOffsetByName.get(n),
1541
2321
  drawHeaderBaseByte: 0,
2322
+ chunkIdx: bucket.chunkIdx,
1542
2323
  });
1543
2324
  derivedByDrawId.set(drawId, reg);
1544
2325
  }
@@ -1560,7 +2341,38 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1560
2341
  derivedByDrawId.delete(drawId);
1561
2342
  }
1562
2343
  }
1563
- {
2344
+ if (bucket.gpuRouted) {
2345
+ const partition = bucket.partitionScene;
2346
+ const recIdx = bucket.drawIdToRecord.get(drawId);
2347
+ const removedEntry = bucket.localEntries[localSlot];
2348
+ const removedCount = removedEntry !== undefined
2349
+ ? removedEntry.indexCount * removedEntry.instanceCount
2350
+ : 0;
2351
+ if (recIdx !== undefined) {
2352
+ const moved = partition.removeRecord(recIdx);
2353
+ if (moved >= 0) {
2354
+ // The record at `moved` (the old tail) now lives at recIdx.
2355
+ // Fix maps for the moved drawId.
2356
+ const movedDrawId = bucket.recordToDrawId[moved];
2357
+ bucket.drawIdToRecord.set(movedDrawId, recIdx);
2358
+ bucket.recordToDrawId[recIdx] = movedDrawId;
2359
+ bucket.recordToDrawId[moved] = -1;
2360
+ }
2361
+ else {
2362
+ bucket.recordToDrawId[recIdx] = -1;
2363
+ }
2364
+ bucket.drawIdToRecord.delete(drawId);
2365
+ }
2366
+ // Mirror the count + emit-estimate decrement across every slot
2367
+ // (both share the master's accounting).
2368
+ for (const s of bucket.slots) {
2369
+ s.recordCount = partition.numRecords;
2370
+ s.totalEmitEstimate = Math.max(0, s.totalEmitEstimate - removedCount);
2371
+ s.scanDirty = true;
2372
+ }
2373
+ bucket.partitionDirty = true;
2374
+ }
2375
+ else {
1564
2376
  const slotIdx = drawIdToSlotIdx[drawId];
1565
2377
  const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
1566
2378
  const removedEntry = bucket.localEntries[localSlot];
@@ -1600,10 +2412,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1600
2412
  const avals = bucket.localPerDrawAvals[localSlot];
1601
2413
  if (avals !== undefined)
1602
2414
  for (const av of avals)
1603
- pool.release(arena.attrs, av);
2415
+ pool.release(arena.attrs, av, bucket.chunkIdx);
1604
2416
  const idxAval = drawIdToIndexAval[drawId];
1605
2417
  if (idxAval !== undefined)
1606
- indexPool.release(arena.indices, idxAval);
2418
+ indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
1607
2419
  const atlasRel = bucket.localAtlasReleases[localSlot];
1608
2420
  if (atlasRel !== undefined)
1609
2421
  atlasRel();
@@ -1778,12 +2590,20 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1778
2590
  const tracker = drawIdToModeTracker[drawId];
1779
2591
  if (tracker === undefined)
1780
2592
  continue;
2593
+ const bucket = drawIdToBucket[drawId];
2594
+ // Phase 5c.3: GPU-routed buckets never reslot CPU-side.
2595
+ // The partition kernel handles re-routing per frame.
2596
+ // Just mark the bucket so the next encodeComputePrep
2597
+ // dispatches partition.
2598
+ if (bucket !== undefined && bucket.gpuRouted) {
2599
+ bucket.partitionDirty = true;
2600
+ continue;
2601
+ }
1781
2602
  const oldKey = tracker.modeKey;
1782
2603
  if (!tracker.recompute())
1783
2604
  continue;
1784
2605
  if (tracker.modeKey === oldKey)
1785
2606
  continue;
1786
- const bucket = drawIdToBucket[drawId];
1787
2607
  const localSlot = drawIdToLocalSlot[drawId];
1788
2608
  const oldSlotIdx = drawIdToSlotIdx[drawId];
1789
2609
  if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined)
@@ -1856,9 +2676,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1856
2676
  if (allocDirty.size > 0) {
1857
2677
  for (const av of allocDirty) {
1858
2678
  pool.repack(device, arena.attrs, av, av.getValue(tok));
1859
- const e = pool.entry(av);
1860
- if (e !== undefined)
1861
- totalDirtyBytes += e.dataBytes;
2679
+ totalDirtyBytes += pool.totalDataBytes(av);
1862
2680
  }
1863
2681
  allocDirty.clear();
1864
2682
  }
@@ -1948,6 +2766,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1948
2766
  }
1949
2767
  // Phase 5c.2: each slot has its own drawTable + shadow. Upload
1950
2768
  // the dirty range of every slot, not just slots[0].
2769
+ // Phase 5c.3: GPU-routed buckets skip this entirely — the
2770
+ // partition kernel writes slot drawTables directly each frame.
2771
+ if (bucket.gpuRouted)
2772
+ continue;
1951
2773
  for (const slot of bucket.slots) {
1952
2774
  if (slot.drawTableDirtyMax <= slot.drawTableDirtyMin)
1953
2775
  continue;
@@ -2018,10 +2840,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2018
2840
  stats.derivedPullMs = _t1 - _t0;
2019
2841
  stats.derivedUploadMs = _t2 - _t1;
2020
2842
  stats.derivedEncodeMs = _t3 - _t2;
2021
- stats.derivedRecords = derivedScene.records.recordCount;
2843
+ stats.derivedRecords = derivedScene.totalRecordCount;
2022
2844
  }
2023
2845
  let anyDirty = false;
2024
2846
  outer: for (const b of buckets) {
2847
+ if (b.gpuRouted && b.partitionDirty) {
2848
+ anyDirty = true;
2849
+ break outer;
2850
+ }
2025
2851
  for (const s of b.slots) {
2026
2852
  if (s.scanDirty) {
2027
2853
  anyDirty = true;
@@ -2031,6 +2857,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2031
2857
  }
2032
2858
  if (!anyDirty)
2033
2859
  return;
2860
+ // Phase 5c.3: per GPU-routed bucket — dispatch partition (its own
2861
+ // compute pass), then copyBufferToBuffer slot counts into scan
2862
+ // paramsBuf. Must happen BEFORE the shared scan pass below.
2863
+ for (const b of buckets) {
2864
+ if (b.gpuRouted && b.partitionDirty) {
2865
+ dispatchPartition(b, enc, token);
2866
+ }
2867
+ }
2034
2868
  const pass = enc.beginComputePass({ label: "heapScene/scan" });
2035
2869
  for (const b of buckets) {
2036
2870
  for (let i = 0; i < b.slots.length; i++) {
@@ -2166,8 +3000,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2166
3000
  let vidChecks = 0, vidBad = 0;
2167
3001
  const push = (s) => { if (issues.length < 60)
2168
3002
  issues.push(s); };
2169
- const arenaSize = arena.attrs.buffer.size;
2170
- const indicesSize = arena.indices.buffer.size;
3003
+ // §3 v1: validateHeap currently only diagnoses chunk 0 — a
3004
+ // multi-chunk-aware validator needs per-bucket-chunkIdx
3005
+ // staging which is a bigger refactor; assert for now so
3006
+ // multi-chunk callers don't get false-passes.
3007
+ if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
3008
+ throw new Error("heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
3009
+ "Run with the default chunk cap (no second chunk opened) for now.");
3010
+ }
3011
+ const arenaSize = arena.attrs.chunk(0).buffer.size;
3012
+ const indicesSize = arena.indices.chunk(0).buffer.size;
2171
3013
  const enc = device.createCommandEncoder({ label: "validateHeap" });
2172
3014
  const stage = (src, size) => {
2173
3015
  const c = device.createBuffer({
@@ -2177,8 +3019,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2177
3019
  enc.copyBufferToBuffer(src, 0, c, 0, size);
2178
3020
  return c;
2179
3021
  };
2180
- const arenaCopy = stage(arena.attrs.buffer, arenaSize);
2181
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
3022
+ const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3023
+ const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
2182
3024
  const TILE_K_LOCAL = 64;
2183
3025
  const dcs = [];
2184
3026
  for (const b of buckets) {
@@ -2454,8 +3296,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2454
3296
  let emitsChecked = 0;
2455
3297
  const push = (s) => { if (issues.length < 30)
2456
3298
  issues.push(s); };
2457
- const arenaSize = arena.attrs.buffer.size;
2458
- const indicesSize = arena.indices.buffer.size;
3299
+ // §3 v1: chunk-0-only like validateHeap.
3300
+ if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
3301
+ throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
3302
+ }
3303
+ const arenaSize = arena.attrs.chunk(0).buffer.size;
3304
+ const indicesSize = arena.indices.chunk(0).buffer.size;
2459
3305
  // Stage all buffers we'll need.
2460
3306
  const enc = device.createCommandEncoder({ label: "simulateDraws" });
2461
3307
  const stage = (src, size) => {
@@ -2465,8 +3311,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2465
3311
  enc.copyBufferToBuffer(src, 0, c, 0, size);
2466
3312
  return c;
2467
3313
  };
2468
- const arenaCopy = stage(arena.attrs.buffer, arenaSize);
2469
- const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
3314
+ const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
3315
+ const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
2470
3316
  const dcs = [];
2471
3317
  for (const b of buckets) {
2472
3318
  if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
@@ -2717,20 +3563,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
2717
3563
  // bytes to two avals → two ROs reading each other's data → the
2718
3564
  // cross-RO triangle symptom.
2719
3565
  const allocClaims = [];
3566
+ // §3 v1: chunk-0-only (see validateHeap).
3567
+ if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
3568
+ throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
3569
+ }
3570
+ const arenaBuf0 = arena.attrs.chunk(0).buffer;
3571
+ const indicesBuf0 = arena.indices.chunk(0).buffer;
2720
3572
  // Stage arena + arena.indices once (shared across buckets).
2721
3573
  const arenaCopy = device.createBuffer({
2722
- size: arena.attrs.buffer.size,
3574
+ size: arenaBuf0.size,
2723
3575
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
2724
3576
  });
2725
- const indicesCopy = arena.indices.buffer.size > 0 ? device.createBuffer({
2726
- size: arena.indices.buffer.size,
3577
+ const indicesCopy = indicesBuf0.size > 0 ? device.createBuffer({
3578
+ size: indicesBuf0.size,
2727
3579
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
2728
3580
  }) : undefined;
2729
3581
  {
2730
3582
  const enc = device.createCommandEncoder({ label: "checkTriangleCoherence.arena" });
2731
- enc.copyBufferToBuffer(arena.attrs.buffer, 0, arenaCopy, 0, arena.attrs.buffer.size);
3583
+ enc.copyBufferToBuffer(arenaBuf0, 0, arenaCopy, 0, arenaBuf0.size);
2732
3584
  if (indicesCopy !== undefined)
2733
- enc.copyBufferToBuffer(arena.indices.buffer, 0, indicesCopy, 0, arena.indices.buffer.size);
3585
+ enc.copyBufferToBuffer(indicesBuf0, 0, indicesCopy, 0, indicesBuf0.size);
2734
3586
  device.queue.submit([enc.finish()]);
2735
3587
  }
2736
3588
  await arenaCopy.mapAsync(GPUMapMode.READ);
@@ -3167,7 +4019,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3167
4019
  { binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
3168
4020
  { binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
3169
4021
  { binding: 2, resource: { buffer: sampleBuf } },
3170
- { binding: 3, resource: { buffer: arena.indices.buffer } },
4022
+ { binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
3171
4023
  { binding: 4, resource: { buffer: outBuf } },
3172
4024
  { binding: 5, resource: { buffer: paramBuf } },
3173
4025
  ],
@@ -3191,12 +4043,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
3191
4043
  outBuf.destroy();
3192
4044
  paramBuf.destroy();
3193
4045
  // Read drawTable + indices for CPU expectation.
4046
+ const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
3194
4047
  const indicesCopy2 = device.createBuffer({
3195
- size: arena.indices.buffer.size,
4048
+ size: idxBuf.size,
3196
4049
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
3197
4050
  });
3198
4051
  const enc2 = device.createCommandEncoder();
3199
- enc2.copyBufferToBuffer(arena.indices.buffer, 0, indicesCopy2, 0, arena.indices.buffer.size);
4052
+ enc2.copyBufferToBuffer(idxBuf, 0, indicesCopy2, 0, idxBuf.size);
3200
4053
  device.queue.submit([enc2.finish()]);
3201
4054
  await indicesCopy2.mapAsync(GPUMapMode.READ);
3202
4055
  const indicesU32 = new Uint32Array(indicesCopy2.getMappedRange().slice(0));