@aardworx/wombat.rendering 0.9.15 → 0.9.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts +52 -3
- package/dist/runtime/derivedModes/modeKeyCpu.d.ts.map +1 -1
- package/dist/runtime/derivedModes/modeKeyCpu.js +146 -7
- package/dist/runtime/derivedModes/modeKeyCpu.js.map +1 -1
- package/dist/runtime/derivedModes/rule.d.ts +59 -0
- package/dist/runtime/derivedModes/rule.d.ts.map +1 -0
- package/dist/runtime/derivedModes/rule.js +55 -0
- package/dist/runtime/derivedModes/rule.js.map +1 -0
- package/dist/runtime/heapScene.d.ts +32 -0
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +192 -24
- package/dist/runtime/heapScene.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/derivedModes/modeKeyCpu.ts +162 -5
- package/src/runtime/derivedModes/rule.ts +110 -0
- package/src/runtime/heapScene.ts +220 -23
- package/src/runtime/index.ts +10 -0
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
} from "../../core/pipelineState.js";
|
|
21
21
|
import type { FramebufferSignature } from "../../core/framebufferSignature.js";
|
|
22
22
|
import type { aval, IDisposable } from "@aardworx/wombat.adaptive";
|
|
23
|
-
import { addMarkingCallback } from "@aardworx/wombat.adaptive";
|
|
23
|
+
import { addMarkingCallback, AdaptiveToken } from "@aardworx/wombat.adaptive";
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
26
|
encodeModeKey,
|
|
@@ -31,6 +31,42 @@ import {
|
|
|
31
31
|
type BlendComponent,
|
|
32
32
|
type DepthSlice,
|
|
33
33
|
} from "../pipelineCache/index.js";
|
|
34
|
+
import type { DerivedModeRule } from "./rule.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-axis derived-mode rules for one RO. Same shape as
|
|
38
|
+
* `HeapDrawSpec.modeRules`.
|
|
39
|
+
*/
|
|
40
|
+
export interface RoModeRules {
|
|
41
|
+
readonly cull?: DerivedModeRule<"cull">;
|
|
42
|
+
readonly frontFace?: DerivedModeRule<"frontFace">;
|
|
43
|
+
readonly topology?: DerivedModeRule<"topology">;
|
|
44
|
+
readonly depthCompare?: DerivedModeRule<"depthCompare">;
|
|
45
|
+
readonly depthWrite?: DerivedModeRule<"depthWrite">;
|
|
46
|
+
readonly alphaToCoverage?: DerivedModeRule<"alphaToCoverage">;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Uniforms accessor exposed to a derived-mode rule's `(u, declared)`
|
|
51
|
+
* closure. Reads each `u.<Name>` from the supplied aval map at
|
|
52
|
+
* evaluation time and tracks which avals were accessed so the
|
|
53
|
+
* `ModeKeyTracker` can subscribe to them.
|
|
54
|
+
*/
|
|
55
|
+
function uniformsProxy(
|
|
56
|
+
uniformAvals: ReadonlyMap<string, aval<unknown>>,
|
|
57
|
+
tok: AdaptiveToken,
|
|
58
|
+
recordDep: ((av: aval<unknown>) => void) | undefined,
|
|
59
|
+
): Record<string, unknown> {
|
|
60
|
+
return new Proxy({}, {
|
|
61
|
+
get(_target, prop): unknown {
|
|
62
|
+
if (typeof prop !== "string") return undefined;
|
|
63
|
+
const av = uniformAvals.get(prop);
|
|
64
|
+
if (av === undefined) return undefined;
|
|
65
|
+
if (recordDep !== undefined) recordDep(av);
|
|
66
|
+
return av.getValue(tok);
|
|
67
|
+
},
|
|
68
|
+
}) as Record<string, unknown>;
|
|
69
|
+
}
|
|
34
70
|
|
|
35
71
|
/**
|
|
36
72
|
* One-shot snapshot of the current values of every leaf aval in a
|
|
@@ -42,9 +78,15 @@ import {
|
|
|
42
78
|
export function snapshotDescriptor(
|
|
43
79
|
ps: PipelineState | undefined,
|
|
44
80
|
signature: FramebufferSignature,
|
|
81
|
+
options: {
|
|
82
|
+
readonly modeRules?: RoModeRules;
|
|
83
|
+
readonly uniformAvals?: ReadonlyMap<string, aval<unknown>>;
|
|
84
|
+
readonly token?: AdaptiveToken;
|
|
85
|
+
readonly recordDep?: (av: aval<unknown>) => void;
|
|
86
|
+
} = {},
|
|
45
87
|
): PipelineStateDescriptor {
|
|
46
88
|
if (ps === undefined) {
|
|
47
|
-
return buildAttachmentsFor(DEFAULT_DESCRIPTOR, signature);
|
|
89
|
+
return applyRules(buildAttachmentsFor(DEFAULT_DESCRIPTOR, signature), options);
|
|
48
90
|
}
|
|
49
91
|
const r = ps.rasterizer;
|
|
50
92
|
const depth = ps.depth !== undefined ? snapshotDepth(ps.depth, signature) : undefined;
|
|
@@ -63,6 +105,52 @@ export function snapshotDescriptor(
|
|
|
63
105
|
attachments,
|
|
64
106
|
alphaToCoverage: atc,
|
|
65
107
|
};
|
|
108
|
+
return applyRules(out, options);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function applyRules(
|
|
112
|
+
d: PipelineStateDescriptor,
|
|
113
|
+
options: {
|
|
114
|
+
readonly modeRules?: RoModeRules;
|
|
115
|
+
readonly uniformAvals?: ReadonlyMap<string, aval<unknown>>;
|
|
116
|
+
readonly token?: AdaptiveToken;
|
|
117
|
+
readonly recordDep?: (av: aval<unknown>) => void;
|
|
118
|
+
},
|
|
119
|
+
): PipelineStateDescriptor {
|
|
120
|
+
const rules = options.modeRules;
|
|
121
|
+
if (rules === undefined) return d;
|
|
122
|
+
const uniforms = options.uniformAvals ?? new Map();
|
|
123
|
+
const tok = options.token ?? AdaptiveToken.top;
|
|
124
|
+
const proxy = uniformsProxy(uniforms, tok, options.recordDep);
|
|
125
|
+
let out = d;
|
|
126
|
+
if (rules.cull !== undefined) {
|
|
127
|
+
const v = rules.cull.evaluate(proxy, out.cullMode);
|
|
128
|
+
if (v !== out.cullMode) out = { ...out, cullMode: v };
|
|
129
|
+
}
|
|
130
|
+
if (rules.frontFace !== undefined) {
|
|
131
|
+
const v = rules.frontFace.evaluate(proxy, out.frontFace);
|
|
132
|
+
if (v !== out.frontFace) out = { ...out, frontFace: v };
|
|
133
|
+
}
|
|
134
|
+
if (rules.topology !== undefined) {
|
|
135
|
+
const v = rules.topology.evaluate(proxy, out.topology);
|
|
136
|
+
if (v !== out.topology) out = { ...out, topology: v, stripIndexFormat: stripFormatFor(v) };
|
|
137
|
+
}
|
|
138
|
+
if (rules.alphaToCoverage !== undefined) {
|
|
139
|
+
const v = rules.alphaToCoverage.evaluate(proxy, out.alphaToCoverage);
|
|
140
|
+
if (v !== out.alphaToCoverage) out = { ...out, alphaToCoverage: v };
|
|
141
|
+
}
|
|
142
|
+
if (out.depth !== undefined && (rules.depthCompare !== undefined || rules.depthWrite !== undefined)) {
|
|
143
|
+
let depthOut = out.depth;
|
|
144
|
+
if (rules.depthCompare !== undefined) {
|
|
145
|
+
const v = rules.depthCompare.evaluate(proxy, depthOut.compare);
|
|
146
|
+
if (v !== depthOut.compare) depthOut = { ...depthOut, compare: v };
|
|
147
|
+
}
|
|
148
|
+
if (rules.depthWrite !== undefined) {
|
|
149
|
+
const v = rules.depthWrite.evaluate(proxy, depthOut.write);
|
|
150
|
+
if (v !== depthOut.write) depthOut = { ...depthOut, write: v };
|
|
151
|
+
}
|
|
152
|
+
if (depthOut !== out.depth) out = { ...out, depth: depthOut };
|
|
153
|
+
}
|
|
66
154
|
return out;
|
|
67
155
|
}
|
|
68
156
|
|
|
@@ -131,18 +219,81 @@ export class ModeKeyTracker implements IDisposable {
|
|
|
131
219
|
private readonly subs: IDisposable[] = [];
|
|
132
220
|
private cachedDescriptor: PipelineStateDescriptor;
|
|
133
221
|
private cachedModeKey: bigint;
|
|
222
|
+
private readonly modeRules: RoModeRules | undefined;
|
|
223
|
+
private readonly uniformAvals: ReadonlyMap<string, aval<unknown>> | undefined;
|
|
224
|
+
/** Avals discovered while evaluating rules; merged into the leaf
|
|
225
|
+
* set returned by `forEachLeaf` so the heap scene subscribes once. */
|
|
226
|
+
private readonly ruleDeps = new Set<aval<unknown>>();
|
|
134
227
|
|
|
135
228
|
constructor(
|
|
136
229
|
ps: PipelineState | undefined,
|
|
137
230
|
signature: FramebufferSignature,
|
|
138
231
|
onDirty: () => void,
|
|
232
|
+
/**
|
|
233
|
+
* Skip the per-instance addMarkingCallback subscriptions. The caller
|
|
234
|
+
* is responsible for invoking `onDirty` (or recomputing directly)
|
|
235
|
+
* when an input aval marks. Use this when the heap scene shares one
|
|
236
|
+
* subscription across N tracker instances (the 20k-ROs-share-one-
|
|
237
|
+
* cullCval case) — saves N subscriptions and N callback dispatches
|
|
238
|
+
* per mark.
|
|
239
|
+
*/
|
|
240
|
+
options: {
|
|
241
|
+
skipSubscribe?: boolean;
|
|
242
|
+
modeRules?: RoModeRules;
|
|
243
|
+
uniformAvals?: ReadonlyMap<string, aval<unknown>>;
|
|
244
|
+
} = {},
|
|
139
245
|
) {
|
|
140
246
|
this.ps = ps;
|
|
141
247
|
this.signature = signature;
|
|
142
248
|
this.onDirty = onDirty;
|
|
143
|
-
this.
|
|
249
|
+
if (options.modeRules !== undefined) this.modeRules = options.modeRules;
|
|
250
|
+
if (options.uniformAvals !== undefined) this.uniformAvals = options.uniformAvals;
|
|
251
|
+
this.cachedDescriptor = this.snapshot();
|
|
144
252
|
this.cachedModeKey = encodeModeKey(this.cachedDescriptor);
|
|
145
|
-
this.subscribeAll();
|
|
253
|
+
if (options.skipSubscribe !== true) this.subscribeAll();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private snapshot(): PipelineStateDescriptor {
|
|
257
|
+
return snapshotDescriptor(this.ps, this.signature, {
|
|
258
|
+
...(this.modeRules !== undefined ? { modeRules: this.modeRules } : {}),
|
|
259
|
+
...(this.uniformAvals !== undefined ? { uniformAvals: this.uniformAvals } : {}),
|
|
260
|
+
recordDep: (av) => { this.ruleDeps.add(av); },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Walk every leaf aval in this tracker's PipelineState and invoke
|
|
266
|
+
* `visit(aval)`. Used by the heap scene to register ONE
|
|
267
|
+
* addMarkingCallback per unique aval across many trackers.
|
|
268
|
+
*/
|
|
269
|
+
forEachLeaf(visit: (a: aval<unknown>) => void): void {
|
|
270
|
+
const ps = this.ps;
|
|
271
|
+
if (ps !== undefined) {
|
|
272
|
+
visit(ps.rasterizer.topology);
|
|
273
|
+
visit(ps.rasterizer.cullMode);
|
|
274
|
+
visit(ps.rasterizer.frontFace);
|
|
275
|
+
if (ps.rasterizer.depthBias !== undefined) visit(ps.rasterizer.depthBias);
|
|
276
|
+
if (ps.depth !== undefined) {
|
|
277
|
+
visit(ps.depth.write);
|
|
278
|
+
visit(ps.depth.compare);
|
|
279
|
+
if (ps.depth.clamp !== undefined) visit(ps.depth.clamp);
|
|
280
|
+
}
|
|
281
|
+
if (ps.blends !== undefined) {
|
|
282
|
+
visit(ps.blends);
|
|
283
|
+
const map = ps.blends.force(/* allow-force */);
|
|
284
|
+
for (const [, bs] of map) {
|
|
285
|
+
visit(bs.color.srcFactor); visit(bs.color.dstFactor); visit(bs.color.operation);
|
|
286
|
+
visit(bs.alpha.srcFactor); visit(bs.alpha.dstFactor); visit(bs.alpha.operation);
|
|
287
|
+
visit(bs.writeMask);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (ps.alphaToCoverage !== undefined) visit(ps.alphaToCoverage);
|
|
291
|
+
}
|
|
292
|
+
// Rule-input deps discovered on the last evaluation. The set is
|
|
293
|
+
// refreshed each `recompute()`; callers re-subscribe accordingly.
|
|
294
|
+
// Surfaced regardless of `ps` because a rule can fire without
|
|
295
|
+
// any underlying PipelineState aval (e.g. ps === undefined).
|
|
296
|
+
for (const av of this.ruleDeps) visit(av);
|
|
146
297
|
}
|
|
147
298
|
|
|
148
299
|
get descriptor(): PipelineStateDescriptor { return this.cachedDescriptor; }
|
|
@@ -152,9 +303,15 @@ export class ModeKeyTracker implements IDisposable {
|
|
|
152
303
|
* Snapshot current aval values and rebuild descriptor + modeKey.
|
|
153
304
|
* Returns true iff the modeKey changed (so the bucket only needs to
|
|
154
305
|
* upload + repartition when something actually moved).
|
|
306
|
+
*
|
|
307
|
+
* Also rediscovers rule-input dependencies on every call — a
|
|
308
|
+
* conditional rule body may read different uniforms in different
|
|
309
|
+
* branches. New deps surface in `forEachLeaf` so the heap scene's
|
|
310
|
+
* subscription-dedupe layer picks them up.
|
|
155
311
|
*/
|
|
156
312
|
recompute(): boolean {
|
|
157
|
-
|
|
313
|
+
this.ruleDeps.clear();
|
|
314
|
+
const next = this.snapshot();
|
|
158
315
|
const nextKey = encodeModeKey(next);
|
|
159
316
|
if (nextKey === this.cachedModeKey) return false;
|
|
160
317
|
this.cachedDescriptor = next;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `derivedMode(axis, (u, declared) => modeValue)` — author-facing
|
|
2
|
+
// marker for per-RO pipeline-state values that are functions of the
|
|
3
|
+
// RO's uniforms.
|
|
4
|
+
//
|
|
5
|
+
// v1 is CPU-evaluated: the user's closure runs in JS each time the
|
|
6
|
+
// rule's inputs mark. That's a good fit for the typical authoring
|
|
7
|
+
// shapes (a uniform-driven enum flip, a determinant-sign cull flip,
|
|
8
|
+
// etc.) and pairs naturally with the bucket-level rebuild fast path
|
|
9
|
+
// shipped in 0.9.16 — only ROs whose inputs actually changed get
|
|
10
|
+
// re-evaluated, and same-key buckets re-bind their pipeline once
|
|
11
|
+
// rather than per-RO.
|
|
12
|
+
//
|
|
13
|
+
// v2 (deferred) lowers rules to a GPU compute kernel via §7's IR
|
|
14
|
+
// machinery — needed only when bulk-animated per-RO inputs become a
|
|
15
|
+
// CPU-side bottleneck. See docs/derived-mode-rules-plan.md.
|
|
16
|
+
//
|
|
17
|
+
// What can be derived (the small-enum axes — cull/blend/depth-compare/
|
|
18
|
+
// topology/etc.) and what stays static (depth bias, blend constant,
|
|
19
|
+
// stencil ref) is covered in docs/derived-modes.md.
|
|
20
|
+
|
|
21
|
+
import type { CullMode, FrontFace, Topology } from "../pipelineCache/index.js";
|
|
22
|
+
|
|
23
|
+
/** Discrete pipeline-state axes a `derivedMode` rule can target. v1
|
|
24
|
+
* supports cull / frontFace / topology / depthCompare / depthWrite /
|
|
25
|
+
* blendEnabled. Stencil + per-attachment blend factor/operation are
|
|
26
|
+
* deferred — same carve-outs as `derived-modes.md`. */
|
|
27
|
+
export type ModeAxis =
|
|
28
|
+
| "cull" | "frontFace" | "topology"
|
|
29
|
+
| "depthCompare" | "depthWrite"
|
|
30
|
+
| "alphaToCoverage";
|
|
31
|
+
|
|
32
|
+
/** Output value type per axis. */
|
|
33
|
+
export type ModeValue<A extends ModeAxis> =
|
|
34
|
+
A extends "cull" ? CullMode :
|
|
35
|
+
A extends "frontFace" ? FrontFace :
|
|
36
|
+
A extends "topology" ? Topology :
|
|
37
|
+
A extends "depthCompare" ? GPUCompareFunction :
|
|
38
|
+
A extends "depthWrite" ? boolean :
|
|
39
|
+
A extends "alphaToCoverage" ? boolean :
|
|
40
|
+
never;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The closure signature a `derivedMode` rule body takes:
|
|
44
|
+
* - `u`: a proxy whose properties resolve to the RO's uniform values
|
|
45
|
+
* at evaluation time. The proxy is type-erased in v1 — declare the
|
|
46
|
+
* uniforms you'll read via `UniformScope` augmentation on the
|
|
47
|
+
* wombat.shader side for compile-time safety, or annotate locally.
|
|
48
|
+
* - `declared`: the SG-declared value for this axis at this leaf.
|
|
49
|
+
* Identity rules `(u, d) => d` are valid and useful for axes that
|
|
50
|
+
* want to pass declared through unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export type DerivedModeBuilder<A extends ModeAxis> = (
|
|
53
|
+
u: Record<string, unknown>,
|
|
54
|
+
declared: ModeValue<A>,
|
|
55
|
+
) => ModeValue<A>;
|
|
56
|
+
|
|
57
|
+
export interface DerivedModeRule<A extends ModeAxis = ModeAxis> {
|
|
58
|
+
readonly __derivedModeRule: true;
|
|
59
|
+
readonly axis: A;
|
|
60
|
+
readonly evaluate: DerivedModeBuilder<A>;
|
|
61
|
+
/**
|
|
62
|
+
* Conservative over-approximation of the rule's output set. Used by
|
|
63
|
+
* the build-time pre-warm in v2 to enumerate the pipelines a scene
|
|
64
|
+
* could realize. v1 doesn't use it at runtime, but recording it
|
|
65
|
+
* here keeps the API forward-compatible.
|
|
66
|
+
*/
|
|
67
|
+
readonly domain?: ReadonlyArray<ModeValue<A>>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Author a derived mode rule.
|
|
72
|
+
*
|
|
73
|
+
* derivedMode("cull", (u, declared) =>
|
|
74
|
+
* u.WindingFlipped ? flipCull(declared) : declared);
|
|
75
|
+
*
|
|
76
|
+
* derivedMode("blend", (u) => u.Premultiplied ? "premul" : "straight");
|
|
77
|
+
*
|
|
78
|
+
* // Determinant-flip-cull (the motivating case):
|
|
79
|
+
* derivedMode("cull", (u, declared) => {
|
|
80
|
+
* const m = u.ModelTrafo as M44d;
|
|
81
|
+
* const det =
|
|
82
|
+
* m.M00 * (m.M11 * m.M22 - m.M12 * m.M21) -
|
|
83
|
+
* m.M01 * (m.M10 * m.M22 - m.M12 * m.M20) +
|
|
84
|
+
* m.M02 * (m.M10 * m.M21 - m.M11 * m.M20);
|
|
85
|
+
* return det < 0 ? flipCull(declared) : declared;
|
|
86
|
+
* });
|
|
87
|
+
*/
|
|
88
|
+
export function derivedMode<A extends ModeAxis>(
|
|
89
|
+
axis: A,
|
|
90
|
+
evaluate: DerivedModeBuilder<A>,
|
|
91
|
+
options: { readonly domain?: ReadonlyArray<ModeValue<A>> } = {},
|
|
92
|
+
): DerivedModeRule<A> {
|
|
93
|
+
const out: DerivedModeRule<A> = options.domain !== undefined
|
|
94
|
+
? { __derivedModeRule: true, axis, evaluate, domain: options.domain }
|
|
95
|
+
: { __derivedModeRule: true, axis, evaluate };
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Brand check — distinguishes a DerivedModeRule from an aval/raw value
|
|
100
|
+
* at runtime so PipelineState consumers can route accordingly. */
|
|
101
|
+
export function isDerivedModeRule(x: unknown): x is DerivedModeRule {
|
|
102
|
+
return typeof x === "object" && x !== null
|
|
103
|
+
&& (x as { __derivedModeRule?: unknown }).__derivedModeRule === true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Convenience: flip 'back' ↔ 'front' (and pass through 'none').
|
|
107
|
+
* Useful in `(u, declared) => mirrored ? flipCull(declared) : declared`. */
|
|
108
|
+
export function flipCull(c: CullMode): CullMode {
|
|
109
|
+
return c === "back" ? "front" : c === "front" ? "back" : "none";
|
|
110
|
+
}
|
package/src/runtime/heapScene.ts
CHANGED
|
@@ -92,8 +92,9 @@ import {
|
|
|
92
92
|
ENC_V3F_TIGHT, SEM_POSITIONS, SEM_NORMALS,
|
|
93
93
|
type ArenaState,
|
|
94
94
|
} from "./heapScene/pools.js";
|
|
95
|
-
import { encodeModeKey } from "./pipelineCache/index.js";
|
|
95
|
+
import { encodeModeKey, type PipelineStateDescriptor } from "./pipelineCache/index.js";
|
|
96
96
|
import { snapshotDescriptor, ModeKeyTracker } from "./derivedModes/modeKeyCpu.js";
|
|
97
|
+
import { addMarkingCallback } from "@aardworx/wombat.adaptive";
|
|
97
98
|
|
|
98
99
|
// GrowBuffer + ALIGN16 + MIN_BUFFER_BYTES + POW2 live in ./heapScene/growBuffer.
|
|
99
100
|
// Packers (WGSL-type → JS-value → arena bytes) live in ./heapScene/packers.
|
|
@@ -340,6 +341,38 @@ export interface HeapDrawSpec {
|
|
|
340
341
|
* with depth write enabled. Matches the demo's existing visuals.
|
|
341
342
|
*/
|
|
342
343
|
readonly pipelineState?: PipelineState;
|
|
344
|
+
/**
|
|
345
|
+
* Per-RO derived pipeline-mode rules: per-axis closures
|
|
346
|
+
* `(u, declared) => modeValue` that compute the effective mode
|
|
347
|
+
* value as a function of this RO's uniforms (see
|
|
348
|
+
* `derivedMode(...)` in `runtime/derivedModes/rule.ts`).
|
|
349
|
+
*
|
|
350
|
+
* A rule overrides the corresponding `pipelineState.rasterizer.*`
|
|
351
|
+
* (or `pipelineState.depth.*`) aval value at descriptor-snapshot
|
|
352
|
+
* time. The rule's closure runs CPU-side with a proxy that
|
|
353
|
+
* resolves `u.<Name>` against this spec's `inputs[<Name>]`. The
|
|
354
|
+
* heap renderer auto-discovers the closure's input dependencies on
|
|
355
|
+
* first run and subscribes the per-RO ModeKeyTracker to each of
|
|
356
|
+
* them — so a rule that reads `u.ModelTrafo` reactively rebuckets
|
|
357
|
+
* when ModelTrafo marks.
|
|
358
|
+
*
|
|
359
|
+
* Common use cases:
|
|
360
|
+
* - cull: flip-by-determinant-sign for mirrored geometry
|
|
361
|
+
* - blend: pick a preset based on a boolean uniform
|
|
362
|
+
* - depthCompare: lighter passes for transparent overlays
|
|
363
|
+
*
|
|
364
|
+
* v1 evaluates rules CPU-side. v2 (deferred) lowers them to a GPU
|
|
365
|
+
* compute kernel for the bulk-animated-input case. See
|
|
366
|
+
* `docs/derived-modes.md`.
|
|
367
|
+
*/
|
|
368
|
+
readonly modeRules?: {
|
|
369
|
+
readonly cull?: import("./derivedModes/rule.js").DerivedModeRule<"cull">;
|
|
370
|
+
readonly frontFace?: import("./derivedModes/rule.js").DerivedModeRule<"frontFace">;
|
|
371
|
+
readonly topology?: import("./derivedModes/rule.js").DerivedModeRule<"topology">;
|
|
372
|
+
readonly depthCompare?: import("./derivedModes/rule.js").DerivedModeRule<"depthCompare">;
|
|
373
|
+
readonly depthWrite?: import("./derivedModes/rule.js").DerivedModeRule<"depthWrite">;
|
|
374
|
+
readonly alphaToCoverage?: import("./derivedModes/rule.js").DerivedModeRule<"alphaToCoverage">;
|
|
375
|
+
};
|
|
343
376
|
}
|
|
344
377
|
|
|
345
378
|
export interface HeapSceneStats {
|
|
@@ -578,11 +611,45 @@ export function buildHeapScene(
|
|
|
578
611
|
* references.
|
|
579
612
|
*/
|
|
580
613
|
const drawIdToSpec: (HeapDrawSpec | undefined)[] = [];
|
|
581
|
-
/** Per-draw ModeKeyTracker —
|
|
582
|
-
*
|
|
614
|
+
/** Per-draw ModeKeyTracker — recomputes the modeKey on demand. Does
|
|
615
|
+
* NOT install per-instance marking callbacks; subscriptions are
|
|
616
|
+
* scene-level (deduped by aval identity below). */
|
|
583
617
|
const drawIdToModeTracker: (ModeKeyTracker | undefined)[] = [];
|
|
584
618
|
/** drawIds whose PS marked since last update; drained by `update`. */
|
|
585
619
|
const dirtyModeKeyDrawIds = new Set<number>();
|
|
620
|
+
/**
|
|
621
|
+
* Scene-level mode-aval subscription dedupe. One IDisposable per
|
|
622
|
+
* unique leaf aval, plus a Set of dependent drawIds. When the aval
|
|
623
|
+
* marks, all drawIds in the set become dirty in O(1) per RO instead
|
|
624
|
+
* of installing N separate addMarkingCallbacks. The big win for the
|
|
625
|
+
* shared-cullCval case (20k ROs subscribing to one cval → 1 callback
|
|
626
|
+
* instead of 20k).
|
|
627
|
+
*/
|
|
628
|
+
const modeAvalToDrawIds = new Map<aval<unknown>, Set<number>>();
|
|
629
|
+
const modeAvalCallbacks = new Map<aval<unknown>, IDisposable>();
|
|
630
|
+
|
|
631
|
+
function subscribeModeLeaf(av: aval<unknown>, drawId: number): void {
|
|
632
|
+
let set = modeAvalToDrawIds.get(av);
|
|
633
|
+
if (set === undefined) {
|
|
634
|
+
set = new Set<number>();
|
|
635
|
+
modeAvalToDrawIds.set(av, set);
|
|
636
|
+
modeAvalCallbacks.set(av, addMarkingCallback(av, () => {
|
|
637
|
+
for (const id of set!) dirtyModeKeyDrawIds.add(id);
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
set.add(drawId);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function unsubscribeModeLeaf(av: aval<unknown>, drawId: number): void {
|
|
644
|
+
const set = modeAvalToDrawIds.get(av);
|
|
645
|
+
if (set === undefined) return;
|
|
646
|
+
set.delete(drawId);
|
|
647
|
+
if (set.size === 0) {
|
|
648
|
+
modeAvalToDrawIds.delete(av);
|
|
649
|
+
const cb = modeAvalCallbacks.get(av);
|
|
650
|
+
if (cb !== undefined) { cb.dispose(); modeAvalCallbacks.delete(av); }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
586
653
|
let nextDrawId = 0;
|
|
587
654
|
|
|
588
655
|
/**
|
|
@@ -1438,6 +1505,13 @@ export function buildHeapScene(
|
|
|
1438
1505
|
effect: Effect,
|
|
1439
1506
|
_textures: HeapTextureSet | undefined,
|
|
1440
1507
|
pipelineState: PipelineState | undefined,
|
|
1508
|
+
/**
|
|
1509
|
+
* Optional precomputed descriptor. addDrawImpl passes this when
|
|
1510
|
+
* the spec carries `modeRules` so the bucket key reflects the
|
|
1511
|
+
* rule-evaluated value (not the raw PS aval values). When omitted
|
|
1512
|
+
* the descriptor is snapshotted from PS as before.
|
|
1513
|
+
*/
|
|
1514
|
+
precomputedDescriptor?: PipelineStateDescriptor,
|
|
1441
1515
|
): Bucket {
|
|
1442
1516
|
if (!familyBuilt) {
|
|
1443
1517
|
throw new Error("heapScene: findOrCreateBucket called before family build");
|
|
@@ -1451,12 +1525,24 @@ export function buildHeapScene(
|
|
|
1451
1525
|
// (the actual value-set in the descriptor), so identical-value
|
|
1452
1526
|
// cvals collapse to ONE bucket. See docs/derived-modes.md and
|
|
1453
1527
|
// tests/heap-multi-pipeline-bucket.test.ts.
|
|
1454
|
-
const psDescriptor = snapshotDescriptor(pipelineState, sig);
|
|
1528
|
+
const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
|
|
1455
1529
|
const psModeKey = encodeModeKey(psDescriptor);
|
|
1456
1530
|
const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
|
|
1457
1531
|
const existing = bucketByKey.get(bk);
|
|
1458
1532
|
if (existing !== undefined) return existing;
|
|
1459
|
-
|
|
1533
|
+
// If a precomputed descriptor is supplied (rule-evaluated path),
|
|
1534
|
+
// use its post-rule axis values so the pipeline reflects what the
|
|
1535
|
+
// RO actually wants. Otherwise fall through to PS-aval forcing.
|
|
1536
|
+
const ps: ResolvedPipelineState = precomputedDescriptor !== undefined
|
|
1537
|
+
? {
|
|
1538
|
+
topology: precomputedDescriptor.topology,
|
|
1539
|
+
cullMode: precomputedDescriptor.cullMode,
|
|
1540
|
+
frontFace: precomputedDescriptor.frontFace,
|
|
1541
|
+
...(precomputedDescriptor.depth !== undefined
|
|
1542
|
+
? { depth: { write: precomputedDescriptor.depth.write, compare: precomputedDescriptor.depth.compare } }
|
|
1543
|
+
: {}),
|
|
1544
|
+
}
|
|
1545
|
+
: resolvePipelineState(pipelineState);
|
|
1460
1546
|
|
|
1461
1547
|
const layout: BucketLayout = fam.schema.drawHeaderUnion;
|
|
1462
1548
|
const isAtlasBucket = layout.atlasTextureBindings.size > 0;
|
|
@@ -1765,7 +1851,24 @@ export function buildHeapScene(
|
|
|
1765
1851
|
const perInstanceAttributes = spec.instanceAttributes !== undefined
|
|
1766
1852
|
? new Set(Object.keys(spec.instanceAttributes))
|
|
1767
1853
|
: new Set<string>();
|
|
1768
|
-
|
|
1854
|
+
|
|
1855
|
+
// If the spec has modeRules, pre-snapshot the rule-evaluated
|
|
1856
|
+
// descriptor so the bucket key + pipeline reflect post-rule
|
|
1857
|
+
// values. Without this, two ROs with identical PS but different
|
|
1858
|
+
// rule outputs would collide on the same bucket.
|
|
1859
|
+
let precomputedDescriptor: PipelineStateDescriptor | undefined;
|
|
1860
|
+
if (spec.modeRules !== undefined) {
|
|
1861
|
+
const uAvals = new Map<string, aval<unknown>>();
|
|
1862
|
+
for (const [name, val] of Object.entries(spec.inputs)) {
|
|
1863
|
+
uAvals.set(name, asAval(val as aval<unknown> | unknown));
|
|
1864
|
+
}
|
|
1865
|
+
precomputedDescriptor = snapshotDescriptor(spec.pipelineState, sig, {
|
|
1866
|
+
modeRules: spec.modeRules,
|
|
1867
|
+
uniformAvals: uAvals,
|
|
1868
|
+
token: outerTok,
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState, precomputedDescriptor);
|
|
1769
1872
|
const fam = familyFor(spec.effect);
|
|
1770
1873
|
const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
|
|
1771
1874
|
|
|
@@ -1947,10 +2050,31 @@ export function buildHeapScene(
|
|
|
1947
2050
|
// value must move the RO to a different bucket. Without this
|
|
1948
2051
|
// tracker the cval flip would be silently ignored.
|
|
1949
2052
|
drawIdToSpec[drawId] = spec;
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2053
|
+
// skipSubscribe: this tracker doesn't install its own callbacks.
|
|
2054
|
+
// The scene-level `modeAvalToDrawIds` does it once per unique aval
|
|
2055
|
+
// (the 20k-ROs-share-one-cullCval optimization).
|
|
2056
|
+
// If the spec has modeRules, pre-build a uniforms-aval map the
|
|
2057
|
+
// rule closures will read against; the tracker auto-discovers
|
|
2058
|
+
// which avals each rule reads and surfaces them via forEachLeaf.
|
|
2059
|
+
let uniformAvals: ReadonlyMap<string, aval<unknown>> | undefined;
|
|
2060
|
+
if (spec.modeRules !== undefined) {
|
|
2061
|
+
const m = new Map<string, aval<unknown>>();
|
|
2062
|
+
for (const [name, val] of Object.entries(spec.inputs)) {
|
|
2063
|
+
m.set(name, asAval(val as aval<unknown> | unknown));
|
|
2064
|
+
}
|
|
2065
|
+
uniformAvals = m;
|
|
2066
|
+
}
|
|
2067
|
+
const tracker = new ModeKeyTracker(
|
|
2068
|
+
spec.pipelineState, sig,
|
|
2069
|
+
() => { dirtyModeKeyDrawIds.add(drawId); },
|
|
2070
|
+
{
|
|
2071
|
+
skipSubscribe: true,
|
|
2072
|
+
...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
|
|
2073
|
+
...(uniformAvals !== undefined ? { uniformAvals } : {}),
|
|
2074
|
+
},
|
|
2075
|
+
);
|
|
1953
2076
|
drawIdToModeTracker[drawId] = tracker;
|
|
2077
|
+
tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
|
|
1954
2078
|
|
|
1955
2079
|
// ─── §7 derived-uniforms registration ────────────────────────────
|
|
1956
2080
|
// A uniform binding on this RO is either a value (aval/constant) or a rule —
|
|
@@ -2106,7 +2230,10 @@ export function buildHeapScene(
|
|
|
2106
2230
|
drawIdToIndexAval[drawId] = undefined;
|
|
2107
2231
|
drawIdToSpec[drawId] = undefined;
|
|
2108
2232
|
const oldTracker = drawIdToModeTracker[drawId];
|
|
2109
|
-
if (oldTracker !== undefined)
|
|
2233
|
+
if (oldTracker !== undefined) {
|
|
2234
|
+
oldTracker.forEachLeaf((av) => unsubscribeModeLeaf(av, drawId));
|
|
2235
|
+
oldTracker.dispose();
|
|
2236
|
+
}
|
|
2110
2237
|
drawIdToModeTracker[drawId] = undefined;
|
|
2111
2238
|
dirtyModeKeyDrawIds.delete(drawId);
|
|
2112
2239
|
|
|
@@ -2212,20 +2339,90 @@ export function buildHeapScene(
|
|
|
2212
2339
|
if (dirtyModeKeyDrawIds.size > 0) {
|
|
2213
2340
|
const dirty = [...dirtyModeKeyDrawIds];
|
|
2214
2341
|
dirtyModeKeyDrawIds.clear();
|
|
2342
|
+
|
|
2343
|
+
// Bucket-level fast path: group dirty drawIds by bucket. If
|
|
2344
|
+
// ALL of a bucket's ROs are dirty (typical SG case where one
|
|
2345
|
+
// shared aval drives every leaf — e.g. <Sg CullMode={cullC}>
|
|
2346
|
+
// wrapping 20k leaves), the whole bucket transitions together.
|
|
2347
|
+
// Rebuild the bucket's pipeline + rename in bucketByKey,
|
|
2348
|
+
// O(1) work instead of N remove+add cycles.
|
|
2349
|
+
const byBucket = new Map<Bucket, number[]>();
|
|
2215
2350
|
for (const drawId of dirty) {
|
|
2216
|
-
const
|
|
2217
|
-
if (
|
|
2218
|
-
|
|
2219
|
-
if (
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
//
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2351
|
+
const b = drawIdToBucket[drawId];
|
|
2352
|
+
if (b === undefined) continue; // RO removed since mark
|
|
2353
|
+
let arr = byBucket.get(b);
|
|
2354
|
+
if (arr === undefined) { arr = []; byBucket.set(b, arr); }
|
|
2355
|
+
arr.push(drawId);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
for (const [bucket, ids] of byBucket) {
|
|
2359
|
+
// First recompute every dirty tracker so .modeKey is fresh.
|
|
2360
|
+
// Filter out ones whose value didn't actually change.
|
|
2361
|
+
const changed: number[] = [];
|
|
2362
|
+
for (const drawId of ids) {
|
|
2363
|
+
const tracker = drawIdToModeTracker[drawId];
|
|
2364
|
+
if (tracker === undefined) continue;
|
|
2365
|
+
const oldKey = tracker.modeKey;
|
|
2366
|
+
if (!tracker.recompute()) continue;
|
|
2367
|
+
if (tracker.modeKey === oldKey) continue;
|
|
2368
|
+
changed.push(drawId);
|
|
2369
|
+
}
|
|
2370
|
+
if (changed.length === 0) continue;
|
|
2371
|
+
|
|
2372
|
+
// Try the all-together fast path.
|
|
2373
|
+
const wholeBucket = changed.length === bucket.drawSlots.size;
|
|
2374
|
+
if (wholeBucket) {
|
|
2375
|
+
const repId = changed[0]!;
|
|
2376
|
+
const repTracker = drawIdToModeTracker[repId]!;
|
|
2377
|
+
const newKey = repTracker.modeKey;
|
|
2378
|
+
// Compute the new bucketByKey string; we need the family.
|
|
2379
|
+
const sample = drawIdToSpec[repId]!;
|
|
2380
|
+
const fam = familyFor(sample.effect);
|
|
2381
|
+
const newBk = `family#${fam.schema.id}|mk#${newKey.toString(16)}`;
|
|
2382
|
+
const existing = bucketByKey.get(newBk);
|
|
2383
|
+
if (existing === undefined) {
|
|
2384
|
+
// No conflict — rename + rebuild pipeline in place.
|
|
2385
|
+
// Find this bucket's current key by linear scan
|
|
2386
|
+
// (buckets ≤ ~100 typically, so this is cheap).
|
|
2387
|
+
let oldBk: string | undefined;
|
|
2388
|
+
for (const [k, v] of bucketByKey) {
|
|
2389
|
+
if (v === bucket) { oldBk = k; break; }
|
|
2390
|
+
}
|
|
2391
|
+
if (oldBk !== undefined) bucketByKey.delete(oldBk);
|
|
2392
|
+
bucketByKey.set(newBk, bucket);
|
|
2393
|
+
// Build the new pipeline with the snapshotted PS.
|
|
2394
|
+
const desc = repTracker.descriptor;
|
|
2395
|
+
const { pipelineLayout } = getBgl(bucket.layout, bucket.isAtlasBucket);
|
|
2396
|
+
const newPipeline = device.createRenderPipeline({
|
|
2397
|
+
label: `heapScene/${newBk}/pipeline`,
|
|
2398
|
+
layout: pipelineLayout,
|
|
2399
|
+
vertex: { module: familyFor(sample.effect).vsModule, entryPoint: familyFor(sample.effect).vsEntryName, buffers: [] },
|
|
2400
|
+
fragment: { module: familyFor(sample.effect).fsModule, entryPoint: familyFor(sample.effect).fsEntryName, targets: colorTargets },
|
|
2401
|
+
primitive: { topology: desc.topology, cullMode: desc.cullMode, frontFace: desc.frontFace },
|
|
2402
|
+
...(depthFormat !== undefined && desc.depth !== undefined
|
|
2403
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: desc.depth.write, depthCompare: desc.depth.compare } }
|
|
2404
|
+
: depthFormat !== undefined
|
|
2405
|
+
? { depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: "always" as GPUCompareFunction } }
|
|
2406
|
+
: {}),
|
|
2407
|
+
});
|
|
2408
|
+
// Swap pipeline reference on the bucket's single slot.
|
|
2409
|
+
// (Multi-slot Phase 5c would update one slot at a time.)
|
|
2410
|
+
(bucket.slots[0] as { pipeline: GPURenderPipeline }).pipeline = newPipeline;
|
|
2411
|
+
continue; // done with this bucket
|
|
2412
|
+
}
|
|
2413
|
+
// Conflict: another bucket already owns newBk. Fall through
|
|
2414
|
+
// to per-RO rebucket below, which will merge into `existing`.
|
|
2415
|
+
}
|
|
2416
|
+
// Slow path — per-RO rebucket (used when partial transition
|
|
2417
|
+
// OR when a merge with an existing destination bucket is
|
|
2418
|
+
// required).
|
|
2419
|
+
for (const drawId of changed) {
|
|
2420
|
+
const spec = drawIdToSpec[drawId];
|
|
2421
|
+
if (spec === undefined) continue;
|
|
2422
|
+
removeDraw(drawId);
|
|
2423
|
+
const newId = addDrawImpl(spec, tok);
|
|
2424
|
+
void newId;
|
|
2425
|
+
}
|
|
2229
2426
|
}
|
|
2230
2427
|
}
|
|
2231
2428
|
|
package/src/runtime/index.ts
CHANGED
|
@@ -126,3 +126,13 @@ export {
|
|
|
126
126
|
type PartitionInput,
|
|
127
127
|
type PartitionResult,
|
|
128
128
|
} from "./derivedModes/partition.js";
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
derivedMode,
|
|
132
|
+
isDerivedModeRule,
|
|
133
|
+
flipCull,
|
|
134
|
+
type DerivedModeRule,
|
|
135
|
+
type DerivedModeBuilder,
|
|
136
|
+
type ModeAxis,
|
|
137
|
+
type ModeValue,
|
|
138
|
+
} from "./derivedModes/rule.js";
|