@aardworx/wombat.rendering 0.9.33 → 0.19.0
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/core/renderObject.d.ts +16 -0
- package/dist/core/renderObject.d.ts.map +1 -1
- 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/heapAdapter.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.js +17 -0
- package/dist/runtime/heapAdapter.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 +55 -0
- package/dist/runtime/heapScene/freelist.d.ts.map +1 -0
- package/dist/runtime/heapScene/freelist.js +188 -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 +84 -42
- package/dist/runtime/heapScene/pools.d.ts.map +1 -1
- package/dist/runtime/heapScene/pools.js +309 -134
- package/dist/runtime/heapScene/pools.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +24 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +1158 -92
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/hybridScene.d.ts +15 -0
- package/dist/runtime/hybridScene.d.ts.map +1 -1
- package/dist/runtime/hybridScene.js +16 -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/dist/runtime/textureAtlas/atlasPool.d.ts +12 -0
- package/dist/runtime/textureAtlas/atlasPool.d.ts.map +1 -1
- package/dist/runtime/textureAtlas/atlasPool.js +20 -0
- package/dist/runtime/textureAtlas/atlasPool.js.map +1 -1
- package/package.json +1 -1
- package/src/core/renderObject.ts +16 -0
- 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/heapAdapter.ts +17 -0
- package/src/runtime/heapEligibility.ts +22 -1
- package/src/runtime/heapScene/chunkedArena.ts +238 -0
- package/src/runtime/heapScene/freelist.ts +196 -0
- package/src/runtime/heapScene/growBuffer.ts +39 -2
- package/src/runtime/heapScene/pools.ts +350 -143
- package/src/runtime/heapScene.ts +1350 -88
- package/src/runtime/hybridScene.ts +31 -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/textureAtlas/atlasPool.ts +19 -0
- package/src/runtime/derivedModes/gpuDispatcher.ts +0 -226
- 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,
|
|
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 = [];
|
|
@@ -115,6 +119,96 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
115
119
|
*/
|
|
116
120
|
const modeAvalToDrawIds = new Map();
|
|
117
121
|
const modeAvalCallbacks = new Map();
|
|
122
|
+
// ─── HeapDrawSpec.active subscription ────────────────────────────────
|
|
123
|
+
// Per-drawId state for the "skip without re-allocating" path. When a
|
|
124
|
+
// tile (or any RO) flips visibility many times per second (e.g. an
|
|
125
|
+
// LOD walker culling/uncrossing the frustum), pool churn would be
|
|
126
|
+
// prohibitive. Instead we keep the RO in the bucket and flip its
|
|
127
|
+
// drawTable record's `indexCount` field between the original count
|
|
128
|
+
// (visible) and 0 (hidden). The scan kernel's prefix sum naturally
|
|
129
|
+
// skips a record with 0 emit count, so this costs one drawTable
|
|
130
|
+
// write + a re-scan dispatch — no arena/freelist activity.
|
|
131
|
+
const drawIdToActiveAval = new Map();
|
|
132
|
+
const drawIdToOrigIndexCount = new Map();
|
|
133
|
+
const drawIdToActiveCallback = new Map();
|
|
134
|
+
const activeAvalToDrawIds = new Map();
|
|
135
|
+
const activeDirty = new Set();
|
|
136
|
+
function subscribeActive(av, drawId) {
|
|
137
|
+
let set = activeAvalToDrawIds.get(av);
|
|
138
|
+
if (set === undefined) {
|
|
139
|
+
set = new Set();
|
|
140
|
+
activeAvalToDrawIds.set(av, set);
|
|
141
|
+
}
|
|
142
|
+
set.add(drawId);
|
|
143
|
+
const cb = addMarkingCallback(av, () => { activeDirty.add(drawId); });
|
|
144
|
+
drawIdToActiveCallback.set(drawId, cb);
|
|
145
|
+
}
|
|
146
|
+
function unsubscribeActive(drawId) {
|
|
147
|
+
const av = drawIdToActiveAval.get(drawId);
|
|
148
|
+
if (av === undefined)
|
|
149
|
+
return;
|
|
150
|
+
const cb = drawIdToActiveCallback.get(drawId);
|
|
151
|
+
cb?.dispose();
|
|
152
|
+
drawIdToActiveCallback.delete(drawId);
|
|
153
|
+
const set = activeAvalToDrawIds.get(av);
|
|
154
|
+
if (set !== undefined) {
|
|
155
|
+
set.delete(drawId);
|
|
156
|
+
if (set.size === 0)
|
|
157
|
+
activeAvalToDrawIds.delete(av);
|
|
158
|
+
}
|
|
159
|
+
drawIdToActiveAval.delete(drawId);
|
|
160
|
+
drawIdToOrigIndexCount.delete(drawId);
|
|
161
|
+
activeDirty.delete(drawId);
|
|
162
|
+
}
|
|
163
|
+
/** Update the drawTable record's effective indexCount in place
|
|
164
|
+
* (GPU-routed: masterShadow + partition re-dispatch; legacy:
|
|
165
|
+
* drawTableShadow + scan re-dispatch). Common helper used by the
|
|
166
|
+
* active-aval drain in `update()`. */
|
|
167
|
+
function setEffectiveIndexCount(drawId, newIndexCount) {
|
|
168
|
+
const bucket = drawIdToBucket[drawId];
|
|
169
|
+
if (bucket === undefined)
|
|
170
|
+
return;
|
|
171
|
+
if (bucket.gpuRouted) {
|
|
172
|
+
const partition = bucket.partitionScene;
|
|
173
|
+
const recIdx = bucket.drawIdToRecord.get(drawId);
|
|
174
|
+
if (recIdx === undefined)
|
|
175
|
+
return;
|
|
176
|
+
const ru32 = partition.recordU32;
|
|
177
|
+
const oldIndexCount = partition.masterShadow[recIdx * ru32 + 3];
|
|
178
|
+
const instanceCount = partition.masterShadow[recIdx * ru32 + 4];
|
|
179
|
+
partition.masterShadow[recIdx * ru32 + 3] = newIndexCount >>> 0;
|
|
180
|
+
const delta = (newIndexCount - oldIndexCount) * instanceCount;
|
|
181
|
+
bucket.partitionDirty = true;
|
|
182
|
+
for (const s of bucket.slots) {
|
|
183
|
+
s.totalEmitEstimate = Math.max(0, s.totalEmitEstimate + delta);
|
|
184
|
+
s.scanDirty = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const slotIdx = drawIdToSlotIdx[drawId];
|
|
189
|
+
const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
|
|
190
|
+
if (slot === undefined)
|
|
191
|
+
return;
|
|
192
|
+
const localSlot = drawIdToLocalSlot[drawId];
|
|
193
|
+
if (localSlot === undefined)
|
|
194
|
+
return;
|
|
195
|
+
const recIdx = slot.slotToRecord[localSlot];
|
|
196
|
+
if (recIdx === undefined || recIdx < 0)
|
|
197
|
+
return;
|
|
198
|
+
const shadow = slot.drawTableShadow;
|
|
199
|
+
const oldIndexCount = shadow[recIdx * RECORD_U32 + 3];
|
|
200
|
+
const instanceCount = shadow[recIdx * RECORD_U32 + 4];
|
|
201
|
+
shadow[recIdx * RECORD_U32 + 3] = newIndexCount >>> 0;
|
|
202
|
+
const byteOff = recIdx * RECORD_BYTES + 3 * 4;
|
|
203
|
+
if (byteOff < slot.drawTableDirtyMin)
|
|
204
|
+
slot.drawTableDirtyMin = byteOff;
|
|
205
|
+
if (byteOff + 4 > slot.drawTableDirtyMax)
|
|
206
|
+
slot.drawTableDirtyMax = byteOff + 4;
|
|
207
|
+
const delta = (newIndexCount - oldIndexCount) * instanceCount;
|
|
208
|
+
slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate + delta);
|
|
209
|
+
slot.scanDirty = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
118
212
|
function subscribeModeLeaf(av, drawId) {
|
|
119
213
|
let set = modeAvalToDrawIds.get(av);
|
|
120
214
|
if (set === undefined) {
|
|
@@ -307,7 +401,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
307
401
|
inputChanged(_t, o) {
|
|
308
402
|
// Pool avals are stored as `aval<unknown>`; the IAdaptiveObject
|
|
309
403
|
// identity matches.
|
|
310
|
-
if (pool.
|
|
404
|
+
if (pool.hasAny(o)) {
|
|
311
405
|
allocDirty.add(o);
|
|
312
406
|
return;
|
|
313
407
|
}
|
|
@@ -331,11 +425,22 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
331
425
|
// `enableDerivedUniforms: false` to opt out.
|
|
332
426
|
const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
|
|
333
427
|
const derivedScene = enableDerivedUniforms
|
|
334
|
-
? new DerivedUniformsScene(device,
|
|
428
|
+
? new DerivedUniformsScene(device, {
|
|
335
429
|
// Larger initial capacity reduces grow-during-first-frame churn.
|
|
336
430
|
initialConstituentSlots: 4096,
|
|
337
431
|
})
|
|
338
432
|
: undefined;
|
|
433
|
+
// §3: register each arena chunk's main-heap buffer with the
|
|
434
|
+
// derived-uniforms scene; refresh the binding when chunks open or
|
|
435
|
+
// a chunk's GrowBuffer reallocates.
|
|
436
|
+
if (derivedScene !== undefined) {
|
|
437
|
+
const wireChunk = (idx) => {
|
|
438
|
+
derivedScene.setMainHeapForChunk(idx, () => arena.attrs.chunk(idx).buffer);
|
|
439
|
+
};
|
|
440
|
+
for (let i = 0; i < arena.attrs.chunkCount; i++)
|
|
441
|
+
wireChunk(i);
|
|
442
|
+
arena.attrs.onChunkAdded(wireChunk);
|
|
443
|
+
}
|
|
339
444
|
/** Per-RO §7 registration handles, keyed by global drawId.
|
|
340
445
|
* Drained on removeDraw to release slots + records. */
|
|
341
446
|
const derivedByDrawId = new Map();
|
|
@@ -798,11 +903,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
798
903
|
// SAME global arena GPUBuffer (emscripten-style aliasing). The
|
|
799
904
|
// WGSL prelude declares one binding per view; the shader picks
|
|
800
905
|
// whichever matches its read.
|
|
906
|
+
const attrsBuf = arena.attrs.chunk(bucket.chunkIdx).buffer;
|
|
801
907
|
const entries = [
|
|
802
|
-
{ binding: 0, resource: { buffer:
|
|
908
|
+
{ binding: 0, resource: { buffer: attrsBuf } }, // heapU32
|
|
803
909
|
{ binding: 1, resource: { buffer: bucket.drawHeap.buffer } }, // headersU32
|
|
804
|
-
{ binding: 2, resource: { buffer:
|
|
805
|
-
{ binding: 3, resource: { buffer:
|
|
910
|
+
{ binding: 2, resource: { buffer: attrsBuf } }, // heapF32
|
|
911
|
+
{ binding: 3, resource: { buffer: attrsBuf } }, // heapV4f
|
|
806
912
|
];
|
|
807
913
|
{
|
|
808
914
|
const s = bucket.slots[slotIdx];
|
|
@@ -815,7 +921,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
815
921
|
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
816
922
|
// zero size constraint when the bucket is empty.
|
|
817
923
|
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 } });
|
|
924
|
+
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
925
|
s.renderBoundRecordCount = s.recordCount;
|
|
820
926
|
}
|
|
821
927
|
// Schema-driven texture + sampler entries. v1 user surface still
|
|
@@ -911,17 +1017,39 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
911
1017
|
}
|
|
912
1018
|
}
|
|
913
1019
|
}
|
|
914
|
-
arena.attrs.
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1020
|
+
arena.attrs.onAnyResize((resizedChunkIdx) => {
|
|
1021
|
+
// Only rebuild bind groups for buckets that bind THIS chunk —
|
|
1022
|
+
// a resize in chunk K doesn't affect a bucket reading chunk N≠K.
|
|
1023
|
+
for (const b of buckets) {
|
|
1024
|
+
if (b.chunkIdx !== resizedChunkIdx)
|
|
1025
|
+
continue;
|
|
1026
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
1027
|
+
b.slots[i].bindGroup = buildBucketBindGroup(b, i);
|
|
1028
|
+
}
|
|
1029
|
+
if (b.gpuRouted && b.partitionScene !== undefined) {
|
|
1030
|
+
b.partitionScene.rebindSlotDrawBufs(b.slots.map(s => s.drawTableBuf.buffer));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// §7: each chunk's main-heap getter is registered via
|
|
1034
|
+
// `derivedScene.setMainHeapForChunk` (at construction + on
|
|
1035
|
+
// onChunkAdded). The getter returns the chunk's current
|
|
1036
|
+
// GPUBuffer; the dispatcher's bind groups consult it per
|
|
1037
|
+
// dispatch, so no explicit rebind needed here.
|
|
918
1038
|
});
|
|
919
1039
|
if (atlasPool !== undefined) {
|
|
920
1040
|
for (const f of ATLAS_PAGE_FORMATS) {
|
|
921
1041
|
atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
|
|
922
1042
|
}
|
|
923
1043
|
}
|
|
924
|
-
arena.indices.
|
|
1044
|
+
arena.indices.onAnyResize((resizedChunkIdx) => {
|
|
1045
|
+
for (const b of buckets) {
|
|
1046
|
+
if (b.chunkIdx !== resizedChunkIdx)
|
|
1047
|
+
continue;
|
|
1048
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
1049
|
+
b.slots[i].bindGroup = buildBucketBindGroup(b, i);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
925
1053
|
// ─── findOrCreateBucket ───────────────────────────────────────────
|
|
926
1054
|
// Slice 3c: the bucket key collapses to (familyId, pipelineState).
|
|
927
1055
|
// Every member effect in the family shares one bucket per
|
|
@@ -958,6 +1086,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
958
1086
|
});
|
|
959
1087
|
const slot = {
|
|
960
1088
|
pipeline, modeKey,
|
|
1089
|
+
cpuDescriptor: descriptor,
|
|
961
1090
|
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
962
1091
|
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
963
1092
|
totalEmitEstimate: 0, scanDirty: false,
|
|
@@ -985,6 +1114,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
985
1114
|
rebuildScanBg();
|
|
986
1115
|
slot.drawTableDirtyMin = 0;
|
|
987
1116
|
slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
|
|
1117
|
+
// Phase 5c.3: partition's bind group references every slot's
|
|
1118
|
+
// drawTable. When ANY slot's GPUBuffer reallocates we rebind all.
|
|
1119
|
+
if (bucket.gpuRouted && bucket.partitionScene !== undefined) {
|
|
1120
|
+
bucket.partitionScene.rebindSlotDrawBufs(bucket.slots.map(s => s.drawTableBuf.buffer));
|
|
1121
|
+
}
|
|
988
1122
|
});
|
|
989
1123
|
blockSumsBuf.onResize(rebuildScanBg);
|
|
990
1124
|
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
@@ -998,7 +1132,654 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
998
1132
|
}
|
|
999
1133
|
/** Side map from Bucket → family. Populated by findOrCreateBucket. */
|
|
1000
1134
|
const familyForBucket = new WeakMap();
|
|
1001
|
-
|
|
1135
|
+
/** Canonical axis order — every part of the GPU routing path uses
|
|
1136
|
+
* this ordering so the kernel's params layout, the per-slot mixed-
|
|
1137
|
+
* radix mode key, and the declared-aval lookup stay in lockstep. */
|
|
1138
|
+
const AXIS_ORDER = [
|
|
1139
|
+
"cull", "frontFace", "topology",
|
|
1140
|
+
"depthCompare", "depthWrite", "alphaToCoverage",
|
|
1141
|
+
];
|
|
1142
|
+
function collectRules(modeRules) {
|
|
1143
|
+
const out = [];
|
|
1144
|
+
for (const axis of AXIS_ORDER) {
|
|
1145
|
+
const r = modeRules[axis];
|
|
1146
|
+
if (r !== undefined)
|
|
1147
|
+
out.push(r);
|
|
1148
|
+
}
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
/** Apply a per-axis enum value to a `PipelineStateDescriptor` and
|
|
1152
|
+
* return the patched copy. Used to materialise one descriptor per
|
|
1153
|
+
* slot (= one per cartesian-product point across the rule domains). */
|
|
1154
|
+
function patchDescriptor(base, axis, value) {
|
|
1155
|
+
switch (axis) {
|
|
1156
|
+
case "cull": return { ...base, cullMode: value };
|
|
1157
|
+
case "frontFace": return { ...base, frontFace: value };
|
|
1158
|
+
case "topology": {
|
|
1159
|
+
const topology = value;
|
|
1160
|
+
return { ...base, topology, stripIndexFormat: stripFormatForTopology(topology) };
|
|
1161
|
+
}
|
|
1162
|
+
case "depthCompare":
|
|
1163
|
+
return base.depth !== undefined
|
|
1164
|
+
? { ...base, depth: { ...base.depth, compare: value } }
|
|
1165
|
+
: base;
|
|
1166
|
+
case "depthWrite":
|
|
1167
|
+
return base.depth !== undefined
|
|
1168
|
+
? { ...base, depth: { ...base.depth, write: value } }
|
|
1169
|
+
: base;
|
|
1170
|
+
case "alphaToCoverage": return { ...base, alphaToCoverage: value };
|
|
1171
|
+
case "blend": {
|
|
1172
|
+
// Blend is per-attachment; apply the resolved AttachmentBlend
|
|
1173
|
+
// to attachment 0 (the main color target). Multi-attachment
|
|
1174
|
+
// structured rules can extend this when needed.
|
|
1175
|
+
const blend = value;
|
|
1176
|
+
const atts = base.attachments.slice();
|
|
1177
|
+
if (atts.length === 0)
|
|
1178
|
+
atts.push(blend);
|
|
1179
|
+
else
|
|
1180
|
+
atts[0] = blend;
|
|
1181
|
+
return { ...base, attachments: atts };
|
|
1182
|
+
}
|
|
1183
|
+
default: return base;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
function stripFormatForTopology(t) {
|
|
1187
|
+
return t === "line-strip" || t === "triangle-strip" ? "uint32" : undefined;
|
|
1188
|
+
}
|
|
1189
|
+
/** Per-axis enum order. The rule body produces u32 indices into
|
|
1190
|
+
* this array, and the heap renderer uses the same order to map
|
|
1191
|
+
* resolved-set integers back to pipeline descriptor values. The
|
|
1192
|
+
* ordering MUST match the bitfield encoder in pipelineCache. */
|
|
1193
|
+
const AXIS_ENUM_TABLE = {
|
|
1194
|
+
cull: ["none", "front", "back"],
|
|
1195
|
+
frontFace: ["ccw", "cw"],
|
|
1196
|
+
topology: ["point-list", "line-list", "line-strip", "triangle-list", "triangle-strip"],
|
|
1197
|
+
depthCompare: ["never", "less", "equal", "less-equal", "greater", "not-equal", "greater-equal", "always"],
|
|
1198
|
+
depthWrite: [false, true],
|
|
1199
|
+
alphaToCoverage: [false, true],
|
|
1200
|
+
// Blend has no canonical enum table — the rule's `resolve` /
|
|
1201
|
+
// `values` callback MUST be supplied so the heap renderer knows
|
|
1202
|
+
// how to map u32 outputs to AttachmentBlend objects.
|
|
1203
|
+
blend: [],
|
|
1204
|
+
};
|
|
1205
|
+
/**
|
|
1206
|
+
* Map a resolved slot value (from `evaluateStructuralSet`) to the
|
|
1207
|
+
* axis-shape value used for descriptor patching.
|
|
1208
|
+
*
|
|
1209
|
+
* - If the resolved value is a JS object: it's already the full
|
|
1210
|
+
* axis value (e.g. an `AttachmentBlend` from a blend rule's
|
|
1211
|
+
* object-literal return) — use it directly.
|
|
1212
|
+
* - If it's a number: for enum axes, look up the canonical
|
|
1213
|
+
* table; for structured axes without a table, pass through.
|
|
1214
|
+
*/
|
|
1215
|
+
function resolveAxisValue(rule, value) {
|
|
1216
|
+
if (typeof value === "object" && value !== null)
|
|
1217
|
+
return value;
|
|
1218
|
+
if (typeof value === "number") {
|
|
1219
|
+
const table = AXIS_ENUM_TABLE[rule.axis];
|
|
1220
|
+
if (table.length > 0) {
|
|
1221
|
+
const v = table[value];
|
|
1222
|
+
if (v !== undefined)
|
|
1223
|
+
return v;
|
|
1224
|
+
}
|
|
1225
|
+
return value;
|
|
1226
|
+
}
|
|
1227
|
+
return value;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Resolve an axis's `declared` value to a u32 index.
|
|
1231
|
+
*
|
|
1232
|
+
* - omitted: `0` (the rule body either doesn't reference `declared`
|
|
1233
|
+
* or wants the canonical zero of its axis as the default).
|
|
1234
|
+
* - number: passed through directly (e.g. `declared: 2` for cull "back").
|
|
1235
|
+
* - aval / string / boolean: looked up in the canonical enum table.
|
|
1236
|
+
*/
|
|
1237
|
+
function resolveDeclaredU32(rule, tok) {
|
|
1238
|
+
const d = rule.declared;
|
|
1239
|
+
if (d === undefined)
|
|
1240
|
+
return 0;
|
|
1241
|
+
const v = (typeof d === "object" && d !== null && "getValue" in d)
|
|
1242
|
+
? d.getValue(tok)
|
|
1243
|
+
: d;
|
|
1244
|
+
if (typeof v === "number")
|
|
1245
|
+
return v >>> 0;
|
|
1246
|
+
const table = AXIS_ENUM_TABLE[rule.axis];
|
|
1247
|
+
if (table.length === 0) {
|
|
1248
|
+
throw new Error(`heapScene/gpuRouting: axis '${rule.axis}' has no canonical enum table; ` +
|
|
1249
|
+
`pass \`declared\` as a u32 index or omit it`);
|
|
1250
|
+
}
|
|
1251
|
+
const i = table.indexOf(v);
|
|
1252
|
+
if (i < 0) {
|
|
1253
|
+
throw new Error(`heapScene/gpuRouting: declared value '${String(v)}' for axis '${rule.axis}' ` +
|
|
1254
|
+
`not in canonical enum table ${JSON.stringify(table)}`);
|
|
1255
|
+
}
|
|
1256
|
+
return i;
|
|
1257
|
+
}
|
|
1258
|
+
/** Extract the rule body Stmt from a RuleExpr's Module. The plugin
|
|
1259
|
+
* emits one `Entry` ValueDef whose body is the user's closure body
|
|
1260
|
+
* (with the synthetic compute-stage scaffolding stripped). */
|
|
1261
|
+
function ruleBodyOf(rule) {
|
|
1262
|
+
const tmpl = rule.expr.template;
|
|
1263
|
+
for (const v of tmpl.values) {
|
|
1264
|
+
if (v.kind === "Entry" && v.entry !== undefined)
|
|
1265
|
+
return v.entry.body;
|
|
1266
|
+
}
|
|
1267
|
+
throw new Error(`heapScene/gpuRouting: rule for axis '${rule.axis}' has no Entry in its template (id=${rule.expr.id})`);
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Specialise a rule for the current declared u32 value:
|
|
1271
|
+
* 1. Bake `declared` as a Const in the rule body (so the kernel
|
|
1272
|
+
* has no per-dispatch uniform — declared marks trigger a
|
|
1273
|
+
* re-specialise + cached-kernel swap).
|
|
1274
|
+
* 2. Run `analyseOutputSet` to collect distinct return Exprs.
|
|
1275
|
+
* 3. `evaluateStructuralSet` folds each Expr to a JS value (a
|
|
1276
|
+
* number for enum axes; a full object for structured axes
|
|
1277
|
+
* like blend). Distinct values define the slot count.
|
|
1278
|
+
* 4. Build a stableStringify-keyed `exprToSlot` map.
|
|
1279
|
+
* 5. Rewrite the body so every ReturnValue emits a Const u32
|
|
1280
|
+
* slot index.
|
|
1281
|
+
* 6. emitPartitionKernel produces the WGSL.
|
|
1282
|
+
*/
|
|
1283
|
+
/** WeakMap-based stable identity for aval objects. Two `cval`s
|
|
1284
|
+
* have different identity; the same cval observed twice has the
|
|
1285
|
+
* same identity. Used in `ruleContentHash` so rules differing
|
|
1286
|
+
* only in their `declared` aval are recognised as distinct. */
|
|
1287
|
+
const avalIdentityMap = new WeakMap();
|
|
1288
|
+
let nextAvalIdentity = 0;
|
|
1289
|
+
function avalIdentity(av) {
|
|
1290
|
+
let id = avalIdentityMap.get(av);
|
|
1291
|
+
if (id === undefined) {
|
|
1292
|
+
id = ++nextAvalIdentity;
|
|
1293
|
+
avalIdentityMap.set(av, id);
|
|
1294
|
+
}
|
|
1295
|
+
return id;
|
|
1296
|
+
}
|
|
1297
|
+
/** Content hash for rule identity within a bucket. Two rules
|
|
1298
|
+
* hashing the same can share a `ruleId` and one body; rules
|
|
1299
|
+
* hashing differently get distinct ids. */
|
|
1300
|
+
function ruleContentHash(rule) {
|
|
1301
|
+
const dec = rule.declared;
|
|
1302
|
+
let dKey;
|
|
1303
|
+
if (dec === undefined) {
|
|
1304
|
+
dKey = "u";
|
|
1305
|
+
}
|
|
1306
|
+
else if (typeof dec === "object" && dec !== null && "getValue" in dec) {
|
|
1307
|
+
dKey = `a${avalIdentity(dec)}`;
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
dKey = `v${String(dec)}`;
|
|
1311
|
+
}
|
|
1312
|
+
return `${rule.axis}|${rule.expr.id}|${dKey}`;
|
|
1313
|
+
}
|
|
1314
|
+
/** Read baseDescriptor's value for a given axis (the value used by
|
|
1315
|
+
* combos that have no rule on that axis). */
|
|
1316
|
+
function axisValueOfDesc(desc, axis) {
|
|
1317
|
+
switch (axis) {
|
|
1318
|
+
case "cull": return desc.cullMode;
|
|
1319
|
+
case "frontFace": return desc.frontFace;
|
|
1320
|
+
case "topology": return desc.topology;
|
|
1321
|
+
case "depthCompare": return desc.depth?.compare;
|
|
1322
|
+
case "depthWrite": return desc.depth?.write;
|
|
1323
|
+
case "alphaToCoverage": return desc.alphaToCoverage;
|
|
1324
|
+
case "blend": return desc.attachments[0];
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Specialise the WHOLE bucket — N-D cartesian over per-axis
|
|
1329
|
+
* unions:
|
|
1330
|
+
* 1. Per (axis, rule): bake declared as Const, analyse the body,
|
|
1331
|
+
* `evaluateStructuralSet` to fold each return Expr to a JS
|
|
1332
|
+
* value. Collect the per-axis union (dedup by stableStringify,
|
|
1333
|
+
* sorted deterministically). If any combo lacks a rule on
|
|
1334
|
+
* this axis, also include baseDesc's value for that axis.
|
|
1335
|
+
* 2. Active axes = axes with ≥1 registered rule, in AXIS_ORDER.
|
|
1336
|
+
* Cardinalities Cₐ = |axisValues[a]|; totalSlots = ∏ Cₐ.
|
|
1337
|
+
* 3. Per axis-rule: rewrite the body so every `ReturnValue`
|
|
1338
|
+
* emits a `Const u32` PER-AXIS index (into axisValues[axis]).
|
|
1339
|
+
* 4. Per combo: assemble a ComboCodegenSpec (per-axis source =
|
|
1340
|
+
* rule or compile-time const index).
|
|
1341
|
+
* 5. Emit the kernel.
|
|
1342
|
+
*
|
|
1343
|
+
* Returns the cartesian slot descriptors + the kernel WGSL.
|
|
1344
|
+
*/
|
|
1345
|
+
function specialiseBucket(bucket) {
|
|
1346
|
+
const Tu32 = { kind: "Int", signed: false, width: 32 };
|
|
1347
|
+
const rulesByAxis = bucket.rulesByAxis;
|
|
1348
|
+
const axisRuleOrder = bucket.axisRuleOrder;
|
|
1349
|
+
const combosByKey = bucket.combosByKey;
|
|
1350
|
+
const comboOrder = bucket.comboOrder;
|
|
1351
|
+
if (comboOrder.length === 0) {
|
|
1352
|
+
throw new Error("heapScene/gpuRouting: specialiseBucket called with no registered combos");
|
|
1353
|
+
}
|
|
1354
|
+
const base = bucket.baseDescriptor;
|
|
1355
|
+
// An axis is "active" (gets cartesian-product cardinality) if it
|
|
1356
|
+
// has at least one rule, OR any combo overrides baseDesc with a
|
|
1357
|
+
// distinct fixed value. Both grow the per-axis value union.
|
|
1358
|
+
const baseAxisValues = new Map(AXIS_ORDER.map(a => [a, axisValueOfDesc(base, a)]));
|
|
1359
|
+
const activeAxes = AXIS_ORDER.filter(a => {
|
|
1360
|
+
if ((rulesByAxis.get(a)?.size ?? 0) > 0)
|
|
1361
|
+
return true;
|
|
1362
|
+
const baseVal = baseAxisValues.get(a);
|
|
1363
|
+
const baseKey = stableStringify(baseVal);
|
|
1364
|
+
for (const k of comboOrder) {
|
|
1365
|
+
const c = combosByKey.get(k);
|
|
1366
|
+
const fixed = c.axisFixedValues.get(a);
|
|
1367
|
+
if (fixed !== undefined && stableStringify(fixed) !== baseKey)
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
return false;
|
|
1371
|
+
});
|
|
1372
|
+
const perAxisRules = new Map();
|
|
1373
|
+
for (const axis of activeAxes) {
|
|
1374
|
+
const list = [];
|
|
1375
|
+
for (const hash of axisRuleOrder.get(axis)) {
|
|
1376
|
+
const entry = rulesByAxis.get(axis).get(hash);
|
|
1377
|
+
const declaredU32 = resolveDeclaredU32(entry.rule, AdaptiveToken.top);
|
|
1378
|
+
const declaredConst = {
|
|
1379
|
+
kind: "Const", type: Tu32,
|
|
1380
|
+
value: { kind: "Int", value: declaredU32 >>> 0, signed: false },
|
|
1381
|
+
};
|
|
1382
|
+
const bakedBody = substituteReadInputInStmt(entry.body, "Uniform", "declared", declaredConst);
|
|
1383
|
+
const symbolic = analyseOutputSet(bakedBody);
|
|
1384
|
+
const env = new Map([["declared", declaredU32]]);
|
|
1385
|
+
const { resolved, unresolvedCount } = evaluateStructuralSet(symbolic, env, new Map());
|
|
1386
|
+
let outputs = resolved.slice();
|
|
1387
|
+
if (outputs.length === 0 || unresolvedCount > 0) {
|
|
1388
|
+
const table = AXIS_ENUM_TABLE[entry.rule.axis];
|
|
1389
|
+
if (table.length > 0 && unresolvedCount > 0) {
|
|
1390
|
+
outputs = table.map((_, i) => i);
|
|
1391
|
+
}
|
|
1392
|
+
else if (outputs.length === 0) {
|
|
1393
|
+
throw new Error(`heapScene/gpuRouting: rule on axis '${entry.rule.axis}' could not be ` +
|
|
1394
|
+
`statically resolved AND the axis has no canonical enum table. ` +
|
|
1395
|
+
`Simplify the rule body so analyseOutputSet can fold each branch.`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
list.push({ entry, bakedBody, resolved: outputs, declaredU32 });
|
|
1399
|
+
entry.lastDeclaredU32 = declaredU32;
|
|
1400
|
+
}
|
|
1401
|
+
perAxisRules.set(axis, list);
|
|
1402
|
+
}
|
|
1403
|
+
const axisInfos = [];
|
|
1404
|
+
for (const axis of activeAxes) {
|
|
1405
|
+
const list = perAxisRules.get(axis) ?? [];
|
|
1406
|
+
const dummyRule = { axis };
|
|
1407
|
+
const byKey = new Map();
|
|
1408
|
+
for (const p of list) {
|
|
1409
|
+
for (const v of p.resolved) {
|
|
1410
|
+
const av = resolveAxisValue(dummyRule, v);
|
|
1411
|
+
byKey.set(stableStringify(av), av);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
const baseValue = baseAxisValues.get(axis);
|
|
1415
|
+
// Include every combo's effective value for this axis (so
|
|
1416
|
+
// const-source idx lookups always succeed).
|
|
1417
|
+
for (const k of comboOrder) {
|
|
1418
|
+
const c = combosByKey.get(k);
|
|
1419
|
+
if (c.axisRules.has(axis))
|
|
1420
|
+
continue;
|
|
1421
|
+
const effective = c.axisFixedValues.has(axis) ? c.axisFixedValues.get(axis) : baseValue;
|
|
1422
|
+
byKey.set(stableStringify(effective), effective);
|
|
1423
|
+
}
|
|
1424
|
+
const sortedKeys = [...byKey.keys()].sort();
|
|
1425
|
+
const values = sortedKeys.map(k => byKey.get(k));
|
|
1426
|
+
const keyToIdx = new Map(sortedKeys.map((k, i) => [k, i]));
|
|
1427
|
+
const baseIdx = keyToIdx.get(stableStringify(baseValue)) ?? 0;
|
|
1428
|
+
axisInfos.push({ axis, values, keyToIdx, baseValue, baseIdx });
|
|
1429
|
+
}
|
|
1430
|
+
// Step 2: cartesian shape.
|
|
1431
|
+
const cards = axisInfos.map(a => a.values.length);
|
|
1432
|
+
const totalSlots = cards.reduce((m, n) => m * n, 1);
|
|
1433
|
+
// Step 3: rewrite each axis-rule body to emit per-axis indices.
|
|
1434
|
+
const ruleSpecs = [];
|
|
1435
|
+
for (const info of axisInfos) {
|
|
1436
|
+
const list = perAxisRules.get(info.axis);
|
|
1437
|
+
const dummyRule = { axis: info.axis };
|
|
1438
|
+
for (const p of list) {
|
|
1439
|
+
const env = new Map([["declared", p.declaredU32]]);
|
|
1440
|
+
const exprToAxisIdx = (e) => {
|
|
1441
|
+
const v = evaluateStructural(e, env, new Map());
|
|
1442
|
+
if (v === undefined)
|
|
1443
|
+
return undefined;
|
|
1444
|
+
const av = resolveAxisValue(dummyRule, v);
|
|
1445
|
+
return info.keyToIdx.get(stableStringify(av));
|
|
1446
|
+
};
|
|
1447
|
+
const rewrittenBody = rewriteOutputsToSlotIndices(p.bakedBody, exprToAxisIdx);
|
|
1448
|
+
ruleSpecs.push({
|
|
1449
|
+
axis: info.axis,
|
|
1450
|
+
axisRuleId: p.entry.axisRuleId,
|
|
1451
|
+
body: rewrittenBody,
|
|
1452
|
+
intrinsics: new Map(),
|
|
1453
|
+
helpersWGSL: "",
|
|
1454
|
+
inputUniforms: ["ModelTrafo"],
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Step 4: per-combo axis sources. Rule axes → rule source.
|
|
1459
|
+
// Non-rule axes → const idx of (axisFixedValues.get(axis)
|
|
1460
|
+
// ?? baseValue) within the axis's sorted union.
|
|
1461
|
+
const comboSpecs = [];
|
|
1462
|
+
for (const comboKey of comboOrder) {
|
|
1463
|
+
const combo = combosByKey.get(comboKey);
|
|
1464
|
+
const axes = axisInfos.map(info => {
|
|
1465
|
+
const axisRuleId = combo.axisRules.get(info.axis);
|
|
1466
|
+
if (axisRuleId !== undefined) {
|
|
1467
|
+
return {
|
|
1468
|
+
axis: info.axis,
|
|
1469
|
+
cardinality: info.values.length,
|
|
1470
|
+
source: { kind: "rule", axisRuleId },
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
const effective = combo.axisFixedValues.has(info.axis)
|
|
1474
|
+
? combo.axisFixedValues.get(info.axis)
|
|
1475
|
+
: info.baseValue;
|
|
1476
|
+
const idx = info.keyToIdx.get(stableStringify(effective)) ?? info.baseIdx;
|
|
1477
|
+
return {
|
|
1478
|
+
axis: info.axis,
|
|
1479
|
+
cardinality: info.values.length,
|
|
1480
|
+
source: { kind: "const", idx },
|
|
1481
|
+
};
|
|
1482
|
+
});
|
|
1483
|
+
comboSpecs.push({ comboId: combo.comboId, axes });
|
|
1484
|
+
}
|
|
1485
|
+
// Step 5: enumerate cartesian → slot descriptors (mixed-radix
|
|
1486
|
+
// decode in the same axis order the kernel uses).
|
|
1487
|
+
const slotDescs = [];
|
|
1488
|
+
for (let slotIdx = 0; slotIdx < totalSlots; slotIdx++) {
|
|
1489
|
+
let desc = base;
|
|
1490
|
+
let rem = slotIdx;
|
|
1491
|
+
// Match kernel: strides[i] = ∏_{j>i} cards[j]. Decode:
|
|
1492
|
+
// idx[i] = (slotIdx / strides[i]) % cards[i].
|
|
1493
|
+
const strides = new Array(axisInfos.length).fill(1);
|
|
1494
|
+
for (let i = axisInfos.length - 2; i >= 0; i--)
|
|
1495
|
+
strides[i] = strides[i + 1] * cards[i + 1];
|
|
1496
|
+
for (let i = 0; i < axisInfos.length; i++) {
|
|
1497
|
+
const info = axisInfos[i];
|
|
1498
|
+
const ai = Math.floor(rem / strides[i]) % cards[i];
|
|
1499
|
+
rem -= ai * strides[i];
|
|
1500
|
+
desc = patchDescriptor(desc, info.axis, info.values[ai]);
|
|
1501
|
+
}
|
|
1502
|
+
slotDescs.push(desc);
|
|
1503
|
+
}
|
|
1504
|
+
const kernelWGSL = emitPartitionKernel({
|
|
1505
|
+
rules: ruleSpecs,
|
|
1506
|
+
combos: comboSpecs,
|
|
1507
|
+
totalSlots,
|
|
1508
|
+
uniformOrder: bucket.uniformOrder ?? [],
|
|
1509
|
+
});
|
|
1510
|
+
return { slotDescs, kernelWGSL };
|
|
1511
|
+
}
|
|
1512
|
+
/** Build pipeline + slot for each cartesian slot descriptor. */
|
|
1513
|
+
function ensureSlotsForResolved(bucket, slotDescs) {
|
|
1514
|
+
const fam = familyForBucket.get(bucket);
|
|
1515
|
+
for (let slotIdx = 0; slotIdx < slotDescs.length; slotIdx++) {
|
|
1516
|
+
const desc = slotDescs[slotIdx];
|
|
1517
|
+
const key = encodeModeKey(desc);
|
|
1518
|
+
if (slotIdx < bucket.slots.length) {
|
|
1519
|
+
const slot = bucket.slots[slotIdx];
|
|
1520
|
+
if (slot.modeKey !== key) {
|
|
1521
|
+
slot.pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, desc);
|
|
1522
|
+
slot.modeKey = key;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
else {
|
|
1526
|
+
createSlot(bucket, desc);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
/** Register a combo (set of axis rules, ≤ one per axis) on a GPU-
|
|
1531
|
+
* routed bucket (or promote the bucket on first call). Returns
|
|
1532
|
+
* the bucket-unique `comboId` to bake into master-pool records. */
|
|
1533
|
+
function registerCombo(bucket, axisRules, baseDesc) {
|
|
1534
|
+
let cpuMigration;
|
|
1535
|
+
if (!bucket.gpuRouted && bucket.drawSlots.size > 0) {
|
|
1536
|
+
cpuMigration = [];
|
|
1537
|
+
for (const ls of bucket.drawSlots) {
|
|
1538
|
+
const e = bucket.localEntries[ls];
|
|
1539
|
+
const drawId = bucket.localToDrawId[ls];
|
|
1540
|
+
const slotIdx = drawIdToSlotIdx[drawId];
|
|
1541
|
+
const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : undefined;
|
|
1542
|
+
const descriptor = slot?.cpuDescriptor;
|
|
1543
|
+
if (descriptor === undefined) {
|
|
1544
|
+
throw new Error(`heapScene/gpuRouting: pre-existing CPU RO (drawId=${drawId}, localSlot=${ls}) ` +
|
|
1545
|
+
`has no captured descriptor — bucket promotion cannot synthesise a migration combo.`);
|
|
1546
|
+
}
|
|
1547
|
+
cpuMigration.push({
|
|
1548
|
+
localSlot: ls,
|
|
1549
|
+
drawId,
|
|
1550
|
+
firstIndex: e.firstIndex,
|
|
1551
|
+
indexCount: e.indexCount,
|
|
1552
|
+
instanceCount: e.instanceCount,
|
|
1553
|
+
perDrawRefs: bucket.localPerDrawRefs[ls],
|
|
1554
|
+
descriptor,
|
|
1555
|
+
targetComboId: 0,
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (!bucket.gpuRouted) {
|
|
1560
|
+
bucket.baseDescriptor = baseDesc;
|
|
1561
|
+
bucket.rulesByAxis = new Map();
|
|
1562
|
+
bucket.axisRuleOrder = new Map();
|
|
1563
|
+
bucket.uniformOrder = [];
|
|
1564
|
+
bucket.uniformIdx = new Map();
|
|
1565
|
+
bucket.combosByKey = new Map();
|
|
1566
|
+
bucket.comboOrder = [];
|
|
1567
|
+
bucket.drawIdToComboId = new Map();
|
|
1568
|
+
bucket.drawIdToRecord = new Map();
|
|
1569
|
+
bucket.recordToDrawId = [];
|
|
1570
|
+
// Synthesise one combo per distinct pre-existing CPU
|
|
1571
|
+
// descriptor. Each carries `axisFixedValues` on axes where the
|
|
1572
|
+
// descriptor differs from baseDescriptor; specialiseBucket
|
|
1573
|
+
// expands the per-axis union to include each variant value
|
|
1574
|
+
// and emits a const-source combo fn that routes to the right
|
|
1575
|
+
// cartesian slot.
|
|
1576
|
+
if (cpuMigration !== undefined) {
|
|
1577
|
+
const baseVals = new Map(AXIS_ORDER.map(a => [a, axisValueOfDesc(baseDesc, a)]));
|
|
1578
|
+
const synthByKey = new Map();
|
|
1579
|
+
for (const m of cpuMigration) {
|
|
1580
|
+
const axisFixedValues = new Map();
|
|
1581
|
+
const keyParts = [];
|
|
1582
|
+
for (const a of AXIS_ORDER) {
|
|
1583
|
+
const v = axisValueOfDesc(m.descriptor, a);
|
|
1584
|
+
const baseKey = stableStringify(baseVals.get(a));
|
|
1585
|
+
const vKey = stableStringify(v);
|
|
1586
|
+
if (vKey !== baseKey) {
|
|
1587
|
+
axisFixedValues.set(a, v);
|
|
1588
|
+
keyParts.push(`${a}:_:fixed:${vKey}`);
|
|
1589
|
+
}
|
|
1590
|
+
else {
|
|
1591
|
+
keyParts.push(`${a}:_`);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
const comboKey = keyParts.join("|");
|
|
1595
|
+
let comboId = synthByKey.get(comboKey);
|
|
1596
|
+
if (comboId === undefined) {
|
|
1597
|
+
comboId = bucket.comboOrder.length;
|
|
1598
|
+
bucket.combosByKey.set(comboKey, {
|
|
1599
|
+
comboId, comboKey,
|
|
1600
|
+
axisRules: new Map(),
|
|
1601
|
+
axisFixedValues,
|
|
1602
|
+
});
|
|
1603
|
+
bucket.comboOrder.push(comboKey);
|
|
1604
|
+
synthByKey.set(comboKey, comboId);
|
|
1605
|
+
}
|
|
1606
|
+
m.targetComboId = comboId;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
// Per-axis: dedupe rule by content hash; assign axisRuleId.
|
|
1611
|
+
const perAxisRuleId = new Map();
|
|
1612
|
+
let respec = false;
|
|
1613
|
+
for (const rule of axisRules) {
|
|
1614
|
+
if (perAxisRuleId.has(rule.axis)) {
|
|
1615
|
+
throw new Error(`heapScene/gpuRouting: combo has two rules on the same axis '${rule.axis}'`);
|
|
1616
|
+
}
|
|
1617
|
+
const hash = ruleContentHash(rule);
|
|
1618
|
+
let axisMap = bucket.rulesByAxis.get(rule.axis);
|
|
1619
|
+
if (axisMap === undefined) {
|
|
1620
|
+
axisMap = new Map();
|
|
1621
|
+
bucket.rulesByAxis.set(rule.axis, axisMap);
|
|
1622
|
+
bucket.axisRuleOrder.set(rule.axis, []);
|
|
1623
|
+
}
|
|
1624
|
+
let entry = axisMap.get(hash);
|
|
1625
|
+
if (entry === undefined) {
|
|
1626
|
+
const order = bucket.axisRuleOrder.get(rule.axis);
|
|
1627
|
+
const body = ruleBodyOf(rule);
|
|
1628
|
+
entry = {
|
|
1629
|
+
axis: rule.axis,
|
|
1630
|
+
axisRuleId: order.length,
|
|
1631
|
+
contentHash: hash,
|
|
1632
|
+
rule,
|
|
1633
|
+
body,
|
|
1634
|
+
lastDeclaredU32: undefined,
|
|
1635
|
+
};
|
|
1636
|
+
axisMap.set(hash, entry);
|
|
1637
|
+
order.push(hash);
|
|
1638
|
+
// Discover the arena uniforms this rule reads — append unseen
|
|
1639
|
+
// ones to the bucket-wide uniformOrder so master records
|
|
1640
|
+
// carry refs for them. `declared` is a CPU-baked Const, not
|
|
1641
|
+
// an arena uniform; skip it.
|
|
1642
|
+
for (const uName of collectUniformReadsInStmt(body)) {
|
|
1643
|
+
if (uName === "declared")
|
|
1644
|
+
continue;
|
|
1645
|
+
if (!bucket.uniformIdx.has(uName)) {
|
|
1646
|
+
const idx = bucket.uniformOrder.length;
|
|
1647
|
+
bucket.uniformOrder.push(uName);
|
|
1648
|
+
bucket.uniformIdx.set(uName, idx);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
respec = true;
|
|
1652
|
+
}
|
|
1653
|
+
perAxisRuleId.set(rule.axis, entry.axisRuleId);
|
|
1654
|
+
}
|
|
1655
|
+
// Combo key = canonical-axis-order list of (axis, contentHash).
|
|
1656
|
+
const comboKeyParts = [];
|
|
1657
|
+
for (const axis of AXIS_ORDER) {
|
|
1658
|
+
const axisRuleId = perAxisRuleId.get(axis);
|
|
1659
|
+
if (axisRuleId === undefined) {
|
|
1660
|
+
comboKeyParts.push(`${axis}:_`);
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
comboKeyParts.push(`${axis}:${bucket.axisRuleOrder.get(axis)[axisRuleId]}`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
const comboKey = comboKeyParts.join("|");
|
|
1667
|
+
let combo = bucket.combosByKey.get(comboKey);
|
|
1668
|
+
if (combo === undefined) {
|
|
1669
|
+
const comboId = bucket.comboOrder.length;
|
|
1670
|
+
const axisRules = new Map();
|
|
1671
|
+
for (const [axis, id] of perAxisRuleId)
|
|
1672
|
+
axisRules.set(axis, id);
|
|
1673
|
+
combo = { comboId, comboKey, axisRules, axisFixedValues: new Map() };
|
|
1674
|
+
bucket.combosByKey.set(comboKey, combo);
|
|
1675
|
+
bucket.comboOrder.push(comboKey);
|
|
1676
|
+
respec = true;
|
|
1677
|
+
}
|
|
1678
|
+
// First time gpuRouted? Build the partition scene now.
|
|
1679
|
+
if (!bucket.gpuRouted) {
|
|
1680
|
+
const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
|
|
1681
|
+
ensureSlotsForResolved(bucket, slotDescs);
|
|
1682
|
+
const partition = new GpuPartitionScene(device, `${bucket.label}/partition`, {
|
|
1683
|
+
totalSlots: slotDescs.length,
|
|
1684
|
+
slotDrawBufs: bucket.slots.map(s => s.drawTableBuf.buffer),
|
|
1685
|
+
kernelWGSL,
|
|
1686
|
+
initialRecords: Math.max(16, (cpuMigration?.length ?? 0) + 16),
|
|
1687
|
+
numUniforms: bucket.uniformOrder.length,
|
|
1688
|
+
});
|
|
1689
|
+
bucket.gpuRouted = true;
|
|
1690
|
+
bucket.partitionScene = partition;
|
|
1691
|
+
bucket.partitionDirty = true;
|
|
1692
|
+
// Migrate any captured CPU records into the master pool, each
|
|
1693
|
+
// using the synthetic combo registered for its CPU descriptor.
|
|
1694
|
+
if (cpuMigration !== undefined) {
|
|
1695
|
+
let totalEmit = 0;
|
|
1696
|
+
for (const m of cpuMigration) {
|
|
1697
|
+
const uRefs = bucket.uniformOrder.map(n => m.perDrawRefs.get(n) ?? 0);
|
|
1698
|
+
const recIdx = partition.appendRecord(m.localSlot, m.firstIndex, m.indexCount, m.instanceCount, m.targetComboId, uRefs);
|
|
1699
|
+
bucket.drawIdToRecord.set(m.drawId, recIdx);
|
|
1700
|
+
bucket.recordToDrawId[recIdx] = m.drawId;
|
|
1701
|
+
bucket.drawIdToComboId.set(m.drawId, m.targetComboId);
|
|
1702
|
+
totalEmit += m.indexCount * m.instanceCount;
|
|
1703
|
+
}
|
|
1704
|
+
const numRecords = partition.numRecords;
|
|
1705
|
+
const recBytes = numRecords * RECORD_BYTES;
|
|
1706
|
+
const needBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
1707
|
+
for (const s of bucket.slots) {
|
|
1708
|
+
s.drawTableBuf.ensureCapacity(recBytes);
|
|
1709
|
+
s.drawTableBuf.setUsed(Math.max(s.drawTableBuf.usedBytes, recBytes));
|
|
1710
|
+
s.blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1711
|
+
s.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1712
|
+
s.recordCount = numRecords;
|
|
1713
|
+
s.totalEmitEstimate += totalEmit;
|
|
1714
|
+
const newNumTiles = Math.max(1, Math.ceil(s.totalEmitEstimate / TILE_K));
|
|
1715
|
+
s.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
1716
|
+
s.scanDirty = true;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
else if (respec) {
|
|
1721
|
+
const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
|
|
1722
|
+
ensureSlotsForResolved(bucket, slotDescs);
|
|
1723
|
+
const newCount = bucket.slots.length;
|
|
1724
|
+
const draws = bucket.slots.map(s => s.drawTableBuf.buffer);
|
|
1725
|
+
if (newCount > bucket.partitionScene.totalSlots) {
|
|
1726
|
+
bucket.partitionScene.growSlots(newCount, draws);
|
|
1727
|
+
}
|
|
1728
|
+
else {
|
|
1729
|
+
bucket.partitionScene.rebindSlotDrawBufs(draws);
|
|
1730
|
+
}
|
|
1731
|
+
// Bucket-wide uniformOrder may have grown (a new rule reads a
|
|
1732
|
+
// previously-unseen uniform). Widen master records before
|
|
1733
|
+
// rebuilding the kernel — the WGSL struct width changes too.
|
|
1734
|
+
if (bucket.uniformOrder.length > bucket.partitionScene.numUniforms) {
|
|
1735
|
+
bucket.partitionScene.growUniforms(bucket.uniformOrder.length);
|
|
1736
|
+
}
|
|
1737
|
+
bucket.partitionScene.rebuildKernel(kernelWGSL);
|
|
1738
|
+
bucket.partitionDirty = true;
|
|
1739
|
+
}
|
|
1740
|
+
return combo.comboId;
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Per-frame partition dispatch. Detects per-rule `declared`
|
|
1744
|
+
* changes and re-specialises the whole bucket if any rule's
|
|
1745
|
+
* declared value differs from what was used at the last
|
|
1746
|
+
* dispatch (cheap — analysis runs on cached IR).
|
|
1747
|
+
*/
|
|
1748
|
+
function dispatchPartition(bucket, enc, tok) {
|
|
1749
|
+
if (!bucket.gpuRouted || bucket.partitionScene === undefined)
|
|
1750
|
+
return;
|
|
1751
|
+
let needRespec = false;
|
|
1752
|
+
outer: for (const axisMap of bucket.rulesByAxis.values()) {
|
|
1753
|
+
for (const entry of axisMap.values()) {
|
|
1754
|
+
const declaredU32 = resolveDeclaredU32(entry.rule, tok);
|
|
1755
|
+
if (declaredU32 !== entry.lastDeclaredU32) {
|
|
1756
|
+
needRespec = true;
|
|
1757
|
+
break outer;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (needRespec) {
|
|
1762
|
+
const { slotDescs, kernelWGSL } = specialiseBucket(bucket);
|
|
1763
|
+
ensureSlotsForResolved(bucket, slotDescs);
|
|
1764
|
+
const newCount = bucket.slots.length;
|
|
1765
|
+
const draws = bucket.slots.map(s => s.drawTableBuf.buffer);
|
|
1766
|
+
if (newCount > bucket.partitionScene.totalSlots) {
|
|
1767
|
+
bucket.partitionScene.growSlots(newCount, draws);
|
|
1768
|
+
}
|
|
1769
|
+
else {
|
|
1770
|
+
bucket.partitionScene.rebindSlotDrawBufs(draws);
|
|
1771
|
+
}
|
|
1772
|
+
bucket.partitionScene.rebuildKernel(kernelWGSL);
|
|
1773
|
+
}
|
|
1774
|
+
bucket.partitionScene.flush();
|
|
1775
|
+
bucket.partitionScene.dispatch(arena.attrs.chunk(bucket.chunkIdx).buffer, enc);
|
|
1776
|
+
for (let i = 0; i < bucket.slots.length; i++) {
|
|
1777
|
+
enc.copyBufferToBuffer(bucket.partitionScene.slotCountBufs[i], 0, bucket.slots[i].paramsBuf, 0, 4);
|
|
1778
|
+
bucket.slots[i].scanDirty = true;
|
|
1779
|
+
}
|
|
1780
|
+
bucket.partitionDirty = false;
|
|
1781
|
+
}
|
|
1782
|
+
function findOrCreateBucket(effect, _textures, pipelineState, chunkIdx,
|
|
1002
1783
|
/**
|
|
1003
1784
|
* Optional precomputed descriptor. addDrawImpl passes this when
|
|
1004
1785
|
* the spec carries `modeRules` so the bucket key reflects the
|
|
@@ -1010,12 +1791,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1010
1791
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
1011
1792
|
}
|
|
1012
1793
|
const fam = familyFor(effect);
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1794
|
+
// §3: bucket key includes `chunkIdx` — two ROs with the same
|
|
1795
|
+
// effect/family that landed in different arena chunks live in
|
|
1796
|
+
// separate buckets so each bucket can bind exactly one chunk's
|
|
1797
|
+
// GPUBuffers in its draw call. The shaders are unchanged
|
|
1798
|
+
// (refs are byte offsets within whichever chunk's buffer is
|
|
1799
|
+
// currently bound).
|
|
1800
|
+
const bk = `family#${fam.schema.id}/c${chunkIdx}`;
|
|
1019
1801
|
const existing = bucketByKey.get(bk);
|
|
1020
1802
|
if (existing !== undefined)
|
|
1021
1803
|
return existing;
|
|
@@ -1024,7 +1806,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1024
1806
|
const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
|
|
1025
1807
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
1026
1808
|
const bucket = {
|
|
1027
|
-
label: bk, textures: undefined, layout,
|
|
1809
|
+
label: bk, textures: undefined, layout, chunkIdx,
|
|
1028
1810
|
slots: [], // populated lazily by ensureSlot per modeKey
|
|
1029
1811
|
bindGroup: null, // legacy field; slot.bindGroup is the real one
|
|
1030
1812
|
drawHeap,
|
|
@@ -1038,6 +1820,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1038
1820
|
localAtlasReleases: [],
|
|
1039
1821
|
localAtlasTextures: [],
|
|
1040
1822
|
localAtlasArrIdx: [],
|
|
1823
|
+
gpuRouted: false,
|
|
1824
|
+
partitionScene: undefined,
|
|
1825
|
+
drawIdToRecord: undefined,
|
|
1826
|
+
recordToDrawId: undefined,
|
|
1827
|
+
rulesByAxis: undefined,
|
|
1828
|
+
axisRuleOrder: undefined,
|
|
1829
|
+
uniformOrder: undefined,
|
|
1830
|
+
uniformIdx: undefined,
|
|
1831
|
+
combosByKey: undefined,
|
|
1832
|
+
comboOrder: undefined,
|
|
1833
|
+
drawIdToComboId: undefined,
|
|
1834
|
+
baseDescriptor: undefined,
|
|
1835
|
+
partitionDirty: false,
|
|
1041
1836
|
};
|
|
1042
1837
|
familyForBucket.set(bucket, fam);
|
|
1043
1838
|
drawHeap.onResize(() => {
|
|
@@ -1219,6 +2014,11 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1219
2014
|
// inside a single outer evaluateAlways and invoke addDrawImpl
|
|
1220
2015
|
// directly with their token — collapsing 1000× nested
|
|
1221
2016
|
// evaluateAlways into 1× outer.
|
|
2017
|
+
// DEBUG counters.
|
|
2018
|
+
let __addDrawCalls = 0;
|
|
2019
|
+
let __removeDrawCalls = 0;
|
|
2020
|
+
sceneObj.__addDrawCalls = () => __addDrawCalls;
|
|
2021
|
+
sceneObj.__removeDrawCalls = () => __removeDrawCalls;
|
|
1222
2022
|
function addDraw(spec) {
|
|
1223
2023
|
let id = -1;
|
|
1224
2024
|
sceneObj.evaluateAlways(AdaptiveToken.top, (tok) => {
|
|
@@ -1227,6 +2027,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1227
2027
|
return id;
|
|
1228
2028
|
}
|
|
1229
2029
|
function addDrawImpl(spec, outerTok) {
|
|
2030
|
+
__addDrawCalls++;
|
|
1230
2031
|
const drawId = nextDrawId++;
|
|
1231
2032
|
// Family-merge (slice 3c): build the family lazily from this
|
|
1232
2033
|
// single spec when no batched lazy-build occurred earlier (e.g.
|
|
@@ -1258,12 +2059,63 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1258
2059
|
token: outerTok,
|
|
1259
2060
|
});
|
|
1260
2061
|
}
|
|
1261
|
-
|
|
2062
|
+
// §3 chunk routing — pick which arena chunk this RO's allocations
|
|
2063
|
+
// land in. Preference order:
|
|
2064
|
+
// 1. If any of the RO's bindings is an aval that's already
|
|
2065
|
+
// allocated in some chunk via the uniform pool, prefer that
|
|
2066
|
+
// chunk to avoid duplication.
|
|
2067
|
+
// 2. Otherwise, use the newest open chunk (highest chunkIdx).
|
|
2068
|
+
// ChunkedArena.alloc spills to the next chunk automatically
|
|
2069
|
+
// when this one is full; the spill ends up driving the
|
|
2070
|
+
// pool's `finalChunk` selection so all of the RO's
|
|
2071
|
+
// allocations land in one chunk consistently.
|
|
2072
|
+
let chunkIdx;
|
|
2073
|
+
{
|
|
2074
|
+
let preferred;
|
|
2075
|
+
for (const [, val] of Object.entries(spec.inputs)) {
|
|
2076
|
+
const av = (typeof val === "object" && val !== null && "getValue" in val)
|
|
2077
|
+
? val : undefined;
|
|
2078
|
+
if (av !== undefined) {
|
|
2079
|
+
const where = pool.firstChunkContaining(av);
|
|
2080
|
+
if (where !== undefined) {
|
|
2081
|
+
preferred = where;
|
|
2082
|
+
break;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
chunkIdx = preferred ?? Math.max(0, arena.attrs.chunkCount - 1);
|
|
2087
|
+
}
|
|
2088
|
+
const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, chunkIdx, precomputedDescriptor);
|
|
2089
|
+
// Phase 5c.3 — every RO carrying a derived-mode rule is registered
|
|
2090
|
+
// on the bucket (lazy-promoting it to GPU-routed on the first
|
|
2091
|
+
// call). ROs may carry DIFFERENT rules even when they share the
|
|
2092
|
+
// bucket's `(effect, textureSet)` — each gets its own `ruleId`
|
|
2093
|
+
// baked into the master-pool record and the partition kernel's
|
|
2094
|
+
// dispatch switch picks the right body per record.
|
|
2095
|
+
const collectedRules = spec.modeRules !== undefined ? collectRules(spec.modeRules) : [];
|
|
2096
|
+
const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
|
|
2097
|
+
let roComboId = 0;
|
|
2098
|
+
if (collectedRules.length > 0) {
|
|
2099
|
+
roComboId = registerCombo(bucket, collectedRules, roDescriptor);
|
|
2100
|
+
}
|
|
2101
|
+
else if (bucket.gpuRouted) {
|
|
2102
|
+
// An unruled RO joining an already-GPU-routed bucket needs a
|
|
2103
|
+
// real combo to land on. Register the trivial all-fixed combo
|
|
2104
|
+
// (every axis falls back to baseDescriptor) so this record's
|
|
2105
|
+
// `comboId` doesn't silently alias to combo 0 (which is
|
|
2106
|
+
// whichever combo was registered first — almost never the
|
|
2107
|
+
// "no rules" intent).
|
|
2108
|
+
roComboId = registerCombo(bucket, [], roDescriptor);
|
|
2109
|
+
}
|
|
1262
2110
|
// Phase 5c.2: route this RO to the bucket's slot covering its
|
|
1263
2111
|
// current descriptor. ensureSlot creates a new slot on miss; the
|
|
1264
|
-
// pipeline lookup hits the scene-level cache.
|
|
1265
|
-
|
|
1266
|
-
|
|
2112
|
+
// pipeline lookup hits the scene-level cache. For GPU-routed
|
|
2113
|
+
// buckets the slot decision is made each frame on the GPU; we
|
|
2114
|
+
// still pin the RO to slot 0 (declared) CPU-side so existing
|
|
2115
|
+
// accounting paths (slotToRecord, dirty bookkeeping) keep
|
|
2116
|
+
// working. The partition kernel overwrites both slots' draw
|
|
2117
|
+
// tables every frame from the master.
|
|
2118
|
+
const roSlot = bucket.gpuRouted ? bucket.slots[0] : ensureSlot(bucket, roDescriptor);
|
|
1267
2119
|
const roSlotIdx = bucket.slots.indexOf(roSlot);
|
|
1268
2120
|
const fam = familyFor(spec.effect);
|
|
1269
2121
|
const effectFields = fam.fieldsForEffect.get(spec.effect.id);
|
|
@@ -1272,7 +2124,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1272
2124
|
// index allocation + one upload.
|
|
1273
2125
|
const indicesAval = asAval(spec.indices);
|
|
1274
2126
|
const indicesArr = readPlain(spec.indices);
|
|
1275
|
-
const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, indicesArr);
|
|
2127
|
+
const idxAlloc = indexPool.acquire(device, arena.indices, indicesAval, bucket.chunkIdx, indicesArr);
|
|
1276
2128
|
const localSlot = bucket.drawHeap.alloc();
|
|
1277
2129
|
// Per-RO instancing: read `spec.instanceCount` (defaults to 1).
|
|
1278
2130
|
const instanceCount = spec.instanceCount !== undefined
|
|
@@ -1305,7 +2157,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1305
2157
|
// overwrites the arena data each frame the inputs are dirty.
|
|
1306
2158
|
if (ruleForUniform(spec, f.name) !== undefined) {
|
|
1307
2159
|
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);
|
|
2160
|
+
const ref = pool.acquire(device, arena.attrs, dummyAval, bucket.chunkIdx, M44d.zero, PACKER_MAT4.dataBytes, PACKER_MAT4.typeId, 1, PACKER_MAT4.pack);
|
|
1309
2161
|
perDrawRefs.set(f.name, ref);
|
|
1310
2162
|
perDrawAvals.push(dummyAval);
|
|
1311
2163
|
continue;
|
|
@@ -1347,7 +2199,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1347
2199
|
? perInstancePlacementFor(f, value, instanceCount)
|
|
1348
2200
|
: poolPlacementFor(f, value);
|
|
1349
2201
|
}
|
|
1350
|
-
const ref = pool.acquire(device, arena.attrs, av, value, placement.dataBytes, placement.typeId, placement.length, placement.pack);
|
|
2202
|
+
const ref = pool.acquire(device, arena.attrs, av, bucket.chunkIdx, value, placement.dataBytes, placement.typeId, placement.length, placement.pack);
|
|
1351
2203
|
perDrawRefs.set(f.name, ref);
|
|
1352
2204
|
perDrawAvals.push(av);
|
|
1353
2205
|
}
|
|
@@ -1366,6 +2218,47 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1366
2218
|
packBucketHeader(bucket, localSlot, perDrawRefs, layoutId);
|
|
1367
2219
|
if (bucket.isAtlasBucket && spec.textures !== undefined && spec.textures.kind === "atlas") {
|
|
1368
2220
|
packAtlasTextureFields(bucket, localSlot, spec.textures);
|
|
2221
|
+
// Pair this addDraw with one atlas refcount bump. The spec's
|
|
2222
|
+
// release closure (stored below, fired in removeDraw) decrements
|
|
2223
|
+
// exactly once per add/remove cycle; without this incRef the
|
|
2224
|
+
// refcount underflows when the same cached spec is re-introduced
|
|
2225
|
+
// across multiple aset add/remove cycles (e.g., a tile flipping
|
|
2226
|
+
// in and out of the heap-eligible subset).
|
|
2227
|
+
//
|
|
2228
|
+
// Falls back to a full re-acquire if the entry was already
|
|
2229
|
+
// evicted (incRef returns false): in that case the spec's stored
|
|
2230
|
+
// (pageId, origin, size) are stale and the new acquire's values
|
|
2231
|
+
// overwrite them before packAtlasTextureFields is re-called.
|
|
2232
|
+
if (atlasPool !== undefined) {
|
|
2233
|
+
const ok = atlasPool.incRef(spec.textures.poolRef);
|
|
2234
|
+
if (!ok) {
|
|
2235
|
+
// Entry was evicted while the cached spec was idle in heap-
|
|
2236
|
+
// remove state. Acquire a fresh sub-rect, redirect the spec
|
|
2237
|
+
// (and the per-aval cell behind its release closure) to the
|
|
2238
|
+
// new ref, and re-emit the drawHeader fields.
|
|
2239
|
+
const acq = atlasPool.acquire(spec.textures.page.format, spec.textures.sourceAval, spec.textures.size.x, spec.textures.size.y, { wantsMips: spec.textures.numMips > 1 });
|
|
2240
|
+
spec.textures.pageId = acq.pageId;
|
|
2241
|
+
spec.textures.origin = acq.origin;
|
|
2242
|
+
spec.textures.size = acq.size;
|
|
2243
|
+
spec.textures.numMips = acq.numMips;
|
|
2244
|
+
spec.textures.page = acq.page;
|
|
2245
|
+
spec.textures.poolRef = acq.ref;
|
|
2246
|
+
// The release closure captures a per-aval cell whose `ref`
|
|
2247
|
+
// must track the LATEST acquired sub-rect; otherwise the
|
|
2248
|
+
// closure releases a long-evicted ref (no-op leak).
|
|
2249
|
+
if (spec.textures.repack !== undefined) {
|
|
2250
|
+
// repack updates the per-aval cell to the new ref as a
|
|
2251
|
+
// side effect of its own atlas-mark drain path. Re-using
|
|
2252
|
+
// it here would free the entry we just acquired; instead
|
|
2253
|
+
// mirror the same effect with a tiny helper attached to
|
|
2254
|
+
// the spec at adapter time (`__retarget`).
|
|
2255
|
+
}
|
|
2256
|
+
const retarget = spec.textures.__retarget;
|
|
2257
|
+
if (retarget !== undefined)
|
|
2258
|
+
retarget(acq.ref);
|
|
2259
|
+
packAtlasTextureFields(bucket, localSlot, spec.textures);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
1369
2262
|
bucket.localAtlasReleases[localSlot] = spec.textures.release;
|
|
1370
2263
|
bucket.localAtlasTextures[localSlot] = spec.textures;
|
|
1371
2264
|
// Reactivity wire-up: subscribe to `sourceAval` (so sceneObj.inputChanged
|
|
@@ -1390,7 +2283,50 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1390
2283
|
const end = byteOff + bucket.layout.drawHeaderBytes;
|
|
1391
2284
|
if (end > bucket.headerDirtyMax)
|
|
1392
2285
|
bucket.headerDirtyMax = end;
|
|
1393
|
-
{
|
|
2286
|
+
if (bucket.gpuRouted) {
|
|
2287
|
+
// Phase 5c.3 GPU routing: master pool is authoritative.
|
|
2288
|
+
// Both slot[0] and slot[1] get the SAME accounting (counts,
|
|
2289
|
+
// emit estimates, buffer capacities) — partition overwrites
|
|
2290
|
+
// their GPU drawTables every frame, so per-slot CPU shadows
|
|
2291
|
+
// are irrelevant. We still grow their buffers in lockstep so
|
|
2292
|
+
// the scan dispatch shape covers every possible record.
|
|
2293
|
+
const partition = bucket.partitionScene;
|
|
2294
|
+
const recIdx = partition.numRecords;
|
|
2295
|
+
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
2296
|
+
throw new Error(`heapScene: GPU-routed bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS})`);
|
|
2297
|
+
}
|
|
2298
|
+
// Pack the bucket's per-RO uniform refs (arena byte offsets) in
|
|
2299
|
+
// the same order as `bucket.uniformOrder`. ROs whose perDrawRefs
|
|
2300
|
+
// don't carry a given name (e.g. their effect doesn't declare
|
|
2301
|
+
// it) get 0 — the kernel only reads refs through combo fns that
|
|
2302
|
+
// gate on `r.comboId`, so an unread 0 is harmless.
|
|
2303
|
+
const uniformRefs = (bucket.uniformOrder ?? []).map(name => perDrawRefs.get(name) ?? 0);
|
|
2304
|
+
partition.appendRecord(localSlot, idxAlloc.firstIndex, idxAlloc.count, instanceCount, roComboId, uniformRefs);
|
|
2305
|
+
bucket.drawIdToRecord.set(drawId, recIdx);
|
|
2306
|
+
bucket.recordToDrawId[recIdx] = drawId;
|
|
2307
|
+
bucket.drawIdToComboId.set(drawId, roComboId);
|
|
2308
|
+
// Master grew? Invalidate partition's bind group so it picks
|
|
2309
|
+
// up the new masterBuf. (rebindSlotDrawBufs nulls bindGroup.)
|
|
2310
|
+
// grow() inside appendRecord already does this when capacity
|
|
2311
|
+
// is exceeded; nothing to do here.
|
|
2312
|
+
const numRecords = recIdx + 1;
|
|
2313
|
+
const recBytes = numRecords * RECORD_BYTES;
|
|
2314
|
+
const needBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
2315
|
+
const emitDelta = idxAlloc.count * instanceCount;
|
|
2316
|
+
for (const s of bucket.slots) {
|
|
2317
|
+
s.drawTableBuf.ensureCapacity(recBytes);
|
|
2318
|
+
s.drawTableBuf.setUsed(Math.max(s.drawTableBuf.usedBytes, recBytes));
|
|
2319
|
+
s.blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
2320
|
+
s.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
2321
|
+
s.recordCount = numRecords;
|
|
2322
|
+
s.totalEmitEstimate += emitDelta;
|
|
2323
|
+
const newNumTiles = Math.max(1, Math.ceil(s.totalEmitEstimate / TILE_K));
|
|
2324
|
+
s.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
2325
|
+
s.scanDirty = true;
|
|
2326
|
+
}
|
|
2327
|
+
bucket.partitionDirty = true;
|
|
2328
|
+
}
|
|
2329
|
+
else {
|
|
1394
2330
|
const dtBuf = roSlot.drawTableBuf;
|
|
1395
2331
|
const recIdx = roSlot.recordCount;
|
|
1396
2332
|
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
@@ -1427,6 +2363,22 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1427
2363
|
drawIdToLocalSlot[drawId] = localSlot;
|
|
1428
2364
|
drawIdToSlotIdx[drawId] = roSlotIdx;
|
|
1429
2365
|
drawIdToIndexAval[drawId] = indicesAval;
|
|
2366
|
+
// ─── HeapDrawSpec.active wire-up ──────────────────────────────────
|
|
2367
|
+
// Track the visibility aval (if any) and apply its initial value
|
|
2368
|
+
// immediately. After this point, ticks on the aval are routed via
|
|
2369
|
+
// `subscribeActive` → `activeDirty` and drained in `update()`.
|
|
2370
|
+
if (spec.active !== undefined) {
|
|
2371
|
+
drawIdToActiveAval.set(drawId, spec.active);
|
|
2372
|
+
drawIdToOrigIndexCount.set(drawId, idxAlloc.count);
|
|
2373
|
+
const initial = spec.active.getValue(outerTok);
|
|
2374
|
+
subscribeActive(spec.active, drawId);
|
|
2375
|
+
if (initial === false) {
|
|
2376
|
+
// Apply the off state synchronously so the very first frame
|
|
2377
|
+
// already skips this RO (no flash of un-gated geometry while
|
|
2378
|
+
// we wait for the next update tick).
|
|
2379
|
+
setEffectiveIndexCount(drawId, 0);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
1430
2382
|
// ─── Reactive rebucket: track PS-modeKey changes ────────────────
|
|
1431
2383
|
// When any mode-axis aval marks (e.g. cullCval.value = 'front'),
|
|
1432
2384
|
// schedule this RO for rebucket on the next update(). Today's
|
|
@@ -1455,34 +2407,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1455
2407
|
});
|
|
1456
2408
|
drawIdToModeTracker[drawId] = tracker;
|
|
1457
2409
|
tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
|
|
1458
|
-
// ───
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
if (typeof
|
|
1470
|
-
subscribeModeLeaf(
|
|
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);
|
|
2410
|
+
// ─── Derived-mode rule registration ────────────────────────────
|
|
2411
|
+
// Subscribe to each rule's `declared` aval so the bucket's
|
|
2412
|
+
// partition kernel re-dispatches when it changes. Slot pipelines
|
|
2413
|
+
// for every (axis, declared) combination are pre-built eagerly
|
|
2414
|
+
// in `initGpuRouting` (one per cartesian-product point), so
|
|
2415
|
+
// declared flips are pipeline-cache hits.
|
|
2416
|
+
if (spec.modeRules !== undefined) {
|
|
2417
|
+
for (const rule of Object.values(spec.modeRules)) {
|
|
2418
|
+
if (rule === undefined)
|
|
2419
|
+
continue;
|
|
2420
|
+
const d = rule.declared;
|
|
2421
|
+
if (typeof d === "object" && d !== null && "getValue" in d) {
|
|
2422
|
+
subscribeModeLeaf(d, drawId);
|
|
1486
2423
|
}
|
|
1487
2424
|
}
|
|
1488
2425
|
}
|
|
@@ -1529,16 +2466,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1529
2466
|
const reg = registerRoDerivations(derivedScene, {}, {
|
|
1530
2467
|
rules: ruleSubset,
|
|
1531
2468
|
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
2469
|
hostUniformOffset: (n) => {
|
|
1537
2470
|
const r = perDrawRefs.get(n);
|
|
1538
2471
|
return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
|
|
1539
2472
|
},
|
|
1540
2473
|
outputOffset: (n) => outOffsetByName.get(n),
|
|
1541
2474
|
drawHeaderBaseByte: 0,
|
|
2475
|
+
chunkIdx: bucket.chunkIdx,
|
|
1542
2476
|
});
|
|
1543
2477
|
derivedByDrawId.set(drawId, reg);
|
|
1544
2478
|
}
|
|
@@ -1548,10 +2482,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1548
2482
|
return drawId;
|
|
1549
2483
|
}
|
|
1550
2484
|
function removeDraw(drawId) {
|
|
2485
|
+
__removeDrawCalls++;
|
|
1551
2486
|
const bucket = drawIdToBucket[drawId];
|
|
1552
2487
|
const localSlot = drawIdToLocalSlot[drawId];
|
|
1553
2488
|
if (bucket === undefined || localSlot === undefined)
|
|
1554
2489
|
return;
|
|
2490
|
+
// Tear down `active` subscription (if any) so its callback won't
|
|
2491
|
+
// fire after the underlying drawTable record is gone.
|
|
2492
|
+
unsubscribeActive(drawId);
|
|
1555
2493
|
// §7: deregister this RO's derivation records and release slots.
|
|
1556
2494
|
if (derivedScene !== undefined) {
|
|
1557
2495
|
const reg = derivedByDrawId.get(drawId);
|
|
@@ -1560,13 +2498,56 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1560
2498
|
derivedByDrawId.delete(drawId);
|
|
1561
2499
|
}
|
|
1562
2500
|
}
|
|
1563
|
-
{
|
|
2501
|
+
if (bucket.gpuRouted) {
|
|
2502
|
+
const partition = bucket.partitionScene;
|
|
2503
|
+
const recIdx = bucket.drawIdToRecord.get(drawId);
|
|
2504
|
+
const removedEntry = bucket.localEntries[localSlot];
|
|
2505
|
+
// Read the EFFECTIVE indexCount from masterShadow (already 0 for
|
|
2506
|
+
// an inactive draw via setEffectiveIndexCount). Using the original
|
|
2507
|
+
// count from localEntries would double-subtract for inactive draws
|
|
2508
|
+
// (whose contribution was already removed when active flipped false).
|
|
2509
|
+
const effectiveIndexCount = recIdx !== undefined
|
|
2510
|
+
? partition.masterShadow[recIdx * partition.recordU32 + 3]
|
|
2511
|
+
: (removedEntry?.indexCount ?? 0);
|
|
2512
|
+
const instanceCount = removedEntry?.instanceCount ?? 0;
|
|
2513
|
+
const removedCount = effectiveIndexCount * instanceCount;
|
|
2514
|
+
if (recIdx !== undefined) {
|
|
2515
|
+
const moved = partition.removeRecord(recIdx);
|
|
2516
|
+
if (moved >= 0) {
|
|
2517
|
+
// The record at `moved` (the old tail) now lives at recIdx.
|
|
2518
|
+
// Fix maps for the moved drawId.
|
|
2519
|
+
const movedDrawId = bucket.recordToDrawId[moved];
|
|
2520
|
+
bucket.drawIdToRecord.set(movedDrawId, recIdx);
|
|
2521
|
+
bucket.recordToDrawId[recIdx] = movedDrawId;
|
|
2522
|
+
bucket.recordToDrawId[moved] = -1;
|
|
2523
|
+
}
|
|
2524
|
+
else {
|
|
2525
|
+
bucket.recordToDrawId[recIdx] = -1;
|
|
2526
|
+
}
|
|
2527
|
+
bucket.drawIdToRecord.delete(drawId);
|
|
2528
|
+
}
|
|
2529
|
+
// Mirror the count + emit-estimate decrement across every slot
|
|
2530
|
+
// (both share the master's accounting).
|
|
2531
|
+
for (const s of bucket.slots) {
|
|
2532
|
+
s.recordCount = partition.numRecords;
|
|
2533
|
+
s.totalEmitEstimate = Math.max(0, s.totalEmitEstimate - removedCount);
|
|
2534
|
+
s.scanDirty = true;
|
|
2535
|
+
}
|
|
2536
|
+
bucket.partitionDirty = true;
|
|
2537
|
+
}
|
|
2538
|
+
else {
|
|
1564
2539
|
const slotIdx = drawIdToSlotIdx[drawId];
|
|
1565
2540
|
const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
|
|
1566
2541
|
const removedEntry = bucket.localEntries[localSlot];
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
|
|
2542
|
+
const localSlotRec = slot.slotToRecord[localSlot];
|
|
2543
|
+
// Effective (post-active) indexCount, read from shadow so that
|
|
2544
|
+
// inactive draws contribute 0 here (their contribution was
|
|
2545
|
+
// already removed when active flipped false).
|
|
2546
|
+
const effectiveIndexCount = localSlotRec !== undefined && localSlotRec >= 0
|
|
2547
|
+
? slot.drawTableShadow[localSlotRec * RECORD_U32 + 3]
|
|
2548
|
+
: (removedEntry?.indexCount ?? 0);
|
|
2549
|
+
const instanceCount = removedEntry?.instanceCount ?? 0;
|
|
2550
|
+
const removedCount = effectiveIndexCount * instanceCount;
|
|
1570
2551
|
slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate - removedCount);
|
|
1571
2552
|
// Swap-pop: move the last record into the freed slot, decrement
|
|
1572
2553
|
// recordCount. firstEmit is GPU-rewritten by the next scan, so
|
|
@@ -1600,10 +2581,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1600
2581
|
const avals = bucket.localPerDrawAvals[localSlot];
|
|
1601
2582
|
if (avals !== undefined)
|
|
1602
2583
|
for (const av of avals)
|
|
1603
|
-
pool.release(arena.attrs, av);
|
|
2584
|
+
pool.release(arena.attrs, av, bucket.chunkIdx);
|
|
1604
2585
|
const idxAval = drawIdToIndexAval[drawId];
|
|
1605
2586
|
if (idxAval !== undefined)
|
|
1606
|
-
indexPool.release(arena.indices, idxAval);
|
|
2587
|
+
indexPool.release(arena.indices, idxAval, bucket.chunkIdx);
|
|
1607
2588
|
const atlasRel = bucket.localAtlasReleases[localSlot];
|
|
1608
2589
|
if (atlasRel !== undefined)
|
|
1609
2590
|
atlasRel();
|
|
@@ -1778,12 +2759,20 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1778
2759
|
const tracker = drawIdToModeTracker[drawId];
|
|
1779
2760
|
if (tracker === undefined)
|
|
1780
2761
|
continue;
|
|
2762
|
+
const bucket = drawIdToBucket[drawId];
|
|
2763
|
+
// Phase 5c.3: GPU-routed buckets never reslot CPU-side.
|
|
2764
|
+
// The partition kernel handles re-routing per frame.
|
|
2765
|
+
// Just mark the bucket so the next encodeComputePrep
|
|
2766
|
+
// dispatches partition.
|
|
2767
|
+
if (bucket !== undefined && bucket.gpuRouted) {
|
|
2768
|
+
bucket.partitionDirty = true;
|
|
2769
|
+
continue;
|
|
2770
|
+
}
|
|
1781
2771
|
const oldKey = tracker.modeKey;
|
|
1782
2772
|
if (!tracker.recompute())
|
|
1783
2773
|
continue;
|
|
1784
2774
|
if (tracker.modeKey === oldKey)
|
|
1785
2775
|
continue;
|
|
1786
|
-
const bucket = drawIdToBucket[drawId];
|
|
1787
2776
|
const localSlot = drawIdToLocalSlot[drawId];
|
|
1788
2777
|
const oldSlotIdx = drawIdToSlotIdx[drawId];
|
|
1789
2778
|
if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined)
|
|
@@ -1856,12 +2845,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1856
2845
|
if (allocDirty.size > 0) {
|
|
1857
2846
|
for (const av of allocDirty) {
|
|
1858
2847
|
pool.repack(device, arena.attrs, av, av.getValue(tok));
|
|
1859
|
-
|
|
1860
|
-
if (e !== undefined)
|
|
1861
|
-
totalDirtyBytes += e.dataBytes;
|
|
2848
|
+
totalDirtyBytes += pool.totalDataBytes(av);
|
|
1862
2849
|
}
|
|
1863
2850
|
allocDirty.clear();
|
|
1864
2851
|
}
|
|
2852
|
+
// 1c. HeapDrawSpec.active drain — flip drawTable.indexCount
|
|
2853
|
+
// between origIndexCount (visible) and 0 (hidden) for any RO
|
|
2854
|
+
// whose active aval ticked since last frame. Set is small in
|
|
2855
|
+
// steady state (only the ROs whose visibility actually
|
|
2856
|
+
// changed). No pool / arena / freelist activity.
|
|
2857
|
+
if (activeDirty.size > 0) {
|
|
2858
|
+
for (const did of activeDirty) {
|
|
2859
|
+
const av = drawIdToActiveAval.get(did);
|
|
2860
|
+
if (av === undefined)
|
|
2861
|
+
continue;
|
|
2862
|
+
const a = av.getValue(tok);
|
|
2863
|
+
const orig = drawIdToOrigIndexCount.get(did) ?? 0;
|
|
2864
|
+
setEffectiveIndexCount(did, a ? orig : 0);
|
|
2865
|
+
}
|
|
2866
|
+
activeDirty.clear();
|
|
2867
|
+
}
|
|
1865
2868
|
// 1b. Atlas-texture aval reactivity: an `aval<ITexture>` that
|
|
1866
2869
|
// drives an atlas placement was marked. Repack the pool entry
|
|
1867
2870
|
// and rewrite the drawHeader fields of every (bucket, slot)
|
|
@@ -1948,6 +2951,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1948
2951
|
}
|
|
1949
2952
|
// Phase 5c.2: each slot has its own drawTable + shadow. Upload
|
|
1950
2953
|
// the dirty range of every slot, not just slots[0].
|
|
2954
|
+
// Phase 5c.3: GPU-routed buckets skip this entirely — the
|
|
2955
|
+
// partition kernel writes slot drawTables directly each frame.
|
|
2956
|
+
if (bucket.gpuRouted)
|
|
2957
|
+
continue;
|
|
1951
2958
|
for (const slot of bucket.slots) {
|
|
1952
2959
|
if (slot.drawTableDirtyMax <= slot.drawTableDirtyMin)
|
|
1953
2960
|
continue;
|
|
@@ -2018,10 +3025,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2018
3025
|
stats.derivedPullMs = _t1 - _t0;
|
|
2019
3026
|
stats.derivedUploadMs = _t2 - _t1;
|
|
2020
3027
|
stats.derivedEncodeMs = _t3 - _t2;
|
|
2021
|
-
stats.derivedRecords = derivedScene.
|
|
3028
|
+
stats.derivedRecords = derivedScene.totalRecordCount;
|
|
2022
3029
|
}
|
|
2023
3030
|
let anyDirty = false;
|
|
2024
3031
|
outer: for (const b of buckets) {
|
|
3032
|
+
if (b.gpuRouted && b.partitionDirty) {
|
|
3033
|
+
anyDirty = true;
|
|
3034
|
+
break outer;
|
|
3035
|
+
}
|
|
2025
3036
|
for (const s of b.slots) {
|
|
2026
3037
|
if (s.scanDirty) {
|
|
2027
3038
|
anyDirty = true;
|
|
@@ -2031,6 +3042,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2031
3042
|
}
|
|
2032
3043
|
if (!anyDirty)
|
|
2033
3044
|
return;
|
|
3045
|
+
// Phase 5c.3: per GPU-routed bucket — dispatch partition (its own
|
|
3046
|
+
// compute pass), then copyBufferToBuffer slot counts into scan
|
|
3047
|
+
// paramsBuf. Must happen BEFORE the shared scan pass below.
|
|
3048
|
+
for (const b of buckets) {
|
|
3049
|
+
if (b.gpuRouted && b.partitionDirty) {
|
|
3050
|
+
dispatchPartition(b, enc, token);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
2034
3053
|
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
2035
3054
|
for (const b of buckets) {
|
|
2036
3055
|
for (let i = 0; i < b.slots.length; i++) {
|
|
@@ -2166,8 +3185,16 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2166
3185
|
let vidChecks = 0, vidBad = 0;
|
|
2167
3186
|
const push = (s) => { if (issues.length < 60)
|
|
2168
3187
|
issues.push(s); };
|
|
2169
|
-
|
|
2170
|
-
|
|
3188
|
+
// §3 v1: validateHeap currently only diagnoses chunk 0 — a
|
|
3189
|
+
// multi-chunk-aware validator needs per-bucket-chunkIdx
|
|
3190
|
+
// staging which is a bigger refactor; assert for now so
|
|
3191
|
+
// multi-chunk callers don't get false-passes.
|
|
3192
|
+
if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
|
|
3193
|
+
throw new Error("heapScene/validateHeap: multi-chunk arenas not yet supported by this validator (chunk-0-only). " +
|
|
3194
|
+
"Run with the default chunk cap (no second chunk opened) for now.");
|
|
3195
|
+
}
|
|
3196
|
+
const arenaSize = arena.attrs.chunk(0).buffer.size;
|
|
3197
|
+
const indicesSize = arena.indices.chunk(0).buffer.size;
|
|
2171
3198
|
const enc = device.createCommandEncoder({ label: "validateHeap" });
|
|
2172
3199
|
const stage = (src, size) => {
|
|
2173
3200
|
const c = device.createBuffer({
|
|
@@ -2177,8 +3204,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2177
3204
|
enc.copyBufferToBuffer(src, 0, c, 0, size);
|
|
2178
3205
|
return c;
|
|
2179
3206
|
};
|
|
2180
|
-
const arenaCopy = stage(arena.attrs.buffer, arenaSize);
|
|
2181
|
-
const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
|
|
3207
|
+
const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
|
|
3208
|
+
const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
|
|
2182
3209
|
const TILE_K_LOCAL = 64;
|
|
2183
3210
|
const dcs = [];
|
|
2184
3211
|
for (const b of buckets) {
|
|
@@ -2259,7 +3286,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2259
3286
|
const typeId = arenaU32[refU32];
|
|
2260
3287
|
const length = arenaU32[refU32 + 1];
|
|
2261
3288
|
if (!KNOWN_TYPE_IDS.has(typeId)) {
|
|
2262
|
-
|
|
3289
|
+
// Dump the first 8 u32s of the alloc to help diagnose
|
|
3290
|
+
// what wrote garbage onto the header. Also peek the
|
|
3291
|
+
// FOUR u32s BEFORE the alloc to see if a neighbour
|
|
3292
|
+
// overran into us.
|
|
3293
|
+
const before = refU32 >= 4
|
|
3294
|
+
? `prev=[${arenaU32[refU32 - 4]},${arenaU32[refU32 - 3]},${arenaU32[refU32 - 2]},${arenaU32[refU32 - 1]}] `
|
|
3295
|
+
: "";
|
|
3296
|
+
const here = `here=[${arenaU32[refU32]},${arenaU32[refU32 + 1]},${arenaU32[refU32 + 2]},${arenaU32[refU32 + 3]},${arenaU32[refU32 + 4]},${arenaU32[refU32 + 5]},${arenaU32[refU32 + 6]},${arenaU32[refU32 + 7]}]`;
|
|
3297
|
+
// Compare CPU shadow vs GPU read-back. Differ → GPU
|
|
3298
|
+
// write corrupted; match → bug is in CPU-side write path
|
|
3299
|
+
// OR the alloc never went through pool.acquire.
|
|
3300
|
+
const shadowChunk = arena.attrs.chunk(0);
|
|
3301
|
+
const shadowU32 = shadowChunk.peekShadowU32(ref, 8);
|
|
3302
|
+
const shadowStr = `shadow=[${shadowU32[0]},${shadowU32[1]},${shadowU32[2]},${shadowU32[3]},${shadowU32[4]},${shadowU32[5]},${shadowU32[6]},${shadowU32[7]}]`;
|
|
3303
|
+
const cpuMatches = shadowU32[0] === arenaU32[refU32]
|
|
3304
|
+
&& shadowU32[1] === arenaU32[refU32 + 1]
|
|
3305
|
+
&& shadowU32[2] === arenaU32[refU32 + 2]
|
|
3306
|
+
&& shadowU32[3] === arenaU32[refU32 + 3];
|
|
3307
|
+
const verdict = cpuMatches ? "GPU=CPU (likely CPU-write bug)" : "GPU≠CPU (likely GPU-write corruption)";
|
|
3308
|
+
push(`bucket#${bucketIdx} slot=${slot} field='${f.name}' alloc@0x${ref.toString(16)} typeId=${typeId} unknown ${verdict} ${before}${here} ${shadowStr}`);
|
|
2263
3309
|
attrAllocsBad++;
|
|
2264
3310
|
continue;
|
|
2265
3311
|
}
|
|
@@ -2363,11 +3409,18 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2363
3409
|
tilesBad++;
|
|
2364
3410
|
}
|
|
2365
3411
|
}
|
|
3412
|
+
// The scan kernel intentionally writes `numRecords - 1` as the
|
|
3413
|
+
// sentinel (not `numRecords`) — the render VS's binary search
|
|
3414
|
+
// uses `hi = firstDrawInTile[_tileIdx + 1u]` as an *inclusive*
|
|
3415
|
+
// upper bound, so the sentinel must point at the last valid
|
|
3416
|
+
// record slot. See scanKernel.ts `buildTileIndex` for the
|
|
3417
|
+
// full rationale.
|
|
3418
|
+
const expectedSentinel = recordCount === 0 ? 0 : recordCount - 1;
|
|
2366
3419
|
const sentinel = fdt[dc.numTiles];
|
|
2367
3420
|
tilesChecked++;
|
|
2368
|
-
if (sentinel !==
|
|
3421
|
+
if (sentinel !== expectedSentinel) {
|
|
2369
3422
|
push(`bucket#${bucketIdx} firstDrawInTile[${dc.numTiles}] sentinel ` +
|
|
2370
|
-
`got=${sentinel} expected=${
|
|
3423
|
+
`got=${sentinel} expected=${expectedSentinel}`);
|
|
2371
3424
|
tilesBad++;
|
|
2372
3425
|
}
|
|
2373
3426
|
dc.firstDrawInTile.unmap();
|
|
@@ -2454,8 +3507,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2454
3507
|
let emitsChecked = 0;
|
|
2455
3508
|
const push = (s) => { if (issues.length < 30)
|
|
2456
3509
|
issues.push(s); };
|
|
2457
|
-
|
|
2458
|
-
|
|
3510
|
+
// §3 v1: chunk-0-only like validateHeap.
|
|
3511
|
+
if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
|
|
3512
|
+
throw new Error("heapScene/simulateDraws: multi-chunk arenas not yet supported (chunk-0-only).");
|
|
3513
|
+
}
|
|
3514
|
+
const arenaSize = arena.attrs.chunk(0).buffer.size;
|
|
3515
|
+
const indicesSize = arena.indices.chunk(0).buffer.size;
|
|
2459
3516
|
// Stage all buffers we'll need.
|
|
2460
3517
|
const enc = device.createCommandEncoder({ label: "simulateDraws" });
|
|
2461
3518
|
const stage = (src, size) => {
|
|
@@ -2465,8 +3522,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2465
3522
|
enc.copyBufferToBuffer(src, 0, c, 0, size);
|
|
2466
3523
|
return c;
|
|
2467
3524
|
};
|
|
2468
|
-
const arenaCopy = stage(arena.attrs.buffer, arenaSize);
|
|
2469
|
-
const indicesCopy = indicesSize > 0 ? stage(arena.indices.buffer, indicesSize) : undefined;
|
|
3525
|
+
const arenaCopy = stage(arena.attrs.chunk(0).buffer, arenaSize);
|
|
3526
|
+
const indicesCopy = indicesSize > 0 ? stage(arena.indices.chunk(0).buffer, indicesSize) : undefined;
|
|
2470
3527
|
const dcs = [];
|
|
2471
3528
|
for (const b of buckets) {
|
|
2472
3529
|
if (b.slots[0].recordCount === 0 || b.slots[0].totalEmitEstimate === 0)
|
|
@@ -2717,20 +3774,26 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2717
3774
|
// bytes to two avals → two ROs reading each other's data → the
|
|
2718
3775
|
// cross-RO triangle symptom.
|
|
2719
3776
|
const allocClaims = [];
|
|
3777
|
+
// §3 v1: chunk-0-only (see validateHeap).
|
|
3778
|
+
if (arena.attrs.chunkCount > 1 || arena.indices.chunkCount > 1) {
|
|
3779
|
+
throw new Error("heapScene/checkTriangleCoherence: multi-chunk arenas not yet supported (chunk-0-only).");
|
|
3780
|
+
}
|
|
3781
|
+
const arenaBuf0 = arena.attrs.chunk(0).buffer;
|
|
3782
|
+
const indicesBuf0 = arena.indices.chunk(0).buffer;
|
|
2720
3783
|
// Stage arena + arena.indices once (shared across buckets).
|
|
2721
3784
|
const arenaCopy = device.createBuffer({
|
|
2722
|
-
size:
|
|
3785
|
+
size: arenaBuf0.size,
|
|
2723
3786
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2724
3787
|
});
|
|
2725
|
-
const indicesCopy =
|
|
2726
|
-
size:
|
|
3788
|
+
const indicesCopy = indicesBuf0.size > 0 ? device.createBuffer({
|
|
3789
|
+
size: indicesBuf0.size,
|
|
2727
3790
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
2728
3791
|
}) : undefined;
|
|
2729
3792
|
{
|
|
2730
3793
|
const enc = device.createCommandEncoder({ label: "checkTriangleCoherence.arena" });
|
|
2731
|
-
enc.copyBufferToBuffer(
|
|
3794
|
+
enc.copyBufferToBuffer(arenaBuf0, 0, arenaCopy, 0, arenaBuf0.size);
|
|
2732
3795
|
if (indicesCopy !== undefined)
|
|
2733
|
-
enc.copyBufferToBuffer(
|
|
3796
|
+
enc.copyBufferToBuffer(indicesBuf0, 0, indicesCopy, 0, indicesBuf0.size);
|
|
2734
3797
|
device.queue.submit([enc.finish()]);
|
|
2735
3798
|
}
|
|
2736
3799
|
await arenaCopy.mapAsync(GPUMapMode.READ);
|
|
@@ -3167,7 +4230,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3167
4230
|
{ binding: 0, resource: { buffer: target.slots[0].drawTableBuf.buffer } },
|
|
3168
4231
|
{ binding: 1, resource: { buffer: target.slots[0].firstDrawInTileBuf.buffer } },
|
|
3169
4232
|
{ binding: 2, resource: { buffer: sampleBuf } },
|
|
3170
|
-
{ binding: 3, resource: { buffer: arena.indices.buffer } },
|
|
4233
|
+
{ binding: 3, resource: { buffer: arena.indices.chunk(target.chunkIdx).buffer } },
|
|
3171
4234
|
{ binding: 4, resource: { buffer: outBuf } },
|
|
3172
4235
|
{ binding: 5, resource: { buffer: paramBuf } },
|
|
3173
4236
|
],
|
|
@@ -3191,12 +4254,13 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3191
4254
|
outBuf.destroy();
|
|
3192
4255
|
paramBuf.destroy();
|
|
3193
4256
|
// Read drawTable + indices for CPU expectation.
|
|
4257
|
+
const idxBuf = arena.indices.chunk(target.chunkIdx).buffer;
|
|
3194
4258
|
const indicesCopy2 = device.createBuffer({
|
|
3195
|
-
size:
|
|
4259
|
+
size: idxBuf.size,
|
|
3196
4260
|
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
|
3197
4261
|
});
|
|
3198
4262
|
const enc2 = device.createCommandEncoder();
|
|
3199
|
-
enc2.copyBufferToBuffer(
|
|
4263
|
+
enc2.copyBufferToBuffer(idxBuf, 0, indicesCopy2, 0, idxBuf.size);
|
|
3200
4264
|
device.queue.submit([enc2.finish()]);
|
|
3201
4265
|
await indicesCopy2.mapAsync(GPUMapMode.READ);
|
|
3202
4266
|
const indicesU32 = new Uint32Array(indicesCopy2.getMappedRange().slice(0));
|
|
@@ -3245,6 +4309,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
3245
4309
|
return { emitsChecked: sampleCount, gpuMismatches, issues };
|
|
3246
4310
|
},
|
|
3247
4311
|
};
|
|
3248
|
-
|
|
4312
|
+
const __addDrawCallsGetter = () => __addDrawCalls;
|
|
4313
|
+
const __removeDrawCallsGetter = () => __removeDrawCalls;
|
|
4314
|
+
return { frame, update, encodeIntoPass, encodeComputePrep, addDraw, removeDraw, stats, dispose, _debug, __addDrawCalls: __addDrawCallsGetter, __removeDrawCalls: __removeDrawCallsGetter };
|
|
3249
4315
|
}
|
|
3250
4316
|
//# sourceMappingURL=heapScene.js.map
|