@aardworx/wombat.rendering 0.9.15 → 0.9.17

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.
@@ -55,6 +55,7 @@ import { GrowBuffer, MIN_BUFFER_BYTES, POW2, ALIGN16, } from "./heapScene/growBu
55
55
  import { UniformPool, IndexPool, DrawHeap, AttributeArena, IndexAllocator, insertSortedFreeBlock, buildArenaState, arenaBytes, writeAttribute, asAval, isBufferView, asFloat32, ALLOC_HEADER_BYTES, ALLOC_HEADER_PAD_TO, ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS, } from "./heapScene/pools.js";
56
56
  import { encodeModeKey } from "./pipelineCache/index.js";
57
57
  import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
58
+ import { addMarkingCallback } from "@aardworx/wombat.adaptive";
58
59
  export function buildHeapScene(device, sig, initialDraws, opts = {}) {
59
60
  const atlasPool = opts.atlasPool;
60
61
  const colorAttachmentName = sig.colorNames[0];
@@ -94,11 +95,48 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
94
95
  * references.
95
96
  */
96
97
  const drawIdToSpec = [];
97
- /** Per-draw ModeKeyTracker — subscribes to PS aval marks, recomputes
98
- * the modeKey, schedules a rebucket if it changed. */
98
+ /** Per-draw ModeKeyTracker — recomputes the modeKey on demand. Does
99
+ * NOT install per-instance marking callbacks; subscriptions are
100
+ * scene-level (deduped by aval identity below). */
99
101
  const drawIdToModeTracker = [];
100
102
  /** drawIds whose PS marked since last update; drained by `update`. */
101
103
  const dirtyModeKeyDrawIds = new Set();
104
+ /**
105
+ * Scene-level mode-aval subscription dedupe. One IDisposable per
106
+ * unique leaf aval, plus a Set of dependent drawIds. When the aval
107
+ * marks, all drawIds in the set become dirty in O(1) per RO instead
108
+ * of installing N separate addMarkingCallbacks. The big win for the
109
+ * shared-cullCval case (20k ROs subscribing to one cval → 1 callback
110
+ * instead of 20k).
111
+ */
112
+ const modeAvalToDrawIds = new Map();
113
+ const modeAvalCallbacks = new Map();
114
+ function subscribeModeLeaf(av, drawId) {
115
+ let set = modeAvalToDrawIds.get(av);
116
+ if (set === undefined) {
117
+ set = new Set();
118
+ modeAvalToDrawIds.set(av, set);
119
+ modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
120
+ for (const id of set)
121
+ dirtyModeKeyDrawIds.add(id);
122
+ }));
123
+ }
124
+ set.add(drawId);
125
+ }
126
+ function unsubscribeModeLeaf(av, drawId) {
127
+ const set = modeAvalToDrawIds.get(av);
128
+ if (set === undefined)
129
+ return;
130
+ set.delete(drawId);
131
+ if (set.size === 0) {
132
+ modeAvalToDrawIds.delete(av);
133
+ const cb = modeAvalCallbacks.get(av);
134
+ if (cb !== undefined) {
135
+ cb.dispose();
136
+ modeAvalCallbacks.delete(av);
137
+ }
138
+ }
139
+ }
102
140
  let nextDrawId = 0;
