@aardworx/wombat.rendering 0.9.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aardworx/wombat.rendering",
3
- "version": "0.9.16",
3
+ "version": "0.9.17",
4
4
  "description": "WebGPU rendering layer for the Wombat TypeScript stack — RenderObject/RenderTask/runtime + window glue, port of Aardvark.Rendering's lower layers on top of WebGPU and wombat.shader.",
5
5
  "license": "MIT",
6
6
  "author": "krauthaufen",
@@ -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,6 +219,11 @@ 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,
@@ -144,43 +237,63 @@ export class ModeKeyTracker implements IDisposable {
144
237
  * cullCval case) — saves N subscriptions and N callback dispatches
145
238
  * per mark.
146
239
  */
147
- options: { skipSubscribe?: boolean } = {},
240
+ options: {
241
+ skipSubscribe?: boolean;
242
+ modeRules?: RoModeRules;
243
+ uniformAvals?: ReadonlyMap<string, aval<unknown>>;
244
+ } = {},
148
245
  ) {
149
246
  this.ps = ps;
150
247
  this.signature = signature;
151
248
  this.onDirty = onDirty;
152
- this.cachedDescriptor = snapshotDescriptor(ps, signature);
249
+ if (options.modeRules !== undefined) this.modeRules = options.modeRules;
250
+ if (options.uniformAvals !== undefined) this.uniformAvals = options.uniformAvals;
251
+ this.cachedDescriptor = this.snapshot();
153
252
  this.cachedModeKey = encodeModeKey(this.cachedDescriptor);
154
253
  if (options.skipSubscribe !== true) this.subscribeAll();
155
254
  }
156
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
+
157
264
  /**
158
265
  * Walk every leaf aval in this tracker's PipelineState and invoke
159
266
  * `visit(aval)`. Used by the heap scene to register ONE
160
267
  * addMarkingCallback per unique aval across many trackers.
161
268
  */
162
269
  forEachLeaf(visit: (a: aval<unknown>) => void): void {
163
- if (this.ps === undefined) return;
164
270
  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);
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
+ }
181
289
  }
290
+ if (ps.alphaToCoverage !== undefined) visit(ps.alphaToCoverage);
182
291
  }
183
- if (ps.alphaToCoverage !== undefined) visit(ps.alphaToCoverage);
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);
184
297
  }
185
298
 
186
299
  get descriptor(): PipelineStateDescriptor { return this.cachedDescriptor; }
@@ -190,9 +303,15 @@ export class ModeKeyTracker implements IDisposable {
190
303
  * Snapshot current aval values and rebuild descriptor + modeKey.
191
304
  * Returns true iff the modeKey changed (so the bucket only needs to
192
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.
193
311
  */
194
312
  recompute(): boolean {
195
- const next = snapshotDescriptor(this.ps, this.signature);
313
+ this.ruleDeps.clear();
314
+ const next = this.snapshot();
196
315
  const nextKey = encodeModeKey(next);
197
316
  if (nextKey === this.cachedModeKey) return false;
198
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
+ }
@@ -92,7 +92,7 @@ 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
97
  import { addMarkingCallback } from "@aardworx/wombat.adaptive";
98
98
 
@@ -341,6 +341,38 @@ export interface HeapDrawSpec {
341
341
  * with depth write enabled. Matches the demo's existing visuals.
342
342
  */
343
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
+ };
344
376
  }
345
377
 
346
378
  export interface HeapSceneStats {
@@ -1473,6 +1505,13 @@ export function buildHeapScene(
1473
1505
  effect: Effect,
1474
1506
  _textures: HeapTextureSet | undefined,
1475
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,
1476
1515
  ): Bucket {
1477
1516
  if (!familyBuilt) {
1478
1517
  throw new Error("heapScene: findOrCreateBucket called before family build");
@@ -1486,12 +1525,24 @@ export function buildHeapScene(
1486
1525
  // (the actual value-set in the descriptor), so identical-value
1487
1526
  // cvals collapse to ONE bucket. See docs/derived-modes.md and
1488
1527
  // tests/heap-multi-pipeline-bucket.test.ts.
1489
- const psDescriptor = snapshotDescriptor(pipelineState, sig);
1528
+ const psDescriptor = precomputedDescriptor ?? snapshotDescriptor(pipelineState, sig);
1490
1529
  const psModeKey = encodeModeKey(psDescriptor);
1491
1530
  const bk = `family#${fam.schema.id}|mk#${psModeKey.toString(16)}`;
1492
1531
  const existing = bucketByKey.get(bk);
1493
1532
  if (existing !== undefined) return existing;
1494
- const ps = resolvePipelineState(pipelineState);
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);
1495
1546
 
1496
1547
  const layout: BucketLayout = fam.schema.drawHeaderUnion;
1497
1548
  const isAtlasBucket = layout.atlasTextureBindings.size > 0;
@@ -1800,7 +1851,24 @@ export function buildHeapScene(
1800
1851
  const perInstanceAttributes = spec.instanceAttributes !== undefined
1801
1852
  ? new Set(Object.keys(spec.instanceAttributes))
1802
1853
  : new Set<string>();
1803
- const bucket = findOrCreateBucket(spec.effect, spec.textures, spec.pipelineState);
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);
1804
1872
  const fam = familyFor(spec.effect);
1805
1873
  const effectFields = fam.fieldsForEffect.get(spec.effect.id)!;
1806
1874
 
@@ -1985,10 +2053,25 @@ export function buildHeapScene(
1985
2053
  // skipSubscribe: this tracker doesn't install its own callbacks.
1986
2054
  // The scene-level `modeAvalToDrawIds` does it once per unique aval
1987
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
+ }
1988
2067
  const tracker = new ModeKeyTracker(
1989
2068
  spec.pipelineState, sig,
1990
2069
  () => { dirtyModeKeyDrawIds.add(drawId); },
1991
- { skipSubscribe: true },
2070
+ {
2071
+ skipSubscribe: true,
2072
+ ...(spec.modeRules !== undefined ? { modeRules: spec.modeRules } : {}),
2073
+ ...(uniformAvals !== undefined ? { uniformAvals } : {}),
2074
+ },
1992
2075
  );
1993
2076
  drawIdToModeTracker[drawId] = tracker;
1994
2077
  tracker.forEachLeaf((av) => subscribeModeLeaf(av, drawId));
@@ -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";