@aardworx/wombat.rendering 0.9.14 → 0.9.16

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.
Files changed (65) hide show
  1. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +62 -0
  2. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
  3. package/dist/runtime/derivedModes/modeKeyCpu.js +225 -0
  4. package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -0
  5. package/dist/runtime/derivedModes/partition.d.ts +36 -0
  6. package/dist/runtime/derivedModes/partition.d.ts.map +1 -0
  7. package/dist/runtime/derivedModes/partition.js +170 -0
  8. package/dist/runtime/derivedModes/partition.js.map +1 -0
  9. package/dist/runtime/derivedModes/slotTable.d.ts +70 -0
  10. package/dist/runtime/derivedModes/slotTable.d.ts.map +1 -0
  11. package/dist/runtime/derivedModes/slotTable.js +130 -0
  12. package/dist/runtime/derivedModes/slotTable.js.map +1 -0
  13. package/dist/runtime/heapScene/growBuffer.d.ts +33 -0
  14. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -0
  15. package/dist/runtime/heapScene/growBuffer.js +76 -0
  16. package/dist/runtime/heapScene/growBuffer.js.map +1 -0
  17. package/dist/runtime/heapScene/packers.d.ts +25 -0
  18. package/dist/runtime/heapScene/packers.d.ts.map +1 -0
  19. package/dist/runtime/heapScene/packers.js +111 -0
  20. package/dist/runtime/heapScene/packers.js.map +1 -0
  21. package/dist/runtime/heapScene/pools.d.ts +181 -0
  22. package/dist/runtime/heapScene/pools.d.ts.map +1 -0
  23. package/dist/runtime/heapScene/pools.js +417 -0
  24. package/dist/runtime/heapScene/pools.js.map +1 -0
  25. package/dist/runtime/heapScene/scanKernel.d.ts +11 -0
  26. package/dist/runtime/heapScene/scanKernel.d.ts.map +1 -0
  27. package/dist/runtime/heapScene/scanKernel.js +181 -0
  28. package/dist/runtime/heapScene/scanKernel.js.map +1 -0
  29. package/dist/runtime/heapScene.d.ts.map +1 -1
  30. package/dist/runtime/heapScene.js +346 -937
  31. package/dist/runtime/heapScene.js.map +1 -1
  32. package/dist/runtime/index.d.ts +4 -0
  33. package/dist/runtime/index.d.ts.map +1 -1
  34. package/dist/runtime/index.js +5 -0
  35. package/dist/runtime/index.js.map +1 -1
  36. package/dist/runtime/pipelineCache/bitfield.d.ts +5 -0
  37. package/dist/runtime/pipelineCache/bitfield.d.ts.map +1 -0
  38. package/dist/runtime/pipelineCache/bitfield.js +201 -0
  39. package/dist/runtime/pipelineCache/bitfield.js.map +1 -0
  40. package/dist/runtime/pipelineCache/cache.d.ts +36 -0
  41. package/dist/runtime/pipelineCache/cache.d.ts.map +1 -0
  42. package/dist/runtime/pipelineCache/cache.js +108 -0
  43. package/dist/runtime/pipelineCache/cache.js.map +1 -0
  44. package/dist/runtime/pipelineCache/descriptor.d.ts +58 -0
  45. package/dist/runtime/pipelineCache/descriptor.d.ts.map +1 -0
  46. package/dist/runtime/pipelineCache/descriptor.js +100 -0
  47. package/dist/runtime/pipelineCache/descriptor.js.map +1 -0
  48. package/dist/runtime/pipelineCache/index.d.ts +5 -0
  49. package/dist/runtime/pipelineCache/index.d.ts.map +1 -0
  50. package/dist/runtime/pipelineCache/index.js +12 -0
  51. package/dist/runtime/pipelineCache/index.js.map +1 -0
  52. package/package.json +1 -1
  53. package/src/runtime/derivedModes/modeKeyCpu.ts +251 -0
  54. package/src/runtime/derivedModes/partition.ts +206 -0
  55. package/src/runtime/derivedModes/slotTable.ts +153 -0
  56. package/src/runtime/heapScene/growBuffer.ts +75 -0
  57. package/src/runtime/heapScene/packers.ts +127 -0
  58. package/src/runtime/heapScene/pools.ts +492 -0
  59. package/src/runtime/heapScene/scanKernel.ts +184 -0
  60. package/src/runtime/heapScene.ts +397 -1064
  61. package/src/runtime/index.ts +40 -0
  62. package/src/runtime/pipelineCache/bitfield.ts +225 -0
  63. package/src/runtime/pipelineCache/cache.ts +129 -0
  64. package/src/runtime/pipelineCache/descriptor.ts +150 -0
  65. package/src/runtime/pipelineCache/index.ts +37 -0
