@aardworx/wombat.rendering 0.9.32 → 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.
- package/dist/runtime/derivedModes/kernelCodegen.d.ts +90 -0
- package/dist/runtime/derivedModes/kernelCodegen.d.ts.map +1 -0
- package/dist/runtime/derivedModes/kernelCodegen.js +623 -0
- package/dist/runtime/derivedModes/kernelCodegen.js.map +1 -0
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.js +23 -67
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts +79 -0
- package/dist/runtime/derivedModes/partitionDispatcher.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partitionDispatcher.js +303 -0
- package/dist/runtime/derivedModes/partitionDispatcher.js.map +1 -0
- package/dist/runtime/derivedModes/partitionKernel.d.ts +12 -0
- package/dist/runtime/derivedModes/partitionKernel.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partitionKernel.js +127 -0
- package/dist/runtime/derivedModes/partitionKernel.js.map +1 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.d.ts +4 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.js +24 -0
- package/dist/runtime/derivedModes/partitionKernelLayout.js.map +1 -0
- package/dist/runtime/derivedModes/rule.d.ts +38 -86
- package/dist/runtime/derivedModes/rule.d.ts.map +1 -1
- package/dist/runtime/derivedModes/rule.js +34 -74
- package/dist/runtime/derivedModes/rule.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts +10 -8
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.js +31 -18
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
- package/dist/runtime/derivedUniforms/marker.d.ts +90 -11
- package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/marker.js +161 -15
- package/dist/runtime/derivedUniforms/marker.js.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +25 -6
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.js +61 -20
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
- package/dist/runtime/heapEligibility.d.ts.map +1 -1
- package/dist/runtime/heapEligibility.js +22 -2
- package/dist/runtime/heapEligibility.js.map +1 -1
- package/dist/runtime/heapScene/chunkedArena.d.ts +76 -0
- package/dist/runtime/heapScene/chunkedArena.d.ts.map +1 -0
- package/dist/runtime/heapScene/chunkedArena.js +223 -0
- package/dist/runtime/heapScene/chunkedArena.js.map +1 -0
- package/dist/runtime/heapScene/freelist.d.ts +50 -0
- package/dist/runtime/heapScene/freelist.d.ts.map +1 -0
- package/dist/runtime/heapScene/freelist.js +160 -0
- package/dist/runtime/heapScene/freelist.js.map +1 -0
- package/dist/runtime/heapScene/growBuffer.d.ts +23 -2
- package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -1
- package/dist/runtime/heapScene/growBuffer.js +35 -3
- package/dist/runtime/heapScene/growBuffer.js.map +1 -1
- package/dist/runtime/heapScene/pools.d.ts +65 -42
- package/dist/runtime/heapScene/pools.d.ts.map +1 -1
- package/dist/runtime/heapScene/pools.js +206 -134
- package/dist/runtime/heapScene/pools.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +12 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +949 -167
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/hybridScene.d.ts +7 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -1
- package/dist/runtime/hybridScene.js +1 -0
- package/dist/runtime/hybridScene.js.map +1 -1
- package/dist/runtime/index.d.ts +4 -3
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +4 -3
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/renderTask.d.ts +5 -0
- package/dist/runtime/renderTask.d.ts.map +1 -1
- package/dist/runtime/renderTask.js +1 -0
- package/dist/runtime/renderTask.js.map +1 -1
- package/dist/runtime/runtime.d.ts +6 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +1 -0
- package/dist/runtime/runtime.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/derivedModes/kernelCodegen.ts +673 -0
- package/src/runtime/derivedModes/modeKeyCpu.ts +22 -60
- package/src/runtime/derivedModes/partitionDispatcher.ts +349 -0
- package/src/runtime/derivedModes/partitionKernelLayout.ts +25 -0
- package/src/runtime/derivedModes/rule.ts +66 -145
- package/src/runtime/derivedUniforms/dispatch.ts +48 -21
- package/src/runtime/derivedUniforms/marker.ts +210 -16
- package/src/runtime/derivedUniforms/sceneIntegration.ts +67 -20
- package/src/runtime/heapEligibility.ts +22 -1
- package/src/runtime/heapScene/chunkedArena.ts +238 -0
- package/src/runtime/heapScene/freelist.ts +161 -0
- package/src/runtime/heapScene/growBuffer.ts +39 -2
- package/src/runtime/heapScene/pools.ts +231 -143
- package/src/runtime/heapScene.ts +1134 -161
- package/src/runtime/hybridScene.ts +8 -0
- package/src/runtime/index.ts +12 -9
- package/src/runtime/renderTask.ts +6 -0
- package/src/runtime/runtime.ts +7 -0
- package/src/runtime/derivedModes/gpuDispatcher.ts +0 -226
- package/src/runtime/derivedModes/gpuKernel.ts +0 -98
|
@@ -52,12 +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,
|
|
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 {
|
|
59
|
-
import {
|
|
58
|
+
import { GpuPartitionScene } from "./derivedModes/partitionDispatcher.js";
|
|
59
|
+
import { emitPartitionKernel, substituteReadInputInStmt, collectUniformReadsInStmt, rewriteOutputsToSlotIndices, } from "./derivedModes/kernelCodegen.js";
|
|
60
|
+
import {} from "./derivedModes/rule.js";
|
|
60
61
|
import { addMarkingCallback } from "@aardworx/wombat.adaptive";
|
|
62
|
+
import { analyseOutputSet, evaluateStructural, evaluateStructuralSet, stableStringify, } from "@aardworx/wombat.shader/ir";
|
|
61
63
|
export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
62
64
|
const atlasPool = opts.atlasPool;
|
|
63
65
|
const colorAttachmentName = sig.colorNames[0];
|
|
@@ -84,7 +86,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
84
86
|
// demand. Skip per-draw enumeration since aval-keyed sharing makes
|
|
85
87
|
// the actual allocated size hard to predict (10K instanced draws
|
|
86
88
|
// sharing the same Positions array → 1 alloc, not 10K).
|
|
87
|
-
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);
|
|
88
90
|
// ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
|
|
89
91
|
const drawIdToBucket = [];
|
|
90
92
|
const drawIdToLocalSlot = [];
|
|
@@ -125,12 +127,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
125
127
|
modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
|
|
126
128
|
for (const id of set)
|
|
127
129
|
dirtyModeKeyDrawIds.add(id);
|
|
128
|
-
// Mark the GPU mode-rules dispatcher dirty too — at least one
|
|
129
|
-
// of those drawIds may carry a GPU rule whose input changed.
|
|
130
|
-
// The dispatch path checks consumeDirty() before encoding, so
|
|
131
|
-
// an unrelated aval mark (e.g. Color change on hover) leaves
|
|
132
|
-
// the kernel quiet.
|
|
133
|
-
gpuModesScene?.markDirty();
|
|
134
130
|
}));
|
|
135
131
|
}
|
|
136
132
|
set.add(drawId);
|
|
@@ -315,7 +311,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
315
311
|
inputChanged(_t, o) {
|
|
316
312
|
// Pool avals are stored as `aval<unknown>`; the IAdaptiveObject
|
|
317
313
|
// identity matches.
|
|
318
|
-
if (pool.
|
|
314
|
+
if (pool.hasAny(o)) {
|
|
319
315
|
allocDirty.add(o);
|
|
320
316
|
return;
|
|
321
317
|
}
|
|
@@ -339,21 +335,31 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
339
335
|
// `enableDerivedUniforms: false` to opt out.
|
|
340
336
|
const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
|
|
341
337
|
const derivedScene = enableDerivedUniforms
|
|
342
|
-
? new DerivedUniformsScene(device,
|
|
338
|
+
? new DerivedUniformsScene(device, {
|
|
343
339
|
// Larger initial capacity reduces grow-during-first-frame churn.
|
|
344
340
|
initialConstituentSlots: 4096,
|
|
345
341
|
})
|
|
346
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
|
+
}
|
|
347
354
|
/** Per-RO §7 registration handles, keyed by global drawId.
|
|
348
355
|
* Drained on removeDraw to release slots + records. */
|
|
349
356
|
const derivedByDrawId = new Map();
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const gpuModesLastKey = new Map();
|
|
357
|
+
// (Phase 5c.2: the per-RO routing decision is CPU-driven via
|
|
358
|
+
// tracker.recompute(). The previous GpuDerivedModesScene dispatch +
|
|
359
|
+
// mapAsync readback path is gone — its output was never consumed
|
|
360
|
+
// after the rebucket-via-spec-patch approach was removed. If a
|
|
361
|
+
// future rule's inputs only exist GPU-side, a partition-kernel-
|
|
362
|
+
// driven scatter would put routing on the GPU; see Phase 5c.3.)
|
|
357
363
|
/** The derived-uniform rule for a uniform binding on this RO, if any: an explicit
|
|
358
364
|
* `derivedUniform(...)` value in `spec.inputs`, or a standard trafo recipe by name
|
|
359
365
|
* (recipes apply only when their base-trafo leaves are actually bound on this RO —
|
|
@@ -807,11 +813,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
807
813
|
// SAME global arena GPUBuffer (emscripten-style aliasing). The
|
|
808
814
|
// WGSL prelude declares one binding per view; the shader picks
|
|
809
815
|
// whichever matches its read.
|
|
816
|
+
const attrsBuf = arena.attrs.chunk(bucket.chunkIdx).buffer;
|
|
810
817
|
const entries = [
|
|
811
|
-
{ binding: 0, resource: { buffer:
|
|
818
|
+
{ binding: 0, resource: { buffer: attrsBuf } }, // heapU32
|
|
812
819
|
{ binding: 1, resource: { buffer: bucket.drawHeap.buffer } }, // headersU32
|
|
813
|
-
{ binding: 2, resource: { buffer:
|
|
814
|
-
{ binding: 3, resource: { buffer:
|
|
820
|
+
{ binding: 2, resource: { buffer: attrsBuf } }, // heapF32
|
|
821
|
+
{ binding: 3, resource: { buffer: attrsBuf } }, // heapV4f
|
|
815
822
|
];
|
|
816
823
|
{
|
|
817
824
|
const s = bucket.slots[slotIdx];
|
|
@@ -824,7 +831,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
824
831
|
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
825
832
|
// zero size constraint when the bucket is empty.
|
|
826
833
|
const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
|
|
827
|
-
entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
|
|
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 } });
|
|
828
835
|
s.renderBoundRecordCount = s.recordCount;
|
|
829
836
|
}
|
|
830
837
|
// Schema-driven texture + sampler entries. v1 user surface still
|
|
@@ -920,17 +927,39 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
920
927
|
}
|
|
921
928
|
}
|
|
922
929
|
}
|
|
923
|
-
arena.attrs.
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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.
|
|
927
948
|
});
|
|
928
949
|
if (atlasPool !== undefined) {
|
|
929
950
|
for (const f of ATLAS_PAGE_FORMATS) {
|
|
930
951
|
atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
|
|
931
952
|
}
|
|
932
953
|
}
|
|
933
|
-
arena.indices.
|
|
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
|
+
});
|
|
934
963
|
// ─── findOrCreateBucket ───────────────────────────────────────────
|
|
935
964
|
// Slice 3c: the bucket key collapses to (familyId, pipelineState).
|
|
936
965
|
// Every member effect in the family shares one bucket per
|
|
@@ -967,6 +996,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
967
996
|
});
|
|
968
997
|
const slot = {
|
|
969
998
|
pipeline, modeKey,
|
|
999
|
+
cpuDescriptor: descriptor,
|
|
970
1000
|
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
971
1001
|
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
972
1002
|
totalEmitEstimate: 0, scanDirty: false,
|
|
@@ -994,6 +1024,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
994
1024
|
rebuildScanBg();
|
|
995
1025
|
slot.drawTableDirtyMin = 0;
|
|
996
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
|
+
}
|
|
997
1032
|
});
|
|
998
1033
|
blockSumsBuf.onResize(rebuildScanBg);
|
|
999
1034
|
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
@@ -1007,7 +1042,654 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1007
1042
|
}
|
|
1008
1043
|
/** Side map from Bucket → family. Populated by findOrCreateBucket. */
|
|
1009
1044
|
const familyForBucket = new WeakMap();
|
|
1010
|
-
|
|
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,
|
|
1011
1693
|
/**
|
|
1012
1694
|
* Optional precomputed descriptor. addDrawImpl passes this when
|
|
1013
1695
|
* the spec carries `modeRules` so the bucket key reflects the
|
|
@@ -1019,12 +1701,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1019
1701
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
1020
1702
|
}
|
|
1021
1703
|
const fam = familyFor(effect);
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
|
|
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}`;
|
|
1028
1711
|
const existing = bucketByKey.get(bk);
|
|
1029
1712
|
if (existing !== undefined)
|
|
1030
1713
|
return existing;
|
|
@@ -1033,7 +1716,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1033
1716
|
const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
|
|
1034
1717
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
1035
1718
|
const bucket = {
|
|
1036
|
-
label: bk, textures: undefined, layout,
|
|
1719
|
+
label: bk, textures: undefined, layout, chunkIdx,
|
|
1037
1720
|
slots: [], // populated lazily by ensureSlot per modeKey
|
|
1038
1721
|
bindGroup: null, // legacy field; slot.bindGroup is the real one
|
|
1039
1722
|
drawHeap,
|
|
@@ -1047,6 +1730,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1047
1730
|
localAtlasReleases: [],
|
|
1048
1731
|
localAtlasTextures: [],
|
|
1049
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,
|
|
1050
1746
|
};
|
|
1051
1747
|
familyForBucket.set(bucket, fam);
|
|
1052
1748
|
drawHeap.onResize(() => {
|
|
@@ -1267,16 +1963,64 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1267
1963
|
token: outerTok,
|
|
1268
1964
|
});
|
|
1269
1965
|
}
|
|
1270
|
-
|
|
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
|
+
}
|
|
1271
2014
|
// Phase 5c.2: route this RO to the bucket's slot covering its
|
|
1272
2015
|
// current descriptor. ensureSlot creates a new slot on miss; the
|
|
1273
|
-
// pipeline lookup hits the scene-level cache.
|
|
1274
|
-
|
|
1275
|
-
|
|
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);
|
|
1276
2023
|
const roSlotIdx = bucket.slots.indexOf(roSlot);
|
|
1277
|
-
if (spec.modeRules !== undefined && drawId < 8) {
|
|
1278
|
-
console.debug(`[heapScene] addDraw drawId=${drawId} bucket=${bucket.label} slotIdx=${roSlotIdx}/${bucket.slots.length} cullMode=${roDescriptor.cullMode} recordCount=${roSlot.recordCount}`);
|
|
1279
|
-
}
|
|
1280
2024
|
const fam = familyFor(spec.effect);
|
|
1281
2025
|
const effectFields = fam.fieldsForEffect.get(spec.effect.id);
|
|
1282
2026
|
// Indices live in their own INDEX-usage buffer (WebGPU constraint).
|
|
@@ -1284,7 +2028,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1284
2028
|
// index allocation + one upload.
|
|
1285
2029
|
const indicesAval = asAval(spec.indices);
|
|
1286
2030
|
const indicesArr = readPlain(spec.indices);
|
|
1287
|
-
const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, indicesArr);
|
|
2031
|
+
const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, bucket.chunkIdx, indicesArr);
|
|
1288
2032
|
const localSlot = bucket.drawHeap.alloc();
|
|
1289
2033
|
// Per-RO instancing: read `spec.instanceCount` (defaults to 1).
|
|
1290
2034
|
const instanceCount = spec.instanceCount !== undefined
|
|
@@ -1317,7 +2061,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1317
2061
|
// overwrites the arena data each frame the inputs are dirty.
|
|
1318
2062
|
if (ruleForUniform(spec, f.name) !== undefined) {
|
|
1319
2063
|
const dummyAval = AVal.constant(M44d.zero);
|
|
1320
|
-
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);
|
|
1321
2065
|
perDrawRefs.set(f.name, ref);
|
|
1322
2066
|
perDrawAvals.push(dummyAval);
|
|
1323
2067
|
continue;
|
|
@@ -1359,7 +2103,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1359
2103
|
? perInstancePlacementFor(f, value, instanceCount)
|
|
1360
2104
|
: poolPlacementFor(f, value);
|
|
1361
2105
|
}
|
|
1362
|
-
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);
|
|
1363
2107
|
perDrawRefs.set(f.name, ref);
|
|
1364
2108
|
perDrawAvals.push(av);
|
|
1365
2109
|
}
|
|
@@ -1402,7 +2146,50 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1402
2146
|
const end = byteOff + bucket.layout.drawHeaderBytes;
|
|
1403
2147
|
if (end > bucket.headerDirtyMax)
|
|
1404
2148
|
bucket.headerDirtyMax = end;
|
|
1405
|
-
{
|
|
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 {
|
|
1406
2193
|
const dtBuf = roSlot.drawTableBuf;
|
|
1407
2194
|
const recIdx = roSlot.recordCount;
|
|
1408
2195
|
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
@@ -1467,46 +2254,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1467
2254
|
});
|
|
1468
2255
|
drawIdToModeTracker[drawId] = tracker;
|
|
1469
2256
|
tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
|
|
1470
|
-
// ───
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
// If declared is reactive (e.g. a shared cullModeC cval),
|
|
1484
|
-
// subscribe so flipping it marks the dispatcher dirty.
|
|
1485
|
-
// Plain values: no subscription needed.
|
|
1486
|
-
if (typeof gpuRule.declared !== "string") {
|
|
1487
|
-
subscribeModeLeaf(gpuRule.declared, drawId);
|
|
1488
|
-
}
|
|
1489
|
-
if (drawId < 3 || drawId % 20 === 0) {
|
|
1490
|
-
console.debug(`[heapScene] GPU rule registered drawId=${drawId} input='${gpuRule.inputUniform}' ref=${inputRef} declared=${typeof gpuRule.declared === "string" ? gpuRule.declared : "<aval>"}`);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
else {
|
|
1494
|
-
console.warn(`[heapScene] GPU rule input '${gpuRule.inputUniform}' not in spec.inputs for drawId=${drawId}; rule disabled`);
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
// ─── Pipeline pre-warm for derived-mode rules (5c.2) ──────────
|
|
1498
|
-
// Enumerate the rule's possible outputs and pre-build the
|
|
1499
|
-
// matching pipelines in the scene-level cache. Future cullModeC
|
|
1500
|
-
// flips become cache hits — zero `createRenderPipeline` calls
|
|
1501
|
-
// on the hot path.
|
|
1502
|
-
if (cullRule !== undefined && cullRule.domain !== undefined && tracker.descriptor !== undefined) {
|
|
1503
|
-
const fam = familyFor(spec.effect);
|
|
1504
|
-
const baseDesc = tracker.descriptor;
|
|
1505
|
-
for (const mode of cullRule.domain) {
|
|
1506
|
-
if (mode === baseDesc.cullMode)
|
|
1507
|
-
continue; // already built
|
|
1508
|
-
const variantDesc = { ...baseDesc, cullMode: mode };
|
|
1509
|
-
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);
|
|
1510
2270
|
}
|
|
1511
2271
|
}
|
|
1512
2272
|
}
|
|
@@ -1553,16 +2313,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1553
2313
|
const reg = registerRoDerivations(derivedScene, {}, {
|
|
1554
2314
|
rules: ruleSubset,
|
|
1555
2315
|
trafoAvals,
|
|
1556
|
-
// A non-trafo rule leaf reads the uniform's data straight from this RO's
|
|
1557
|
-
// drawHeader region in the arena (= its pool ref + the alloc-header pad).
|
|
1558
|
-
// Only resolvable if the uniform was actually packed (i.e. it's a drawHeader
|
|
1559
|
-
// field of the effect too) — undefined otherwise, which `resolveSource` rejects.
|
|
1560
2316
|
hostUniformOffset: (n) => {
|
|
1561
2317
|
const r = perDrawRefs.get(n);
|
|
1562
2318
|
return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
|
|
1563
2319
|
},
|
|
1564
2320
|
outputOffset: (n) => outOffsetByName.get(n),
|
|
1565
2321
|
drawHeaderBaseByte: 0,
|
|
2322
|
+
chunkIdx: bucket.chunkIdx,
|
|
1566
2323
|
});
|
|
1567
2324
|
derivedByDrawId.set(drawId, reg);
|
|
1568
2325
|
}
|
|
@@ -1584,7 +2341,38 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1584
2341
|
derivedByDrawId.delete(drawId);
|
|
1585
2342
|
}
|
|
1586
2343
|
}
|
|
1587
|
-
{
|
|
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 {
|
|
1588
2376
|
const slotIdx = drawIdToSlotIdx[drawId];
|
|
1589
2377
|
const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
|
|
1590
2378
|
const removedEntry = bucket.localEntries[localSlot];
|
|
@@ -1624,10 +2412,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1624
2412
|
const avals = bucket.localPerDrawAvals[localSlot];
|
|
1625
2413
|
if (avals !== undefined)
|
|
1626
2414
|
for (const av of avals)
|
|
1627
|
-
pool.release(arena.attrs, av);
|
|
2415
|
+
pool.release(arena.attrs, av, bucket.chunkIdx);
|
|
1628
2416
|
const idxAval = drawIdToIndexAval[drawId];
|
|
1629
2417
|
if (idxAval !== undefined)
|
|
1630
|
-
indexPool.release(arena.indices, idxAval);
|
|
2418
|
+
indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
|
|
1631
2419
|
const atlasRel = bucket.localAtlasReleases[localSlot];
|
|
1632
2420
|
if (atlasRel !== undefined)
|
|
1633
2421
|
atlasRel();
|
|
@@ -1681,10 +2469,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1681
2469
|
}
|
|
1682
2470
|
drawIdToModeTracker[drawId] = undefined;
|
|
1683
2471
|
dirtyModeKeyDrawIds.delete(drawId);
|
|
1684
|
-
if (gpuModesScene !== undefined) {
|
|
1685
|
-
gpuModesScene.deregisterRo(drawId);
|
|
1686
|
-
gpuModesLastKey.delete(drawId);
|
|
1687
|
-
}
|
|
1688
2472
|
stats.totalDraws--;
|
|
1689
2473
|
stats.geometryBytes = arenaBytes(arena);
|
|
1690
2474
|
// GC the bucket if it just emptied (common after reactive
|
|
@@ -1806,12 +2590,20 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1806
2590
|
const tracker = drawIdToModeTracker[drawId];
|
|
1807
2591
|
if (tracker === undefined)
|
|
1808
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
|
+
}
|
|
1809
2602
|
const oldKey = tracker.modeKey;
|
|
1810
2603
|
if (!tracker.recompute())
|
|
1811
2604
|
continue;
|
|
1812
2605
|
if (tracker.modeKey === oldKey)
|
|
1813
2606
|
continue;
|
|
1814
|
-
const bucket = drawIdToBucket[drawId];
|
|
1815
2607
|
const localSlot = drawIdToLocalSlot[drawId];
|
|
1816
2608
|
const oldSlotIdx = drawIdToSlotIdx[drawId];
|
|
1817
2609
|
if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined)
|
|
@@ -1884,9 +2676,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1884
2676
|
if (allocDirty.size > 0) {
|
|
1885
2677
|
for (const av of allocDirty) {
|
|
1886
2678
|
pool.repack(device, arena.attrs, av, av.getValue(tok));
|
|
1887
|
-
|
|
1888
|
-
if (e !== undefined)
|
|
1889
|
-
totalDirtyBytes += e.dataBytes;
|
|
2679
|
+
totalDirtyBytes += pool.totalDataBytes(av);
|
|
1890
2680
|
}
|
|
1891
2681
|
allocDirty.clear();
|
|
1892
2682
|
}
|
|
@@ -1976,6 +2766,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1976
2766
|
}
|
|
1977
2767
|
// Phase 5c.2: each slot has its own drawTable + shadow. Upload
|
|
1978
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;
|
|
1979
2773
|
for (const slot of bucket.slots) {
|
|
1980
2774
|
if (slot.drawTableDirtyMax <= slot.drawTableDirtyMin)
|
|
1981
2775
|
continue;
|
|
@@ -1991,15 +2785,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1991
2785
|
* begin/end — caller owns the pass. Used by both the convenience
|
|
1992
2786
|
* `frame()` below and (eventually) the hybrid render task.
|
|
1993
2787
|
*/
|
|
1994
|
-
let _encodeLogged = false;
|
|
1995
2788
|
function encodeIntoPass(pass) {
|
|
1996
2789
|
let curBg = null;
|
|
1997
|
-
let drawCount = 0;
|
|
1998
|
-
const slotInfo = [];
|
|
1999
2790
|
for (const b of buckets) {
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2791
|
+
// Phase 5c.2: iterate the bucket's slots, drawIndirect per
|
|
2792
|
+
// non-empty slot. Empty slots draw zero (skipped to save the
|
|
2793
|
+
// setPipeline/setBindGroup overhead).
|
|
2794
|
+
for (const slot of b.slots) {
|
|
2003
2795
|
if (slot.recordCount === 0)
|
|
2004
2796
|
continue;
|
|
2005
2797
|
const bg = slot.bindGroup;
|
|
@@ -2008,16 +2800,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2008
2800
|
curBg = bg;
|
|
2009
2801
|
}
|
|
2010
2802
|
pass.setPipeline(slot.pipeline);
|
|
2011
|
-
if (slot.indirectBuf !== undefined)
|
|
2803
|
+
if (slot.indirectBuf !== undefined)
|
|
2012
2804
|
pass.drawIndirect(slot.indirectBuf, 0);
|
|
2013
|
-
drawCount++;
|
|
2014
|
-
}
|
|
2015
2805
|
}
|
|
2016
2806
|
}
|
|
2017
|
-
if (!_encodeLogged) {
|
|
2018
|
-
console.debug(`[heapScene] encode: ${drawCount} drawIndirect; slots:`, slotInfo);
|
|
2019
|
-
_encodeLogged = true;
|
|
2020
|
-
}
|
|
2021
2807
|
}
|
|
2022
2808
|
/**
|
|
2023
2809
|
* Compute prep — must be called BEFORE `beginRenderPass`. For each
|
|
@@ -2030,40 +2816,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2030
2816
|
* we just swap the bind group + dispatch shape per bucket.
|
|
2031
2817
|
*/
|
|
2032
2818
|
function encodeComputePrep(enc, token) {
|
|
2033
|
-
// GPU-eval derived-mode rules (Task 2 Phase 5): dispatch the
|
|
2034
|
-
// hardcoded determinant-flip-cull kernel + start readback.
|
|
2035
|
-
// Resolved on the next frame's update() — one-frame lag for the
|
|
2036
|
-
// first appearance of a state transition. CPU fallback runs at
|
|
2037
|
-
// addDraw time so the initial bucket assignment is still correct.
|
|
2038
|
-
if (gpuModesScene !== undefined
|
|
2039
|
-
&& gpuModesScene.registered > 0
|
|
2040
|
-
&& gpuModesScene.consumeDirty()) {
|
|
2041
|
-
// Dirty-gate: skip the kernel + readback when no rule input
|
|
2042
|
-
// changed since the last dispatch. Unrelated avals (Color on
|
|
2043
|
-
// hover, etc.) don't trigger dirty, so steady state has zero
|
|
2044
|
-
// GPU mode-rule work per frame.
|
|
2045
|
-
const numROs = nextDrawId; // upper bound; sparse entries are skipped via 0xFFFFFFFF sentinel
|
|
2046
|
-
gpuModesScene.dispatch(arena.attrs.buffer, numROs, enc);
|
|
2047
|
-
// mapAsync the staging buffer AFTER the caller's submit. The
|
|
2048
|
-
// microtask queued by Promise.resolve() runs after the current
|
|
2049
|
-
// synchronous block — by which time `frame()` has already
|
|
2050
|
-
// submitted the enc. Without this deferral, mapAsync fires
|
|
2051
|
-
// before submit and WebGPU rejects the next dispatch's
|
|
2052
|
-
// copy-to-staging with "buffer used in submit while mapped".
|
|
2053
|
-
// No readback: v1's patch-spec.modeRules approach broke
|
|
2054
|
-
// reactivity (a second cullModeC flip stopped re-bucketing
|
|
2055
|
-
// because the patched constant closure never produces a new
|
|
2056
|
-
// modeKey). The CPU fallback is authoritative for now —
|
|
2057
|
-
// tracker.recompute drives the rebucket flow correctly.
|
|
2058
|
-
//
|
|
2059
|
-
// Phase 5c.2 (no-readback by design): pre-warm all slot
|
|
2060
|
-
// pipelines at scene-build via static analysis; kernel writes
|
|
2061
|
-
// per-RO slot index; partition kernel produces per-slot
|
|
2062
|
-
// indirect args; encode iterates fixed slots. The
|
|
2063
|
-
// GpuDerivedModesScene kernel dispatch stays here as a
|
|
2064
|
-
// demonstration of the GPU path — its output is currently
|
|
2065
|
-
// unused but proves the kernel runs against arena data.
|
|
2066
|
-
}
|
|
2067
2819
|
// §7: derived-uniforms dispatch first — writes into per-bucket
|
|
2068
2820
|
// drawHeap regions before the scan reads anything. One dispatcher
|
|
2069
2821
|
// per bucket; constituents are shared so dirty state propagates
|
|
@@ -2088,10 +2840,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2088
2840
|
stats.derivedPullMs = _t1 - _t0;
|
|
2089
2841
|
stats.derivedUploadMs = _t2 - _t1;
|
|
2090
2842
|
stats.derivedEncodeMs = _t3 - _t2;
|
|
2091
|
-
stats.derivedRecords = derivedScene.
|
|
2843
|
+
stats.derivedRecords = derivedScene.totalRecordCount;
|
|
2092
2844
|
}
|
|
2093
2845
|
let anyDirty = false;
|
|
2094
2846
|
outer: for (const b of buckets) {
|
|
2847
|
+
if (b.gpuRouted && b.partitionDirty) {
|
|
2848
|
+
anyDirty = true;
|
|
2849
|
+
break outer;
|
|
2850
|
+
}
|
|
2095
2851
|
for (const s of b.slots) {
|
|
2096
2852
|
if (s.scanDirty) {
|
|
2097
2853
|
anyDirty = true;
|
|
@@ -2101,6 +2857,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2101
2857
|
}
|
|
2102
2858
|
if (!anyDirty)
|
|
2103
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
|
+
}
|
|
2104
2868
|
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
2105
2869
|
for (const b of buckets) {
|
|
2106
2870
|
for (let i = 0; i < b.slots.length; i++) {
|
|
@@ -2197,7 +2961,6 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2197
2961
|
arena.indices.destroy();
|
|
2198
2962
|
for (const b of buckets)
|
|
2199
2963
|
destroyBucketResources(b);
|
|
2200
|
-
gpuModesScene?.dispose();
|
|
2201
2964
|
}
|
|
2202
2965
|
// Test-only escape hatch for inspecting megacall bucket state. Not
|
|
2203
2966
|
// part of the public API surface — keep cast at use-site.
|
|
@@ -2237,8 +3000,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2237
3000
|
let vidChecks = 0, vidBad = 0;
|
|
2238
3001
|
const push = (s) => { if (issues.length < 60)
|
|
2239
3002
|
issues.push(s); };
|
|
2240
|
-
|
|
2241
|
-
|
|
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;
|
|
2242
3013
|
const enc = device.createCommandEncoder({ label: "validateHeap" });
|
|
2243
3014
|
const stage = (src, size) => {
|
|
2244
3015
|
const c = device.createBuffer({
|
|
@@ -2248,8 +3019,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2248
3019
|
enc.copyBufferToBuffer(src, 0, c, 0, size);
|
|
2249
3020
|
return c;
|
|
2250
3021
|
};
|
|
2251
|
-
const arenaCopy = stage(arena.attrs.buffer, arenaSize);
|
|
2252
|
-
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;
|
|
2253
3024
|
const TILE_K_LOCAL = 64;
|
|
2254
3025
|
const dcs = [];
|
|
2255
3026
|
for (const b of buckets) {
|
|
@@ -2525,8 +3296,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2525
3296
|
let emitsChecked = 0;
|
|
2526
3297
|
const push = (s) => { if (issues.length < 30)
|
|
2527
3298
|
issues.push(s); };
|
|
2528
|
-
|
|
2529
|
-
|
|
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;
|
|
2530
3305
|
// Stage all buffers we'll need.
|
|
2531
3306
|
const enc = device.createCommandEncoder({ label: "simulateDraws" });
|
|
2532
3307
|
const stage = (src, size) => {
|
|
@@ -2536,8 +3311,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2536
3311
|
enc.copyBufferToBuffer(src, 0, c, 0, size);
|
|
2537
3312
|
return c;
|
|
2538
3313
|
};
|
|
2539
|
-
const arenaCopy = stage(arena.attrs.buffer, arenaSize);
|
|
2540
|
-
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;
|
|
2541
3316
|
const dcs = [];
|
|
2542
3317
|
for (const b of buckets) {
|
|
2543
3318
|
if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
|
|
@@ -2788,20 +3563,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2788
3563
|
// bytes to two avals → two ROs reading each other's data → the
|
|
2789
3564
|
// cross-RO triangle symptom.
|
|
2790
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;
|
|
2791
3572
|
// Stage arena + arena.indices once (shared across buckets).
|
|
2792
3573
|
const arenaCopy = device.createBuffer({
|
|
2793
|
-
size:
|
|
3574
|
+
size: arenaBuf0.size,
|
|
2794
3575
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2795
3576
|
});
|
|
2796
|
-
const indicesCopy =
|
|
2797
|
-
size:
|
|
3577
|
+
const indicesCopy = indicesBuf0.size > 0 ? device.createBuffer({
|
|
3578
|
+
size: indicesBuf0.size,
|
|
2798
3579
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2799
3580
|
}) : undefined;
|
|
2800
3581
|
{
|
|
2801
3582
|
const enc = device.createCommandEncoder({ label: "checkTriangleCoherence.arena" });
|
|
2802
|
-
enc.copyBufferToBuffer(
|
|
3583
|
+
enc.copyBufferToBuffer(arenaBuf0, 0, arenaCopy, 0, arenaBuf0.size);
|
|
2803
3584
|
if (indicesCopy !== undefined)
|
|
2804
|
-
enc.copyBufferToBuffer(
|
|
3585
|
+
enc.copyBufferToBuffer(indicesBuf0, 0, indicesCopy, 0, indicesBuf0.size);
|
|
2805
3586
|
device.queue.submit([enc.finish()]);
|
|
2806
3587
|
}
|
|
2807
3588
|
await arenaCopy.mapAsync(GPUMapMode.READ);
|
|
@@ -3238,7 +4019,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3238
4019
|
{ binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
|
|
3239
4020
|
{ binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
|
|
3240
4021
|
{ binding: 2, resource: { buffer: sampleBuf } },
|
|
3241
|
-
{ binding: 3, resource: { buffer: arena.indices.buffer } },
|
|
4022
|
+
{ binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
|
|
3242
4023
|
{ binding: 4, resource: { buffer: outBuf } },
|
|
3243
4024
|
{ binding: 5, resource: { buffer: paramBuf } },
|
|
3244
4025
|
],
|
|
@@ -3262,12 +4043,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3262
4043
|
outBuf.destroy();
|
|
3263
4044
|
paramBuf.destroy();
|
|
3264
4045
|
// Read drawTable + indices for CPU expectation.
|
|
4046
|
+
const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
|
|
3265
4047
|
const indicesCopy2 = device.createBuffer({
|
|
3266
|
-
size:
|
|
4048
|
+
size: idxBuf.size,
|
|
3267
4049
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3268
4050
|
});
|
|
3269
4051
|
const enc2 = device.createCommandEncoder();
|
|
3270
|
-
enc2.copyBufferToBuffer(
|
|
4052
|
+
enc2.copyBufferToBuffer(idxBuf, 0, indicesCopy2, 0, idxBuf.size);
|
|
3271
4053
|
device.queue.submit([enc2.finish()]);
|
|
3272
4054
|
await indicesCopy2.mapAsync(GPUMapMode.READ);
|
|
3273
4055
|
const indicesU32 = new Uint32Array(indicesCopy2.getMappedRange().slice(0));
|