@aardworx/wombat.rendering 0.9.13 → 0.9.15

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 (70) hide show
  1. package/dist/runtime/derivedModes/modeKeyCpu.d.ts +45 -0
  2. package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
  3. package/dist/runtime/derivedModes/modeKeyCpu.js +179 -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/derivedUniforms/marker.d.ts +11 -3
  14. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -1
  15. package/dist/runtime/derivedUniforms/marker.js +10 -5
  16. package/dist/runtime/derivedUniforms/marker.js.map +1 -1
  17. package/dist/runtime/heapScene/growBuffer.d.ts +33 -0
  18. package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -0
  19. package/dist/runtime/heapScene/growBuffer.js +76 -0
  20. package/dist/runtime/heapScene/growBuffer.js.map +1 -0
  21. package/dist/runtime/heapScene/packers.d.ts +25 -0
  22. package/dist/runtime/heapScene/packers.d.ts.map +1 -0
  23. package/dist/runtime/heapScene/packers.js +111 -0
  24. package/dist/runtime/heapScene/packers.js.map +1 -0
  25. package/dist/runtime/heapScene/pools.d.ts +181 -0
  26. package/dist/runtime/heapScene/pools.d.ts.map +1 -0
  27. package/dist/runtime/heapScene/pools.js +417 -0
  28. package/dist/runtime/heapScene/pools.js.map +1 -0
  29. package/dist/runtime/heapScene/scanKernel.d.ts +11 -0
  30. package/dist/runtime/heapScene/scanKernel.d.ts.map +1 -0
  31. package/dist/runtime/heapScene/scanKernel.js +181 -0
  32. package/dist/runtime/heapScene/scanKernel.js.map +1 -0
  33. package/dist/runtime/heapScene.d.ts.map +1 -1
  34. package/dist/runtime/heapScene.js +228 -937
  35. package/dist/runtime/heapScene.js.map +1 -1
  36. package/dist/runtime/index.d.ts +4 -0
  37. package/dist/runtime/index.d.ts.map +1 -1
  38. package/dist/runtime/index.js +5 -0
  39. package/dist/runtime/index.js.map +1 -1
  40. package/dist/runtime/pipelineCache/bitfield.d.ts +5 -0
  41. package/dist/runtime/pipelineCache/bitfield.d.ts.map +1 -0
  42. package/dist/runtime/pipelineCache/bitfield.js +201 -0
  43. package/dist/runtime/pipelineCache/bitfield.js.map +1 -0
  44. package/dist/runtime/pipelineCache/cache.d.ts +36 -0
  45. package/dist/runtime/pipelineCache/cache.d.ts.map +1 -0
  46. package/dist/runtime/pipelineCache/cache.js +108 -0
  47. package/dist/runtime/pipelineCache/cache.js.map +1 -0
  48. package/dist/runtime/pipelineCache/descriptor.d.ts +58 -0
  49. package/dist/runtime/pipelineCache/descriptor.d.ts.map +1 -0
  50. package/dist/runtime/pipelineCache/descriptor.js +100 -0
  51. package/dist/runtime/pipelineCache/descriptor.js.map +1 -0
  52. package/dist/runtime/pipelineCache/index.d.ts +5 -0
  53. package/dist/runtime/pipelineCache/index.d.ts.map +1 -0
  54. package/dist/runtime/pipelineCache/index.js +12 -0
  55. package/dist/runtime/pipelineCache/index.js.map +1 -0
  56. package/package.json +1 -1
  57. package/src/runtime/derivedModes/modeKeyCpu.ts +213 -0
  58. package/src/runtime/derivedModes/partition.ts +206 -0
  59. package/src/runtime/derivedModes/slotTable.ts +153 -0
  60. package/src/runtime/derivedUniforms/marker.ts +19 -6
  61. package/src/runtime/heapScene/growBuffer.ts +75 -0
  62. package/src/runtime/heapScene/packers.ts +127 -0
  63. package/src/runtime/heapScene/pools.ts +492 -0
  64. package/src/runtime/heapScene/scanKernel.ts +184 -0
  65. package/src/runtime/heapScene.ts +283 -1064
  66. package/src/runtime/index.ts +40 -0
  67. package/src/runtime/pipelineCache/bitfield.ts +225 -0
  68. package/src/runtime/pipelineCache/cache.ts +129 -0
  69. package/src/runtime/pipelineCache/descriptor.ts +150 -0
  70. package/src/runtime/pipelineCache/index.ts +37 -0
