@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/builder.ts ADDED
@@ -0,0 +1,1009 @@
1
+ import type { Buffer, RhiCaps, Texture, TextureView } from '@forgeax/engine-rhi';
2
+ import { CompiledRenderGraphImpl } from './compiled-graph.js';
3
+ import { err, ok, RenderGraphError, type Result } from './errors.js';
4
+ import {
5
+ accessResourceId,
6
+ bufferHandle,
7
+ type CompiledPass,
8
+ type CompiledResource,
9
+ type CompiledView,
10
+ handleData,
11
+ type PassRecord,
12
+ type ResourceRecord,
13
+ type TextureViewRecord,
14
+ textureHandle,
15
+ textureViewHandle,
16
+ } from './kernel-internal.js';
17
+ import type {
18
+ CompiledRenderGraph,
19
+ CompiledRenderGraphInfo,
20
+ ComputeGraphPass,
21
+ CopyGraphPass,
22
+ GraphAccess,
23
+ GraphBuffer,
24
+ GraphBufferAccess,
25
+ GraphBufferDescriptor,
26
+ GraphExtent,
27
+ GraphPass,
28
+ GraphTexture,
29
+ GraphTextureAccess,
30
+ GraphTextureDescriptor,
31
+ GraphTextureView,
32
+ GraphTextureViewDescriptor,
33
+ ImportedBufferDescriptor,
34
+ ImportedTextureDescriptor,
35
+ ImportedTextureViewResolver,
36
+ RasterGraphPass,
37
+ RenderGraphCompileOptions,
38
+ RenderGraphFrame,
39
+ } from './types.js';
40
+
41
+ const BUFFER_USAGE = {
42
+ copySrc: 0x0004,
43
+ copyDst: 0x0008,
44
+ index: 0x0010,
45
+ vertex: 0x0020,
46
+ uniform: 0x0040,
47
+ storage: 0x0080,
48
+ indirect: 0x0100,
49
+ } as const;
50
+
51
+ const TEXTURE_USAGE = {
52
+ copySrc: 0x01,
53
+ copyDst: 0x02,
54
+ textureBinding: 0x04,
55
+ storageBinding: 0x08,
56
+ renderAttachment: 0x10,
57
+ } as const;
58
+
59
+ let nextGeneration = 1;
60
+
61
+ interface NormalizedRange {
62
+ readonly mipStart: number;
63
+ readonly mipEnd: number;
64
+ readonly layerStart: number;
65
+ readonly layerEnd: number;
66
+ readonly aspect: GPUTextureAspect;
67
+ }
68
+
69
+ interface NormalizedAccess {
70
+ readonly passIndex: number;
71
+ readonly passName: string;
72
+ readonly resourceId: number;
73
+ readonly viewId?: number | undefined;
74
+ readonly usage: GraphBufferAccess | GraphTextureAccess;
75
+ readonly read: boolean;
76
+ readonly write: boolean;
77
+ readonly range?: NormalizedRange | undefined;
78
+ }
79
+
80
+ function bufferUsage(access: GraphBufferAccess): number {
81
+ switch (access) {
82
+ case 'uniform-read':
83
+ return BUFFER_USAGE.uniform;
84
+ case 'storage-read':
85
+ case 'storage-write':
86
+ case 'storage-read-write':
87
+ return BUFFER_USAGE.storage;
88
+ case 'indirect-read':
89
+ return BUFFER_USAGE.indirect;
90
+ case 'vertex-read':
91
+ return BUFFER_USAGE.vertex;
92
+ case 'index-read':
93
+ return BUFFER_USAGE.index;
94
+ case 'copy-src':
95
+ return BUFFER_USAGE.copySrc;
96
+ case 'copy-dst':
97
+ return BUFFER_USAGE.copyDst;
98
+ }
99
+ }
100
+
101
+ function textureUsage(access: GraphTextureAccess): number {
102
+ switch (access) {
103
+ case 'sampled-read':
104
+ return TEXTURE_USAGE.textureBinding;
105
+ case 'storage-read':
106
+ case 'storage-write':
107
+ case 'storage-read-write':
108
+ return TEXTURE_USAGE.storageBinding;
109
+ case 'color-attachment':
110
+ case 'depth-stencil-read':
111
+ case 'depth-stencil-write':
112
+ return TEXTURE_USAGE.renderAttachment;
113
+ case 'copy-src':
114
+ return TEXTURE_USAGE.copySrc;
115
+ case 'copy-dst':
116
+ return TEXTURE_USAGE.copyDst;
117
+ }
118
+ }
119
+
120
+ function accessMode(access: GraphBufferAccess | GraphTextureAccess): {
121
+ readonly read: boolean;
122
+ readonly write: boolean;
123
+ } {
124
+ switch (access) {
125
+ case 'storage-read-write':
126
+ return { read: true, write: true };
127
+ case 'storage-write':
128
+ case 'color-attachment':
129
+ case 'depth-stencil-write':
130
+ case 'copy-dst':
131
+ return { read: false, write: true };
132
+ default:
133
+ return { read: true, write: false };
134
+ }
135
+ }
136
+
137
+ function rangesOverlap(
138
+ left: NormalizedRange | undefined,
139
+ right: NormalizedRange | undefined,
140
+ ): boolean {
141
+ if (left === undefined || right === undefined) return true;
142
+ const aspectOverlap =
143
+ left.aspect === 'all' || right.aspect === 'all' || left.aspect === right.aspect;
144
+ return (
145
+ aspectOverlap &&
146
+ left.mipStart < right.mipEnd &&
147
+ right.mipStart < left.mipEnd &&
148
+ left.layerStart < right.layerEnd &&
149
+ right.layerStart < left.layerEnd
150
+ );
151
+ }
152
+
153
+ function freezeInfo(info: CompiledRenderGraphInfo): CompiledRenderGraphInfo {
154
+ return Object.freeze({
155
+ passes: Object.freeze(
156
+ info.passes.map((pass) =>
157
+ Object.freeze({
158
+ ...pass,
159
+ accesses: Object.freeze(pass.accesses.map((access) => Object.freeze({ ...access }))),
160
+ dependencies: Object.freeze([...pass.dependencies]),
161
+ }),
162
+ ),
163
+ ),
164
+ resources: Object.freeze(info.resources.map((resource) => Object.freeze({ ...resource }))),
165
+ });
166
+ }
167
+
168
+ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
169
+ private readonly owner = Object.freeze({});
170
+ private readonly labels = new Set<string>();
171
+ private readonly resources = new Map<number, ResourceRecord<FrameCtx>>();
172
+ private readonly views = new Map<number, TextureViewRecord<FrameCtx>>();
173
+ private readonly passes: PassRecord<FrameCtx>[] = [];
174
+ private readonly passNames = new Set<string>();
175
+ private nextId = 1;
176
+ private sealed = false;
177
+
178
+ createTexture(
179
+ label: string,
180
+ descriptor: GraphTextureDescriptor,
181
+ ): Result<GraphTexture, RenderGraphError> {
182
+ const writable = this.ensureWritable();
183
+ if (!writable.ok) return writable;
184
+ const unique = this.reserveLabel(label);
185
+ if (!unique.ok) return unique;
186
+ const id = this.nextId++;
187
+ this.resources.set(id, { id, label, kind: 'texture', origin: 'created', descriptor });
188
+ return ok(textureHandle(this.owner, id));
189
+ }
190
+
191
+ importTexture(
192
+ label: string,
193
+ descriptor: ImportedTextureDescriptor,
194
+ resolve: (frame: FrameCtx) => Texture,
195
+ ): Result<GraphTexture, RenderGraphError> {
196
+ const writable = this.ensureWritable();
197
+ if (!writable.ok) return writable;
198
+ const unique = this.reserveLabel(label);
199
+ if (!unique.ok) return unique;
200
+ const id = this.nextId++;
201
+ this.resources.set(id, {
202
+ id,
203
+ label,
204
+ kind: 'texture',
205
+ origin: 'imported',
206
+ descriptor,
207
+ resolve,
208
+ });
209
+ return ok(textureHandle(this.owner, id));
210
+ }
211
+
212
+ createBuffer(
213
+ label: string,
214
+ descriptor: GraphBufferDescriptor,
215
+ ): Result<GraphBuffer, RenderGraphError> {
216
+ const writable = this.ensureWritable();
217
+ if (!writable.ok) return writable;
218
+ const unique = this.reserveLabel(label);
219
+ if (!unique.ok) return unique;
220
+ const id = this.nextId++;
221
+ this.resources.set(id, { id, label, kind: 'buffer', origin: 'created', descriptor });
222
+ return ok(bufferHandle(this.owner, id));
223
+ }
224
+
225
+ importBuffer(
226
+ label: string,
227
+ descriptor: ImportedBufferDescriptor,
228
+ resolve: (frame: FrameCtx) => Buffer,
229
+ ): Result<GraphBuffer, RenderGraphError> {
230
+ const writable = this.ensureWritable();
231
+ if (!writable.ok) return writable;
232
+ const unique = this.reserveLabel(label);
233
+ if (!unique.ok) return unique;
234
+ const id = this.nextId++;
235
+ this.resources.set(id, {
236
+ id,
237
+ label,
238
+ kind: 'buffer',
239
+ origin: 'imported',
240
+ descriptor,
241
+ resolve,
242
+ });
243
+ return ok(bufferHandle(this.owner, id));
244
+ }
245
+
246
+ view(
247
+ texture: GraphTexture,
248
+ descriptor: GraphTextureViewDescriptor = {},
249
+ ): Result<GraphTextureView, RenderGraphError> {
250
+ const writable = this.ensureWritable();
251
+ if (!writable.ok) return writable;
252
+ const data = handleData(texture);
253
+ if (data?.kind !== 'texture' || data.owner !== this.owner) {
254
+ return err(this.foreignHandleError());
255
+ }
256
+ const textureRecord = this.resources.get(data.id);
257
+ if (textureRecord?.kind !== 'texture') return err(this.foreignHandleError());
258
+ const label = descriptor.label ?? `${textureRecord.label}.view.${this.nextId}`;
259
+ const unique = this.reserveLabel(label);
260
+ if (!unique.ok) return unique;
261
+ const id = this.nextId++;
262
+ this.views.set(id, { id, label, textureId: data.id, descriptor });
263
+ return ok(textureViewHandle(this.owner, id, data.id));
264
+ }
265
+
266
+ importView(
267
+ texture: GraphTexture,
268
+ descriptor: GraphTextureViewDescriptor,
269
+ resolve: ImportedTextureViewResolver<FrameCtx>,
270
+ ): Result<GraphTextureView, RenderGraphError> {
271
+ const writable = this.ensureWritable();
272
+ if (!writable.ok) return writable;
273
+ const data = handleData(texture);
274
+ if (data?.kind !== 'texture' || data.owner !== this.owner) {
275
+ return err(this.foreignHandleError());
276
+ }
277
+ const textureRecord = this.resources.get(data.id);
278
+ if (textureRecord?.kind !== 'texture' || textureRecord.origin !== 'imported') {
279
+ return err(
280
+ new RenderGraphError({
281
+ code: 'resource-descriptor-invalid',
282
+ expected: 'an imported view belongs to an imported texture',
283
+ hint: 'use view() for graph-created textures and importView() for host-owned views',
284
+ detail: {
285
+ resourceLabel: textureRecord?.label ?? 'foreign',
286
+ field: 'origin',
287
+ expected: 'imported',
288
+ actual: textureRecord?.origin ?? 'foreign',
289
+ },
290
+ }),
291
+ );
292
+ }
293
+ const label = descriptor.label ?? `${textureRecord.label}.view.${this.nextId}`;
294
+ const unique = this.reserveLabel(label);
295
+ if (!unique.ok) return unique;
296
+ const id = this.nextId++;
297
+ this.views.set(id, { id, label, textureId: data.id, descriptor, resolve });
298
+ return ok(textureViewHandle(this.owner, id, data.id));
299
+ }
300
+
301
+ addRasterPass(
302
+ name: string,
303
+ descriptor: RasterGraphPass<FrameCtx>,
304
+ ): Result<void, RenderGraphError> {
305
+ return this.addPass(name, { kind: 'raster', descriptor });
306
+ }
307
+
308
+ addComputePass(
309
+ name: string,
310
+ descriptor: ComputeGraphPass<FrameCtx>,
311
+ ): Result<void, RenderGraphError> {
312
+ return this.addPass(name, { kind: 'compute', descriptor });
313
+ }
314
+
315
+ addCopyPass(name: string, descriptor: CopyGraphPass<FrameCtx>): Result<void, RenderGraphError> {
316
+ return this.addPass(name, { kind: 'copy', descriptor });
317
+ }
318
+
319
+ compile(
320
+ options: RenderGraphCompileOptions,
321
+ ): Result<CompiledRenderGraph<FrameCtx>, RenderGraphError> {
322
+ const writable = this.ensureWritable();
323
+ if (!writable.ok) return writable;
324
+ this.sealed = true;
325
+
326
+ const descriptors = this.validateDescriptors(options.surfaceSize);
327
+ if (!descriptors.ok) return descriptors;
328
+ const analyzed = this.analyze(options.device.caps);
329
+ if (!analyzed.ok) return analyzed;
330
+
331
+ const allocated = this.allocate(
332
+ options,
333
+ analyzed.value.usageByResource,
334
+ analyzed.value.firstUseByResource,
335
+ analyzed.value.lastUseByResource,
336
+ );
337
+ if (!allocated.ok) return allocated;
338
+
339
+ const info = freezeInfo({
340
+ passes: analyzed.value.passes.map((pass, executionIndex) => ({
341
+ name: pass.name,
342
+ kind: pass.pass.kind,
343
+ executionIndex,
344
+ accesses: pass.pass.descriptor.accesses.map((access) => {
345
+ const id = accessResourceId(access);
346
+ return {
347
+ resource: this.resources.get(id ?? -1)?.label ?? 'foreign',
348
+ usage: access.usage,
349
+ };
350
+ }),
351
+ dependencies: pass.dependencies.map(
352
+ (dependency) => analyzed.value.passes[dependency]?.name ?? 'unknown',
353
+ ),
354
+ })),
355
+ resources: [...allocated.value.resources.values()].map((resource) => ({
356
+ label: resource.record.label,
357
+ kind: resource.record.kind,
358
+ origin: resource.record.origin,
359
+ firstUse: resource.firstUse,
360
+ lastUse: resource.lastUse,
361
+ derivedUsage: resource.usage,
362
+ })),
363
+ });
364
+
365
+ return ok(
366
+ new CompiledRenderGraphImpl(
367
+ nextGeneration++,
368
+ this.owner,
369
+ options.device,
370
+ allocated.value.resources,
371
+ allocated.value.views,
372
+ Object.freeze(analyzed.value.passes),
373
+ info,
374
+ ),
375
+ );
376
+ }
377
+
378
+ private addPass(name: string, pass: GraphPass<FrameCtx>): Result<void, RenderGraphError> {
379
+ const writable = this.ensureWritable();
380
+ if (!writable.ok) return writable;
381
+ if (this.passNames.has(name)) {
382
+ return err(
383
+ new RenderGraphError({
384
+ code: 'duplicate-pass-name',
385
+ expected: `pass name '${name}' is unique within one builder`,
386
+ hint: `rename the second '${name}' pass; names are diagnostics, not identity`,
387
+ detail: { passName: name },
388
+ }),
389
+ );
390
+ }
391
+ for (const access of pass.descriptor.accesses) {
392
+ const valid = this.validateAccessHandle(name, access);
393
+ if (!valid.ok) return valid;
394
+ }
395
+ this.passNames.add(name);
396
+ this.passes.push({ id: this.passes.length, name, pass });
397
+ return ok(undefined);
398
+ }
399
+
400
+ private validateAccessHandle(
401
+ passName: string,
402
+ access: GraphAccess,
403
+ ): Result<void, RenderGraphError> {
404
+ const data = handleData(access.resource);
405
+ if (data === undefined || data.owner !== this.owner) {
406
+ return err(this.foreignHandleError(passName));
407
+ }
408
+ if (data.kind === 'texture-view') {
409
+ if (!this.views.has(data.id)) return err(this.foreignHandleError(passName));
410
+ return ok(undefined);
411
+ }
412
+ if (data.kind !== 'buffer' || !this.resources.has(data.id)) {
413
+ return err(this.foreignHandleError(passName));
414
+ }
415
+ return ok(undefined);
416
+ }
417
+
418
+ private analyze(caps: RhiCaps): Result<
419
+ {
420
+ readonly passes: readonly CompiledPass<FrameCtx>[];
421
+ readonly usageByResource: ReadonlyMap<number, number>;
422
+ readonly firstUseByResource: ReadonlyMap<number, number>;
423
+ readonly lastUseByResource: ReadonlyMap<number, number>;
424
+ },
425
+ RenderGraphError
426
+ > {
427
+ const normalizedByPass: NormalizedAccess[][] = [];
428
+ const usageByResource = new Map<number, number>();
429
+ const firstUseByResource = new Map<number, number>();
430
+ const lastUseByResource = new Map<number, number>();
431
+ const compiledPasses: CompiledPass<FrameCtx>[] = [];
432
+ const history: NormalizedAccess[] = [];
433
+
434
+ for (let passIndex = 0; passIndex < this.passes.length; passIndex++) {
435
+ const pass = this.passes[passIndex];
436
+ if (pass === undefined) continue;
437
+ const capability = this.validateCapabilities(pass, caps);
438
+ if (!capability.ok) return capability;
439
+ const normalized: NormalizedAccess[] = [];
440
+ for (const access of pass.pass.descriptor.accesses) {
441
+ const item = this.normalizeAccess(passIndex, pass, access);
442
+ if (!item.ok) return item;
443
+ normalized.push(item.value);
444
+ const usage =
445
+ this.resources.get(item.value.resourceId)?.kind === 'buffer'
446
+ ? bufferUsage(access.usage as GraphBufferAccess)
447
+ : textureUsage(access.usage as GraphTextureAccess);
448
+ usageByResource.set(
449
+ item.value.resourceId,
450
+ (usageByResource.get(item.value.resourceId) ?? 0) | usage,
451
+ );
452
+ if (!firstUseByResource.has(item.value.resourceId)) {
453
+ firstUseByResource.set(item.value.resourceId, passIndex);
454
+ }
455
+ lastUseByResource.set(item.value.resourceId, passIndex);
456
+ }
457
+ const conflict = this.validatePassAccesses(pass, normalized);
458
+ if (!conflict.ok) return conflict;
459
+ const attachments = this.validateAttachments(pass);
460
+ if (!attachments.ok) return attachments;
461
+
462
+ const dependencies = new Set<number>();
463
+ for (const current of normalized) {
464
+ if (current.read) {
465
+ const priorWrite = this.findPriorWrite(history, current);
466
+ if (priorWrite !== undefined) {
467
+ dependencies.add(priorWrite.passIndex);
468
+ } else if (this.resources.get(current.resourceId)?.origin === 'created') {
469
+ const label = this.resources.get(current.resourceId)?.label;
470
+ return err(
471
+ new RenderGraphError({
472
+ code: 'uninitialized-read',
473
+ expected: `graph-created resource '${label}' is written before pass '${pass.name}' reads it`,
474
+ hint: 'add a clear/write/copy-dst pass before the first read, or import initialized data',
475
+ detail: { passName: pass.name, resourceLabel: label, usage: current.usage },
476
+ }),
477
+ );
478
+ }
479
+ }
480
+ if (current.write) {
481
+ for (const dependency of this.findWriteDependencies(history, current)) {
482
+ dependencies.add(dependency);
483
+ }
484
+ }
485
+ }
486
+ normalizedByPass.push(normalized);
487
+ history.push(...normalized);
488
+ compiledPasses.push({
489
+ ...pass,
490
+ dependencies: Object.freeze([...dependencies].sort((left, right) => left - right)),
491
+ resourceIds: new Set(normalized.map((access) => access.resourceId)),
492
+ viewIds: new Set(
493
+ normalized.flatMap((access) => (access.viewId === undefined ? [] : [access.viewId])),
494
+ ),
495
+ });
496
+ }
497
+
498
+ for (const [resourceId, usage] of usageByResource) {
499
+ const resource = this.resources.get(resourceId);
500
+ if (resource?.origin !== 'imported') continue;
501
+ if ((resource.descriptor.usage & usage) !== usage) {
502
+ return err(
503
+ new RenderGraphError({
504
+ code: 'import-usage-mismatch',
505
+ expected: `imported resource '${resource.label}' physical usage contains derived graph usage ${usage}`,
506
+ hint: 'recreate the imported resource with every usage declared by graph accesses',
507
+ detail: {
508
+ resourceLabel: resource.label,
509
+ field: 'usage',
510
+ expected: String(usage),
511
+ actual: resource.descriptor.usage,
512
+ },
513
+ }),
514
+ );
515
+ }
516
+ }
517
+
518
+ return ok({
519
+ passes: compiledPasses,
520
+ usageByResource,
521
+ firstUseByResource,
522
+ lastUseByResource,
523
+ });
524
+ }
525
+
526
+ private normalizeAccess(
527
+ passIndex: number,
528
+ pass: PassRecord<FrameCtx>,
529
+ access: GraphAccess,
530
+ ): Result<NormalizedAccess, RenderGraphError> {
531
+ const passName = pass.name;
532
+ const data = handleData(access.resource);
533
+ if (data === undefined || data.owner !== this.owner) {
534
+ return err(this.foreignHandleError(passName));
535
+ }
536
+ const mode = this.accessModeForPass(pass, access);
537
+ if (data.kind === 'buffer') {
538
+ return ok({ passIndex, passName, resourceId: data.id, usage: access.usage, ...mode });
539
+ }
540
+ if (data.kind !== 'texture-view') return err(this.foreignHandleError(passName));
541
+ const view = this.views.get(data.id);
542
+ const texture = this.resources.get(data.textureId);
543
+ if (view === undefined || texture?.kind !== 'texture') {
544
+ return err(this.foreignHandleError(passName));
545
+ }
546
+ const mipLevels = texture.descriptor.mipLevelCount ?? 1;
547
+ const layers =
548
+ typeof texture.descriptor.size === 'object'
549
+ ? (texture.descriptor.size.depthOrArrayLayers ?? 1)
550
+ : 1;
551
+ const mipStart = view.descriptor.baseMipLevel ?? 0;
552
+ const layerStart = view.descriptor.baseArrayLayer ?? 0;
553
+ return ok({
554
+ passIndex,
555
+ passName,
556
+ resourceId: data.textureId,
557
+ viewId: data.id,
558
+ usage: access.usage,
559
+ ...mode,
560
+ range: {
561
+ mipStart,
562
+ mipEnd: mipStart + (view.descriptor.mipLevelCount ?? mipLevels - mipStart),
563
+ layerStart,
564
+ layerEnd: layerStart + (view.descriptor.arrayLayerCount ?? layers - layerStart),
565
+ aspect: view.descriptor.aspect ?? 'all',
566
+ },
567
+ });
568
+ }
569
+
570
+ private accessModeForPass(
571
+ pass: PassRecord<FrameCtx>,
572
+ access: GraphAccess,
573
+ ): { readonly read: boolean; readonly write: boolean } {
574
+ const base = accessMode(access.usage);
575
+ if (pass.pass.kind !== 'raster' || handleData(access.resource)?.kind !== 'texture-view') {
576
+ return base;
577
+ }
578
+ if (access.usage === 'color-attachment') {
579
+ const attachment = pass.pass.descriptor.colorAttachments.find(
580
+ (candidate) => candidate.view === access.resource,
581
+ );
582
+ return attachment?.loadOp === 'load' ? { read: true, write: true } : base;
583
+ }
584
+ if (access.usage === 'depth-stencil-write') {
585
+ const attachment = pass.pass.descriptor.depthStencilAttachment;
586
+ if (
587
+ attachment?.view === access.resource &&
588
+ (attachment.depthLoadOp === 'load' || attachment.stencilLoadOp === 'load')
589
+ ) {
590
+ return { read: true, write: true };
591
+ }
592
+ }
593
+ return base;
594
+ }
595
+
596
+ private validateCapabilities(
597
+ pass: PassRecord<FrameCtx>,
598
+ caps: RhiCaps,
599
+ ): Result<void, RenderGraphError> {
600
+ if (pass.pass.kind === 'compute' && !caps.compute) {
601
+ return this.capabilityError(pass.name, 'compute');
602
+ }
603
+ for (const access of pass.pass.descriptor.accesses) {
604
+ if (
605
+ (access.usage === 'storage-read' ||
606
+ access.usage === 'storage-write' ||
607
+ access.usage === 'storage-read-write') &&
608
+ handleData(access.resource)?.kind === 'buffer' &&
609
+ !caps.storageBuffer
610
+ ) {
611
+ return this.capabilityError(pass.name, 'storage-buffer', access);
612
+ }
613
+ if (
614
+ (access.usage === 'storage-read' ||
615
+ access.usage === 'storage-write' ||
616
+ access.usage === 'storage-read-write') &&
617
+ handleData(access.resource)?.kind === 'texture-view' &&
618
+ !caps.storageTexture
619
+ ) {
620
+ return this.capabilityError(pass.name, 'storage-texture', access);
621
+ }
622
+ if (access.usage === 'indirect-read' && !caps.indirectDrawing) {
623
+ return this.capabilityError(pass.name, 'indirect', access);
624
+ }
625
+ }
626
+ return ok(undefined);
627
+ }
628
+
629
+ private capabilityError(
630
+ passName: string,
631
+ capability: 'compute' | 'storage-buffer' | 'storage-texture' | 'indirect',
632
+ access?: GraphAccess,
633
+ ): Result<never, RenderGraphError> {
634
+ const resourceId = access === undefined ? undefined : accessResourceId(access);
635
+ return err(
636
+ new RenderGraphError({
637
+ code: 'capability-missing',
638
+ expected: `pass '${passName}' is built only when capability '${capability}' is available`,
639
+ hint: 'select the fallback algorithm before adding this pass to the builder',
640
+ detail: {
641
+ passName,
642
+ capability,
643
+ ...(resourceId === undefined
644
+ ? {}
645
+ : { resourceLabel: this.resources.get(resourceId)?.label, usage: access?.usage }),
646
+ },
647
+ }),
648
+ );
649
+ }
650
+
651
+ private validatePassAccesses(
652
+ pass: PassRecord<FrameCtx>,
653
+ accesses: readonly NormalizedAccess[],
654
+ ): Result<void, RenderGraphError> {
655
+ for (let leftIndex = 0; leftIndex < accesses.length; leftIndex++) {
656
+ const left = accesses[leftIndex];
657
+ if (left === undefined) continue;
658
+ for (let rightIndex = leftIndex + 1; rightIndex < accesses.length; rightIndex++) {
659
+ const right = accesses[rightIndex];
660
+ if (
661
+ right === undefined ||
662
+ left.resourceId !== right.resourceId ||
663
+ !rangesOverlap(left.range, right.range)
664
+ ) {
665
+ continue;
666
+ }
667
+ if (!left.write && !right.write) continue;
668
+ if (left.usage === right.usage && left.usage === 'storage-read-write') continue;
669
+ const label = this.resources.get(left.resourceId)?.label;
670
+ return err(
671
+ new RenderGraphError({
672
+ code: 'access-conflict',
673
+ expected: `pass '${pass.name}' uses resource '${label}' in one compatible WebGPU usage scope`,
674
+ hint: 'split conflicting read/write roles into ordered passes or use storage-read-write once',
675
+ detail: {
676
+ passName: pass.name,
677
+ resourceLabel: label,
678
+ accesses: [left.usage, right.usage],
679
+ },
680
+ }),
681
+ );
682
+ }
683
+ }
684
+ if (pass.pass.kind === 'copy') {
685
+ const invalid = accesses.find(
686
+ (access) => access.usage !== 'copy-src' && access.usage !== 'copy-dst',
687
+ );
688
+ if (invalid !== undefined) {
689
+ return err(
690
+ new RenderGraphError({
691
+ code: 'access-conflict',
692
+ expected: `copy pass '${pass.name}' declares only copy-src/copy-dst accesses`,
693
+ hint: 'move shader or attachment work into raster/compute passes',
694
+ detail: { passName: pass.name, usage: invalid.usage },
695
+ }),
696
+ );
697
+ }
698
+ }
699
+ return ok(undefined);
700
+ }
701
+
702
+ private validateAttachments(pass: PassRecord<FrameCtx>): Result<void, RenderGraphError> {
703
+ if (pass.pass.kind !== 'raster') return ok(undefined);
704
+ const accesses = pass.pass.descriptor.accesses;
705
+ const has = (view: GraphTextureView, usage: GraphTextureAccess): boolean =>
706
+ accesses.some((access) => access.resource === view && access.usage === usage);
707
+ for (const attachment of pass.pass.descriptor.colorAttachments) {
708
+ if (!has(attachment.view, 'color-attachment')) {
709
+ return this.missingAttachmentAccess(pass.name, attachment.view, 'color-attachment');
710
+ }
711
+ if (
712
+ attachment.resolveTarget !== undefined &&
713
+ !has(attachment.resolveTarget, 'color-attachment')
714
+ ) {
715
+ return this.missingAttachmentAccess(
716
+ pass.name,
717
+ attachment.resolveTarget,
718
+ 'color-attachment',
719
+ );
720
+ }
721
+ }
722
+ const depth = pass.pass.descriptor.depthStencilAttachment;
723
+ if (depth !== undefined) {
724
+ const usage = depth.depthReadOnly === true ? 'depth-stencil-read' : 'depth-stencil-write';
725
+ if (!has(depth.view, usage))
726
+ return this.missingAttachmentAccess(pass.name, depth.view, usage);
727
+ }
728
+ return ok(undefined);
729
+ }
730
+
731
+ private missingAttachmentAccess(
732
+ passName: string,
733
+ view: GraphTextureView,
734
+ usage: GraphTextureAccess,
735
+ ): Result<never, RenderGraphError> {
736
+ const data = handleData(view);
737
+ const label = data?.kind === 'texture-view' ? this.views.get(data.id)?.label : undefined;
738
+ return err(
739
+ new RenderGraphError({
740
+ code: 'resource-not-declared-by-pass',
741
+ expected: `raster pass '${passName}' attachment '${label}' declares '${usage}' access`,
742
+ hint: 'add the attachment view and matching usage to accesses',
743
+ detail: { passName, resourceLabel: label, usage },
744
+ }),
745
+ );
746
+ }
747
+
748
+ private findPriorWrite(
749
+ history: readonly NormalizedAccess[],
750
+ current: NormalizedAccess,
751
+ ): NormalizedAccess | undefined {
752
+ for (let index = history.length - 1; index >= 0; index--) {
753
+ const prior = history[index];
754
+ if (
755
+ prior !== undefined &&
756
+ prior.resourceId === current.resourceId &&
757
+ prior.write &&
758
+ rangesOverlap(prior.range, current.range)
759
+ ) {
760
+ return prior;
761
+ }
762
+ }
763
+ return undefined;
764
+ }
765
+
766
+ private findWriteDependencies(
767
+ history: readonly NormalizedAccess[],
768
+ current: NormalizedAccess,
769
+ ): readonly number[] {
770
+ const dependencies = new Set<number>();
771
+ for (let index = history.length - 1; index >= 0; index--) {
772
+ const prior = history[index];
773
+ if (
774
+ prior === undefined ||
775
+ prior.resourceId !== current.resourceId ||
776
+ !rangesOverlap(prior.range, current.range)
777
+ ) {
778
+ continue;
779
+ }
780
+ if (prior.read) dependencies.add(prior.passIndex);
781
+ if (prior.write) {
782
+ dependencies.add(prior.passIndex);
783
+ break;
784
+ }
785
+ }
786
+ return [...dependencies];
787
+ }
788
+
789
+ private validateDescriptors(
790
+ surfaceSize: RenderGraphCompileOptions['surfaceSize'],
791
+ ): Result<void, RenderGraphError> {
792
+ if (surfaceSize.width <= 0 || surfaceSize.height <= 0) {
793
+ return err(
794
+ new RenderGraphError({
795
+ code: 'resource-descriptor-invalid',
796
+ expected: 'surfaceSize width and height are positive integers',
797
+ hint: 'compile after the render surface has a non-zero physical extent',
798
+ detail: {
799
+ resourceLabel: 'surface',
800
+ field: 'surfaceSize',
801
+ expected: 'width > 0 and height > 0',
802
+ actual: `${surfaceSize.width}x${surfaceSize.height}`,
803
+ },
804
+ }),
805
+ );
806
+ }
807
+ for (const resource of this.resources.values()) {
808
+ if (resource.kind === 'buffer' && resource.descriptor.size <= 0) {
809
+ return err(
810
+ new RenderGraphError({
811
+ code: 'resource-descriptor-invalid',
812
+ expected: `buffer '${resource.label}' size is greater than zero`,
813
+ hint: 'derive a positive byte size before creating/importing the buffer',
814
+ detail: {
815
+ resourceLabel: resource.label,
816
+ field: 'size',
817
+ expected: 'size > 0',
818
+ actual: resource.descriptor.size,
819
+ },
820
+ }),
821
+ );
822
+ }
823
+ if (resource.kind === 'texture') {
824
+ const extent = this.resolveExtent(resource.descriptor.size, surfaceSize);
825
+ if (extent.width <= 0 || extent.height <= 0 || extent.depthOrArrayLayers <= 0) {
826
+ return err(
827
+ new RenderGraphError({
828
+ code: 'resource-descriptor-invalid',
829
+ expected: `texture '${resource.label}' extent is positive`,
830
+ hint: 'repair the authored extent or compile surface size',
831
+ detail: {
832
+ resourceLabel: resource.label,
833
+ field: 'size',
834
+ expected: 'all extent axes > 0',
835
+ actual: `${extent.width}x${extent.height}x${extent.depthOrArrayLayers}`,
836
+ },
837
+ }),
838
+ );
839
+ }
840
+ }
841
+ }
842
+ return ok(undefined);
843
+ }
844
+
845
+ private allocate(
846
+ options: RenderGraphCompileOptions,
847
+ usageByResource: ReadonlyMap<number, number>,
848
+ firstUseByResource: ReadonlyMap<number, number>,
849
+ lastUseByResource: ReadonlyMap<number, number>,
850
+ ): Result<
851
+ {
852
+ readonly resources: ReadonlyMap<number, CompiledResource<FrameCtx>>;
853
+ readonly views: ReadonlyMap<number, CompiledView<FrameCtx>>;
854
+ },
855
+ RenderGraphError
856
+ > {
857
+ const compiledResources = new Map<number, CompiledResource<FrameCtx>>();
858
+ const compiledViews = new Map<number, CompiledView<FrameCtx>>();
859
+ const createdTextures: Texture[] = [];
860
+ const createdBuffers: Buffer[] = [];
861
+ const discard = (): void => {
862
+ for (const texture of createdTextures) options.device.destroyTexture(texture);
863
+ for (const buffer of createdBuffers) options.device.destroyBuffer(buffer);
864
+ };
865
+
866
+ for (const resource of this.resources.values()) {
867
+ const usage = usageByResource.get(resource.id) ?? 0;
868
+ let texture: Texture | undefined;
869
+ let buffer: Buffer | undefined;
870
+ if (resource.origin === 'created' && usage !== 0) {
871
+ if (resource.kind === 'texture') {
872
+ const extent = this.resolveExtent(resource.descriptor.size, options.surfaceSize);
873
+ const created = options.device.createTexture({
874
+ label: resource.label,
875
+ size: extent,
876
+ mipLevelCount: resource.descriptor.mipLevelCount ?? 1,
877
+ sampleCount: resource.descriptor.sampleCount ?? 1,
878
+ dimension: resource.descriptor.dimension ?? '2d',
879
+ format: resource.descriptor.format,
880
+ usage,
881
+ viewFormats: [...(resource.descriptor.viewFormats ?? [])],
882
+ });
883
+ if (!created.ok) {
884
+ discard();
885
+ return err(
886
+ new RenderGraphError({
887
+ code: 'resource-allocation-failed',
888
+ expected: `RHI creates graph texture '${resource.label}'`,
889
+ hint: 'inspect detail.rhiCode and repair the descriptor/capability route',
890
+ detail: { resourceKey: resource.label, rhiCode: created.error.code },
891
+ }),
892
+ );
893
+ }
894
+ texture = created.value;
895
+ createdTextures.push(texture);
896
+ } else {
897
+ const created = options.device.createBuffer({
898
+ label: resource.label,
899
+ size: resource.descriptor.size,
900
+ usage,
901
+ mappedAtCreation: resource.descriptor.mappedAtCreation ?? false,
902
+ });
903
+ if (!created.ok) {
904
+ discard();
905
+ return err(
906
+ new RenderGraphError({
907
+ code: 'resource-allocation-failed',
908
+ expected: `RHI creates graph buffer '${resource.label}'`,
909
+ hint: 'inspect detail.rhiCode and repair the descriptor/capability route',
910
+ detail: { resourceKey: resource.label, rhiCode: created.error.code },
911
+ }),
912
+ );
913
+ }
914
+ buffer = created.value;
915
+ createdBuffers.push(buffer);
916
+ }
917
+ }
918
+ compiledResources.set(resource.id, {
919
+ record: resource,
920
+ usage,
921
+ firstUse: firstUseByResource.get(resource.id) ?? null,
922
+ lastUse: lastUseByResource.get(resource.id) ?? null,
923
+ ...(texture === undefined ? {} : { texture }),
924
+ ...(buffer === undefined ? {} : { buffer }),
925
+ });
926
+ }
927
+
928
+ for (const view of this.views.values()) {
929
+ const resource = compiledResources.get(view.textureId);
930
+ let physicalView: TextureView | undefined;
931
+ if (resource?.record.origin === 'created' && resource.texture !== undefined) {
932
+ const created = options.device.createTextureView(resource.texture, view.descriptor);
933
+ if (!created.ok) {
934
+ discard();
935
+ return err(
936
+ new RenderGraphError({
937
+ code: 'resource-allocation-failed',
938
+ expected: `RHI creates graph texture view '${view.label}'`,
939
+ hint: 'inspect detail.rhiCode and repair the view descriptor',
940
+ detail: { resourceKey: view.label, rhiCode: created.error.code },
941
+ }),
942
+ );
943
+ }
944
+ physicalView = created.value;
945
+ }
946
+ compiledViews.set(view.id, {
947
+ record: view,
948
+ ...(physicalView === undefined ? {} : { view: physicalView }),
949
+ });
950
+ }
951
+ return ok({ resources: compiledResources, views: compiledViews });
952
+ }
953
+
954
+ private resolveExtent(
955
+ extent: GraphExtent,
956
+ surface: RenderGraphCompileOptions['surfaceSize'],
957
+ ): { readonly width: number; readonly height: number; readonly depthOrArrayLayers: number } {
958
+ if (extent === 'surface') return { ...surface, depthOrArrayLayers: 1 };
959
+ if (extent === 'half-surface') {
960
+ return {
961
+ width: Math.ceil(surface.width / 2),
962
+ height: Math.ceil(surface.height / 2),
963
+ depthOrArrayLayers: 1,
964
+ };
965
+ }
966
+ return {
967
+ width: extent.width,
968
+ height: extent.height,
969
+ depthOrArrayLayers: extent.depthOrArrayLayers ?? 1,
970
+ };
971
+ }
972
+
973
+ private ensureWritable(): Result<void, RenderGraphError> {
974
+ return this.sealed
975
+ ? err(
976
+ new RenderGraphError({
977
+ code: 'builder-sealed',
978
+ expected: 'a RenderGraphBuilder accepts declarations only before compile()',
979
+ hint: 'create a new builder for a changed topology',
980
+ detail: {},
981
+ }),
982
+ )
983
+ : ok(undefined);
984
+ }
985
+
986
+ private reserveLabel(label: string): Result<void, RenderGraphError> {
987
+ if (this.labels.has(label)) {
988
+ return err(
989
+ new RenderGraphError({
990
+ code: 'duplicate-resource-label',
991
+ expected: `resource label '${label}' is unique within one builder`,
992
+ hint: `rename the second '${label}' declaration`,
993
+ detail: { resourceLabel: label },
994
+ }),
995
+ );
996
+ }
997
+ this.labels.add(label);
998
+ return ok(undefined);
999
+ }
1000
+
1001
+ private foreignHandleError(passName?: string): RenderGraphError {
1002
+ return new RenderGraphError({
1003
+ code: 'foreign-resource-handle',
1004
+ expected: 'every graph resource handle belongs to this builder',
1005
+ hint: 'create/import/view the resource on the same builder that declares the pass',
1006
+ detail: { ...(passName === undefined ? {} : { passName }) },
1007
+ });
1008
+ }
1009
+ }