@@ -0,0 +1,251 @@
1
+ // Per-RO modeKey production (CPU side).
2
+ //
3
+ // Task 1 of the derived-modes work: turn a `PipelineState` (the
4
+ // aval-shaped struct from core/pipelineState.ts) plus the surrounding
5
+ // `FramebufferSignature` into a canonical `PipelineStateDescriptor`
6
+ // and its bitfield-encoded modeKey. Subscribes to every input aval
7
+ // so that mutating a `cval` for cullMode / blendFactor / etc. fires
8
+ // a dirty callback, the bucket flushes the changed RO's modeKey to
9
+ // GPU on the next frame, and the partition kernel re-buckets the
10
+ // record into its new slot. No rule-IR machinery yet — Task 2 layers
11
+ // `derivedMode(...)` on top of this.
12
+
13
+ import type {
14
+ DepthState,
15
+ PipelineState,
16
+ RasterizerState,
17
+ StencilState,
18
+ BlendState as PsBlendState,
19
+ BlendComponentState as PsBlendComponentState,
20
+ } from "../../core/pipelineState.js";
21
+ import type { FramebufferSignature } from "../../core/framebufferSignature.js";
22
+ import type { aval, IDisposable } from "@aardworx/wombat.adaptive";
23
+ import { addMarkingCallback } from "@aardworx/wombat.adaptive";
24
+
25
+ import {
26
+ encodeModeKey,
27
+ DEFAULT_DESCRIPTOR,
28
+ DEFAULT_ATTACHMENT_BLEND,
29
+ type PipelineStateDescriptor,
30
+ type AttachmentBlend,
31
+ type BlendComponent,
32
+ type DepthSlice,
33
+ } from "../pipelineCache/index.js";
34
+
35
+ /**
36
+ * One-shot snapshot of the current values of every leaf aval in a
37
+ * `PipelineState`, mapped onto a `PipelineStateDescriptor` whose
38
+ * `attachments` array follows the framebuffer's `colorNames` order
39
+ * (missing entries default to non-blended / 0xF writeMask, matching
40
+ * `BLEND_DEFAULT` semantics in heapScene).
41
+ */
42
+ export function snapshotDescriptor(
43
+ ps: PipelineState | undefined,
44
+ signature: FramebufferSignature,
45
+ ): PipelineStateDescriptor {
46
+ if (ps === undefined) {
47
+ return buildAttachmentsFor(DEFAULT_DESCRIPTOR, signature);
48
+ }
49
+ const r = ps.rasterizer;
50
+ const depth = ps.depth !== undefined ? snapshotDepth(ps.depth, signature) : undefined;
51
+ const atc = ps.alphaToCoverage !== undefined ? ps.alphaToCoverage.force(/* allow-force */) : false;
52
+ const blends = ps.blends !== undefined ? ps.blends.force(/* allow-force */) : undefined;
53
+ const attachments: AttachmentBlend[] = signature.colorNames.map((name) => {
54
+ const b = blends?.tryFind(name);
55
+ return b !== undefined ? snapshotAttachment(b) : DEFAULT_ATTACHMENT_BLEND;
56
+ });
57
+ const out: PipelineStateDescriptor = {
58
+ topology: r.topology.force(/* allow-force */),
59
+ stripIndexFormat: stripFormatFor(r.topology.force(/* allow-force */)),
60
+ frontFace: r.frontFace.force(/* allow-force */),
61
+ cullMode: r.cullMode.force(/* allow-force */),
62
+ ...(depth !== undefined ? { depth } : {}),
63
+ attachments,
64
+ alphaToCoverage: atc,
65
+ };
66
+ return out;
67
+ }
68
+
69
+ function buildAttachmentsFor(
70
+ base: PipelineStateDescriptor,
71
+ signature: FramebufferSignature,
72
+ ): PipelineStateDescriptor {
73
+ // When `ps` is undefined, every named attachment uses defaults.
74
+ return {
75
+ ...base,
76
+ attachments: signature.colorNames.map(() => DEFAULT_ATTACHMENT_BLEND),
77
+ };
78
+ }
79
+
80
+ function snapshotDepth(d: DepthState, signature: FramebufferSignature): DepthSlice | undefined {
81
+ if (signature.depthStencil === undefined) return undefined;
82
+ return {
83
+ write: d.write.force(/* allow-force */),
84
+ compare: d.compare.force(/* allow-force */),
85
+ clamp: d.clamp !== undefined ? d.clamp.force(/* allow-force */) : false,
86
+ };
87
+ }
88
+
89
+ function snapshotAttachment(b: PsBlendState): AttachmentBlend {
90
+ const color = snapshotBlendComponent(b.color);
91
+ const alpha = snapshotBlendComponent(b.alpha);
92
+ const writeMask = b.writeMask.force(/* allow-force */) & 0xF;
93
+ // "Blend enabled" derives from whether the components differ from
94
+ // the no-op (src=one, dst=zero, op=add) shape. This mirrors WebGPU's
95
+ // contract: a blend descriptor MUST be present for blending to occur,
96
+ // and the no-op shape is the canonical disabled state.
97
+ const enabled =
98
+ !(color.srcFactor === "one" && color.dstFactor === "zero" && color.operation === "add" &&
99
+ alpha.srcFactor === "one" && alpha.dstFactor === "zero" && alpha.operation === "add");
100
+ return { enabled, color, alpha, writeMask };
101
+ }
102
+
103
+ function snapshotBlendComponent(c: PsBlendComponentState): BlendComponent {
104
+ return {
105
+ srcFactor: c.srcFactor.force(/* allow-force */),
106
+ dstFactor: c.dstFactor.force(/* allow-force */),
107
+ operation: c.operation.force(/* allow-force */),
108
+ };
109
+ }
110
+
111
+ function stripFormatFor(topology: GPUPrimitiveTopology): GPUIndexFormat | undefined {
112
+ return topology === "line-strip" || topology === "triangle-strip" ? "uint32" : undefined;
113
+ }
114
+
115
+ // ─── Reactive tracker ──────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Subscribes to every leaf aval in a `PipelineState` and invokes
119
+ * `onDirty` when any of them marks. Use one tracker per RO; call
120
+ * `recompute()` after a dirty signal to refresh the descriptor +
121
+ * modeKey, and `dispose()` when the RO is removed.
122
+ *
123
+ * Reactive cvals are the typical case; constant avals also "subscribe"
124
+ * — `addMarkingCallback` is a no-op for an aval that never marks, so
125
+ * no special-casing required.
126
+ */
127
+ export class ModeKeyTracker implements IDisposable {
128
+ readonly ps: PipelineState | undefined;
129
+ readonly signature: FramebufferSignature;
130
+ private readonly onDirty: () => void;
131
+ private readonly subs: IDisposable[] = [];
132
+ private cachedDescriptor: PipelineStateDescriptor;
133
+ private cachedModeKey: bigint;
134
+
135
+ constructor(
136
+ ps: PipelineState | undefined,
137
+ signature: FramebufferSignature,
138
+ onDirty: () => void,
139
+ /**
140
+ * Skip the per-instance addMarkingCallback subscriptions. The caller
141
+ * is responsible for invoking `onDirty` (or recomputing directly)
142
+ * when an input aval marks. Use this when the heap scene shares one
143
+ * subscription across N tracker instances (the 20k-ROs-share-one-
144
+ * cullCval case) — saves N subscriptions and N callback dispatches
145
+ * per mark.
146
+ */
147
+ options: { skipSubscribe?: boolean } = {},
148
+ ) {
149
+ this.ps = ps;
150
+ this.signature = signature;
151
+ this.onDirty = onDirty;
152
+ this.cachedDescriptor = snapshotDescriptor(ps, signature);
153
+ this.cachedModeKey = encodeModeKey(this.cachedDescriptor);
154
+ if (options.skipSubscribe !== true) this.subscribeAll();
155
+ }
156
+
157
+ /**
158
+ * Walk every leaf aval in this tracker's PipelineState and invoke
159
+ * `visit(aval)`. Used by the heap scene to register ONE
160
+ * addMarkingCallback per unique aval across many trackers.
161
+ */
162
+ forEachLeaf(visit: (a: aval<unknown>) => void): void {
163
+ if (this.ps === undefined) return;
164
+ const ps = this.ps;
165
+ visit(ps.rasterizer.topology);
166
+ visit(ps.rasterizer.cullMode);
167
+ visit(ps.rasterizer.frontFace);
168
+ if (ps.rasterizer.depthBias !== undefined) visit(ps.rasterizer.depthBias);
169
+ if (ps.depth !== undefined) {
170
+ visit(ps.depth.write);
171
+ visit(ps.depth.compare);
172
+ if (ps.depth.clamp !== undefined) visit(ps.depth.clamp);
173
+ }
174
+ if (ps.blends !== undefined) {
175
+ visit(ps.blends);
176
+ const map = ps.blends.force(/* allow-force */);
177
+ for (const [, bs] of map) {
178
+ visit(bs.color.srcFactor); visit(bs.color.dstFactor); visit(bs.color.operation);
179
+ visit(bs.alpha.srcFactor); visit(bs.alpha.dstFactor); visit(bs.alpha.operation);
180
+ visit(bs.writeMask);
181
+ }
182
+ }
183
+ if (ps.alphaToCoverage !== undefined) visit(ps.alphaToCoverage);
184
+ }
185
+
186
+ get descriptor(): PipelineStateDescriptor { return this.cachedDescriptor; }
187
+ get modeKey(): bigint { return this.cachedModeKey; }
188
+
189
+ /**
190
+ * Snapshot current aval values and rebuild descriptor + modeKey.
191
+ * Returns true iff the modeKey changed (so the bucket only needs to
192
+ * upload + repartition when something actually moved).
193
+ */
194
+ recompute(): boolean {
195
+ const next = snapshotDescriptor(this.ps, this.signature);
196
+ const nextKey = encodeModeKey(next);
197
+ if (nextKey === this.cachedModeKey) return false;
198
+ this.cachedDescriptor = next;
199
+ this.cachedModeKey = nextKey;
200
+ return true;
201
+ }
202
+
203
+ dispose(): void {
204
+ for (const s of this.subs) s.dispose();
205
+ this.subs.length = 0;
206
+ }
207
+
208
+ private subscribeAll(): void {
209
+ if (this.ps === undefined) return;
210
+ const ps = this.ps;
211
+ this.sub(ps.rasterizer.topology);
212
+ this.sub(ps.rasterizer.cullMode);
213
+ this.sub(ps.rasterizer.frontFace);
214
+ if (ps.rasterizer.depthBias !== undefined) this.sub(ps.rasterizer.depthBias);
215
+ if (ps.depth !== undefined) {
216
+ this.sub(ps.depth.write);
217
+ this.sub(ps.depth.compare);
218
+ if (ps.depth.clamp !== undefined) this.sub(ps.depth.clamp);
219
+ }
220
+ if (ps.stencil !== undefined) {
221
+ // Stencil enabled/reference/masks contribute to neither modeKey
222
+ // nor cache key (v1 carves stencil out — only the "enabled"
223
+ // flag matters and we surface it via snapshotDescriptor). We
224
+ // still subscribe so callers can repackage if a future change
225
+ // flips the enable bit.
226
+ this.subStencilFaces(ps.stencil);
227
+ }
228
+ if (ps.blends !== undefined) {
229
+ this.sub(ps.blends);
230
+ const map = ps.blends.force(/* allow-force */);
231
+ for (const [, bs] of map) this.subBlendState(bs);
232
+ }
233
+ if (ps.alphaToCoverage !== undefined) this.sub(ps.alphaToCoverage);
234
+ // blendConstant is dynamic state; not in the modeKey, no subscription needed.
235
+ }
236
+
237
+ private sub(a: aval<unknown>): void {
238
+ this.subs.push(addMarkingCallback(a, this.onDirty));
239
+ }
240
+
241
+ private subBlendState(b: PsBlendState): void {
242
+ this.sub(b.color.srcFactor); this.sub(b.color.dstFactor); this.sub(b.color.operation);
243
+ this.sub(b.alpha.srcFactor); this.sub(b.alpha.dstFactor); this.sub(b.alpha.operation);
244
+ this.sub(b.writeMask);
245
+ }
246
+
247
+ private subStencilFaces(_s: StencilState): void {
248
+ // v1: stencil isn't in the modeKey; deliberately not subscribing
249
+ // to face avals here. Wire up in v2 when stencil enters the key.
250
+ }
251
+ }
@@ -0,0 +1,206 @@
1
+ // Per-frame partition kernel — turns per-RO slot assignments into
2
+ // per-slot draw metadata (record lists + cumulative emit prefix sums
3
+ // + total indexCount × instanceCount per slot).
4
+ //
5
+ // Each bucket runs this kernel once per frame (dirty-gated). Output
6
+ // feeds one `drawIndirect` per slot in the encode loop. Records routed
7
+ // to the same slot share an indirect call; records in different slots
8
+ // land in separate `setPipeline + drawIndirect` calls.
9
+ //
10
+ // The CPU function `partitionCPU` is the algorithmic reference and
11
+ // the basis for unit tests. The WGSL kernel below mirrors the same
12
+ // algorithm using two compute passes (histogram + scatter), with an
13
+ // exclusive prefix scan on the histogram in between.
14
+ //
15
+ // Layout choices:
16
+ //
17
+ // - Records are NOT physically re-sorted. Instead, the kernel writes
18
+ // a permuted index list `slotRecords[]` (one u32 per record), plus
19
+ // per-slot offsets `slotOffsets[]` and counts `slotCounts[]`. The
20
+ // existing drawTable stays put — the VS prelude does one extra
21
+ // read of `slotRecords[…]` to map from "drawIndex within slot"
22
+ // back to "drawIndex in the full drawTable."
23
+ // - `slotEmitPrefix[]` is the slot-local prefix-sum of
24
+ // `record.indexCount * record.instanceCount`. The VS prelude
25
+ // binary-searches THIS (per-slot) instead of the global table.
26
+ // - `slotTotalEmit[]` is the dispatched vertex count for that
27
+ // slot's drawIndirect.
28
+ //
29
+ // Buffers grow lazily; the partition wrapper resizes as slot counts
30
+ // or record counts change. v1 caps slots per bucket at MAX_SLOTS
31
+ // (256), exceeded slots throw with a friendly diagnostic.
32
+
33
+ /** v1 cap on distinct pipeline slots per bucket. */
34
+ export const MAX_SLOTS_PER_BUCKET = 256;
35
+
36
+ export interface PartitionInput {
37
+ /** Per-record slot index (length = numRecords). */
38
+ readonly slot: ReadonlyArray<number>;
39
+ /** Per-record (indexCount * instanceCount) — emit count contributed
40
+ * to its slot's drawIndirect vertex total. */
41
+ readonly emitCount: ReadonlyArray<number>;
42
+ /** Number of distinct slots used. Slots outside this range produce
43
+ * an error. */
44
+ readonly slotCount: number;
45
+ }
46
+
47
+ export interface PartitionResult {
48
+ /** Per-slot record count. Length = slotCount. */
49
+ readonly slotCounts: Uint32Array;
50
+ /** Per-slot cumulative record-count prefix sum (length = slotCount + 1). */
51
+ readonly slotOffsets: Uint32Array;
52
+ /** Per-slot total emit count (vertex count for the drawIndirect). */
53
+ readonly slotTotalEmit: Uint32Array;
54
+ /** Permuted record indices, grouped by slot. Length = numRecords. */
55
+ readonly slotRecords: Uint32Array;
56
+ /** Slot-local prefix sum of emitCounts, length = numRecords + slotCount.
57
+ * For slot s, the prefix array is
58
+ * slotEmitPrefix[slotOffsets[s] + s ..= slotOffsets[s+1] + s]
59
+ * (one extra entry per slot for the upper bound). */
60
+ readonly slotEmitPrefix: Uint32Array;
61
+ }
62
+
63
+ /**
64
+ * Pure-CPU partition. Reference implementation for the WGSL kernel
65
+ * below. Order within a slot is stable wrt. input record order so
66
+ * tests can predict the permuted layout deterministically.
67
+ */
68
+ export function partitionCPU(input: PartitionInput): PartitionResult {
69
+ const { slot, emitCount, slotCount } = input;
70
+ const numRecords = slot.length;
71
+ if (emitCount.length !== numRecords) {
72
+ throw new Error("partition: slot.length != emitCount.length");
73
+ }
74
+ if (slotCount > MAX_SLOTS_PER_BUCKET) {
75
+ throw new Error(`partition: slotCount ${slotCount} exceeds cap ${MAX_SLOTS_PER_BUCKET}`);
76
+ }
77
+
78
+ const slotCounts = new Uint32Array(slotCount);
79
+ const slotOffsets = new Uint32Array(slotCount + 1);
80
+ const slotTotalEmit = new Uint32Array(slotCount);
81
+ const slotRecords = new Uint32Array(numRecords);
82
+ const slotEmitPrefix = new Uint32Array(numRecords + slotCount);
83
+
84
+ // Pass 1: histogram + per-slot emit sum.
85
+ for (let i = 0; i < numRecords; i++) {
86
+ const s = slot[i]!;
87
+ if (s < 0 || s >= slotCount) {
88
+ throw new Error(`partition: record ${i} has out-of-range slot ${s} (slotCount=${slotCount})`);
89
+ }
90
+ slotCounts[s] = slotCounts[s]! + 1;
91
+ slotTotalEmit[s] = slotTotalEmit[s]! + emitCount[i]!;
92
+ }
93
+
94
+ // Pass 2: exclusive prefix sum -> per-slot start offsets.
95
+ let acc = 0;
96
+ for (let s = 0; s < slotCount; s++) {
97
+ slotOffsets[s] = acc;
98
+ acc += slotCounts[s]!;
99
+ }
100
+ slotOffsets[slotCount] = acc;
101
+
102
+ // Pass 3: scatter — second pass over records to place each at its
103
+ // slot's current cursor, emitting the within-slot prefix sums.
104
+ const cursors = new Uint32Array(slotCount);
105
+ for (let i = 0; i < numRecords; i++) {
106
+ const s = slot[i]!;
107
+ const base = slotOffsets[s]!; // first slotRecords[] index for this slot
108
+ const prefBase = base + s; // first slotEmitPrefix[] index for this slot
109
+ const k = cursors[s]!; // within-slot position
110
+ slotRecords[base + k] = i;
111
+ // Slot prefix sums are length (slotCounts[s] + 1): first entry
112
+ // is 0, then cumulative; the last entry equals slotTotalEmit[s].
113
+ if (k === 0) {
114
+ slotEmitPrefix[prefBase] = 0;
115
+ }
116
+ slotEmitPrefix[prefBase + k + 1] = slotEmitPrefix[prefBase + k]! + emitCount[i]!;
117
+ cursors[s] = k + 1;
118
+ }
119
+
120
+ return { slotCounts, slotOffsets, slotTotalEmit, slotRecords, slotEmitPrefix };
121
+ }
122
+
123
+ // ─── WGSL kernel ───────────────────────────────────────────────────────
124
+ //
125
+ // Mirrors `partitionCPU` in two compute passes:
126
+ //
127
+ // Pass A (histogram):
128
+ // - one thread per record
129
+ // - read slot[i], emitCount[i]
130
+ // - atomicAdd(slotCounts[s], 1)
131
+ // - atomicAdd(slotTotalEmit[s], emitCount[i])
132
+ //
133
+ // Pass B (scatter):
134
+ // - one thread per record
135
+ // - look up slot[i]
136
+ // - atomicAdd(cursors[s], 1) -> within-slot position k
137
+ // - slotRecords[slotOffsets[s] + k] = i
138
+ // - slotEmitPrefix[slotOffsets[s] + s + k + 1] is computed by a
139
+ // second scan pass (the within-slot prefix sum). v1 emits a
140
+ // serial CPU-side scan for simplicity; v2 should fold this into
141
+ // a per-slot warp-scan once buckets get large.
142
+ //
143
+ // Between the two passes, the CPU runs an exclusive scan on
144
+ // `slotCounts` to produce `slotOffsets` (small array, ≤ MAX_SLOTS).
145
+ //
146
+ // The within-slot prefix scan in pass B uses a non-atomic read of
147
+ // `slotEmitPrefix[prefBase + k]` — this is safe because the only
148
+ // thread that writes index `prefBase + k` is the one whose `cursors`
149
+ // fetchAdd returned `k`, and that thread completed before any thread
150
+ // whose fetchAdd returned `k+1` could read `prefBase + k`.
151
+ //
152
+ // (Wait — that's only true if we have a memory barrier across
153
+ // invocations, which WGSL doesn't guarantee inside one dispatch. In
154
+ // practice v1 punts: the kernel emits the histogram + slotRecords,
155
+ // and the host or a follow-up pass computes slotEmitPrefix. The
156
+ // kernel below reflects this: it produces slotCounts, slotOffsets
157
+ // (after host scan), slotTotalEmit, and slotRecords. The
158
+ // slotEmitPrefix scan is performed in a third compute pass on the
159
+ // already-scattered records.)
160
+
161
+ export const PARTITION_HISTOGRAM_WGSL = /* wgsl */ `
162
+ struct PartitionInputs {
163
+ numRecords: u32,
164
+ slotCount: u32,
165
+ _pad0: u32,
166
+ _pad1: u32,
167
+ };
168
+ @group(0) @binding(0) var<uniform> uIn: PartitionInputs;
169
+ @group(0) @binding(1) var<storage, read> slot: array<u32>;
170
+ @group(0) @binding(2) var<storage, read> emitCount: array<u32>;
171
+ @group(0) @binding(3) var<storage, read_write> slotCounts: array<atomic<u32>>;
172
+ @group(0) @binding(4) var<storage, read_write> slotTotalEmit: array<atomic<u32>>;
173
+
174
+ @compute @workgroup_size(64)
175
+ fn histogram(@builtin(global_invocation_id) gid: vec3<u32>) {
176
+ let i = gid.x;
177
+ if (i >= uIn.numRecords) { return; }
178
+ let s = slot[i];
179
+ atomicAdd(&slotCounts[s], 1u);
180
+ atomicAdd(&slotTotalEmit[s], emitCount[i]);
181
+ }
182
+ `;
183
+
184
+ export const PARTITION_SCATTER_WGSL = /* wgsl */ `
185
+ struct PartitionInputs {
186
+ numRecords: u32,
187
+ slotCount: u32,
188
+ _pad0: u32,
189
+ _pad1: u32,
190
+ };
191
+ @group(0) @binding(0) var<uniform> uIn: PartitionInputs;
192
+ @group(0) @binding(1) var<storage, read> slot: array<u32>;
193
+ @group(0) @binding(2) var<storage, read> slotOffsets: array<u32>;
194
+ @group(0) @binding(3) var<storage, read_write> cursors: array<atomic<u32>>;
195
+ @group(0) @binding(4) var<storage, read_write> slotRecords: array<u32>;
196
+
197
+ @compute @workgroup_size(64)
198
+ fn scatter(@builtin(global_invocation_id) gid: vec3<u32>) {
199
+ let i = gid.x;
200
+ if (i >= uIn.numRecords) { return; }
201
+ let s = slot[i];
202
+ let base = slotOffsets[s];
203
+ let k = atomicAdd(&cursors[s], 1u);
204
+ slotRecords[base + k] = i;
205
+ }
206
+ `;
@@ -0,0 +1,153 @@
1
+ // SlotTable — per-bucket (or per-scene-scope) registry that maps a
2
+ // per-RO modeKey to a fixed slot index. Each slot owns one
3
+ // `GPURenderPipeline` and one offset into a shared indirect-args
4
+ // buffer. The partition kernel reads `modeKeyToSlot` to atomic-add a
5
+ // record's counts into the right slot; encode iterates the slots and
6
+ // emits one `drawIndirect` per slot (zero-record slots draw zero).
7
+ //
8
+ // Lifecycle:
9
+ //
10
+ // - addRO(modeKey, descriptor) → ensures the slot exists.
11
+ // - On miss, allocates a new slot, fires `precompile` for the
12
+ // missing descriptor (or `getOrCreateSync` if `sync=true`).
13
+ // - removeRO(modeKey) → drops a refcount on the slot. v1 keeps the
14
+ // slot around even at refcount=0 (pipelines are cheap to retain
15
+ // and the future case is "RO reappears next frame").
16
+ // - ready(): Promise<void> → resolves when every registered slot
17
+ // has a linked pipeline.
18
+ // - syncLookup(modeKey) → slot index or -1 (used by partition kernel
19
+ // setup; missing slot is a logical error post-ready()).
20
+ //
21
+ // This module does NOT touch heapScene. It's a generic data structure
22
+ // that the heap integration drives. Tests use a mock builder.
23
+
24
+ import {
25
+ PipelineCache,
26
+ encodeModeKey as encodeMK,
27
+ type PipelineBuilder,
28
+ type PipelineStateDescriptor,
29
+ } from "../pipelineCache/index.js";
30
+
31
+ export interface SlotTableEntry {
32
+ readonly slotIndex: number;
33
+ readonly modeKey: bigint;
34
+ readonly descriptor: PipelineStateDescriptor;
35
+ /** Refcount of ROs currently bound to this slot. v1 doesn't GC at 0. */
36
+ refCount: number;
37
+ /** Populated lazily by precompile; null until linked. */
38
+ pipeline: GPURenderPipeline | null;
39
+ }
40
+
41
+ /**
42
+ * One SlotTable per `(effect, textureSet)` bucket. The cache may be
43
+ * shared across buckets — pipelines that happen to share a descriptor
44
+ * (rare across distinct effects, since pipeline objects bake the
45
+ * shader / layout) still dedupe at the cache level if a builder
46
+ * legitimately produces the same GPURenderPipeline for the same
47
+ * descriptor key.
48
+ */
49
+ export class SlotTable {
50
+ private readonly entries: SlotTableEntry[] = [];
51
+ private readonly byKey = new Map<bigint, SlotTableEntry>();
52
+ /** Tracks pending precompile promises so ready() waits for the union. */
53
+ private pending: Set<Promise<unknown>> = new Set();
54
+ private version = 0;
55
+
56
+ constructor(
57
+ readonly cache: PipelineCache,
58
+ readonly builder: PipelineBuilder,
59
+ ) {}
60
+
61
+ /** Total slot count (linked or pending). */
62
+ get slotCount(): number { return this.entries.length; }
63
+ /**
64
+ * Bumps whenever a new slot is appended. Consumers (partition setup,
65
+ * indirect-args resize, GPU lookup-table upload) compare against
66
+ * their last-seen value to know when to re-upload.
67
+ */
68
+ get layoutVersion(): number { return this.version; }
69
+ /** Iterate all slots in slot-index order. */
70
+ *all(): IterableIterator<SlotTableEntry> { yield* this.entries; }
71
+
72
+ /**
73
+ * Add (or reference-bump) the slot for `descriptor`. Returns the
74
+ * existing slot if it already exists, otherwise creates one and
75
+ * kicks off compilation via `builder` (async by default).
76
+ *
77
+ * Pass `sync: true` on the runtime-mutation path to use
78
+ * `builder.createSync` instead — the returned slot's `pipeline` will
79
+ * be populated immediately.
80
+ */
81
+ addRO(
82
+ descriptor: PipelineStateDescriptor,
83
+ options: { sync?: boolean } = {},
84
+ ): SlotTableEntry {
85
+ const modeKey = encodeMK(descriptor);
86
+ const existing = this.byKey.get(modeKey);
87
+ if (existing !== undefined) {
88
+ existing.refCount += 1;
89
+ return existing;
90
+ }
91
+ const slotIndex = this.entries.length;
92
+ const entry: SlotTableEntry = {
93
+ slotIndex, modeKey, descriptor, refCount: 1, pipeline: null,
94
+ };
95
+ this.entries.push(entry);
96
+ this.byKey.set(modeKey, entry);
97
+ this.version += 1;
98
+ if (options.sync === true) {
99
+ entry.pipeline = this.cache.getOrCreateSync(this.builder, descriptor);
100
+ } else {
101
+ // Use the cache's precompile path so concurrent requests
102
+ // deduplicate. Tracks promise so `ready()` blocks until done.
103
+ const p = this.cache.precompile(this.builder, [descriptor]).then(() => {
104
+ const got = this.cache.lookup(modeKey);
105
+ if (got !== null) entry.pipeline = got;
106
+ });
107
+ this.pending.add(p);
108
+ // Auto-clean once settled.
109
+ p.finally(() => this.pending.delete(p));
110
+ }
111
+ return entry;
112
+ }
113
+
114
+ /** Drop one reference from the slot. Pipelines stay resident. */
115
+ removeRO(modeKey: bigint): void {
116
+ const e = this.byKey.get(modeKey);
117
+ if (e === undefined) return;
118
+ if (e.refCount > 0) e.refCount -= 1;
119
+ }
120
+
121
+ /**
122
+ * Move an RO from one slot to another (reactive aval mutation
123
+ * caused its modeKey to change). Returns the new (possibly newly
124
+ * allocated) slot.
125
+ */
126
+ rebindRO(
127
+ fromModeKey: bigint,
128
+ toDescriptor: PipelineStateDescriptor,
129
+ options: { sync?: boolean } = {},
130
+ ): SlotTableEntry {
131
+ this.removeRO(fromModeKey);
132
+ return this.addRO(toDescriptor, options);
133
+ }
134
+
135
+ /** Sync slot lookup by modeKey. Returns the entry or null. */
136
+ lookup(modeKey: bigint): SlotTableEntry | null {
137
+ return this.byKey.get(modeKey) ?? null;
138
+ }
139
+
140
+ /**
141
+ * Resolves when every currently-registered slot has a linked
142
+ * pipeline. Subsequent `addRO` calls after this resolves with
143
+ * `sync: false` will produce fresh pending work; call `ready()`
144
+ * again to wait.
145
+ */
146
+ async ready(): Promise<void> {
147
+ if (this.pending.size === 0) return;
148
+ // Snapshot current pending set; new ones added during await
149
+ // resolve on their own callers' awaits if needed.
150
+ const snap = [...this.pending];
151
+ await Promise.all(snap);
152
+ }
153
+ }
@@ -0,0 +1,75 @@
1
+ // GrowBuffer — pow2-grown GPUBuffer with a high-water mark and
2
+ // resize-listener machinery for dependents (bind groups, mostly).
3
+ //
4
+ // On grow, a fresh buffer is created at the next pow2 capacity, the
5
+ // live tail is copied over via copyBufferToBuffer, and the listeners
6
+ // (registered via `onResize`) are invoked. The old buffer is
7
+ // destroyed.
8
+
9
+ import type { IDisposable } from "@aardworx/wombat.adaptive";
10
+
11
+ export const MIN_BUFFER_BYTES = 64 * 1024;
12
+
13
+ export const POW2 = (n: number): number => {
14
+ let p = 1; while (p < n) p <<= 1; return p;
15
+ };
16
+
17
+ export const ALIGN16 = (n: number) => (n + 15) & ~15;
18
+
19
+ /**
20
+ * A GPUBuffer that can grow to next power-of-two on demand. On grow,
21
+ * a fresh buffer is created at the new size, the live tail copied
22
+ * over via copyBufferToBuffer, and dependents (bind groups, mostly)
23
+ * are notified to rebuild via the `onResize` callback.
24
+ *
25
+ * `usedBytes` is the high-water mark — the runtime advances this as
26
+ * it allocates, and `ensureCapacity` grows when required. This
27
+ * separates allocation policy from grow policy.
28
+ */
29
+ export class GrowBuffer {
30
+ private buf: GPUBuffer;
31
+ private cap: number;
32
+ private used = 0;
33
+ private readonly listeners = new Set<() => void>();
34
+ constructor(
35
+ private readonly device: GPUDevice,
36
+ private readonly label: string,
37
+ private readonly usage: GPUBufferUsageFlags,
38
+ initialBytes: number,
39
+ ) {
40
+ this.cap = Math.max(MIN_BUFFER_BYTES, POW2(initialBytes));
41
+ this.buf = device.createBuffer({
42
+ size: this.cap,
43
+ usage: usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
44
+ label,
45
+ });
46
+ }
47
+ get buffer(): GPUBuffer { return this.buf; }
48
+ get capacity(): number { return this.cap; }
49
+ get usedBytes(): number { return this.used; }
50
+ setUsed(n: number): void { this.used = n; }
51
+ onResize(cb: () => void): IDisposable {
52
+ this.listeners.add(cb);
53
+ return { dispose: () => { this.listeners.delete(cb); } };
54
+ }
55
+ /** Ensure the buffer is at least `bytes` capacity. Grows by pow2 + copies live tail. */
56
+ ensureCapacity(bytes: number): void {
57
+ if (bytes <= this.cap) return;
58
+ const newCap = POW2(bytes);
59
+ const newBuf = this.device.createBuffer({
60
+ size: newCap,
61
+ usage: this.usage | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
62
+ label: this.label,
63
+ });
64
+ if (this.used > 0) {
65
+ const enc = this.device.createCommandEncoder({ label: `${this.label}: grow-copy` });
66
+ enc.copyBufferToBuffer(this.buf, 0, newBuf, 0, ALIGN16(this.used));
67
+ this.device.queue.submit([enc.finish()]);
68
+ }
69
+ this.buf.destroy();
70
+ this.buf = newBuf;
71
+ this.cap = newCap;
72
+ for (const cb of this.listeners) cb();
73
+ }
74
+ destroy(): void { this.buf.destroy(); }
75
+ }