@forgeax/engine-render 0.1.31 → 0.1.32

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 (118) hide show
  1. package/README.md +68 -1
  2. package/dist/assembly/webgpu-ready.d.ts +2 -1
  3. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  4. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  5. package/dist/{chunk-C3DDSXYK.mjs → chunk-4NNZQCL3.mjs} +110 -35
  6. package/dist/chunk-4NNZQCL3.mjs.map +1 -0
  7. package/dist/{chunk-JRBDO2KB.mjs → chunk-6TFBXLRJ.mjs} +19 -7
  8. package/dist/chunk-6TFBXLRJ.mjs.map +1 -0
  9. package/dist/{chunk-FUUO4UND.mjs → chunk-DRR7D3KX.mjs} +3 -3
  10. package/dist/chunk-DRR7D3KX.mjs.map +1 -0
  11. package/dist/{chunk-S5UE2T5P.mjs → chunk-GWCEMDVR.mjs} +380 -4
  12. package/dist/chunk-GWCEMDVR.mjs.map +1 -0
  13. package/dist/{chunk-MDQJPY3T.mjs → chunk-JCIRYQ4V.mjs} +3 -3
  14. package/dist/{chunk-MDQJPY3T.mjs.map → chunk-JCIRYQ4V.mjs.map} +1 -1
  15. package/dist/{chunk-PSEQ6QPH.mjs → chunk-JRIPCMYC.mjs} +17 -10
  16. package/dist/chunk-JRIPCMYC.mjs.map +1 -0
  17. package/dist/{chunk-7MQ3XBSP.mjs → chunk-LULCA3OF.mjs} +4 -4
  18. package/dist/{chunk-7MQ3XBSP.mjs.map → chunk-LULCA3OF.mjs.map} +1 -1
  19. package/dist/{chunk-NO4WZCAH.mjs → chunk-S665NJNF.mjs} +3 -3
  20. package/dist/{chunk-NO4WZCAH.mjs.map → chunk-S665NJNF.mjs.map} +1 -1
  21. package/dist/components/depth-of-field.d.ts +28 -0
  22. package/dist/components/depth-of-field.d.ts.map +1 -0
  23. package/dist/components/directional-light.d.ts +2 -1
  24. package/dist/components/directional-light.d.ts.map +1 -1
  25. package/dist/components/index.d.ts +1 -0
  26. package/dist/components/index.d.ts.map +1 -1
  27. package/dist/construct-renderer.mjs +898 -328
  28. package/dist/construct-renderer.mjs.map +1 -1
  29. package/dist/extract/camera.d.ts.map +1 -1
  30. package/dist/features/depth-of-field/depth-of-field-assembly.d.ts +19 -0
  31. package/dist/features/depth-of-field/depth-of-field-assembly.d.ts.map +1 -0
  32. package/dist/features/depth-of-field/depth-of-field-feature.d.ts +43 -0
  33. package/dist/features/depth-of-field/depth-of-field-feature.d.ts.map +1 -0
  34. package/dist/features/depth-of-field/depth-of-field-inspection.d.ts +34 -0
  35. package/dist/features/depth-of-field/depth-of-field-inspection.d.ts.map +1 -0
  36. package/dist/features/depth-of-field/depth-of-field-params.d.ts +83 -0
  37. package/dist/features/depth-of-field/depth-of-field-params.d.ts.map +1 -0
  38. package/dist/features/types.d.ts +19 -1
  39. package/dist/features/types.d.ts.map +1 -1
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.mjs +6 -5
  43. package/dist/index.mjs.map +1 -1
  44. package/dist/inspection-types.d.ts +35 -0
  45. package/dist/inspection-types.d.ts.map +1 -1
  46. package/dist/instances.d.ts +7 -0
  47. package/dist/instances.d.ts.map +1 -1
  48. package/dist/internal.mjs +6 -6
  49. package/dist/mesh-material-bindings.d.ts +3 -0
  50. package/dist/mesh-material-bindings.d.ts.map +1 -1
  51. package/dist/pipeline/standard-output/graph.d.ts.map +1 -1
  52. package/dist/pipeline/standard-output/types.d.ts +4 -2
  53. package/dist/pipeline/standard-output/types.d.ts.map +1 -1
  54. package/dist/pipeline/standard-post.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/dist/record/frame-snapshot.d.ts +21 -1
  57. package/dist/record/frame-snapshot.d.ts.map +1 -1
  58. package/dist/record/frame.d.ts.map +1 -1
  59. package/dist/record/main-pass.d.ts.map +1 -1
  60. package/dist/record/typed-frame-graph.d.ts +7 -0
  61. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  62. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  63. package/dist/render-contract.d.ts +8 -1
  64. package/dist/render-contract.d.ts.map +1 -1
  65. package/dist/render-graph-primitives.d.ts +2 -0
  66. package/dist/render-graph-primitives.d.ts.map +1 -1
  67. package/dist/render-pipeline.d.ts +13 -1
  68. package/dist/render-pipeline.d.ts.map +1 -1
  69. package/dist/render-system-extract-tail.d.ts.map +1 -1
  70. package/dist/render-system-extract.d.ts +18 -4
  71. package/dist/render-system-extract.d.ts.map +1 -1
  72. package/dist/render-system.d.ts +2 -1
  73. package/dist/render-system.d.ts.map +1 -1
  74. package/dist/temporal/index.mjs +5 -5
  75. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  76. package/package.json +21 -21
  77. package/src/__tests__/gpu-driven-baseline.characterization.test.ts +12 -0
  78. package/src/__tests__/gpu-driven-production.integration.test.ts +27 -1
  79. package/src/__tests__/instances-store.unit.test.ts +15 -0
  80. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +28 -0
  81. package/src/assembly/webgpu-ready.ts +5 -5
  82. package/src/assembly/webgpu-renderer.ts +14 -14
  83. package/src/components/depth-of-field.ts +45 -0
  84. package/src/components/directional-light.ts +2 -1
  85. package/src/components/index.ts +1 -0
  86. package/src/extract/camera.ts +22 -2
  87. package/src/features/depth-of-field/__tests__/depth-of-field-params.unit.test.ts +943 -0
  88. package/src/features/depth-of-field/depth-of-field-assembly.ts +100 -0
  89. package/src/features/depth-of-field/depth-of-field-feature.ts +295 -0
  90. package/src/features/depth-of-field/depth-of-field-inspection.ts +237 -0
  91. package/src/features/depth-of-field/depth-of-field-params.ts +556 -0
  92. package/src/features/host.ts +1 -1
  93. package/src/features/types.ts +18 -1
  94. package/src/index.ts +32 -0
  95. package/src/inspection-types.ts +38 -0
  96. package/src/instances.ts +19 -5
  97. package/src/mesh-material-bindings.ts +4 -0
  98. package/src/pipeline/standard-output/graph.ts +3 -0
  99. package/src/pipeline/standard-output/types.ts +4 -1
  100. package/src/pipeline/standard-post.ts +21 -2
  101. package/src/plugin.ts +2 -0
  102. package/src/record/frame-snapshot.ts +26 -1
  103. package/src/record/frame.ts +39 -6
  104. package/src/record/main-pass.ts +1 -5
  105. package/src/record/typed-frame-graph.ts +106 -6
  106. package/src/recovery/render-system-candidate.ts +6 -0
  107. package/src/render-contract.ts +11 -0
  108. package/src/render-graph-primitives.ts +8 -1
  109. package/src/render-pipeline.ts +17 -1
  110. package/src/render-system-extract-tail.ts +87 -13
  111. package/src/render-system-extract.ts +103 -25
  112. package/src/render-system.ts +78 -0
  113. package/src/typed-render-graph-primitives.ts +3 -0
  114. package/dist/chunk-C3DDSXYK.mjs.map +0 -1
  115. package/dist/chunk-FUUO4UND.mjs.map +0 -1
  116. package/dist/chunk-JRBDO2KB.mjs.map +0 -1
  117. package/dist/chunk-PSEQ6QPH.mjs.map +0 -1
  118. package/dist/chunk-S5UE2T5P.mjs.map +0 -1
