@aardworx/wombat.rendering 0.9.11 → 0.9.13

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 +76 -0
  18. package/dist/runtime/derivedUniforms/marker.d.ts.map +1 -0
  19. package/dist/runtime/derivedUniforms/marker.js +156 -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 +78 -26
  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 +169 -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 +77 -28
  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,77 @@
1
+ // §7 v2 — derived-uniform rule registry.
2
+ //
3
+ // Content-hash–keyed table of FLATTENED rules. `register` dedups on the
4
+ // flattened IR's hash, assigns a monotonic id (never reused), and bumps
5
+ // `version` whenever a new unique rule appears so the dispatcher knows to
6
+ // recompile its uber-kernel. v0 never sweeps dead entries (a stale rule's
7
+ // switch arm just goes unused — bytes per rule). See
8
+ // docs/derived-uniforms-extensible.md § "Registry" / "Lifecycle".
9
+
10
+ import type { Expr, Type } from "@aardworx/wombat.shader/ir";
11
+ import type { DerivedRule } from "./rule.js";
12
+ import { inputsOf, type RuleInput } from "./flatten.js";
13
+
14
+ export interface RuleEntry {
15
+ /** Monotonic id — the value the uber-kernel `switch`es on. Never reused. */
16
+ readonly id: number;
17
+ /** Flattened IR (reads only non-derived inputs). */
18
+ readonly ir: Expr;
19
+ readonly outputType: Type;
20
+ /** Distinct inputs of `ir`, sorted by name — defines the switch-arm argument order. */
21
+ readonly inputs: readonly RuleInput[];
22
+ refcount: number;
23
+ }
24
+
25
+ export class DerivedUniformRegistry {
26
+ private readonly byHash = new Map<string, RuleEntry>();
27
+ private readonly byId = new Map<number, RuleEntry>();
28
+ private nextId = 0;
29
+ /** Bumps on every `register` that finds a NEW hash. */
30
+ version = 0;
31
+
32
+ /**
33
+ * Register a flattened rule. Returns its stable id. Idempotent per hash
34
+ * (just bumps the refcount); a new hash allocates an id and bumps `version`.
35
+ */
36
+ register(rule: DerivedRule): number {
37
+ const cached = this.byHash.get(rule.hash);
38
+ if (cached !== undefined) {
39
+ cached.refcount++;
40
+ return cached.id;
41
+ }
42
+ const entry: RuleEntry = {
43
+ id: this.nextId++,
44
+ ir: rule.ir,
45
+ outputType: rule.outputType,
46
+ inputs: inputsOf(rule.ir),
47
+ refcount: 1,
48
+ };
49
+ this.byHash.set(rule.hash, entry);
50
+ this.byId.set(entry.id, entry);
51
+ this.version++;
52
+ return entry.id;
53
+ }
54
+
55
+ /** Drop one reference. v0: never sweeps — kept for forward-compat with v1 GC. */
56
+ release(hash: string): void {
57
+ const entry = this.byHash.get(hash);
58
+ if (entry !== undefined && entry.refcount > 0) entry.refcount--;
59
+ }
60
+
61
+ get(id: number): RuleEntry | undefined {
62
+ return this.byId.get(id);
63
+ }
64
+
65
+ getByHash(hash: string): RuleEntry | undefined {
66
+ return this.byHash.get(hash);
67
+ }
68
+
69
+ /** All entries in id order — the codegen's switch-arm list. */
70
+ entries(): RuleEntry[] {
71
+ return [...this.byId.values()].sort((a, b) => a.id - b.id);
72
+ }
73
+
74
+ get size(): number {
75
+ return this.byId.size;
76
+ }
77
+ }
@@ -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,49 @@ 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
+ // Each input leaf must be a uniform bound on this RO (a value, not another rule): a
1680
+ // matrix-typed leaf resolves to a df32 constituent slot (it's expected to be an
1681
+ // aval<Trafo3d>); anything else reads the packed value from the drawHeader. If a leaf
1682
+ // isn't bound: for an explicit user rule that's a hard error; for a standard recipe it
1683
+ // just means "this RO doesn't supply the base trafos", so the field falls back to its
1684
+ // packed `spec.inputs` value (or errors later if there is none — same as without §7).
1685
+ for (const inp of inputsOf(rule.ir)) {
1686
+ const specName = STANDARD_TRAFO_LEAVES.get(inp.name) ?? inp.name;
1687
+ const lv = spec.inputs[specName];
1688
+ if (lv === undefined || isDerivedRule(lv)) {
1689
+ if (explicit) {
1690
+ throw new Error(`derivedUniform '${name}': leaf '${inp.name}' is not a uniform bound on this RO`);
1691
+ }
1692
+ return undefined; // standard recipe, base trafos not bound here — use the packed value
1693
+ }
1694
+ }
1695
+ return rule;
1696
+ };
1697
+
1668
1698
  // ─── Atlas bindings ───────────────────────────────────────────────
1669
1699
  //
1670
1700
  // A bucket whose textures live in the atlas binds N consecutive
