@aardworx/wombat.rendering 0.1.0

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 (46) hide show
  1. package/package.json +67 -0
  2. package/src/commands/clear.ts +66 -0
  3. package/src/commands/index.ts +11 -0
  4. package/src/commands/render.ts +106 -0
  5. package/src/core/acquire.ts +22 -0
  6. package/src/core/adaptiveResource.ts +88 -0
  7. package/src/core/buffer.ts +45 -0
  8. package/src/core/bufferView.ts +26 -0
  9. package/src/core/clear.ts +16 -0
  10. package/src/core/command.ts +50 -0
  11. package/src/core/drawCall.ts +22 -0
  12. package/src/core/framebuffer.ts +37 -0
  13. package/src/core/framebufferSignature.ts +19 -0
  14. package/src/core/index.ts +116 -0
  15. package/src/core/pipelineState.ts +64 -0
  16. package/src/core/renderContext.ts +48 -0
  17. package/src/core/renderObject.ts +57 -0
  18. package/src/core/renderTask.ts +17 -0
  19. package/src/core/renderTree.ts +29 -0
  20. package/src/core/sampler.ts +12 -0
  21. package/src/core/shader.ts +28 -0
  22. package/src/core/texture.ts +63 -0
  23. package/src/index.ts +18 -0
  24. package/src/resources/adaptiveBuffer.ts +135 -0
  25. package/src/resources/adaptiveSampler.ts +82 -0
  26. package/src/resources/adaptiveTexture.ts +269 -0
  27. package/src/resources/framebuffer.ts +163 -0
  28. package/src/resources/framebufferSignature.ts +36 -0
  29. package/src/resources/index.ts +82 -0
  30. package/src/resources/mipGen.ts +148 -0
  31. package/src/resources/preparedComputeShader.ts +426 -0
  32. package/src/resources/preparedRenderObject.ts +498 -0
  33. package/src/resources/renderPipeline.ts +148 -0
  34. package/src/resources/shaderDiagnostics.ts +52 -0
  35. package/src/resources/sourceMapDecoder.ts +133 -0
  36. package/src/resources/uniformBuffer.ts +170 -0
  37. package/src/resources/webgpuFlags.ts +43 -0
  38. package/src/runtime/copy.ts +13 -0
  39. package/src/runtime/index.ts +24 -0
  40. package/src/runtime/renderTask.ts +137 -0
  41. package/src/runtime/renderTo.ts +154 -0
  42. package/src/runtime/runtime.ts +113 -0
  43. package/src/runtime/scenePass.ts +273 -0
  44. package/src/window/canvas.ts +252 -0
  45. package/src/window/index.ts +14 -0
  46. package/src/window/loop.ts +62 -0
