@aardworx/wombat.rendering 0.9.11 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/runtime/derivedUniforms/codegen.d.ts +8 -0
  2. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -0
  3. package/dist/runtime/derivedUniforms/codegen.js +449 -0
  4. package/dist/runtime/derivedUniforms/codegen.js.map +1 -0
  5. package/dist/runtime/derivedUniforms/dispatch.d.ts +14 -93
  6. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  7. package/dist/runtime/derivedUniforms/dispatch.js +123 -299
  8. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  9. package/dist/runtime/derivedUniforms/flatten.d.ts +25 -0
  10. package/dist/runtime/derivedUniforms/flatten.d.ts.map +1 -0
  11. package/dist/runtime/derivedUniforms/flatten.js +71 -0
  12. package/dist/runtime/derivedUniforms/flatten.js.map +1 -0
  13. package/dist/runtime/derivedUniforms/index.d.ts +10 -5
  14. package/dist/runtime/derivedUniforms/index.d.ts.map +1 -1
  15. package/dist/runtime/derivedUniforms/index.js +24 -19
  16. package/dist/runtime/derivedUniforms/index.js.map +1 -1
  17. package/dist/runtime/derivedUniforms/marker.d.ts +32 -0
  18. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -0
  19. package/dist/runtime/derivedUniforms/marker.js +84 -0
  20. package/dist/runtime/derivedUniforms/marker.js.map +1 -0
  21. package/dist/runtime/derivedUniforms/recipes.d.ts +6 -41
  22. package/dist/runtime/derivedUniforms/recipes.d.ts.map +1 -1
  23. package/dist/runtime/derivedUniforms/recipes.js +53 -129
  24. package/dist/runtime/derivedUniforms/recipes.js.map +1 -1
  25. package/dist/runtime/derivedUniforms/records.d.ts +40 -0
  26. package/dist/runtime/derivedUniforms/records.d.ts.map +1 -0
  27. package/dist/runtime/derivedUniforms/records.js +151 -0
  28. package/dist/runtime/derivedUniforms/records.js.map +1 -0
  29. package/dist/runtime/derivedUniforms/registry.d.ts +33 -0
  30. package/dist/runtime/derivedUniforms/registry.d.ts.map +1 -0
  31. package/dist/runtime/derivedUniforms/registry.js +58 -0
  32. package/dist/runtime/derivedUniforms/registry.js.map +1 -0
  33. package/dist/runtime/derivedUniforms/rule.d.ts +26 -0
  34. package/dist/runtime/derivedUniforms/rule.d.ts.map +1 -0
  35. package/dist/runtime/derivedUniforms/rule.js +35 -0
  36. package/dist/runtime/derivedUniforms/rule.js.map +1 -0
  37. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +34 -33
  38. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  39. package/dist/runtime/derivedUniforms/sceneIntegration.js +80 -90
  40. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  41. package/dist/runtime/heapScene.d.ts.map +1 -1
  42. package/dist/runtime/heapScene.js +72 -27
  43. package/dist/runtime/heapScene.js.map +1 -1
  44. package/dist/runtime/hybridScene.js +1 -1
  45. package/dist/runtime/hybridScene.js.map +1 -1
  46. package/dist/runtime/index.d.ts +1 -0
  47. package/dist/runtime/index.d.ts.map +1 -1
  48. package/dist/runtime/index.js +2 -0
  49. package/dist/runtime/index.js.map +1 -1
  50. package/dist/runtime/renderTask.js +1 -1
  51. package/dist/runtime/renderTask.js.map +1 -1
  52. package/dist/runtime/runtime.js +1 -1
  53. package/dist/runtime/runtime.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/runtime/derivedUniforms/codegen.ts +477 -0
  56. package/src/runtime/derivedUniforms/dispatch.ts +135 -341
  57. package/src/runtime/derivedUniforms/flatten.ts +89 -0
  58. package/src/runtime/derivedUniforms/index.ts +50 -34
  59. package/src/runtime/derivedUniforms/marker.ts +91 -0
  60. package/src/runtime/derivedUniforms/recipes.ts +63 -155
  61. package/src/runtime/derivedUniforms/records.ts +158 -0
  62. package/src/runtime/derivedUniforms/registry.ts +77 -0
  63. package/src/runtime/derivedUniforms/rule.ts +59 -0
  64. package/src/runtime/derivedUniforms/sceneIntegration.ts +115 -149
  65. package/src/runtime/heapScene.ts +73 -29
  66. package/src/runtime/hybridScene.ts +1 -1
  67. package/src/runtime/index.ts +8 -0
  68. package/src/runtime/renderTask.ts +1 -1
  69. package/src/runtime/runtime.ts +1 -1
  70. package/dist/runtime/derivedUniforms/slotId.d.ts +0 -15
  71. package/dist/runtime/derivedUniforms/slotId.d.ts.map +0 -1
  72. package/dist/runtime/derivedUniforms/slotId.js +0 -30
  73. package/dist/runtime/derivedUniforms/slotId.js.map +0 -1
  74. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts +0 -13
  75. package/dist/runtime/derivedUniforms/uberKernel.wgsl.d.ts.map +0 -1
  76. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js +0 -218
  77. package/dist/runtime/derivedUniforms/uberKernel.wgsl.js.map +0 -1
  78. package/src/runtime/derivedUniforms/slotId.ts +0 -35
  79. package/src/runtime/derivedUniforms/uberKernel.wgsl.ts +0 -220
