@aardworx/wombat.rendering 0.19.11 → 0.19.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 (32) hide show
  1. package/dist/core/renderObject.d.ts +9 -0
  2. package/dist/core/renderObject.d.ts.map +1 -1
  3. package/dist/runtime/derivedUniforms/codegen.d.ts +11 -0
  4. package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
  5. package/dist/runtime/derivedUniforms/codegen.js +59 -2
  6. package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
  7. package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
  8. package/dist/runtime/derivedUniforms/dispatch.js +21 -11
  9. package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
  10. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +24 -1
  11. package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
  12. package/dist/runtime/derivedUniforms/sceneIntegration.js +57 -6
  13. package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
  14. package/dist/runtime/derivedUniforms/slots.d.ts +6 -0
  15. package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -1
  16. package/dist/runtime/derivedUniforms/slots.js +14 -0
  17. package/dist/runtime/derivedUniforms/slots.js.map +1 -1
  18. package/dist/runtime/heapAdapter.d.ts.map +1 -1
  19. package/dist/runtime/heapAdapter.js +1 -0
  20. package/dist/runtime/heapAdapter.js.map +1 -1
  21. package/dist/runtime/heapScene.d.ts +11 -1
  22. package/dist/runtime/heapScene.d.ts.map +1 -1
  23. package/dist/runtime/heapScene.js +6 -0
  24. package/dist/runtime/heapScene.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/core/renderObject.ts +9 -0
  27. package/src/runtime/derivedUniforms/codegen.ts +61 -2
  28. package/src/runtime/derivedUniforms/dispatch.ts +20 -10
  29. package/src/runtime/derivedUniforms/sceneIntegration.ts +75 -6
  30. package/src/runtime/derivedUniforms/slots.ts +15 -0
  31. package/src/runtime/heapAdapter.ts +1 -0
  32. package/src/runtime/heapScene.ts +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aardworx/wombat.rendering",
3
- "version": "0.19.11",
3
+ "version": "0.19.12",
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",
@@ -102,4 +102,13 @@ export interface RenderObject {
102
102
  readonly depthWrite?: import("../runtime/derivedModes/rule.js").DerivedModeRule<"depthWrite">;
103
103
  readonly alphaToCoverage?: import("../runtime/derivedModes/rule.js").DerivedModeRule<"alphaToCoverage">;
104
104
  };
105
+ /**
106
+ * GPU transform propagation: this RO's `Model` as an ancestor trafo CHAIN
107
+ * (root→leaf order, constant runs folded) instead of a single composed
108
+ * `ModelTrafo` aval. The scene-graph emits this so a shared root trafo over
109
+ * N objects never fans out to N composed CPU avals — the heap composes each
110
+ * RO's Model on the GPU. Heap-path only (the legacy path composes on CPU).
111
+ * See docs/gpu-transform-propagation.md.
112
+ */
113
+ readonly modelChain?: readonly aval<import("@aardworx/wombat.base").Trafo3d>[];
105
114
  }
@@ -23,6 +23,18 @@ export interface UberKernel {
23
23
  readonly strideU32: number;
24
24
  }
25
25
 
26
+ /**
27
+ * Reserved rule id for the built-in variable-length trafo-chain arm (GPU
28
+ * transform propagation). A chain record is laid out as
29
+ * [ CHAIN_RULE_ID, out_slot, count, link0, link1, … ]
30
+ * where each `linkK` is a Constituent handle. The arm folds the df32 product
31
+ * `link0 · link1 · … · link(count-1)` and writes it (collapsed) to `out_slot`.
32
+ * One arm handles every chain length, so distinct SG ancestor paths don't each
33
+ * spawn a kernel arm / recompile. Kept far above the registry's monotonic ids
34
+ * (which start at 0) so it can never collide. See docs/gpu-transform-propagation.md.
35
+ */
36
+ export const CHAIN_RULE_ID = 0x7fffffff;
37
+
26
38
  // ─── Rule-shape classification ────────────────────────────────────────
27
39
 
28
40
  type Shape =
