@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
package/src/runtime/heapScene.ts
CHANGED
|
@@ -157,8 +157,13 @@ export interface HeapGeometry {
|
|
|
157
157
|
* slot owns its own drawTable + scan buffers + indirect + pipeline.
|
|
158
158
|
*/
|
|
159
159
|
interface BucketSlot {
|
|
160
|
-
/** The render pipeline this slot draws with.
|
|
161
|
-
|
|
160
|
+
/** The render pipeline this slot draws with. Replaced when a rule
|
|
161
|
+
* output changes for this slot (typically via the pipeline cache). */
|
|
162
|
+
pipeline: GPURenderPipeline;
|
|
163
|
+
/** modeKey this slot covers — the bitfield-encoded descriptor. */
|
|
164
|
+
modeKey: bigint;
|
|
165
|
+
/** Render bind group (drawTable + firstDrawInTile are per-slot). */
|
|
166
|
+
bindGroup?: GPUBindGroup;
|
|
162
167
|
|
|
163
168
|
// ─── drawTable / megacall state ────────────────────────────────────
|
|
164
169
|
drawTableBuf?: GrowBuffer;
|
|
@@ -379,6 +384,11 @@ export interface HeapDrawSpec {
|
|
|
379
384
|
|
|
380
385
|
export interface HeapSceneStats {
|
|
381
386
|
readonly groups: number;
|
|
387
|
+
/** Total pipeline slots across all buckets — one per realized
|
|
388
|
+
* (effect, textureSet, modeKey) combination. Phase 5c.2 brings
|
|
389
|
+
* this divergence: groups counts (effect, textureSet) only,
|
|
390
|
+
* slotCount counts the per-bucket modeKey splits. */
|
|
391
|
+
readonly slotCount: number;
|
|
382
392
|
totalDraws: number;
|
|
383
393
|
drawBytes: number;
|
|
384
394
|
geometryBytes: number;
|
|
@@ -604,6 +614,10 @@ export function buildHeapScene(
|
|
|
604
614
|
// ─── Per-draw global bookkeeping (sparse, indexed by drawId) ──────
|
|
605
615
|
const drawIdToBucket: (Bucket | undefined)[] = [];
|
|
606
616
|
const drawIdToLocalSlot: (number | undefined)[] = [];
|
|
617
|
+
/** Per-draw slot index within its bucket. Phase 5c.2: each bucket
|
|
618
|
+
* holds multiple BucketSlots (one per realized modeKey); this map
|
|
619
|
+
* tells each removeDraw / record write which slot to touch. */
|
|
620
|
+
const drawIdToSlotIdx: (number | undefined)[] = [];
|
|
607
621
|
/** Per-draw index aval — for `indexPool.release` on removeDraw. */
|
|
608
622
|
const drawIdToIndexAval: (aval<Uint32Array> | undefined)[] = [];
|
|
609
623
|
/**
|
|
@@ -1085,18 +1099,19 @@ export function buildHeapScene(
|
|
|
1085
1099
|
});
|
|
1086
1100
|
}
|
|
1087
1101
|
|
|
1088
|
-
function buildScanBindGroup(bucket: Bucket): GPUBindGroup {
|
|
1102
|
+
function buildScanBindGroup(bucket: Bucket, slotIdx = 0): GPUBindGroup {
|
|
1089
1103
|
if (scanBgl === undefined) throw new Error("heapScene: scan BGL missing");
|
|
1104
|
+
const s = bucket.slots[slotIdx]!;
|
|
1090
1105
|
return device.createBindGroup({
|
|
1091
|
-
label: `heapScene/${bucket.label}/scanBg`,
|
|
1106
|
+
label: `heapScene/${bucket.label}/slot${slotIdx}/scanBg`,
|
|
1092
1107
|
layout: scanBgl,
|
|
1093
1108
|
entries: [
|
|
1094
|
-
{ binding: 0, resource: { buffer:
|
|
1095
|
-
{ binding: 1, resource: { buffer:
|
|
1096
|
-
{ binding: 2, resource: { buffer:
|
|
1097
|
-
{ binding: 3, resource: { buffer:
|
|
1098
|
-
{ binding: 4, resource: { buffer:
|
|
1099
|
-
{ binding: 5, resource: { buffer:
|
|
1109
|
+
{ binding: 0, resource: { buffer: s.drawTableBuf!.buffer } },
|
|
1110
|
+
{ binding: 1, resource: { buffer: s.blockSumsBuf!.buffer } },
|
|
1111
|
+
{ binding: 2, resource: { buffer: s.blockOffsetsBuf!.buffer } },
|
|
1112
|
+
{ binding: 3, resource: { buffer: s.indirectBuf! } },
|
|
1113
|
+
{ binding: 4, resource: { buffer: s.paramsBuf! } },
|
|
1114
|
+
{ binding: 5, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
|
|
1100
1115
|
],
|
|
1101
1116
|
});
|
|
1102
1117
|
}
|
|
@@ -1105,6 +1120,45 @@ export function buildHeapScene(
|
|
|
1105
1120
|
const buckets: Bucket[] = [];
|
|
1106
1121
|
const bucketByKey = new Map<string, Bucket>();
|
|
1107
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* Scene-level pipeline cache keyed on `(famId, modeKey)` — when an
|
|
1125
|
+
* RO mode-flip renames a bucket, the new pipeline is fetched from
|
|
1126
|
+
* this cache rather than re-created. Cache misses fall back to
|
|
1127
|
+
* `device.createRenderPipeline` and stash the result. Pre-warming
|
|
1128
|
+
* (when ROs are added with rules) extends this cache so the cache
|
|
1129
|
+
* always hits on subsequent flips. See Phase 5c.2.
|
|
1130
|
+
*/
|
|
1131
|
+
const pipelineByModeKey = new Map<string, GPURenderPipeline>();
|
|
1132
|
+
|
|
1133
|
+
/** Build a GPURenderPipeline from a descriptor + family + bucket
|
|
1134
|
+
* layout. Cached by `(familyId, modeKey)`. */
|
|
1135
|
+
function getOrCreatePipeline(
|
|
1136
|
+
fam: ReturnType<typeof familyFor>,
|
|
1137
|
+
layout: BucketLayout,
|
|
1138
|
+
isAtlasBucket: boolean,
|
|
1139
|
+
desc: PipelineStateDescriptor,
|
|
1140
|
+
): GPURenderPipeline {
|
|
1141
|
+
const modeKey = encodeModeKey(desc);
|
|
1142
|
+
const cacheKey = `f${fam.schema.id}|mk${modeKey.toString(16)}`;
|
|
1143
|
+
const cached = pipelineByModeKey.get(cacheKey);
|
|
1144
|
+
if (cached !== undefined) return cached;
|
|
1145
|
+
const { pipelineLayout } = getBgl(layout, isAtlasBucket);
|
|
1146
|
+
const pipeline = device.createRenderPipeline({
|
|
1147
|
+
label: `heapScene/cached/${cacheKey}`,
|
|
1148
|
+
layout: pipelineLayout,
|
|
1149
|
+
vertex: { module: fam.vsModule, entryPoint: fam.vsEntryName, buffers: [] },
|
|
1150
|
+
fragment: { module: fam.fsModule, entryPoint: fam.fsEntryName, targets: colorTargets },
|
|
1151
|
+
primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
|
|
1152
|
+
...(depthFormat !== undefined && desc.depth !== undefined
|
|
1153
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
|
|
1154
|
+
: depthFormat !== undefined
|
|
1155
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" as GPUCompareFunction } }
|
|
1156
|
+
: {}),
|
|
1157
|
+
});
|
|
1158
|
+
pipelineByModeKey.set(cacheKey, pipeline);
|
|
1159
|
+
return pipeline;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1108
1162
|
// ─── Family state (§6 family-merge, slice 3c) ─────────────────────
|
|
1109
1163
|
// Built lazily on the first addDraw batch from the union of all
|
|
1110
1164
|
// effects in that batch, then frozen. Subsequent addDraws with an
|
|
@@ -1362,7 +1416,7 @@ export function buildHeapScene(
|
|
|
1362
1416
|
// pipelines via noTexBgl/texBgl; the bind-group itself binds this
|
|
1363
1417
|
// bucket's drawHeap (binding 1) + its globals UBO (binding 0) +
|
|
1364
1418
|
// the global arena's heapF32 view (binding 2) + textures if any.
|
|
1365
|
-
function buildBucketBindGroup(bucket: Bucket): GPUBindGroup {
|
|
1419
|
+
function buildBucketBindGroup(bucket: Bucket, slotIdx = 0): GPUBindGroup {
|
|
1366
1420
|
// heapU32 / heapF32 / heapV4f are different typed views of the
|
|
1367
1421
|
// SAME global arena GPUBuffer (emscripten-style aliasing). The
|
|
1368
1422
|
// WGSL prelude declares one binding per view; the shader picks
|
|
@@ -1374,7 +1428,8 @@ export function buildHeapScene(
|
|
|
1374
1428
|
{ binding: 3, resource: { buffer: arena.attrs.buffer } }, // heapV4f
|
|
1375
1429
|
];
|
|
1376
1430
|
{
|
|
1377
|
-
|
|
1431
|
+
const s = bucket.slots[slotIdx]!;
|
|
1432
|
+
if (s.drawTableBuf === undefined) {
|
|
1378
1433
|
throw new Error("heapScene: megacall bucket without drawTableBuf");
|
|
1379
1434
|
}
|
|
1380
1435
|
// Bind drawTable with size = recordCount * RECORD_BYTES so the VS
|
|
@@ -1382,13 +1437,13 @@ export function buildHeapScene(
|
|
|
1382
1437
|
// the live record count — keeps stale tail entries out of the
|
|
1383
1438
|
// binary search. Minimum one zero-record to satisfy WebGPU non-
|
|
1384
1439
|
// zero size constraint when the bucket is empty.
|
|
1385
|
-
const dtBytes = Math.max(RECORD_BYTES,
|
|
1440
|
+
const dtBytes = Math.max(RECORD_BYTES, s.recordCount * RECORD_BYTES);
|
|
1386
1441
|
entries.push(
|
|
1387
|
-
{ binding: 4, resource: { buffer:
|
|
1442
|
+
{ binding: 4, resource: { buffer: s.drawTableBuf.buffer, offset: 0, size: dtBytes } },
|
|
1388
1443
|
{ binding: 5, resource: { buffer: arena.indices.buffer } },
|
|
1389
|
-
{ binding: 6, resource: { buffer:
|
|
1444
|
+
{ binding: 6, resource: { buffer: s.firstDrawInTileBuf!.buffer } },
|
|
1390
1445
|
);
|
|
1391
|
-
|
|
1446
|
+
s.renderBoundRecordCount = s.recordCount;
|
|
1392
1447
|
}
|
|
1393
1448
|
// Schema-driven texture + sampler entries. v1 user surface still
|
|
1394
1449
|
// accepts a single (texture, sampler) pair via HeapTextureSet —
|
|
@@ -1483,32 +1538,28 @@ export function buildHeapScene(
|
|
|
1483
1538
|
// carry strong refcount handles (`localAtlasReleases`) that drive
|
|
1484
1539
|
// `pool.release` on removeDraw.
|
|
1485
1540
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1541
|
+
/** Rebuild every slot's render bind group across every bucket.
|
|
1542
|
+
* Used when a global resource (arena.attrs, arena.indices, atlas
|
|
1543
|
+
* page set) reallocates and invalidates every bind group at once. */
|
|
1544
|
+
function rebuildAllBindGroups(filterAtlasOnly = false): void {
|
|
1545
|
+
for (const b of buckets) {
|
|
1546
|
+
if (filterAtlasOnly && !b.isAtlasBucket) continue;
|
|
1547
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
1548
|
+
b.slots[i]!.bindGroup = buildBucketBindGroup(b, i);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1488
1553
|
arena.attrs.onResize(() => {
|
|
1489
|
-
|
|
1490
|
-
// §7 dispatcher targets arena.attrs.buffer; rebind on grow.
|
|
1554
|
+
rebuildAllBindGroups();
|
|
1491
1555
|
if (derivedScene !== undefined) derivedScene.rebindMainHeap(arena.attrs.buffer);
|
|
1492
1556
|
});
|
|
1493
|
-
// Same when the atlas pool allocates a fresh page — its slot in the
|
|
1494
|
-
// BGL ladder transitions from placeholder to the real GPUTexture.
|
|
1495
|
-
// Every atlas bucket rebuilds (cheap; just N+N+1 entries each).
|
|
1496
1557
|
if (atlasPool !== undefined) {
|
|
1497
1558
|
for (const f of ATLAS_PAGE_FORMATS) {
|
|
1498
|
-
atlasPool.onPageAdded(f, () =>
|
|
1499
|
-
for (const b of buckets) {
|
|
1500
|
-
if (b.isAtlasBucket) b.bindGroup = buildBucketBindGroup(b);
|
|
1501
|
-
}
|
|
1502
|
-
});
|
|
1559
|
+
atlasPool.onPageAdded(f, () => rebuildAllBindGroups(true));
|
|
1503
1560
|
}
|
|
1504
1561
|
}
|
|
1505
|
-
|
|
1506
|
-
// also invalidates every bucket's bind group.
|
|
1507
|
-
{
|
|
1508
|
-
arena.indices.onResize(() => {
|
|
1509
|
-
for (const b of buckets) b.bindGroup = buildBucketBindGroup(b);
|
|
1510
|
-
});
|
|
1511
|
-
}
|
|
1562
|
+
arena.indices.onResize(() => rebuildAllBindGroups());
|
|
1512
1563
|
|
|
1513
1564
|
// ─── findOrCreateBucket ───────────────────────────────────────────
|
|
1514
1565
|
// Slice 3c: the bucket key collapses to (familyId, pipelineState).
|
|
@@ -1517,6 +1568,95 @@ export function buildHeapScene(
|
|
|
1517
1568
|
// right per-effect helper at draw time. Atlas-binding shape follows
|
|
1518
1569
|
// from `family.drawHeaderUnion.atlasTextureBindings.size > 0`.
|
|
1519
1570
|
void texIdOf; // retained for future per-bucket diagnostics
|
|
1571
|
+
/**
|
|
1572
|
+
* Allocate a brand-new BucketSlot inside `bucket` covering
|
|
1573
|
+
* `descriptor` (modeKey = encode(descriptor)). Builds the per-slot
|
|
1574
|
+
* GPU resources: pipeline (via cache), drawTable + scan buffers +
|
|
1575
|
+
* indirect + paramsBuf, and the slot's render/scan bind groups
|
|
1576
|
+
* (the latter rebuilt on resize). The bucket's drawHeap +
|
|
1577
|
+
* texture bindings are shared across slots.
|
|
1578
|
+
*/
|
|
1579
|
+
function createSlot(bucket: Bucket, descriptor: PipelineStateDescriptor): BucketSlot {
|
|
1580
|
+
const fam = familyForBucket.get(bucket)!;
|
|
1581
|
+
const pipeline = getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, descriptor);
|
|
1582
|
+
const modeKey = encodeModeKey(descriptor);
|
|
1583
|
+
const slotIdx = bucket.slots.length;
|
|
1584
|
+
const slotLabel = `${bucket.label}/slot${slotIdx}`;
|
|
1585
|
+
|
|
1586
|
+
const dtBuf = new GrowBuffer(
|
|
1587
|
+
device, `heapScene/${slotLabel}/drawTable`,
|
|
1588
|
+
GPUBufferUsage.STORAGE, 1024,
|
|
1589
|
+
);
|
|
1590
|
+
const blockSumsBuf = new GrowBuffer(
|
|
1591
|
+
device, `heapScene/${slotLabel}/blockSums`,
|
|
1592
|
+
GPUBufferUsage.STORAGE,
|
|
1593
|
+
4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
|
|
1594
|
+
);
|
|
1595
|
+
const blockOffsetsBuf = new GrowBuffer(
|
|
1596
|
+
device, `heapScene/${slotLabel}/blockOffsets`,
|
|
1597
|
+
GPUBufferUsage.STORAGE,
|
|
1598
|
+
4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
|
|
1599
|
+
);
|
|
1600
|
+
const firstDrawInTileBuf = new GrowBuffer(
|
|
1601
|
+
device, `heapScene/${slotLabel}/firstDrawInTile`,
|
|
1602
|
+
GPUBufferUsage.STORAGE, 128,
|
|
1603
|
+
);
|
|
1604
|
+
const indirectBuf = device.createBuffer({
|
|
1605
|
+
label: `heapScene/${slotLabel}/indirect`, size: 16,
|
|
1606
|
+
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
1607
|
+
});
|
|
1608
|
+
device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
|
|
1609
|
+
const paramsBuf = device.createBuffer({
|
|
1610
|
+
label: `heapScene/${slotLabel}/params`, size: 16,
|
|
1611
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
const slot: BucketSlot = {
|
|
1615
|
+
pipeline, modeKey,
|
|
1616
|
+
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
1617
|
+
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
1618
|
+
totalEmitEstimate: 0, scanDirty: false,
|
|
1619
|
+
drawTableBuf: dtBuf,
|
|
1620
|
+
drawTableShadow: new Uint32Array(dtBuf.capacity / 4),
|
|
1621
|
+
blockSumsBuf, blockOffsetsBuf, firstDrawInTileBuf,
|
|
1622
|
+
indirectBuf, paramsBuf,
|
|
1623
|
+
};
|
|
1624
|
+
bucket.slots.push(slot);
|
|
1625
|
+
const thisSlotIdx = bucket.slots.length - 1;
|
|
1626
|
+
|
|
1627
|
+
const ensureScanBuffers = (): void => {
|
|
1628
|
+
const needBlocks = Math.max(1, Math.ceil(slot.recordCount / SCAN_TILE_SIZE));
|
|
1629
|
+
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1630
|
+
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1631
|
+
};
|
|
1632
|
+
const rebuildScanBg = (): void => {
|
|
1633
|
+
slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
|
|
1634
|
+
};
|
|
1635
|
+
dtBuf.onResize(() => {
|
|
1636
|
+
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
1637
|
+
grown.set(slot.drawTableShadow!);
|
|
1638
|
+
slot.drawTableShadow = grown;
|
|
1639
|
+
ensureScanBuffers();
|
|
1640
|
+
slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
|
|
1641
|
+
rebuildScanBg();
|
|
1642
|
+
slot.drawTableDirtyMin = 0;
|
|
1643
|
+
slot.drawTableDirtyMax = slot.recordCount * RECORD_BYTES;
|
|
1644
|
+
});
|
|
1645
|
+
blockSumsBuf.onResize(rebuildScanBg);
|
|
1646
|
+
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
1647
|
+
firstDrawInTileBuf.onResize(() => {
|
|
1648
|
+
rebuildScanBg();
|
|
1649
|
+
slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
|
|
1650
|
+
});
|
|
1651
|
+
slot.scanBindGroup = buildScanBindGroup(bucket, thisSlotIdx);
|
|
1652
|
+
slot.bindGroup = buildBucketBindGroup(bucket, thisSlotIdx);
|
|
1653
|
+
|
|
1654
|
+
return slot;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** Side map from Bucket → family. Populated by findOrCreateBucket. */
|
|
1658
|
+
const familyForBucket = new WeakMap<Bucket, ReturnType<typeof familyFor>>();
|
|
1659
|
+
|
|
1520
1660
|
function findOrCreateBucket(
|
|
1521
1661
|
effect: Effect,
|
|
1522
1662
|
_textures: HeapTextureSet | undefined,
|
|
@@ -1533,79 +1673,27 @@ export function buildHeapScene(
|
|
|
1533
1673
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
1534
1674
|
}
|
|
1535
1675
|
const fam = familyFor(effect);
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
|
|
1542
|
-
// cvals collapse to ONE bucket. See docs/derived-modes.md and
|
|
1543
|
-
// tests/heap-multi-pipeline-bucket.test.ts.
|
|
1544
|
-
const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
|
|
1545
|
-
const psModeKey = encodeModeKey(psDescriptor);
|
|
1546
|
-
const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
|
|
1676
|
+
// Phase 5c.2: bucket key drops modeKey. All ROs with the same
|
|
1677
|
+
// (effect, textureSet) share one bucket; per-modeKey slots live
|
|
1678
|
+
// inside the bucket and route records via `ensureSlot`. Pipeline
|
|
1679
|
+
// pre-warm + the scene-level pipelineByModeKey cache mean every
|
|
1680
|
+
// realized slot lookup hits the cache.
|
|
1681
|
+
const bk = `family#${fam.schema.id}`;
|
|
1547
1682
|
const existing = bucketByKey.get(bk);
|
|
1548
1683
|
if (existing !== undefined) return existing;
|
|
1549
|
-
// If a precomputed descriptor is supplied (rule-evaluated path),
|
|
1550
|
-
// use its post-rule axis values so the pipeline reflects what the
|
|
1551
|
-
// RO actually wants. Otherwise fall through to PS-aval forcing.
|
|
1552
|
-
const ps: ResolvedPipelineState = precomputedDescriptor !== undefined
|
|
1553
|
-
? {
|
|
1554
|
-
topology: precomputedDescriptor.topology,
|
|
1555
|
-
cullMode: precomputedDescriptor.cullMode,
|
|
1556
|
-
frontFace: precomputedDescriptor.frontFace,
|
|
1557
|
-
...(precomputedDescriptor.depth !== undefined
|
|
1558
|
-
? { depth: { write: precomputedDescriptor.depth.write, compare: precomputedDescriptor.depth.compare } }
|
|
1559
|
-
: {}),
|
|
1560
|
-
}
|
|
1561
|
-
: resolvePipelineState(pipelineState);
|
|
1562
1684
|
|
|
1563
1685
|
const layout: BucketLayout = fam.schema.drawHeaderUnion;
|
|
1564
1686
|
const isAtlasBucket = layout.atlasTextureBindings.size > 0;
|
|
1565
|
-
const vsModule = fam.vsModule;
|
|
1566
|
-
const fsModule = fam.fsModule;
|
|
1567
|
-
const vsEntry = fam.vsEntryName;
|
|
1568
|
-
const fsEntry = fam.fsEntryName;
|
|
1569
|
-
const { pipelineLayout } = getBgl(layout, isAtlasBucket);
|
|
1570
|
-
|
|
1571
|
-
const pipeline = device.createRenderPipeline({
|
|
1572
|
-
label: `heapScene/${bk}/pipeline`,
|
|
1573
|
-
layout: pipelineLayout,
|
|
1574
|
-
vertex: { module: vsModule, entryPoint: vsEntry, buffers: [] },
|
|
1575
|
-
fragment: { module: fsModule, entryPoint: fsEntry, targets: colorTargets },
|
|
1576
|
-
primitive: { topology: ps.topology, cullMode: ps.cullMode, frontFace: ps.frontFace },
|
|
1577
|
-
...(depthFormat !== undefined && ps.depth !== undefined
|
|
1578
|
-
? { depthStencil: { format: depthFormat, depthWriteEnabled: ps.depth.write, depthCompare: ps.depth.compare } }
|
|
1579
|
-
: depthFormat !== undefined
|
|
1580
|
-
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" as GPUCompareFunction } }
|
|
1581
|
-
: {}),
|
|
1582
|
-
});
|
|
1583
|
-
|
|
1584
|
-
// Per-bucket DrawHeader buffer (every uniform / attribute is a u32
|
|
1585
|
-
// ref into the global arena; the per-bucket buffer just holds the
|
|
1586
|
-
// refs).
|
|
1587
1687
|
const drawHeapBuf = new GrowBuffer(
|
|
1588
1688
|
device, `heapScene/${bk}/drawHeap`, GPUBufferUsage.STORAGE,
|
|
1589
1689
|
Math.max(layout.drawHeaderBytes, 64),
|
|
1590
1690
|
);
|
|
1591
1691
|
const drawHeap = new DrawHeap(drawHeapBuf, layout.drawHeaderBytes);
|
|
1592
1692
|
|
|
1593
|
-
const slot0: BucketSlot = {
|
|
1594
|
-
pipeline,
|
|
1595
|
-
drawTableDirtyMin: Infinity, drawTableDirtyMax: 0,
|
|
1596
|
-
recordCount: 0, slotToRecord: [], recordToSlot: [],
|
|
1597
|
-
totalEmitEstimate: 0,
|
|
1598
|
-
scanDirty: false,
|
|
1599
|
-
};
|
|
1600
1693
|
const bucket: Bucket = {
|
|
1601
|
-
// §6 family-merge: family buckets aren't keyed on a specific
|
|
1602
|
-
// texture set — atlas placements are addressed per-RO via
|
|
1603
|
-
// drawHeader fields (`pageRef` + `formatBits` + `origin` +
|
|
1604
|
-
// `size`), and the bucket's atlas-binding ladder is driven by
|
|
1605
|
-
// `atlasPool.pagesFor(format)`. Leave `textures` undefined.
|
|
1606
1694
|
label: bk, textures: undefined, layout,
|
|
1607
|
-
slots: [
|
|
1608
|
-
bindGroup: null as unknown as GPUBindGroup,
|
|
1695
|
+
slots: [], // populated lazily by ensureSlot per modeKey
|
|
1696
|
+
bindGroup: null as unknown as GPUBindGroup, // legacy field; slot.bindGroup is the real one
|
|
1609
1697
|
drawHeap,
|
|
1610
1698
|
drawHeaderStaging: new Float32Array(drawHeapBuf.capacity / 4),
|
|
1611
1699
|
headerDirtyMin: Infinity, headerDirtyMax: 0,
|
|
@@ -1618,92 +1706,49 @@ export function buildHeapScene(
|
|
|
1618
1706
|
localAtlasTextures: [],
|
|
1619
1707
|
localAtlasArrIdx: [],
|
|
1620
1708
|
};
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
device, `heapScene/${bk}/drawTable`,
|
|
1624
|
-
GPUBufferUsage.STORAGE,
|
|
1625
|
-
1024,
|
|
1626
|
-
);
|
|
1627
|
-
const blockSumsBuf = new GrowBuffer(
|
|
1628
|
-
device, `heapScene/${bk}/blockSums`,
|
|
1629
|
-
GPUBufferUsage.STORAGE,
|
|
1630
|
-
4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
|
|
1631
|
-
);
|
|
1632
|
-
const blockOffsetsBuf = new GrowBuffer(
|
|
1633
|
-
device, `heapScene/${bk}/blockOffsets`,
|
|
1634
|
-
GPUBufferUsage.STORAGE,
|
|
1635
|
-
4 * Math.max(1, Math.ceil((dtBuf.capacity / RECORD_BYTES) / SCAN_TILE_SIZE)),
|
|
1636
|
-
);
|
|
1637
|
-
// 32 u32 = 128 bytes is the floor; pow2-grown by ensureCapacity
|
|
1638
|
-
// as totalEmitEstimate grows.
|
|
1639
|
-
const firstDrawInTileBuf = new GrowBuffer(
|
|
1640
|
-
device, `heapScene/${bk}/firstDrawInTile`,
|
|
1641
|
-
GPUBufferUsage.STORAGE,
|
|
1642
|
-
128,
|
|
1643
|
-
);
|
|
1644
|
-
const indirectBuf = device.createBuffer({
|
|
1645
|
-
label: `heapScene/${bk}/indirect`, size: 16,
|
|
1646
|
-
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
1647
|
-
});
|
|
1648
|
-
// Initialize to (0, 1, 0, 0) so an unscanned/empty bucket draws
|
|
1649
|
-
// nothing safely.
|
|
1650
|
-
device.queue.writeBuffer(indirectBuf, 0, new Uint32Array([0, 1, 0, 0]));
|
|
1651
|
-
const paramsBuf = device.createBuffer({
|
|
1652
|
-
label: `heapScene/${bk}/params`, size: 16,
|
|
1653
|
-
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1654
|
-
});
|
|
1655
|
-
bucket.slots[0]!.drawTableBuf = dtBuf;
|
|
1656
|
-
bucket.slots[0]!.drawTableShadow = new Uint32Array(dtBuf.capacity / 4);
|
|
1657
|
-
bucket.slots[0]!.blockSumsBuf = blockSumsBuf;
|
|
1658
|
-
bucket.slots[0]!.blockOffsetsBuf = blockOffsetsBuf;
|
|
1659
|
-
bucket.slots[0]!.firstDrawInTileBuf = firstDrawInTileBuf;
|
|
1660
|
-
bucket.slots[0]!.indirectBuf = indirectBuf;
|
|
1661
|
-
bucket.slots[0]!.paramsBuf = paramsBuf;
|
|
1662
|
-
const ensureScanBuffers = (): void => {
|
|
1663
|
-
const needBlocks = Math.max(1, Math.ceil(bucket.slots[0]!.recordCount / SCAN_TILE_SIZE));
|
|
1664
|
-
blockSumsBuf.ensureCapacity(needBlocks * 4);
|
|
1665
|
-
blockOffsetsBuf.ensureCapacity(needBlocks * 4);
|
|
1666
|
-
};
|
|
1667
|
-
const rebuildScanBg = (): void => {
|
|
1668
|
-
bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
|
|
1669
|
-
};
|
|
1670
|
-
dtBuf.onResize(() => {
|
|
1671
|
-
const grown = new Uint32Array(dtBuf.capacity / 4);
|
|
1672
|
-
grown.set(bucket.slots[0]!.drawTableShadow!);
|
|
1673
|
-
bucket.slots[0]!.drawTableShadow = grown;
|
|
1674
|
-
ensureScanBuffers();
|
|
1675
|
-
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1676
|
-
rebuildScanBg();
|
|
1677
|
-
bucket.slots[0]!.drawTableDirtyMin = 0;
|
|
1678
|
-
bucket.slots[0]!.drawTableDirtyMax = bucket.slots[0]!.recordCount * RECORD_BYTES;
|
|
1679
|
-
});
|
|
1680
|
-
blockSumsBuf.onResize(rebuildScanBg);
|
|
1681
|
-
blockOffsetsBuf.onResize(rebuildScanBg);
|
|
1682
|
-
firstDrawInTileBuf.onResize(() => {
|
|
1683
|
-
rebuildScanBg();
|
|
1684
|
-
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1685
|
-
});
|
|
1686
|
-
bucket.slots[0]!.scanBindGroup = buildScanBindGroup(bucket);
|
|
1687
|
-
}
|
|
1688
|
-
bucket.bindGroup = buildBucketBindGroup(bucket);
|
|
1709
|
+
familyForBucket.set(bucket, fam);
|
|
1710
|
+
|
|
1689
1711
|
drawHeap.onResize(() => {
|
|
1690
1712
|
bucket.drawHeaderStaging = new Float32Array(drawHeapBuf.capacity / 4);
|
|
1691
|
-
// GPU-side data was copied by the GrowBuffer's resize, but our
|
|
1692
|
-
// CPU mirror is fresh — mark all live local slots dirty so
|
|
1693
|
-
// their headers get re-packed and re-uploaded next frame.
|
|
1694
1713
|
for (const s of bucket.drawSlots) bucket.dirty.add(s);
|
|
1695
|
-
|
|
1696
|
-
//
|
|
1697
|
-
|
|
1698
|
-
|
|
1714
|
+
// Rebuild every slot's render bind group (binding 1 = drawHeap
|
|
1715
|
+
// points at the new buffer).
|
|
1716
|
+
for (let i = 0; i < bucket.slots.length; i++) {
|
|
1717
|
+
bucket.slots[i]!.bindGroup = buildBucketBindGroup(bucket, i);
|
|
1718
|
+
}
|
|
1699
1719
|
});
|
|
1700
|
-
|
|
1701
|
-
//
|
|
1720
|
+
|
|
1721
|
+
// Ensure the bucket has a slot for the supplied descriptor — at
|
|
1722
|
+
// bucket-creation time we eagerly seed slot 0 from the caller's
|
|
1723
|
+
// descriptor so the first addDraw doesn't pay a slot-create cost.
|
|
1724
|
+
const seedDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
|
|
1725
|
+
createSlot(bucket, seedDescriptor);
|
|
1726
|
+
|
|
1702
1727
|
buckets.push(bucket);
|
|
1703
1728
|
bucketByKey.set(bk, bucket);
|
|
1704
1729
|
return bucket;
|
|
1705
1730
|
}
|
|
1706
1731
|
|
|
1732
|
+
/**
|
|
1733
|
+
* Return the existing BucketSlot for `descriptor` inside `bucket`,
|
|
1734
|
+
* or create one on miss. The mode-flip hot path calls this when an
|
|
1735
|
+
* RO's tracker.modeKey lands on a slot that doesn't exist yet.
|
|
1736
|
+
*/
|
|
1737
|
+
function ensureSlot(bucket: Bucket, descriptor: PipelineStateDescriptor): BucketSlot {
|
|
1738
|
+
const wanted = encodeModeKey(descriptor);
|
|
1739
|
+
for (const s of bucket.slots) {
|
|
1740
|
+
if (s.modeKey === wanted) return s;
|
|
1741
|
+
}
|
|
1742
|
+
return createSlot(bucket, descriptor);
|
|
1743
|
+
}
|
|
1744
|
+
/** Find an RO's slot by drawId; null if the drawId is dead. */
|
|
1745
|
+
function slotForDrawId(drawId: number): BucketSlot | null {
|
|
1746
|
+
const idx = drawIdToSlotIdx[drawId];
|
|
1747
|
+
const bucket = drawIdToBucket[drawId];
|
|
1748
|
+
if (idx === undefined || bucket === undefined) return null;
|
|
1749
|
+
return bucket.slots[idx] ?? null;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1707
1752
|
// ─── Per-bucket header writer (refs only, post step 3) ────────────
|
|
1708
1753
|
// The DrawHeader is now a flat list of u32 refs into the arena —
|
|
1709
1754
|
// values themselves live in arena allocations the pool manages.
|
|
@@ -1827,6 +1872,7 @@ export function buildHeapScene(
|
|
|
1827
1872
|
// ─── Stats (declared early so addDraw/removeDraw can mutate it) ───
|
|
1828
1873
|
const stats: HeapSceneStats = {
|
|
1829
1874
|
groups: 0,
|
|
1875
|
+
slotCount: 0,
|
|
1830
1876
|
totalDraws: 0,
|
|
1831
1877
|
drawBytes: 0,
|
|
1832
1878
|
geometryBytes: 0,
|
|
@@ -1836,6 +1882,14 @@ export function buildHeapScene(
|
|
|
1836
1882
|
derivedRecords: 0,
|
|
1837
1883
|
};
|
|
1838
1884
|
Object.defineProperty(stats, "groups", { get: () => buckets.length, configurable: true });
|
|
1885
|
+
Object.defineProperty(stats, "slotCount", {
|
|
1886
|
+
get: () => {
|
|
1887
|
+
let n = 0;
|
|
1888
|
+
for (const b of buckets) n += b.slots.length;
|
|
1889
|
+
return n;
|
|
1890
|
+
},
|
|
1891
|
+
configurable: true,
|
|
1892
|
+
});
|
|
1839
1893
|
|
|
1840
1894
|
// ─── addDraw / removeDraw ─────────────────────────────────────────
|
|
1841
1895
|
// Public addDraw wrapper. Establishes a sceneObj.evaluateAlways
|
|
@@ -1885,6 +1939,12 @@ export function buildHeapScene(
|
|
|
1885
1939
|
});
|
|
1886
1940
|
}
|
|
1887
1941
|
const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
|
|
1942
|
+
// Phase 5c.2: route this RO to the bucket's slot covering its
|
|
1943
|
+
// current descriptor. ensureSlot creates a new slot on miss; the
|
|
1944
|
+
// pipeline lookup hits the scene-level cache.
|
|
1945
|
+
const roDescriptor = precomputedDescriptor ?? snapshotDescriptor(spec.pipelineState, sig);
|
|
1946
|
+
const roSlot = ensureSlot(bucket, roDescriptor);
|
|
1947
|
+
const roSlotIdx = bucket.slots.indexOf(roSlot);
|
|
1888
1948
|
const fam = familyFor(spec.effect);
|
|
1889
1949
|
const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
|
|
1890
1950
|
|
|
@@ -2022,8 +2082,8 @@ export function buildHeapScene(
|
|
|
2022
2082
|
if (end > bucket.headerDirtyMax) bucket.headerDirtyMax = end;
|
|
2023
2083
|
|
|
2024
2084
|
{
|
|
2025
|
-
const dtBuf =
|
|
2026
|
-
const recIdx =
|
|
2085
|
+
const dtBuf = roSlot.drawTableBuf!;
|
|
2086
|
+
const recIdx = roSlot.recordCount;
|
|
2027
2087
|
if (recIdx >= SCAN_MAX_RECORDS) {
|
|
2028
2088
|
throw new Error(
|
|
2029
2089
|
`heapScene: bucket exceeds SCAN_MAX_RECORDS (${SCAN_MAX_RECORDS}); ` +
|
|
@@ -2035,28 +2095,29 @@ export function buildHeapScene(
|
|
|
2035
2095
|
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
2036
2096
|
// Grow scan-side buffers if recordCount crosses a tile boundary.
|
|
2037
2097
|
const needBlocks = Math.max(1, Math.ceil((recIdx + 1) / SCAN_TILE_SIZE));
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
const shadow =
|
|
2098
|
+
roSlot.blockSumsBuf!.ensureCapacity(needBlocks * 4);
|
|
2099
|
+
roSlot.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
|
|
2100
|
+
const shadow = roSlot.drawTableShadow!;
|
|
2041
2101
|
// firstEmit is GPU-overwritten by the prefix-sum pass; 0 is fine.
|
|
2042
2102
|
shadow[recIdx * RECORD_U32 + 0] = 0;
|
|
2043
2103
|
shadow[recIdx * RECORD_U32 + 1] = localSlot;
|
|
2044
2104
|
shadow[recIdx * RECORD_U32 + 2] = idxAlloc.firstIndex;
|
|
2045
2105
|
shadow[recIdx * RECORD_U32 + 3] = idxAlloc.count;
|
|
2046
2106
|
shadow[recIdx * RECORD_U32 + 4] = instanceCount;
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
if (byteOff <
|
|
2051
|
-
if (byteOff + RECORD_BYTES >
|
|
2052
|
-
|
|
2053
|
-
const newNumTiles = Math.max(1, Math.ceil(
|
|
2054
|
-
|
|
2055
|
-
|
|
2107
|
+
roSlot.recordCount = recIdx + 1;
|
|
2108
|
+
roSlot.slotToRecord[localSlot] = recIdx;
|
|
2109
|
+
roSlot.recordToSlot[recIdx] = localSlot;
|
|
2110
|
+
if (byteOff < roSlot.drawTableDirtyMin) roSlot.drawTableDirtyMin = byteOff;
|
|
2111
|
+
if (byteOff + RECORD_BYTES > roSlot.drawTableDirtyMax) roSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2112
|
+
roSlot.totalEmitEstimate += idxAlloc.count * instanceCount;
|
|
2113
|
+
const newNumTiles = Math.max(1, Math.ceil(roSlot.totalEmitEstimate / TILE_K));
|
|
2114
|
+
roSlot.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
|
|
2115
|
+
roSlot.scanDirty = true;
|
|
2056
2116
|
}
|
|
2057
2117
|
|
|
2058
2118
|
drawIdToBucket[drawId] = bucket;
|
|
2059
2119
|
drawIdToLocalSlot[drawId] = localSlot;
|
|
2120
|
+
drawIdToSlotIdx[drawId] = roSlotIdx;
|
|
2060
2121
|
drawIdToIndexAval[drawId] = indicesAval;
|
|
2061
2122
|
|
|
2062
2123
|
// ─── Reactive rebucket: track PS-modeKey changes ────────────────
|
|
@@ -2097,7 +2158,8 @@ export function buildHeapScene(
|
|
|
2097
2158
|
// the scene-wide dispatcher. Provide the arena byte offset of the
|
|
2098
2159
|
// RO's input uniform so the kernel reads from the right slot.
|
|
2099
2160
|
{
|
|
2100
|
-
const
|
|
2161
|
+
const cullRule = spec.modeRules?.cull;
|
|
2162
|
+
const gpuRule = cullRule?.gpu;
|
|
2101
2163
|
if (gpuRule !== undefined && gpuRule.kernel === "flipCullByDeterminant") {
|
|
2102
2164
|
const inputRef = perDrawRefs.get(gpuRule.inputUniform);
|
|
2103
2165
|
if (inputRef !== undefined) {
|
|
@@ -2116,6 +2178,21 @@ export function buildHeapScene(
|
|
|
2116
2178
|
console.warn(`[heapScene] GPU rule input '${gpuRule.inputUniform}' not in spec.inputs for drawId=${drawId}; rule disabled`);
|
|
2117
2179
|
}
|
|
2118
2180
|
}
|
|
2181
|
+
|
|
2182
|
+
// ─── Pipeline pre-warm for derived-mode rules (5c.2) ──────────
|
|
2183
|
+
// Enumerate the rule's possible outputs and pre-build the
|
|
2184
|
+
// matching pipelines in the scene-level cache. Future cullModeC
|
|
2185
|
+
// flips become cache hits — zero `createRenderPipeline` calls
|
|
2186
|
+
// on the hot path.
|
|
2187
|
+
if (cullRule !== undefined && cullRule.domain !== undefined && tracker.descriptor !== undefined) {
|
|
2188
|
+
const fam = familyFor(spec.effect);
|
|
2189
|
+
const baseDesc = tracker.descriptor;
|
|
2190
|
+
for (const mode of cullRule.domain) {
|
|
2191
|
+
if (mode === baseDesc.cullMode) continue; // already built
|
|
2192
|
+
const variantDesc: PipelineStateDescriptor = { ...baseDesc, cullMode: mode };
|
|
2193
|
+
getOrCreatePipeline(fam, bucket.layout, bucket.isAtlasBucket, variantDesc);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2119
2196
|
}
|
|
2120
2197
|
|
|
2121
2198
|
// ─── §7 derived-uniforms registration ────────────────────────────
|
|
@@ -2188,17 +2265,19 @@ export function buildHeapScene(
|
|
|
2188
2265
|
}
|
|
2189
2266
|
}
|
|
2190
2267
|
{
|
|
2268
|
+
const slotIdx = drawIdToSlotIdx[drawId];
|
|
2269
|
+
const slot = slotIdx !== undefined ? bucket.slots[slotIdx]! : bucket.slots[0]!;
|
|
2191
2270
|
const removedEntry = bucket.localEntries[localSlot];
|
|
2192
2271
|
const removedCount = removedEntry !== undefined
|
|
2193
2272
|
? removedEntry.indexCount * removedEntry.instanceCount
|
|
2194
2273
|
: 0;
|
|
2195
|
-
|
|
2274
|
+
slot.totalEmitEstimate = Math.max(0, slot.totalEmitEstimate - removedCount);
|
|
2196
2275
|
// Swap-pop: move the last record into the freed slot, decrement
|
|
2197
2276
|
// recordCount. firstEmit is GPU-rewritten by the next scan, so
|
|
2198
2277
|
// we only fix (drawIdx, indexStart, indexCount, instanceCount).
|
|
2199
|
-
const recIdx =
|
|
2200
|
-
const lastRecIdx =
|
|
2201
|
-
const shadow =
|
|
2278
|
+
const recIdx = slot.slotToRecord[localSlot]!;
|
|
2279
|
+
const lastRecIdx = slot.recordCount - 1;
|
|
2280
|
+
const shadow = slot.drawTableShadow!;
|
|
2202
2281
|
if (recIdx !== lastRecIdx) {
|
|
2203
2282
|
const dst = recIdx * RECORD_U32;
|
|
2204
2283
|
const src = lastRecIdx * RECORD_U32;
|
|
@@ -2207,17 +2286,17 @@ export function buildHeapScene(
|
|
|
2207
2286
|
shadow[dst + 2] = shadow[src + 2]!;
|
|
2208
2287
|
shadow[dst + 3] = shadow[src + 3]!;
|
|
2209
2288
|
shadow[dst + 4] = shadow[src + 4]!;
|
|
2210
|
-
const movedSlot =
|
|
2211
|
-
|
|
2212
|
-
|
|
2289
|
+
const movedSlot = slot.recordToSlot[lastRecIdx]!;
|
|
2290
|
+
slot.slotToRecord[movedSlot] = recIdx;
|
|
2291
|
+
slot.recordToSlot[recIdx] = movedSlot;
|
|
2213
2292
|
const byteOff = recIdx * RECORD_BYTES;
|
|
2214
|
-
if (byteOff <
|
|
2215
|
-
if (byteOff + RECORD_BYTES >
|
|
2293
|
+
if (byteOff < slot.drawTableDirtyMin) slot.drawTableDirtyMin = byteOff;
|
|
2294
|
+
if (byteOff + RECORD_BYTES > slot.drawTableDirtyMax) slot.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2216
2295
|
}
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2296
|
+
slot.slotToRecord[localSlot] = -1;
|
|
2297
|
+
slot.recordToSlot[lastRecIdx] = -1;
|
|
2298
|
+
slot.recordCount = lastRecIdx;
|
|
2299
|
+
slot.scanDirty = true;
|
|
2221
2300
|
}
|
|
2222
2301
|
|
|
2223
2302
|
// Release pool entries — refcount drops; if zero, allocation freed.
|
|
@@ -2269,6 +2348,7 @@ export function buildHeapScene(
|
|
|
2269
2348
|
|
|
2270
2349
|
drawIdToBucket[drawId] = undefined;
|
|
2271
2350
|
drawIdToLocalSlot[drawId] = undefined;
|
|
2351
|
+
drawIdToSlotIdx[drawId] = undefined;
|
|
2272
2352
|
drawIdToIndexAval[drawId] = undefined;
|
|
2273
2353
|
drawIdToSpec[drawId] = undefined;
|
|
2274
2354
|
const oldTracker = drawIdToModeTracker[drawId];
|
|
@@ -2386,117 +2466,88 @@ export function buildHeapScene(
|
|
|
2386
2466
|
const dirty = [...dirtyModeKeyDrawIds];
|
|
2387
2467
|
dirtyModeKeyDrawIds.clear();
|
|
2388
2468
|
|
|
2389
|
-
//
|
|
2390
|
-
//
|
|
2391
|
-
//
|
|
2392
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
if (arr === undefined) { arr = []; byBucket.set(b, arr); }
|
|
2401
|
-
arr.push(drawId);
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
// Two-phase rebucket: collect all proposed renames first, then
|
|
2405
|
-
// commit them atomically. Avoids the "two buckets swap names"
|
|
2406
|
-
// conflict (e.g. cullModeC flip with both 'back' and 'front'
|
|
2407
|
-
// buckets present — A wants 'front', B wants 'back', they
|
|
2408
|
-
// collide). After phase 1 every fast-path bucket is removed
|
|
2409
|
-
// from bucketByKey, freeing both keys for phase 2's atomic
|
|
2410
|
-
// re-insertion. Slow per-RO rebuckets handle anything that
|
|
2411
|
-
// still conflicts (rare).
|
|
2412
|
-
type Rename = {
|
|
2413
|
-
bucket: Bucket;
|
|
2414
|
-
repId: number;
|
|
2415
|
-
newKey: bigint;
|
|
2416
|
-
newBk: string;
|
|
2417
|
-
oldBk: string;
|
|
2418
|
-
};
|
|
2419
|
-
const renames: Rename[] = [];
|
|
2420
|
-
const slowChanged: number[] = []; // ROs going through per-RO
|
|
2421
|
-
|
|
2422
|
-
for (const [bucket, ids] of byBucket) {
|
|
2423
|
-
const changed: number[] = [];
|
|
2424
|
-
for (const drawId of ids) {
|
|
2425
|
-
const tracker = drawIdToModeTracker[drawId];
|
|
2426
|
-
if (tracker === undefined) continue;
|
|
2427
|
-
const oldKey = tracker.modeKey;
|
|
2428
|
-
if (!tracker.recompute()) continue;
|
|
2429
|
-
if (tracker.modeKey === oldKey) continue;
|
|
2430
|
-
changed.push(drawId);
|
|
2431
|
-
}
|
|
2432
|
-
if (changed.length === 0) continue;
|
|
2433
|
-
|
|
2434
|
-
const wholeBucket = changed.length === bucket.drawSlots.size;
|
|
2435
|
-
if (wholeBucket) {
|
|
2436
|
-
const repId = changed[0]!;
|
|
2437
|
-
const repTracker = drawIdToModeTracker[repId]!;
|
|
2438
|
-
const sample = drawIdToSpec[repId]!;
|
|
2439
|
-
const fam = familyFor(sample.effect);
|
|
2440
|
-
const newKey = repTracker.modeKey;
|
|
2441
|
-
const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
|
|
2442
|
-
let oldBk: string | undefined;
|
|
2443
|
-
for (const [k, v] of bucketByKey) {
|
|
2444
|
-
if (v === bucket) { oldBk = k; break; }
|
|
2445
|
-
}
|
|
2446
|
-
if (oldBk !== undefined && oldBk !== newBk) {
|
|
2447
|
-
renames.push({ bucket, repId, newKey, newBk, oldBk });
|
|
2448
|
-
continue;
|
|
2449
|
-
}
|
|
2450
|
-
if (oldBk === newBk) continue; // shouldn't happen but safe
|
|
2451
|
-
}
|
|
2452
|
-
// Partial transition: per-RO slow path.
|
|
2453
|
-
for (const c of changed) slowChanged.push(c);
|
|
2454
|
-
}
|
|
2469
|
+
// Phase 5c.2 reslot pass. For each dirty drawId whose
|
|
2470
|
+
// tracker.modeKey changed:
|
|
2471
|
+
// 1. Find its bucket + current slot.
|
|
2472
|
+
// 2. ensureSlot for the new modeKey (within the same bucket).
|
|
2473
|
+
// 3. Move the record from oldSlot.drawTable to
|
|
2474
|
+
// newSlot.drawTable (swap-pop + push). Bucket-shared
|
|
2475
|
+
// drawHeap + pool entries stay put.
|
|
2476
|
+
// 4. Update drawIdToSlotIdx.
|
|
2477
|
+
// No bucket rename, no createRenderPipeline (pre-warm + cache
|
|
2478
|
+
// mean ensureSlot is a hit). No removeDraw+addDraw arena
|
|
2479
|
+
// churn.
|
|
2455
2480
|
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2481
|
+
for (const drawId of dirty) {
|
|
2482
|
+
const tracker = drawIdToModeTracker[drawId];
|
|
2483
|
+
if (tracker === undefined) continue;
|
|
2484
|
+
const oldKey = tracker.modeKey;
|
|
2485
|
+
if (!tracker.recompute()) continue;
|
|
2486
|
+
if (tracker.modeKey === oldKey) continue;
|
|
2487
|
+
|
|
2488
|
+
const bucket = drawIdToBucket[drawId];
|
|
2489
|
+
const localSlot = drawIdToLocalSlot[drawId];
|
|
2490
|
+
const oldSlotIdx = drawIdToSlotIdx[drawId];
|
|
2491
|
+
if (bucket === undefined || localSlot === undefined || oldSlotIdx === undefined) continue;
|
|
2492
|
+
|
|
2493
|
+
const newSlot = ensureSlot(bucket, tracker.descriptor);
|
|
2494
|
+
const newSlotIdx = bucket.slots.indexOf(newSlot);
|
|
2495
|
+
if (newSlotIdx === oldSlotIdx) continue;
|
|
2496
|
+
|
|
2497
|
+
// Swap-pop from old slot's drawTable.
|
|
2498
|
+
const oldSlot = bucket.slots[oldSlotIdx]!;
|
|
2499
|
+
const entry = bucket.localEntries[localSlot];
|
|
2500
|
+
const emit = entry !== undefined ? entry.indexCount * entry.instanceCount : 0;
|
|
2501
|
+
const oldRecIdx = oldSlot.slotToRecord[localSlot]!;
|
|
2502
|
+
const lastRecIdx = oldSlot.recordCount - 1;
|
|
2503
|
+
const oldShadow = oldSlot.drawTableShadow!;
|
|
2504
|
+
if (oldRecIdx !== lastRecIdx) {
|
|
2505
|
+
const dst = oldRecIdx * RECORD_U32;
|
|
2506
|
+
const src = lastRecIdx * RECORD_U32;
|
|
2507
|
+
oldShadow[dst + 0] = 0;
|
|
2508
|
+
oldShadow[dst + 1] = oldShadow[src + 1]!;
|
|
2509
|
+
oldShadow[dst + 2] = oldShadow[src + 2]!;
|
|
2510
|
+
oldShadow[dst + 3] = oldShadow[src + 3]!;
|
|
2511
|
+
oldShadow[dst + 4] = oldShadow[src + 4]!;
|
|
2512
|
+
const movedLocal = oldSlot.recordToSlot[lastRecIdx]!;
|
|
2513
|
+
oldSlot.slotToRecord[movedLocal] = oldRecIdx;
|
|
2514
|
+
oldSlot.recordToSlot[oldRecIdx] = movedLocal;
|
|
2515
|
+
const off = oldRecIdx * RECORD_BYTES;
|
|
2516
|
+
if (off < oldSlot.drawTableDirtyMin) oldSlot.drawTableDirtyMin = off;
|
|
2517
|
+
if (off + RECORD_BYTES > oldSlot.drawTableDirtyMax) oldSlot.drawTableDirtyMax = off + RECORD_BYTES;
|
|
2490
2518
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
const
|
|
2499
|
-
|
|
2519
|
+
oldSlot.slotToRecord[localSlot] = -1;
|
|
2520
|
+
oldSlot.recordToSlot[lastRecIdx] = -1;
|
|
2521
|
+
oldSlot.recordCount = lastRecIdx;
|
|
2522
|
+
oldSlot.totalEmitEstimate = Math.max(0, oldSlot.totalEmitEstimate - emit);
|
|
2523
|
+
oldSlot.scanDirty = true;
|
|
2524
|
+
|
|
2525
|
+
// Push into new slot's drawTable.
|
|
2526
|
+
const newRecIdx = newSlot.recordCount;
|
|
2527
|
+
const dtBuf = newSlot.drawTableBuf!;
|
|
2528
|
+
const byteOff = newRecIdx * RECORD_BYTES;
|
|
2529
|
+
dtBuf.ensureCapacity(byteOff + RECORD_BYTES);
|
|
2530
|
+
dtBuf.setUsed(Math.max(dtBuf.usedBytes, byteOff + RECORD_BYTES));
|
|
2531
|
+
const needBlocks = Math.max(1, Math.ceil((newRecIdx + 1) / SCAN_TILE_SIZE));
|
|
2532
|
+
newSlot.blockSumsBuf!.ensureCapacity(needBlocks * 4);
|
|
2533
|
+
newSlot.blockOffsetsBuf!.ensureCapacity(needBlocks * 4);
|
|
2534
|
+
const newShadow = newSlot.drawTableShadow!;
|
|
2535
|
+
newShadow[newRecIdx * RECORD_U32 + 0] = 0;
|
|
2536
|
+
newShadow[newRecIdx * RECORD_U32 + 1] = localSlot;
|
|
2537
|
+
newShadow[newRecIdx * RECORD_U32 + 2] = entry !== undefined ? entry.firstIndex : 0;
|
|
2538
|
+
newShadow[newRecIdx * RECORD_U32 + 3] = entry !== undefined ? entry.indexCount : 0;
|
|
2539
|
+
newShadow[newRecIdx * RECORD_U32 + 4] = entry !== undefined ? entry.instanceCount : 0;
|
|
2540
|
+
newSlot.recordCount = newRecIdx + 1;
|
|
2541
|
+
newSlot.slotToRecord[localSlot] = newRecIdx;
|
|
2542
|
+
newSlot.recordToSlot[newRecIdx] = localSlot;
|
|
2543
|
+
if (byteOff < newSlot.drawTableDirtyMin) newSlot.drawTableDirtyMin = byteOff;
|
|
2544
|
+
if (byteOff + RECORD_BYTES > newSlot.drawTableDirtyMax) newSlot.drawTableDirtyMax = byteOff + RECORD_BYTES;
|
|
2545
|
+
newSlot.totalEmitEstimate += emit;
|
|
2546
|
+
const newNumTiles = Math.max(1, Math.ceil(newSlot.totalEmitEstimate / TILE_K));
|
|
2547
|
+
newSlot.firstDrawInTileBuf!.ensureCapacity((newNumTiles + 1) * 4);
|
|
2548
|
+
newSlot.scanDirty = true;
|
|
2549
|
+
|
|
2550
|
+
drawIdToSlotIdx[drawId] = newSlotIdx;
|
|
2500
2551
|
}
|
|
2501
2552
|
}
|
|
2502
2553
|
|
|
@@ -2617,9 +2668,19 @@ export function buildHeapScene(
|
|
|
2617
2668
|
function encodeIntoPass(pass: GPURenderPassEncoder): void {
|
|
2618
2669
|
let curBg: GPUBindGroup | null = null;
|
|
2619
2670
|
for (const b of buckets) {
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2671
|
+
// Phase 5c.2: iterate the bucket's slots, drawIndirect per
|
|
2672
|
+
// non-empty slot. Empty slots draw zero (skip — saves the
|
|
2673
|
+
// setPipeline/setBindGroup overhead).
|
|
2674
|
+
for (const slot of b.slots) {
|
|
2675
|
+
if (slot.recordCount === 0) continue;
|
|
2676
|
+
const bg = slot.bindGroup;
|
|
2677
|
+
if (bg !== undefined && bg !== curBg) {
|
|
2678
|
+
pass.setBindGroup(0, bg);
|
|
2679
|
+
curBg = bg;
|
|
2680
|
+
}
|
|
2681
|
+
pass.setPipeline(slot.pipeline);
|
|
2682
|
+
if (slot.indirectBuf !== undefined) pass.drawIndirect(slot.indirectBuf, 0);
|
|
2683
|
+
}
|
|
2623
2684
|
}
|
|
2624
2685
|
}
|
|
2625
2686
|
|
|
@@ -2695,38 +2756,42 @@ export function buildHeapScene(
|
|
|
2695
2756
|
stats.derivedRecords = derivedScene.records.recordCount;
|
|
2696
2757
|
}
|
|
2697
2758
|
let anyDirty = false;
|
|
2698
|
-
for (const b of buckets) {
|
|
2759
|
+
outer: for (const b of buckets) {
|
|
2760
|
+
for (const s of b.slots) {
|
|
2761
|
+
if (s.scanDirty) { anyDirty = true; break outer; }
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2699
2764
|
if (!anyDirty) return;
|
|
2700
2765
|
const pass = enc.beginComputePass({ label: "heapScene/scan" });
|
|
2701
2766
|
for (const b of buckets) {
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2767
|
+
for (let i = 0; i < b.slots.length; i++) {
|
|
2768
|
+
const slot = b.slots[i]!;
|
|
2769
|
+
if (!slot.scanDirty) continue;
|
|
2770
|
+
// Rebuild render bind group if recordCount changed — drawTable
|
|
2771
|
+
// binding is sized to recordCount * RECORD_BYTES.
|
|
2772
|
+
if (slot.renderBoundRecordCount !== slot.recordCount) {
|
|
2773
|
+
slot.bindGroup = buildBucketBindGroup(b, i);
|
|
2774
|
+
}
|
|
2775
|
+
const numRecords = slot.recordCount;
|
|
2776
|
+
const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
2777
|
+
device.queue.writeBuffer(
|
|
2778
|
+
slot.paramsBuf!, 0,
|
|
2779
|
+
new Uint32Array([numRecords, numBlocks, 0, 0]),
|
|
2780
|
+
);
|
|
2781
|
+
pass.setBindGroup(0, slot.scanBindGroup!);
|
|
2782
|
+
pass.setPipeline(scanPipeTile!);
|
|
2783
|
+
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2784
|
+
pass.setPipeline(scanPipeBlocks!);
|
|
2785
|
+
pass.dispatchWorkgroups(1, 1, 1);
|
|
2786
|
+
pass.setPipeline(scanPipeAdd!);
|
|
2787
|
+
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2788
|
+
const SCAN_WG_SIZE = 256;
|
|
2789
|
+
const numTilesCap = Math.max(1, Math.ceil(slot.totalEmitEstimate / TILE_K));
|
|
2790
|
+
const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
|
|
2791
|
+
pass.setPipeline(scanPipeBuildTileIndex!);
|
|
2792
|
+
pass.dispatchWorkgroups(tileWgs, 1, 1);
|
|
2793
|
+
slot.scanDirty = false;
|
|
2707
2794
|
}
|
|
2708
|
-
const numRecords = b.slots[0]!.recordCount;
|
|
2709
|
-
const numBlocks = Math.max(1, Math.ceil(numRecords / SCAN_TILE_SIZE));
|
|
2710
|
-
device.queue.writeBuffer(
|
|
2711
|
-
b.slots[0]!.paramsBuf!, 0,
|
|
2712
|
-
new Uint32Array([numRecords, numBlocks, 0, 0]),
|
|
2713
|
-
);
|
|
2714
|
-
pass.setBindGroup(0, b.slots[0]!.scanBindGroup!);
|
|
2715
|
-
pass.setPipeline(scanPipeTile!);
|
|
2716
|
-
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2717
|
-
pass.setPipeline(scanPipeBlocks!);
|
|
2718
|
-
pass.dispatchWorkgroups(1, 1, 1);
|
|
2719
|
-
pass.setPipeline(scanPipeAdd!);
|
|
2720
|
-
pass.dispatchWorkgroups(numBlocks, 1, 1);
|
|
2721
|
-
// numTiles is computed on GPU from indirect[0]; CPU dispatch
|
|
2722
|
-
// must cover the worst-case totalEmit. Each WG handles WG_SIZE
|
|
2723
|
-
// tiles; +1 for the sentinel slot.
|
|
2724
|
-
const SCAN_WG_SIZE = 256;
|
|
2725
|
-
const numTilesCap = Math.max(1, Math.ceil(b.slots[0]!.totalEmitEstimate / TILE_K));
|
|
2726
|
-
const tileWgs = Math.max(1, Math.ceil((numTilesCap + 1) / SCAN_WG_SIZE));
|
|
2727
|
-
pass.setPipeline(scanPipeBuildTileIndex!);
|
|
2728
|
-
pass.dispatchWorkgroups(tileWgs, 1, 1);
|
|
2729
|
-
b.slots[0]!.scanDirty = false;
|
|
2730
2795
|
}
|
|
2731
2796
|
pass.end();
|
|
2732
2797
|
}
|