@@ -0,0 +1,59 @@
1
+ // §7 v2 — derived-uniform rule: a content-hashed pure IR fragment.
2
+ //
3
+ // A DerivedRule is a closure-shaped value: an output WGSL type plus an IR
4
+ // expression ("ir") that computes it from `ReadInput("Uniform", name)` leaves.
5
+ // The names are resolved per-RenderObject at registration time (constituent
6
+ // slot / host uniform). See docs/derived-uniforms-extensible.md.
7
+ //
8
+ // This is the pre-plugin surface: rules are hand-built from IR exprs. The
9
+ // inline `derivedUniform(u => …)` marker (lowered by the wombat-shader-vite
10
+ // plugin) lands later and produces exactly this shape.
11
+
12
+ import type { Expr, Type } from "@aardworx/wombat.shader/ir";
13
+ import { hashValue, stableStringify } from "@aardworx/wombat.shader/ir";
14
+
15
+ /** A rule's IR fragment: the result expression, over `ReadInput("Uniform", …)` leaves. */
16
+ export type IRFragment = Expr;
17
+
18
+ export interface DerivedRule<T = unknown> {
19
+ /** Brand — lets `isDerivedRule` recognise a rule among uniform binding values. */
20
+ readonly __derivedRule: true;
21
+ /** Output WGSL type (`=== ir.type`). Drives the storer codegen and the drawHeader byte budget. */
22
+ readonly outputType: Type;
23
+ /** Pure IR computing the output; every leaf input is a `ReadInput("Uniform", name)`. */
24
+ readonly ir: IRFragment;
25
+ /** Stable structural content hash of `ir` — the registry dedup key. */
26
+ readonly hash: string;
27
+ /** Phantom — carries the TS result type through `derivedUniform<T>`. Never read. */
28
+ readonly __t?: T;
29
+ }
30
+
31
+ /** True iff `x` is a derived-uniform rule (vs. an `aval` / constant value). */
32
+ export function isDerivedRule(x: unknown): x is DerivedRule {
33
+ return typeof x === "object" && x !== null && (x as { __derivedRule?: unknown }).__derivedRule === true;
34
+ }
35
+
36
+ /** True iff two IR `Type`s are structurally identical. */
37
+ export function sameType(a: Type, b: Type): boolean {
38
+ return a === b || stableStringify(a) === stableStringify(b);
39
+ }
40
+
41
+ /** Stable content hash of an IR expression. */
42
+ export function hashIR(ir: Expr): string {
43
+ return hashValue(ir);
44
+ }
45
+
46
+ /** Build a `DerivedRule` from a hand-constructed IR expression. */
47
+ export function ruleFromIR<T = unknown>(ir: Expr, outputType: Type = ir.type): DerivedRule<T> {
48
+ if (!sameType(outputType, ir.type)) {
49
+ throw new Error(
50
+ `derived rule: declared outputType ${stableStringify(outputType)} != ir type ${stableStringify(ir.type)}`,
51
+ );
52
+ }
53
+ return { __derivedRule: true, outputType, ir, hash: hashIR(ir) };
54
+ }
55
+
56
+ /** A `ReadInput("Uniform", name)` leaf of the given type — the only leaf kind a rule may use. */
57
+ export function uniformRef(name: string, type: Type): Expr {
58
+ return { kind: "ReadInput", scope: "Uniform", name, type };
59
+ }
@@ -1,84 +1,61 @@
1
- // §7 heap scene integration helper.
1
+ // §7 v2 heap-scene integration.
2
2
  //
3
- // Subscription model. Standard adaptive idiom: calling `av.getValue(t)`
4
- // inside an `evaluateAlways(t, ...)` scope (re-)adds the evaluating
5
- // object to `av.outputs`. We don't manage subscription manually.
3
+ // One DerivedUniformsScene per heap scene: owns the rule registry, the (single,
4
+ // scene-wide) records buffer, the constituent-slots heap (df32 trafo halves), the
5
+ // constituents GPU buffer, and the dispatcher. Per RO: flatten each rule against the
6
+ // RO's rule set, register it, resolve every input leaf to a tagged slot handle
7
+ // (constituent fwd/bwd / host drawHeader byte), and push one record.
6
8
  //
7
- // Per frame, the heap scene's `update()` runs `evaluateAlways` once on
8
- // the parent scene-object (as it already does). Inside that scope:
9
- // 1. `scene.constituents.pullDirty(token)` drains the dirty queue.
10
- // For each dirty aval, it calls `getValue(token)` which both
11
- // gives us the new value AND re-establishes our subscription on
12
- // that specific aval. O(changed). Avals that didn't change keep
13
- // us in their outputs from earlier evaluations.
14
- // 2. `scene.uploadDirty(dirtySlots)` O(changed) value uploads +
15
- // O(changed) bitmask flips.
16
- // 3. Per bucket: `bucket.dispatcher.encode(enc)`. The kernel
17
- // early-returns on records whose inputs are all clean.
18
- //
19
- // On clean frames pullDirty/uploadDirty are no-ops. The records
20
- // buffer is static. CPU steady-state cost: zero.
21
-
22
- import type { aval, IAdaptiveObject } from "@aardworx/wombat.adaptive";
9
+ // Per frame, inside the heap scene's `evaluateAlways(token, …)` scope:
10
+ // const dirty = scene.pullDirty(token); // drains changed trafo avals (re-subscribes)
11
+ // scene.uploadDirty(dirty); // O(changed) value uploads
12
+ // scene.encode(enc); // one compute dispatch
13
+ // Clean frames: zero CPU work, records buffer static.
14
+ // See docs/derived-uniforms-extensible.md.
15
+
16
+ import type { aval, IAdaptiveObject, AdaptiveToken } from "@aardworx/wombat.adaptive";
23
17
  import type { Trafo3d } from "@aardworx/wombat.base";