103
141
  /**
104
142
  * Unwrap an aval to its inner value, or pass through a plain value.
@@ -853,7 +891,14 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
853
891
  // right per-effect helper at draw time. Atlas-binding shape follows
854
892
  // from `family.drawHeaderUnion.atlasTextureBindings.size > 0`.
855
893
  void texIdOf; // retained for future per-bucket diagnostics
856
- function findOrCreateBucket(effect, _textures, pipelineState) {
894
+ function findOrCreateBucket(effect, _textures, pipelineState,
895
+ /**
896
+ * Optional precomputed descriptor. addDrawImpl passes this when
897
+ * the spec carries `modeRules` so the bucket key reflects the
898
+ * rule-evaluated value (not the raw PS aval values). When omitted
899
+ * the descriptor is snapshotted from PS as before.
900
+ */
901
+ precomputedDescriptor) {
857
902
  if (!familyBuilt) {
858
903
  throw new Error("heapScene: findOrCreateBucket called before family build");
859
904
  }
@@ -866,13 +911,25 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
866
911
  // (the actual value-set in the descriptor), so identical-value
867
912
  // cvals collapse to ONE bucket. See docs/derived-modes.md and
868
913
  // tests/heap-multi-pipeline-bucket.test.ts.
869
- const psDescriptor = snapshotDescriptor(pipelineState, sig);
914
+ const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
870
915
  const psModeKey = encodeModeKey(psDescriptor);
871
916
  const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
872
917
  const existing = bucketByKey.get(bk);
873
918
  if (existing !== undefined)
874
919
  return existing;
875
- const ps = resolvePipelineState(pipelineState);
920
+ // If a precomputed descriptor is supplied (rule-evaluated path),
921
+ // use its post-rule axis values so the pipeline reflects what the
922
+ // RO actually wants. Otherwise fall through to PS-aval forcing.
923
+ const ps = precomputedDescriptor !== undefined
924
+ ? {
925
+ topology: precomputedDescriptor.topology,
926
+ cullMode: precomputedDescriptor.cullMode,
927
+ frontFace: precomputedDescriptor.frontFace,
928
+ ...(precomputedDescriptor.depth !== undefined
929
+ ? { depth: { write: precomputedDescriptor.depth.write, compare: precomputedDescriptor.depth.compare } }
930
+ : {}),
931
+ }
932
+ : resolvePipelineState(pipelineState);
876
933
  const layout = fam.schema.drawHeaderUnion;
877
934
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
878
935
  const vsModule = fam.vsModule;
@@ -1147,7 +1204,23 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1147
1204
  const perInstanceAttributes = spec.instanceAttributes !== undefined
1148
1205
  ? new Set(Object.keys(spec.instanceAttributes))
1149
1206
  : new Set();
1150
- const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState);
1207
+ // If the spec has modeRules, pre-snapshot the rule-evaluated
1208
+ // descriptor so the bucket key + pipeline reflect post-rule
1209
+ // values. Without this, two ROs with identical PS but different
1210
+ // rule outputs would collide on the same bucket.
1211
+ let precomputedDescriptor;
1212
+ if (spec.modeRules !== undefined) {
1213
+ const uAvals = new Map();
1214
+ for (const [name, val] of Object.entries(spec.inputs)) {
1215
+ uAvals.set(name, asAval(val));
1216
+ }
1217
+ precomputedDescriptor = snapshotDescriptor(spec.pipelineState, sig, {
1218
+ modeRules: spec.modeRules,
1219
+ uniformAvals: uAvals,
1220
+ token: outerTok,
1221
+ });
1222
+ }
1223
+ const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
1151
1224
  const fam = familyFor(spec.effect);
1152
1225
  const effectFields = fam.fieldsForEffect.get(spec.effect.id);
1153
1226
  // Indices live in their own INDEX-usage buffer (WebGPU constraint).
@@ -1316,10 +1389,27 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1316
1389
  // value must move the RO to a different bucket. Without this
1317
1390
  // tracker the cval flip would be silently ignored.
1318
1391
  drawIdToSpec[drawId] = spec;
