@forgeax/engine-render-graph 0.1.2

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