@forgeax/engine-render 0.1.19 → 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.
Files changed (174) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/assembly/factory.d.ts +16 -5
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/{chunk-RJRUS55L.mjs → chunk-4XINDLAY.mjs} +267 -186
  5. package/dist/chunk-4XINDLAY.mjs.map +1 -0
  6. package/dist/{chunk-YTQ5C7YQ.mjs → chunk-CDTWTHZR.mjs} +176 -8
  7. package/dist/chunk-CDTWTHZR.mjs.map +1 -0
  8. package/dist/{chunk-IPXCABEP.mjs → chunk-M2MP4ZOU.mjs} +2 -2
  9. package/dist/chunk-M2MP4ZOU.mjs.map +1 -0
  10. package/dist/{chunk-LOSO4CTV.mjs → chunk-TBQJOK2L.mjs} +67 -3
  11. package/dist/chunk-TBQJOK2L.mjs.map +1 -0
  12. package/dist/components/light-helpers.d.ts.map +1 -1
  13. package/dist/components/spot-light.d.ts +12 -0
  14. package/dist/components/spot-light.d.ts.map +1 -1
  15. package/dist/construct-renderer.mjs +2281 -164
  16. package/dist/construct-renderer.mjs.map +1 -1
  17. package/dist/device/gpu-residency.d.ts +19 -2
  18. package/dist/device/gpu-residency.d.ts.map +1 -1
  19. package/dist/errors/render.d.ts +69 -1
  20. package/dist/errors/render.d.ts.map +1 -1
  21. package/dist/gpu-usage.d.ts +2 -0
  22. package/dist/gpu-usage.d.ts.map +1 -1
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.mjs +3 -3
  26. package/dist/internal.mjs +1 -1
  27. package/dist/light-buffer-layout.d.ts +20 -12
  28. package/dist/light-buffer-layout.d.ts.map +1 -1
  29. package/dist/pbr-pipeline.d.ts +6 -3
  30. package/dist/pbr-pipeline.d.ts.map +1 -1
  31. package/dist/pipeline/standard-forward-lane.d.ts.map +1 -1
  32. package/dist/pipeline/standard-pipeline.d.ts.map +1 -1
  33. package/dist/pipeline/standard-profile.d.ts +13 -0
  34. package/dist/pipeline/standard-profile.d.ts.map +1 -1
  35. package/dist/record/frame-lighting.d.ts +2 -0
  36. package/dist/record/frame-lighting.d.ts.map +1 -1
  37. package/dist/record/frame-snapshot.d.ts +37 -1
  38. package/dist/record/frame-snapshot.d.ts.map +1 -1
  39. package/dist/record/frame-targets.d.ts.map +1 -1
  40. package/dist/record/frame.d.ts +3 -2
  41. package/dist/record/frame.d.ts.map +1 -1
  42. package/dist/record/gpu-timing.d.ts +32 -0
  43. package/dist/record/gpu-timing.d.ts.map +1 -0
  44. package/dist/record/main-pass-material.d.ts +1 -1
  45. package/dist/record/main-pass-material.d.ts.map +1 -1
  46. package/dist/record/render-context.d.ts +5 -1
  47. package/dist/record/render-context.d.ts.map +1 -1
  48. package/dist/record/shadow-pass.d.ts.map +1 -1
  49. package/dist/record/typed-frame-graph.d.ts +11 -3
  50. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  51. package/dist/render-contract.d.ts +66 -1
  52. package/dist/render-contract.d.ts.map +1 -1
  53. package/dist/render-data.d.ts +10 -6
  54. package/dist/render-data.d.ts.map +1 -1
  55. package/dist/render-pipeline.d.ts +36 -0
  56. package/dist/render-pipeline.d.ts.map +1 -1
  57. package/dist/render-system-extract.d.ts +55 -3
  58. package/dist/render-system-extract.d.ts.map +1 -1
  59. package/dist/render-system.d.ts +7 -1
  60. package/dist/render-system.d.ts.map +1 -1
  61. package/dist/renderer-inspect.d.ts +29 -0
  62. package/dist/renderer-inspect.d.ts.map +1 -0
  63. package/dist/scene/render-scene.d.ts.map +1 -1
  64. package/dist/temporal/frame.d.ts +44 -0
  65. package/dist/temporal/frame.d.ts.map +1 -0
  66. package/dist/temporal/index.mjs +3 -3
  67. package/dist/typed-render-graph-primitives.d.ts +6 -1
  68. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  69. package/dist/typed-shadow-atlas.d.ts +10 -0
  70. package/dist/typed-shadow-atlas.d.ts.map +1 -0
  71. package/dist/typed-shadow-passes.d.ts.map +1 -1
  72. package/dist/volume/capability.d.ts +57 -0
  73. package/dist/volume/capability.d.ts.map +1 -0
  74. package/dist/volume/component.d.ts +61 -0
  75. package/dist/volume/component.d.ts.map +1 -0
  76. package/dist/volume/extract.d.ts +11 -0
  77. package/dist/volume/extract.d.ts.map +1 -0
  78. package/dist/volume/inspection.d.ts +38 -0
  79. package/dist/volume/inspection.d.ts.map +1 -0
  80. package/dist/volume/passes.d.ts +38 -0
  81. package/dist/volume/passes.d.ts.map +1 -0
  82. package/dist/volume/recovery.d.ts +38 -0
  83. package/dist/volume/recovery.d.ts.map +1 -0
  84. package/dist/volume/resources.d.ts +57 -0
  85. package/dist/volume/resources.d.ts.map +1 -0
  86. package/dist/volume/temporal.d.ts +35 -0
  87. package/dist/volume/temporal.d.ts.map +1 -0
  88. package/package.json +20 -20
  89. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +14 -14
  90. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +1 -1
  91. package/src/__tests__/render-error-code-owner.test-d.ts +5 -0
  92. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +28 -0
  93. package/src/__tests__/renderer-frame-transaction.integration.test.ts +1 -1
  94. package/src/__tests__/skinned-shadow-caster.test.ts +21 -0
  95. package/src/__tests__/standard-profile-oracle.unit.test.ts +7 -5
  96. package/src/__tests__/volume-optics-oracle.unit.test.ts +85 -0
  97. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +655 -0
  98. package/src/__tests__/volumetric-fog-component.unit.test.ts +71 -0
  99. package/src/__tests__/volumetric-fog-composite-stage-readback.dawn.test.ts +376 -0
  100. package/src/__tests__/volumetric-fog-errors.unit.test.ts +51 -0
  101. package/src/__tests__/volumetric-fog-inspection.unit.test.ts +77 -0
  102. package/src/__tests__/volumetric-fog-light-capability.unit.test.ts +19 -0
  103. package/src/__tests__/volumetric-fog-light-consumers.integration.test.ts +18 -0
  104. package/src/__tests__/volumetric-fog-light-errors.unit.test.ts +17 -0
  105. package/src/__tests__/volumetric-fog-light-topology.unit.test.ts +21 -0
  106. package/src/__tests__/volumetric-fog-light-transaction.integration.test.ts +53 -0
  107. package/src/__tests__/volumetric-fog-m9-watcher.integration.test.ts +27 -0
  108. package/src/__tests__/volumetric-fog-point-light-owner.integration.test.ts +44 -0
  109. package/src/__tests__/volumetric-fog-point-spot-accumulation.integration.test.ts +49 -0
  110. package/src/__tests__/volumetric-fog-point-spot-contract.unit.test.ts +28 -0
  111. package/src/__tests__/volumetric-fog-point-spot-lkg.integration.test.ts +65 -0
  112. package/src/__tests__/volumetric-fog-point-spot-selection.integration.test.ts +49 -0
  113. package/src/__tests__/volumetric-fog-point-spot-temporal.unit.test.ts +55 -0
  114. package/src/__tests__/volumetric-fog-projector-contract.unit.test.ts +22 -0
  115. package/src/__tests__/volumetric-fog-projector-inspection.unit.test.ts +12 -0
  116. package/src/__tests__/volumetric-fog-projector-surface-volume.integration.test.ts +11 -0
  117. package/src/__tests__/volumetric-fog-projector-transaction.integration.test.ts +26 -0
  118. package/src/__tests__/volumetric-fog-recovery.integration.test.ts +47 -0
  119. package/src/__tests__/volumetric-fog-selected-light-resolution.integration.test.ts +63 -0
  120. package/src/__tests__/volumetric-fog-spot-closure.integration.test.ts +26 -0
  121. package/src/__tests__/volumetric-fog-spot-contract.test-d.ts +21 -0
  122. package/src/__tests__/volumetric-fog-spot-contract.unit.test.ts +27 -0
  123. package/src/__tests__/volumetric-fog-spot-frame-optics.integration.test.ts +14 -0
  124. package/src/__tests__/volumetric-fog-spot-optics.integration.test.ts +29 -0
  125. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +550 -0
  126. package/src/__tests__/volumetric-fog-temporal-selected-light.unit.test.ts +33 -0
  127. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +45 -0
  128. package/src/__tests__/volumetric-fog-topology.unit.test.ts +237 -0
  129. package/src/__tests__/volumetric-fog-transparent.integration.test.ts +27 -0
  130. package/src/assembly/factory.ts +271 -15
  131. package/src/components/light-helpers.ts +4 -0
  132. package/src/components/spot-light.ts +22 -3
  133. package/src/device/gpu-residency.ts +168 -58
  134. package/src/errors/render.ts +119 -1
  135. package/src/gpu-usage.ts +2 -0
  136. package/src/index.ts +38 -1
  137. package/src/light-buffer-layout.ts +59 -26
  138. package/src/pbr-pipeline.ts +33 -10
  139. package/src/pipeline/standard-forward-lane.ts +20 -0
  140. package/src/pipeline/standard-pipeline.ts +20 -0
  141. package/src/pipeline/standard-profile.ts +23 -0
  142. package/src/record/frame-lighting.ts +103 -89
  143. package/src/record/frame-snapshot.ts +41 -1
  144. package/src/record/frame-targets.ts +6 -2
  145. package/src/record/frame.ts +908 -5
  146. package/src/record/gpu-timing.ts +224 -0
  147. package/src/record/main-pass-material.ts +6 -2
  148. package/src/record/render-context.ts +5 -0
  149. package/src/record/shadow-pass.ts +87 -38
  150. package/src/record/typed-frame-graph.ts +143 -8
  151. package/src/render-contract.ts +72 -0
  152. package/src/render-data.ts +50 -22
  153. package/src/render-pipeline.ts +44 -0
  154. package/src/render-system-extract.ts +329 -3
  155. package/src/render-system.ts +79 -0
  156. package/src/renderer-inspect.ts +66 -0
  157. package/src/scene/render-scene.ts +2 -0
  158. package/src/temporal/frame.ts +128 -0
  159. package/src/transmission/__tests__/standard-transmission.dawn.test.ts +2 -3
  160. package/src/typed-render-graph-primitives.ts +32 -2
  161. package/src/typed-shadow-atlas.ts +17 -0
  162. package/src/typed-shadow-passes.ts +6 -5
  163. package/src/volume/capability.ts +173 -0
  164. package/src/volume/component.ts +110 -0
  165. package/src/volume/extract.ts +21 -0
  166. package/src/volume/inspection.ts +78 -0
  167. package/src/volume/passes.ts +680 -0
  168. package/src/volume/recovery.ts +86 -0
  169. package/src/volume/resources.ts +236 -0
  170. package/src/volume/temporal.ts +89 -0
  171. package/dist/chunk-IPXCABEP.mjs.map +0 -1
  172. package/dist/chunk-LOSO4CTV.mjs.map +0 -1
  173. package/dist/chunk-RJRUS55L.mjs.map +0 -1
  174. package/dist/chunk-YTQ5C7YQ.mjs.map +0 -1
