@forgeax/engine-render-graph 0.0.0-dev.8d955ade1c79

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 (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +184 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/observation.unit.test.d.ts +2 -0
  5. package/dist/__tests__/observation.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/render-graph-alias-source.unit.test.d.ts +2 -0
  7. package/dist/__tests__/render-graph-alias-source.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/render-graph-builder.unit.test.d.ts +2 -0
  9. package/dist/__tests__/render-graph-builder.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/render-graph-errors.test-d.d.ts +2 -0
  11. package/dist/__tests__/render-graph-errors.test-d.d.ts.map +1 -0
  12. package/dist/__tests__/render-graph-regressions.unit.test.d.ts +2 -0
  13. package/dist/__tests__/render-graph-regressions.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts +2 -0
  15. package/dist/__tests__/render-graph-rhi-null.integration.test.d.ts.map +1 -0
  16. package/dist/__tests__/render-graph.unit.test.d.ts +2 -0
  17. package/dist/__tests__/render-graph.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/resource-declaration-collision.test.d.ts +2 -0
  19. package/dist/__tests__/resource-declaration-collision.test.d.ts.map +1 -0
  20. package/dist/builder.d.ts +42 -0
  21. package/dist/builder.d.ts.map +1 -0
  22. package/dist/compiled-graph.d.ts +23 -0
  23. package/dist/compiled-graph.d.ts.map +1 -0
  24. package/dist/errors.d.ts +148 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/graph.d.ts +403 -0
  27. package/dist/graph.d.ts.map +1 -0
  28. package/dist/index.d.ts +8 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.mjs +2256 -0
  31. package/dist/index.mjs.map +1 -0
  32. package/dist/kernel-internal.d.ts +84 -0
  33. package/dist/kernel-internal.d.ts.map +1 -0
  34. package/dist/observation.d.ts +38 -0
  35. package/dist/observation.d.ts.map +1 -0
  36. package/dist/pass-registry.d.ts +12 -0
  37. package/dist/pass-registry.d.ts.map +1 -0
  38. package/dist/pipeline/__tests__/color-value-domain.test.d.ts +2 -0
  39. package/dist/pipeline/__tests__/color-value-domain.test.d.ts.map +1 -0
  40. package/dist/pipeline/color-value-domain.d.ts +35 -0
  41. package/dist/pipeline/color-value-domain.d.ts.map +1 -0
  42. package/dist/resource-registry.d.ts +62 -0
  43. package/dist/resource-registry.d.ts.map +1 -0
  44. package/dist/types.d.ts +171 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/package.json +61 -0
  47. package/src/__tests__/observation.unit.test.ts +103 -0
  48. package/src/__tests__/render-graph-alias-source.unit.test.ts +118 -0
  49. package/src/__tests__/render-graph-builder.unit.test.ts +673 -0
  50. package/src/__tests__/render-graph-errors.test-d.ts +207 -0
  51. package/src/__tests__/render-graph-regressions.unit.test.ts +126 -0
  52. package/src/__tests__/render-graph-rhi-null.integration.test.ts +86 -0
  53. package/src/__tests__/render-graph.unit.test.ts +2551 -0
  54. package/src/__tests__/resource-declaration-collision.test.ts +151 -0
  55. package/src/builder.ts +1009 -0
  56. package/src/compiled-graph.ts +383 -0
  57. package/src/errors.ts +205 -0
  58. package/src/graph.ts +1269 -0
  59. package/src/index.ts +97 -0
  60. package/src/kernel-internal.ts +148 -0
  61. package/src/observation.ts +134 -0
  62. package/src/pass-registry.ts +43 -0
  63. package/src/pipeline/__tests__/color-value-domain.test.ts +43 -0
  64. package/src/pipeline/color-value-domain.ts +139 -0
  65. package/src/resource-registry.ts +174 -0
  66. package/src/types.ts +216 -0
package/src/graph.ts ADDED
@@ -0,0 +1,1269 @@
1
+ // @forgeax/engine-render-graph/src/graph.ts — RenderGraph core primitives.
2
+ //
3
+ // Shape (plan-strategy D-1/D-4/D-5/D-6.1):
4
+ // - ResourceDescriptor / PassDescriptor — declaration types
5
+ // - RenderGraph — main class: addResource / addPass / compile / execute
6
+ // - PassInfo / ResourceInfo — query interfaces (D-5)
7
+
8
+ import type {
9
+ RhiCaps,
10
+ ComputePassDescriptor as RhiComputePassDescriptor,
11
+ RhiComputePassEncoder,
12
+ RhiDevice,
13
+ Texture,
14
+ TextureFormat,
15
+ TextureView,
16
+ } from '@forgeax/engine-rhi';
17
+ import {
18
+ type AliasSourceDetail,
19
+ type CapMissingDetail,
20
+ type DanglingReadDetail,
21
+ err,
22
+ ok,
23
+ RenderGraphError,
24
+ type Result,
25
+ } from './errors.js';
26
+ import {
27
+ type CurrentFrameObservationDescriptor,
28
+ type CurrentFrameObservationLease,
29
+ createCurrentFrameObservationLease,
30
+ } from './observation.js';
31
+ import type { PassEntry } from './pass-registry.js';
32
+ import { PassRegistry } from './pass-registry.js';
33
+ import {
34
+ type ColorDomainConnection,
35
+ type ColorValueDomain,
36
+ validateColorDomainConnection,
37
+ } from './pipeline/color-value-domain.js';
38
+ import type { ResourceEntry } from './resource-registry.js';
39
+ import { ResourceRegistry } from './resource-registry.js';
40
+
41
+ // ── Declaration types ────────────────────────────────────────────
42
+
43
+ export type ResourceKind = 'texture' | 'buffer';
44
+
45
+ export type ResourceLifetime = 'transient' | 'persistent';
46
+
47
+ export type BufferRole = 'auto-storage-or-uniform' | 'uniform';
48
+
49
+ /**
50
+ * Three-state size for addColorTarget (D-8):
51
+ * - 'swapchain' — matches the output canvas size
52
+ * - 'half-swapchain' — 1/2 the output canvas size (bloom downscale)
53
+ * - { w, h } — fixed pixel dimensions (shadow maps, etc.)
54
+ */
55
+ export type ColorTargetSize =
56
+ | 'swapchain'
57
+ | 'half-swapchain'
58
+ | { readonly w: number; readonly h: number };
59
+
60
+ /**
61
+ * Color target descriptor for addColorTarget (D-8).
62
+ *
63
+ * format: GPU texture format (e.g. 'rgba16float', 'bgra8unorm').
64
+ * size: target dimensions relative to swap-chain or absolute.
65
+ * lifetime: allocation lifetime (default 'transient'); persistent targets keep
66
+ * their physical texture across unchanged compiles and replace it on descriptor drift.
67
+ * sample: multisample count (default 1; MSAA count=4 via #301).
68
+ * usage: GPU texture usage flags (default RENDER_ATTACHMENT | TEXTURE_BINDING).
69
+ * viewFormats: extra GPU texture formats viewable via createTextureView from this
70
+ * texture; mirrors GPUTextureDescriptor.viewFormats. The LDR MSAA path needs
71
+ * `bgra8unorm` storage + `bgra8unorm-srgb` view (hardware sRGB encoding on
72
+ * store), so the consumer pre-declares the alternate format here.
73
+ */
74
+ export interface ColorTargetDescriptor {
75
+ readonly format: TextureFormat;
76
+ readonly size: ColorTargetSize;
77
+ readonly lifetime?: ResourceLifetime | undefined;
78
+ readonly sample?: number | undefined;
79
+ readonly usage?: number | undefined;
80
+ readonly viewFormats?: readonly TextureFormat[] | undefined;
81
+ /** Semantic color domain. Omitted only for legacy graphs without domain connections. */
82
+ readonly domain?: ColorValueDomain | undefined;
83
+ }
84
+
85
+ export interface ResolvedColorTargetDescriptor {
86
+ readonly texture: Texture;
87
+ readonly format: TextureFormat;
88
+ readonly size: { readonly width: number; readonly height: number };
89
+ readonly usage: number;
90
+ readonly sample: number;
91
+ }
92
+
93
+ export interface ResourceDescriptor {
94
+ readonly kind: ResourceKind;
95
+ readonly lifetime: ResourceLifetime;
96
+ readonly bufferRole?: BufferRole;
97
+ }
98
+
99
+ /**
100
+ * Opaque handle returned by addColorTarget. Resolved to a TextureView after
101
+ * compile by calling resolve(name) in a pass execute closure.
102
+ */
103
+ export type ColorTargetHandle = string;
104
+
105
+ /**
106
+ * Per-pass resolve context: maps color target names to compiled TextureViews.
107
+ * A pass execute closure receives this alongside the user-provided Ctx.
108
+ */
109
+ export interface ResolveContext {
110
+ /** Resolve a color target name to its compiled TextureView, or undefined if not compiled. */
111
+ readonly resolve: (name: string) => unknown;
112
+ }
113
+
114
+ export interface PassDescriptor<Ctx = unknown> {
115
+ readonly reads: readonly string[];
116
+ readonly writes: readonly string[];
117
+ readonly execute?: ((ctx: Ctx) => void) | ((ctx: Ctx, resolve: ResolveContext) => void);
118
+ readonly compute?: boolean;
119
+ readonly storageBuffer?: boolean;
120
+ /** Explicit resource-to-resource color-domain edges validated at compile time. */
121
+ readonly colorConnections?: readonly ColorDomainConnection[] | undefined;
122
+ }
123
+
124
+ export interface ComputePassDescriptor<Ctx> {
125
+ readonly reads: readonly string[];
126
+ readonly writes: readonly string[];
127
+ readonly storageBuffer?: boolean | undefined;
128
+ readonly begin?: ((frame: Ctx) => RhiComputePassDescriptor) | undefined;
129
+ readonly onBeginError?: ((frame: Ctx, cause: unknown) => void) | undefined;
130
+ readonly after?: ((frame: Ctx) => void) | undefined;
131
+ encode(context: {
132
+ readonly pass: RhiComputePassEncoder;
133
+ readonly frame: Ctx;
134
+ readonly resources: ResolveContext;
135
+ }): void;
136
+ }
137
+
138
+ /** Optional nested observer for per-pass execution attribution. */
139
+ export type PassExecuteRunner = (passName: string, action: () => void) => void;
140
+
141
+ // ── Query types (D-5) ────────────────────────────────────────────
142
+
143
+ export interface PassInfo {
144
+ readonly name: string;
145
+ readonly reads: readonly string[];
146
+ readonly writes: readonly string[];
147
+ }
148
+
149
+ export interface ResourceInfo {
150
+ readonly key: string;
151
+ readonly kind: ResourceKind;
152
+ readonly lifetime: ResourceLifetime;
153
+ }
154
+
155
+ // ── Internalized graph ───────────────────────────────────────────
156
+
157
+ export interface InternalizedPass {
158
+ readonly name: string;
159
+ readonly reads: readonly string[];
160
+ readonly writes: readonly string[];
161
+ }
162
+
163
+ /**
164
+ * RHI buffer binding type a buffer resource resolves to after compile.
165
+ * Mirrors the `@webgpu/types` GPUBufferBindingType subset used by the
166
+ * storage-vs-uniform cap switch (research Finding 7; runtime template
167
+ * `pbr-pipeline.ts` `caps.storageBuffer ? 'read-only-storage' : 'uniform'`).
168
+ */
169
+ export type ResolvedBufferType = 'read-only-storage' | 'uniform';
170
+
171
+ /**
172
+ * Resolved buffer binding for a registered `kind:'buffer'` resource.
173
+ * Produced by compile() per AC-09 / D-6.1: the declarative graph picks the
174
+ * concrete binding type from `caps.storageBuffer`, so a consumer building a
175
+ * bind-group layout reads `resolvedBufferType` instead of duplicating the
176
+ * cap switch.
177
+ */
178
+ export interface ResolvedBuffer {
179
+ readonly key: string;
180
+ readonly resolvedBufferType: ResolvedBufferType;
181
+ }
182
+
183
+ export interface InternalizedGraph {
184
+ readonly passes: readonly InternalizedPass[];
185
+ /**
186
+ * Buffer resources resolved to a concrete RHI binding type (AC-09).
187
+ * Empty when the graph declares no `kind:'buffer'` resources.
188
+ */
189
+ readonly resolvedBuffers: readonly ResolvedBuffer[];
190
+ /**
191
+ * Resolved TextureViews keyed by resource name.
192
+ * Populated by the compile allocation phase for addColorTarget resources.
193
+ * Empty when no color targets were declared.
194
+ */
195
+ readonly resolvedTextures: ReadonlyMap<string, TextureView>;
196
+ }
197
+
198
+ // ── Compile options ──────────────────────────────────────────────
199
+
200
+ export interface CompileOptions {
201
+ // D-6: second independent literal union (NOT derived from RhiCaps['backendKind']);
202
+ // add-only '|null' member so passthrough callers forwarding
203
+ // device.caps.backendKind type-check. Deriving from RhiCaps to collapse the
204
+ // duplicate is a known OOS follow-on (architecture-principles #2 Derive).
205
+ readonly backendKind: 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null';
206
+ readonly caps: RhiCaps;
207
+ /**
208
+ * RHI device interface handle for real GPU texture allocation (D-1).
209
+ * Required when the graph has addColorTarget resources; the compile phase
210
+ * calls device.createTexture/createTextureView for each color target.
211
+ */
212
+ readonly device?: RhiDevice | undefined;
213
+ }
214
+
215
+ // ── RenderGraph ──────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Descriptor key for texture pool lookups (D-2 / D-8 / KB-2).
219
+ * Transient resources with identical descriptors share the same physical
220
+ * texture; drift in any field triggers rebuild.
221
+ * NOTE: stringified form is used as Map key; the interface is for doc only.
222
+ */
223
+ // interface _TexturePoolKey { format:string; width:number; height:number; usage:number; sample:number; viewFormats:string[]; }
224
+
225
+ /** Pooled texture entry shared by transient and persistent allocation paths. */
226
+ interface PooledTexture {
227
+ readonly texture: unknown; // opaque RHI Texture handle
228
+ readonly view: unknown; // opaque RHI TextureView handle
229
+ readonly descriptorKey: string;
230
+ }
231
+
232
+ interface StagedColorTargetAllocation {
233
+ readonly resolvedTextures: Map<string, TextureView>;
234
+ readonly transient: ReadonlyMap<string, PooledTexture>;
235
+ readonly persistent: ReadonlyMap<string, PooledTexture>;
236
+ }
237
+
238
+ /** A subset of RhiDevice surface needed by drain() and reclaim to release pooled textures. */
239
+ type DrainDevice = Pick<RhiDevice, 'destroyTexture' | 'queue'>;
240
+
241
+ function poolKey(meta: {
242
+ format: TextureFormat;
243
+ width: number;
244
+ height: number;
245
+ usage: number;
246
+ sample: number;
247
+ viewFormats: readonly TextureFormat[];
248
+ }): string {
249
+ return `${meta.format}:${meta.width}x${meta.height}:${meta.usage}:${meta.sample}:${JSON.stringify(meta.viewFormats)}`;
250
+ }
251
+
252
+ /**
253
+ * Runtime projection of the type-only WebGPU GPUTextureFormat union.
254
+ *
255
+ * `TextureFormat` is erased at runtime, but RenderGraph must reject malformed
256
+ * declarations before it calls an RHI device. Keep this ordered vocabulary as
257
+ * the single runtime validation authority; backend capability or usage refusal
258
+ * remains a `resource-alloc-failed` result after this syntax gate.
259
+ */
260
+ const VALID_GPU_TEXTURE_FORMATS = [
261
+ 'r8unorm',
262
+ 'r8snorm',
263
+ 'r8uint',
264
+ 'r8sint',
265
+ 'r16unorm',
266
+ 'r16snorm',
267
+ 'r16uint',
268
+ 'r16sint',
269
+ 'r16float',
270
+ 'rg8unorm',
271
+ 'rg8snorm',
272
+ 'rg8uint',
273
+ 'rg8sint',
274
+ 'r32uint',
275
+ 'r32sint',
276
+ 'r32float',
277
+ 'rg16unorm',
278
+ 'rg16snorm',
279
+ 'rg16uint',
280
+ 'rg16sint',
281
+ 'rg16float',
282
+ 'rgba8unorm',
283
+ 'rgba8unorm-srgb',
284
+ 'rgba8snorm',
285
+ 'rgba8uint',
286
+ 'rgba8sint',
287
+ 'bgra8unorm',
288
+ 'bgra8unorm-srgb',
289
+ 'rgb9e5ufloat',
290
+ 'rgb10a2uint',
291
+ 'rgb10a2unorm',
292
+ 'rg11b10ufloat',
293
+ 'rg32uint',
294
+ 'rg32sint',
295
+ 'rg32float',
296
+ 'rgba16unorm',
297
+ 'rgba16snorm',
298
+ 'rgba16uint',
299
+ 'rgba16sint',
300
+ 'rgba16float',
301
+ 'rgba32uint',
302
+ 'rgba32sint',
303
+ 'rgba32float',
304
+ 'stencil8',
305
+ 'depth16unorm',
306
+ 'depth24plus',
307
+ 'depth24plus-stencil8',
308
+ 'depth32float',
309
+ 'depth32float-stencil8',
310
+ 'bc1-rgba-unorm',
311
+ 'bc1-rgba-unorm-srgb',
312
+ 'bc2-rgba-unorm',
313
+ 'bc2-rgba-unorm-srgb',
314
+ 'bc3-rgba-unorm',
315
+ 'bc3-rgba-unorm-srgb',
316
+ 'bc4-r-unorm',
317
+ 'bc4-r-snorm',
318
+ 'bc5-rg-unorm',
319
+ 'bc5-rg-snorm',
320
+ 'bc6h-rgb-ufloat',
321
+ 'bc6h-rgb-float',
322
+ 'bc7-rgba-unorm',
323
+ 'bc7-rgba-unorm-srgb',
324
+ 'etc2-rgb8unorm',
325
+ 'etc2-rgb8unorm-srgb',
326
+ 'etc2-rgb8a1unorm',
327
+ 'etc2-rgb8a1unorm-srgb',
328
+ 'etc2-rgba8unorm',
329
+ 'etc2-rgba8unorm-srgb',
330
+ 'eac-r11unorm',
331
+ 'eac-r11snorm',
332
+ 'eac-rg11unorm',
333
+ 'eac-rg11snorm',
334
+ 'astc-4x4-unorm',
335
+ 'astc-4x4-unorm-srgb',
336
+ 'astc-5x4-unorm',
337
+ 'astc-5x4-unorm-srgb',
338
+ 'astc-5x5-unorm',
339
+ 'astc-5x5-unorm-srgb',
340
+ 'astc-6x5-unorm',
341
+ 'astc-6x5-unorm-srgb',
342
+ 'astc-6x6-unorm',
343
+ 'astc-6x6-unorm-srgb',
344
+ 'astc-8x5-unorm',
345
+ 'astc-8x5-unorm-srgb',
346
+ 'astc-8x6-unorm',
347
+ 'astc-8x6-unorm-srgb',
348
+ 'astc-8x8-unorm',
349
+ 'astc-8x8-unorm-srgb',
350
+ 'astc-10x5-unorm',
351
+ 'astc-10x5-unorm-srgb',
352
+ 'astc-10x6-unorm',
353
+ 'astc-10x6-unorm-srgb',
354
+ 'astc-10x8-unorm',
355
+ 'astc-10x8-unorm-srgb',
356
+ 'astc-10x10-unorm',
357
+ 'astc-10x10-unorm-srgb',
358
+ 'astc-12x10-unorm',
359
+ 'astc-12x10-unorm-srgb',
360
+ 'astc-12x12-unorm',
361
+ 'astc-12x12-unorm-srgb',
362
+ ] as const satisfies readonly TextureFormat[];
363
+
364
+ const VALID_GPU_TEXTURE_FORMAT_SET: ReadonlySet<string> = new Set(VALID_GPU_TEXTURE_FORMATS);
365
+
366
+ export class RenderGraph<Ctx = unknown> {
367
+ private readonly resources = new ResourceRegistry();
368
+ private readonly passes = new PassRegistry<Ctx>();
369
+ private compiled: InternalizedGraph | null = null;
370
+
371
+ /** Transient texture pool: keyed by descriptor, reused across compiles (D-2). */
372
+ private readonly transientPool = new Map<string, PooledTexture>();
373
+ /**
374
+ * Pending-destroy queue (bug-20260622): replaced textures awaiting GPU
375
+ * retirement before actual device.destroyTexture. drainTransient(),
376
+ * setTransientEntry(), and setPersistentEntry() push here instead of destroying immediately;
377
+ * reclaimRetiredTransients() (called post-queue.submit in recordFrame) drains
378
+ * the queue when the GPU signals onSubmittedWorkDone.
379
+ */
380
+ private readonly pendingDestroy: PooledTexture[] = [];
381
+ /** Persistent textures: keyed by resource name, kept across compiles. */
382
+ private readonly persistentTextures = new Map<string, PooledTexture>();
383
+ /** Swap-chain size for resolving 'swapchain' / 'half-swapchain' sizes. */
384
+ private swapChainWidth = 800;
385
+ private swapChainHeight = 600;
386
+ /** Last compile-time swap-chain size; diff triggers recompile-invalidation. */
387
+ private compiledWidth = 800;
388
+ private compiledHeight = 600;
389
+ /**
390
+ * feat-20260612 M-4 / w15: device reference stashed at compile-time so
391
+ * drain() can release pooled textures via device.destroyTexture without
392
+ * a separate parameter. Set by compile(); null until the first compile.
393
+ * Render-graph stays RHI-pure (no runtime dep): the destroy bookkeeping
394
+ * SSOT is the RHI shim, exactly as GpuTexture.destroy() routes through it.
395
+ */
396
+ private lastDevice: DrainDevice | null = null;
397
+
398
+ /**
399
+ * Set the current swap-chain dimensions (w7).
400
+ * The compile allocation phase uses this to resolve 'swapchain' and
401
+ * 'half-swapchain' size specifiers. Returns true when dimensions differ
402
+ * from the last compile, signalling that a recompile is needed.
403
+ */
404
+ setSwapChainSize(width: number, height: number): boolean {
405
+ this.swapChainWidth = width;
406
+ this.swapChainHeight = height;
407
+ if (width !== this.compiledWidth || height !== this.compiledHeight) {
408
+ return true;
409
+ }
410
+ return false;
411
+ }
412
+
413
+ /**
414
+ * w7: resolve a color-target name to its compiled TextureView.
415
+ * Returns the GPU view after compile, or undefined if not yet compiled
416
+ * or the name was not registered via addColorTarget.
417
+ */
418
+ getColorTargetView(name: string): unknown {
419
+ return this.compiled?.resolvedTextures.get(name);
420
+ }
421
+
422
+ /**
423
+ * w7: resolve a color-target name to its compiled GPU Texture handle.
424
+ * Returns the texture after compile, or undefined if not yet compiled.
425
+ */
426
+ getColorTargetTexture(name: string): unknown {
427
+ return this.compiled?.resolvedTextures.get(`${name}::tex`);
428
+ }
429
+
430
+ getColorTargetDescriptor(name: string): ResolvedColorTargetDescriptor | undefined {
431
+ const meta = this.resources.getColorTargetMeta(name);
432
+ const texture = this.compiled?.resolvedTextures.get(`${name}::tex`);
433
+ if (meta === undefined || texture === undefined) return undefined;
434
+ return {
435
+ texture: texture as unknown as Texture,
436
+ format: meta.format,
437
+ size: {
438
+ width: this.resolveWidth(meta.size),
439
+ height: this.resolveHeight(meta.size),
440
+ },
441
+ usage: meta.usage,
442
+ sample: meta.sample,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Declare a color target alias: both names share the same physical texture.
448
+ * The source must already be registered via addColorTarget.
449
+ * Used for hdrComposited -> hdrColor folding (KB-1 / D-2). The returned
450
+ * Result contains the opaque alias handle or a duplicate-resource error.
451
+ */
452
+ addColorTargetAlias(name: string, source: string): Result<ColorTargetHandle, RenderGraphError> {
453
+ const result = this.resources.addColorTargetAlias(name, source);
454
+ if (!result.ok) return result;
455
+ return ok(name);
456
+ }
457
+
458
+ addResource(
459
+ key: string,
460
+ descriptor: ResourceDescriptor,
461
+ ): Result<ResourceEntry, RenderGraphError> {
462
+ return this.resources.add(key, descriptor);
463
+ }
464
+
465
+ /**
466
+ * Declare a color target resource that the compiler will allocate as a
467
+ * transient or persistent GPU texture (D-1 / D-8). A successful Result
468
+ * contains an opaque string handle that can be referenced in pass read/write
469
+ * arrays and resolved to a TextureView via resolve(name) inside a pass
470
+ * execute closure. A duplicate key is rejected before registry publication.
471
+ *
472
+ * Omitted lifetime preserves the default `transient`; `persistent` retains
473
+ * identity across unchanged compiles and replaces on descriptor drift.
474
+ * format/size/sample/usage are stored on the resource entry for the compile
475
+ * allocation phase (w6).
476
+ */
477
+ addColorTarget(
478
+ name: string,
479
+ desc: ColorTargetDescriptor,
480
+ ): Result<ColorTargetHandle, RenderGraphError> {
481
+ const result = this.resources.addColorTarget(name, desc);
482
+ if (!result.ok) return result;
483
+ return ok(name);
484
+ }
485
+
486
+ addPass(name: string, descriptor: PassDescriptor<Ctx>): PassEntry<Ctx> {
487
+ return this.passes.add(name, descriptor);
488
+ }
489
+
490
+ /** @internal Renderer composition seam for declaring feature work at its semantic target. */
491
+ _addPassBefore(name: string, before: string, descriptor: PassDescriptor<Ctx>): PassEntry<Ctx> {
492
+ return this.passes.add(name, descriptor, before);
493
+ }
494
+
495
+ addComputePass(name: string, descriptor: ComputePassDescriptor<Ctx>): PassEntry<Ctx> {
496
+ return this.addComputePassAt(name, descriptor);
497
+ }
498
+
499
+ /** @internal Renderer composition seam for declaring feature work at its semantic target. */
500
+ _addComputePassBefore(
501
+ name: string,
502
+ before: string,
503
+ descriptor: ComputePassDescriptor<Ctx>,
504
+ ): PassEntry<Ctx> {
505
+ return this.addComputePassAt(name, descriptor, before);
506
+ }
507
+
508
+ private addComputePassAt(
509
+ name: string,
510
+ descriptor: ComputePassDescriptor<Ctx>,
511
+ before?: string,
512
+ ): PassEntry<Ctx> {
513
+ return this.passes.add(
514
+ name,
515
+ {
516
+ reads: descriptor.reads,
517
+ writes: descriptor.writes,
518
+ compute: true,
519
+ storageBuffer: descriptor.storageBuffer ?? true,
520
+ execute: (frame: Ctx, resources: ResolveContext) => {
521
+ const encoder = (
522
+ frame as Ctx & { readonly encoder: import('@forgeax/engine-rhi').RhiCommandEncoder }
523
+ ).encoder;
524
+ const begin = descriptor.begin?.(frame);
525
+ let pass: RhiComputePassEncoder;
526
+ try {
527
+ pass = encoder.beginComputePass({
528
+ label: name,
529
+ ...(begin?.timestampWrites === undefined
530
+ ? {}
531
+ : { timestampWrites: begin.timestampWrites }),
532
+ });
533
+ } catch (cause) {
534
+ descriptor.onBeginError?.(frame, cause);
535
+ return;
536
+ }
537
+ try {
538
+ descriptor.encode({ pass, frame, resources });
539
+ } finally {
540
+ pass.end();
541
+ }
542
+ descriptor.after?.(frame);
543
+ },
544
+ },
545
+ before,
546
+ );
547
+ }
548
+
549
+ /**
550
+ * Validate a producer-scoped current-frame texture without exposing graph
551
+ * resource names through the observation lease.
552
+ */
553
+ createCurrentFrameObservationLease(
554
+ descriptor: CurrentFrameObservationDescriptor,
555
+ currentFrameId: number,
556
+ ): Result<CurrentFrameObservationLease, RenderGraphError> {
557
+ return createCurrentFrameObservationLease(descriptor, currentFrameId);
558
+ }
559
+
560
+ /**
561
+ * Compile the graph into an internalized form.
562
+ *
563
+ * Phases (plan-strategy 3.1):
564
+ * 1. Cap-gate fail-fast
565
+ * 2. Unknown-resource fail-fast (every pass read/write key is registered)
566
+ * 3. Dangling-read fail-fast
567
+ * 4. Preserve declaration order as the temporal authority
568
+ * 5. Buffer-role resolution (AC-09 / D-6.1)
569
+ * 6. GPU allocation for color targets (D-1) — when device is provided and the
570
+ * graph has addColorTarget resources, allocate textures via
571
+ * device.createTexture/createTextureView. Errors surface as
572
+ * 'resource-alloc-failed' or 'invalid-format'.
573
+ */
574
+ compile(opts: CompileOptions): Result<InternalizedGraph, RenderGraphError> {
575
+ const passList = this.passes.list();
576
+ const { caps, device } = opts;
577
+
578
+ const capErr = this.validateCaps(passList, caps);
579
+ if (capErr) return capErr;
580
+
581
+ const colorDomainErr = this.validateColorDomains(passList);
582
+ if (colorDomainErr) return colorDomainErr;
583
+
584
+ const unknownErr = this.validateNoUnknownResource(passList);
585
+ if (unknownErr) return unknownErr;
586
+
587
+ const danglingErr = this.validateNoDanglingRead(passList);
588
+ if (danglingErr) return danglingErr;
589
+
590
+ const formatErr = this.validateColorTargetFormats();
591
+ if (!formatErr.ok) return formatErr;
592
+
593
+ const internalizedPasses: InternalizedPass[] = passList.map((pass) => {
594
+ return {
595
+ name: pass.name,
596
+ reads: pass.descriptor.reads,
597
+ writes: pass.descriptor.writes,
598
+ };
599
+ });
600
+
601
+ const resolvedBuffers = this.resolveBuffers(caps);
602
+
603
+ // Phase 7: GPU allocation for color targets (D-1).
604
+ const resizeDetected =
605
+ this.swapChainWidth !== this.compiledWidth || this.swapChainHeight !== this.compiledHeight;
606
+ const allocatedTextures = this.allocateColorTargets(device, resizeDetected);
607
+ if (!allocatedTextures.ok) return allocatedTextures;
608
+
609
+ // Phase 6.5: resize drain (AC-09, plan-strategy D-4). Defer the drain
610
+ // until allocation succeeds so a refused compile leaves the active graph
611
+ // and its pool untouched for a retry.
612
+ if (resizeDetected) {
613
+ this.drainTransient();
614
+ }
615
+
616
+ for (const [key, pooled] of allocatedTextures.value.transient) {
617
+ this.setTransientEntry(key, pooled);
618
+ }
619
+ for (const [key, pooled] of allocatedTextures.value.persistent) {
620
+ this.setPersistentEntry(key, pooled);
621
+ }
622
+
623
+ this.compiled = {
624
+ passes: internalizedPasses,
625
+ resolvedBuffers,
626
+ resolvedTextures: allocatedTextures.value.resolvedTextures,
627
+ };
628
+ this.compiledWidth = this.swapChainWidth;
629
+ this.compiledHeight = this.swapChainHeight;
630
+ if (device !== undefined) {
631
+ this.lastDevice = device;
632
+ }
633
+ return ok(this.compiled);
634
+ }
635
+
636
+ /**
637
+ * feat-20260612 M-4 / w15: release every pooled GPU texture and clear
638
+ * the pools.
639
+ *
640
+ * Walks `transientPool` + `persistentTextures`, forwarding each
641
+ * `PooledTexture.texture` opaque handle to `device.destroyTexture(...)`,
642
+ * then clears both Maps. The destroy bookkeeping SSOT is the RHI shim
643
+ * (architecture-principles §1 SSOT: same path GpuTexture.destroy()
644
+ * uses); render-graph stays RHI-pure (no runtime dep).
645
+ *
646
+ * Plan-strategy D-7: drain covers the dispose exit path (`Renderer.dispose()`);
647
+ * descriptor-drift replacement during compile is fenced through
648
+ * `pendingDestroy` and `reclaimRetiredTransients()`.
649
+ *
650
+ * Idempotent (architecture-principles §6): a second drain on cleared
651
+ * Maps is a no-op. drain() before any compile is also a safe no-op.
652
+ * Per-handle errors from the RHI shim (e.g. 'destroy-after-destroy'
653
+ * on a stale handle) are tolerated so the dispose chain can make
654
+ * progress (mirrors gpuStore.destroyAll's swallow-and-continue
655
+ * policy; plan-strategy D-3 / D-8). The structured error stays
656
+ * available on the device handle for future inspector hooks.
657
+ */
658
+ drain(): void {
659
+ const device = this.lastDevice;
660
+ if (device === null) {
661
+ this.transientPool.clear();
662
+ this.persistentTextures.clear();
663
+ this.pendingDestroy.length = 0;
664
+ return;
665
+ }
666
+ for (const pooled of this.transientPool.values()) {
667
+ try {
668
+ device.destroyTexture(pooled.texture as Texture);
669
+ } catch {
670
+ // swallow-and-continue: per-handle destroy failures do not
671
+ // interrupt the drain chain (docstring tolerance contract).
672
+ }
673
+ }
674
+ this.transientPool.clear();
675
+ // bug-20260622 D-6: drain teardown path — destroy any pendingDestroy
676
+ // items left over (Renderer.dispose() has no in-flight frames).
677
+ for (const pooled of this.pendingDestroy) {
678
+ try {
679
+ device.destroyTexture(pooled.texture as Texture);
680
+ } catch {
681
+ // swallow-and-continue: per-handle destroy failures do not
682
+ // interrupt the drain chain (docstring tolerance contract).
683
+ }
684
+ }
685
+ this.pendingDestroy.length = 0;
686
+ for (const pooled of this.persistentTextures.values()) {
687
+ try {
688
+ device.destroyTexture(pooled.texture as Texture);
689
+ } catch {
690
+ // swallow-and-continue: per-handle destroy failures do not
691
+ // interrupt the drain chain (docstring tolerance contract).
692
+ }
693
+ }
694
+ this.persistentTextures.clear();
695
+ }
696
+
697
+ /**
698
+ * Relinquish every texture owned by this graph after its last frame has
699
+ * been submitted. Unlike {@link drain}, this does not synchronously destroy
700
+ * GPU resources: they join `pendingDestroy` and are released by
701
+ * `reclaimRetiredTransients()` only after `onSubmittedWorkDone` resolves.
702
+ *
703
+ * A retired graph is no longer executable. Runtime replaces a memoized
704
+ * per-frame graph through this entry when topology changes (rather than
705
+ * dropping the graph and its pools), while `drain()` remains the teardown
706
+ * path where immediate destruction is safe.
707
+ */
708
+ retire(): void {
709
+ const device = this.lastDevice;
710
+ if (device === null) {
711
+ this.transientPool.clear();
712
+ this.persistentTextures.clear();
713
+ this.pendingDestroy.length = 0;
714
+ this.compiled = null;
715
+ return;
716
+ }
717
+ for (const pooled of this.transientPool.values()) {
718
+ this.pendingDestroy.push(pooled);
719
+ }
720
+ this.transientPool.clear();
721
+ for (const pooled of this.persistentTextures.values()) {
722
+ this.pendingDestroy.push(pooled);
723
+ }
724
+ this.persistentTextures.clear();
725
+ this.compiled = null;
726
+ }
727
+
728
+ /**
729
+ * Release every transient-pool texture while keeping persistentTextures
730
+ * intact (AC-09: resize drain, plan-strategy D-4).
731
+ *
732
+ * Walks `transientPool` values and forwards each `PooledTexture.texture`
733
+ * opaque handle to `device.destroyTexture(...)`, then clears the transient
734
+ * pool. Mirror of `drain()` but scoped to the transient pool only.
735
+ *
736
+ * Persistent textures survive `drainTransient` — they are only released by
737
+ * the full `drain()` on teardown. `drainTransient` is an internal helper
738
+ * called by `compile()` when swap-chain size changes; it is NOT a public API
739
+ * (callers should use `drain()` for teardown).
740
+ *
741
+ * Idempotent (architecture-principles §6): a second drainTransient on an
742
+ * already-cleared transient pool is a no-op.
743
+ */
744
+ private drainTransient(): void {
745
+ const device = this.lastDevice;
746
+ if (device === null) {
747
+ this.transientPool.clear();
748
+ return;
749
+ }
750
+ // bug-20260622 D-1: push old transient textures into pendingDestroy
751
+ // queue instead of destroying immediately. The GPU may still hold
752
+ // references from a prior in-flight command buffer.
753
+ for (const pooled of this.transientPool.values()) {
754
+ this.pendingDestroy.push(pooled);
755
+ }
756
+ this.transientPool.clear();
757
+ }
758
+
759
+ /**
760
+ * Guarded transient pool insert (AC-08, plan-strategy D-4).
761
+ *
762
+ * Before overwriting a key in the transient pool, destroys the old pooled
763
+ * texture via `device.destroyTexture(...)` to prevent stranded GPU textures.
764
+ * The guard is defensive: in current production code flow this code path is
765
+ * unreachable (set() only follows a get() miss inside allocateColorTargets),
766
+ * but the single-line guard costs almost nothing and closes the symmetry gap
767
+ * (every GPU resource allocation has a paired destroy).
768
+ *
769
+ * When `lastDevice` is null (no device ever stashed), the old entry is
770
+ * silently dropped without destroy (mirrors drainTransient's null-device
771
+ * fast path).
772
+ */
773
+ private setTransientEntry(key: string, pooled: PooledTexture): void {
774
+ const old = this.transientPool.get(key);
775
+ if (old) {
776
+ // bug-20260622 D-1: push into pendingDestroy queue instead of
777
+ // destroying immediately — the old texture may still be referenced
778
+ // by an in-flight command buffer.
779
+ this.pendingDestroy.push(old);
780
+ }
781
+ this.transientPool.set(key, pooled);
782
+ }
783
+
784
+ /**
785
+ * Publish a persistent replacement only after a complete allocation succeeds.
786
+ * The old handle remains fenced until the GPU retires work that may still
787
+ * reference it, just like a transient replacement.
788
+ */
789
+ private setPersistentEntry(key: string, pooled: PooledTexture): void {
790
+ const old = this.persistentTextures.get(key);
791
+ if (old && old.texture !== pooled.texture) {
792
+ this.pendingDestroy.push(old);
793
+ }
794
+ this.persistentTextures.set(key, pooled);
795
+ }
796
+
797
+ /**
798
+ * bug-20260622 D-2: reclaim pool textures queued in pendingDestroy after
799
+ * the GPU has retired all prior command buffers.
800
+ *
801
+ * Takes a snapshot of pendingDestroy, then calls
802
+ * `lastDevice.queue.onSubmittedWorkDone()`. When the promise resolves,
803
+ * the snapshot items are actually destroyed via
804
+ * `device.destroyTexture(...)` and removed from the queue.
805
+ *
806
+ * Idempotent (architecture-principles D-4): a second reclaim on an
807
+ * already-drained pendingDestroy is a no-op. When lastDevice is null
808
+ * (no device ever stashed), pendingDestroy is cleared directly.
809
+ *
810
+ * Per-handle destroy errors are tolerated (swallow-and-continue,
811
+ * plan-strategy D-5) — a stale-handle destroy-after-destroy does not
812
+ * interrupt the reclaim chain.
813
+ */
814
+ async reclaimRetiredTransients(): Promise<void> {
815
+ const device = this.lastDevice;
816
+ if (device === null) {
817
+ this.pendingDestroy.length = 0;
818
+ return;
819
+ }
820
+ if (this.pendingDestroy.length === 0) return;
821
+
822
+ // Snapshot the queue; items added after this point are handled by the
823
+ // next reclaim call (D-4: no race with concurrent push from same-frame
824
+ // drainTransient).
825
+ const snapshot = this.pendingDestroy.splice(0);
826
+
827
+ // Wait for all prior GPU work to complete.
828
+ await device.queue.onSubmittedWorkDone();
829
+
830
+ // Destroy snapshot items.
831
+ for (const pooled of snapshot) {
832
+ try {
833
+ device.destroyTexture(pooled.texture as Texture);
834
+ } catch {
835
+ // swallow-and-continue: per-handle destroy errors are tolerated
836
+ // (docstring contract) — a stale handle does not interrupt the
837
+ // reclaim chain for subsequent items.
838
+ }
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Drop the pendingDestroy queue WITHOUT calling device.destroyTexture
844
+ * (feat-20260622-s5 M3 / B-2 / B-AC-02).
845
+ *
846
+ * Used on the device-lost recover() rebuild path: the queue holds
847
+ * PooledTexture handles minted against the now-lost device, so calling
848
+ * destroyTexture on them against the freshly-rebuilt device is meaningless
849
+ * (the old GPUDevice owns them; spec retires its resources implicitly when
850
+ * it is lost). recover() calls this after `gpuStore.destroyAll()` and before
851
+ * `tryCreateWebGPURenderer` so no stale handle reaches the new device.
852
+ *
853
+ * device-lost is an upstream judgement (createRenderer's health state); the
854
+ * graph stays RHI-pure and takes no device parameter — it only exposes the
855
+ * clear entry. Same effect as the existing null-device fast paths in drain()
856
+ * / reclaimRetiredTransients() (`pendingDestroy.length = 0`), surfaced as a
857
+ * method recover() can call directly. Idempotent: a second call on an
858
+ * already-empty queue is a no-op.
859
+ */
860
+ clearPendingDestroy(): void {
861
+ this.pendingDestroy.length = 0;
862
+ }
863
+
864
+ /**
865
+ * Execute the compiled graph in declaration order, calling
866
+ * each pass's execute closure with the provided context. Passes without an
867
+ * execute closure are silently skipped.
868
+ */
869
+ execute(ctx: Ctx, runPass?: PassExecuteRunner): void {
870
+ const compiled = this.compiled;
871
+ if (!compiled) return;
872
+ const resolvedTextures: ReadonlyMap<string, unknown> = compiled.resolvedTextures;
873
+ const resolveCtx: ResolveContext = {
874
+ resolve: (name: string) => resolvedTextures.get(name),
875
+ };
876
+ const passList = this.passes.list();
877
+ const passByName = new Map<string, PassEntry<Ctx>>(passList.map((p) => [p.name, p]));
878
+ for (const internalPass of compiled.passes) {
879
+ const entry = passByName.get(internalPass.name);
880
+ const execute = entry?.descriptor.execute;
881
+ if (execute) {
882
+ if (runPass === undefined) {
883
+ (execute as (ctx: Ctx, resolve: ResolveContext) => void)(ctx, resolveCtx);
884
+ } else {
885
+ runPass(internalPass.name, () =>
886
+ (execute as (ctx: Ctx, resolve: ResolveContext) => void)(ctx, resolveCtx),
887
+ );
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ listPasses(): readonly PassInfo[] {
894
+ return this.passes.list().map((p) => ({
895
+ name: p.name,
896
+ reads: p.descriptor.reads,
897
+ writes: p.descriptor.writes,
898
+ }));
899
+ }
900
+
901
+ listResources(): readonly ResourceInfo[] {
902
+ const result: ResourceInfo[] = [];
903
+ for (const entry of this.resources.entries()) {
904
+ result.push({
905
+ key: entry.key,
906
+ kind: entry.descriptor.kind,
907
+ lifetime: entry.descriptor.lifetime,
908
+ });
909
+ }
910
+ return result;
911
+ }
912
+
913
+ // ── Private helpers ────────────────────────────────────────────
914
+
915
+ private validateCaps(
916
+ passList: readonly PassEntry<Ctx>[],
917
+ caps: RhiCaps,
918
+ ): Result<never, RenderGraphError> | null {
919
+ for (const pass of passList) {
920
+ const { name, descriptor } = pass;
921
+
922
+ if (descriptor.compute && !caps.compute) {
923
+ return err(
924
+ new RenderGraphError({
925
+ code: 'cap-missing',
926
+ expected: `pass '${name}' is a compute pass but caps.compute is false`,
927
+ hint: 'use a render pass path or enable compute on the backend',
928
+ detail: { cap: 'compute', passName: name } satisfies CapMissingDetail,
929
+ }),
930
+ );
931
+ }
932
+
933
+ if (descriptor.storageBuffer && !caps.storageBuffer) {
934
+ return err(
935
+ new RenderGraphError({
936
+ code: 'cap-missing',
937
+ expected: `pass '${name}' requires storage buffer but caps.storageBuffer is false`,
938
+ hint: 'switch to uniform buffer or enable storageBuffer on the backend',
939
+ detail: {
940
+ cap: 'storageBuffer',
941
+ passName: name,
942
+ } satisfies CapMissingDetail,
943
+ }),
944
+ );
945
+ }
946
+ }
947
+ return null;
948
+ }
949
+
950
+ private validateColorDomains(
951
+ passList: readonly PassEntry<Ctx>[],
952
+ ): Result<never, RenderGraphError> | null {
953
+ for (const pass of passList) {
954
+ for (const connection of pass.descriptor.colorConnections ?? []) {
955
+ const source = this.resources.get(connection.source)?.colorTarget?.domain;
956
+ const destination = this.resources.get(connection.destination)?.colorTarget?.domain;
957
+ const validation = validateColorDomainConnection(
958
+ source,
959
+ destination,
960
+ connection.conversion,
961
+ );
962
+ if (!validation.ok) return err(validation.error);
963
+ }
964
+ }
965
+ return null;
966
+ }
967
+
968
+ private validateNoUnknownResource(
969
+ passList: readonly PassEntry<Ctx>[],
970
+ ): Result<never, RenderGraphError> | null {
971
+ for (const pass of passList) {
972
+ for (const key of [...pass.descriptor.reads, ...pass.descriptor.writes]) {
973
+ // Built-in reserved key 'swapchain' (feat-20260609 framebuffers demo
974
+ // M5 / T-12-a): the swap-chain output is not a graph-allocated
975
+ // resource; passes that write to 'swapchain' surface their writeView
976
+ // through the resolveCtx fallback (`resolveCtx.resolve('swapchain')`
977
+ // returns undefined -> the dispatcher falls back to `ctx.view`, the
978
+ // current swap-chain view). Allowed in `writes` (a fullscreen pass
979
+ // outputs to the swap-chain) and in `reads` (a future pass that
980
+ // samples the swap-chain via copyTextureToTexture). The graph never
981
+ // allocates, owns, or aliases this resource — it is purely an
982
+ // ordering/contract token.
983
+ if (key === 'swapchain') continue;
984
+ if (!this.resources.has(key)) {
985
+ return err(
986
+ new RenderGraphError({
987
+ code: 'unknown-resource',
988
+ expected: `pass '${pass.name}' references resource key '${key}' but it is not registered`,
989
+ hint: `call addResource('${key}', ...) before compile, or remove '${key}' from pass '${pass.name}'`,
990
+ detail: {
991
+ resourceKey: key,
992
+ passName: pass.name,
993
+ } satisfies DanglingReadDetail,
994
+ }),
995
+ );
996
+ }
997
+ }
998
+ }
999
+ return null;
1000
+ }
1001
+
1002
+ /**
1003
+ * Resolve every registered `kind:'buffer'` resource to a concrete RHI
1004
+ * binding type (AC-09 / D-6.1). `bufferRole='auto-storage-or-uniform'`
1005
+ * (the default when unset) picks `'read-only-storage'` when the backend
1006
+ * advertises `caps.storageBuffer`, else falls back to `'uniform'`;
1007
+ * `bufferRole='uniform'` is always `'uniform'`. Mirrors the runtime
1008
+ * `pbr-pipeline.ts` cap switch (research Finding 7), expressed here in the
1009
+ * RHI-pure graph layer so consumers never duplicate the branch.
1010
+ */
1011
+ private resolveBuffers(caps: RhiCaps): ResolvedBuffer[] {
1012
+ const resolved: ResolvedBuffer[] = [];
1013
+ for (const entry of this.resources.entries()) {
1014
+ if (entry.descriptor.kind !== 'buffer') continue;
1015
+ const role = entry.descriptor.bufferRole ?? 'auto-storage-or-uniform';
1016
+ const resolvedBufferType: ResolvedBufferType =
1017
+ role === 'uniform' ? 'uniform' : caps.storageBuffer ? 'read-only-storage' : 'uniform';
1018
+ resolved.push({ key: entry.key, resolvedBufferType });
1019
+ }
1020
+ return resolved;
1021
+ }
1022
+
1023
+ private validateColorTargetFormats(): Result<undefined, RenderGraphError> {
1024
+ for (const entry of this.resources.entries()) {
1025
+ const meta = entry.colorTarget;
1026
+ if (!meta || VALID_GPU_TEXTURE_FORMAT_SET.has(meta.format)) continue;
1027
+
1028
+ return err(
1029
+ new RenderGraphError({
1030
+ code: 'invalid-format',
1031
+ expected: `addColorTarget format must be a valid GPU texture format; received '${meta.format}'`,
1032
+ hint: `replace '${meta.format}' with one of detail.expected before recompiling`,
1033
+ detail: {
1034
+ resourceKey: entry.key,
1035
+ format: meta.format,
1036
+ expected: VALID_GPU_TEXTURE_FORMATS,
1037
+ },
1038
+ }),
1039
+ );
1040
+ }
1041
+ return ok(undefined);
1042
+ }
1043
+
1044
+ /**
1045
+ * Phase 7: allocate GPU textures for registered color targets (D-1 / D-2).
1046
+ *
1047
+ * For each addColorTarget resource, resolves the concrete size from the
1048
+ * ColorTargetSize descriptor and swapChainSize, then looks up the transient
1049
+ * pool by descriptor key. Pool hit reuses the same physical texture/view;
1050
+ * pool miss (drift) triggers device.createTexture/createTextureView rebuild.
1051
+ *
1052
+ * Alias targets (addColorTargetAlias) fold into the source's physical texture
1053
+ * (KB-1 MoveNode pattern). Persistent targets are retained across compiles
1054
+ * with size-drift rebuild.
1055
+ *
1056
+ * device === undefined is a no-op (returns an empty map).
1057
+ * Allocation is transactional: newly created textures are destroyed on any
1058
+ * failure, and pool mutations are committed only after every target succeeds.
1059
+ */
1060
+ private allocateColorTargets(
1061
+ device: RhiDevice | undefined,
1062
+ invalidateTransientPool = false,
1063
+ ): Result<StagedColorTargetAllocation, RenderGraphError> {
1064
+ const result = new Map<string, TextureView>();
1065
+ if (
1066
+ !device ||
1067
+ typeof (device as unknown as Record<string, unknown>).createTexture !== 'function'
1068
+ )
1069
+ return ok({ resolvedTextures: result, transient: new Map(), persistent: new Map() });
1070
+
1071
+ const stagedTransient = new Map<string, PooledTexture>();
1072
+ const stagedPersistent = new Map<string, PooledTexture>();
1073
+ const stagedAllocations: PooledTexture[] = [];
1074
+ const discardStaged = (): void => {
1075
+ for (const pooled of stagedAllocations) {
1076
+ try {
1077
+ device.destroyTexture(pooled.texture as Texture);
1078
+ } catch {
1079
+ // A cleanup failure must not hide the allocation error.
1080
+ }
1081
+ }
1082
+ };
1083
+
1084
+ for (const entry of this.resources.entries()) {
1085
+ const meta = entry.colorTarget;
1086
+ if (!meta) continue;
1087
+
1088
+ // Resolve alias: fold to source physical texture.
1089
+ if (meta.aliasedFrom !== undefined) {
1090
+ const sourceView = result.get(meta.aliasedFrom);
1091
+ const sourceTexture = result.get(`${meta.aliasedFrom}::tex`);
1092
+ if (sourceView === undefined || sourceTexture === undefined) {
1093
+ discardStaged();
1094
+ return err(
1095
+ new RenderGraphError({
1096
+ code: 'alias-source-missing',
1097
+ expected: `alias '${entry.key}' source '${meta.aliasedFrom}' must resolve to a compiled color target`,
1098
+ hint: `register color target '${meta.aliasedFrom}' before compiling alias '${entry.key}'`,
1099
+ detail: {
1100
+ aliasKey: entry.key,
1101
+ sourceKey: meta.aliasedFrom,
1102
+ } satisfies AliasSourceDetail,
1103
+ }),
1104
+ );
1105
+ }
1106
+ result.set(entry.key, sourceView);
1107
+ result.set(`${entry.key}::tex`, sourceTexture);
1108
+ continue;
1109
+ }
1110
+
1111
+ const width = this.resolveWidth(meta.size);
1112
+ const height = this.resolveHeight(meta.size);
1113
+ const lifetime = entry.lifetime;
1114
+
1115
+ // feat-20260612-hdrp-ssao M9 scope-amendment (M8 graph barrier):
1116
+ // Include the resource name in the transient pool key. Without this,
1117
+ // two simultaneously-active transient color targets with identical
1118
+ // descriptors (e.g. ssaoRaw / ssaoBlurred — both r8unorm half-swapchain
1119
+ // RENDER_ATTACHMENT|TEXTURE_BINDING) share the same GPU texture. When
1120
+ // one pass writes and the next pass both writes (color attachment) and
1121
+ // reads (texture binding from the same view), WebGPU rejects the command
1122
+ // buffer: "TextureBinding|RenderAttachment in the same synchronization
1123
+ // scope."
1124
+ const descriptorKey = poolKey({
1125
+ format: meta.format,
1126
+ width,
1127
+ height,
1128
+ usage: meta.usage,
1129
+ sample: meta.sample,
1130
+ viewFormats: meta.viewFormats ?? [],
1131
+ });
1132
+ const key = `${entry.key}:${descriptorKey}`;
1133
+
1134
+ if (lifetime === 'transient') {
1135
+ const pooled = invalidateTransientPool ? undefined : this.transientPool.get(key);
1136
+ if (pooled) {
1137
+ result.set(entry.key, pooled.view as TextureView);
1138
+ // w7-fix (round 3): re-publish the GPU Texture handle on every
1139
+ // compile, not only on pool-miss. Without this the second compile
1140
+ // (the recompile-on-resize path) leaves `${entry.key}::tex` empty,
1141
+ // breaking consumers that read `getColorTargetTexture` (shadow
1142
+ // debugReadback, fxaa copyTextureToTexture, MSAA srgb-view creation).
1143
+ // biome-ignore lint/suspicious/noExplicitAny: opaque RHI texture handle
1144
+ result.set(`${entry.key}::tex`, pooled.texture as any);
1145
+ continue;
1146
+ }
1147
+ } else if (lifetime === 'persistent') {
1148
+ const persisted = this.persistentTextures.get(entry.key);
1149
+ if (persisted?.descriptorKey === descriptorKey) {
1150
+ result.set(entry.key, persisted.view as TextureView);
1151
+ // biome-ignore lint/suspicious/noExplicitAny: opaque RHI texture handle
1152
+ result.set(`${entry.key}::tex`, persisted.texture as any);
1153
+ continue;
1154
+ }
1155
+ }
1156
+
1157
+ // Pool miss or persistent fresh allocation: create new texture.
1158
+ const texResult = device.createTexture({
1159
+ label: entry.key,
1160
+ size: { width, height, depthOrArrayLayers: 1 },
1161
+ mipLevelCount: 1,
1162
+ sampleCount: meta.sample,
1163
+ dimension: '2d',
1164
+ format: meta.format,
1165
+ usage: meta.usage,
1166
+ viewFormats: meta.viewFormats ?? [],
1167
+ } as never);
1168
+
1169
+ if (!texResult.ok) {
1170
+ discardStaged();
1171
+ return err(
1172
+ new RenderGraphError({
1173
+ code: 'resource-alloc-failed',
1174
+ expected: `device.createTexture must succeed for color target '${entry.key}'`,
1175
+ hint: `retry after recovering the RHI allocation failure for '${entry.key}'`,
1176
+ detail: {
1177
+ resourceKey: entry.key,
1178
+ rhiCode: texResult.error.code,
1179
+ },
1180
+ }),
1181
+ );
1182
+ }
1183
+
1184
+ const viewResult = device.createTextureView(texResult.value, {});
1185
+ if (!viewResult.ok) {
1186
+ try {
1187
+ device.destroyTexture(texResult.value);
1188
+ } catch {
1189
+ // A cleanup failure must not hide the allocation error.
1190
+ }
1191
+ discardStaged();
1192
+ return err(
1193
+ new RenderGraphError({
1194
+ code: 'resource-alloc-failed',
1195
+ expected: `device.createTextureView must succeed for color target '${entry.key}'`,
1196
+ hint: `retry after recovering the RHI view allocation failure for '${entry.key}'`,
1197
+ detail: {
1198
+ resourceKey: entry.key,
1199
+ rhiCode: viewResult.error.code,
1200
+ },
1201
+ }),
1202
+ );
1203
+ }
1204
+
1205
+ const pooled: PooledTexture = {
1206
+ texture: texResult.value,
1207
+ view: viewResult.value,
1208
+ descriptorKey,
1209
+ };
1210
+ stagedAllocations.push(pooled);
1211
+
1212
+ if (lifetime === 'transient') {
1213
+ stagedTransient.set(key, pooled);
1214
+ } else {
1215
+ stagedPersistent.set(entry.key, pooled);
1216
+ }
1217
+
1218
+ result.set(entry.key, viewResult.value);
1219
+ // biome-ignore lint/suspicious/noExplicitAny: store Texture alongside TextureView
1220
+ result.set(`${entry.key}::tex`, texResult.value as any);
1221
+ }
1222
+
1223
+ return ok({
1224
+ resolvedTextures: result,
1225
+ transient: stagedTransient,
1226
+ persistent: stagedPersistent,
1227
+ });
1228
+ }
1229
+
1230
+ private resolveWidth(size: ColorTargetDescriptor['size']): number {
1231
+ if (typeof size === 'string') {
1232
+ return size === 'half-swapchain' ? Math.ceil(this.swapChainWidth / 2) : this.swapChainWidth;
1233
+ }
1234
+ return size.w;
1235
+ }
1236
+
1237
+ private resolveHeight(size: ColorTargetDescriptor['size']): number {
1238
+ if (typeof size === 'string') {
1239
+ return size === 'half-swapchain' ? Math.ceil(this.swapChainHeight / 2) : this.swapChainHeight;
1240
+ }
1241
+ return size.h;
1242
+ }
1243
+
1244
+ private validateNoDanglingRead(
1245
+ passList: readonly PassEntry<Ctx>[],
1246
+ ): Result<never, RenderGraphError> | null {
1247
+ const writers = new Set<string>();
1248
+ for (const pass of passList) {
1249
+ for (const key of pass.descriptor.reads) {
1250
+ const imported = this.resources.get(key)?.descriptor.lifetime === 'persistent';
1251
+ if (key !== 'swapchain' && !imported && !writers.has(key)) {
1252
+ return err(
1253
+ new RenderGraphError({
1254
+ code: 'dangling-read',
1255
+ expected: `pass '${pass.name}' reads key '${key}' but no pass writes it`,
1256
+ hint: `add a pass that writes '${key}', or remove '${key}' from pass '${pass.name}' reads`,
1257
+ detail: {
1258
+ resourceKey: key,
1259
+ passName: pass.name,
1260
+ } satisfies DanglingReadDetail,
1261
+ }),
1262
+ );
1263
+ }
1264
+ }
1265
+ for (const key of pass.descriptor.writes) writers.add(key);
1266
+ }
1267
+ return null;
1268
+ }
1269
+ }