@@ -0,0 +1,213 @@
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
+ this.ps = ps;
141
+ this.signature = signature;
142
+ this.onDirty = onDirty;
143
+ this.cachedDescriptor = snapshotDescriptor(ps, signature);
144
+ this.cachedModeKey = encodeModeKey(this.cachedDescriptor);
145
+ this.subscribeAll();
146
+ }
147
+
148
+ get descriptor(): PipelineStateDescriptor { return this.cachedDescriptor; }
149
+ get modeKey(): bigint { return this.cachedModeKey; }
150
+
151
+ /**
152
+ * Snapshot current aval values and rebuild descriptor + modeKey.
153
+ * Returns true iff the modeKey changed (so the bucket only needs to
154
+ * upload + repartition when something actually moved).
155
+ */
156
+ recompute(): boolean {
157
+ const next = snapshotDescriptor(this.ps, this.signature);
158
+ const nextKey = encodeModeKey(next);
159
+ if (nextKey === this.cachedModeKey) return false;
160
+ this.cachedDescriptor = next;
161
+ this.cachedModeKey = nextKey;
162
+ return true;
163
+ }
164
+
165
+ dispose(): void {
166
+ for (const s of this.subs) s.dispose();
167
+ this.subs.length = 0;
168
+ }
169
+
170
+ private subscribeAll(): void {
171
+ if (this.ps === undefined) return;
172
+ const ps = this.ps;
173
+ this.sub(ps.rasterizer.topology);
174
+ this.sub(ps.rasterizer.cullMode);
175
+ this.sub(ps.rasterizer.frontFace);
176
+ if (ps.rasterizer.depthBias !== undefined) this.sub(ps.rasterizer.depthBias);
177
+ if (ps.depth !== undefined) {
178
+ this.sub(ps.depth.write);
179
+ this.sub(ps.depth.compare);
180
+ if (ps.depth.clamp !== undefined) this.sub(ps.depth.clamp);
181
+ }
182
+ if (ps.stencil !== undefined) {
183
+ // Stencil enabled/reference/masks contribute to neither modeKey
184
+ // nor cache key (v1 carves stencil out — only the "enabled"
185
+ // flag matters and we surface it via snapshotDescriptor). We
186
+ // still subscribe so callers can repackage if a future change
187
+ // flips the enable bit.
188
+ this.subStencilFaces(ps.stencil);
189
+ }
190
+ if (ps.blends !== undefined) {
191
+ this.sub(ps.blends);
192
+ const map = ps.blends.force(/* allow-force */);
193
+ for (const [, bs] of map) this.subBlendState(bs);
194
+ }
195
+ if (ps.alphaToCoverage !== undefined) this.sub(ps.alphaToCoverage);
196
+ // blendConstant is dynamic state; not in the modeKey, no subscription needed.
197
+ }
198
+
199
+ private sub(a: aval<unknown>): void {
200
+ this.subs.push(addMarkingCallback(a, this.onDirty));
201
+ }
202
+
203
+ private subBlendState(b: PsBlendState): void {
204
+ this.sub(b.color.srcFactor); this.sub(b.color.dstFactor); this.sub(b.color.operation);
205
+ this.sub(b.alpha.srcFactor); this.sub(b.alpha.dstFactor); this.sub(b.alpha.operation);
206
+ this.sub(b.writeMask);
207
+ }
208
+
209
+ private subStencilFaces(_s: StencilState): void {
210
+ // v1: stencil isn't in the modeKey; deliberately not subscribing
211
+ // to face avals here. Wire up in v2 when stencil enters the key.
212
+ }
213
+ }
@@ -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
+ }
@@ -144,24 +144,37 @@ export class DerivedExpr {
144
144
  reflect(n: DerivedExpr): DerivedExpr { return new DerivedExpr(intrinsicExpr("reflect", this.ir.type, [this.ir, n.ir])); }
145
145
  }
146
146
 
147
- /** The `u` passed to a `derivedUniform` builder. `u.<Name>` is a mat4 trafo leaf. */
147
+ /** The `u` passed to a `derivedUniform` builder. `u.<Name>` is a uniform leaf — a mat4
148
+ * trafo by default; its real type when the wombat-shader-vite plugin supplies one (or
149
+ * when you write `u.<Name>.as("vec4")`). */
148
150
  export type DerivedScope = { readonly [name: string]: DerivedExpr };
149
151
 
150
- function makeScope(): DerivedScope {
152
+ /** Per-leaf type hints, keyed by uniform name (the build-time marker fills these in). */
153
+ export type DerivedLeafTypes = Readonly<Record<string, LeafTypeName>>;
154
+
155
+ function makeScope(leafTypes?: DerivedLeafTypes): DerivedScope {
151
156
  return new Proxy({} as DerivedScope, {
152
157
  get(_t, key): DerivedExpr {
153
158
  if (typeof key !== "string") throw new Error("derivedUniform: leaf names must be strings");
154
- return new DerivedExpr(uniformRef(key, mat(4)));
159
+ const hinted = leafTypes?.[key];
160
+ return new DerivedExpr(uniformRef(key, hinted ? LEAF_TYPES[hinted] : mat(4)));
155
161
  },
156
162
  });
157
163
  }
158
164
 
159
165
  /**
160
166
  * Define a derived uniform. `derivedUniform(u => u.ViewTrafo.mul(u.ModelTrafo))` makes a
161
- * rule that slots in wherever a uniform value goes — see this module's header.
167
+ * rule that slots in wherever a uniform value goes — see this module's header. The optional
168
+ * `leafTypes` map gives `u.<Name>` leaves their real WGSL types (the wombat-shader-vite
169
+ * `derivedUniform(...)` marker fills it in from your `UniformScope` declarations; without
170
+ * the plugin, `u.<Name>` defaults to mat4 and you use `.as("vec4")` etc. when you need
171
+ * another type).
162
172
  */
163
- export function derivedUniform<T = unknown>(build: (u: DerivedScope) => DerivedExpr): DerivedRule<T> {
164
- const result = build(makeScope());
173
+ export function derivedUniform<T = unknown>(
174
+ build: (u: DerivedScope) => DerivedExpr,
175
+ leafTypes?: DerivedLeafTypes,
176
+ ): DerivedRule<T> {
177
+ const result = build(makeScope(leafTypes));
165
178
  if (!(result instanceof DerivedExpr)) {
166
179
  throw new Error("derivedUniform: the builder must return a derived expression, e.g. u.ViewTrafo.mul(u.ModelTrafo)");
167
180
  }