@@ -0,0 +1,154 @@
1
+ // renderTo — Aardvark `RenderTask.renderTo` ported.
2
+ //
3
+ // Usage:
4
+ // const result = renderTo(ctx, scene, { size, signature, clear });
5
+ // const colorAval: aval<ITexture> = result.color("color");
6
+ // // bind colorAval as a texture on a downstream RenderObject
7
+ //
8
+ // `result.framebuffer` is an `AdaptiveResource<IFramebuffer>` that:
9
+ // - Allocates the FBO on first acquire.
10
+ // - On `compute(token)`: reads `size`, encodes a clear pass +
11
+ // scene render into the active `RenderContext.encoder`, returns
12
+ // the FBO. Encoding only runs when an outer encoder is active;
13
+ // when read outside a frame the FBO is returned untouched.
14
+ // - On last release: disposes the inner cache and frees the FBO.
15
+ //
16
+ // Each `result.color(name)` is a derived `aval<ITexture>` whose
17
+ // lifetime forwards to `framebuffer`, so consuming any attachment
18
+ // keeps the whole pipeline alive.
19
+
20
+ import {
21
+ AdaptiveResource,
22
+ ITexture,
23
+ RenderContext,
24
+ type ClearValues,
25
+ type FramebufferSignature,
26
+ type IFramebuffer,
27
+ type RenderTree,
28
+ } from "../core/index.js";
29
+ import {
30
+ allocateFramebuffer,
31
+ type FramebufferSize,
32
+ } from "../resources/index.js";
33
+ import { beginPassDescriptor } from "../commands/index.js";
34
+ import { HashMap, type AdaptiveToken, type aval } from "@aardworx/wombat.adaptive";
35
+ import { ScenePass } from "./scenePass.js";
36
+ import type { RuntimeContext } from "./renderTask.js";
37
+
38
+ export interface RenderToOptions {
39
+ readonly size: aval<FramebufferSize>;
40
+ readonly signature: FramebufferSignature;
41
+ /** What to clear the FBO with each frame. Omit to skip the clear pass. */
42
+ readonly clear?: ClearValues;
43
+ readonly label?: string;
44
+ /**
45
+ * Extra GPU texture usage flags for every color attachment.
46
+ * `RENDER_ATTACHMENT | TEXTURE_BINDING` are always set; pass
47
+ * `COPY_SRC` here to enable readback, `STORAGE_BINDING` for
48
+ * compute writes, etc.
49
+ */
50
+ readonly extraUsage?: GPUTextureUsageFlags;
51
+ }
52
+
53
+ export interface RenderToResult {
54
+ /** The full framebuffer resource. Acquire/release for explicit lifetime control. */
55
+ readonly framebuffer: AdaptiveResource<IFramebuffer>;
56
+ /**
57
+ * Returns an `aval<ITexture>` for the named color attachment.
58
+ * The aval is itself an AdaptiveResource — acquiring it
59
+ * activates the render-to pipeline.
60
+ */
61
+ color(name: string): AdaptiveResource<ITexture>;
62
+ /**
63
+ * Convenience for multi-target render passes: returns a
64
+ * `HashMap<string, aval<ITexture>>` covering every color
65
+ * attachment in the signature. Each entry shares the underlying
66
+ * lifetime — acquiring any (or the whole map's entries) activates
67
+ * the render-to pipeline.
68
+ */
69
+ colors(): HashMap<string, AdaptiveResource<ITexture>>;
70
+ /** Returns the depth-stencil attachment as an `ITexture` (if the signature has one). */
71
+ depthStencil(): AdaptiveResource<ITexture>;
72
+ }
73
+
74
+ class RenderToFramebuffer extends AdaptiveResource<IFramebuffer> {
75
+ private scenePass: ScenePass | undefined;
76
+
77
+ constructor(
78
+ private readonly ctx: RuntimeContext,
79
+ private readonly fbo: AdaptiveResource<IFramebuffer>,
80
+ private readonly scene: RenderTree,
81
+ private readonly clearValues: ClearValues | undefined,
82
+ private readonly signature: FramebufferSignature,
83
+ ) { super(); }
84
+
85
+ protected override create(): void {
86
+ this.fbo.acquire();
87
+ this.scenePass = new ScenePass(
88
+ this.ctx.device, this.signature, this.scene, this.ctx.compileEffect,
89
+ );
90
+ }
91
+
92
+ protected override destroy(): void {
93
+ this.scenePass?.dispose();
94
+ this.scenePass = undefined;
95
+ this.fbo.release();
96
+ }
97
+
98
+ override compute(token: AdaptiveToken): IFramebuffer {
99
+ const fb = this.fbo.getValue(token);
100
+ const enc = RenderContext.encoder;
101
+ if (enc !== null && this.scenePass !== undefined) {
102
+ const leaves = this.scenePass.resolve(token);
103
+ if (leaves.length > 0 || this.clearValues !== undefined) {
104
+ const pass = enc.beginRenderPass(beginPassDescriptor(fb, this.clearValues));
105
+ for (const leaf of leaves) leaf.record(pass, token);
106
+ pass.end();
107
+ }
108
+ }
109
+ return fb;
110
+ }
111
+ }
112
+
113
+ export function renderTo(
114
+ ctx: RuntimeContext,
115
+ scene: RenderTree,
116
+ opts: RenderToOptions,
117
+ ): RenderToResult {
118
+ const fbo = allocateFramebuffer(ctx.device, opts.signature, opts.size, {
119
+ ...(opts.label !== undefined ? { labelPrefix: opts.label } : {}),
120
+ ...(opts.extraUsage !== undefined ? { extraUsage: opts.extraUsage } : {}),
121
+ });
122
+ const renderToFbo = new RenderToFramebuffer(
123
+ ctx, fbo, scene,
124
+ opts.clear,
125
+ opts.signature,
126
+ );
127
+ return {
128
+ framebuffer: renderToFbo,
129
+ color(name) {
130
+ return renderToFbo.derive<ITexture>(fb => {
131
+ const tex = fb.colorTextures?.tryFind(name);
132
+ if (tex === undefined) {
133
+ throw new Error(`renderTo.color: framebuffer has no color attachment "${name}"`);
134
+ }
135
+ return ITexture.fromGPU(tex);
136
+ });
137
+ },
138
+ colors() {
139
+ let out = HashMap.empty<string, AdaptiveResource<ITexture>>();
140
+ for (const [name] of opts.signature.colors) {
141
+ out = out.add(name, this.color(name));
142
+ }
143
+ return out;
144
+ },
145
+ depthStencil() {
146
+ return renderToFbo.derive<ITexture>(fb => {
147
+ if (fb.depthStencilTexture === undefined) {
148
+ throw new Error("renderTo.depthStencil: framebuffer has no depth-stencil attachment");
149
+ }
150
+ return ITexture.fromGPU(fb.depthStencilTexture);
151
+ });
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,113 @@
1
+ // Runtime — the user-facing entry point. Holds the device +
2
+ // effect-compilation hook, exposes `compile(commands)` and
3
+ // (eventually) `renderTo(...)`.
4
+
5
+ import type { Command, CompiledEffect, Effect, IRenderTask, RenderTree } from "../core/index.js";
6
+ import type { alist } from "@aardworx/wombat.adaptive";
7
+ import { compileRenderTask, type RuntimeContext } from "./renderTask.js";
8
+ import { renderTo, type RenderToOptions, type RenderToResult } from "./renderTo.js";
9
+
10
+ export interface RuntimeOptions {
11
+ readonly device: GPUDevice;
12
+ /**
13
+ * Optional override for `Effect → CompiledEffect`. Defaults to
14
+ * `effect.compile({ target: "wgsl" })`. Override when tests want
15
+ * a hand-built CompiledEffect or when the caller needs custom
16
+ * `CompileOptions` (`skipMatrixReversal`, source-file labelling,
17
+ * …).
18
+ */
19
+ readonly compileEffect?: (effect: Effect) => CompiledEffect;
20
+ }
21
+
22
+ export class Runtime {
23
+ private readonly ctx: RuntimeContext;
24
+ private readonly _tasks = new Set<IRenderTask>();
25
+ private _disposed = false;
26
+ private _deviceLost = false;
27
+ /**
28
+ * Resolves to the lost-info when the device is reported lost.
29
+ * Stays pending while the device is alive.
30
+ */
31
+ readonly deviceLost: Promise<GPUDeviceLostInfo>;
32
+
33
+ constructor(opts: RuntimeOptions) {
34
+ this.ctx = {
35
+ device: opts.device,
36
+ compileEffect: opts.compileEffect ?? ((e: Effect) => e.compile({ target: "wgsl" })),
37
+ };
38
+ // `device.lost` is a real-WebGPU promise; mock devices may not
39
+ // expose it. Treat as "never lost" in that case.
40
+ const lost = (opts.device as { lost?: Promise<GPUDeviceLostInfo> }).lost;
41
+ this.deviceLost = lost !== undefined
42
+ ? lost.then((info) => {
43
+ this._deviceLost = true;
44
+ // Best-effort: dispose all outstanding tasks so user code
45
+ // that still holds them stops trying to encode against a
46
+ // dead device.
47
+ this.disposeAll();
48
+ return info;
49
+ })
50
+ : new Promise(() => { /* never resolves on mock */ });
51
+ }
52
+
53
+ get device(): GPUDevice { return this.ctx.device; }
54
+ get isDeviceLost(): boolean { return this._deviceLost; }
55
+
56
+ // Recovery story (after `device.lost`):
57
+ //
58
+ // 1. The `lost`-handler above fires `disposeAll()`, releasing all
59
+ // `IRenderTask`s and the `PreparedRenderObject`s they hold.
60
+ // User-level avals (`cval`, `clist`, `cset`) survive — they're
61
+ // device-agnostic.
62
+ // 2. Caller re-requests an adapter + device, constructs a new
63
+ // `Runtime` from it, and re-`compile()`s their original
64
+ // `alist<Command>`. The new Runtime builds fresh
65
+ // PreparedRenderObjects + a fresh ScenePass tree from the same
66
+ // user data; the per-device caches inside compile-pipeline /
67
+ // sampler / mip-gen are keyed on `WeakMap<GPUDevice, ...>` so
68
+ // they re-populate naturally for the new device.
69
+ //
70
+ // We don't expose a single-call `replaceDevice()` because every
71
+ // prepared object's GPU handles are baked in at construction; the
72
+ // cleanest path is to discard the old Runtime and construct a new
73
+ // one from the same source `alist<Command>`.
74
+
75
+ /** Compile an `alist<Command>` into a runnable `IRenderTask`. */
76
+ compile(commands: alist<Command>): IRenderTask {
77
+ if (this._disposed) throw new Error("Runtime: compile after disposeAll");
78
+ const task = compileRenderTask(this.ctx, commands);
79
+ this._tasks.add(task);
80
+ const origDispose = task.dispose.bind(task);
81
+ task.dispose = () => { origDispose(); this._tasks.delete(task); };
82
+ return task;
83
+ }
84
+
85
+ /**
86
+ * Render `scene` into a freshly-allocated framebuffer, returning
87
+ * lazy `aval<ITexture>` handles for each color/depth attachment.
88
+ * The framebuffer + inner render task come live when any
89
+ * returned aval is acquired (transitively, e.g. by binding it
90
+ * to a downstream RenderObject), and shut down on last release.
91
+ */
92
+ renderTo(scene: RenderTree, opts: RenderToOptions): RenderToResult {
93
+ if (this._disposed) throw new Error("Runtime: renderTo after disposeAll");
94
+ return renderTo(this.ctx, scene, opts);
95
+ }
96
+
97
+ /**
98
+ * Tear down every IRenderTask compiled through this Runtime.
99
+ * After calling this, `compile()` and `renderTo()` throw.
100
+ * Idempotent. Intended for page-unload + fatal error paths.
101
+ *
102
+ * Note: doesn't destroy the GPUDevice itself — the user still
103
+ * owns its lifecycle.
104
+ */
105
+ disposeAll(): void {
106
+ if (this._disposed) return;
107
+ this._disposed = true;
108
+ for (const t of [...this._tasks]) {
109
+ try { t.dispose(); } catch (e) { console.error("Runtime.disposeAll: task.dispose threw", e); }
110
+ }
111
+ this._tasks.clear();
112
+ }
113
+ }
@@ -0,0 +1,273 @@
1
+ // ScenePass — delta-driven walker for one `Render` command's
2
+ // `RenderTree`. Built once at task-compile time; subscribes to the
3
+ // dynamic subtrees (`Adaptive` / `OrderedFromList` /
4
+ // `UnorderedFromSet`); incrementally maintains its set of
5
+ // PreparedRenderObjects.
6
+ //
7
+ // Per-frame cost = O(deltas) + O(leaves) for emission. The static
8
+ // recursion through Empty / Leaf / Ordered / Unordered nodes that
9
+ // the previous walker did each frame is gone — those are folded
10
+ // into a stable NodeWalker tree at construction.
11
+ //
12
+ // Lifted from Aardvark.Rendering's CommandTask delta handling.
13
+ // We keep the *adaptive bookkeeping* pattern (per-RO AdaptiveObject,
14
+ // reader-driven splice) and drop the native command-stream linked-
15
+ // list — that was a .NET↔native amortisation in the F# build with
16
+ // no analogue in WebGPU/JS.
17
+
18
+ import {
19
+ type AdaptiveToken,
20
+ type IIndexListReader,
21
+ type IHashSetReader,
22
+ MapExt,
23
+ type Index,
24
+ type aval,
25
+ type alist,
26
+ type aset,
27
+ } from "@aardworx/wombat.adaptive";
28
+ import {
29
+ type CompiledEffect,
30
+ type Effect,
31
+ type FramebufferSignature,
32
+ type RenderObject,
33
+ type RenderTree,
34
+ } from "../core/index.js";
35
+ import {
36
+ prepareRenderObject,
37
+ type PreparedRenderObject,
38
+ } from "../resources/index.js";
39
+
40
+ interface BuildContext {
41
+ readonly device: GPUDevice;
42
+ readonly compileEffect: (e: Effect) => CompiledEffect;
43
+ readonly signature: FramebufferSignature;
44
+ readonly stats: WalkerStats;
45
+ }
46
+
47
+ /**
48
+ * Counters surfaced for tests. `prepareCount` only goes up when
49
+ * `prepareRenderObject` is called for a fresh RenderObject; it
50
+ * does not increment per frame on an unchanged scene.
51
+ */
52
+ export interface WalkerStats {
53
+ prepareCount: number;
54
+ }
55
+
56
+ abstract class NodeWalker {
57
+ /** Pull deltas from any subscribed readers and propagate to children. */
58
+ abstract update(token: AdaptiveToken): void;
59
+ /** Push this subtree's current leaves into `out`, in render order. */
60
+ abstract emit(out: PreparedRenderObject[]): void;
61
+ /** Release every PreparedRenderObject this walker owns. */
62
+ abstract dispose(): void;
63
+ }
64
+
65
+ class EmptyWalker extends NodeWalker {
66
+ update(): void {}
67
+ emit(): void {}
68
+ dispose(): void {}
69
+ }
70
+
71
+ class LeafWalker extends NodeWalker {
72
+ private readonly prepared: PreparedRenderObject;
73
+ constructor(ctx: BuildContext, obj: RenderObject) {
74
+ super();
75
+ const eff = ctx.compileEffect(obj.effect);
76
+ this.prepared = prepareRenderObject(ctx.device, obj, eff, ctx.signature, {
77
+ effectId: obj.effect.id,
78
+ });
79
+ this.prepared.acquire();
80
+ ctx.stats.prepareCount++;
81
+ }
82
+ update(): void {}
83
+ emit(out: PreparedRenderObject[]): void { out.push(this.prepared); }
84
+ dispose(): void { this.prepared.release(); }
85
+ }
86
+
87
+ class OrderedWalker extends NodeWalker {
88
+ constructor(private readonly children: readonly NodeWalker[]) { super(); }
89
+ update(token: AdaptiveToken): void { for (const c of this.children) c.update(token); }
90
+ emit(out: PreparedRenderObject[]): void { for (const c of this.children) c.emit(out); }
91
+ dispose(): void { for (const c of this.children) c.dispose(); }
92
+ }
93
+
94
+ // Same identity-rank counter used by the previous walker. Sorting
95
+ // Unordered subtrees by pipeline-then-group0-layout to minimise
96
+ // state changes inside the render pass.
97
+ const sortRanks = new WeakMap<object, number>();
98
+ let nextSortRank = 1;
99
+ function rankOf(o: object): number {
100
+ let r = sortRanks.get(o);
101
+ if (r === undefined) { r = nextSortRank++; sortRanks.set(o, r); }
102
+ return r;
103
+ }
104
+ function compareLeaves(a: PreparedRenderObject, b: PreparedRenderObject): number {
105
+ const pa = rankOf(a.pipeline);
106
+ const pb = rankOf(b.pipeline);
107
+ if (pa !== pb) return pa - pb;
108
+ const la = a.groups[0]?.layout;
109
+ const lb = b.groups[0]?.layout;
110
+ if (la !== undefined && lb !== undefined && la !== lb) return rankOf(la) - rankOf(lb);
111
+ return 0;
112
+ }
113
+
114
+ class UnorderedWalker extends NodeWalker {
115
+ constructor(private readonly children: readonly NodeWalker[]) { super(); }
116
+ update(token: AdaptiveToken): void { for (const c of this.children) c.update(token); }
117
+ emit(out: PreparedRenderObject[]): void {
118
+ const start = out.length;
119
+ for (const c of this.children) c.emit(out);
120
+ // Sort just our slice in-place.
121
+ const slice = out.splice(start);
122
+ slice.sort(compareLeaves);
123
+ for (const p of slice) out.push(p);
124
+ }
125
+ dispose(): void { for (const c of this.children) c.dispose(); }
126
+ }
127
+
128
+ class AdaptiveWalker extends NodeWalker {
129
+ private current: NodeWalker | undefined;
130
+ private currentTree: RenderTree | undefined;
131
+ constructor(private readonly source: aval<RenderTree>, private readonly ctx: BuildContext) {
132
+ super();
133
+ }
134
+ update(token: AdaptiveToken): void {
135
+ const t = this.source.getValue(token);
136
+ if (t !== this.currentTree) {
137
+ this.current?.dispose();
138
+ this.current = build(t, this.ctx);
139
+ this.currentTree = t;
140
+ }
141
+ this.current!.update(token);
142
+ }
143
+ emit(out: PreparedRenderObject[]): void { this.current?.emit(out); }
144
+ dispose(): void { this.current?.dispose(); this.current = undefined; }
145
+ }
146
+
147
+ class OrderedFromListWalker extends NodeWalker {
148
+ private readonly reader: IIndexListReader<RenderTree>;
149
+ // Sorted-by-Index map. Reassigned (immutable persistent) on every delta.
150
+ private map: MapExt<Index, NodeWalker> = MapExt.empty(indexCompare);
151
+ constructor(source: alist<RenderTree>, private readonly ctx: BuildContext) {
152
+ super();
153
+ this.reader = source.getReader();
154
+ }
155
+ update(token: AdaptiveToken): void {
156
+ const deltas = this.reader.getChanges(token);
157
+ for (const [idx, op] of deltas) {
158
+ if (op.tag === "Set") {
159
+ const old = this.map.tryFind(idx);
160
+ if (old !== undefined) old.dispose();
161
+ const w = build(op.value, this.ctx);
162
+ // Update the new child immediately so its initial state
163
+ // is consistent before the next emit.
164
+ w.update(token);
165
+ this.map = this.map.add(idx, w);
166
+ } else {
167
+ const old = this.map.tryFind(idx);
168
+ if (old !== undefined) {
169
+ old.dispose();
170
+ this.map = this.map.remove(idx);
171
+ }
172
+ }
173
+ }
174
+ // Existing children may have dirty sub-readers — update them too.
175
+ for (const [, w] of this.map) w.update(token);
176
+ }
177
+ emit(out: PreparedRenderObject[]): void {
178
+ for (const [, w] of this.map) w.emit(out);
179
+ }
180
+ dispose(): void {
181
+ for (const [, w] of this.map) w.dispose();
182
+ this.map = MapExt.empty(indexCompare);
183
+ }
184
+ }
185
+
186
+ class UnorderedFromSetWalker extends NodeWalker {
187
+ private readonly reader: IHashSetReader<RenderTree>;
188
+ private readonly map = new Map<RenderTree, NodeWalker>();
189
+ constructor(source: aset<RenderTree>, private readonly ctx: BuildContext) {
190
+ super();
191
+ this.reader = source.getReader();
192
+ }
193
+ update(token: AdaptiveToken): void {
194
+ const deltas = this.reader.getChanges(token);
195
+ for (const op of deltas) {
196
+ if (op.count > 0) {
197
+ if (!this.map.has(op.value)) {
198
+ const w = build(op.value, this.ctx);
199
+ w.update(token);
200
+ this.map.set(op.value, w);
201
+ }
202
+ } else if (op.count < 0) {
203
+ const old = this.map.get(op.value);
204
+ if (old !== undefined) {
205
+ old.dispose();
206
+ this.map.delete(op.value);
207
+ }
208
+ }
209
+ }
210
+ for (const w of this.map.values()) w.update(token);
211
+ }
212
+ emit(out: PreparedRenderObject[]): void {
213
+ const start = out.length;
214
+ for (const w of this.map.values()) w.emit(out);
215
+ const slice = out.splice(start);
216
+ slice.sort(compareLeaves);
217
+ for (const p of slice) out.push(p);
218
+ }
219
+ dispose(): void {
220
+ for (const w of this.map.values()) w.dispose();
221
+ this.map.clear();
222
+ }
223
+ }
224
+
225
+ function indexCompare(a: Index, b: Index): number { return a.compareTo(b); }
226
+
227
+ function build(tree: RenderTree, ctx: BuildContext): NodeWalker {
228
+ switch (tree.kind) {
229
+ case "Empty": return new EmptyWalker();
230
+ case "Leaf": return new LeafWalker(ctx, tree.object);
231
+ case "Ordered": return new OrderedWalker(tree.children.map(c => build(c, ctx)));
232
+ case "Unordered": return new UnorderedWalker(tree.children.map(c => build(c, ctx)));
233
+ case "Adaptive": return new AdaptiveWalker(tree.tree, ctx);
234
+ case "OrderedFromList": return new OrderedFromListWalker(tree.children, ctx);
235
+ case "UnorderedFromSet": return new UnorderedFromSetWalker(tree.children, ctx);
236
+ }
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // ScenePass — public entry
241
+ // ---------------------------------------------------------------------------
242
+
243
+ export class ScenePass {
244
+ private readonly root: NodeWalker;
245
+ /** Bumped on `prepareRenderObject`. Tests use this to confirm O(deltas) prep cost. */
246
+ readonly stats: WalkerStats = { prepareCount: 0 };
247
+
248
+ constructor(
249
+ device: GPUDevice,
250
+ signature: FramebufferSignature,
251
+ tree: RenderTree,
252
+ compileEffect: (e: Effect) => CompiledEffect,
253
+ ) {
254
+ const ctx: BuildContext = { device, signature, compileEffect, stats: this.stats };
255
+ this.root = build(tree, ctx);
256
+ }
257
+
258
+ /**
259
+ * Pull deltas from every dynamic subtree, splice the affected
260
+ * walkers, and produce the current ordered list of leaves.
261
+ * Idempotent on a clean adaptive graph (no readers fire).
262
+ */
263
+ resolve(token: AdaptiveToken): PreparedRenderObject[] {
264
+ this.root.update(token);
265
+ const out: PreparedRenderObject[] = [];
266
+ this.root.emit(out);
267
+ return out;
268
+ }
269
+
270
+ dispose(): void {
271
+ this.root.dispose();
272
+ }
273
+ }