@@ -2625,7 +2655,7 @@ export function buildHeapScene(
2625
2655
  // pre-pass. We still allocate an arena slot here so the
2626
2656
  // drawHeader gets a valid ref written by packBucketHeader; §7
2627
2657
  // overwrites the arena data each frame the inputs are dirty.
2628
- if (derivedScene !== undefined && isDerivedUniformName(f.name)) {
2658
+ if (ruleForUniform(spec, f.name) !== undefined) {
2629
2659
  const dummyAval = AVal.constant<M44d>(M44d.zero);
2630
2660
  const ref = pool.acquire(
2631
2661
  device, arena.attrs, dummyAval, M44d.zero,
@@ -2762,32 +2792,51 @@ export function buildHeapScene(
2762
2792
  drawIdToIndexAval[drawId] = indicesAval;
2763
2793
 
2764
2794
  // ─── §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.
2795
+ // A uniform binding on this RO is either a value (aval/constant) or a rule —
2796
+ // collect the rules (explicit `derivedUniform(...)` values + standard trafo
2797
+ // recipes) for fields the effect declares. The kernel writes each result into
2798
+ // this RO's drawHeader region in the bucket's drawHeap (its arena slot).
2770
2799
  if (derivedScene !== undefined) {
2771
- const requiredNames: string[] = [];
2772
- const arenaByteOffsetByName = new Map<string, number>();
2800
+ const ruleSubset = new Map<string, DerivedRule>();
2801
+ const outOffsetByName = new Map<string, number>();
2773
2802
  for (const f of bucket.layout.drawHeaderFields) {
2774
2803
  if (f.name === "__layoutId") continue;
2775
2804
  if (!effectFields.has(f.name)) continue;
2776
- if (!isDerivedUniformName(f.name)) continue;
2805
+ const rule = ruleForUniform(spec, f.name);
2806
+ if (rule === undefined) continue;
2777
2807
  const ref = perDrawRefs.get(f.name);
2778
2808
  if (ref === undefined) continue;
2779
- requiredNames.push(f.name);
2780
- arenaByteOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
2809
+ ruleSubset.set(f.name, rule);
2810
+ outOffsetByName.set(f.name, ref + ALLOC_HEADER_PAD_TO);
2781
2811
  }
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,
2812
+ if (ruleSubset.size > 0) {
2813
+ // Trafo avals a rule leaf may reference: the standard `Model`/`View`/`Proj`
2814
+ // leaf names (mapped to their `*Trafo` bindings), plus every aval-valued input
2815
+ // by its own name (so a `derivedUniform(u => u.ViewTrafo.mul(u.ModelTrafo))`
2816
+ // resolves `u.ViewTrafo` to that binding). resolveSource only uses these for
2817
+ // matrix-typed leaves; a non-matrix leaf falls through to the (unsupported-in-v0)
2818
+ // host path.
2819
+ const trafoAvals = new Map<string, aval<Trafo3d>>();
2820
+ for (const [leaf, specName] of STANDARD_TRAFO_LEAVES) {
2821
+ const av = spec.inputs[specName];
2822
+ if (av !== undefined && !isDerivedRule(av)) trafoAvals.set(leaf, av as aval<Trafo3d>);
2823
+ }
2824
+ for (const n of Object.keys(spec.inputs)) {
2825
+ const v = spec.inputs[n];
2826
+ if (v !== undefined && !isDerivedRule(v) && !trafoAvals.has(n)) trafoAvals.set(n, v as aval<Trafo3d>);
2827
+ }
2828
+ const reg = registerRoDerivations(derivedScene, {}, {
2829
+ rules: ruleSubset,
2830
+ trafoAvals,
2831
+ // A non-trafo rule leaf reads the uniform's data straight from this RO's
2832
+ // drawHeader region in the arena (= its pool ref + the alloc-header pad).
2833
+ // Only resolvable if the uniform was actually packed (i.e. it's a drawHeader
2834
+ // field of the effect too) — undefined otherwise, which `resolveSource` rejects.
2835
+ hostUniformOffset: (n) => {
2836
+ const r = perDrawRefs.get(n);
2837
+ return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
2788
2838
  },
2789
- requiredNames,
2790
- byteOffsetByName: arenaByteOffsetByName,
2839
+ outputOffset: (n) => outOffsetByName.get(n),
2791
2840
  drawHeaderBaseByte: 0,
2792
2841
  });
2793
2842
  derivedByDrawId.set(drawId, reg);
@@ -3126,19 +3175,19 @@ export function buildHeapScene(
3126
3175
  // adds the evaluating object to the aval's outputs). Without
3127
3176
  // this scope, view changes never propagate again — the scene
3128
3177
  // freezes after one frame.
3129
- let dirty: ReturnType<typeof derivedScene.constituents.pullDirty> | undefined;
3178
+ let dirty: ReturnType<typeof derivedScene.pullDirty> | undefined;
3130
3179
  sceneObj.evaluateAlways(token, (t) => {
3131
- dirty = derivedScene.constituents.pullDirty(t);
3180
+ dirty = derivedScene.pullDirty(t);
3132
3181
  });
3133
3182
  const _t1 = performance.now();
3134
3183
  derivedScene.uploadDirty(dirty!);
3135
3184
  const _t2 = performance.now();
3136
- if (dirty!.size > 0) derivedScene.dispatcher.encode(enc);
3185
+ if (dirty!.size > 0) derivedScene.encode(enc);
3137
3186
  const _t3 = performance.now();
3138
3187
  stats.derivedPullMs = _t1 - _t0;
3139
3188
  stats.derivedUploadMs = _t2 - _t1;
3140
3189
  stats.derivedEncodeMs = _t3 - _t2;
3141
- stats.derivedRecords = derivedScene.dispatcher.records.recordCount;
3190
+ stats.derivedRecords = derivedScene.records.recordCount;
3142
3191
  }
3143
3192
  let anyDirty = false;
3144
3193
  for (const b of buckets) { if (b.scanDirty) { anyDirty = true; break; } }