@@ -136,6 +148,12 @@ fn load_entry_mat4(h: u32, r: u32, c: u32) -> vec2<f32> {
136
148
  fn write_mat4_entry(out_byte: u32, r: u32, c: u32, v: f32) {
137
149
  MainHeap[(out_byte >> 2u) + r * 4u + c] = v;
138
150
  }
151
+ // Write one df32 mat4 entry (r,c) into a constituent slot — keeps both hi+lo
152
+ // so a downstream §7 pass reads it at full df32 precision. Used by the chain
153
+ // pass, whose output IS a per-RO Model constituent (not a collapsed uniform).
154
+ fn write_constituent_entry(slot: u32, r: u32, c: u32, e: vec2<f32>) {
155
+ Constituents[slot * 16u + r * 4u + c] = e;
156
+ }
139
157
  fn write_mat3_entry(out_byte: u32, r: u32, c: u32, v: f32) {
140
158
  // std140 mat3<f32>: 3 rows × 4-float stride; the 4th column per row is left untouched.
141
159
  MainHeap[(out_byte >> 2u) + r * 4u + c] = v;
@@ -144,7 +162,7 @@ fn write_mat3_entry(out_byte: u32, r: u32, c: u32, v: f32) {
144
162
 
145
163
  function bindings(strideU32: number): string {
146
164
  return /* wgsl */ `
147
- @group(0) @binding(0) var<storage, read> Constituents: array<vec2<f32>>;
165
+ @group(0) @binding(0) var<storage, read_write> Constituents: array<vec2<f32>>;
148
166
  @group(0) @binding(1) var<storage, read_write> MainHeap: array<f32>;
149
167
  @group(0) @binding(2) var<storage, read> RecordData: array<u32>;
150
168
  struct CountUniform { count: u32 }
@@ -214,6 +232,43 @@ function emitMatMulChainArm(id: number, n: number): string {
214
232
  return `\nfn arm_${id}(${params}, out_byte: u32) {\n${body}}\n`;
215
233
  }
216
234
 
235
+ /** Built-in `CHAIN` arm (GPU transform propagation): variable-length df32
236
+ * matmul chain read straight from the record. `RecordData[base+2]` = count;
237
+ * `RecordData[base+3+k]` = link k (Constituent handle). Folds
238
+ * P = L0·L1·…·L(count-1) in df32 and writes it — keeping BOTH hi+lo — into the
239
+ * output CONSTITUENT slot (`out_slot` = the out handle's payload), so a later
240
+ * §7 pass reads it at full df32 precision. The inverse half is produced by a
241
+ * second chain record over the links' inv slots in reverse order (same arm).
242
+ * P starts at identity so count==0 yields identity. */
243
+ const CHAIN_ARM = /* wgsl */ `
244
+ fn arm_chain(base: u32, out_slot: u32) {
245
+ let count = RecordData[base + 2u];
246
+ var P: array<vec2<f32>, 16>;
247
+ for (var i: u32 = 0u; i < 16u; i = i + 1u) { P[i] = vec2<f32>(0.0, 0.0); }
248
+ P[0] = vec2<f32>(1.0, 0.0); P[5] = vec2<f32>(1.0, 0.0);
249
+ P[10] = vec2<f32>(1.0, 0.0); P[15] = vec2<f32>(1.0, 0.0);
250
+ for (var k: u32 = 0u; k < count; k = k + 1u) {
251
+ let h = RecordData[base + 3u + k];
252
+ var Q: array<vec2<f32>, 16>;
253
+ for (var r: u32 = 0u; r < 4u; r = r + 1u) {
254
+ for (var c: u32 = 0u; c < 4u; c = c + 1u) {
255
+ var acc = vec2<f32>(0.0, 0.0);
256
+ for (var t: u32 = 0u; t < 4u; t = t + 1u) {
257
+ acc = df_add(acc, df_mul(P[r * 4u + t], load_entry_mat4(h, t, c)));
258
+ }
259
+ Q[r * 4u + c] = acc;
260
+ }
261
+ }
262
+ P = Q;
263
+ }
264
+ for (var r: u32 = 0u; r < 4u; r = r + 1u) {
265
+ for (var c: u32 = 0u; c < 4u; c = c + 1u) {
266
+ write_constituent_entry(out_slot, r, c, P[r * 4u + c]);
267
+ }
268
+ }
269
+ }
270
+ `;
271
+
217
272
  // ─── Generic path: arbitrary IR lowered via the wgsl printer (f32) ────
218
273
  //
219
274
  // The rule's IR is rewritten so each input leaf — `ReadInput("Uniform", x)` or
@@ -454,13 +509,16 @@ export function buildUberKernel(registry: DerivedUniformRegistry, strideU32: num
454
509
 
455
510
  const arms = entries.map((e, i) => emitArm(e, shapes[i]!)).join("");
456
511
  const cases = entries.map((e, i) => emitCase(e, shapes[i]!)).join("\n");
457
- const needsDf32 = shapes.some((s) => s.kind === "matmulChain");
512
+ // The CHAIN arm is always present (GPU transform propagation) and always
513
+ // needs the df32 library.
514
+ const needsDf32 = true;
458
515
 
459
516
  const wgsl = `${bindings(strideU32)}
460
517
  ${needsDf32 ? DF32_LIB : ""}
461
518
  ${HANDLE_HELPERS}
462
519
  ${loadersStorers}
463
520
  ${arms}
521
+ ${CHAIN_ARM}
464
522
  @compute @workgroup_size(64)
465
523
  fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
466
524
  if (gid.x >= Count.count) { return; }
@@ -468,6 +526,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
468
526
  let rule_id = RecordData[base];
469
527
  let out_byte = RecordData[base + 1u] & SLOT_PAYLOAD_MASK;
470
528
  switch rule_id {
529
+ case ${CHAIN_RULE_ID >>> 0}u: { arm_chain(base, out_byte); }
471
530
  ${cases}
472
531
  default: { return; }
473
532
  }
@@ -43,7 +43,9 @@ export interface DerivedUniformsResources {
43
43
 
44
44
  function bglEntries(): GPUBindGroupLayoutEntry[] {
45
45
  return [
46
- { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
46
+ // Constituents: read_write the chain pass writes per-RO Model constituents
47
+ // here (a separate, earlier dispatch); §7 reads them. read_write permits both.
48
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
47
49
  { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
48
50
  { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
49
51
  { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
@@ -119,9 +121,12 @@ class RecordsGpu {
119
121
  class UberPipeline {
120
122
  private readonly device: GPUDevice;
121
123
  readonly bgl: GPUBindGroupLayout;
122
- private pipe: GPUComputePipeline | undefined;
124
+ // Cache one pipeline per record stride (the kernel bakes RECORD_STRIDE). The
125
+ // chain pass and the §7 pass usually have different strides, and both are
126
+ // dispatched every frame, so a single-slot cache would thrash → recompile
127
+ // per frame. Cleared on a registry-version bump (new rule shape).
128
+ private readonly pipes = new Map<number, GPUComputePipeline>();
123
129
  private builtVersion = -1;
124
- private builtStride = -1;
125
130
 
126
131
  constructor(device: GPUDevice) {
127
132
  this.device = device;
@@ -129,20 +134,25 @@ class UberPipeline {
129
134
  }
130
135
 
131
136
  pipeline(registry: DerivedUniformRegistry, strideU32: number): GPUComputePipeline | undefined {
132
- if (registry.size === 0) return undefined;
133
- if (this.pipe !== undefined && this.builtVersion === registry.version && this.builtStride === strideU32) {
134
- return this.pipe;
137
+ // Always buildable: the kernel carries the built-in CHAIN arm even with an
138
+ // empty registry, so a scene using only transform-propagation chains (no
139
+ // §7 rules) still gets a pipeline. encodeChunk's recordCount===0 guard is
140
+ // what skips the dispatch when there's genuinely nothing to do.
141
+ if (registry.version !== this.builtVersion) {
142
+ this.pipes.clear();
143
+ this.builtVersion = registry.version;
135
144
  }
145
+ const cached = this.pipes.get(strideU32);
146
+ if (cached !== undefined) return cached;
136
147
  const { wgsl } = buildUberKernel(registry, strideU32);
137
148
  const module = this.device.createShaderModule({ code: wgsl, label: "derivedUniforms.uber" });
138
- this.pipe = this.device.createComputePipeline({
149
+ const pipe = this.device.createComputePipeline({
139
150
  label: "derivedUniforms.uber",
140
151
  layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bgl] }),
141
152
  compute: { module, entryPoint: "main" },
142
153
  });
143
- this.builtVersion = registry.version;
144
- this.builtStride = strideU32;
145
- return this.pipe;
154
+ this.pipes.set(strideU32, pipe);
155
+ return pipe;
146
156
  }
147
157
  }
148
158
 
@@ -25,6 +25,7 @@ import {
25
25
  } from "./slots.js";
26
26
  import { DerivedUniformRegistry } from "./registry.js";
27
27
  import { RecordsBuffer, SlotTag, makeHandle } from "./records.js";
28
+ import { CHAIN_RULE_ID } from "./codegen.js";
28
29
  import { DerivedUniformsDispatcher, uploadConstituentsRange } from "./dispatch.js";
29
30
  import { flatten, type RuleInput } from "./flatten.js";
30
31
  import { ruleFromIR, type DerivedRule } from "./rule.js";
@@ -41,6 +42,11 @@ export class DerivedUniformsScene {
41
42
  * scene-wide; records partition by chunk so each dispatch binds
42
43
  * the right chunk's main heap. */
43
44
  private readonly recordsByChunk = new Map<number, RecordsBuffer>();
45
+ /** Scene-wide CHAIN records (GPU transform propagation). Dispatched BEFORE
46
+ * the §7 per-chunk passes — they write per-RO Model constituents that §7
47
+ * then reads. Scene-wide (not per-chunk) because they target the shared
48
+ * constituents buffer, not any chunk's main heap. */
49
+ readonly chainRecords = new RecordsBuffer();
44
50
  readonly dispatcher: DerivedUniformsDispatcher;
45
51
  /** Constituents storage GPU buffer (`array<vec2<f32>>` — df32 mat4 halves). */
46
52
  constituentsBuf: GPUBuffer;
@@ -57,7 +63,7 @@ export class DerivedUniformsScene {
57
63
  this.constituentsBuf = device.createBuffer({
58
64
  label: "derivedUniforms.constituents",
59
65
  size: initial * DF32_MAT4_BYTES,
60
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
66
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
61
67
  });
62
68
  this.dispatcher = new DerivedUniformsDispatcher(device, {
63
69
  constituentsBuf: () => this.constituentsBuf,
@@ -71,6 +77,13 @@ export class DerivedUniformsScene {
71
77
  this.mainHeapByChunk.set(chunkIdx, getter);
72
78
  }
73
79
 
80
+ /** Any registered main-heap getter (the chain pass needs a MainHeap binding
81
+ * even though its arm never touches it). */
82
+ private firstHeapGetter(): (() => GPUBuffer) | undefined {
83
+ for (const g of this.mainHeapByChunk.values()) return g;
84
+ return undefined;
85
+ }
86
+
74
87
  /** Get-or-create the records buffer for `chunkIdx`. */
75
88
  recordsFor(chunkIdx: number): RecordsBuffer {
76
89
  let r = this.recordsByChunk.get(chunkIdx);
@@ -97,7 +110,7 @@ export class DerivedUniformsScene {
97
110
  this.constituentsBuf = this.device.createBuffer({
98
111
  label: "derivedUniforms.constituents",
99
112
  size: cap,
100
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
113
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
101
114
  });
102
115
  this.bufferEpoch++;
103
116
  // Fresh buffer is zero-initialised; re-upload everything packed so far.
@@ -134,6 +147,17 @@ export class DerivedUniformsScene {
134
147
  * main heap buffer. */
135
148
  encode(enc: GPUCommandEncoder): boolean {
136
149
  let any = false;
150
+ // Phase 1: chain pass — writes per-RO Model constituents (fwd+inv) into the
151
+ // shared constituents buffer. Must run BEFORE the §7 passes that read them.
152
+ // The MainHeap binding is unused by the chain arm; bind any registered heap.
153
+ if (this.chainRecords.recordCount > 0) {
154
+ const anyHeap = this.firstHeapGetter();
155
+ if (anyHeap === undefined) {
156
+ throw new Error("derivedUniforms.encode: chain records present but no chunk main-heap registered to bind.");
157
+ }
158
+ if (this.dispatcher.encodeChunk(enc, this.registry, this.chainRecords, anyHeap)) any = true;
159
+ }
160
+ // Phase 2: §7 per-chunk (reads constituents incl. the chain-written Model).
137
161
  for (const [chunkIdx, records] of this.recordsByChunk) {
138
162
  if (records.recordCount === 0) continue;
139
163
  const heapGetter = this.mainHeapByChunk.get(chunkIdx);
@@ -159,6 +183,18 @@ export class DerivedUniformsScene {
159
183
  export interface RoDerivedRequest {
160
184
  /** Derived rules to install, keyed by the uniform name each produces. */
161
185
  readonly rules: ReadonlyMap<string, DerivedRule>;
186
+ /**
187
+ * GPU transform propagation: the SG ancestor trafo chain for THIS RO's
188
+ * `Model` (root→leaf order, constant runs folded), as `aval<Trafo3d>` links.
189
+ * Links are constituents shared by aval identity, so a root trafo shared by
190
+ * N ROs is ONE slot referenced N times (no CPU fan-out). When present, the
191
+ * chain pass computes a per-RO `Model` constituent (fwd from the links, inv
192
+ * from the reversed inverse links) and the §7 rules' `Model` leaf resolves to
193
+ * it — so ModelTrafo / ModelView / ModelViewInv / NormalMatrix / custom rules
194
+ * all derive from the GPU-composed Model, unchanged. See
195
+ * docs/gpu-transform-propagation.md.
196
+ */
197
+ readonly modelChain?: readonly aval<Trafo3d>[];
162
198
  /** Trafo avals available on this RO, keyed by the uniform name a rule leaf may reference (e.g. "ModelTrafo"). */
163
199
  readonly trafoAvals: ReadonlyMap<string, aval<Trafo3d>>;
164
200
  /** drawHeader byte offset (relative to `drawHeaderBaseByte`) of a host-supplied uniform on this RO; undefined if not present. */
@@ -180,6 +216,9 @@ export interface RoRegistration {
180
216
  readonly ruleHashes: readonly string[];
181
217
  /** Chunk this registration was placed into — used by deregister. */
182
218
  readonly chunkIdx: number;
219
+ /** Per-RO Model output constituent pair (transform propagation); freed on
220
+ * deregister. Present iff the request carried a `modelChain`. */
221
+ readonly modelPair?: PairedSlots;
183
222
  }
184
223
 
185
224
  export function registerRoDerivations(
@@ -187,7 +226,9 @@ export function registerRoDerivations(
187
226
  owner: object,
188
227
  req: RoDerivedRequest,
189
228
  ): RoRegistration {
190
- if (req.rules.size === 0) return { owner, constituentAvals: [], ruleHashes: [], chunkIdx: req.chunkIdx };
229
+ if (req.rules.size === 0 && req.modelChain === undefined) {
230
+ return { owner, constituentAvals: [], ruleHashes: [], chunkIdx: req.chunkIdx };
231
+ }
191
232
  const records = scene.recordsFor(req.chunkIdx);
192
233
 
193
234
  const acquiredAvals: aval<Trafo3d>[] = [];
@@ -207,8 +248,9 @@ export function registerRoDerivations(
207
248
  };
208
249
  const resolve = (inp: RuleInput): number => {
209
250
  // A matrix-typed leaf bound as an aval is a `Trafo3d` ⇒ a df32 constituent slot
210
- // (its `Inverse` reads the stored backward half — free).
211
- if (inp.type.kind === "Matrix" && req.trafoAvals.has(inp.name)) {
251
+ // (its `Inverse` reads the stored backward half — free). `Model` may instead
252
+ // be a GPU-composed chain output already in `pairByName` (modelChain case).
253
+ if (inp.type.kind === "Matrix" && (req.trafoAvals.has(inp.name) || pairByName.has(inp.name))) {
212
254
  const p = acquireTrafo(inp.name);
213
255
  return makeHandle(SlotTag.Constituent, inp.inverse ? p.inv : p.fwd);
214
256
  }
@@ -225,6 +267,27 @@ export function registerRoDerivations(
225
267
  );
226
268
  };
227
269
 
270
+ // GPU transform propagation: compute this RO's `Model` as a per-RO constituent
271
+ // from its ancestor chain, and point §7's `Model` leaf at it. The chain pass
272
+ // (a separate, earlier dispatch) writes the fwd half from the links and the
273
+ // inv half from the reversed inverse links.
274
+ let modelPair: PairedSlots | undefined;
275
+ if (req.modelChain !== undefined) {
276
+ modelPair = scene.constituents.allocOutputPair();
277
+ pairByName.set("Model", modelPair);
278
+ const linkPairs = req.modelChain.map((av) => {
279
+ acquiredAvals.push(av);
280
+ return scene.constituents.acquire(av);
281
+ });
282
+ const n = linkPairs.length;
283
+ // Model.fwd = link0·link1·…·link(n-1) (forward order, fwd slots).
284
+ scene.chainRecords.add(owner, CHAIN_RULE_ID, makeHandle(SlotTag.Constituent, modelPair.fwd),
285
+ [n, ...linkPairs.map((p) => makeHandle(SlotTag.Constituent, p.fwd))]);
286
+ // Model.inv = link(n-1)⁻¹·…·link0⁻¹ (reversed order, inv slots).
287
+ scene.chainRecords.add(owner, CHAIN_RULE_ID, makeHandle(SlotTag.Constituent, modelPair.inv),
288
+ [n, ...linkPairs.slice().reverse().map((p) => makeHandle(SlotTag.Constituent, p.inv))]);
289
+ }
290
+
228
291
  for (const [outName, rule] of req.rules) {
229
292
  // `flatten` returns the same Expr object when nothing was substituted (the common case —
230
293
  // the standard recipes reference no derived names), so we can reuse `rule` (it already
@@ -241,12 +304,18 @@ export function registerRoDerivations(
241
304
  }
242
305
  records.add(owner, id, makeHandle(SlotTag.HostHeap, req.drawHeaderBaseByte + outOff), inSlots);
243
306
  }
307
+
244
308
  scene.ensureConstituentsCapacity(scene.constituents.slotCount * DF32_MAT4_BYTES);
245
- return { owner, constituentAvals: acquiredAvals, ruleHashes, chunkIdx: req.chunkIdx };
309
+ return {
310
+ owner, constituentAvals: acquiredAvals, ruleHashes, chunkIdx: req.chunkIdx,
311
+ ...(modelPair !== undefined ? { modelPair } : {}),
312
+ };
246
313
  }
247
314
 
248
315
  export function deregisterRoDerivations(scene: DerivedUniformsScene, reg: RoRegistration): void {
249
316
  scene.recordsFor(reg.chunkIdx).removeAllForOwner(reg.owner);
317
+ scene.chainRecords.removeAllForOwner(reg.owner);
250
318
  for (const h of reg.ruleHashes) scene.registry.release(h);
251
319
  for (const av of reg.constituentAvals) scene.constituents.release(av);
320
+ if (reg.modelPair !== undefined) scene.constituents.freeOutputPair(reg.modelPair);
252
321
  }
@@ -87,6 +87,21 @@ export class ConstituentSlots {
87
87
  this.mirror = new Float32Array(initialCapacity * 32);
88
88
  }
89
89
 
90
+ /** Allocate a fwd+inv slot pair for a GPU-WRITTEN output (not keyed by any
91
+ * aval, never dirty-tracked or CPU-uploaded). Used for a per-RO `Model`
92
+ * produced by the transform-propagation chain pass and then consumed by §7
93
+ * as a constituent. Free with `freeOutputPair`. */
94
+ allocOutputPair(): PairedSlots {
95
+ const fwd = this.pool.alloc() as SlotIndex;
96
+ const inv = this.pool.alloc() as SlotIndex;
97
+ this.ensureCapacity(this.pool.highWaterMark);
98
+ return { fwd, inv };
99
+ }
100
+ freeOutputPair(p: PairedSlots): void {
101
+ this.pool.release(p.fwd);
102
+ this.pool.release(p.inv);
103
+ }
104
+
90
105
  acquire(av: aval<Trafo3d>): PairedSlots {
91
106
  let entry = this.byAval.get(av);
92
107
  if (entry === undefined) {
@@ -464,6 +464,7 @@ export function renderObjectToHeapSpec(
464
464
  ...geom,
465
465
  ...(textures !== undefined ? { textures } : {}),
466
466
  ...(ro.modeRules !== undefined ? { modeRules: ro.modeRules } : {}),
467
+ ...(ro.modelChain !== undefined ? { modelChain: ro.modelChain } : {}),
467
468
  // Pass `active` through unforced so the heap path can subscribe
468
469
  // and react to flips without re-running the spec adapter.
469
470
  ...(ro.active !== undefined ? { active: ro.active } : {}),
@@ -530,6 +530,16 @@ export interface HeapDrawSpec {
530
530
  readonly depthWrite?: import("./derivedModes/rule.js").DerivedModeRule<"depthWrite">;
531
531
  readonly alphaToCoverage?: import("./derivedModes/rule.js").DerivedModeRule<"alphaToCoverage">;
532
532
  };
533
+ /**
534
+ * GPU transform propagation: this RO's `Model` ancestor trafo chain (root→
535
+ * leaf, constant runs folded) as `aval<Trafo3d>` links. When present, the §7
536
+ * compute pass composes a per-RO Model constituent from these on the GPU
537
+ * (instead of the RO supplying a single composed `ModelTrafo` aval), and the
538
+ * standard recipes / custom rules derive ModelView / inverses / NormalMatrix
539
+ * from it. Eliminates the CPU fan-out of a shared root trafo over many ROs.
540
+ * See docs/gpu-transform-propagation.md.
541
+ */
542
+ readonly modelChain?: readonly aval<Trafo3d>[];
533
543
  }
534
544
 
535
545
  export interface HeapSceneStats {
@@ -1193,6 +1203,10 @@ export function buildHeapScene(
1193
1203
  // just means "this RO doesn't supply the base trafos", so the field falls back to its
1194
1204
  // packed `spec.inputs` value (or errors later if there is none — same as without §7).
1195
1205
  for (const inp of inputsOf(rule.ir)) {
1206
+ // `Model` may be supplied by the GPU transform-propagation chain rather
1207
+ // than a ModelTrafo aval — treat it as bound when a modelChain is present
1208
+ // (registerRoDerivations points the §7 Model leaf at the chain output).
1209
+ if (inp.name === "Model" && spec.modelChain !== undefined) continue;
1196
1210
  const specName = STANDARD_TRAFO_LEAVES.get(inp.name) ?? inp.name;
1197
1211
  const lv = spec.inputs[specName];
1198
1212
  if (lv === undefined || isDerivedRule(lv)) {
@@ -3415,6 +3429,7 @@ export function buildHeapScene(
3415
3429
  const reg = registerRoDerivations(derivedScene, {}, {
3416
3430
  rules: ruleSubset,
3417
3431
  trafoAvals,
3432
+ ...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
3418
3433
  hostUniformOffset: (n) => {
3419
3434
  const r = perDrawRefs.get(n);
3420
3435
  return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;