@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.
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts +62 -0
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -0
- package/dist/runtime/derivedModes/modeKeyCpu.js +225 -0
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -0
- package/dist/runtime/derivedModes/partition.d.ts +36 -0
- package/dist/runtime/derivedModes/partition.d.ts.map +1 -0
- package/dist/runtime/derivedModes/partition.js +170 -0
- package/dist/runtime/derivedModes/partition.js.map +1 -0
- package/dist/runtime/derivedModes/slotTable.d.ts +70 -0
- package/dist/runtime/derivedModes/slotTable.d.ts.map +1 -0
- package/dist/runtime/derivedModes/slotTable.js +130 -0
- package/dist/runtime/derivedModes/slotTable.js.map +1 -0
- package/dist/runtime/heapScene/growBuffer.d.ts +33 -0
- package/dist/runtime/heapScene/growBuffer.d.ts.map +1 -0
- package/dist/runtime/heapScene/growBuffer.js +76 -0
- package/dist/runtime/heapScene/growBuffer.js.map +1 -0
- package/dist/runtime/heapScene/packers.d.ts +25 -0
- package/dist/runtime/heapScene/packers.d.ts.map +1 -0
- package/dist/runtime/heapScene/packers.js +111 -0
- package/dist/runtime/heapScene/packers.js.map +1 -0
- package/dist/runtime/heapScene/pools.d.ts +181 -0
- package/dist/runtime/heapScene/pools.d.ts.map +1 -0
- package/dist/runtime/heapScene/pools.js +417 -0
- package/dist/runtime/heapScene/pools.js.map +1 -0
- package/dist/runtime/heapScene/scanKernel.d.ts +11 -0
- package/dist/runtime/heapScene/scanKernel.d.ts.map +1 -0
- package/dist/runtime/heapScene/scanKernel.js +181 -0
- package/dist/runtime/heapScene/scanKernel.js.map +1 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +346 -937
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/pipelineCache/bitfield.d.ts +5 -0
- package/dist/runtime/pipelineCache/bitfield.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/bitfield.js +201 -0
- package/dist/runtime/pipelineCache/bitfield.js.map +1 -0
- package/dist/runtime/pipelineCache/cache.d.ts +36 -0
- package/dist/runtime/pipelineCache/cache.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/cache.js +108 -0
- package/dist/runtime/pipelineCache/cache.js.map +1 -0
- package/dist/runtime/pipelineCache/descriptor.d.ts +58 -0
- package/dist/runtime/pipelineCache/descriptor.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/descriptor.js +100 -0
- package/dist/runtime/pipelineCache/descriptor.js.map +1 -0
- package/dist/runtime/pipelineCache/index.d.ts +5 -0
- package/dist/runtime/pipelineCache/index.d.ts.map +1 -0
- package/dist/runtime/pipelineCache/index.js +12 -0
- package/dist/runtime/pipelineCache/index.js.map +1 -0
- package/package.json +1 -1
- package/src/runtime/derivedModes/modeKeyCpu.ts +251 -0
- package/src/runtime/derivedModes/partition.ts +206 -0
- package/src/runtime/derivedModes/slotTable.ts +153 -0
- package/src/runtime/heapScene/growBuffer.ts +75 -0
- package/src/runtime/heapScene/packers.ts +127 -0
- package/src/runtime/heapScene/pools.ts +492 -0
- package/src/runtime/heapScene/scanKernel.ts +184 -0
- package/src/runtime/heapScene.ts +397 -1064
- package/src/runtime/index.ts +40 -0
- package/src/runtime/pipelineCache/bitfield.ts +225 -0
- package/src/runtime/pipelineCache/cache.ts +129 -0
- package/src/runtime/pipelineCache/descriptor.ts +150 -0
- 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
|
+
}
|