@forgeax/engine-render-graph 0.1.7 → 0.1.20

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.
package/src/builder.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Buffer, RhiCaps, Texture, TextureView } from '@forgeax/engine-rhi';
2
2
  import { CompiledRenderGraphImpl } from './compiled-graph.js';
3
3
  import { err, ok, RenderGraphError, type Result } from './errors.js';
4
+ import type { ResolvedColorTargetDescriptor } from './graph.js';
4
5
  import {
5
6
  accessResourceId,
6
7
  bufferHandle,
@@ -14,9 +15,11 @@ import {
14
15
  textureHandle,
15
16
  textureViewHandle,
16
17
  } from './kernel-internal.js';
18
+ import { isTextureViewDimensionCompatible } from './resource-registry.js';
17
19
  import type {
18
20
  CompiledRenderGraph,
19
21
  CompiledRenderGraphInfo,
22
+ CompiledResourceDescriptor,
20
23
  ComputeGraphPass,
21
24
  CopyGraphPass,
22
25
  GraphAccess,
@@ -58,6 +61,60 @@ const TEXTURE_USAGE = {
58
61
 
59
62
  let nextGeneration = 1;
60
63
 
64
+ function textureByteSize(
65
+ format: GPUTextureFormat,
66
+ extent: { readonly width: number; readonly height: number; readonly depthOrArrayLayers: number },
67
+ mipLevelCount: number,
68
+ ): number | undefined {
69
+ const bytesPerTexel =
70
+ format === 'r8unorm' || format === 'r8snorm' || format === 'r8uint' || format === 'r8sint'
71
+ ? 1
72
+ : format === 'rg8unorm' ||
73
+ format === 'rg8snorm' ||
74
+ format === 'rg8uint' ||
75
+ format === 'rg8sint'
76
+ ? 2
77
+ : format === 'rgba8unorm' ||
78
+ format === 'rgba8unorm-srgb' ||
79
+ format === 'rgba8snorm' ||
80
+ format === 'rgba8uint' ||
81
+ format === 'rgba8sint' ||
82
+ format === 'r32float' ||
83
+ format === 'r32uint' ||
84
+ format === 'r32sint'
85
+ ? 4
86
+ : format === 'rg16float' ||
87
+ format === 'rg16uint' ||
88
+ format === 'rg16sint' ||
89
+ format === 'rg16snorm' ||
90
+ format === 'rg16unorm'
91
+ ? 4
92
+ : format === 'rgba16float' ||
93
+ format === 'rgba16uint' ||
94
+ format === 'rgba16sint' ||
95
+ format === 'rgba16snorm' ||
96
+ format === 'rgba16unorm' ||
97
+ format === 'rg32float' ||
98
+ format === 'rg32uint' ||
99
+ format === 'rg32sint'
100
+ ? 8
101
+ : format === 'rgba32float' || format === 'rgba32uint' || format === 'rgba32sint'
102
+ ? 16
103
+ : undefined;
104
+ if (bytesPerTexel === undefined) return undefined;
105
+ let bytes = 0;
106
+ let width = extent.width;
107
+ let height = extent.height;
108
+ let depth = extent.depthOrArrayLayers;
109
+ for (let level = 0; level < mipLevelCount; level += 1) {
110
+ bytes += width * height * depth * bytesPerTexel;
111
+ width = Math.max(1, Math.floor(width / 2));
112
+ height = Math.max(1, Math.floor(height / 2));
113
+ depth = Math.max(1, Math.floor(depth / 2));
114
+ }
115
+ return bytes;
116
+ }
117
+
61
118
  interface NormalizedRange {
62
119
  readonly mipStart: number;
63
120
  readonly mipEnd: number;
@@ -106,6 +163,8 @@ function textureUsage(access: GraphTextureAccess): number {
106
163
  case 'storage-write':
107
164
  case 'storage-read-write':
108
165
  return TEXTURE_USAGE.storageBinding;
166
+ case 'sampled-storage-read-write':
167
+ return TEXTURE_USAGE.storageBinding | TEXTURE_USAGE.textureBinding;
109
168
  case 'color-attachment':
110
169
  case 'depth-stencil-read':
111
170
  case 'depth-stencil-write':
@@ -123,6 +182,7 @@ function accessMode(access: GraphBufferAccess | GraphTextureAccess): {
123
182
  } {
124
183
  switch (access) {
125
184
  case 'storage-read-write':
185
+ case 'sampled-storage-read-write':
126
186
  return { read: true, write: true };
127
187
  case 'storage-write':
128
188
  case 'color-attachment':
@@ -151,7 +211,14 @@ function rangesOverlap(
151
211
  }
152
212
 
153
213
  function freezeInfo(info: CompiledRenderGraphInfo): CompiledRenderGraphInfo {
214
+ const freezeDescriptor = (descriptor: CompiledResourceDescriptor) => {
215
+ if (descriptor.kind !== 'texture') return Object.freeze({ ...descriptor });
216
+ const size =
217
+ typeof descriptor.size === 'string' ? descriptor.size : Object.freeze({ ...descriptor.size });
218
+ return Object.freeze({ ...descriptor, size });
219
+ };
154
220
  return Object.freeze({
221
+ generation: info.generation,
155
222
  passes: Object.freeze(
156
223
  info.passes.map((pass) =>
157
224
  Object.freeze({
@@ -161,7 +228,14 @@ function freezeInfo(info: CompiledRenderGraphInfo): CompiledRenderGraphInfo {
161
228
  }),
162
229
  ),
163
230
  ),
164
- resources: Object.freeze(info.resources.map((resource) => Object.freeze({ ...resource }))),
231
+ resources: Object.freeze(
232
+ info.resources.map((resource) =>
233
+ Object.freeze({
234
+ ...resource,
235
+ descriptor: freezeDescriptor(resource.descriptor),
236
+ }),
237
+ ),
238
+ ),
165
239
  });
166
240
  }
167
241
 
@@ -336,7 +410,20 @@ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
336
410
  );
337
411
  if (!allocated.ok) return allocated;
338
412
 
413
+ const generation = nextGeneration;
414
+ const physicalAllocationKeys = new WeakMap<object, string>();
415
+ let nextPhysicalAllocationKey = 1;
416
+ const physicalKey = (resource: CompiledResource<FrameCtx>): string | undefined => {
417
+ const handle = resource.texture ?? resource.buffer;
418
+ if (handle === undefined) return undefined;
419
+ const existing = physicalAllocationKeys.get(handle);
420
+ if (existing !== undefined) return existing;
421
+ const key = `allocation-${nextPhysicalAllocationKey++}`;
422
+ physicalAllocationKeys.set(handle, key);
423
+ return key;
424
+ };
339
425
  const info = freezeInfo({
426
+ generation,
340
427
  passes: analyzed.value.passes.map((pass, executionIndex) => ({
341
428
  name: pass.name,
342
429
  kind: pass.pass.kind,
@@ -352,16 +439,78 @@ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
352
439
  (dependency) => analyzed.value.passes[dependency]?.name ?? 'unknown',
353
440
  ),
354
441
  })),
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
- })),
442
+ resources: [...allocated.value.resources.values()].map((resource) => {
443
+ const texture =
444
+ resource.record.kind === 'texture'
445
+ ? (resource.record.descriptor as GraphTextureDescriptor)
446
+ : undefined;
447
+ const buffer =
448
+ resource.record.kind === 'buffer'
449
+ ? (resource.record.descriptor as GraphBufferDescriptor)
450
+ : undefined;
451
+ const allocationKey = physicalKey(resource);
452
+ const extent =
453
+ texture === undefined ? undefined : this.resolveExtent(texture.size, options.surfaceSize);
454
+ return {
455
+ label: resource.record.label,
456
+ kind: resource.record.kind,
457
+ origin: resource.record.origin,
458
+ descriptor:
459
+ texture === undefined
460
+ ? {
461
+ kind: 'buffer' as const,
462
+ size: buffer?.size ?? 0,
463
+ }
464
+ : {
465
+ kind: 'texture' as const,
466
+ format: texture.format,
467
+ ...(texture.domain === undefined ? {} : { domain: texture.domain }),
468
+ size: texture.size,
469
+ width: extent?.width ?? 1,
470
+ height: extent?.height ?? 1,
471
+ depthOrArrayLayers: extent?.depthOrArrayLayers ?? 1,
472
+ mipLevelCount: texture.mipLevelCount ?? 1,
473
+ sampleCount: texture.sampleCount ?? 1,
474
+ },
475
+ firstUse: resource.firstUse,
476
+ lastUse: resource.lastUse,
477
+ derivedUsage: resource.usage,
478
+ ...(allocationKey === undefined ? {} : { physicalAllocationKey: allocationKey }),
479
+ ...(texture === undefined ? {} : { format: texture.format }),
480
+ ...(texture === undefined
481
+ ? buffer === undefined
482
+ ? {}
483
+ : { byteSize: buffer.size }
484
+ : {
485
+ byteSize: textureByteSize(
486
+ texture.format,
487
+ this.resolveExtent(texture.size, options.surfaceSize),
488
+ texture.mipLevelCount ?? 1,
489
+ ),
490
+ }),
491
+ ...(texture === undefined
492
+ ? {}
493
+ : {
494
+ dimension: texture.dimension ?? '2d',
495
+ extent,
496
+ }),
497
+ };
498
+ }),
363
499
  });
364
500
 
501
+ const colorTargetDescriptors = new Map<string, ResolvedColorTargetDescriptor>();
502
+ for (const resource of allocated.value.resources.values()) {
503
+ if (resource.record.kind !== 'texture' || resource.texture === undefined) continue;
504
+ const extent = this.resolveExtent(resource.record.descriptor.size, options.surfaceSize);
505
+ colorTargetDescriptors.set(resource.record.label, {
506
+ texture: resource.texture,
507
+ format: resource.record.descriptor.format,
508
+ size: { width: extent.width, height: extent.height },
509
+ usage: resource.usage,
510
+ sample: resource.record.descriptor.sampleCount ?? 1,
511
+ });
512
+ }
513
+
365
514
  return ok(
366
515
  new CompiledRenderGraphImpl(
367
516
  nextGeneration++,
@@ -371,6 +520,7 @@ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
371
520
  allocated.value.views,
372
521
  Object.freeze(analyzed.value.passes),
373
522
  info,
523
+ colorTargetDescriptors,
374
524
  ),
375
525
  );
376
526
  }
@@ -431,6 +581,12 @@ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
431
581
  const compiledPasses: CompiledPass<FrameCtx>[] = [];
432
582
  const history: NormalizedAccess[] = [];
433
583
 
584
+ for (const resource of this.resources.values()) {
585
+ if (resource.kind !== 'texture' || resource.origin !== 'created') continue;
586
+ const usage = resource.descriptor.usage ?? 0;
587
+ if (usage !== 0) usageByResource.set(resource.id, usage);
588
+ }
589
+
434
590
  for (let passIndex = 0; passIndex < this.passes.length; passIndex++) {
435
591
  const pass = this.passes[passIndex];
436
592
  if (pass === undefined) continue;
@@ -665,7 +821,11 @@ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
665
821
  continue;
666
822
  }
667
823
  if (!left.write && !right.write) continue;
668
- if (left.usage === right.usage && left.usage === 'storage-read-write') continue;
824
+ if (
825
+ left.usage === right.usage &&
826
+ (left.usage === 'storage-read-write' || left.usage === 'sampled-storage-read-write')
827
+ )
828
+ continue;
669
829
  const label = this.resources.get(left.resourceId)?.label;
670
830
  return err(
671
831
  new RenderGraphError({
@@ -839,6 +999,27 @@ export class RenderGraphBuilder<FrameCtx extends RenderGraphFrame> {
839
999
  }
840
1000
  }
841
1001
  }
1002
+ for (const view of this.views.values()) {
1003
+ const texture = this.resources.get(view.textureId);
1004
+ if (texture?.kind !== 'texture') continue;
1005
+ const allocationDimension = texture.descriptor.dimension ?? '2d';
1006
+ const viewDimension = view.descriptor.dimension;
1007
+ if (!isTextureViewDimensionCompatible(allocationDimension, viewDimension)) {
1008
+ return err(
1009
+ new RenderGraphError({
1010
+ code: 'resource-descriptor-invalid',
1011
+ expected: `texture '${texture.label}' view dimension matches allocation dimension`,
1012
+ hint: 'use a 3d view only for a 3d allocation and preserve array views on 2d allocations',
1013
+ detail: {
1014
+ resourceLabel: texture.label,
1015
+ field: 'dimension',
1016
+ expected: allocationDimension,
1017
+ actual: viewDimension ?? '2d',
1018
+ },
1019
+ }),
1020
+ );
1021
+ }
1022
+ }
842
1023
  return ok(undefined);
843
1024
  }
844
1025
 
@@ -8,6 +8,7 @@ import type {
8
8
  TextureView,
9
9
  } from '@forgeax/engine-rhi';
10
10
  import { err, ok, RenderGraphError, type Result } from './errors.js';
11
+ import type { ResolvedColorTargetDescriptor } from './graph.js';
11
12
  import type {
12
13
  CompiledPass,
13
14
  CompiledResource,
@@ -55,12 +56,32 @@ export class CompiledRenderGraphImpl<FrameCtx extends RenderGraphFrame>
55
56
  private readonly views: ReadonlyMap<number, CompiledView<FrameCtx>>,
56
57
  private readonly passes: readonly CompiledPass<FrameCtx>[],
57
58
  private readonly info: CompiledRenderGraphInfo,
59
+ private readonly colorTargetDescriptors: ReadonlyMap<string, ResolvedColorTargetDescriptor>,
58
60
  ) {}
59
61
 
60
62
  inspect(): CompiledRenderGraphInfo {
61
63
  return this.info;
62
64
  }
63
65
 
66
+ getColorTargetView(name: string): TextureView | undefined {
67
+ for (const view of this.views.values()) {
68
+ const resource = this.resources.get(view.record.textureId);
69
+ if (resource?.record.label === name) return view.view;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ getColorTargetTexture(name: string): Texture | undefined {
75
+ for (const resource of this.resources.values()) {
76
+ if (resource.record.label === name) return resource.texture;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ getColorTargetDescriptor(name: string): ResolvedColorTargetDescriptor | undefined {
82
+ return this.colorTargetDescriptors.get(name);
83
+ }
84
+
64
85
  execute(frame: FrameCtx, runPass?: RenderGraphPassRunner): Result<void, RenderGraphError> {
65
86
  if (this.retired) {
66
87
  return err(
package/src/errors.ts CHANGED
@@ -66,6 +66,28 @@ export type RenderGraphErrorCode = keyof RenderGraphErrorDetailByCode;
66
66
  /** Detail union projected from the private code-to-detail map. */
67
67
  export type RenderGraphErrorDetail = RenderGraphErrorDetailByCode[RenderGraphErrorCode];
68
68
 
69
+ /** Failure stages for the shared float/output surface contract. */
70
+ export type SurfaceFailureKind =
71
+ | 'allocation'
72
+ | 'attachment'
73
+ | 'sampled-read'
74
+ | 'view-domain'
75
+ | 'raw-endpoint';
76
+
77
+ /** Closed error codes derived from the surface failure kinds. */
78
+ export type SurfaceFailureCode = `surface-${SurfaceFailureKind}-failed`;
79
+
80
+ /** Shared detail for graph and render-surface recovery. */
81
+ export interface RenderSurfaceFailureDetail {
82
+ readonly lane: string;
83
+ readonly stage: string;
84
+ readonly target: string;
85
+ readonly format: string;
86
+ readonly domain: string;
87
+ readonly endpoint: string;
88
+ readonly capability: string;
89
+ }
90
+
69
91
  /**
70
92
  * Constructor arguments correlated by code. `detail` remains optional to
71
93
  * preserve the existing envelope behavior for callers that only need the
package/src/index.ts CHANGED
@@ -14,10 +14,13 @@ export {
14
14
  RenderGraphError,
15
15
  type RenderGraphErrorCode,
16
16
  type RenderGraphErrorDetail,
17
+ type RenderSurfaceFailureDetail,
17
18
  type ResourceAllocFailedDetail,
18
19
  type Result,
19
20
  type ResultErr,
20
21
  type ResultOk,
22
+ type SurfaceFailureCode,
23
+ type SurfaceFailureKind,
21
24
  } from './errors.js';
22
25
  export type {
23
26
  BufferRole,
@@ -65,6 +68,7 @@ export {
65
68
  export type {
66
69
  CompiledRenderGraph,
67
70
  CompiledRenderGraphInfo,
71
+ CompiledResourceDescriptor,
68
72
  ComputeGraphPass,
69
73
  CopyGraphPass,
70
74
  GraphAccess,
@@ -32,6 +32,14 @@ describe('ColorValueDomain', () => {
32
32
  expect(JSON.parse(serializeColorResourceDescriptor(descriptor))).toEqual(descriptor);
33
33
  });
34
34
 
35
+ it('keeps display-encoded as an explicit domain for float storage', () => {
36
+ const descriptor: ColorResourceDescriptor = {
37
+ domain: 'display-encoded',
38
+ format: 'rgba16float',
39
+ };
40
+ expect(JSON.parse(serializeColorResourceDescriptor(descriptor))).toEqual(descriptor);
41
+ });
42
+
35
43
  it.each([undefined, 'gamma-magic'])('rejects a missing or unknown domain: %s', (value) => {
36
44
  const result = deserializeColorValueDomain(value);
37
45
  expect(result.ok).toBe(false);
@@ -26,6 +26,16 @@ import type {
26
26
  } from './graph.js';
27
27
  import type { ColorValueDomain } from './pipeline/color-value-domain.js';
28
28
 
29
+ /** Check the only allocation/view dimension combinations supported by WebGPU. */
30
+ export function isTextureViewDimensionCompatible(
31
+ allocationDimension: GPUTextureDimension,
32
+ viewDimension: GPUTextureViewDimension | undefined,
33
+ ): boolean {
34
+ if (viewDimension === undefined) return true;
35
+ if (allocationDimension === '3d') return viewDimension === '3d';
36
+ return viewDimension !== '3d';
37
+ }
38
+
29
39
  /**
30
40
  * Per-resource GPU allocation metadata carried through compile.
31
41
  * When the resource was registered via addColorTarget, colorTarget
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  TextureView,
11
11
  } from '@forgeax/engine-rhi';
12
12
  import type { RenderGraphError, Result } from './errors.js';
13
+ import type { ColorValueDomain } from './pipeline/color-value-domain.js';
13
14
 
14
15
  declare const graphTextureBrand: unique symbol;
15
16
  declare const graphTextureViewBrand: unique symbol;
@@ -44,10 +45,14 @@ export type GraphExtent =
44
45
  export interface GraphTextureDescriptor {
45
46
  readonly format: TextureFormat;
46
47
  readonly size: GraphExtent;
48
+ /** Additional RHI usage required by an external diagnostic consumer. */
49
+ readonly usage?: number | undefined;
47
50
  readonly mipLevelCount?: number | undefined;
48
51
  readonly sampleCount?: number | undefined;
49
52
  readonly dimension?: GPUTextureDimension | undefined;
50
53
  readonly viewFormats?: readonly TextureFormat[] | undefined;
54
+ /** Explicit semantic color domain; never inferred from the attachment format. */
55
+ readonly domain?: ColorValueDomain | undefined;
51
56
  }
52
57
 
53
58
  export interface ImportedTextureDescriptor extends GraphTextureDescriptor {
@@ -92,6 +97,7 @@ export type GraphTextureAccess =
92
97
  | 'storage-read'
93
98
  | 'storage-write'
94
99
  | 'storage-read-write'
100
+ | 'sampled-storage-read-write'
95
101
  | 'color-attachment'
96
102
  | 'depth-stencil-read'
97
103
  | 'depth-stencil-write'
@@ -174,6 +180,26 @@ export interface GraphAccessInfo {
174
180
  readonly usage: GraphBufferAccess | GraphTextureAccess;
175
181
  }
176
182
 
183
+ /** Detached physical allocation facts for one compiled graph resource. */
184
+ export type CompiledResourceDescriptor =
185
+ | {
186
+ readonly kind: 'texture';
187
+ readonly format: TextureFormat;
188
+ /** Explicit semantic color domain retained in detached graph facts. */
189
+ readonly domain?: ColorValueDomain | undefined;
190
+ /** Authored extent retained so consumers can validate the allocation contract. */
191
+ readonly size: GraphExtent;
192
+ readonly width: number;
193
+ readonly height: number;
194
+ readonly depthOrArrayLayers: number;
195
+ readonly mipLevelCount: number;
196
+ readonly sampleCount: number;
197
+ }
198
+ | {
199
+ readonly kind: 'buffer';
200
+ readonly size: number;
201
+ };
202
+
177
203
  export interface CompiledRenderGraphInfo {
178
204
  readonly passes: readonly {
179
205
  readonly name: string;
@@ -186,10 +212,24 @@ export interface CompiledRenderGraphInfo {
186
212
  readonly label: string;
187
213
  readonly kind: GraphResourceKind;
188
214
  readonly origin: GraphResourceOrigin;
215
+ readonly descriptor: CompiledResourceDescriptor;
189
216
  readonly firstUse: number | null;
190
217
  readonly lastUse: number | null;
191
218
  readonly derivedUsage: number;
219
+ /** Stable compile-local identity of the physical RHI allocation. */
220
+ readonly physicalAllocationKey?: string | undefined;
221
+ /** Descriptor-derived byte size when the format is uncompressed and known. */
222
+ readonly byteSize?: number | undefined;
223
+ /** Texture format retained for producer-owned resource inspection. */
224
+ readonly format?: TextureFormat | undefined;
225
+ /** Texture allocation facts retained for capability/lifetime inspection. */
226
+ readonly dimension?: GPUTextureDimension | undefined;
227
+ readonly extent?:
228
+ | { readonly width: number; readonly height: number; readonly depthOrArrayLayers: number }
229
+ | undefined;
192
230
  }[];
231
+ /** Monotonic graph generation assigned when this graph was compiled. */
232
+ readonly generation: number;
193
233
  }
194
234
 
195
235
  export interface RenderGraphFrame {