@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
@@ -120,6 +120,7 @@ import type {
120
120
  ExtractPipelineSurface,
121
121
  InstancesSnapshot,
122
122
  LightProbeSnapshot,
123
+ MaterialProgramSelection,
123
124
  MaterialSnapshot,
124
125
  MaterialSnapshotCache,
125
126
  MaterialSnapshotCachesByWorld,
@@ -161,7 +162,7 @@ import {
161
162
  materialColorParameterSchema,
162
163
  materialNormalScale,
163
164
  materialParamSchemaForMaterial,
164
- materialProgramKeysForMaterial,
165
+ materialProgramSelectionsForMaterial,
165
166
  materialSceneIndexProgramKeysForMaterial,
166
167
  materialStandardTextureMask,
167
168
  materialTextureFields,
@@ -191,6 +192,25 @@ function skinPaletteIdentity(world: World, entity: number): string {
191
192
  return `skin:${world.identity}:${entity}`;
192
193
  }
193
194
 
195
+ function materialProgramSelectionRequiresSkin(selection: {
196
+ readonly abi?: {
197
+ readonly skinPaletteAddress?: unknown;
198
+ readonly vertexInputs?: readonly { readonly semantic?: unknown }[];
199
+ };
200
+ }): boolean {
201
+ if (selection.abi?.skinPaletteAddress !== undefined) return true;
202
+ const semantics = new Set((selection.abi?.vertexInputs ?? []).map((input) => input.semantic));
203
+ return semantics.has('skinIndex') && semantics.has('skinWeight');
204
+ }
205
+
206
+ function isMissingMaterialProgram(error: unknown): boolean {
207
+ return (
208
+ error !== null &&
209
+ typeof error === 'object' &&
210
+ (error as { readonly code?: unknown }).code === 'material-specialization-not-cooked'
211
+ );
212
+ }
213
+
194
214
  export function extractFrames(
195
215
  worlds: readonly World[],
196
216
  owner: number | ExtractFramesOwner,
@@ -482,8 +502,8 @@ export function extractFrames(
482
502
  // bug-20260710-editor-cross-world-shadow: RECOMPUTE directional CSM matrices
483
503
  // against the surfaced camera. In a single-world app the per-world extract
484
504
  // already produced correct matrices (light+camera share the world), and this
485
- // recompute reproduces them byte-identically (same config, same camera). In
486
- // the editor super-composite the directional light's world has NO camera, so
505
+ // recompute uses the same config and camera. In the editor super-composite
506
+ // the directional light's world has NO camera, so
487
507
  // its per-world `lightViewProj` is undefined/degenerate; the surfaced camera
488
508
  // lives in the cameraOwner world. Pairing them here is the ONLY place both
489
509
  // are visible. Point/spot shadows are camera-independent (light-space only)
@@ -1226,9 +1246,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
1226
1246
  // bug-20260710-editor-cross-world-shadow: CSM matrices are computed by the
1227
1247
  // shared pure {@link computeDirectionalCsm}, called here per-world with THIS
1228
1248
  // world's own `cameras[0]`. In a single-world app the light and camera share
1229
- // the world, so this per-world result is final (byte-identical to the prior
1230
- // inline block). In the editor super-composite the light's world may have no
1231
- // camera → this yields no matrices; {@link extractFrames} then RECOMPUTES at
1249
+ // the world, so this per-world result is final. In the editor
1250
+ // super-composite the light's world may have no camera → this yields no
1251
+ // matrices; {@link extractFrames} then RECOMPUTES at
1232
1252
  // the merge layer using the surfaced (cameraOwner) camera + the raw config
1233
1253
  // carried on ExtractedLights. The raw config + direction are surfaced
1234
1254
  // unconditionally so the merge layer can re-run the builder.
@@ -2030,6 +2050,7 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2030
2050
  : undefined;
2031
2051
 
2032
2052
  let materialSnap: MaterialSnapshot;
2053
+ let materialProgramSelections: readonly MaterialProgramSelection[] | undefined;
2033
2054
 
2034
2055
  if (cachedMaterial !== undefined) {
2035
2056
  materialSnap = cachedMaterial.snapshot;
@@ -2143,11 +2164,59 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2143
2164
  ...context.materialContext,
2144
2165
  geometry: hasSkin ? ('skinned' as const) : context.materialContext.geometry,
2145
2166
  };
2146
- const materialProgramKeys = materialProgramKeysForMaterial(
2147
- asset,
2148
- assets,
2149
- materialContext,
2150
- );
2167
+ try {
2168
+ materialProgramSelections = materialProgramSelectionsForMaterial(
2169
+ asset,
2170
+ assets,
2171
+ materialContext,
2172
+ );
2173
+ } catch (error) {
2174
+ // A standalone mesh can only satisfy a cooked skinned program
2175
+ // through a Skin-bearing mesh. Probe that same exact owner
2176
+ // selection only when the resolved mesh really lacks the
2177
+ // advertised attributes; otherwise retain the producer's exact
2178
+ // publication error instead of guessing from module names/order.
2179
+ if (isMissingMaterialProgram(error) && materialContext?.geometry === 'mesh') {
2180
+ try {
2181
+ const skinnedSelections = materialProgramSelectionsForMaterial(asset, assets, {
2182
+ ...materialContext,
2183
+ geometry: 'skinned',
2184
+ });
2185
+ if (
2186
+ skinnedSelections?.some(materialProgramSelectionRequiresSkin) === true &&
2187
+ gpuDrivenMesh !== undefined
2188
+ ) {
2189
+ const hasSkinIndex = gpuDrivenMesh.attributes.skinIndex !== undefined;
2190
+ const hasSkinWeight = gpuDrivenMesh.attributes.skinWeight !== undefined;
2191
+ if (!hasSkinIndex || !hasSkinWeight) {
2192
+ const missing =
2193
+ !hasSkinIndex && !hasSkinWeight
2194
+ ? 'both'
2195
+ : !hasSkinIndex
2196
+ ? 'skinIndex'
2197
+ : 'skinWeight';
2198
+ worldInternal._routeError(new MaterialSkinAttrMissingError(entity, missing), {
2199
+ severity: Severity.Error,
2200
+ systemName: 'RenderSystem.extract (material-skin-attr-missing)',
2201
+ });
2202
+ continue;
2203
+ }
2204
+ }
2205
+ } catch {
2206
+ // Preserve the original exact-context publication error.
2207
+ }
2208
+ }
2209
+ throw error;
2210
+ }
2211
+ const materialProgramKeys =
2212
+ materialProgramSelections === undefined
2213
+ ? undefined
2214
+ : Object.fromEntries(
2215
+ materialProgramSelections.map(({ pass, specializationKey }) => [
2216
+ pass,
2217
+ specializationKey,
2218
+ ]),
2219
+ );
2151
2220
  const materialSceneIndexProgramKeys = materialSceneIndexProgramKeysForMaterial(
2152
2221
  asset,
2153
2222
  assets,
@@ -2208,7 +2277,9 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2208
2277
  skinSkeletonView !== undefined &&
2209
2278
  skinSkeletonView !== undefined &&
2210
2279
  skinSkeletonView !== 0;
2211
- const isPbrSkinMaterial = isStandardPbrSkinMaterialShader(firstPassShader);
2280
+ const isPbrSkinMaterial =
2281
+ materialProgramSelections?.some(materialProgramSelectionRequiresSkin) === true ||
2282
+ isStandardPbrSkinMaterialShader(firstPassShader);
2212
2283
  if (hasSkinSkel && !isPbrSkinMaterial) {
2213
2284
  worldInternal._routeError(new SkinMaterialMismatchError(entity, firstPassShader), {
2214
2285
  severity: Severity.Error,
@@ -2759,7 +2830,10 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2759
2830
  // reinterpret a skinned vertex stream through an ordinary mesh group.
2760
2831
  if (skinSlice === undefined && hasSkin) {
2761
2832
  releaseSkinIdentity();
2762
- if (materialSnap.materialShaderId === 'forgeax::pbr-skin') {
2833
+ if (
2834
+ materialSnap.materialShaderId === 'forgeax::pbr-skin' ||
2835
+ materialProgramSelections?.some(materialProgramSelectionRequiresSkin) === true
2836
+ ) {
2763
2837
  pendingDispatch.length = 0;
2764
2838
  continue;
2765
2839
  }
@@ -116,6 +116,7 @@ import type {
116
116
  MaterialColorParameterSchema,
117
117
  MaterialParameter,
118
118
  MaterialPass,
119
+ MaterialProgramAbi,
119
120
  MaterialRenderState,
120
121
  MaterialTextureCoordinates,
121
122
  MaterialTextureValue,
@@ -149,6 +150,7 @@ import {
149
150
  tonemapToU32,
150
151
  validateDynamicResolutionCamera,
151
152
  } from './components';
153
+ import { DepthOfField } from './components/depth-of-field';
152
154
  import type { DirectionalShadowQuality } from './components/directional-shadow-filter';
153
155
  import type { LightValidationError } from './components/light-helpers';
154
156
  import { selectEnvironment } from './environment/frame';
@@ -165,6 +167,10 @@ import type {
165
167
  } from './extract/environment';
166
168
  import type { GpuDrivenDrawSnapshot } from './extract/gpu-driven';
167
169
  import { resolveVisibility, type VisibilitySnapshot } from './extract/visibility';
170
+ import {
171
+ depthOfFieldRequestFailure,
172
+ resolveDepthOfFieldParams,
173
+ } from './features/depth-of-field/depth-of-field-params';
168
174
  import type {
169
175
  RenderFeatureHiddenEntityReport,
170
176
  RenderFeatureWorldVisibilitySnapshot,
@@ -1543,6 +1549,53 @@ export function materialProgramKeysForMaterial(
1543
1549
  return materialProgramKeysForAddress(material, assets, context, 'direct');
1544
1550
  }
1545
1551
 
1552
+ /**
1553
+ * Return the exact direct/scene-index programs selected for one renderer
1554
+ * context. The selection result is kept next to the key projection so
1555
+ * consumers that validate a mesh contract can inspect the producer ABI
1556
+ * without rebuilding pass/context matching from payload order.
1557
+ */
1558
+ export interface MaterialProgramSelection {
1559
+ readonly pass: string;
1560
+ readonly context: MaterialCookProgramContext;
1561
+ readonly specializationKey: string;
1562
+ readonly abi?: MaterialProgramAbi;
1563
+ }
1564
+
1565
+ export function materialProgramSelectionsForMaterial(
1566
+ material: MaterialAsset,
1567
+ assets: AssetRegistry,
1568
+ context: MaterialCookProgramContext | undefined,
1569
+ address: 'direct' | 'scene-index' = 'direct',
1570
+ ): readonly MaterialProgramSelection[] | undefined {
1571
+ const projection = assets.getMaterialProjectionForPayload(material);
1572
+ if (projection === undefined) return undefined;
1573
+ if (context === undefined)
1574
+ throw new Error('Published material selection requires renderer-owned compiler context');
1575
+ return projection.passes.map((pass) => {
1576
+ const mode = String(
1577
+ (pass.renderState?.tags as Record<string, unknown> | undefined)?.LightMode ?? pass.name,
1578
+ );
1579
+ const passKind = /shadow/i.test(mode)
1580
+ ? ('shadow' as const)
1581
+ : /depth/i.test(mode)
1582
+ ? ('depth' as const)
1583
+ : ('forward' as const);
1584
+ const selectedContext: MaterialCookProgramContext = {
1585
+ ...context,
1586
+ pass: passKind,
1587
+ pipeline: /deferred|gbuffer/i.test(mode) ? 'deferred' : context.pipeline,
1588
+ };
1589
+ const selected = selectMaterialPassProgram(projection, pass.name, selectedContext, address);
1590
+ return {
1591
+ pass: pass.name,
1592
+ context: selectedContext,
1593
+ specializationKey: selected.specializationKey,
1594
+ ...(selected.abi === undefined ? {} : { abi: selected.abi }),
1595
+ };
1596
+ });
1597
+ }
1598
+
1546
1599
  /** Resolve the producer's scene-index program key for each cooked Pass. */
1547
1600
  export function materialSceneIndexProgramKeysForMaterial(
1548
1601
  material: MaterialAsset,
@@ -1597,26 +1650,12 @@ function materialProgramKeysForAddress(
1597
1650
  context: MaterialCookProgramContext | undefined,
1598
1651
  address: 'direct' | 'scene-index',
1599
1652
  ): Readonly<Record<string, string>> | undefined {
1600
- const projection = assets.getMaterialProjectionForPayload(material);
1601
- if (projection === undefined) return undefined;
1602
- if (context === undefined)
1603
- throw new Error('Published material selection requires renderer-owned compiler context');
1604
- const entries: [string, string][] = [];
1605
- for (const pass of projection.passes) {
1606
- const mode = String(
1607
- (pass.renderState?.tags as Record<string, unknown> | undefined)?.LightMode ?? pass.name,
1608
- );
1609
- const selectedContext: MaterialCookProgramContext = {
1610
- ...context,
1611
- pass: /shadow/i.test(mode) ? 'shadow' : /depth/i.test(mode) ? 'depth' : 'forward',
1612
- pipeline: /deferred|gbuffer/i.test(mode) ? 'deferred' : context.pipeline,
1613
- };
1614
- entries.push([
1615
- pass.name,
1616
- selectMaterialPassProgram(projection, pass.name, selectedContext, address).specializationKey,
1617
- ]);
1618
- }
1619
- return entries.length === 0 ? undefined : Object.fromEntries(entries);
1653
+ const selections = materialProgramSelectionsForMaterial(material, assets, context, address);
1654
+ return selections === undefined
1655
+ ? undefined
1656
+ : Object.fromEntries(
1657
+ selections.map(({ pass, specializationKey }) => [pass, specializationKey]),
1658
+ );
1620
1659
  }
1621
1660
 
1622
1661
  export function runtimeMaterialShaderIdForMaterial(
@@ -2712,9 +2751,10 @@ export interface DirectionalCsmResult {
2712
2751
  * the caller leaves lightViewProj undefined rather than emitting zero matrices
2713
2752
  * that the WGSL reader would sample as NaN → "fully lit").
2714
2753
  *
2715
- * Byte-identical to the prior inline computation for the single-world case
2716
- * (same PSSM lambda, same toward-light Z reach RC-2 fix, same clip-space
2717
- * matrix / tile-placement-in-shader split).
2754
+ * Keeps the prior PSSM lambda, toward-light Z reach RC-2 fix, and clip-space
2755
+ * matrix / tile-placement-in-shader split. The XY fit is additionally snapped
2756
+ * to the cascade's shadow-texel grid so camera motion cannot continuously
2757
+ * shimmer a receiver across raster texel boundaries.
2718
2758
  */
2719
2759
  export function computeDirectionalCsm(
2720
2760
  direction: Vec3,
@@ -2820,6 +2860,29 @@ export function computeDirectionalCsm(
2820
2860
  if ((ls[2] ?? 0) < minZ) minZ = ls[2] ?? 0;
2821
2861
  if ((ls[2] ?? 0) > maxZ) maxZ = ls[2] ?? 0;
2822
2862
  }
2863
+ // Stabilize the orthographic receiver grid against sub-texel camera
2864
+ // motion. The raster pass writes each cascade into a square `mapSize`
2865
+ // viewport, so the light-space texel pitch is the fitted extent divided
2866
+ // by that viewport size. Snap the fitted center to that grid and retain a
2867
+ // half-texel guard on each side; caster and receiver projections then move
2868
+ // together in whole shadow texels without clipping a frustum corner when
2869
+ // the rounded center shifts by half a texel.
2870
+ const texelX = (maxX - minX) / config.mapSize;
2871
+ const texelY = (maxY - minY) / config.mapSize;
2872
+ if (Number.isFinite(texelX) && texelX > 0) {
2873
+ const centerX = (minX + maxX) * 0.5;
2874
+ const snappedCenterX = Math.round(centerX / texelX) * texelX;
2875
+ const halfWidth = (maxX - minX) * 0.5 + texelX * 0.5;
2876
+ minX = snappedCenterX - halfWidth;
2877
+ maxX = snappedCenterX + halfWidth;
2878
+ }
2879
+ if (Number.isFinite(texelY) && texelY > 0) {
2880
+ const centerY = (minY + maxY) * 0.5;
2881
+ const snappedCenterY = Math.round(centerY / texelY) * texelY;
2882
+ const halfHeight = (maxY - minY) * 0.5 + texelY * 0.5;
2883
+ minY = snappedCenterY - halfHeight;
2884
+ maxY = snappedCenterY + halfHeight;
2885
+ }
2823
2886
  // Orthographic projection from light-space AABB. RC-2: whole-frustum
2824
2887
  // toward-light extreme for the near bound; per-cascade far (minZ) + X/Y.
2825
2888
  const nearZ = Math.max(maxZ, lightSpaceMaxZFull);
@@ -2924,15 +2987,26 @@ function collectCameraSnapshots(world: World): CameraSnapshot[] {
2924
2987
  const cameraQuery = world
2925
2988
  .query({
2926
2989
  read: [Camera],
2927
- optional: [MotionBlur, DynamicResolution, ScreenSpaceReflection],
2990
+ optional: [MotionBlur, DynamicResolution, ScreenSpaceReflection, DepthOfField],
2928
2991
  with: [Transform, GlobalTransform],
2929
2992
  })
2930
2993
  .unwrap();
2931
2994
  for (const row of cameraQuery) {
2932
2995
  const cam = row.get(Camera);
2933
2996
  const motionBlur = row.get(MotionBlur);
2997
+ const depthOfField = row.get(DepthOfField);
2934
2998
  const dynamicResolution = row.get(DynamicResolution);
2935
2999
  const screenSpaceReflection = row.get(ScreenSpaceReflection);
3000
+ const projection = cameraProjectionFromF32(cam.projection);
3001
+ const depthOfFieldResult = resolveDepthOfFieldParams(depthOfField, {
3002
+ projection,
3003
+ fov: cam.fov,
3004
+ near: cam.near,
3005
+ far: cam.far,
3006
+ });
3007
+ const depthOfFieldError = depthOfFieldResult.ok
3008
+ ? undefined
3009
+ : depthOfFieldRequestFailure(depthOfFieldResult.error);
2936
3010
  const entity = row.entity;
2937
3011
  const view = worldInternal._getArrayView(entity, GlobalTransform, 'world');
2938
3012
  if (view === undefined) continue;
@@ -2967,7 +3041,7 @@ function collectCameraSnapshots(world: World): CameraSnapshot[] {
2967
3041
  aspect: cam.aspect,
2968
3042
  near: cam.near,
2969
3043
  far: cam.far,
2970
- projection: cameraProjectionFromF32(cam.projection),
3044
+ projection,
2971
3045
  orthoLeft: cam.left,
2972
3046
  orthoRight: cam.right,
2973
3047
  orthoBottom: cam.bottom,
@@ -2990,6 +3064,10 @@ function collectCameraSnapshots(world: World): CameraSnapshot[] {
2990
3064
  cam.clearColor[2] ?? 0,
2991
3065
  cam.clearColor[3] ?? 1,
2992
3066
  ],
3067
+ ...(depthOfFieldResult.ok && depthOfFieldResult.value !== undefined
3068
+ ? { depthOfField: depthOfFieldResult.value }
3069
+ : {}),
3070
+ ...(depthOfFieldError === undefined ? {} : { depthOfFieldError }),
2993
3071
  ...(motionBlur === undefined
2994
3072
  ? {}
2995
3073
  : {
@@ -46,6 +46,8 @@ import {
46
46
  type RenderError,
47
47
  TransmissionCapabilityMissingError,
48
48
  } from './errors/render';
49
+ import { projectDepthOfFieldInspection } from './features/depth-of-field/depth-of-field-inspection';
50
+ import { resolveDepthOfFieldFrameParams } from './features/depth-of-field/depth-of-field-params';
49
51
  import {
50
52
  type RenderFeaturePreparedGraphicsResolverInput,
51
53
  runRenderFeatureFrame,
@@ -68,6 +70,7 @@ import {
68
70
  } from './fullscreen-post-process-pass';
69
71
  import type {
70
72
  BloomInspection,
73
+ DepthOfFieldInspection,
71
74
  DirectionalShadowInspection,
72
75
  GpuDrivenProductionInspection,
73
76
  LodOcclusionInspection,
@@ -215,6 +218,7 @@ import {
215
218
  type RenderFeatureGraphCandidate,
216
219
  recordRenderFeatureCandidate,
217
220
  resetRenderFeatureGraphState,
221
+ settleDepthOfFieldGraphCandidate,
218
222
  settleVolumetricFogGraphCandidate,
219
223
  } from './record/typed-frame-graph';
220
224
  import {
@@ -576,6 +580,7 @@ export interface RenderSystem {
576
580
  readonly temporal: TemporalInspection;
577
581
  readonly bloom: BloomInspection;
578
582
  readonly motionBlurInspection: MotionBlurInspection | undefined;
583
+ readonly depthOfFieldInspection: DepthOfFieldInspection | undefined;
579
584
  readonly temporalTargetInspection: TemporalTargetInspection | undefined;
580
585
  readonly lodOcclusionInspection: LodOcclusionInspection | undefined;
581
586
  /** Bounded GPU-driven counters without materializing the persistent scene table. */
@@ -1159,6 +1164,14 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
1159
1164
  compiledFrameGraph: null,
1160
1165
  compiledFrameGraphTopologyKey: null,
1161
1166
  compiledFrameGraphGeneration: 0,
1167
+ depthOfFieldPreviousGraph: null,
1168
+ depthOfFieldPreviousGraphKey: null,
1169
+ depthOfFieldPreviousPerFrameGraph: undefined,
1170
+ depthOfFieldPreviousGraphGeneration: 0,
1171
+ depthOfFieldPreviousStandardLightingGraphSignature: '',
1172
+ depthOfFieldCandidateGraph: null,
1173
+ depthOfFieldAccepted: undefined,
1174
+ depthOfFieldLastSubmitFailed: false,
1162
1175
  volumetricFogPreviousGraph: null,
1163
1176
  volumetricFogPreviousGraphKey: null,
1164
1177
  volumetricFogCandidateGraph: null,
@@ -2280,6 +2293,26 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
2280
2293
  ...(unavailable ? { lastFailure: 'scene-data-unavailable' as const } : {}),
2281
2294
  };
2282
2295
  },
2296
+ get depthOfFieldInspection(): DepthOfFieldInspection | undefined {
2297
+ const camera = latestCamera;
2298
+ if (camera === undefined) return undefined;
2299
+ const graph = frameState.compiledFrameGraph?.inspect();
2300
+ return projectDepthOfFieldInspection({
2301
+ ...(camera.depthOfField === undefined ? {} : { params: camera.depthOfField }),
2302
+ ...(camera.depthOfFieldError === undefined ? {} : { error: camera.depthOfFieldError }),
2303
+ ...(graph === undefined ? {} : { graph }),
2304
+ // The explicit property keeps the inspector on the submit-bound
2305
+ // transaction path even before the first accepted frame. A missing
2306
+ // property remains supported for direct structural callers/tests.
2307
+ accepted: frameState.depthOfFieldAccepted,
2308
+ ...(frameState.depthOfFieldLastSubmitFailed === true ? { lastSubmitFailed: true } : {}),
2309
+ // Inspection can be queried while the renderer is being assembled,
2310
+ // before a device scope has been published. Keep that observation
2311
+ // bounded instead of turning a not-yet-ready renderer into a getter
2312
+ // exception.
2313
+ deviceGeneration: internals.deviceScope?.generation ?? 0,
2314
+ });
2315
+ },
2283
2316
  get temporalTargetInspection(): TemporalTargetInspection | undefined {
2284
2317
  const graph = frameState.compiledFrameGraph?.inspect();
2285
2318
  if (graph === undefined) return undefined;
@@ -2888,6 +2921,7 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
2888
2921
  const materialWorld = preparedWorlds[renderable.worldId];
2889
2922
  return projectMeshMaterialBindingObservation({
2890
2923
  worldId: renderable.worldId,
2924
+ ...(materialWorld === undefined ? {} : { worldIdentity: materialWorld.identity }),
2891
2925
  entityKey: renderable.entityKey,
2892
2926
  bindings: renderable.materials.map((material, slotIndex) => ({
2893
2927
  handle: material.materialHandle ?? 0,
@@ -3283,6 +3317,29 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3283
3317
  internals.errorRegistry.fire(temporalCommit.error);
3284
3318
  }
3285
3319
  frameState.lastSuccessfulCameraAntialias = cameras[0]?.antialias;
3320
+ const submittedCamera = cameras[0];
3321
+ const submittedDepthOfField =
3322
+ submittedCamera === undefined
3323
+ ? undefined
3324
+ : resolveDepthOfFieldFrameParams(
3325
+ submittedCamera.depthOfField,
3326
+ submittedCamera.depthOfFieldError,
3327
+ frameState.depthOfFieldAccepted?.params,
3328
+ );
3329
+ const submittedDepthOfFieldGraph = frameState.compiledFrameGraph?.inspect();
3330
+ if (submittedDepthOfField !== undefined && submittedDepthOfFieldGraph !== undefined) {
3331
+ frameState.depthOfFieldAccepted = {
3332
+ params: submittedDepthOfField,
3333
+ graph: submittedDepthOfFieldGraph,
3334
+ deviceGeneration: internals.deviceScope.generation,
3335
+ };
3336
+ frameState.depthOfFieldLastSubmitFailed = false;
3337
+ } else if (submittedDepthOfField === undefined) {
3338
+ frameState.depthOfFieldAccepted = undefined;
3339
+ frameState.depthOfFieldLastSubmitFailed = false;
3340
+ } else {
3341
+ frameState.depthOfFieldLastSubmitFailed = true;
3342
+ }
3286
3343
  internals.getPipelineState()?.perPassResources.commitBloomResources?.();
3287
3344
  const nextBloom = cameras[0]?.bloom ?? 'off';
3288
3345
  if (frameState.lastSuccessfulBloom === 'on' && nextBloom !== 'on') {
@@ -3294,6 +3351,9 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3294
3351
  }
3295
3352
  frameState.lastSuccessfulBloom = nextBloom;
3296
3353
  } else {
3354
+ if (cameras[0]?.depthOfField !== undefined) {
3355
+ frameState.depthOfFieldLastSubmitFailed = true;
3356
+ }
3297
3357
  internals.getPipelineState()?.perPassResources.discardBloomResources?.();
3298
3358
  }
3299
3359
  if (!submitted && frameState.environmentGeneration !== undefined) {
@@ -3638,6 +3698,7 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3638
3698
  frameState.volumetricFogHistorySlot = null;
3639
3699
  frameState.volumetricFogHistorySignature = null;
3640
3700
  frameState.hdrpClusterMembership = null;
3701
+ settleDepthOfFieldGraphCandidate(frameState, false);
3641
3702
  const compiled = frameState.compiledFrameGraph;
3642
3703
  frameState.compiledFrameGraph = null;
3643
3704
  frameState.compiledFrameGraphTopologyKey = null;
@@ -3645,6 +3706,14 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3645
3706
  frameState.standardLightingGraphSignature = '';
3646
3707
  frameState.standardLightingInspection = undefined;
3647
3708
  frameState.pointShadowInspection = undefined;
3709
+ frameState.depthOfFieldPreviousGraph = null;
3710
+ frameState.depthOfFieldPreviousGraphKey = null;
3711
+ frameState.depthOfFieldPreviousPerFrameGraph = undefined;
3712
+ frameState.depthOfFieldPreviousGraphGeneration = 0;
3713
+ frameState.depthOfFieldPreviousStandardLightingGraphSignature = '';
3714
+ frameState.depthOfFieldCandidateGraph = null;
3715
+ frameState.depthOfFieldAccepted = undefined;
3716
+ frameState.depthOfFieldLastSubmitFailed = false;
3648
3717
  if (compiled !== null) compiled.retire().catch(() => undefined);
3649
3718
  for (const retired of frameState.retiredCompiledFrameGraphs) {
3650
3719
  retired.retire().catch(() => undefined);
@@ -3746,12 +3815,19 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3746
3815
  // handles cannot be used on the fresh device and the next draw must
3747
3816
  // lazily build a new graph from the preserved ECS / asset POD caches.
3748
3817
  settleVolumetricFogGraphCandidate(frameState, false);
3818
+ settleDepthOfFieldGraphCandidate(frameState, false);
3749
3819
  frameState.compiledFrameGraph?.retire().catch(() => undefined);
3750
3820
  frameState.compiledFrameGraph = null;
3751
3821
  frameState.compiledFrameGraphTopologyKey = null;
3752
3822
  frameState.volumetricFogPreviousGraph = null;
3753
3823
  frameState.volumetricFogPreviousGraphKey = null;
3754
3824
  frameState.volumetricFogCandidateGraph = null;
3825
+ frameState.depthOfFieldPreviousGraph = null;
3826
+ frameState.depthOfFieldPreviousGraphKey = null;
3827
+ frameState.depthOfFieldPreviousPerFrameGraph = undefined;
3828
+ frameState.depthOfFieldPreviousGraphGeneration = 0;
3829
+ frameState.depthOfFieldPreviousStandardLightingGraphSignature = '';
3830
+ frameState.depthOfFieldCandidateGraph = null;
3755
3831
  frameState.volumetricFogAccepted = undefined;
3756
3832
  frameState.volumetricFogAcceptedContext = undefined;
3757
3833
  frameState.volumetricFogHistoryGraph = null;
@@ -3772,6 +3848,8 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3772
3848
  frameState.standardLightingGraphSignature = '';
3773
3849
  frameState.standardLightingInspection = undefined;
3774
3850
  frameState.pointShadowInspection = undefined;
3851
+ frameState.depthOfFieldAccepted = undefined;
3852
+ frameState.depthOfFieldLastSubmitFailed = false;
3775
3853
  frameState.directionalShadowCache = null;
3776
3854
  frameState.directionalShadowCacheRecorded = false;
3777
3855
  frameState.currentFrameObservationSource = undefined;
@@ -84,6 +84,8 @@ function resolvedDepthView(
84
84
  if (!texture.ok) throw texture.error;
85
85
  const view = frame.runtime.device.createTextureView(texture.value, {
86
86
  label: 'typed-ssao-depth-only-view',
87
+ // Multisampled WebGPU textures still use a `2d` view dimension; the
88
+ // sample count is carried by the source texture and WGSL type.
87
89
  dimension: '2d',
88
90
  aspect: 'depth-only',
89
91
  baseMipLevel: 0,
@@ -892,6 +894,7 @@ export function addTypedFullscreenPass(
892
894
  ...(options.fragmentEntryPoint === undefined
893
895
  ? {}
894
896
  : { fragmentEntryPoint: options.fragmentEntryPoint }),
897
+ ...(options.depth === undefined ? {} : { msaaActive: options.depth.sampleCount === 4 }),
895
898
  });
896
899
  },
897
900
  });