package/src/instances.ts CHANGED
@@ -502,17 +502,23 @@ export class InstanceCollectionStore {
502
502
  if (!record.ok) return record;
503
503
  const value = record.value;
504
504
  value.lastSnapshotRevision = value.revision;
505
- const result = {
506
- ...this.info(value),
507
- transforms: new Float32Array(value.transforms),
508
- dirtyRanges: value.dirtyRanges.map((range) => ({ ...range })),
509
- };
505
+ const result = this.detachedSnapshot(value);
510
506
  // Keep the current ranges stable for repeated reads by entities sharing
511
507
  // this collection. The next authoring mutation starts a fresh interval
512
508
  // set once this revision has been observed.
513
509
  return ok(result);
514
510
  }
515
511
 
512
+ /**
513
+ * Read a detached matrix view without advancing the render-consumption
514
+ * cursor. Inspection and picking may call this between authoring updates;
515
+ * the next renderer snapshot must still receive the complete dirty union.
516
+ */
517
+ peek(id: InstanceCollectionId): InstanceCollectionResult<InstanceCollectionSnapshot> {
518
+ const record = this.live('inspect', id);
519
+ return record.ok ? ok(this.detachedSnapshot(record.value)) : record;
520
+ }
521
+
516
522
  release(id: InstanceCollectionId): InstanceCollectionResult<void> {
517
523
  const record = this.records.get(id);
518
524
  if (record === undefined) return err(missing('release', id));
@@ -569,6 +575,14 @@ export class InstanceCollectionStore {
569
575
  return record === undefined ? err(missing(operation, id)) : ok(record);
570
576
  }
571
577
 
578
+ private detachedSnapshot(record: CollectionRecord): InstanceCollectionSnapshot {
579
+ return {
580
+ ...this.info(record),
581
+ transforms: new Float32Array(record.transforms),
582
+ dirtyRanges: record.dirtyRanges.map((range) => ({ ...range })),
583
+ };
584
+ }
585
+
572
586
  private info(record: CollectionRecord): InstanceCollectionInfo {
573
587
  return Object.freeze({
574
588
  collectionId: record.id,
@@ -112,6 +112,8 @@ export interface MeshMaterialBindingResidency {
112
112
  /** Public read-only observation of the bindings used by the last frame. */
113
113
  export interface MeshMaterialBindingObservation {
114
114
  readonly worldId: number;
115
+ /** Stable World identity for cross-world inspection; worldId is only a frame slot. */
116
+ readonly worldIdentity?: string;
115
117
  readonly entityKey: number;
116
118
  readonly bindings: readonly ResolvedMeshMaterialBinding[];
117
119
  readonly diagnostics: readonly MeshMaterialBindingDiagnostic[];
@@ -143,6 +145,7 @@ const EMPTY_RESIDENCY: MeshMaterialBindingResidency = Object.freeze({
143
145
  */
144
146
  export function projectMeshMaterialBindingObservation(input: {
145
147
  readonly worldId: number;
148
+ readonly worldIdentity?: string;
146
149
  readonly entityKey: number;
147
150
  readonly bindings: readonly ResolvedMeshMaterialBinding[];
148
151
  readonly diagnostics: readonly MeshMaterialBindingDiagnostic[];
@@ -168,6 +171,7 @@ export function projectMeshMaterialBindingObservation(input: {
168
171
  });
169
172
  return {
170
173
  worldId: input.worldId,
174
+ ...(input.worldIdentity === undefined ? {} : { worldIdentity: input.worldIdentity }),
171
175
  entityKey: input.entityKey,
172
176
  bindings: Object.freeze([...input.bindings]),
173
177
  diagnostics: Object.freeze([...input.diagnostics]),
@@ -26,6 +26,7 @@ function requestedStages(input: StandardOutputRequest): StandardOutputLogicalSta
26
26
  const stages: StandardOutputLogicalStage[] = [];
27
27
  if (input.temporal) stages.push('temporal');
28
28
  if (input.meter) stages.push('meter');
29
+ if (input.dof === true) stages.push('dof');
29
30
  if (input.bloom) stages.push('bloom');
30
31
  if (input.exposure || input.whiteBalance) stages.push('exposure-white-balance');
31
32
  stages.push('tone');
@@ -43,6 +44,7 @@ function physicalStages(
43
44
  case 'temporal':
44
45
  case 'meter':
45
46
  case 'bloom':
47
+ case 'dof':
46
48
  case 'exposure-white-balance':
47
49
  return { name, input: LINEAR_HDR_DOMAIN, output: LINEAR_HDR_DOMAIN };
48
50
  case 'tone':
@@ -68,6 +70,7 @@ export function createStandardOutputPlan(input: StandardOutputRequest): Standard
68
70
  temporal: input.temporal,
69
71
  meter: input.meter,
70
72
  bloom: input.bloom,
73
+ dof: input.dof === true,
71
74
  exposure: input.exposure,
72
75
  whiteBalance: input.whiteBalance,
73
76
  lut: input.lut,
@@ -3,6 +3,7 @@ import type { ColorValueDomain } from '@forgeax/engine-render-graph';
3
3
  export const STANDARD_OUTPUT_LOGICAL_STAGES = [
4
4
  'temporal',
5
5
  'meter',
6
+ 'dof',
6
7
  'bloom',
7
8
  'exposure-white-balance',
8
9
  'tone',
@@ -26,6 +27,8 @@ export interface StandardOutputRequest {
26
27
  readonly temporal: boolean;
27
28
  readonly meter: boolean;
28
29
  readonly bloom: boolean;
30
+ /** Built-in spatial DoF stage; false keeps the path exact-zero. */
31
+ readonly dof?: boolean;
29
32
  readonly exposure: boolean;
30
33
  readonly whiteBalance: boolean;
31
34
  readonly lut: boolean;
@@ -44,7 +47,7 @@ export interface StandardOutputPlan {
44
47
  readonly features: Readonly<
45
48
  Pick<
46
49
  StandardOutputRequest,
47
- 'temporal' | 'meter' | 'bloom' | 'exposure' | 'whiteBalance' | 'lut' | 'fxaa'
50
+ 'temporal' | 'meter' | 'bloom' | 'dof' | 'exposure' | 'whiteBalance' | 'lut' | 'fxaa'
48
51
  >
49
52
  >;
50
53
  readonly outputEncoding: StandardOutputEncoding;
@@ -7,6 +7,7 @@ import { RenderGraphError } from '@forgeax/engine-render-graph';
7
7
  import { err, ok, type Result } from '@forgeax/engine-types';
8
8
  import { addTypedDebugOverlayPass } from '../debug-draw-glue';
9
9
  import { SceneDataUnavailableError } from '../errors/render';
10
+ import { addDepthOfFieldPasses } from '../features/depth-of-field/depth-of-field-feature';
10
11
  import { addMotionBlurPass } from '../features/motion-blur/motion-blur-feature';
11
12
  import { GPU_TEXTURE_USAGE_COPY_SRC } from '../gpu-texture-usage';
12
13
  import type {
@@ -179,6 +180,7 @@ export function addStandardPost(
179
180
  temporal: !clearOnly && topology.camera.antialias === 'taa',
180
181
  meter: topology.output?.autoExposure === true,
181
182
  bloom: !clearOnly && topology.camera.bloom === 'on' && topology.camera.tonemap !== 'none',
183
+ dof: !clearOnly && topology.camera.depthOfField !== undefined,
182
184
  exposure: topology.output?.autoExposure === true,
183
185
  whiteBalance: topology.output?.whiteBalance === true,
184
186
  lut: topology.output?.colorLut === true,
@@ -313,8 +315,8 @@ export function addStandardPost(
313
315
  }
314
316
  }
315
317
 
316
- // Meter the temporally resolved color before Bloom. This preserves the
317
- // Standard order: temporal -> meter -> bloom -> exposure/WB -> tone -> LUT.
318
+ // Meter the temporally resolved color before DoF/Bloom. This preserves the
319
+ // Standard order: temporal -> motion -> meter -> DoF -> bloom -> output.
318
320
  if (outputPlan.features.exposure && autoExposure !== undefined) {
319
321
  const added = addAutoExposureGraphPasses(context, {
320
322
  source: postInput,
@@ -325,6 +327,23 @@ export function addStandardPost(
325
327
  if (!added.ok) return added;
326
328
  }
327
329
 
330
+ if (outputPlan.features.dof) {
331
+ const dof = addDepthOfFieldPasses(
332
+ {
333
+ graph: context.graph,
334
+ camera: context.camera ?? {},
335
+ temporalTaa: topology.temporal?.taa === true,
336
+ msaaActive: depth.sampleCount === 4,
337
+ ...(topology.extent === undefined ? {} : { extent: topology.extent }),
338
+ },
339
+ postInput,
340
+ depth,
341
+ topology.temporal?.taa === true ? postTemporal : undefined,
342
+ );
343
+ if (!dof.ok) return dof;
344
+ postInput = dof.value;
345
+ }
346
+
328
347
  if (bloomActive) {
329
348
  const composited = target(
330
349
  context,
package/src/plugin.ts CHANGED
@@ -2,6 +2,7 @@ import type { Component, World } from '@forgeax/engine-ecs';
2
2
  import type { Plugin } from '@forgeax/engine-plugin';
3
3
  import { Atmosphere } from './components/atmosphere';
4
4
  import { Camera } from './components/camera';
5
+ import { DepthOfField } from './components/depth-of-field';
5
6
  import { DirectionalLight } from './components/directional-light';
6
7
  import { DynamicResolution } from './components/dynamic-resolution';
7
8
  import { Instances } from './components/instances';
@@ -33,6 +34,7 @@ const RENDER_COMPONENTS: readonly Component[] = [
33
34
  Camera,
34
35
  DirectionalLight,
35
36
  DynamicResolution,
37
+ DepthOfField,
36
38
  Instances,
37
39
  Layer,
38
40
  LightProbe,
@@ -4,7 +4,10 @@
4
4
  import type { World } from '@forgeax/engine-ecs';
5
5
  import type { RenderReadVersion } from '@forgeax/engine-ecs/projection';
6
6
  import { vec3 } from '@forgeax/engine-math';
7
- import type { ResolvedColorTargetDescriptor } from '@forgeax/engine-render-graph';
7
+ import type {
8
+ CompiledRenderGraphInfo,
9
+ ResolvedColorTargetDescriptor,
10
+ } from '@forgeax/engine-render-graph';
8
11
  import type {
9
12
  BindGroup,
10
13
  BindGroupLayout,
@@ -17,6 +20,7 @@ import type { ClusterBinScratch } from '../cluster-binner';
17
20
  import type { DirectionalShadowQuality } from '../components/directional-shadow-filter';
18
21
  import type { EnvironmentGeneration } from '../environment/generation';
19
22
  import type { EnvironmentLifecycle } from '../environment/lifecycle';
23
+ import type { DepthOfFieldParams } from '../features/depth-of-field/depth-of-field-params';
20
24
  import type { GpuBuffer } from '../gpu-resource';
21
25
  import type { InstanceCollectionStore } from '../instances';
22
26
  import type { IblBindingInspection } from '../mesh-material-bindings';
@@ -452,6 +456,27 @@ export interface RenderFrameState {
452
456
  compiledFrameGraphTopologyKey: string | null;
453
457
  /** Monotonic generation of successfully promoted compiled graphs. */
454
458
  compiledFrameGraphGeneration: number;
459
+ /**
460
+ * DoF graph candidate staged until the surrounding frame transaction
461
+ * submits. The previous graph, access projection, generation, and lighting
462
+ * signature are restored together when encoding or submission aborts.
463
+ */
464
+ depthOfFieldPreviousGraph: CompiledRenderGraph<RenderPipelineFrame> | null;
465
+ depthOfFieldPreviousGraphKey: string | null;
466
+ depthOfFieldPreviousPerFrameGraph?: RenderFrameState['perFrameGraph'];
467
+ depthOfFieldPreviousGraphGeneration: number;
468
+ depthOfFieldPreviousStandardLightingGraphSignature: string;
469
+ depthOfFieldCandidateGraph: CompiledRenderGraph<RenderPipelineFrame> | null;
470
+ /** DoF graph/params detached at the same successful-submit boundary. */
471
+ depthOfFieldAccepted?:
472
+ | {
473
+ readonly params: DepthOfFieldParams;
474
+ readonly graph: CompiledRenderGraphInfo;
475
+ readonly deviceGeneration: number;
476
+ }
477
+ | undefined;
478
+ /** A DoF candidate was present when the frame failed to submit. */
479
+ depthOfFieldLastSubmitFailed?: boolean | undefined;
455
480
  /**
456
481
  * Last-known-good graph held while a volume topology candidate is tested.
457
482
  * The candidate is promoted only after its command buffer is submitted;
@@ -34,6 +34,14 @@ import {
34
34
  RendererOperationError,
35
35
  } from '../errors/render';
36
36
  import { gpuDrivenDrawKey, gpuDrivenSourceDrawItemIndex } from '../extract/gpu-driven';
37
+ import {
38
+ DEPTH_OF_FIELD_MSAA_POST_PROCESS_IDS,
39
+ DEPTH_OF_FIELD_POST_PROCESS_IDS,
40
+ } from '../features/depth-of-field/depth-of-field-feature';
41
+ import {
42
+ packDepthOfFieldParams,
43
+ resolveDepthOfFieldFrameParams,
44
+ } from '../features/depth-of-field/depth-of-field-params';
37
45
  import type { GpuDrivenProduction, PreparedGpuDrivenFrame } from '../gpu-driven/production-raster';
38
46
  import type { ShadowViewIdentity } from '../gpu-driven/shadow-views';
39
47
  import { GpuBuffer } from '../gpu-resource';
@@ -323,6 +331,7 @@ import {
323
331
  executeCompiledFrameGraph,
324
332
  getRenderFeatureGraphState,
325
333
  type RenderFeatureGraphCandidate,
334
+ settleDepthOfFieldGraphCandidate,
326
335
  settleVolumetricFogGraphCandidate,
327
336
  } from './typed-frame-graph';
328
337
  import {
@@ -1671,8 +1680,17 @@ export function recordFrame(
1671
1680
  }),
1672
1681
  );
1673
1682
  }
1674
- const camera = activeCameras[0];
1675
- if (!camera) return false;
1683
+ const requestedCamera = activeCameras[0];
1684
+ if (!requestedCamera) return false;
1685
+ const frameDepthOfField = resolveDepthOfFieldFrameParams(
1686
+ requestedCamera.depthOfField,
1687
+ requestedCamera.depthOfFieldError,
1688
+ frameState.depthOfFieldAccepted?.params,
1689
+ );
1690
+ const camera: CameraSnapshot =
1691
+ frameDepthOfField === undefined || frameDepthOfField === requestedCamera.depthOfField
1692
+ ? requestedCamera
1693
+ : { ...requestedCamera, depthOfField: frameDepthOfField };
1676
1694
  frameState.ssrRequested = camera.screenSpaceReflection !== undefined;
1677
1695
  const pipelineState = internals.getPipelineState();
1678
1696
  if (pipelineState === null) return false;
@@ -2643,6 +2661,20 @@ export function recordFrame(
2643
2661
  motionBlurView.setUint32(12, temporalView?.historyValid === true ? 0 : 1, true);
2644
2662
  framePostProcessParams.set('forgeax.motion-blur', motionBlurParams);
2645
2663
  }
2664
+ if (camera.depthOfField !== undefined) {
2665
+ const dofParams = packDepthOfFieldParams(camera.depthOfField, {
2666
+ outputHeight: targetH,
2667
+ near: camera.near,
2668
+ far: camera.far,
2669
+ useTemporalDepth: camera.antialias === 'taa' && temporalView !== undefined,
2670
+ });
2671
+ for (const id of [
2672
+ ...DEPTH_OF_FIELD_POST_PROCESS_IDS,
2673
+ ...DEPTH_OF_FIELD_MSAA_POST_PROCESS_IDS,
2674
+ ]) {
2675
+ framePostProcessParams.set(id, dofParams);
2676
+ }
2677
+ }
2646
2678
 
2647
2679
  const passCtx: _InternalRenderPipelineContext = {
2648
2680
  assets: internals.assets,
@@ -2925,11 +2957,12 @@ export function recordFrame(
2925
2957
  frameState.ssrHistoryOwner?.abortFrame(frameState.ssrHistoryCandidate, 'submit');
2926
2958
  frameState.ssrHistoryCandidate = undefined;
2927
2959
  }
2928
- // Volume topology replacements are provisional until the queue submission
2929
- // succeeds. This runs for every early-return path as well, so a swapchain,
2930
- // encoder, or graph failure cannot leave a non-submitted candidate as the
2931
- // next frame's accepted graph.
2960
+ // DoF and volume topology replacements are provisional until the queue
2961
+ // submission succeeds. This runs for every early-return path as well, so a
2962
+ // swapchain, encoder, or graph failure cannot leave a non-submitted
2963
+ // candidate as the next frame's accepted graph.
2932
2964
  settleVolumetricFogGraphCandidate(frameState, submitted);
2965
+ settleDepthOfFieldGraphCandidate(frameState, submitted);
2933
2966
  if (volumeInspectionReady && (submitted || volumeSubmissionAttempted)) {
2934
2967
  updateVolumetricFogInspection(
2935
2968
  frameState,
@@ -676,11 +676,7 @@ export function recordMainPass(
676
676
  hint: 'publish the current frame view/light/shadow bind group before GPU raster',
677
677
  });
678
678
  }
679
- if (
680
- gpuDrivenStandardPbrFrameResources === undefined ||
681
- gpuDrivenStandardPbrFrameResources.materialBindGroups.length === 0 ||
682
- gpuDrivenStandardPbrFrameResources.materialBindGroups.some((group) => group === undefined)
683
- ) {
679
+ if (gpuDrivenStandardPbrFrameResources === undefined) {
684
680
  throw new RhiError({
685
681
  code: 'rhi-not-available',
686
682
  expected: 'complete main-pass Standard PBR material bind-group receipt',
@@ -23,6 +23,8 @@ import { RhiError } from '@forgeax/engine-rhi';
23
23
  import { err, ok, type Result } from '@forgeax/engine-types';
24
24
  import { executeRendererFrameTransaction } from '../assembly/renderer-frame-transaction';
25
25
  import { type RenderError, RenderFeatureStageFailedError } from '../errors/render';
26
+ import { depthOfFieldTopology } from '../features/depth-of-field/depth-of-field-feature';
27
+ import { resolveDepthOfFieldFrameParams } from '../features/depth-of-field/depth-of-field-params';
26
28
  import {
27
29
  getRenderFeaturePlanExecutionProjection,
28
30
  hasSubmissionSensitiveFeatures,
@@ -1419,6 +1421,7 @@ function topologyOf(
1419
1421
  : undefined;
1420
1422
  const preparedLut =
1421
1423
  frameState.pendingStandardLutGpuResources ?? frameState.standardLutGpuResources;
1424
+ const depthOfField = depthOfFieldTopology(camera.depthOfField);
1422
1425
  return ok({
1423
1426
  pipelineId: STANDARD_PIPELINE_ID,
1424
1427
  standardProfile: internals.standardProfile,
@@ -1438,6 +1441,7 @@ function topologyOf(
1438
1441
  tonemap: camera.tonemap,
1439
1442
  antialias: camera.antialias,
1440
1443
  bloom: camera.bloom,
1444
+ ...(depthOfField === undefined ? {} : { depthOfField }),
1441
1445
  },
1442
1446
  atmosphere: !clearOnly && frameState.environmentFrame?.source.kind === 'atmosphere',
1443
1447
  output: {
@@ -1754,18 +1758,66 @@ export function settleVolumetricFogGraphCandidate(
1754
1758
  if (candidate === null) return;
1755
1759
  const previous = frameState.volumetricFogPreviousGraph ?? null;
1756
1760
  const previousKey = frameState.volumetricFogPreviousGraphKey ?? null;
1761
+ // A frame can replace both the volume and DoF topology at once. DoF owns
1762
+ // the shared compiled graph's full projection (per-frame resolver,
1763
+ // generation, and lighting signature), so let it settle the graph exactly
1764
+ // once while volume clears only its own candidate marker.
1765
+ const sharedWithDepthOfField = frameState.depthOfFieldCandidateGraph === candidate;
1757
1766
  if (submitted) {
1758
- if (previous !== null && previous !== candidate) retire(frameState, previous);
1767
+ if (!sharedWithDepthOfField && previous !== null && previous !== candidate) {
1768
+ retire(frameState, previous);
1769
+ }
1759
1770
  } else {
1760
- if (candidate !== previous) retire(frameState, candidate);
1761
- frameState.compiledFrameGraph = previous;
1762
- frameState.compiledFrameGraphTopologyKey = previousKey;
1771
+ if (!sharedWithDepthOfField) {
1772
+ if (candidate !== previous) retire(frameState, candidate);
1773
+ frameState.compiledFrameGraph = previous;
1774
+ frameState.compiledFrameGraphTopologyKey = previousKey;
1775
+ }
1763
1776
  }
1764
1777
  frameState.volumetricFogPreviousGraph = null;
1765
1778
  frameState.volumetricFogPreviousGraphKey = null;
1766
1779
  frameState.volumetricFogCandidateGraph = null;
1767
1780
  }
1768
1781
 
1782
+ /**
1783
+ * Settle a DoF graph replacement at the same submit barrier as the frame.
1784
+ * Graph compilation publishes a candidate for the current record pass, but a
1785
+ * failed encode/finish/submit must restore every graph-owned LKG projection
1786
+ * before the next frame can inspect or reuse it.
1787
+ */
1788
+ export function settleDepthOfFieldGraphCandidate(
1789
+ frameState: RenderFrameState,
1790
+ submitted: boolean,
1791
+ ): void {
1792
+ const candidate = frameState.depthOfFieldCandidateGraph;
1793
+ if (candidate === null || candidate === undefined) return;
1794
+ // Sparse synthetic frame states used by graph tests predate the explicit
1795
+ // transaction fields. Treat omitted pending values as the empty LKG rather
1796
+ // than passing undefined into retirement or restoring an invalid key.
1797
+ const previous = frameState.depthOfFieldPreviousGraph ?? null;
1798
+ if (submitted) {
1799
+ if (previous !== null && previous !== candidate) retire(frameState, previous);
1800
+ } else {
1801
+ if (candidate !== previous) retire(frameState, candidate);
1802
+ frameState.compiledFrameGraph = previous;
1803
+ frameState.compiledFrameGraphTopologyKey = frameState.depthOfFieldPreviousGraphKey ?? null;
1804
+ if (frameState.depthOfFieldPreviousPerFrameGraph === undefined) {
1805
+ delete frameState.perFrameGraph;
1806
+ } else {
1807
+ frameState.perFrameGraph = frameState.depthOfFieldPreviousPerFrameGraph;
1808
+ }
1809
+ frameState.graphGeneration = frameState.depthOfFieldPreviousGraphGeneration ?? 0;
1810
+ frameState.standardLightingGraphSignature =
1811
+ frameState.depthOfFieldPreviousStandardLightingGraphSignature ?? '';
1812
+ }
1813
+ frameState.depthOfFieldPreviousGraph = null;
1814
+ frameState.depthOfFieldPreviousGraphKey = null;
1815
+ frameState.depthOfFieldPreviousPerFrameGraph = undefined;
1816
+ frameState.depthOfFieldPreviousGraphGeneration = 0;
1817
+ frameState.depthOfFieldPreviousStandardLightingGraphSignature = '';
1818
+ frameState.depthOfFieldCandidateGraph = null;
1819
+ }
1820
+
1769
1821
  export function ensureCompiledFrameGraph(
1770
1822
  internals: RenderSystemInternals,
1771
1823
  frameState: RenderFrameState,
@@ -1791,6 +1843,18 @@ export function ensureCompiledFrameGraph(
1791
1843
  const featureMetrics = mutableFeatureGraphInspection(internals);
1792
1844
  featureMetrics.ensureCalls += 1;
1793
1845
  const requestedStandardLightingSignature = standardLightingTopologySignature(standardLighting);
1846
+ const frameDepthOfField = resolveDepthOfFieldFrameParams(
1847
+ camera.depthOfField,
1848
+ camera.depthOfFieldError,
1849
+ frameState.depthOfFieldAccepted?.params,
1850
+ );
1851
+ // A rejected authoring request remains visible on the source camera, while
1852
+ // an accepted graph continues to receive its own params until a valid
1853
+ // replacement or explicit component removal is submitted.
1854
+ const graphCamera: CameraSnapshot =
1855
+ frameDepthOfField === undefined || frameDepthOfField === camera.depthOfField
1856
+ ? camera
1857
+ : { ...camera, depthOfField: frameDepthOfField };
1794
1858
  const lastKnownGood = (): CompiledRenderGraph<RenderPipelineFrame> | null =>
1795
1859
  frameState.compiledFrameGraph !== null &&
1796
1860
  !hasSubmissionSensitiveFeatures(getRenderFeatureGraphState(internals).plans) &&
@@ -1825,7 +1889,7 @@ export function ensureCompiledFrameGraph(
1825
1889
  internals,
1826
1890
  frameState,
1827
1891
  pipelineState,
1828
- camera,
1892
+ graphCamera,
1829
1893
  clearOnly,
1830
1894
  lights,
1831
1895
  surfaceWidth,
@@ -2032,6 +2096,7 @@ export function ensureCompiledFrameGraph(
2032
2096
  const built = frameState.activePipeline.build(
2033
2097
  {
2034
2098
  graph: builder,
2099
+ camera: { depthOfField: graphCamera.depthOfField },
2035
2100
  observationCaptureDomains: internals.observationCaptureDomains,
2036
2101
  ...(autoExposureResources?.ok === true
2037
2102
  ? { standardOutput: { autoExposure: autoExposureResources.value } }
@@ -2172,10 +2237,32 @@ export function ensureCompiledFrameGraph(
2172
2237
  return lastKnownGood();
2173
2238
  }
2174
2239
  const previous = frameState.compiledFrameGraph;
2240
+ const depthOfFieldReplacement =
2241
+ (frameState.depthOfFieldCandidateGraph === null ||
2242
+ frameState.depthOfFieldCandidateGraph === undefined) &&
2243
+ (topologyResult.value.camera.depthOfField !== undefined ||
2244
+ frameState.depthOfFieldAccepted !== undefined);
2175
2245
  if (volumeTopologyCandidate) {
2176
2246
  frameState.volumetricFogPreviousGraph = previous;
2177
2247
  frameState.volumetricFogPreviousGraphKey = frameState.compiledFrameGraphTopologyKey;
2178
2248
  frameState.volumetricFogCandidateGraph = compiled.value;
2249
+ if (depthOfFieldReplacement) {
2250
+ frameState.depthOfFieldPreviousGraph = previous;
2251
+ frameState.depthOfFieldPreviousGraphKey = frameState.compiledFrameGraphTopologyKey;
2252
+ frameState.depthOfFieldPreviousPerFrameGraph = frameState.perFrameGraph;
2253
+ frameState.depthOfFieldPreviousGraphGeneration = frameState.graphGeneration;
2254
+ frameState.depthOfFieldPreviousStandardLightingGraphSignature =
2255
+ frameState.standardLightingGraphSignature;
2256
+ frameState.depthOfFieldCandidateGraph = compiled.value;
2257
+ }
2258
+ } else if (depthOfFieldReplacement) {
2259
+ frameState.depthOfFieldPreviousGraph = previous;
2260
+ frameState.depthOfFieldPreviousGraphKey = frameState.compiledFrameGraphTopologyKey;
2261
+ frameState.depthOfFieldPreviousPerFrameGraph = frameState.perFrameGraph;
2262
+ frameState.depthOfFieldPreviousGraphGeneration = frameState.graphGeneration;
2263
+ frameState.depthOfFieldPreviousStandardLightingGraphSignature =
2264
+ frameState.standardLightingGraphSignature;
2265
+ frameState.depthOfFieldCandidateGraph = compiled.value;
2179
2266
  }
2180
2267
  frameState.compiledFrameGraph = compiled.value;
2181
2268
  frameState.compiledFrameGraphTopologyKey = key;
@@ -2209,7 +2296,9 @@ export function ensureCompiledFrameGraph(
2209
2296
  } else if (featureGraphCandidate !== undefined) {
2210
2297
  recordRenderFeatureCandidateEvent(internals, featureGraphCandidate, 'abandoned');
2211
2298
  }
2212
- if (!volumeTopologyCandidate && previous !== null) retire(frameState, previous);
2299
+ if (!volumeTopologyCandidate && !depthOfFieldReplacement && previous !== null) {
2300
+ retire(frameState, previous);
2301
+ }
2213
2302
  return compiled.value;
2214
2303
  }
2215
2304
 
@@ -2245,6 +2334,7 @@ export function executeCompiledFrameGraph(
2245
2334
  frameState.pendingAutoExposureState = undefined;
2246
2335
  frameState.pendingStandardLutState = undefined;
2247
2336
  settleVolumetricFogGraphCandidate(frameState, false);
2337
+ settleDepthOfFieldGraphCandidate(frameState, false);
2248
2338
  rejectTemporalFrame();
2249
2339
  return false;
2250
2340
  }
@@ -2308,6 +2398,8 @@ export function executeCompiledFrameGraph(
2308
2398
  },
2309
2399
  }),
2310
2400
  );
2401
+ settleVolumetricFogGraphCandidate(frameState, false);
2402
+ settleDepthOfFieldGraphCandidate(frameState, false);
2311
2403
  return false;
2312
2404
  }
2313
2405
  const capturedGeneration = deviceScope?.generation;
@@ -3000,6 +3092,8 @@ export function executeCompiledFrameGraph(
3000
3092
  frameState.pendingAtmospherePublish?.();
3001
3093
  frameState.pendingAtmospherePublish = undefined;
3002
3094
  onSubmitted?.(internals.device.queue.onSubmittedWorkDone());
3095
+ settleVolumetricFogGraphCandidate(frameState, true);
3096
+ settleDepthOfFieldGraphCandidate(frameState, true);
3003
3097
  },
3004
3098
  abort: (failure) => {
3005
3099
  frameState.pendingAtmospherePublish = undefined;
@@ -3038,9 +3132,15 @@ export function executeCompiledFrameGraph(
3038
3132
  frameState.environmentLifecycle?.discard(frameState.environmentGeneration);
3039
3133
  frameState.environmentGeneration = undefined;
3040
3134
  }
3135
+ settleVolumetricFogGraphCandidate(frameState, false);
3136
+ settleDepthOfFieldGraphCandidate(frameState, false);
3041
3137
  },
3042
3138
  });
3139
+ // The callbacks above settle the normal transaction path. Keep this guard
3140
+ // for synthetic callers and any future early transaction result that does
3141
+ // not enter a callback.
3043
3142
  settleVolumetricFogGraphCandidate(frameState, transaction.ok);
3143
+ settleDepthOfFieldGraphCandidate(frameState, transaction.ok);
3044
3144
  if (!transaction.ok) rejectTemporalFrame();
3045
3145
  return transaction.ok;
3046
3146
  }
@@ -232,6 +232,12 @@ export function createRenderSystemRecovery(
232
232
  directionalShadowCacheRecorded: false,
233
233
  compiledFrameGraph: null,
234
234
  compiledFrameGraphTopologyKey: null,
235
+ depthOfFieldPreviousGraph: null,
236
+ depthOfFieldPreviousGraphKey: null,
237
+ depthOfFieldPreviousPerFrameGraph: undefined,
238
+ depthOfFieldPreviousGraphGeneration: 0,
239
+ depthOfFieldPreviousStandardLightingGraphSignature: '',
240
+ depthOfFieldCandidateGraph: null,
235
241
  volumetricFogPreviousGraph: null,
236
242
  volumetricFogPreviousGraphKey: null,
237
243
  volumetricFogCandidateGraph: null,
@@ -31,10 +31,15 @@ import type { EnvironmentInspection } from './environment/inspection.js';
31
31
  import type { GpuDrivenPreparationError } from './errors/gpu-driven.js';
32
32
  import type { RecoveryOutcome, RecoveryPhase } from './errors/recover.js';
33
33
  import type { RenderError } from './errors/render.js';
34
+ import type {
35
+ DepthOfFieldParams,
36
+ DepthOfFieldRequestFailure,
37
+ } from './features/depth-of-field/depth-of-field-params.js';
34
38
  import type { RenderFeature, RenderFeatureDiagnostics } from './features/types.js';
35
39
  import type { PostProcessShaderEntry } from './fullscreen-post-process-pass.js';
36
40
  import type {
37
41
  BloomInspection,
42
+ DepthOfFieldInspection,
38
43
  DirectionalShadowInspection,
39
44
  LightInspection,
40
45
  LodOcclusionInspection,
@@ -171,6 +176,10 @@ export interface CameraSnapshot {
171
176
  readonly screenSpaceReflection?: ScreenSpaceReflectionData;
172
177
  /** Renderer-owned temporal projection used by Standard's TAA producer. */
173
178
  readonly temporal?: TemporalView;
179
+ /** Active-camera depth-of-field parameters, present only when demanded. */
180
+ readonly depthOfField?: DepthOfFieldParams;
181
+ /** Structured DoF validation facts; invalid requests remain inspectable. */
182
+ readonly depthOfFieldError?: DepthOfFieldRequestFailure;
174
183
  /** Active-camera motion blur parameters, present only when demanded. */
175
184
  readonly motionBlur?: {
176
185
  readonly shutterAngle: number;
@@ -776,6 +785,8 @@ export interface RenderInspection {
776
785
  readonly temporalTarget?: TemporalTargetInspection;
777
786
  /** Renderer-owned Motion Blur facts; absent when no component is active. */
778
787
  readonly motionBlur?: MotionBlurInspection;
788
+ /** Renderer-owned Depth of Field facts; absent when no component is active. */
789
+ readonly depthOfField?: DepthOfFieldInspection;
779
790
  /** Bounded renderer-owned LOD/occlusion facts; absent before a candidate frame. */
780
791
  readonly lodOcclusion?: LodOcclusionInspection;
781
792
  }
@@ -577,6 +577,7 @@ function dispatchFullscreenPass(
577
577
  graphDepthView?: TextureView,
578
578
  paramsOverride?: Uint8Array,
579
579
  fragmentEntryPoint?: string,
580
+ msaaActive = false,
580
581
  ): void {
581
582
  if (shader === 'fxaa') {
582
583
  if (resolveCtx === undefined) {
@@ -643,7 +644,7 @@ function dispatchFullscreenPass(
643
644
  const built = buildFullscreenPostProcessPass(
644
645
  { device: ctx.runtime.device, errorRegistry: ctx.runtime.errorRegistry },
645
646
  entry,
646
- ctx.msaaActive,
647
+ msaaActive,
647
648
  );
648
649
  if (built === null) return;
649
650
 
@@ -842,6 +843,8 @@ export function encodeFullscreenPass(
842
843
  readonly depthView?: TextureView | undefined;
843
844
  readonly paramsOverride?: Uint8Array | undefined;
844
845
  readonly fragmentEntryPoint?: string | undefined;
846
+ /** The depth target's actual sample count, when this typed pass reads depth. */
847
+ readonly msaaActive?: boolean | undefined;
845
848
  },
846
849
  ): void {
847
850
  dispatchFullscreenPass(
@@ -858,5 +861,9 @@ export function encodeFullscreenPass(
858
861
  input.depthView,
859
862
  input.paramsOverride,
860
863
  input.fragmentEntryPoint,
864
+ // Legacy callers rely on the frame context's MSAA state. Typed callers
865
+ // pass the actual depth target sample count explicitly so a single-sample
866
+ // view can never select the multisampled shader by camera policy alone.
867
+ input.msaaActive ?? ctx.msaaActive,
861
868
  );
862
869
  }
@@ -37,6 +37,7 @@ import type {
37
37
  } from '@forgeax/engine-rhi';
38
38
  import { ok, type Result } from '@forgeax/engine-types';
39
39
  import type { Tonemap } from './components/camera';
40
+ import type { DepthOfFieldSide } from './components/depth-of-field';
40
41
  import type { DirectionalShadowFilterLabel } from './components/directional-shadow-filter';
41
42
  import type { RenderError } from './errors/render';
42
43
  import type { RenderFeatureTargetKind } from './features/targets';
@@ -234,7 +235,16 @@ export interface RenderPipelineTopology {
234
235
  };
235
236
  /** Renderer-owned immutable dimensions for Standard graph consumers. */
236
237
  readonly extent?: RenderExtent;
237
- readonly camera: Pick<RenderPipelineContext['camera'], 'tonemap' | 'antialias' | 'bloom'>;
238
+ readonly camera: Pick<RenderPipelineContext['camera'], 'tonemap' | 'antialias' | 'bloom'> & {
239
+ /** Shape-only DoF admission; optical values remain per-frame UBO data. */
240
+ readonly depthOfField?:
241
+ | {
242
+ readonly blurSide: DepthOfFieldSide;
243
+ readonly useNear: boolean;
244
+ readonly useFar: boolean;
245
+ }
246
+ | undefined;
247
+ };
238
248
  /** Frame-stable Standard output policy; no raw RHI handles enter topology. */
239
249
  readonly output?: {
240
250
  readonly autoExposure: boolean;
@@ -475,6 +485,12 @@ export interface GpuDrivenStandardPbrFrameResources {
475
485
 
476
486
  export interface RenderPipelineBuildContext<FrameCtx extends RenderPipelineFrame> {
477
487
  readonly graph: RenderGraphBuilder<FrameCtx>;
488
+ /** Per-frame camera values consumed by optical fullscreen features. */
489
+ readonly camera?: {
490
+ readonly depthOfField?:
491
+ | import('./features/depth-of-field/depth-of-field-params').DepthOfFieldParams
492
+ | undefined;
493
+ };
478
494
  /** Optional Standard-only output-domain coverage producer input. */
479
495
  readonly targetCoverage?: import('./temporal/target-coverage-attachment').TargetCoverageAttachment;
480
496
  /** Explicit receipt-bound color domains requested for this frame. */