24
18
  import {
25
- ConstituentSlots,
26
- DF32_MAT4_BYTES,
27
- type DerivationRecord, type PairedSlots, type SlotIndex,
28
- type SubscribeFn,
19
+ ConstituentSlots, DF32_MAT4_BYTES,
20
+ type PairedSlots, type SlotIndex,
29
21
  } from "./slots.js";
30
- import {
31
- RecipeId, recipeIdByName, recipeInputs, recipeInputCount,
32
- DERIVED_UNIFORM_NAMES,
33
- type ConstituentRef,
34
- } from "./recipes.js";
35
- import {
36
- DerivedUniformsDispatcher, DerivedUniformsPipeline,
37
- uploadConstituentsRange,
38
- } from "./dispatch.js";
39
-
40
- /** Scene-wide §7 state. One per heap scene. Owns the single records
41
- * buffer + dispatcher — all buckets share constituents, dirty mask,
42
- * AND main heap (arena.attrs.buffer), so per-bucket records would
43
- * just multiply dispatch overhead for no reason. */
22
+ import { DerivedUniformRegistry } from "./registry.js";
23
+ import { RecordsBuffer, SlotTag, makeHandle } from "./records.js";
24
+ import { DerivedUniformsDispatcher, uploadConstituentsRange } from "./dispatch.js";
25
+ import { flatten, type RuleInput } from "./flatten.js";
26
+ import { ruleFromIR, type DerivedRule } from "./rule.js";
27
+
28
+ export interface DerivedUniformsSceneOptions {
29
+ readonly initialConstituentSlots?: number;
30
+ }
31
+
44
32
  export class DerivedUniformsScene {
45
33
  readonly device: GPUDevice;
46
34
  readonly constituents: ConstituentSlots;
47
- readonly pipeline: DerivedUniformsPipeline;
48
- readonly dispatcher: DerivedUniformsDispatcher;
49
- /** Constituents storage GPU buffer (df32 mat4 array). */
35
+ readonly registry = new DerivedUniformRegistry();
36
+ /** Single scene-wide records buffer — all buckets share constituents AND the main heap, so per-bucket records would just multiply dispatch overhead. */
37
+ readonly records = new RecordsBuffer();
38
+ readonly dispatcher: DerivedUniformsDispatcher;
39
+ /** Constituents storage GPU buffer (`array<vec2<f32>>` — df32 mat4 halves). */
50
40
  constituentsBuf: GPUBuffer;
51
- /** Caller-owned main heap (arena.attrs.buffer). Updated via
52
- * `rebindMainHeap` on arena resize. */
53
- private mainHeapRef: { current: GPUBuffer };
41
+ private readonly mainHeapRef: { current: GPUBuffer };
54
42
  /** Bumped each time a shared GPU buffer is replaced. */
55
43
  bufferEpoch = 0;
56
44
 
57
- constructor(
58
- device: GPUDevice,
59
- mainHeapBuf: GPUBuffer,
60
- opts?: { initialConstituentSlots?: number; initialRecordCapacity?: number },
61
- ) {
45
+ constructor(device: GPUDevice, mainHeapBuf: GPUBuffer, opts?: DerivedUniformsSceneOptions) {
62
46
  this.device = device;
63
47
  const initial = opts?.initialConstituentSlots ?? 64;
64
- const subscribe: SubscribeFn = () => {};
65
- this.constituents = new ConstituentSlots(subscribe, initial);
66
- this.pipeline = new DerivedUniformsPipeline(device);
48
+ this.constituents = new ConstituentSlots(() => {}, initial);
67
49
  this.constituentsBuf = device.createBuffer({
68
50
  label: "derivedUniforms.constituents",
69
- size: initial * DF32_MAT4_BYTES,
51
+ size: initial * DF32_MAT4_BYTES,
70
52
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
71
53
  });
72
54
  this.mainHeapRef = { current: mainHeapBuf };
73
- this.dispatcher = new DerivedUniformsDispatcher(
74
- device,
75
- this.pipeline,
76
- {
77
- constituentsBuf: () => this.constituentsBuf,
78
- mainHeapBuf: () => this.mainHeapRef.current,
79
- },
80
- opts?.initialRecordCapacity ?? 64,
81
- );
55
+ this.dispatcher = new DerivedUniformsDispatcher(device, {
56
+ constituentsBuf: () => this.constituentsBuf,
57
+ mainHeapBuf: () => this.mainHeapRef.current,
58
+ });
82
59
  }
83
60
 
84
61
  rebindMainHeap(mainHeapBuf: GPUBuffer): void {
@@ -92,40 +69,42 @@ export class DerivedUniformsScene {
92
69
  this.constituentsBuf.destroy();
93
70
  this.constituentsBuf = this.device.createBuffer({
94
71
  label: "derivedUniforms.constituents",
95
- size: cap,
72
+ size: cap,
96
73
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
97
74
  });
98
75
  this.bufferEpoch++;
99
- // The fresh buffer is zero-initialized; the previous contents are
100
- // gone. Re-upload everything we've ever packed (slot 0..slotCount).
101
- // Without this, slots that were last written before the grow read
102
- // zero on the GPU — every df_mul on them produces NaN.
76
+ // Fresh buffer is zero-initialised; re-upload everything packed so far.
103
77
  const slotCount = this.constituents.slotCount;
104
78
  if (slotCount > 0) {
105
79
  const mirror = this.constituents.cpuMirror;
106
80
  this.device.queue.writeBuffer(
107
- this.constituentsBuf, 0,
108
- mirror.buffer, mirror.byteOffset,
109
- slotCount * DF32_MAT4_BYTES,
81
+ this.constituentsBuf, 0, mirror.buffer, mirror.byteOffset, slotCount * DF32_MAT4_BYTES,
110
82
  );
111
83
  }
112
84
  }
113
85
 
114
- /** Routed from heap scene's `inputChanged(t, o)`. Returns true iff
115
- * `o` matched a known constituent aval. */
86
+ /** Routed from the heap scene's `inputChanged(t, o)`. True iff `o` is a tracked trafo aval. */
116
87
  routeInputChanged(o: IAdaptiveObject): boolean {
117
88
  if (!this.constituents.has(o)) return false;
118
89
  this.constituents.markDirty(o);
119
90
  return true;
120
91
  }
121
92
 
122
- /** Apply the dirty set: upload changed constituent values. O(changed). */
93
+ /** Drain changed trafo avals (re-subscribes via `getValue`). Call inside the scene's evaluate scope. */
94
+ pullDirty(token: AdaptiveToken): ReadonlySet<SlotIndex> {
95
+ return this.constituents.pullDirty(token);
96
+ }
97
+
98
+ /** Upload the changed constituent value range. O(changed). */
123
99
  uploadDirty(dirty: ReadonlySet<SlotIndex>): void {
124
100
  if (dirty.size === 0) return;
125
101
  this.ensureConstituentsCapacity(this.constituents.slotCount * DF32_MAT4_BYTES);
126
- uploadConstituentsRange(
127
- this.device, this.constituentsBuf, this.constituents.cpuMirror, dirty,
128
- );
102
+ uploadConstituentsRange(this.device, this.constituentsBuf, this.constituents.cpuMirror, dirty);
103
+ }
104
+
105
+ /** Run the uber-kernel. No-op if there are no records. */
106
+ encode(enc: GPUCommandEncoder): boolean {
107
+ return this.dispatcher.encode(enc, this.registry, this.records);
129
108
  }
130
109
 
131
110
  dispose(): void {
@@ -136,104 +115,91 @@ export class DerivedUniformsScene {
136
115
 
137
116
  // ─── Per-RO registration ──────────────────────────────────────────────
138
117
 
139
- export interface RoTrafoInputs {
140
- readonly modelTrafo?: aval<Trafo3d> | undefined;
141
- readonly viewTrafo?: aval<Trafo3d> | undefined;
142
- readonly projTrafo?: aval<Trafo3d> | undefined;
143
- }
144
-
145
118
  export interface RoDerivedRequest {
146
- readonly trafos: RoTrafoInputs;
147
- readonly requiredNames: readonly string[];
148
- readonly byteOffsetByName: ReadonlyMap<string, number>;
119
+ /** Derived rules to install, keyed by the uniform name each produces. */
120
+ readonly rules: ReadonlyMap<string, DerivedRule>;
121
+ /** Trafo avals available on this RO, keyed by the uniform name a rule leaf may reference (e.g. "ModelTrafo"). */
122
+ readonly trafoAvals: ReadonlyMap<string, aval<Trafo3d>>;
123
+ /** drawHeader byte offset (relative to `drawHeaderBaseByte`) of a host-supplied uniform on this RO; undefined if not present. */
124
+ readonly hostUniformOffset: (name: string) => number | undefined;
125
+ /** drawHeader byte offset (relative to `drawHeaderBaseByte`) where each derived output is written; undefined if absent. */
126
+ readonly outputOffset: (name: string) => number | undefined;
127
+ /** Absolute byte offset of this RO's drawHeader within the main heap. */
149
128
  readonly drawHeaderBaseByte: number;
150
129
  }
151
130
 
152
131
  export interface RoRegistration {
153
- /** Per-record handles (RecordsBuffer ids) — passed back to deregister. */
154
- readonly recordIds: readonly number[];
132
+ /** The records-buffer owner key (the RenderObject). */
133
+ readonly owner: object;
155
134
  readonly constituentAvals: readonly aval<Trafo3d>[];
135
+ /** Flattened-rule hashes — for `registry.release` on teardown. */
136
+ readonly ruleHashes: readonly string[];
156
137
  }
157
138
 
158
- /** Register all derived-uniform records for one RO. */
159
139
  export function registerRoDerivations(
160
- scene: DerivedUniformsScene,
161
- req: RoDerivedRequest,
140
+ scene: DerivedUniformsScene,
141
+ owner: object,
142
+ req: RoDerivedRequest,
162
143
  ): RoRegistration {
163
- if (req.requiredNames.length === 0) {
164
- return { recordIds: [], constituentAvals: [] };
165
- }
144
+ if (req.rules.size === 0) return { owner, constituentAvals: [], ruleHashes: [] };
166
145
 
167
146
  const acquiredAvals: aval<Trafo3d>[] = [];
168
- const pairFor = new Map<"Model" | "View" | "Proj", PairedSlots>();
169
- const TRAFO_FIELD: Record<"Model" | "View" | "Proj", keyof RoTrafoInputs> = {
170
- Model: "modelTrafo", View: "viewTrafo", Proj: "projTrafo",
171
- };
172
- const acquirePair = (key: "Model" | "View" | "Proj"): PairedSlots => {
173
- let p = pairFor.get(key);
147
+ const ruleHashes: string[] = [];
148
+ const pairByName = new Map<string, PairedSlots>();
149
+ const acquireTrafo = (name: string): PairedSlots => {
150
+ let p = pairByName.get(name);
174
151
  if (p !== undefined) return p;
175
- const av = req.trafos[TRAFO_FIELD[key]];
152
+ const av = req.trafoAvals.get(name);
176
153
  if (av === undefined) {
177
- throw new Error(
178
- `derivedUniforms: RO requires '${key}' trafo but trafos.${TRAFO_FIELD[key]} is undefined`,
179
- );
154
+ throw new Error(`derivedUniforms: rule references trafo '${name}' not available on this RO`);
180
155
  }
181
156
  p = scene.constituents.acquire(av);
182
- pairFor.set(key, p);
157
+ pairByName.set(name, p);
183
158
  acquiredAvals.push(av);
184
159
  return p;
185
160
  };
186
-
187
- const resolveConstituent = (ref: ConstituentRef): SlotIndex => {
188
- switch (ref) {
189
- case "Model.fwd": return acquirePair("Model").fwd;
190
- case "Model.bwd": return acquirePair("Model").inv;
191
- case "View.fwd": return acquirePair("View" ).fwd;
192
- case "View.bwd": return acquirePair("View" ).inv;
193
- case "Proj.fwd": return acquirePair("Proj" ).fwd;
194
- case "Proj.bwd": return acquirePair("Proj" ).inv;
161
+ const resolve = (inp: RuleInput): number => {
162
+ // A matrix-typed leaf bound as an aval is a `Trafo3d` ⇒ a df32 constituent slot
163
+ // (its `Inverse` reads the stored backward half — free).
164
+ if (inp.type.kind === "Matrix" && req.trafoAvals.has(inp.name)) {
165
+ const p = acquireTrafo(inp.name);
166
+ return makeHandle(SlotTag.Constituent, inp.inverse ? p.inv : p.fwd);
167
+ }
168
+ // Anything else is a plain host uniform — whatever value reached this RO (the sg
169
+ // already collapsed any subtree overrides).
170
+ if (inp.inverse) {
171
+ throw new Error(`derivedUniforms: Inverse of a non-constituent input '${inp.name}' is not supported`);
195
172
  }
173
+ const off = req.hostUniformOffset(inp.name);
174
+ if (off !== undefined) return makeHandle(SlotTag.HostHeap, req.drawHeaderBaseByte + off);
175
+ throw new Error(
176
+ `derivedUniforms: rule input '${inp.name}' cannot be resolved on this RO ` +
177
+ `(not a Trafo3d binding, and non-trafo host-uniform leaves aren't supported yet)`,
178
+ );
196
179
  };
197
180
 
198
- const recordIds: number[] = [];
199
- for (const name of req.requiredNames) {
200
- const id = recipeIdByName(name);
201
- if (id === undefined) {
202
- throw new Error(`derivedUniforms: unknown derived name '${name}'`);
181
+ for (const [outName, rule] of req.rules) {
182
+ // `flatten` returns the same Expr object when nothing was substituted (the common case
183
+ // the standard recipes reference no derived names), so we can reuse `rule` (it already
184
+ // carries a precomputed `.hash`) and skip a redundant IR hash walk per RO.
185
+ const flatIr = flatten(outName, rule.ir, req.rules);
186
+ const flat = flatIr === rule.ir ? rule : ruleFromIR(flatIr, rule.outputType);
187
+ const id = scene.registry.register(flat);
188
+ ruleHashes.push(flat.hash);
189
+ const entry = scene.registry.get(id)!;
190
+ const inSlots = entry.inputs.map(resolve);
191
+ const outOff = req.outputOffset(outName);
192
+ if (outOff === undefined) {
193
+ throw new Error(`derivedUniforms: no drawHeader slot for derived uniform '${outName}' on this RO`);
203
194
  }
204
- const off = req.byteOffsetByName.get(name);
205
- if (off === undefined) {
206
- throw new Error(
207
- `derivedUniforms: '${name}' has no byte offset in drawHeader for this RO`,
208
- );
209
- }
210
- const refs = recipeInputs(id);
211
- const slots = refs.map(resolveConstituent);
212
- const inCount = recipeInputCount(id);
213
- const rec: DerivationRecord = {
214
- recipe: id as number,
215
- in0: slots[0]!,
216
- in1: inCount >= 2 ? slots[1]! : (0 as SlotIndex),
217
- in2: inCount >= 3 ? slots[2]! : (0 as SlotIndex),
218
- outByte: req.drawHeaderBaseByte + off,
219
- };
220
- recordIds.push(scene.dispatcher.records.add(rec));
195
+ scene.records.add(owner, id, makeHandle(SlotTag.HostHeap, req.drawHeaderBaseByte + outOff), inSlots);
221
196
  }
222
-
223
- return { recordIds, constituentAvals: acquiredAvals };
197
+ scene.ensureConstituentsCapacity(scene.constituents.slotCount * DF32_MAT4_BYTES);
198
+ return { owner, constituentAvals: acquiredAvals, ruleHashes };
224
199
  }
225
200
 
226
- export function deregisterRoDerivations(
227
- scene: DerivedUniformsScene,
228
- reg: RoRegistration,
229
- ): void {
230
- for (const id of reg.recordIds) scene.dispatcher.records.remove(id);
201
+ export function deregisterRoDerivations(scene: DerivedUniformsScene, reg: RoRegistration): void {
202
+ scene.records.removeAllForOwner(reg.owner);
203
+ for (const h of reg.ruleHashes) scene.registry.release(h);
231
204
  for (const av of reg.constituentAvals) scene.constituents.release(av);
232
205
  }
233
-
234
- export function isDerivedUniformName(name: string): boolean {
235
- return DERIVED_UNIFORM_NAMES.has(name);
236
- }
237
-
238
- // Re-export RecipeId for callers (e.g. heapScene's drawHeader walk).
239
- export { RecipeId };
@@ -72,8 +72,8 @@ import {
72
72
  import {
73
73
  DerivedUniformsScene,
74
74
  registerRoDerivations, deregisterRoDerivations,
75
- isDerivedUniformName,
76
- type RoRegistration,
75
+ isDerivedRule, STANDARD_DERIVED_RULES, STANDARD_TRAFO_LEAVES, inputsOf,
76
+ type RoRegistration, type DerivedRule,
77
77
  } from "./derivedUniforms/index.js";
78
78
 
79
79
  // ---------------------------------------------------------------------------
@@ -1652,19 +1652,51 @@ export function buildHeapScene(
1652
1652
  // ─── §7 derived-uniforms (opt-in) ─────────────────────────────────
1653
1653
  // Constructed lazily after sceneObj so the SubscribeFn can reference
1654
1654
  // both. `derivedScene` is undefined when the option is off; nothing
1655
- // else in this file should run when it is.
1656
- const enableDerivedUniforms = opts.enableDerivedUniforms === true;
1655
+ // else in this file should run when it is. On by default — pass
1656
+ // `enableDerivedUniforms: false` to opt out.
1657
+ const enableDerivedUniforms = opts.enableDerivedUniforms !== false;
1657
1658
  const derivedScene: DerivedUniformsScene | undefined = enableDerivedUniforms
1658
1659
  ? new DerivedUniformsScene(device, arena.attrs.buffer, {
1659
1660
  // Larger initial capacity reduces grow-during-first-frame churn.
1660
1661
  initialConstituentSlots: 4096,
1661
- initialRecordCapacity: 8192,
1662
1662
  })
1663
1663
  : undefined;
1664
1664
  /** Per-RO §7 registration handles, keyed by global drawId.
1665
1665
  * Drained on removeDraw to release slots + records. */
1666
1666
  const derivedByDrawId = new Map<number, RoRegistration>();
1667
1667
 
1668
+ /** The derived-uniform rule for a uniform binding on this RO, if any: an explicit
1669
+ * `derivedUniform(...)` value in `spec.inputs`, or a standard trafo recipe by name
1670
+ * (recipes apply only when their base-trafo leaves are actually bound on this RO —
1671
+ * otherwise the field falls back to its `spec.inputs` value, if any).
1672
+ * (A uniform binding is either a value — an aval/constant — or a rule.) */
1673
+ const ruleForUniform = (spec: HeapDrawSpec, name: string): DerivedRule | undefined => {
1674
+ if (derivedScene === undefined) return undefined;
1675
+ const v = spec.inputs[name];
1676
+ const explicit = isDerivedRule(v);
1677
+ const rule = explicit ? v : STANDARD_DERIVED_RULES.get(name);
1678
+ if (rule === undefined) return undefined;
1679
+ // Check the rule's input leaves are resolvable on this RO. A matrix-typed leaf needs an
1680
+ // aval<Trafo3d> binding (constituent slot). Non-trafo / host-uniform leaves aren't wired
1681
+ // yet — for an explicit user rule that's a hard error; for a standard recipe it just means
1682
+ // "this RO doesn't supply the base trafos", so we fall back to the packed value.
1683
+ for (const inp of inputsOf(rule.ir)) {
1684
+ const specName = STANDARD_TRAFO_LEAVES.get(inp.name) ?? inp.name;
1685
+ const lv = spec.inputs[specName];
1686
+ const resolvable = inp.type.kind === "Matrix" && lv !== undefined && !isDerivedRule(lv);
1687
+ if (!resolvable) {
1688
+ if (explicit) {
1689
+ throw new Error(
1690
+ `derivedUniform '${name}': leaf '${inp.name}' is not resolvable on this RO ` +
1691
+ `(need an aval<Trafo3d> binding; non-trafo host-uniform leaves aren't supported yet)`,
1692
+ );
1693
+ }
1694
+ return undefined; // standard recipe, base trafos not bound here — use the packed value
1695
+ }
1696
+ }
1697
+ return rule;
1698
+ };
1699
+
1668
1700
  // ─── Atlas bindings ───────────────────────────────────────────────
1669
1701
  //
1670
1702
  // A bucket whose textures live in the atlas binds N consecutive
@@ -2625,7 +2657,7 @@ export function buildHeapScene(
2625
2657
  // pre-pass. We still allocate an arena slot here so the
2626
2658
  // drawHeader gets a valid ref written by packBucketHeader; §7
2627
2659
  // overwrites the arena data each frame the inputs are dirty.
2628
- if (derivedScene !== undefined && isDerivedUniformName(f.name)) {
2660
+ if (ruleForUniform(spec, f.name) !== undefined) {
2629
2661
  const dummyAval = AVal.constant<M44d>(M44d.zero);
2630
2662
  const ref = pool.acquire(
2631
2663
  device, arena.attrs, dummyAval, M44d.zero,
@@ -2762,32 +2794,44 @@ export function buildHeapScene(
2762
2794
  drawIdToIndexAval[drawId] = indicesAval;
2763
2795
 
2764
2796
  // ─── §7 derived-uniforms registration ────────────────────────────
2765
- // Collect the derived names this effect actually uses (i.e. fields
2766
- // declared in the drawHeader for this effect AND in the §7 set).
2767
- // For each, the kernel writes into byte offset
2768
- // localSlot * drawHeaderBytes + field.byteOffset
2769
- // i.e. this RO's drawHeader region in the bucket's drawHeap.
2797
+ // A uniform binding on this RO is either a value (aval/constant) or a rule —
2798
+ // collect the rules (explicit `derivedUniform(...)` values + standard trafo
2799
+ // recipes) for fields the effect declares. The kernel writes each result into
2800
+ // this RO's drawHeader region in the bucket's drawHeap (its arena slot).
2770
2801
  if (derivedScene !== undefined) {
2771
- const requiredNames: string[] = [];
2772
- const arenaByteOffsetByName = new Map<string, number>();
2802
+ const ruleSubset = new Map<string, DerivedRule>();
2803
+ const outOffsetByName = new Map<string, number>();
2773
2804
  for (const f of bucket.layout.drawHeaderFields) {
2774
2805
  if (f.name === "__layoutId") continue;
2775
2806
  if (!effectFields.has(f.name)) continue;
2776
- if (!isDerivedUniformName(f.name)) continue;
2807
+ const rule = ruleForUniform(spec, f.name);
2808
+ if (rule === undefined) continue;
2777
2809
  const ref = perDrawRefs.get(f.name);
2778
2810
  if (ref === undefined) continue;
2779
- requiredNames.push(f.name);
2780
- arenaByteOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
2811
+ ruleSubset.set(f.name, rule);
2812
+ outOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
2781
2813
  }
2782
- if (requiredNames.length > 0) {
2783
- const reg = registerRoDerivations(derivedScene, {
2784
- trafos: {
2785
- modelTrafo: spec.inputs["ModelTrafo"] as aval<Trafo3d> | undefined,
2786
- viewTrafo: spec.inputs["ViewTrafo"] as aval<Trafo3d> | undefined,
2787
- projTrafo: spec.inputs["ProjTrafo"] as aval<Trafo3d> | undefined,
2788
- },
2789
- requiredNames,
2790
- byteOffsetByName: arenaByteOffsetByName,
2814
+ if (ruleSubset.size > 0) {
2815
+ // Trafo avals a rule leaf may reference: the standard `Model`/`View`/`Proj`
2816
+ // leaf names (mapped to their `*Trafo` bindings), plus every aval-valued input
2817
+ // by its own name (so a `derivedUniform(u => u.ViewTrafo.mul(u.ModelTrafo))`
2818
+ // resolves `u.ViewTrafo` to that binding). resolveSource only uses these for
2819
+ // matrix-typed leaves; a non-matrix leaf falls through to the (unsupported-in-v0)
2820
+ // host path.
2821
+ const trafoAvals = new Map<string, aval<Trafo3d>>();
2822
+ for (const [leaf, specName] of STANDARD_TRAFO_LEAVES) {
2823
+ const av = spec.inputs[specName];
2824
+ if (av !== undefined && !isDerivedRule(av)) trafoAvals.set(leaf, av as aval<Trafo3d>);
2825
+ }
2826
+ for (const n of Object.keys(spec.inputs)) {
2827
+ const v = spec.inputs[n];
2828
+ if (v !== undefined && !isDerivedRule(v) && !trafoAvals.has(n)) trafoAvals.set(n, v as aval<Trafo3d>);
2829
+ }
2830
+ const reg = registerRoDerivations(derivedScene, {}, {
2831
+ rules: ruleSubset,
2832
+ trafoAvals,
2833
+ hostUniformOffset: () => undefined,
2834
+ outputOffset: (n) => outOffsetByName.get(n),
2791
2835
  drawHeaderBaseByte: 0,
2792
2836
  });
2793
2837
  derivedByDrawId.set(drawId, reg);
@@ -3126,19 +3170,19 @@ export function buildHeapScene(
3126
3170
  // adds the evaluating object to the aval's outputs). Without
3127
3171
  // this scope, view changes never propagate again — the scene
3128
3172
  // freezes after one frame.
3129
- let dirty: ReturnType<typeof derivedScene.constituents.pullDirty> | undefined;
3173
+ let dirty: ReturnType<typeof derivedScene.pullDirty> | undefined;
3130
3174
  sceneObj.evaluateAlways(token, (t) => {
3131
- dirty = derivedScene.constituents.pullDirty(t);
3175
+ dirty = derivedScene.pullDirty(t);
3132
3176
  });
3133
3177
  const _t1 = performance.now();
3134
3178
  derivedScene.uploadDirty(dirty!);
3135
3179
  const _t2 = performance.now();
3136
- if (dirty!.size > 0) derivedScene.dispatcher.encode(enc);
3180
+ if (dirty!.size > 0) derivedScene.encode(enc);
3137
3181
  const _t3 = performance.now();
3138
3182
  stats.derivedPullMs = _t1 - _t0;
3139
3183
  stats.derivedUploadMs = _t2 - _t1;
3140
3184
  stats.derivedEncodeMs = _t3 - _t2;
3141
- stats.derivedRecords = derivedScene.dispatcher.records.recordCount;
3185
+ stats.derivedRecords = derivedScene.records.recordCount;
3142
3186
  }
3143
3187
  let anyDirty = false;
3144
3188
  for (const b of buckets) { if (b.scanDirty) { anyDirty = true; break; } }
@@ -225,7 +225,7 @@ export function compileHybridScene(
225
225
  fragmentOutputLayout,
226
226
  atlasPool,
227
227
  ...(opts.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
228
- ...(opts.enableDerivedUniforms === true ? { enableDerivedUniforms: true } : {}),
228
+ ...(opts.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
229
229
  });
230
230
 
231
231
  // ─── Legacy subset → RenderTree → ScenePass ──────────────────────
@@ -43,6 +43,14 @@ export { flattenRenderTree } from "./flattenTree.js";
43
43
  export { isHeapEligible } from "./heapEligibility.js";
44
44
  export { renderObjectToHeapSpec } from "./heapAdapter.js";
45
45
 
46
+ // ─── Derived uniforms (§7): a uniform binding is a value (aval/constant) OR a rule ──
47
+ export {
48
+ derivedUniform, DerivedExpr, type DerivedScope,
49
+ ruleFromIR, uniformRef, isDerivedRule, hashIR,
50
+ type DerivedRule, type IRFragment,
51
+ STANDARD_DERIVED_RULES, isStandardDerivedName,
52
+ } from "./derivedUniforms/index.js";
53
+
46
54
  export {
47
55
  compileHybridScene,
48
56
  type CompileHybridSceneOptions,
@@ -255,7 +255,7 @@ class RenderTask implements IRenderTask {
255
255
  compileEffect: this.ctx.compileEffect,
256
256
  ...(this.ctx.heapEnabled !== undefined ? { heapEnabled: this.ctx.heapEnabled } : {}),
257
257
  ...(this.ctx.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
258
- ...(this.ctx.enableDerivedUniforms === true ? { enableDerivedUniforms: true } : {}),
258
+ ...(this.ctx.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
259
259
  });
260
260
  this._scenes.set(cmd, s);
261
261
  }
@@ -76,7 +76,7 @@ export class Runtime {
76
76
  })),
77
77
  ...(opts.heapEnabled !== undefined ? { heapEnabled: opts.heapEnabled } : {}),
78
78
  ...(opts.enableFamilyMerge === true ? { enableFamilyMerge: true } : {}),
79
- ...(opts.enableDerivedUniforms === true ? { enableDerivedUniforms: true } : {}),
79
+ ...(opts.enableDerivedUniforms === false ? { enableDerivedUniforms: false } : {}),
80
80
  };
81
81
  // `device.lost` is a real-WebGPU promise; mock devices may not
82
82
  // expose it. Treat as "never lost" in that case.
@@ -1,15 +0,0 @@
1
- /** Branded u32 — top bit selects heap, low 31 bits are slot index. */
2
- export type SlotId = number & {
3
- readonly __slotId: unique symbol;
4
- };
5
- export declare const SlotId: {
6
- /** Encode a Constituents-heap slot. */
7
- constituent(idx: number): SlotId;
8
- /** Encode an Intermediates-heap slot. */
9
- intermediate(idx: number): SlotId;
10
- isIntermediate(s: SlotId): boolean;
11
- index(s: SlotId): number;
12
- };
13
- /** Bytes per df32 mat4 slot: 16 entries × 2 floats × 4 bytes. */
14
- export declare const DF32_MAT4_BYTES = 128;
15
- //# sourceMappingURL=slotId.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"slotId.d.ts","sourceRoot":"","sources":["../../../src/runtime/derivedUniforms/slotId.ts"],"names":[],"mappings":"AAWA,sEAAsE;AACtE,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,OAAO,MAAM,CAAA;CAAE,CAAC;AAInE,eAAO,MAAM,MAAM;IACjB,uCAAuC;qBACtB,MAAM,GAAG,MAAM;IAGhC,yCAAyC;sBACvB,MAAM,GAAG,MAAM;sBAGf,MAAM,GAAG,OAAO;aAGzB,MAAM,GAAG,MAAM;CAGzB,CAAC;AAEF,iEAAiE;AACjE,eAAO,MAAM,eAAe,MAAM,CAAC"}