@aardworx/wombat.rendering 0.9.26 → 0.9.28
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/heapScene.d.ts +5 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +373 -327
- package/dist/runtime/heapScene.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/heapScene.ts +405 -340
|
@@ -88,6 +88,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
88
88
|
// ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
|
|
89
89
|
const drawIdToBucket = [];
|
|
90
90
|
const drawIdToLocalSlot = [];
|
|
91
|
+
/** Per-draw slot index within its bucket. Phase 5c.2: each bucket
|
|
92
|
+
* holds multiple BucketSlots (one per realized modeKey); this map
|
|
93
|
+
* tells each removeDraw / record write which slot to touch. */
|
|
94
|
+
const drawIdToSlotIdx = [];
|
|
91
95
|
/** Per-draw index aval — for `indexPool.release` on removeDraw. */
|
|
92
96
|
const drawIdToIndexAval = [];
|
|
93
97
|
/**
|
|
@@ -519,25 +523,59 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
519
523
|
compute: { module: scanModule, entryPoint: "buildTileIndex" },
|
|
520
524
|
});
|
|
521
525
|
}
|
|
522
|
-
function buildScanBindGroup(bucket) {
|
|
526
|
+
function buildScanBindGroup(bucket, slotIdx = 0) {
|
|
523
527
|
if (scanBgl === undefined)
|
|
524
528
|
throw new Error("heapScene: scan BGL missing");
|
|
529
|
+
const s = bucket.slots[slotIdx];
|
|
525
530
|
return device.createBindGroup({
|
|
526
|
-
label: `heapScene/${bucket.label}/scanBg`,
|
|
531
|
+
label: `heapScene/${bucket.label}/slot${slotIdx}/scanBg`,
|
|
527
532
|
layout: scanBgl,
|
|
528
533
|
entries: [
|
|
529
|
-
{ binding: 0, resource: { buffer:
|
|
530
|
-
{ binding: 1, resource: { buffer:
|
|
531
|
-
{ binding: 2, resource: { buffer:
|
|
532
|
-
{ binding: 3, resource: { buffer:
|
|
533
|
-
{ binding: 4, resource: { buffer:
|
|
534
|
-
{ binding: 5, resource: { buffer:
|
|
534
|
+
{ binding: 0, resource: { buffer: s.drawTableBuf.buffer } },
|
|
535
|
+
{ binding: 1, resource: { buffer: s.blockSumsBuf.buffer } },
|
|
536
|
+
{ binding: 2, resource: { buffer: s.blockOffsetsBuf.buffer } },
|
|
537
|
+
{ binding: 3, resource: { buffer: s.indirectBuf } },
|
|
538
|
+
{ binding: 4, resource: { buffer: s.paramsBuf } },
|
|
539
|
+
{ binding: 5, resource: { buffer: s.firstDrawInTileBuf.buffer } },
|
|
535
540
|
],
|
|
536
541
|
});
|
|
537
542
|
}
|
|
538
543
|
// ─── Buckets ──────────────────────────────────────────────────────
|
|
539
544
|
const buckets = [];
|
|
540
545
|
const bucketByKey = new Map();
|
|
546
|
+
/**
|
|
547
|
+
* Scene-level pipeline cache keyed on `(famId, modeKey)` — when an
|
|
548
|
+
* RO mode-flip renames a bucket, the new pipeline is fetched from
|
|
549
|
+
* this cache rather than re-created. Cache misses fall back to
|
|
550
|
+
* `device.createRenderPipeline` and stash the result. Pre-warming
|
|
551
|
+
* (when ROs are added with rules) extends this cache so the cache
|
|
552
|
+
* always hits on subsequent flips. See Phase 5c.2.
|
|
553
|
+
*/
|
|
554
|
+
const pipelineByModeKey = new Map();
|
|
555
|
+
/** Build a GPURenderPipeline from a descriptor + family + bucket
|
|
556
|
+
* layout. Cached by `(familyId, modeKey)`. */
|
|
557
|
+
function getOrCreatePipeline(fam, layout, isAtlasBucket, desc) {
|
|
558
|
+
const modeKey = encodeModeKey(desc);
|
|
559
|
+
const cacheKey = `f${fam.schema.id}|mk${modeKey.toString(16)}`;
|
|
560
|
+
const cached = pipelineByModeKey.get(cacheKey);
|
|
561
|
+
if (cached !== undefined)
|
|
562
|
+
return cached;
|
|
563
|
+
const { pipelineLayout } = getBgl(layout, isAtlasBucket);
|
|
564
|
+
const pipeline = device.createRenderPipeline({
|
|
565
|
+
label: `heapScene/cached/${cacheKey}`,
|
|
566
|
+
layout: pipelineLayout,
|
|
567
|
+
vertex: { module: fam.vsModule, entryPoint: fam.vsEntryName, buffers: [] },
|
|
568
|
+
fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets: colorTargets },
|
|
569
|
+
primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
|
|
570
|
+
...(depthFormat !== undefined && desc.depth !== undefined
|
|
571
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
|
|
572
|
+
: depthFormat !== undefined
|
|
573
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
|
|
574
|
+
: {}),
|
|
575
|
+
});
|
|
576
|
+
pipelineByModeKey.set(cacheKey, pipeline);
|
|
577
|
+
return pipeline;
|
|
578
|
+
}
|
|
541
579
|
// Keyed by `effect.id` (content hash), NOT object identity. Two
|
|
542
580
|
// Effect objects with identical content (e.g. produced by separate
|
|
543
581
|
// calls to `effect(...)` or the pickChain composer) share one
|
|
@@ -764,7 +802,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
764
802
|
// pipelines via noTexBgl/texBgl; the bind-group itself binds this
|
|
765
803
|
// bucket's drawHeap (binding 1) + its globals UBO (binding 0) +
|
|
766
804
|
// the global arena's heapF32 view (binding 2) + textures if any.
|
|
767
|
-
function buildBucketBindGroup(bucket) {
|
|
805
|
+
function buildBucketBindGroup(bucket, slotIdx = 0) {
|
|
768
806
|
// heapU32 / heapF32 / heapV4f are different typed views of the
|
|
769
807
|
// SAME global arena GPUBuffer (emscripten-style aliasing). The
|
|
770
808
|
// WGSL prelude declares one binding per view; the shader picks
|
|
@@ -776,7 +814,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
776
814
|
{ binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
|
|
777
815
|
];
|
|
778
816
|
{
|
|
779
|
-
|
|
817
|
+
const s = bucket.slots[slotIdx];
|
|
818
|
+
if (s.drawTableBuf === undefined) {
|
|
780
819
|
throw new Error("heapScene: megacall bucket without drawTableBuf");
|
|
781
820
|
}
|
|
782
821
|
// Bind drawTable with size = recordCount * RECORD_BYTES so the VS
|
|
@@ -784,9 +823,9 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
784
823
|
// the live record count — keeps stale tail entries out of the
|
|
785
824
|
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
786
825
|
// zero size constraint when the bucket is empty.
|
|
787
|
-
const dtBytes = Math.max(RECORD_BYTES,
|
|
788
|
-
entries.push({ binding: 4, resource: { buffer:
|
|
789
|
-
|
|
826
|
+
const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
|
|
827
|
+
entries.push({ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } }, { binding: 5, resource: { buffer: arena.indices.buffer } }, { binding: 6, resource: { buffer: s.firstDrawInTileBuf.buffer } });
|
|
828
|
+
s.renderBoundRecordCount = s.recordCount;
|
|
790
829
|
}
|
|
791
830
|
// Schema-driven texture + sampler entries. v1 user surface still
|
|
792
831
|
// accepts a single (texture, sampler) pair via HeapTextureSet —
|
|
@@ -869,36 +908,29 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
869
908
|
// `atlasPool.pagesFor(format)` when building bind groups. Buckets
|
|
870
909
|
// carry strong refcount handles (`localAtlasReleases`) that drive
|
|
871
910
|
// `pool.release` on removeDraw.
|
|
872
|
-
|
|
873
|
-
|
|
911
|
+
/** Rebuild every slot's render bind group across every bucket.
|
|
912
|
+
* Used when a global resource (arena.attrs, arena.indices, atlas
|
|
913
|
+
* page set) reallocates and invalidates every bind group at once. */
|
|
914
|
+
function rebuildAllBindGroups(filterAtlasOnly = false) {
|
|
915
|
+
for (const b of buckets) {
|
|
916
|
+
if (filterAtlasOnly && !b.isAtlasBucket)
|
|
917
|
+
continue;
|
|
918
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
919
|
+
b.slots[i].bindGroup = buildBucketBindGroup(b, i);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
874
923
|
arena.attrs.onResize(() => {
|
|
875
|
-
|
|
876
|
-
b.bindGroup = buildBucketBindGroup(b);
|
|
877
|
-
// §7 dispatcher targets arena.attrs.buffer; rebind on grow.
|
|
924
|
+
rebuildAllBindGroups();
|
|
878
925
|
if (derivedScene !== undefined)
|
|
879
926
|
derivedScene.rebindMainHeap(arena.attrs.buffer);
|
|
880
927
|
});
|
|
881
|
-
// Same when the atlas pool allocates a fresh page — its slot in the
|
|
882
|
-
// BGL ladder transitions from placeholder to the real GPUTexture.
|
|
883
|
-
// Every atlas bucket rebuilds (cheap; just N+N+1 entries each).
|
|
884
928
|
if (atlasPool !== undefined) {
|
|
885
929
|
for (const f of ATLAS_PAGE_FORMATS) {
|
|
886
|
-
atlasPool.onPageAdded(f, () =>
|
|
887
|
-
for (const b of buckets) {
|
|
888
|
-
if (b.isAtlasBucket)
|
|
889
|
-
b.bindGroup = buildBucketBindGroup(b);
|
|
890
|
-
}
|
|
891
|
-
});
|
|
930
|
+
atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
|
|
892
931
|
}
|
|
893
932
|
}
|
|
894
|
-
|
|
895
|
-
// also invalidates every bucket's bind group.
|
|
896
|
-
{
|
|
897
|
-
arena.indices.onResize(() => {
|
|
898
|
-
for (const b of buckets)
|
|
899
|
-
b.bindGroup = buildBucketBindGroup(b);
|
|
900
|
-
});
|
|
901
|
-
}
|
|
933
|
+
arena.indices.onResize(() => rebuildAllBindGroups());
|
|
902
934
|
// ─── findOrCreateBucket ───────────────────────────────────────────
|
|
903
935
|
// Slice 3c: the bucket key collapses to (familyId, pipelineState).
|
|
904
936
|
// Every member effect in the family shares one bucket per
|
|
@@ -906,6 +938,75 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
906
938
|
// right per-effect helper at draw time. Atlas-binding shape follows
|
|
907
939
|
// from `family.drawHeaderUnion.atlasTextureBindings.size > 0`.
|
|
908
940
|
void texIdOf; // retained for future per-bucket diagnostics
|
|
941
|
+
/**
|
|
942
|
+
* Allocate a brand-new BucketSlot inside `bucket` covering
|
|
943
|
+
* `descriptor` (modeKey = encode(descriptor)). Builds the per-slot
|
|
944
|
+
* GPU resources: pipeline (via cache), drawTable + scan buffers +
|
|
945
|
+
* indirect + paramsBuf, and the slot's render/scan bind groups
|
|
946
|
+
* (the latter rebuilt on resize). The bucket's drawHeap +
|
|
947
|
+
* texture bindings are shared across slots.
|
|
948
|
+
*/
|
|
949
|
+
function createSlot(bucket, descriptor) {
|
|
950
|
+
const fam = familyForBucket.get(bucket);
|
|
951
|
+
const pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, descriptor);
|
|
952
|
+
const modeKey = encodeModeKey(descriptor);
|
|
953
|
+
const slotIdx = bucket.slots.length;
|
|
954
|
+
const slotLabel = `${bucket.label}/slot${slotIdx}`;
|
|
955
|
+
const dtBuf = new GrowBuffer(device, `heapScene/${slotLabel}/drawTable`, GPUBufferUsage.STORAGE, 1024);
|
|
956
|
+
const blockSumsBuf = new GrowBuffer(device, `heapScene/${slotLabel}/blockSums`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
|
|
957
|
+
const blockOffsetsBuf = new GrowBuffer(device, `heapScene/${slotLabel}/blockOffsets`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
|
|
958
|
+
const firstDrawInTileBuf = new GrowBuffer(device, `heapScene/${slotLabel}/firstDrawInTile`, GPUBufferUsage.STORAGE, 128);
|
|
959
|
+
const indirectBuf = device.createBuffer({
|
|
960
|
+
label: `heapScene/${slotLabel}/indirect`, size: 16,
|
|
961
|
+
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
962
|
+
});
|
|
963
|
+
device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
|
|
964
|
+
const paramsBuf = device.createBuffer({
|
|
965
|
+
label: `heapScene/${slotLabel}/params`, size: 16,
|
|
966
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
967
|
+
});
|
|
968
|
+
const slot = {
|
|
969
|
+
pipeline, modeKey,
|
|
970
|
+
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
971
|
+
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
972
|
+
totalEmitEstimate: 0, scanDirty: false,
|
|
973
|
+
drawTableBuf: dtBuf,
|
|
974
|
+
drawTableShadow: new Uint32Array(dtBuf.capacity / 4),
|
|
975
|
+
blockSumsBuf, blockOffsetsBuf, firstDrawInTileBuf,
|
|
976
|
+
indirectBuf, paramsBuf,
|
|
977
|
+
};
|
|
978
|
+
bucket.slots.push(slot);
|
|
979
|
+
const thisSlotIdx = bucket.slots.length - 1;
|
|
980
|
+
const ensureScanBuffers = () => {
|
|
981
|
+
const needBlocks = Math.max(1, Math.ceil(slot.recordCount / SCAN_TILE_SIZE));
|
|
982
|
+
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
983
|
+
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
984
|
+
};
|
|
985
|
+
const rebuildScanBg = () => {
|
|
986
|
+
slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
|
|
987
|
+
};
|
|
988
|
+
dtBuf.onResize(() => {
|
|
989
|
+
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
990
|
+
grown.set(slot.drawTableShadow);
|
|
991
|
+
slot.drawTableShadow = grown;
|
|
992
|
+
ensureScanBuffers();
|
|
993
|
+
slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
|
|
994
|
+
rebuildScanBg();
|
|
995
|
+
slot.drawTableDirtyMin = 0;
|
|
996
|
+
slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
|
|
997
|
+
});
|
|
998
|
+
blockSumsBuf.onResize(rebuildScanBg);
|
|
999
|
+
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
1000
|
+
firstDrawInTileBuf.onResize(() => {
|
|
1001
|
+
rebuildScanBg();
|
|
1002
|
+
slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
|
|
1003
|
+
});
|
|
1004
|
+
slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
|
|
1005
|
+
slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
|
|
1006
|
+
return slot;
|
|
1007
|
+
}
|
|
1008
|
+
/** Side map from Bucket → family. Populated by findOrCreateBucket. */
|
|
1009
|
+
const familyForBucket = new WeakMap();
|
|
909
1010
|
function findOrCreateBucket(effect, _textures, pipelineState,
|
|
910
1011
|
/**
|
|
911
1012
|
* Optional precomputed descriptor. addDrawImpl passes this when
|
|
@@ -918,73 +1019,23 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
918
1019
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
919
1020
|
}
|
|
920
1021
|
const fam = familyFor(effect);
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
// cvals collapse to ONE bucket. See docs/derived-modes.md and
|
|
928
|
-
// tests/heap-multi-pipeline-bucket.test.ts.
|
|
929
|
-
const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
|
|
930
|
-
const psModeKey = encodeModeKey(psDescriptor);
|
|
931
|
-
const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
|
|
1022
|
+
// Phase 5c.2: bucket key drops modeKey. All ROs with the same
|
|
1023
|
+
// (effect, textureSet) share one bucket; per-modeKey slots live
|
|
1024
|
+
// inside the bucket and route records via `ensureSlot`. Pipeline
|
|
1025
|
+
// pre-warm + the scene-level pipelineByModeKey cache mean every
|
|
1026
|
+
// realized slot lookup hits the cache.
|
|
1027
|
+
const bk = `family#${fam.schema.id}`;
|
|
932
1028
|
const existing = bucketByKey.get(bk);
|
|
933
1029
|
if (existing !== undefined)
|
|
934
1030
|
return existing;
|
|
935
|
-
// If a precomputed descriptor is supplied (rule-evaluated path),
|
|
936
|
-
// use its post-rule axis values so the pipeline reflects what the
|
|
937
|
-
// RO actually wants. Otherwise fall through to PS-aval forcing.
|
|
938
|
-
const ps = precomputedDescriptor !== undefined
|
|
939
|
-
? {
|
|
940
|
-
topology: precomputedDescriptor.topology,
|
|
941
|
-
cullMode: precomputedDescriptor.cullMode,
|
|
942
|
-
frontFace: precomputedDescriptor.frontFace,
|
|
943
|
-
...(precomputedDescriptor.depth !== undefined
|
|
944
|
-
? { depth: { write: precomputedDescriptor.depth.write, compare: precomputedDescriptor.depth.compare } }
|
|
945
|
-
: {}),
|
|
946
|
-
}
|
|
947
|
-
: resolvePipelineState(pipelineState);
|
|
948
1031
|
const layout = fam.schema.drawHeaderUnion;
|
|
949
1032
|
const isAtlasBucket = layout.atlasTextureBindings.size > 0;
|
|
950
|
-
const vsModule = fam.vsModule;
|
|
951
|
-
const fsModule = fam.fsModule;
|
|
952
|
-
const vsEntry = fam.vsEntryName;
|
|
953
|
-
const fsEntry = fam.fsEntryName;
|
|
954
|
-
const { pipelineLayout } = getBgl(layout, isAtlasBucket);
|
|
955
|
-
const pipeline = device.createRenderPipeline({
|
|
956
|
-
label: `heapScene/${bk}/pipeline`,
|
|
957
|
-
layout: pipelineLayout,
|
|
958
|
-
vertex: { module: vsModule, entryPoint: vsEntry, buffers: [] },
|
|
959
|
-
fragment: { module: fsModule, entryPoint: fsEntry, targets: colorTargets },
|
|
960
|
-
primitive: { topology: ps.topology, cullMode: ps.cullMode, frontFace: ps.frontFace },
|
|
961
|
-
...(depthFormat !== undefined && ps.depth !== undefined
|
|
962
|
-
? { depthStencil: { format: depthFormat, depthWriteEnabled: ps.depth.write, depthCompare: ps.depth.compare } }
|
|
963
|
-
: depthFormat !== undefined
|
|
964
|
-
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
|
|
965
|
-
: {}),
|
|
966
|
-
});
|
|
967
|
-
// Per-bucket DrawHeader buffer (every uniform / attribute is a u32
|
|
968
|
-
// ref into the global arena; the per-bucket buffer just holds the
|
|
969
|
-
// refs).
|
|
970
1033
|
const drawHeapBuf = new GrowBuffer(device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE, Math.max(layout.drawHeaderBytes, 64));
|
|
971
1034
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
972
|
-
const slot0 = {
|
|
973
|
-
pipeline,
|
|
974
|
-
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
975
|
-
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
976
|
-
totalEmitEstimate: 0,
|
|
977
|
-
scanDirty: false,
|
|
978
|
-
};
|
|
979
1035
|
const bucket = {
|
|
980
|
-
// §6 family-merge: family buckets aren't keyed on a specific
|
|
981
|
-
// texture set — atlas placements are addressed per-RO via
|
|
982
|
-
// drawHeader fields (`pageRef` + `formatBits` + `origin` +
|
|
983
|
-
// `size`), and the bucket's atlas-binding ladder is driven by
|
|
984
|
-
// `atlasPool.pagesFor(format)`. Leave `textures` undefined.
|
|
985
1036
|
label: bk, textures: undefined, layout,
|
|
986
|
-
slots: [
|
|
987
|
-
bindGroup: null,
|
|
1037
|
+
slots: [], // populated lazily by ensureSlot per modeKey
|
|
1038
|
+
bindGroup: null, // legacy field; slot.bindGroup is the real one
|
|
988
1039
|
drawHeap,
|
|
989
1040
|
drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
|
|
990
1041
|
headerDirtyMin: Infinity, headerDirtyMax: 0,
|
|
@@ -997,76 +1048,47 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
997
1048
|
localAtlasTextures: [],
|
|
998
1049
|
localAtlasArrIdx: [],
|
|
999
1050
|
};
|
|
1000
|
-
|
|
1001
|
-
const dtBuf = new GrowBuffer(device, `heapScene/${bk}/drawTable`, GPUBufferUsage.STORAGE, 1024);
|
|
1002
|
-
const blockSumsBuf = new GrowBuffer(device, `heapScene/${bk}/blockSums`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
|
|
1003
|
-
const blockOffsetsBuf = new GrowBuffer(device, `heapScene/${bk}/blockOffsets`, GPUBufferUsage.STORAGE, 4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)));
|
|
1004
|
-
// 32 u32 = 128 bytes is the floor; pow2-grown by ensureCapacity
|
|
1005
|
-
// as totalEmitEstimate grows.
|
|
1006
|
-
const firstDrawInTileBuf = new GrowBuffer(device, `heapScene/${bk}/firstDrawInTile`, GPUBufferUsage.STORAGE, 128);
|
|
1007
|
-
const indirectBuf = device.createBuffer({
|
|
1008
|
-
label: `heapScene/${bk}/indirect`, size: 16,
|
|
1009
|
-
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
1010
|
-
});
|
|
1011
|
-
// Initialize to (0, 1, 0, 0) so an unscanned/empty bucket draws
|
|
1012
|
-
// nothing safely.
|
|
1013
|
-
device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
|
|
1014
|
-
const paramsBuf = device.createBuffer({
|
|
1015
|
-
label: `heapScene/${bk}/params`, size: 16,
|
|
1016
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1017
|
-
});
|
|
1018
|
-
bucket.slots[0].drawTableBuf = dtBuf;
|
|
1019
|
-
bucket.slots[0].drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
1020
|
-
bucket.slots[0].blockSumsBuf = blockSumsBuf;
|
|
1021
|
-
bucket.slots[0].blockOffsetsBuf = blockOffsetsBuf;
|
|
1022
|
-
bucket.slots[0].firstDrawInTileBuf = firstDrawInTileBuf;
|
|
1023
|
-
bucket.slots[0].indirectBuf = indirectBuf;
|
|
1024
|
-
bucket.slots[0].paramsBuf = paramsBuf;
|
|
1025
|
-
const ensureScanBuffers = () => {
|
|
1026
|
-
const needBlocks = Math.max(1, Math.ceil(bucket.slots[0].recordCount / SCAN_TILE_SIZE));
|
|
1027
|
-
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1028
|
-
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1029
|
-
};
|
|
1030
|
-
const rebuildScanBg = () => {
|
|
1031
|
-
bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
|
|
1032
|
-
};
|
|
1033
|
-
dtBuf.onResize(() => {
|
|
1034
|
-
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
1035
|
-
grown.set(bucket.slots[0].drawTableShadow);
|
|
1036
|
-
bucket.slots[0].drawTableShadow = grown;
|
|
1037
|
-
ensureScanBuffers();
|
|
1038
|
-
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1039
|
-
rebuildScanBg();
|
|
1040
|
-
bucket.slots[0].drawTableDirtyMin = 0;
|
|
1041
|
-
bucket.slots[0].drawTableDirtyMax = bucket.slots[0].recordCount * RECORD_BYTES;
|
|
1042
|
-
});
|
|
1043
|
-
blockSumsBuf.onResize(rebuildScanBg);
|
|
1044
|
-
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
1045
|
-
firstDrawInTileBuf.onResize(() => {
|
|
1046
|
-
rebuildScanBg();
|
|
1047
|
-
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1048
|
-
});
|
|
1049
|
-
bucket.slots[0].scanBindGroup = buildScanBindGroup(bucket);
|
|
1050
|
-
}
|
|
1051
|
-
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1051
|
+
familyForBucket.set(bucket, fam);
|
|
1052
1052
|
drawHeap.onResize(() => {
|
|
1053
1053
|
bucket.drawHeaderStaging = new Float32Array(drawHeapBuf.capacity / 4);
|
|
1054
|
-
// GPU-side data was copied by the GrowBuffer's resize, but our
|
|
1055
|
-
// CPU mirror is fresh — mark all live local slots dirty so
|
|
1056
|
-
// their headers get re-packed and re-uploaded next frame.
|
|
1057
1054
|
for (const s of bucket.drawSlots)
|
|
1058
1055
|
bucket.dirty.add(s);
|
|
1059
|
-
|
|
1060
|
-
//
|
|
1061
|
-
|
|
1062
|
-
|
|
1056
|
+
// Rebuild every slot's render bind group (binding 1 = drawHeap
|
|
1057
|
+
// points at the new buffer).
|
|
1058
|
+
for (let i = 0; i < bucket.slots.length; i++) {
|
|
1059
|
+
bucket.slots[i].bindGroup = buildBucketBindGroup(bucket, i);
|
|
1060
|
+
}
|
|
1063
1061
|
});
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1062
|
+
// Ensure the bucket has a slot for the supplied descriptor — at
|
|
1063
|
+
// bucket-creation time we eagerly seed slot 0 from the caller's
|
|
1064
|
+
// descriptor so the first addDraw doesn't pay a slot-create cost.
|
|
1065
|
+
const seedDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
|
|
1066
|
+
createSlot(bucket, seedDescriptor);
|
|
1066
1067
|
buckets.push(bucket);
|
|
1067
1068
|
bucketByKey.set(bk, bucket);
|
|
1068
1069
|
return bucket;
|
|
1069
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Return the existing BucketSlot for `descriptor` inside `bucket`,
|
|
1073
|
+
* or create one on miss. The mode-flip hot path calls this when an
|
|
1074
|
+
* RO's tracker.modeKey lands on a slot that doesn't exist yet.
|
|
1075
|
+
*/
|
|
1076
|
+
function ensureSlot(bucket, descriptor) {
|
|
1077
|
+
const wanted = encodeModeKey(descriptor);
|
|
1078
|
+
for (const s of bucket.slots) {
|
|
1079
|
+
if (s.modeKey === wanted)
|
|
1080
|
+
return s;
|
|
1081
|
+
}
|
|
1082
|
+
return createSlot(bucket, descriptor);
|
|
1083
|
+
}
|
|
1084
|
+
/** Find an RO's slot by drawId; null if the drawId is dead. */
|
|
1085
|
+
function slotForDrawId(drawId) {
|
|
1086
|
+
const idx = drawIdToSlotIdx[drawId];
|
|
1087
|
+
const bucket = drawIdToBucket[drawId];
|
|
1088
|
+
if (idx === undefined || bucket === undefined)
|
|
1089
|
+
return null;
|
|
1090
|
+
return bucket.slots[idx] ?? null;
|
|
1091
|
+
}
|
|
1070
1092
|
// ─── Per-bucket header writer (refs only, post step 3) ────────────
|
|
1071
1093
|
// The DrawHeader is now a flat list of u32 refs into the arena —
|
|
1072
1094
|
// values themselves live in arena allocations the pool manages.
|
|
@@ -1179,6 +1201,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1179
1201
|
// ─── Stats (declared early so addDraw/removeDraw can mutate it) ───
|
|
1180
1202
|
const stats = {
|
|
1181
1203
|
groups: 0,
|
|
1204
|
+
slotCount: 0,
|
|
1182
1205
|
totalDraws: 0,
|
|
1183
1206
|
drawBytes: 0,
|
|
1184
1207
|
geometryBytes: 0,
|
|
@@ -1188,6 +1211,15 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1188
1211
|
derivedRecords: 0,
|
|
1189
1212
|
};
|
|
1190
1213
|
Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
|
|
1214
|
+
Object.defineProperty(stats, "slotCount", {
|
|
1215
|
+
get: () => {
|
|
1216
|
+
let n = 0;
|
|
1217
|
+
for (const b of buckets)
|
|
1218
|
+
n += b.slots.length;
|
|
1219
|
+
return n;
|
|
1220
|
+
},
|
|
1221
|
+
configurable: true,
|
|
1222
|
+
});
|
|
1191
1223
|
// ─── addDraw / removeDraw ─────────────────────────────────────────
|
|
1192
1224
|
// Public addDraw wrapper. Establishes a sceneObj.evaluateAlways
|
|
1193
1225
|
// scope so external callers (no batched outer eval) still register
|
|
@@ -1236,6 +1268,12 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1236
1268
|
});
|
|
1237
1269
|
}
|
|
1238
1270
|
const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
|
|
1271
|
+
// Phase 5c.2: route this RO to the bucket's slot covering its
|
|
1272
|
+
// current descriptor. ensureSlot creates a new slot on miss; the
|
|
1273
|
+
// pipeline lookup hits the scene-level cache.
|
|
1274
|
+
const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
|
|
1275
|
+
const roSlot = ensureSlot(bucket, roDescriptor);
|
|
1276
|
+
const roSlotIdx = bucket.slots.indexOf(roSlot);
|
|
1239
1277
|
const fam = familyFor(spec.effect);
|
|
1240
1278
|
const effectFields = fam.fieldsForEffect.get(spec.effect.id);
|
|
1241
1279
|
// Indices live in their own INDEX-usage buffer (WebGPU constraint).
|
|
@@ -1362,8 +1400,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1362
1400
|
if (end > bucket.headerDirtyMax)
|
|
1363
1401
|
bucket.headerDirtyMax = end;
|
|
1364
1402
|
{
|
|
1365
|
-
const dtBuf =
|
|
1366
|
-
const recIdx =
|
|
1403
|
+
const dtBuf = roSlot.drawTableBuf;
|
|
1404
|
+
const recIdx = roSlot.recordCount;
|
|
1367
1405
|
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
1368
1406
|
throw new Error(`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
|
|
1369
1407
|
`extend the scan to multi-level if you need more`);
|
|
@@ -1373,29 +1411,30 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1373
1411
|
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
1374
1412
|
// Grow scan-side buffers if recordCount crosses a tile boundary.
|
|
1375
1413
|
const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
const shadow =
|
|
1414
|
+
roSlot.blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1415
|
+
roSlot.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1416
|
+
const shadow = roSlot.drawTableShadow;
|
|
1379
1417
|
// firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
|
|
1380
1418
|
shadow[recIdx * RECORD_U32 + 0] = 0;
|
|
1381
1419
|
shadow[recIdx * RECORD_U32 + 1] = localSlot;
|
|
1382
1420
|
shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
|
|
1383
1421
|
shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
|
|
1384
1422
|
shadow[recIdx * RECORD_U32 + 4] = instanceCount;
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
if (byteOff <
|
|
1389
|
-
|
|
1390
|
-
if (byteOff + RECORD_BYTES >
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
const newNumTiles = Math.max(1, Math.ceil(
|
|
1394
|
-
|
|
1395
|
-
|
|
1423
|
+
roSlot.recordCount = recIdx + 1;
|
|
1424
|
+
roSlot.slotToRecord[localSlot] = recIdx;
|
|
1425
|
+
roSlot.recordToSlot[recIdx] = localSlot;
|
|
1426
|
+
if (byteOff < roSlot.drawTableDirtyMin)
|
|
1427
|
+
roSlot.drawTableDirtyMin = byteOff;
|
|
1428
|
+
if (byteOff + RECORD_BYTES > roSlot.drawTableDirtyMax)
|
|
1429
|
+
roSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1430
|
+
roSlot.totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
1431
|
+
const newNumTiles = Math.max(1, Math.ceil(roSlot.totalEmitEstimate / TILE_K));
|
|
1432
|
+
roSlot.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
1433
|
+
roSlot.scanDirty = true;
|
|
1396
1434
|
}
|
|
1397
1435
|
drawIdToBucket[drawId] = bucket;
|
|
1398
1436
|
drawIdToLocalSlot[drawId] = localSlot;
|
|
1437
|
+
drawIdToSlotIdx[drawId] = roSlotIdx;
|
|
1399
1438
|
drawIdToIndexAval[drawId] = indicesAval;
|
|
1400
1439
|
// ─── Reactive rebucket: track PS-modeKey changes ────────────────
|
|
1401
1440
|
// When any mode-axis aval marks (e.g. cullCval.value = 'front'),
|
|
@@ -1430,7 +1469,8 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1430
1469
|
// the scene-wide dispatcher. Provide the arena byte offset of the
|
|
1431
1470
|
// RO's input uniform so the kernel reads from the right slot.
|
|
1432
1471
|
{
|
|
1433
|
-
const
|
|
1472
|
+
const cullRule = spec.modeRules?.cull;
|
|
1473
|
+
const gpuRule = cullRule?.gpu;
|
|
1434
1474
|
if (gpuRule !== undefined && gpuRule.kernel === "flipCullByDeterminant") {
|
|
1435
1475
|
const inputRef = perDrawRefs.get(gpuRule.inputUniform);
|
|
1436
1476
|
if (inputRef !== undefined) {
|
|
@@ -1451,6 +1491,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1451
1491
|
console.warn(`[heapScene] GPU rule input '${gpuRule.inputUniform}' not in spec.inputs for drawId=${drawId}; rule disabled`);
|
|
1452
1492
|
}
|
|
1453
1493
|
}
|
|
1494
|
+
// ─── Pipeline pre-warm for derived-mode rules (5c.2) ──────────
|
|
1495
|
+
// Enumerate the rule's possible outputs and pre-build the
|
|
1496
|
+
// matching pipelines in the scene-level cache. Future cullModeC
|
|
1497
|
+
// flips become cache hits — zero `createRenderPipeline` calls
|
|
1498
|
+
// on the hot path.
|
|
1499
|
+
if (cullRule !== undefined && cullRule.domain !== undefined && tracker.descriptor !== undefined) {
|
|
1500
|
+
const fam = familyFor(spec.effect);
|
|
1501
|
+
const baseDesc = tracker.descriptor;
|
|
1502
|
+
for (const mode of cullRule.domain) {
|
|
1503
|
+
if (mode === baseDesc.cullMode)
|
|
1504
|
+
continue; // already built
|
|
1505
|
+
const variantDesc = { ...baseDesc, cullMode: mode };
|
|
1506
|
+
getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, variantDesc);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1454
1509
|
}
|
|
1455
1510
|
// ─── §7 derived-uniforms registration ────────────────────────────
|
|
1456
1511
|
// A uniform binding on this RO is either a value (aval/constant) or a rule —
|
|
@@ -1527,17 +1582,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1527
1582
|
}
|
|
1528
1583
|
}
|
|
1529
1584
|
{
|
|
1585
|
+
const slotIdx = drawIdToSlotIdx[drawId];
|
|
1586
|
+
const slot = slotIdx !== undefined ? bucket.slots[slotIdx] : bucket.slots[0];
|
|
1530
1587
|
const removedEntry = bucket.localEntries[localSlot];
|
|
1531
1588
|
const removedCount = removedEntry !== undefined
|
|
1532
1589
|
? removedEntry.indexCount * removedEntry.instanceCount
|
|
1533
1590
|
: 0;
|
|
1534
|
-
|
|
1591
|
+
slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate - removedCount);
|
|
1535
1592
|
// Swap-pop: move the last record into the freed slot, decrement
|
|
1536
1593
|
// recordCount. firstEmit is GPU-rewritten by the next scan, so
|
|
1537
1594
|
// we only fix (drawIdx, indexStart, indexCount, instanceCount).
|
|
1538
|
-
const recIdx =
|
|
1539
|
-
const lastRecIdx =
|
|
1540
|
-
const shadow =
|
|
1595
|
+
const recIdx = slot.slotToRecord[localSlot];
|
|
1596
|
+
const lastRecIdx = slot.recordCount - 1;
|
|
1597
|
+
const shadow = slot.drawTableShadow;
|
|
1541
1598
|
if (recIdx !== lastRecIdx) {
|
|
1542
1599
|
const dst = recIdx * RECORD_U32;
|
|
1543
1600
|
const src = lastRecIdx * RECORD_U32;
|
|
@@ -1546,19 +1603,19 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1546
1603
|
shadow[dst + 2] = shadow[src + 2];
|
|
1547
1604
|
shadow[dst + 3] = shadow[src + 3];
|
|
1548
1605
|
shadow[dst + 4] = shadow[src + 4];
|
|
1549
|
-
const movedSlot =
|
|
1550
|
-
|
|
1551
|
-
|
|
1606
|
+
const movedSlot = slot.recordToSlot[lastRecIdx];
|
|
1607
|
+
slot.slotToRecord[movedSlot] = recIdx;
|
|
1608
|
+
slot.recordToSlot[recIdx] = movedSlot;
|
|
1552
1609
|
const byteOff = recIdx * RECORD_BYTES;
|
|
1553
|
-
if (byteOff <
|
|
1554
|
-
|
|
1555
|
-
if (byteOff + RECORD_BYTES >
|
|
1556
|
-
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1610
|
+
if (byteOff < slot.drawTableDirtyMin)
|
|
1611
|
+
slot.drawTableDirtyMin = byteOff;
|
|
1612
|
+
if (byteOff + RECORD_BYTES > slot.drawTableDirtyMax)
|
|
1613
|
+
slot.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1614
|
+
}
|
|
1615
|
+
slot.slotToRecord[localSlot] = -1;
|
|
1616
|
+
slot.recordToSlot[lastRecIdx] = -1;
|
|
1617
|
+
slot.recordCount = lastRecIdx;
|
|
1618
|
+
slot.scanDirty = true;
|
|
1562
1619
|
}
|
|
1563
1620
|
// Release pool entries — refcount drops; if zero, allocation freed.
|
|
1564
1621
|
const avals = bucket.localPerDrawAvals[localSlot];
|
|
@@ -1611,6 +1668,7 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1611
1668
|
bucket.drawHeap.release(localSlot);
|
|
1612
1669
|
drawIdToBucket[drawId] = undefined;
|
|
1613
1670
|
drawIdToLocalSlot[drawId] = undefined;
|
|
1671
|
+
drawIdToSlotIdx[drawId] = undefined;
|
|
1614
1672
|
drawIdToIndexAval[drawId] = undefined;
|
|
1615
1673
|
drawIdToSpec[drawId] = undefined;
|
|
1616
1674
|
const oldTracker = drawIdToModeTracker[drawId];
|
|
@@ -1730,113 +1788,91 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1730
1788
|
if (dirtyModeKeyDrawIds.size > 0) {
|
|
1731
1789
|
const dirty = [...dirtyModeKeyDrawIds];
|
|
1732
1790
|
dirtyModeKeyDrawIds.clear();
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1739
|
-
|
|
1791
|
+
// Phase 5c.2 reslot pass. For each dirty drawId whose
|
|
1792
|
+
// tracker.modeKey changed:
|
|
1793
|
+
// 1. Find its bucket + current slot.
|
|
1794
|
+
// 2. ensureSlot for the new modeKey (within the same bucket).
|
|
1795
|
+
// 3. Move the record from oldSlot.drawTable to
|
|
1796
|
+
// newSlot.drawTable (swap-pop + push). Bucket-shared
|
|
1797
|
+
// drawHeap + pool entries stay put.
|
|
1798
|
+
// 4. Update drawIdToSlotIdx.
|
|
1799
|
+
// No bucket rename, no createRenderPipeline (pre-warm + cache
|
|
1800
|
+
// mean ensureSlot is a hit). No removeDraw+addDraw arena
|
|
1801
|
+
// churn.
|
|
1740
1802
|
for (const drawId of dirty) {
|
|
1741
|
-
const
|
|
1742
|
-
if (
|
|
1743
|
-
continue; // RO removed since mark
|
|
1744
|
-
let arr = byBucket.get(b);
|
|
1745
|
-
if (arr === undefined) {
|
|
1746
|
-
arr = [];
|
|
1747
|
-
byBucket.set(b, arr);
|
|
1748
|
-
}
|
|
1749
|
-
arr.push(drawId);
|
|
1750
|
-
}
|
|
1751
|
-
const renames = [];
|
|
1752
|
-
const slowChanged = []; // ROs going through per-RO
|
|
1753
|
-
for (const [bucket, ids] of byBucket) {
|
|
1754
|
-
const changed = [];
|
|
1755
|
-
for (const drawId of ids) {
|
|
1756
|
-
const tracker = drawIdToModeTracker[drawId];
|
|
1757
|
-
if (tracker === undefined)
|
|
1758
|
-
continue;
|
|
1759
|
-
const oldKey = tracker.modeKey;
|
|
1760
|
-
if (!tracker.recompute())
|
|
1761
|
-
continue;
|
|
1762
|
-
if (tracker.modeKey === oldKey)
|
|
1763
|
-
continue;
|
|
1764
|
-
changed.push(drawId);
|
|
1765
|
-
}
|
|
1766
|
-
if (changed.length === 0)
|
|
1803
|
+
const tracker = drawIdToModeTracker[drawId];
|
|
1804
|
+
if (tracker === undefined)
|
|
1767
1805
|
continue;
|
|
1768
|
-
const
|
|
1769
|
-
if (
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
const sample = drawIdToSpec[repId];
|
|
1773
|
-
const fam = familyFor(sample.effect);
|
|
1774
|
-
const newKey = repTracker.modeKey;
|
|
1775
|
-
const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
|
|
1776
|
-
let oldBk;
|
|
1777
|
-
for (const [k, v] of bucketByKey) {
|
|
1778
|
-
if (v === bucket) {
|
|
1779
|
-
oldBk = k;
|
|
1780
|
-
break;
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
if (oldBk !== undefined && oldBk !== newBk) {
|
|
1784
|
-
renames.push({ bucket, repId, newKey, newBk, oldBk });
|
|
1785
|
-
continue;
|
|
1786
|
-
}
|
|
1787
|
-
if (oldBk === newBk)
|
|
1788
|
-
continue; // shouldn't happen but safe
|
|
1789
|
-
}
|
|
1790
|
-
// Partial transition: per-RO slow path.
|
|
1791
|
-
for (const c of changed)
|
|
1792
|
-
slowChanged.push(c);
|
|
1793
|
-
}
|
|
1794
|
-
if (renames.length > 0) {
|
|
1795
|
-
// Phase 1: remove all old keys. After this, the renames' new
|
|
1796
|
-
// keys are free unless they collide with a NON-rename
|
|
1797
|
-
// bucket's current key.
|
|
1798
|
-
for (const r of renames)
|
|
1799
|
-
bucketByKey.delete(r.oldBk);
|
|
1800
|
-
// Phase 2: assign new keys, building new pipelines. If a
|
|
1801
|
-
// collision still exists after phase 1 (against a stale
|
|
1802
|
-
// bucket that's NOT being renamed), restore old key + fall
|
|
1803
|
-
// back to per-RO for that bucket's records.
|
|
1804
|
-
for (const r of renames) {
|
|
1805
|
-
if (bucketByKey.has(r.newBk)) {
|
|
1806
|
-
// Collision with a stale bucket. Restore + flag for slow.
|
|
1807
|
-
bucketByKey.set(r.oldBk, r.bucket);
|
|
1808
|
-
for (const id of r.bucket.drawSlots)
|
|
1809
|
-
slowChanged.push(id);
|
|
1810
|
-
continue;
|
|
1811
|
-
}
|
|
1812
|
-
bucketByKey.set(r.newBk, r.bucket);
|
|
1813
|
-
const tracker = drawIdToModeTracker[r.repId];
|
|
1814
|
-
const sample = drawIdToSpec[r.repId];
|
|
1815
|
-
const desc = tracker.descriptor;
|
|
1816
|
-
const { pipelineLayout } = getBgl(r.bucket.layout, r.bucket.isAtlasBucket);
|
|
1817
|
-
const newPipeline = device.createRenderPipeline({
|
|
1818
|
-
label: `heapScene/${r.newBk}/pipeline`,
|
|
1819
|
-
layout: pipelineLayout,
|
|
1820
|
-
vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
|
|
1821
|
-
fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
|
|
1822
|
-
primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
|
|
1823
|
-
...(depthFormat !== undefined && desc.depth !== undefined
|
|
1824
|
-
? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
|
|
1825
|
-
: depthFormat !== undefined
|
|
1826
|
-
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
|
|
1827
|
-
: {}),
|
|
1828
|
-
});
|
|
1829
|
-
r.bucket.slots[0].pipeline = newPipeline;
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
// Slow per-RO rebucket for the leftover cases.
|
|
1833
|
-
for (const drawId of slowChanged) {
|
|
1834
|
-
const spec = drawIdToSpec[drawId];
|
|
1835
|
-
if (spec === undefined)
|
|
1806
|
+
const oldKey = tracker.modeKey;
|
|
1807
|
+
if (!tracker.recompute())
|
|
1808
|
+
continue;
|
|
1809
|
+
if (tracker.modeKey === oldKey)
|
|
1836
1810
|
continue;
|
|
1837
|
-
|
|
1838
|
-
const
|
|
1839
|
-
|
|
1811
|
+
const bucket = drawIdToBucket[drawId];
|
|
1812
|
+
const localSlot = drawIdToLocalSlot[drawId];
|
|
1813
|
+
const oldSlotIdx = drawIdToSlotIdx[drawId];
|
|
1814
|
+
if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined)
|
|
1815
|
+
continue;
|
|
1816
|
+
const newSlot = ensureSlot(bucket, tracker.descriptor);
|
|
1817
|
+
const newSlotIdx = bucket.slots.indexOf(newSlot);
|
|
1818
|
+
if (newSlotIdx === oldSlotIdx)
|
|
1819
|
+
continue;
|
|
1820
|
+
// Swap-pop from old slot's drawTable.
|
|
1821
|
+
const oldSlot = bucket.slots[oldSlotIdx];
|
|
1822
|
+
const entry = bucket.localEntries[localSlot];
|
|
1823
|
+
const emit = entry !== undefined ? entry.indexCount * entry.instanceCount : 0;
|
|
1824
|
+
const oldRecIdx = oldSlot.slotToRecord[localSlot];
|
|
1825
|
+
const lastRecIdx = oldSlot.recordCount - 1;
|
|
1826
|
+
const oldShadow = oldSlot.drawTableShadow;
|
|
1827
|
+
if (oldRecIdx !== lastRecIdx) {
|
|
1828
|
+
const dst = oldRecIdx * RECORD_U32;
|
|
1829
|
+
const src = lastRecIdx * RECORD_U32;
|
|
1830
|
+
oldShadow[dst + 0] = 0;
|
|
1831
|
+
oldShadow[dst + 1] = oldShadow[src + 1];
|
|
1832
|
+
oldShadow[dst + 2] = oldShadow[src + 2];
|
|
1833
|
+
oldShadow[dst + 3] = oldShadow[src + 3];
|
|
1834
|
+
oldShadow[dst + 4] = oldShadow[src + 4];
|
|
1835
|
+
const movedLocal = oldSlot.recordToSlot[lastRecIdx];
|
|
1836
|
+
oldSlot.slotToRecord[movedLocal] = oldRecIdx;
|
|
1837
|
+
oldSlot.recordToSlot[oldRecIdx] = movedLocal;
|
|
1838
|
+
const off = oldRecIdx * RECORD_BYTES;
|
|
1839
|
+
if (off < oldSlot.drawTableDirtyMin)
|
|
1840
|
+
oldSlot.drawTableDirtyMin = off;
|
|
1841
|
+
if (off + RECORD_BYTES > oldSlot.drawTableDirtyMax)
|
|
1842
|
+
oldSlot.drawTableDirtyMax = off + RECORD_BYTES;
|
|
1843
|
+
}
|
|
1844
|
+
oldSlot.slotToRecord[localSlot] = -1;
|
|
1845
|
+
oldSlot.recordToSlot[lastRecIdx] = -1;
|
|
1846
|
+
oldSlot.recordCount = lastRecIdx;
|
|
1847
|
+
oldSlot.totalEmitEstimate = Math.max(0, oldSlot.totalEmitEstimate - emit);
|
|
1848
|
+
oldSlot.scanDirty = true;
|
|
1849
|
+
// Push into new slot's drawTable.
|
|
1850
|
+
const newRecIdx = newSlot.recordCount;
|
|
1851
|
+
const dtBuf = newSlot.drawTableBuf;
|
|
1852
|
+
const byteOff = newRecIdx * RECORD_BYTES;
|
|
1853
|
+
dtBuf.ensureCapacity(byteOff + RECORD_BYTES);
|
|
1854
|
+
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
1855
|
+
const needBlocks = Math.max(1, Math.ceil((newRecIdx + 1) / SCAN_TILE_SIZE));
|
|
1856
|
+
newSlot.blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1857
|
+
newSlot.blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1858
|
+
const newShadow = newSlot.drawTableShadow;
|
|
1859
|
+
newShadow[newRecIdx * RECORD_U32 + 0] = 0;
|
|
1860
|
+
newShadow[newRecIdx * RECORD_U32 + 1] = localSlot;
|
|
1861
|
+
newShadow[newRecIdx * RECORD_U32 + 2] = entry !== undefined ? entry.firstIndex : 0;
|
|
1862
|
+
newShadow[newRecIdx * RECORD_U32 + 3] = entry !== undefined ? entry.indexCount : 0;
|
|
1863
|
+
newShadow[newRecIdx * RECORD_U32 + 4] = entry !== undefined ? entry.instanceCount : 0;
|
|
1864
|
+
newSlot.recordCount = newRecIdx + 1;
|
|
1865
|
+
newSlot.slotToRecord[localSlot] = newRecIdx;
|
|
1866
|
+
newSlot.recordToSlot[newRecIdx] = localSlot;
|
|
1867
|
+
if (byteOff < newSlot.drawTableDirtyMin)
|
|
1868
|
+
newSlot.drawTableDirtyMin = byteOff;
|
|
1869
|
+
if (byteOff + RECORD_BYTES > newSlot.drawTableDirtyMax)
|
|
1870
|
+
newSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
1871
|
+
newSlot.totalEmitEstimate += emit;
|
|
1872
|
+
const newNumTiles = Math.max(1, Math.ceil(newSlot.totalEmitEstimate / TILE_K));
|
|
1873
|
+
newSlot.firstDrawInTileBuf.ensureCapacity((newNumTiles + 1) * 4);
|
|
1874
|
+
newSlot.scanDirty = true;
|
|
1875
|
+
drawIdToSlotIdx[drawId] = newSlotIdx;
|
|
1840
1876
|
}
|
|
1841
1877
|
}
|
|
1842
1878
|
// 1. Pool: re-pack any aval whose value changed since last frame.
|
|
@@ -1951,13 +1987,21 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
1951
1987
|
function encodeIntoPass(pass) {
|
|
1952
1988
|
let curBg = null;
|
|
1953
1989
|
for (const b of buckets) {
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1990
|
+
// Phase 5c.2: iterate the bucket's slots, drawIndirect per
|
|
1991
|
+
// non-empty slot. Empty slots draw zero (skip — saves the
|
|
1992
|
+
// setPipeline/setBindGroup overhead).
|
|
1993
|
+
for (const slot of b.slots) {
|
|
1994
|
+
if (slot.recordCount === 0)
|
|
1995
|
+
continue;
|
|
1996
|
+
const bg = slot.bindGroup;
|
|
1997
|
+
if (bg !== undefined && bg !== curBg) {
|
|
1998
|
+
pass.setBindGroup(0, bg);
|
|
1999
|
+
curBg = bg;
|
|
2000
|
+
}
|
|
2001
|
+
pass.setPipeline(slot.pipeline);
|
|
2002
|
+
if (slot.indirectBuf !== undefined)
|
|
2003
|
+
pass.drawIndirect(slot.indirectBuf, 0);
|
|
1957
2004
|
}
|
|
1958
|
-
pass.setPipeline(b.slots[0].pipeline);
|
|
1959
|
-
if (b.slots[0].recordCount > 0)
|
|
1960
|
-
pass.drawIndirect(b.slots[0].indirectBuf, 0);
|
|
1961
2005
|
}
|
|
1962
2006
|
}
|
|
1963
2007
|
/**
|
|
@@ -2032,42 +2076,44 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
|
|
|
2032
2076
|
stats.derivedRecords = derivedScene.records.recordCount;
|
|
2033
2077
|
}
|
|
2034
2078
|
let anyDirty = false;
|
|
2035
|
-
for (const b of buckets) {
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2079
|
+
outer: for (const b of buckets) {
|
|
2080
|
+
for (const s of b.slots) {
|
|
2081
|
+
if (s.scanDirty) {
|
|
2082
|
+
anyDirty = true;
|
|
2083
|
+
break outer;
|
|
2084
|
+
}
|
|
2039
2085
|
}
|
|
2040
2086
|
}
|
|
2041
2087
|
if (!anyDirty)
|
|
2042
2088
|
return;
|
|
2043
2089
|
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
2044
2090
|
for (const b of buckets) {
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2091
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
2092
|
+
const slot = b.slots[i];
|
|
2093
|
+
if (!slot.scanDirty)
|
|
2094
|
+
continue;
|
|
2095
|
+
// Rebuild render bind group if recordCount changed — drawTable
|
|
2096
|
+
// binding is sized to recordCount * RECORD_BYTES.
|
|
2097
|
+
if (slot.renderBoundRecordCount !== slot.recordCount) {
|
|
2098
|
+
slot.bindGroup = buildBucketBindGroup(b, i);
|
|
2099
|
+
}
|
|
2100
|
+
const numRecords = slot.recordCount;
|
|
2101
|
+
const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
2102
|
+
device.queue.writeBuffer(slot.paramsBuf, 0, new Uint32Array([numRecords, numBlocks, 0, 0]));
|
|
2103
|
+
pass.setBindGroup(0, slot.scanBindGroup);
|
|
2104
|
+
pass.setPipeline(scanPipeTile);
|
|
2105
|
+
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2106
|
+
pass.setPipeline(scanPipeBlocks);
|
|
2107
|
+
pass.dispatchWorkgroups(1, 1, 1);
|
|
2108
|
+
pass.setPipeline(scanPipeAdd);
|
|
2109
|
+
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2110
|
+
const SCAN_WG_SIZE = 256;
|
|
2111
|
+
const numTilesCap = Math.max(1, Math.ceil(slot.totalEmitEstimate / TILE_K));
|
|
2112
|
+
const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
|
|
2113
|
+
pass.setPipeline(scanPipeBuildTileIndex);
|
|
2114
|
+
pass.dispatchWorkgroups(tileWgs, 1, 1);
|
|
2115
|
+
slot.scanDirty = false;
|
|
2116
|
+
}
|
|
2071
2117
|
}
|
|
2072
2118
|
pass.end();
|
|
2073
2119
|
}
|