1319
- const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => {
1320
- dirtyModeKeyDrawIds.add(drawId);
1392
+ // skipSubscribe: this tracker doesn't install its own callbacks.
1393
+ // The scene-level `modeAvalToDrawIds` does it once per unique aval
1394
+ // (the 20k-ROs-share-one-cullCval optimization).
1395
+ // If the spec has modeRules, pre-build a uniforms-aval map the
1396
+ // rule closures will read against; the tracker auto-discovers
1397
+ // which avals each rule reads and surfaces them via forEachLeaf.
1398
+ let uniformAvals;
1399
+ if (spec.modeRules !== undefined) {
1400
+ const m = new Map();
1401
+ for (const [name, val] of Object.entries(spec.inputs)) {
1402
+ m.set(name, asAval(val));
1403
+ }
1404
+ uniformAvals = m;
1405
+ }
1406
+ const tracker = new ModeKeyTracker(spec.pipelineState, sig, () => { dirtyModeKeyDrawIds.add(drawId); }, {
1407
+ skipSubscribe: true,
1408
+ ...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
1409
+ ...(uniformAvals !== undefined ? { uniformAvals } : {}),
1321
1410
  });
1322
1411
  drawIdToModeTracker[drawId] = tracker;
1412
+ tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
1323
1413
  // ─── §7 derived-uniforms registration ────────────────────────────
1324
1414
  // A uniform binding on this RO is either a value (aval/constant) or a rule —
1325
1415
  // collect the rules (explicit `derivedUniform(...)` values + standard trafo
@@ -1482,8 +1572,10 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1482
1572
  drawIdToIndexAval[drawId] = undefined;
1483
1573
  drawIdToSpec[drawId] = undefined;
1484
1574
  const oldTracker = drawIdToModeTracker[drawId];
1485
- if (oldTracker !== undefined)
1575
+ if (oldTracker !== undefined) {
1576
+ oldTracker.forEachLeaf((av) => unsubscribeModeLeaf(av, drawId));
1486
1577
  oldTracker.dispose();
1578
+ }
1487
1579
  drawIdToModeTracker[drawId] = undefined;
1488
1580
  dirtyModeKeyDrawIds.delete(drawId);
1489
1581
  stats.totalDraws--;
@@ -1592,24 +1684,100 @@ export function buildHeapScene(device, sig, initialDraws, opts = {}) {
1592
1684
  if (dirtyModeKeyDrawIds.size > 0) {
1593
1685
  const dirty = [...dirtyModeKeyDrawIds];
1594
1686
  dirtyModeKeyDrawIds.clear();
1687
+ // Bucket-level fast path: group dirty drawIds by bucket. If
1688
+ // ALL of a bucket's ROs are dirty (typical SG case where one
1689
+ // shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
1690
+ // wrapping 20k leaves), the whole bucket transitions together.
1691
+ // Rebuild the bucket's pipeline + rename in bucketByKey,
1692
+ // O(1) work instead of N remove+add cycles.
1693
+ const byBucket = new Map();
1595
1694
  for (const drawId of dirty) {
1596
- const tracker = drawIdToModeTracker[drawId];
1597
- if (tracker === undefined)
1695
+ const b = drawIdToBucket[drawId];
1696
+ if (b === undefined)
1598
1697
  continue; // RO removed since mark
1599
- const oldKey = tracker.modeKey;
1600
- if (!tracker.recompute())
1601
- continue; // value unchanged
1602
- if (tracker.modeKey === oldKey)
1603
- continue; // belt + braces
1604
- const spec = drawIdToSpec[drawId];
1605
- if (spec === undefined)
1698
+ let arr = byBucket.get(b);
1699
+ if (arr === undefined) {
1700
+ arr = [];
1701
+ byBucket.set(b, arr);
1702
+ }
1703
+ arr.push(drawId);
1704
+ }
1705
+ for (const [bucket, ids] of byBucket) {
1706
+ // First recompute every dirty tracker so .modeKey is fresh.
1707
+ // Filter out ones whose value didn't actually change.
1708
+ const changed = [];
1709
+ for (const drawId of ids) {
1710
+ const tracker = drawIdToModeTracker[drawId];
1711
+ if (tracker === undefined)
1712
+ continue;
1713
+ const oldKey = tracker.modeKey;
1714
+ if (!tracker.recompute())
1715
+ continue;
1716
+ if (tracker.modeKey === oldKey)
1717
+ continue;
1718
+ changed.push(drawId);
1719
+ }
1720
+ if (changed.length === 0)
1606
1721
  continue;
1607
- removeDraw(drawId);
1608
- const newId = addDrawImpl(spec, tok);
1609
- // The drawId returned by addDraw is a fresh slot; we don't
1610
- // attempt to preserve the caller's id mapping in v1. v2
1611
- // could thread the original id through if needed.
1612
- void newId;
1722
+ // Try the all-together fast path.
1723
+ const wholeBucket = changed.length === bucket.drawSlots.size;
1724
+ if (wholeBucket) {
1725
+ const repId = changed[0];
1726
+ const repTracker = drawIdToModeTracker[repId];
1727
+ const newKey = repTracker.modeKey;
1728
+ // Compute the new bucketByKey string; we need the family.
1729
+ const sample = drawIdToSpec[repId];
1730
+ const fam = familyFor(sample.effect);
1731
+ const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
1732
+ const existing = bucketByKey.get(newBk);
1733
+ if (existing === undefined) {
1734
+ // No conflict — rename + rebuild pipeline in place.
1735
+ // Find this bucket's current key by linear scan
1736
+ // (buckets ≤ ~100 typically, so this is cheap).
1737
+ let oldBk;
1738
+ for (const [k, v] of bucketByKey) {
1739
+ if (v === bucket) {
1740
+ oldBk = k;
1741
+ break;
1742
+ }
1743
+ }
1744
+ if (oldBk !== undefined)
1745
+ bucketByKey.delete(oldBk);
1746
+ bucketByKey.set(newBk, bucket);
1747
+ // Build the new pipeline with the snapshotted PS.
1748
+ const desc = repTracker.descriptor;
1749
+ const { pipelineLayout } = getBgl(bucket.layout, bucket.isAtlasBucket);
1750
+ const newPipeline = device.createRenderPipeline({
1751
+ label: `heapScene/${newBk}/pipeline`,
1752
+ layout: pipelineLayout,
1753
+ vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
1754
+ fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
1755
+ primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
1756
+ ...(depthFormat !== undefined && desc.depth !== undefined
1757
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
1758
+ : depthFormat !== undefined
1759
+ ? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" } }
1760
+ : {}),
1761
+ });
1762
+ // Swap pipeline reference on the bucket's single slot.
1763
+ // (Multi-slot Phase 5c would update one slot at a time.)
1764
+ bucket.slots[0].pipeline = newPipeline;
1765
+ continue; // done with this bucket
1766
+ }
1767
+ // Conflict: another bucket already owns newBk. Fall through
1768
+ // to per-RO rebucket below, which will merge into `existing`.
1769
+ }
1770
+ // Slow path — per-RO rebucket (used when partial transition
1771
+ // OR when a merge with an existing destination bucket is
1772
+ // required).
1773
+ for (const drawId of changed) {
1774
+ const spec = drawIdToSpec[drawId];
1775
+ if (spec === undefined)
1776
+ continue;
1777
+ removeDraw(drawId);
1778
+ const newId = addDrawImpl(spec, tok);
1779
+ void newId;
1780
+ }
1613
1781
  }
1614
1782
  }
1615
1783
  // 1. Pool: re-pack any aval whose value changed since last frame.