@@ -4,12 +4,13 @@
4
4
  import { HANDLE_NINESLICE_QUAD, resolveAssetHandle } from '@forgeax/engine-assets-runtime';
5
5
  import type { World } from '@forgeax/engine-ecs';
6
6
  import type { RenderReadLease } from '@forgeax/engine-ecs/projection';
7
- import { mat4 } from '@forgeax/engine-math';
7
+ import { mat4, vec3 } from '@forgeax/engine-math';
8
8
  import {
9
9
  type CurrentFrameObservationLease,
10
10
  createCurrentFrameObservationLease,
11
11
  } from '@forgeax/engine-render-graph';
12
12
  import {
13
+ type Buffer,
13
14
  err,
14
15
  ok,
15
16
  type Result,
@@ -25,12 +26,22 @@ import { ObservationUnavailableError, type ObservationUnavailableReason } from '
25
26
  import type { FogFrame } from '../extract/environment';
26
27
  import type { GpuDrivenProduction, PreparedGpuDrivenFrame } from '../gpu-driven/production-raster';
27
28
  import { GpuBuffer } from '../gpu-resource';
28
- import { GPU_BUFFER_USAGE_COPY_DST, GPU_BUFFER_USAGE_VERTEX } from '../gpu-usage';
29
+ import {
30
+ GPU_BUFFER_USAGE_COPY_DST,
31
+ GPU_BUFFER_USAGE_UNIFORM,
32
+ GPU_BUFFER_USAGE_VERTEX,
33
+ } from '../gpu-usage';
29
34
  import { disposeTransientInstanceBuffers } from '../instance-buffer-cache';
30
- import type { CameraSnapshot, RenderRecordPhase } from '../render-contract';
35
+ import type {
36
+ CameraSnapshot,
37
+ RenderRecordPhase,
38
+ SpotLightProjectorFrameContext,
39
+ VolumetricFogFrameContext,
40
+ } from '../render-contract';
31
41
  import type {
32
42
  DispatchEntry,
33
43
  ExtractedLights,
44
+ ExtractedVolumetricFog,
34
45
  MaterialSnapshot,
35
46
  RenderableSnapshot,
36
47
  SkyboxSnapshot,
@@ -43,6 +54,9 @@ import {
43
54
  TRANSPARENT_SORT_MODE_LAYER_Z,
44
55
  } from '../systems/transparent-sort-config';
45
56
  import type { TransmissionDemand } from '../transmission/projection';
57
+ import { hasVolumetricFogCapability } from '../volume/capability';
58
+ import { inspectVolumetricFogResources, VOLUMETRIC_FOG_PARAMS_BYTES } from '../volume/resources';
59
+ import { resolveVolumeTemporalReset, type VolumeTemporalSignature } from '../volume/temporal';
46
60
  import {
47
61
  buildPerFrameBindGroups,
48
62
  prepareFrameLighting,
@@ -188,12 +202,14 @@ export async function observeCurrentFrame(
188
202
  }
189
203
 
190
204
  import { createTemporalView, resolveTemporalReset } from '../temporal/view';
205
+ import { inspectVolumetricFog } from '../volume/inspection';
191
206
  import {
192
207
  acquireSwapChainTarget,
193
208
  graphExecutionPhase,
194
209
  resolveShadowMapSize,
195
210
  resolveSpotShadowMapSize,
196
211
  } from './frame-targets';
212
+ import type { GpuTimingCapture } from './gpu-timing';
197
213
  import {
198
214
  computeProjectionMatrix,
199
215
  computeViewMatrix,
@@ -210,6 +226,7 @@ import {
210
226
  ensureCompiledFrameGraph,
211
227
  executeCompiledFrameGraph,
212
228
  type RenderFeatureGraphCandidate,
229
+ settleVolumetricFogGraphCandidate,
213
230
  } from './typed-frame-graph';
214
231
  import { writePointsLinesViewUbo, writeViewUbo } from './view-ubo';
215
232
 
@@ -294,6 +311,703 @@ function runRecordProfilePhase<T>(
294
311
  return runner === undefined ? action() : runner(phase, action);
295
312
  }
296
313
 
314
+ const VOLUMETRIC_FOG_PASS_COUNT = 4;
315
+
316
+ type VolumetricFogParamsSlot = 0 | 1;
317
+
318
+ function nextVolumetricFogParamsSlot(frameState: RenderFrameState): VolumetricFogParamsSlot {
319
+ const accepted = frameState.volumetricFogParamsAcceptedSlot;
320
+ const pending = frameState.volumetricFogParamsPendingSlot;
321
+ if (pending !== null && pending !== accepted) return pending;
322
+ return accepted === null ? 0 : accepted === 0 ? 1 : 0;
323
+ }
324
+
325
+ function stageVolumetricFogParams(
326
+ internals: RenderSystemInternals,
327
+ frameState: RenderFrameState,
328
+ params: Float32Array,
329
+ ): Buffer | undefined {
330
+ const slot = nextVolumetricFogParamsSlot(frameState);
331
+ let buffer = frameState.volumetricFogParamsBuffers[slot];
332
+ if (buffer === null) {
333
+ const created = internals.device.createBuffer({
334
+ label: `volumetric-fog-params-${slot}`,
335
+ size: VOLUMETRIC_FOG_PARAMS_BYTES,
336
+ usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST,
337
+ mappedAtCreation: false,
338
+ });
339
+ if (!created.ok) {
340
+ internals.errorRegistry.fire(created.error);
341
+ return undefined;
342
+ }
343
+ buffer = created.value;
344
+ frameState.volumetricFogParamsBuffers[slot] = buffer;
345
+ }
346
+ const written = internals.device.queue.writeBuffer(buffer, 0, params);
347
+ if (!written.ok) {
348
+ internals.errorRegistry.fire(written.error);
349
+ return undefined;
350
+ }
351
+ frameState.volumetricFogParamsPendingSlot = slot;
352
+ frameState.volumetricFogPendingParams = new Float32Array(params);
353
+ return buffer;
354
+ }
355
+
356
+ /** Retire parameter buffers only after the submit that stopped using them. */
357
+ function retireVolumetricFogParams(
358
+ internals: RenderSystemInternals,
359
+ frameState: RenderFrameState,
360
+ ): void {
361
+ const buffers = frameState.volumetricFogParamsBuffers;
362
+ frameState.volumetricFogParamsBuffers = [null, null];
363
+ frameState.volumetricFogParamsPendingSlot = null;
364
+ frameState.volumetricFogParamsAcceptedSlot = null;
365
+ frameState.volumetricFogAcceptedParams = undefined;
366
+ frameState.volumetricFogPendingParams = undefined;
367
+ const live = buffers.filter((buffer): buffer is Buffer => buffer !== null);
368
+ if (live.length === 0) return;
369
+ const device = internals.device;
370
+ const release = (): void => {
371
+ for (const buffer of live) {
372
+ const destroyed = device.destroyBuffer(buffer);
373
+ if (!destroyed.ok) internals.errorRegistry.fire(destroyed.error);
374
+ }
375
+ };
376
+ try {
377
+ void device.queue.onSubmittedWorkDone().then(release, release);
378
+ } catch {
379
+ release();
380
+ }
381
+ }
382
+
383
+ function volumeRevision(values: readonly number[]): number {
384
+ let hash = 2166136261;
385
+ for (const value of values) {
386
+ const quantized = Number.isFinite(value) ? Math.round(value * 1_000_000) : 0;
387
+ hash = Math.imul(hash ^ quantized, 16777619) >>> 0;
388
+ }
389
+ return hash;
390
+ }
391
+
392
+ function volumeTemporalSignature(
393
+ fog: ExtractedVolumetricFog,
394
+ prepared: VolumetricFogFrameContext,
395
+ camera: CameraSnapshot,
396
+ lights: ExtractedLights,
397
+ width: number,
398
+ height: number,
399
+ ): VolumeTemporalSignature {
400
+ const authored = fog.fog;
401
+ const directional = lights.directional;
402
+ const cameraRevision = volumeRevision([
403
+ ...camera.world,
404
+ camera.fov,
405
+ camera.aspect,
406
+ camera.near,
407
+ camera.far,
408
+ camera.orthoLeft,
409
+ camera.orthoRight,
410
+ camera.orthoBottom,
411
+ camera.orthoTop,
412
+ ]);
413
+ const fogRevision =
414
+ authored === undefined
415
+ ? 0
416
+ : volumeRevision([
417
+ ...authored.bounds.min,
418
+ ...authored.bounds.max,
419
+ ...authored.extinction,
420
+ ...authored.albedo,
421
+ ...authored.emission,
422
+ authored.anisotropy,
423
+ authored.maxDistance,
424
+ ]);
425
+ const selectedSpot =
426
+ fog.spotLightEntity === undefined
427
+ ? fog.lightKind === 'spot'
428
+ ? lights.spot.find((spot) => spot.entity === fog.lightEntity)
429
+ : undefined
430
+ : lights.spot.find((spot) => spot.entity === fog.spotLightEntity);
431
+ const selectedPoint =
432
+ fog.pointLightEntity === undefined
433
+ ? fog.lightKind === 'point'
434
+ ? lights.point.find((point) => point.entity === fog.lightEntity)
435
+ : undefined
436
+ : lights.point.find((point) => point.entity === fog.pointLightEntity);
437
+ const selectedDirectional =
438
+ fog.lightKind === 'directional' && directional?.entity === fog.lightEntity
439
+ ? directional
440
+ : undefined;
441
+ const lightRevision =
442
+ selectedSpot === undefined && selectedPoint === undefined && selectedDirectional === undefined
443
+ ? 0
444
+ : volumeRevision(
445
+ selectedSpot === undefined && selectedPoint === undefined
446
+ ? selectedDirectional === undefined
447
+ ? []
448
+ : [
449
+ ...selectedDirectional.direction,
450
+ ...selectedDirectional.color,
451
+ selectedDirectional.intensity,
452
+ ...(lights.lightViewProj?.flatMap((matrix) => [...matrix]) ?? []),
453
+ ]
454
+ : [
455
+ ...(selectedPoint === undefined
456
+ ? []
457
+ : [
458
+ ...selectedPoint.position,
459
+ ...selectedPoint.color,
460
+ selectedPoint.intensity,
461
+ selectedPoint.invRangeSquared,
462
+ selectedPoint.shadowAtlasLayer ?? -1,
463
+ selectedPoint.shadowNear ?? 0,
464
+ selectedPoint.shadowFar ?? 0,
465
+ ]),
466
+ ...(selectedSpot === undefined
467
+ ? []
468
+ : [
469
+ ...selectedSpot.position,
470
+ ...selectedSpot.direction,
471
+ ...selectedSpot.color,
472
+ selectedSpot.intensity,
473
+ selectedSpot.invRangeSquared,
474
+ selectedSpot.cosInner,
475
+ selectedSpot.cosOuter,
476
+ selectedSpot.depthBias ?? 0.005,
477
+ selectedSpot.normalBias ?? 0.05,
478
+ selectedSpot.pcfKernelSize ?? 3,
479
+ selectedSpot.shadowAtlasTile,
480
+ ...(selectedSpot.lightViewProj === undefined
481
+ ? []
482
+ : [...selectedSpot.lightViewProj]),
483
+ ]),
484
+ ],
485
+ );
486
+ return {
487
+ cameraRevision,
488
+ fogRevision,
489
+ lightRevision,
490
+ densityGeneration: prepared.densityGeneration,
491
+ width,
492
+ height,
493
+ ...(fog.lightKind === undefined ? {} : { lightKind: fog.lightKind }),
494
+ ...(fog.lightEntity === undefined ? {} : { lightEntity: fog.lightEntity }),
495
+ ...(fog.pointLightEntity === undefined ? {} : { pointLightEntity: fog.pointLightEntity }),
496
+ ...(fog.spotLightEntity === undefined ? {} : { spotLightEntity: fog.spotLightEntity }),
497
+ ...(fog.projector === undefined ? {} : { projector: fog.projector }),
498
+ lightShadowRevision: lightRevision,
499
+ };
500
+ }
501
+
502
+ function resolveVolumetricFogHistoryContext(
503
+ frameState: RenderFrameState,
504
+ graph: import('@forgeax/engine-render-graph').CompiledRenderGraph<
505
+ import('../render-pipeline').RenderPipelineFrame
506
+ >,
507
+ fog: ExtractedVolumetricFog,
508
+ prepared: VolumetricFogFrameContext,
509
+ camera: CameraSnapshot,
510
+ lights: ExtractedLights,
511
+ width: number,
512
+ height: number,
513
+ ): { readonly context: VolumetricFogFrameContext; readonly signature: VolumeTemporalSignature } {
514
+ const signature = volumeTemporalSignature(fog, prepared, camera, lights, width, height);
515
+ const acceptedGraph = frameState.volumetricFogHistoryGraph;
516
+ const acceptedSlot = frameState.volumetricFogHistorySlot;
517
+ const previousSignature = frameState.volumetricFogHistorySignature;
518
+ let reset = acceptedGraph !== graph || acceptedSlot === null || previousSignature === null;
519
+ if (!reset && previousSignature !== null) {
520
+ const temporal = frameState.temporalFrame;
521
+ const previousResetReason = temporal?.resetReason;
522
+ const reprojection =
523
+ previousResetReason === 'out-of-screen'
524
+ ? 'out-of-screen'
525
+ : previousResetReason === 'depth-discontinuity'
526
+ ? 'depth-discontinuity'
527
+ : undefined;
528
+ reset = resolveVolumeTemporalReset(previousSignature, signature, {
529
+ taaEnabled: camera.antialias === 'taa',
530
+ ...(reprojection === undefined ? {} : { reprojection }),
531
+ }).reset;
532
+ }
533
+ const historyReadSlot = reset ? null : acceptedSlot;
534
+ const historyWriteSlot = reset || acceptedSlot === null ? 0 : acceptedSlot === 0 ? 1 : 0;
535
+ return {
536
+ context: {
537
+ ...prepared,
538
+ historyReadSlot,
539
+ historyWriteSlot,
540
+ historyValid: historyReadSlot !== null,
541
+ },
542
+ signature,
543
+ };
544
+ }
545
+
546
+ /**
547
+ * Resolve the authored density POD into the renderer-owned GPU resources used
548
+ * by the graph. This is deliberately a pull operation: a missing/stale
549
+ * handle or failed upload produces a structured error and leaves the graph
550
+ * candidate disabled for this frame rather than binding a synthetic density.
551
+ */
552
+ function prepareVolumetricFogFrame(
553
+ internals: RenderSystemInternals,
554
+ frameState: RenderFrameState,
555
+ world: World,
556
+ worlds: readonly World[],
557
+ fog: ExtractedVolumetricFog | undefined,
558
+ lights: ExtractedLights,
559
+ volumeCapability: boolean,
560
+ projector: SpotLightProjectorFrameContext | undefined,
561
+ ): VolumetricFogFrameContext | undefined {
562
+ if (
563
+ !volumeCapability ||
564
+ fog?.status !== 'available' ||
565
+ fog.fog === undefined ||
566
+ fog.densityHandle === undefined ||
567
+ fog.densityAsset === undefined ||
568
+ (fog.lightKind === 'spot'
569
+ ? lights.spot.find((light) => light.entity === fog.lightEntity) === undefined
570
+ : fog.lightKind === 'point'
571
+ ? lights.point.find((light) => light.entity === fog.lightEntity) === undefined
572
+ : lights.directional === undefined)
573
+ ) {
574
+ return undefined;
575
+ }
576
+ const ownerWorld = worlds[fog.worldId ?? 0] ?? world;
577
+ const resident = internals.gpuStore.ensureResident(
578
+ fog.densityHandle,
579
+ fog.densityAsset,
580
+ ownerWorld,
581
+ );
582
+ if (!resident.ok || !('texture' in resident.value)) {
583
+ if (!resident.ok) {
584
+ internals.errorRegistry.fire(
585
+ resident.error instanceof RhiError
586
+ ? resident.error
587
+ : new RhiError({
588
+ code: 'webgpu-runtime-error',
589
+ expected: 'volumetric density residency to resolve a GPU texture',
590
+ hint: 'repair the authored TextureAsset payload and retry the next frame',
591
+ detail: { error: resident.error },
592
+ }),
593
+ );
594
+ }
595
+ return undefined;
596
+ }
597
+ const params = new Float32Array(32);
598
+ params.set(fog.fog.bounds.min, 0);
599
+ params.set(fog.fog.bounds.max, 4);
600
+ params.set(fog.fog.extinction, 8);
601
+ params.set(fog.fog.albedo, 12);
602
+ params.set(fog.fog.emission, 16);
603
+ const selectedPoint =
604
+ fog.pointLightEntity === undefined
605
+ ? undefined
606
+ : lights.point.find((light) => light.entity === fog.pointLightEntity);
607
+ const selectedSpot =
608
+ fog.spotLightEntity === undefined
609
+ ? fog.lightKind === 'spot'
610
+ ? lights.spot.find((light) => light.entity === fog.lightEntity)
611
+ : undefined
612
+ : lights.spot.find((light) => light.entity === fog.spotLightEntity);
613
+ const lightDirection =
614
+ selectedSpot?.direction ?? lights.directional?.direction ?? vec3.create(0, -1, 0);
615
+ const lightColor = selectedSpot?.color ?? lights.directional?.color ?? vec3.create(0, 0, 0);
616
+ if (lightDirection === undefined || lightColor === undefined) return undefined;
617
+ params.set(lightDirection, 20);
618
+ // Reuse the vec4 padding lane as the renderer-owned frame phase for
619
+ // sub-froxel jitter. The temporal resolver consumes it without growing the
620
+ // public fog component or allocating another uniform buffer.
621
+ params[23] = frameState.frameNumber % 1024;
622
+ const selectedSpotIndex =
623
+ selectedSpot === undefined
624
+ ? -1
625
+ : lights.spot.findIndex((light) => light.entity === selectedSpot.entity);
626
+ params.set(lightColor, 24);
627
+ params[19] =
628
+ selectedPoint === undefined
629
+ ? -1
630
+ : lights.point.findIndex((light) => light.entity === selectedPoint.entity);
631
+ params[27] = selectedSpotIndex;
632
+ params[28] = fog.fog.maxDistance;
633
+ params[29] = fog.fog.anisotropy;
634
+ params[30] =
635
+ selectedPoint !== undefined && selectedSpot !== undefined
636
+ ? 3
637
+ : selectedPoint !== undefined
638
+ ? 1
639
+ : selectedSpot !== undefined
640
+ ? 2
641
+ : 0;
642
+ // The temporal shader reads this lane after the record stage resolves the
643
+ // accepted graph/slot. It is overwritten on the next queue-visible frame;
644
+ // the first candidate deliberately starts with history disabled.
645
+ params[31] =
646
+ frameState.temporalFrame !== undefined && frameState.temporalFrame.resetReason === 'none'
647
+ ? 1
648
+ : 0;
649
+
650
+ const paramsBuffer = stageVolumetricFogParams(internals, frameState, params);
651
+ if (paramsBuffer === undefined) return undefined;
652
+ return {
653
+ densityTexture: resident.value.texture.handle,
654
+ densityView: resident.value.view,
655
+ paramsBuffer,
656
+ densityGeneration: resident.value.receipt.generation,
657
+ historyReadSlot: null,
658
+ historyWriteSlot: 0,
659
+ historyValid: false,
660
+ ...(projector === undefined
661
+ ? {}
662
+ : {
663
+ projectorTexture: projector.texture,
664
+ projectorView: projector.view,
665
+ projectorSampler: projector.sampler,
666
+ }),
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Resolve the SpotLight-owned cookie independently of volumetric fog. Surface
672
+ * lighting must keep the authored projector when the fog component is absent;
673
+ * otherwise the view bind group silently falls back to white and changes the
674
+ * direct-light energy on the floor. A fog-selected projector is preferred so
675
+ * the surface and volume lanes consume one identity, with the first authored
676
+ * projector as the deterministic scene fallback.
677
+ */
678
+ function prepareSpotLightProjectorFrame(
679
+ internals: RenderSystemInternals,
680
+ world: World,
681
+ worlds: readonly World[],
682
+ lights: ExtractedLights,
683
+ fog: ExtractedVolumetricFog | undefined,
684
+ ): SpotLightProjectorFrameContext | undefined {
685
+ const preferred =
686
+ fog?.projectorHandle === undefined
687
+ ? undefined
688
+ : lights.spot.find((light) => light.projectorHandle === fog.projectorHandle);
689
+ const source =
690
+ preferred ??
691
+ lights.spot.find(
692
+ (light) =>
693
+ light.projectorHandle !== undefined &&
694
+ light.projectorAsset !== undefined &&
695
+ light.lightViewProj !== undefined,
696
+ );
697
+ if (
698
+ source === undefined ||
699
+ source.projectorHandle === undefined ||
700
+ source.projectorAsset === undefined
701
+ ) {
702
+ return undefined;
703
+ }
704
+ const ownerWorld = worlds[fog?.worldId ?? 0] ?? world;
705
+ const resident = internals.gpuStore.ensureResident(
706
+ source.projectorHandle,
707
+ source.projectorAsset,
708
+ ownerWorld,
709
+ );
710
+ if (!resident.ok || !('texture' in resident.value)) {
711
+ if (!resident.ok) {
712
+ internals.errorRegistry.fire(
713
+ resident.error instanceof RhiError
714
+ ? resident.error
715
+ : new RhiError({
716
+ code: 'webgpu-runtime-error',
717
+ expected: 'SpotLight projector residency to resolve a GPU texture',
718
+ hint: 'repair the authored projector TextureAsset payload and retry the next frame',
719
+ detail: { error: resident.error },
720
+ }),
721
+ );
722
+ }
723
+ return undefined;
724
+ }
725
+ const sampler = internals.device.createSampler({
726
+ label: 'spot-projector-linear-clamp',
727
+ minFilter: 'linear',
728
+ magFilter: 'linear',
729
+ mipmapFilter: 'linear',
730
+ addressModeU: 'clamp-to-edge',
731
+ addressModeV: 'clamp-to-edge',
732
+ addressModeW: 'clamp-to-edge',
733
+ });
734
+ if (!sampler.ok) {
735
+ internals.errorRegistry.fire(sampler.error);
736
+ return undefined;
737
+ }
738
+ return {
739
+ texture: resident.value.texture.handle,
740
+ view: resident.value.view,
741
+ sampler: sampler.value,
742
+ };
743
+ }
744
+
745
+ /** Stage the accepted parameter payload into the writable slot for a degraded
746
+ * source frame. This keeps the accepted GPU buffer immutable until submit
747
+ * succeeds while still allowing the LKG graph to update its history flag. */
748
+ function stageAcceptedVolumetricFogFrame(
749
+ internals: RenderSystemInternals,
750
+ frameState: RenderFrameState,
751
+ accepted: VolumetricFogFrameContext | undefined,
752
+ ): VolumetricFogFrameContext | undefined {
753
+ if (accepted === undefined) return undefined;
754
+ const params = frameState.volumetricFogAcceptedParams;
755
+ if (params === undefined) return accepted;
756
+ const paramsBuffer = stageVolumetricFogParams(internals, frameState, new Float32Array(params));
757
+ if (paramsBuffer === undefined) return accepted;
758
+ return { ...accepted, paramsBuffer };
759
+ }
760
+
761
+ function updateVolumetricFogInspection(
762
+ frameState: RenderFrameState,
763
+ fog: ExtractedVolumetricFog | undefined,
764
+ lights: ExtractedLights,
765
+ prepared: VolumetricFogFrameContext | undefined,
766
+ submitted: boolean,
767
+ volumeCapability: boolean,
768
+ volumeSubmissionAttempted: boolean,
769
+ ): void {
770
+ const resourceFacts =
771
+ frameState.compiledFrameGraph === undefined || frameState.compiledFrameGraph === null
772
+ ? undefined
773
+ : inspectVolumetricFogResources(frameState.compiledFrameGraph.inspect(), {
774
+ parameterBufferCount: frameState.volumetricFogParamsBuffers.filter(
775
+ (buffer) => buffer !== null,
776
+ ).length,
777
+ });
778
+ const graphSampleCount = resourceFacts?.sampleCount ?? 0;
779
+ const graphMemoryBytes = resourceFacts?.totalBytes ?? 0;
780
+ const previous = frameState.volumetricFogInspection;
781
+ const accepted = frameState.volumetricFogAccepted;
782
+ const acceptedLkg =
783
+ accepted === undefined
784
+ ? undefined
785
+ : {
786
+ guid: accepted.guid ?? previous.guid,
787
+ generation: accepted.generation ?? previous.generation,
788
+ digest: accepted.digest ?? previous.digest,
789
+ deviceEpoch: previous.deviceEpoch,
790
+ format: previous.format ?? accepted.densityAsset?.format ?? fog?.densityAsset?.format,
791
+ passCount: previous.passCount > 0 ? previous.passCount : VOLUMETRIC_FOG_PASS_COUNT,
792
+ sampleCount: previous.sampleCount > 0 ? previous.sampleCount : graphSampleCount,
793
+ memoryBytes: previous.memoryBytes > 0 ? previous.memoryBytes : graphMemoryBytes,
794
+ resourceFacts: previous.resourceFacts ?? resourceFacts,
795
+ lightEntity: accepted.lightEntity,
796
+ lightKind: accepted.lightKind,
797
+ pointLightEntity: accepted.pointLightEntity,
798
+ spotLightEntity: accepted.spotLightEntity,
799
+ projector: accepted.projector,
800
+ };
801
+
802
+ const candidateIdentity =
803
+ fog?.guid === undefined || fog.generation === undefined
804
+ ? undefined
805
+ : {
806
+ guid: fog.guid,
807
+ generation: fog.generation,
808
+ digest: fog.digest,
809
+ };
810
+ const selectedLightAvailable =
811
+ fog?.lightKind === 'spot'
812
+ ? lights.spot.some((light) => light.entity === fog.lightEntity)
813
+ : fog?.lightKind === 'point'
814
+ ? lights.point.some(
815
+ (light) => light.entity === (fog.pointLightEntity ?? fog.lightEntity),
816
+ ) &&
817
+ (fog.spotLightEntity === undefined ||
818
+ lights.spot.some((light) => light.entity === fog.spotLightEntity))
819
+ : fog?.lightKind === 'directional'
820
+ ? lights.directional?.entity === fog.lightEntity
821
+ : lights.directional !== undefined;
822
+ const capability = volumeCapability && selectedLightAvailable ? 'available' : 'unavailable';
823
+
824
+ const inspectAccepted = (status: 'accepted' | 'degraded', candidateFailure?: 'submit-failed') => {
825
+ if (
826
+ acceptedLkg === undefined ||
827
+ acceptedLkg.guid === undefined ||
828
+ acceptedLkg.generation === undefined
829
+ ) {
830
+ return false;
831
+ }
832
+ const recovery = {
833
+ guid: acceptedLkg.guid,
834
+ generation: acceptedLkg.generation,
835
+ deviceEpoch: acceptedLkg.deviceEpoch,
836
+ status,
837
+ ...(status === 'degraded' ? { lkgGeneration: acceptedLkg.generation } : {}),
838
+ ...(candidateIdentity === undefined
839
+ ? {}
840
+ : {
841
+ candidateGeneration: candidateIdentity.generation,
842
+ ...(candidateIdentity.digest === undefined
843
+ ? {}
844
+ : { candidateDigest: candidateIdentity.digest }),
845
+ }),
846
+ ...(acceptedLkg.lightEntity === undefined || acceptedLkg.lightKind === undefined
847
+ ? {}
848
+ : {
849
+ selectedLight: {
850
+ entity: acceptedLkg.lightEntity,
851
+ kind: acceptedLkg.lightKind,
852
+ revision: 0,
853
+ shadowTile: -1,
854
+ ...(acceptedLkg.pointLightEntity === undefined
855
+ ? {}
856
+ : { pointLightEntity: acceptedLkg.pointLightEntity }),
857
+ ...(acceptedLkg.spotLightEntity === undefined
858
+ ? {}
859
+ : { spotLightEntity: acceptedLkg.spotLightEntity }),
860
+ ...(acceptedLkg.projector === undefined ? {} : { projector: acceptedLkg.projector }),
861
+ },
862
+ }),
863
+ ...(candidateFailure === undefined ? {} : { candidateFailure }),
864
+ } satisfies import('../volume/recovery').VolumeRecoveryState;
865
+ frameState.volumetricFogInspection = inspectVolumetricFog({
866
+ authored: true,
867
+ capability: 'available',
868
+ degraded: status === 'degraded',
869
+ recovery,
870
+ ...(acceptedLkg.digest === undefined ? {} : { acceptedDigest: acceptedLkg.digest }),
871
+ ...(acceptedLkg.format === undefined ? {} : { format: acceptedLkg.format }),
872
+ passCount: acceptedLkg.passCount,
873
+ sampleCount: acceptedLkg.sampleCount,
874
+ memoryBytes: acceptedLkg.memoryBytes,
875
+ ...(acceptedLkg.resourceFacts === undefined
876
+ ? {}
877
+ : { resourceFacts: acceptedLkg.resourceFacts }),
878
+ });
879
+ return true;
880
+ };
881
+
882
+ if (fog === undefined || fog.status === 'off') {
883
+ // A failed fog-off candidate must not erase the inspection/LKG that is
884
+ // still being rendered. The accepted projection is retired only after a
885
+ // successful no-volume submission below.
886
+ if (!submitted && volumeSubmissionAttempted && inspectAccepted('degraded', 'submit-failed')) {
887
+ return;
888
+ }
889
+ if (!submitted && volumeSubmissionAttempted) return;
890
+ if (!submitted) return;
891
+ frameState.volumetricFogInspection = inspectVolumetricFog({
892
+ authored: false,
893
+ capability: 'available',
894
+ });
895
+ return;
896
+ }
897
+
898
+ const authored = fog.status === 'available' || fog.status === 'degraded';
899
+
900
+ // A successfully submitted available candidate is the only path that may
901
+ // become the new accepted projection. This call runs after graph settlement
902
+ // so the inspection and the graph/context observe the same transaction.
903
+ if (
904
+ submitted &&
905
+ fog.status === 'available' &&
906
+ prepared !== undefined &&
907
+ capability === 'available'
908
+ ) {
909
+ const recovery =
910
+ candidateIdentity === undefined
911
+ ? undefined
912
+ : {
913
+ guid: candidateIdentity.guid,
914
+ generation: candidateIdentity.generation,
915
+ deviceEpoch: previous.deviceEpoch,
916
+ status: 'accepted' as const,
917
+ ...(fog.lightEntity === undefined || fog.lightKind === undefined
918
+ ? {}
919
+ : {
920
+ selectedLight: {
921
+ entity: fog.lightEntity,
922
+ kind: fog.lightKind,
923
+ revision: 0,
924
+ shadowTile:
925
+ lights.spot.find((spot) => spot.entity === fog.spotLightEntity)
926
+ ?.shadowAtlasTile ??
927
+ lights.spot.find((spot) => spot.entity === fog.lightEntity)
928
+ ?.shadowAtlasTile ??
929
+ -1,
930
+ ...(fog.pointLightEntity === undefined
931
+ ? {}
932
+ : { pointLightEntity: fog.pointLightEntity }),
933
+ ...(fog.spotLightEntity === undefined
934
+ ? {}
935
+ : { spotLightEntity: fog.spotLightEntity }),
936
+ ...(fog.projector === undefined ? {} : { projector: fog.projector }),
937
+ },
938
+ }),
939
+ };
940
+ frameState.volumetricFogInspection = inspectVolumetricFog({
941
+ authored: true,
942
+ capability,
943
+ ...(recovery === undefined ? {} : { recovery }),
944
+ ...(candidateIdentity?.digest === undefined
945
+ ? {}
946
+ : { acceptedDigest: candidateIdentity.digest }),
947
+ ...(fog.densityAsset?.format === undefined ? {} : { format: fog.densityAsset.format }),
948
+ passCount: VOLUMETRIC_FOG_PASS_COUNT,
949
+ sampleCount: graphSampleCount,
950
+ memoryBytes: graphMemoryBytes,
951
+ ...(resourceFacts === undefined ? {} : { resourceFacts }),
952
+ });
953
+ return;
954
+ }
955
+
956
+ // A submitted degraded source (or an available source whose residency
957
+ // preparation failed) is rendered through the previously accepted graph.
958
+ // Keep that LKG resource fact visible instead of projecting a zero-resource
959
+ // candidate merely because the current source could not be prepared.
960
+ if (
961
+ submitted &&
962
+ acceptedLkg !== undefined &&
963
+ (fog.status === 'degraded' || prepared === undefined)
964
+ ) {
965
+ if (inspectAccepted('degraded')) return;
966
+ }
967
+
968
+ // Any candidate that reached encode/submit but failed must project the LKG,
969
+ // never the unsubmitted candidate. The candidate identity remains visible so
970
+ // recovery tooling can distinguish the rejected bytes from the frame being
971
+ // rendered.
972
+ if (!submitted && volumeSubmissionAttempted && inspectAccepted('degraded', 'submit-failed')) {
973
+ return;
974
+ }
975
+
976
+ // A degraded source that has no LKG is still a diagnostic, but it must not
977
+ // claim accepted resources. Keep its identity in the candidate stage.
978
+ const candidateRecovery =
979
+ candidateIdentity === undefined
980
+ ? undefined
981
+ : {
982
+ guid: candidateIdentity.guid,
983
+ generation: candidateIdentity.generation,
984
+ deviceEpoch: previous.deviceEpoch,
985
+ status: 'candidate' as const,
986
+ candidateGeneration: candidateIdentity.generation,
987
+ ...(candidateIdentity.digest === undefined
988
+ ? {}
989
+ : { candidateDigest: candidateIdentity.digest }),
990
+ ...(!submitted && volumeSubmissionAttempted
991
+ ? { candidateFailure: 'submit-failed' as const }
992
+ : {}),
993
+ };
994
+ frameState.volumetricFogInspection = inspectVolumetricFog({
995
+ authored,
996
+ capability,
997
+ degraded: fog.status === 'degraded' || capability === 'unavailable',
998
+ ...(candidateRecovery === undefined ? {} : { recovery: candidateRecovery }),
999
+ ...(acceptedLkg?.digest === undefined ? {} : { acceptedDigest: acceptedLkg.digest }),
1000
+ ...(fog.densityAsset?.format === undefined
1001
+ ? acceptedLkg?.format === undefined
1002
+ ? {}
1003
+ : { format: acceptedLkg.format }
1004
+ : { format: fog.densityAsset.format }),
1005
+ passCount: 0,
1006
+ sampleCount: 0,
1007
+ memoryBytes: 0,
1008
+ });
1009
+ }
1010
+
297
1011
  export function recordFrame(
298
1012
  internals: RenderSystemInternals,
299
1013
  world: World,
@@ -331,6 +1045,8 @@ export function recordFrame(
331
1045
  fogSignature = '',
332
1046
  environmentReady = true,
333
1047
  transmissionDemand?: TransmissionDemand,
1048
+ volumetricFog?: ExtractedVolumetricFog,
1049
+ timingCapture?: GpuTimingCapture,
334
1050
  ): boolean {
335
1051
  // The `try / finally` wrapper advances `frameState.frameNumber` exactly
336
1052
  // once per `recordFrame` invocation regardless of which early-return
@@ -361,6 +1077,11 @@ export function recordFrame(
361
1077
  );
362
1078
  activeCameras = [makeZeroCameraFallbackSnapshot()];
363
1079
  }
1080
+ let submitted = false;
1081
+ let volumeInspectionReady = false;
1082
+ let volumeSubmissionAttempted = false;
1083
+ let preparedVolumetricFogForInspection: VolumetricFogFrameContext | undefined;
1084
+ let volumeCapabilityForInspection = false;
364
1085
  try {
365
1086
  // Case D: N>1 Camera => fire onError, use first hit (D-S7).
366
1087
  if (activeCameras.length > 1) {
@@ -494,6 +1215,64 @@ export function recordFrame(
494
1215
  const effectiveShadowMapSize = resolveShadowMapSize(internals, lights);
495
1216
  const effectiveSpotShadowMapSize = resolveSpotShadowMapSize(internals, lights);
496
1217
  const graphShadowMapSize = effectiveShadowMapSize ?? effectiveSpotShadowMapSize;
1218
+ const volumeCapability = hasVolumetricFogCapability(
1219
+ internals.device.caps,
1220
+ internals.volumetricFogShaders,
1221
+ );
1222
+ volumeInspectionReady = true;
1223
+ volumeCapabilityForInspection = volumeCapability;
1224
+
1225
+ const preparedSpotLightProjector = prepareSpotLightProjectorFrame(
1226
+ internals,
1227
+ world,
1228
+ worlds,
1229
+ lights,
1230
+ volumetricFog,
1231
+ );
1232
+
1233
+ const preparedVolumetricFog = prepareVolumetricFogFrame(
1234
+ internals,
1235
+ frameState,
1236
+ world,
1237
+ worlds,
1238
+ volumetricFog,
1239
+ lights,
1240
+ volumeCapability,
1241
+ preparedSpotLightProjector,
1242
+ );
1243
+ preparedVolumetricFogForInspection = preparedVolumetricFog;
1244
+ // A degraded authored snapshot must keep consuming the last accepted GPU
1245
+ // projection. The current source is still surfaced as a diagnostic, but
1246
+ // it must not replace the graph/context that produced the last good frame.
1247
+ // An absent component is different: a successful no-volume frame retires
1248
+ // the accepted projection and compiles the normal graph.
1249
+ const acceptedFallback =
1250
+ volumeCapability && volumetricFog !== undefined && preparedVolumetricFog === undefined
1251
+ ? frameState.volumetricFogAccepted
1252
+ : undefined;
1253
+ const acceptedFallbackContext =
1254
+ acceptedFallback === undefined
1255
+ ? undefined
1256
+ : stageAcceptedVolumetricFogFrame(
1257
+ internals,
1258
+ frameState,
1259
+ frameState.volumetricFogAcceptedContext,
1260
+ );
1261
+ const graphVolumetricFog = volumeCapability
1262
+ ? preparedVolumetricFog === undefined
1263
+ ? acceptedFallback
1264
+ : volumetricFog
1265
+ : undefined;
1266
+ const baseVolumetricFogContext = preparedVolumetricFog ?? acceptedFallbackContext;
1267
+ // A volume graph replacement is provisional whenever it can affect the
1268
+ // accepted volume projection. This includes authoring degradation (which
1269
+ // consumes the LKG graph), an authored replacement, and an explicit
1270
+ // fog-off transition. Non-volume frames retain the legacy immediate graph
1271
+ // retirement path.
1272
+ const volumeTopologyCandidate =
1273
+ (graphVolumetricFog?.status === 'available' && baseVolumetricFogContext !== undefined) ||
1274
+ (graphVolumetricFog === undefined && frameState.volumetricFogAccepted !== undefined) ||
1275
+ (volumetricFog?.status === 'degraded' && acceptedFallback !== undefined);
497
1276
 
498
1277
  let preparedGpuDriven: PreparedGpuDrivenFrame | undefined;
499
1278
  if (gpuDriven !== undefined) {
@@ -533,6 +1312,8 @@ export function recordFrame(
533
1312
  featureGraphCandidate,
534
1313
  cameras.length === 0,
535
1314
  transmissionDemand,
1315
+ graphVolumetricFog,
1316
+ volumeTopologyCandidate,
536
1317
  ),
537
1318
  );
538
1319
  if (preflightGraph === null) return false;
@@ -623,6 +1404,35 @@ export function recordFrame(
623
1404
  : { kind: 'taa', view: temporalView };
624
1405
  const recordCamera: CameraSnapshot =
625
1406
  temporalView === undefined ? camera : { ...camera, temporal: temporalView };
1407
+ let activeVolumetricFogContext: VolumetricFogFrameContext | undefined;
1408
+ let activeVolumetricFogSignature: VolumeTemporalSignature | undefined;
1409
+ if (graphVolumetricFog?.status === 'available' && baseVolumetricFogContext !== undefined) {
1410
+ const resolvedHistory = resolveVolumetricFogHistoryContext(
1411
+ frameState,
1412
+ preflightGraph,
1413
+ graphVolumetricFog,
1414
+ baseVolumetricFogContext,
1415
+ camera,
1416
+ lights,
1417
+ targetW,
1418
+ targetH,
1419
+ );
1420
+ activeVolumetricFogContext = resolvedHistory.context;
1421
+ activeVolumetricFogSignature = resolvedHistory.signature;
1422
+ const historyFlag = new Float32Array([activeVolumetricFogContext.historyValid ? 1 : 0]);
1423
+ if (frameState.volumetricFogPendingParams !== undefined) {
1424
+ frameState.volumetricFogPendingParams[31] = historyFlag[0] ?? 0;
1425
+ }
1426
+ const historyFlagWritten = internals.device.queue.writeBuffer(
1427
+ activeVolumetricFogContext.paramsBuffer,
1428
+ 31 * Float32Array.BYTES_PER_ELEMENT,
1429
+ historyFlag,
1430
+ );
1431
+ if (!historyFlagWritten.ok) {
1432
+ internals.errorRegistry.fire(historyFlagWritten.error);
1433
+ return false;
1434
+ }
1435
+ }
626
1436
 
627
1437
  const depthView: TextureView | null = null;
628
1438
  // feat-20260709 M3 / D-3: clear-color read from the active CameraSnapshot's
@@ -772,7 +1582,18 @@ export function recordFrame(
772
1582
  // feat-20260531-skybox-env-background M2 / w6: gate relaxed from
773
1583
  // `validatedOrdered.length > 0` to include skybox-only frames
774
1584
  // (plan-strategy D-3, R-3).
775
- if (validatedOrdered.length > 0 || skyboxActive) {
1585
+ const currentViewProjection = mat4.create();
1586
+ mat4.multiply(
1587
+ currentViewProjection,
1588
+ computeProjectionMatrix(camera),
1589
+ computeViewMatrix(camera),
1590
+ );
1591
+ if (
1592
+ validatedOrdered.length > 0 ||
1593
+ skyboxActive ||
1594
+ activeVolumetricFogContext !== undefined ||
1595
+ preparedVolumetricFog !== undefined
1596
+ ) {
776
1597
  runRecordProfilePhase(profilePhase, 'record/uploads', () => {
777
1598
  // View UBO + CSM/spot-shadow matrix pack: assembled + uploaded in one
778
1599
  // queue.writeBuffer round-trip. Extracted to view-ubo.ts (M3/w18) so
@@ -922,6 +1743,15 @@ export function recordFrame(
922
1743
  geometryColorResolveView,
923
1744
  ldrSpriteColorView,
924
1745
  postProcessParams: framePostProcessParams,
1746
+ ...(internals.volumetricFogShaders === undefined
1747
+ ? {}
1748
+ : { volumetricFogShaders: internals.volumetricFogShaders }),
1749
+ ...(activeVolumetricFogContext === undefined
1750
+ ? {}
1751
+ : { volumetricFog: activeVolumetricFogContext }),
1752
+ ...(preparedSpotLightProjector === undefined
1753
+ ? {}
1754
+ : { spotLightProjector: preparedSpotLightProjector }),
925
1755
  dispatch: transparentDispatch,
926
1756
  hdrpClusterBindGroup,
927
1757
  hdrpClusterMembershipBindGroup,
@@ -935,7 +1765,21 @@ export function recordFrame(
935
1765
  directionalShadowCacheReuse: directionalShadowCache.reuse,
936
1766
  ...(profilePhase !== undefined ? { profilePhase } : {}),
937
1767
  };
938
- const submitted = runRecordProfilePhase(profilePhase, 'record/graph-execute', () =>
1768
+ const stagedTemporalFrame = {
1769
+ frameId: frameState.frameNumber,
1770
+ currentViewProjection: new Float32Array(currentViewProjection),
1771
+ jitter: [0, 0] as [number, number],
1772
+ viewport: { width: targetW, height: targetH },
1773
+ cameraPosition: [
1774
+ camera.position[0] ?? 0,
1775
+ camera.position[1] ?? 0,
1776
+ camera.position[2] ?? 0,
1777
+ ] as [number, number, number],
1778
+ };
1779
+ frameState.temporalFrameInput = stagedTemporalFrame;
1780
+ frameState.temporalFrameTransaction.stage(stagedTemporalFrame);
1781
+ volumeSubmissionAttempted = volumeTopologyCandidate || activeVolumetricFogContext !== undefined;
1782
+ submitted = runRecordProfilePhase(profilePhase, 'record/graph-execute', () =>
939
1783
  executeCompiledFrameGraph(
940
1784
  internals,
941
1785
  frameState,
@@ -944,6 +1788,7 @@ export function recordFrame(
944
1788
  profilePhase === undefined
945
1789
  ? undefined
946
1790
  : (pass, encode) => profilePhase(graphExecutionPhase(pass.name), encode),
1791
+ timingCapture,
947
1792
  ),
948
1793
  );
949
1794
  if (submitted && !directionalShadowCache.reuse) {
@@ -951,8 +1796,66 @@ export function recordFrame(
951
1796
  ? directionalShadowCache.next
952
1797
  : null;
953
1798
  }
1799
+ if (submitted) {
1800
+ if (graphVolumetricFog === undefined) {
1801
+ frameState.volumetricFogAccepted = undefined;
1802
+ frameState.volumetricFogAcceptedContext = undefined;
1803
+ frameState.volumetricFogHistoryGraph = null;
1804
+ frameState.volumetricFogHistorySlot = null;
1805
+ frameState.volumetricFogHistorySignature = null;
1806
+ retireVolumetricFogParams(internals, frameState);
1807
+ } else if (
1808
+ activeVolumetricFogContext !== undefined &&
1809
+ activeVolumetricFogSignature !== undefined
1810
+ ) {
1811
+ // Temporal history is graph-owned state. Promote the write slot only
1812
+ // after queue submission succeeds; a failed candidate keeps the
1813
+ // accepted graph/context/slot and retries into the same pending slot.
1814
+ frameState.volumetricFogHistoryGraph = preflightGraph;
1815
+ frameState.volumetricFogHistorySlot = activeVolumetricFogContext.historyWriteSlot;
1816
+ frameState.volumetricFogHistorySignature = activeVolumetricFogSignature;
1817
+ if (frameState.volumetricFogParamsPendingSlot !== null) {
1818
+ frameState.volumetricFogParamsAcceptedSlot = frameState.volumetricFogParamsPendingSlot;
1819
+ frameState.volumetricFogAcceptedParams =
1820
+ frameState.volumetricFogPendingParams === undefined
1821
+ ? undefined
1822
+ : new Float32Array(frameState.volumetricFogPendingParams);
1823
+ frameState.volumetricFogParamsPendingSlot = null;
1824
+ frameState.volumetricFogPendingParams = undefined;
1825
+ }
1826
+ if (volumetricFog?.status === 'available' && preparedVolumetricFog !== undefined) {
1827
+ frameState.volumetricFogAccepted = volumetricFog;
1828
+ frameState.volumetricFogAcceptedContext = activeVolumetricFogContext;
1829
+ } else if (acceptedFallback !== undefined) {
1830
+ // The current authored source is degraded, so keep the old accepted
1831
+ // POD while advancing the LKG graph's ping-pong slot.
1832
+ frameState.volumetricFogAcceptedContext = activeVolumetricFogContext;
1833
+ }
1834
+ } else if (volumetricFog?.status === 'available' && preparedVolumetricFog !== undefined) {
1835
+ // Defensive fallback for a volume graph that was accepted before the
1836
+ // temporal context could be resolved; do not manufacture history.
1837
+ frameState.volumetricFogAccepted = volumetricFog;
1838
+ frameState.volumetricFogAcceptedContext = preparedVolumetricFog;
1839
+ }
1840
+ }
954
1841
  return submitted;
955
1842
  } finally {
1843
+ // Volume topology replacements are provisional until the queue submission
1844
+ // succeeds. This runs for every early-return path as well, so a swapchain,
1845
+ // encoder, or graph failure cannot leave a non-submitted candidate as the
1846
+ // next frame's accepted graph.
1847
+ settleVolumetricFogGraphCandidate(frameState, submitted);
1848
+ if (volumeInspectionReady && (submitted || volumeSubmissionAttempted)) {
1849
+ updateVolumetricFogInspection(
1850
+ frameState,
1851
+ volumetricFog,
1852
+ lights,
1853
+ preparedVolumetricFogForInspection,
1854
+ submitted,
1855
+ volumeCapabilityForInspection,
1856
+ volumeSubmissionAttempted,
1857
+ );
1858
+ }
956
1859
  frameState.frameNumber += 1;
957
1860
  // feat-20260608-cluster-lighting M5 / w22: clear HDRP once-per-frame fired
958
1861
  // set so the next frame re-fires if the condition persists.