@forgeax/engine-render 0.1.32 → 0.1.33

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 (250) hide show
  1. package/README.md +379 -33
  2. package/dist/assembly/analytic-fog-registration.d.ts +3 -0
  3. package/dist/assembly/analytic-fog-registration.d.ts.map +1 -0
  4. package/dist/assembly/fxaa-registration.d.ts +4 -0
  5. package/dist/assembly/fxaa-registration.d.ts.map +1 -0
  6. package/dist/assembly/lod-observation.d.ts +4 -0
  7. package/dist/assembly/lod-observation.d.ts.map +1 -0
  8. package/dist/assembly/motion-blur-registration.d.ts +3 -0
  9. package/dist/assembly/motion-blur-registration.d.ts.map +1 -0
  10. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  11. package/dist/assembly/temporal-post-process-prewarm.d.ts +13 -0
  12. package/dist/assembly/temporal-post-process-prewarm.d.ts.map +1 -0
  13. package/dist/assembly/webgpu-ready-contract.d.ts +4 -3
  14. package/dist/assembly/webgpu-ready-contract.d.ts.map +1 -1
  15. package/dist/assembly/webgpu-ready.d.ts +1 -1
  16. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  17. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  18. package/dist/bloom-admission.d.ts +13 -0
  19. package/dist/bloom-admission.d.ts.map +1 -0
  20. package/dist/{chunk-DRR7D3KX.mjs → chunk-3RTCJTH4.mjs} +12 -9
  21. package/dist/chunk-3RTCJTH4.mjs.map +1 -0
  22. package/dist/{chunk-S665NJNF.mjs → chunk-LEAXOMYJ.mjs} +3 -3
  23. package/dist/{chunk-S665NJNF.mjs.map → chunk-LEAXOMYJ.mjs.map} +1 -1
  24. package/dist/{chunk-6TFBXLRJ.mjs → chunk-NB2S7ZRX.mjs} +155 -11
  25. package/dist/chunk-NB2S7ZRX.mjs.map +1 -0
  26. package/dist/{chunk-LULCA3OF.mjs → chunk-NNQL7CB3.mjs} +4 -4
  27. package/dist/{chunk-LULCA3OF.mjs.map → chunk-NNQL7CB3.mjs.map} +1 -1
  28. package/dist/{chunk-4NNZQCL3.mjs → chunk-OO3DRYK7.mjs} +66 -200
  29. package/dist/chunk-OO3DRYK7.mjs.map +1 -0
  30. package/dist/{chunk-GWCEMDVR.mjs → chunk-PLAXOOX5.mjs} +232 -7
  31. package/dist/chunk-PLAXOOX5.mjs.map +1 -0
  32. package/dist/{chunk-JRIPCMYC.mjs → chunk-VOZWDM3L.mjs} +283 -193
  33. package/dist/chunk-VOZWDM3L.mjs.map +1 -0
  34. package/dist/{chunk-JCIRYQ4V.mjs → chunk-ZDQWGQW7.mjs} +10 -4
  35. package/dist/chunk-ZDQWGQW7.mjs.map +1 -0
  36. package/dist/components/atmosphere.d.ts +2 -0
  37. package/dist/components/atmosphere.d.ts.map +1 -1
  38. package/dist/components/camera.d.ts +20 -4
  39. package/dist/components/camera.d.ts.map +1 -1
  40. package/dist/components/mesh-filter.d.ts +6 -5
  41. package/dist/components/mesh-filter.d.ts.map +1 -1
  42. package/dist/components/motion-blur.d.ts +1 -0
  43. package/dist/components/motion-blur.d.ts.map +1 -1
  44. package/dist/construct-renderer.mjs +2865 -526
  45. package/dist/construct-renderer.mjs.map +1 -1
  46. package/dist/device/gpu-residency.d.ts +6 -0
  47. package/dist/device/gpu-residency.d.ts.map +1 -1
  48. package/dist/environment/analytic-fog.d.ts +6 -0
  49. package/dist/environment/analytic-fog.d.ts.map +1 -0
  50. package/dist/errors/render.d.ts +6 -2
  51. package/dist/errors/render.d.ts.map +1 -1
  52. package/dist/extract/camera.d.ts.map +1 -1
  53. package/dist/extract/contracts.d.ts +7 -0
  54. package/dist/extract/contracts.d.ts.map +1 -1
  55. package/dist/extract/environment.d.ts +2 -0
  56. package/dist/extract/environment.d.ts.map +1 -1
  57. package/dist/features/error-reporting.d.ts +13 -0
  58. package/dist/features/error-reporting.d.ts.map +1 -0
  59. package/dist/features/host.d.ts +5 -0
  60. package/dist/features/host.d.ts.map +1 -1
  61. package/dist/features/motion-blur/motion-blur-feature.d.ts +20 -2
  62. package/dist/features/motion-blur/motion-blur-feature.d.ts.map +1 -1
  63. package/dist/features/motion-blur/motion-blur-params.d.ts +21 -0
  64. package/dist/features/motion-blur/motion-blur-params.d.ts.map +1 -1
  65. package/dist/features/motion-blur/motion-blur-runtime.d.ts +54 -0
  66. package/dist/features/motion-blur/motion-blur-runtime.d.ts.map +1 -0
  67. package/dist/features/plan.d.ts +7 -4
  68. package/dist/features/plan.d.ts.map +1 -1
  69. package/dist/features/prepared-gpu-work.d.ts +9 -5
  70. package/dist/features/prepared-gpu-work.d.ts.map +1 -1
  71. package/dist/features/render-graph-compute.d.ts.map +1 -1
  72. package/dist/features/render-graph-contribution.d.ts.map +1 -1
  73. package/dist/gpu-driven/material-artifacts.d.ts.map +1 -1
  74. package/dist/gpu-driven/prepared-draw.d.ts +7 -0
  75. package/dist/gpu-driven/prepared-draw.d.ts.map +1 -1
  76. package/dist/gpu-driven/production-raster.d.ts +3 -1
  77. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  78. package/dist/gpu-driven/view-gpu.d.ts +9 -0
  79. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  80. package/dist/gpu-scene.d.ts +6 -1
  81. package/dist/gpu-scene.d.ts.map +1 -1
  82. package/dist/index.d.ts +5 -2
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.mjs +5 -5
  85. package/dist/inspection-types.d.ts +43 -2
  86. package/dist/inspection-types.d.ts.map +1 -1
  87. package/dist/instances.d.ts +8 -1
  88. package/dist/instances.d.ts.map +1 -1
  89. package/dist/internal.mjs +6 -6
  90. package/dist/pipeline/standard-post.d.ts +28 -19
  91. package/dist/pipeline/standard-post.d.ts.map +1 -1
  92. package/dist/pipeline-spec.d.ts +2 -2
  93. package/dist/record/frame-snapshot.d.ts +3 -0
  94. package/dist/record/frame-snapshot.d.ts.map +1 -1
  95. package/dist/record/frame-targets.d.ts.map +1 -1
  96. package/dist/record/frame.d.ts +5 -2
  97. package/dist/record/frame.d.ts.map +1 -1
  98. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  99. package/dist/record/mesh-ssbo.d.ts +6 -5
  100. package/dist/record/mesh-ssbo.d.ts.map +1 -1
  101. package/dist/record/render-context.d.ts +12 -28
  102. package/dist/record/render-context.d.ts.map +1 -1
  103. package/dist/record/shadow-pass.d.ts.map +1 -1
  104. package/dist/record/skybox-post-pass.d.ts +5 -3
  105. package/dist/record/skybox-post-pass.d.ts.map +1 -1
  106. package/dist/record/typed-frame-graph.d.ts +1 -1
  107. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  108. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  109. package/dist/render-contract.d.ts +24 -4
  110. package/dist/render-contract.d.ts.map +1 -1
  111. package/dist/render-pipeline.d.ts +3 -1
  112. package/dist/render-pipeline.d.ts.map +1 -1
  113. package/dist/render-system-extract-tail.d.ts.map +1 -1
  114. package/dist/render-system-extract.d.ts +8 -1
  115. package/dist/render-system-extract.d.ts.map +1 -1
  116. package/dist/render-system.d.ts +7 -1
  117. package/dist/render-system.d.ts.map +1 -1
  118. package/dist/scene/render-scene.d.ts +2 -0
  119. package/dist/scene/render-scene.d.ts.map +1 -1
  120. package/dist/temporal/frame-coordinator.d.ts.map +1 -1
  121. package/dist/temporal/frame.d.ts +7 -0
  122. package/dist/temporal/frame.d.ts.map +1 -1
  123. package/dist/temporal/index.mjs +5 -5
  124. package/dist/temporal/view.d.ts +6 -1
  125. package/dist/temporal/view.d.ts.map +1 -1
  126. package/dist/typed-render-graph-primitives.d.ts +10 -7
  127. package/dist/typed-render-graph-primitives.d.ts.map +1 -1
  128. package/dist/volume/component.d.ts +0 -1
  129. package/dist/volume/component.d.ts.map +1 -1
  130. package/dist/volume/passes.d.ts.map +1 -1
  131. package/dist/volume/temporal.d.ts +3 -1
  132. package/dist/volume/temporal.d.ts.map +1 -1
  133. package/package.json +21 -21
  134. package/src/__tests__/analytic-fog-graph.unit.test.ts +199 -0
  135. package/src/__tests__/analytic-fog-optics.unit.test.ts +38 -0
  136. package/src/__tests__/bloom-exact-zero.unit.test.ts +112 -18
  137. package/src/__tests__/bloom-lifecycle.integration.test.ts +17 -4
  138. package/src/__tests__/dynamic-resolution-plugin.integration.test.ts +20 -0
  139. package/src/__tests__/environment-fog-lanes.integration.test.ts +14 -0
  140. package/src/__tests__/environment-selection.unit.test.ts +12 -0
  141. package/src/__tests__/factory-contract.integration.test.ts +331 -68
  142. package/src/__tests__/frame-camera-selection.unit.test.ts +39 -1
  143. package/src/__tests__/frame-plan-contract.unit.test.ts +2 -0
  144. package/src/__tests__/gpu-driven-indirect-raster-evidence.ts +2 -1
  145. package/src/__tests__/gpu-driven-shadow-views.dawn.test.ts +1 -1
  146. package/src/__tests__/gpu-driven-shadow-views.integration.test.ts +1 -1
  147. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +11 -0
  148. package/src/__tests__/gpu-resource-store-stride.unit.test.ts +12 -0
  149. package/src/__tests__/gpu-scene.unit.test.ts +91 -0
  150. package/src/__tests__/instances-store.unit.test.ts +75 -0
  151. package/src/__tests__/mesh-submission-lifetime.unit.test.ts +31 -0
  152. package/src/__tests__/motion-blur-compute.dawn.test.ts +583 -0
  153. package/src/__tests__/prepared-gpu-driven-pbr.integration.test.ts +21 -0
  154. package/src/__tests__/prepared-graphics-lifecycle.unit.test.ts +86 -0
  155. package/src/__tests__/public-surface.test-d.ts +8 -0
  156. package/src/__tests__/render-error-code-owner.test-d.ts +2 -0
  157. package/src/__tests__/render-feature-error-reporting.unit.test.ts +24 -0
  158. package/src/__tests__/render-feature-stage-fault-recovery.unit.test.ts +23 -0
  159. package/src/__tests__/render-scene-temporal-retry.unit.test.ts +31 -0
  160. package/src/__tests__/renderer-frame-transaction.integration.test.ts +7 -1
  161. package/src/__tests__/shader-manifest-fixture.ts +4 -4
  162. package/src/__tests__/skinned-shadow-caster.test.ts +2 -0
  163. package/src/__tests__/standard-lighting-write.unit.test.ts +2 -1
  164. package/src/__tests__/standard-pipeline.dawn.test.ts +1 -1
  165. package/src/__tests__/standard-pipeline.integration.test.ts +18 -8
  166. package/src/__tests__/temporal-reset-matrix.unit.test.ts +10 -0
  167. package/src/__tests__/temporal-view.unit.test.ts +2 -1
  168. package/src/__tests__/typed-pipeline-topology.unit.test.ts +20 -4
  169. package/src/__tests__/volume-optics-oracle.unit.test.ts +3 -1
  170. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +177 -13
  171. package/src/__tests__/volumetric-fog-stage-readback.dawn.test.ts +447 -61
  172. package/src/__tests__/volumetric-fog-temporal.unit.test.ts +27 -0
  173. package/src/__tests__/volumetric-fog-topology.unit.test.ts +19 -3
  174. package/src/assembly/analytic-fog-registration.ts +14 -0
  175. package/src/assembly/fxaa-registration.ts +17 -0
  176. package/src/assembly/lod-observation.ts +12 -0
  177. package/src/assembly/motion-blur-registration.ts +17 -0
  178. package/src/assembly/renderer-facade.ts +2 -0
  179. package/src/assembly/temporal-post-process-prewarm.ts +40 -0
  180. package/src/assembly/webgpu-ready-contract.ts +8 -3
  181. package/src/assembly/webgpu-ready.ts +181 -245
  182. package/src/assembly/webgpu-renderer.ts +16 -31
  183. package/src/bloom-admission.ts +21 -0
  184. package/src/components/__tests__/motion-blur.unit.test.ts +1 -0
  185. package/src/components/atmosphere.ts +4 -0
  186. package/src/components/camera.ts +66 -10
  187. package/src/components/mesh-filter.ts +11 -9
  188. package/src/components/motion-blur.ts +1 -0
  189. package/src/device/gpu-residency.ts +56 -12
  190. package/src/environment/analytic-fog.ts +44 -0
  191. package/src/environment/background.ts +2 -2
  192. package/src/errors/render.ts +14 -1
  193. package/src/extract/camera.ts +20 -7
  194. package/src/extract/contracts.ts +7 -0
  195. package/src/extract/environment.ts +4 -0
  196. package/src/features/error-reporting.ts +78 -0
  197. package/src/features/host.ts +25 -12
  198. package/src/features/motion-blur/__tests__/motion-blur-feature.unit.test.ts +111 -1
  199. package/src/features/motion-blur/__tests__/motion-blur-params.unit.test.ts +58 -3
  200. package/src/features/motion-blur/motion-blur-feature.ts +1519 -15
  201. package/src/features/motion-blur/motion-blur-params.ts +70 -2
  202. package/src/features/motion-blur/motion-blur-runtime.ts +176 -0
  203. package/src/features/plan.ts +25 -8
  204. package/src/features/prepared-gpu-work.ts +86 -35
  205. package/src/features/render-graph-compute.ts +13 -5
  206. package/src/features/render-graph-contribution.ts +2 -0
  207. package/src/gpu-driven/material-artifacts.ts +2 -7
  208. package/src/gpu-driven/prepared-draw.ts +15 -10
  209. package/src/gpu-driven/production-raster.ts +83 -7
  210. package/src/gpu-driven/view-gpu.ts +17 -0
  211. package/src/gpu-scene.ts +188 -21
  212. package/src/index.ts +32 -0
  213. package/src/inspection-types.ts +60 -2
  214. package/src/instances.ts +80 -1
  215. package/src/pipeline/standard-post.ts +307 -78
  216. package/src/pipeline-spec.ts +5 -5
  217. package/src/points-lines/__tests__/view-ubo.unit.test.ts +2 -1
  218. package/src/record/__tests__/temporal-payload.unit.test.ts +2 -1
  219. package/src/record/frame-snapshot.ts +5 -1
  220. package/src/record/frame-targets.ts +10 -6
  221. package/src/record/frame.ts +163 -22
  222. package/src/record/main-pass-geometry.ts +30 -1
  223. package/src/record/main-pass-sprite-draws.ts +6 -1
  224. package/src/record/mesh-ssbo.ts +43 -6
  225. package/src/record/render-context.ts +12 -28
  226. package/src/record/shadow-pass.ts +6 -1
  227. package/src/record/skybox-post-pass.ts +122 -196
  228. package/src/record/typed-frame-graph.ts +153 -9
  229. package/src/recovery/render-system-candidate.ts +22 -10
  230. package/src/render-contract.ts +23 -4
  231. package/src/render-pipeline.ts +6 -1
  232. package/src/render-system-extract-tail.ts +16 -0
  233. package/src/render-system-extract.ts +45 -13
  234. package/src/render-system.ts +255 -120
  235. package/src/scene/render-scene.ts +85 -4
  236. package/src/temporal/__tests__/temporal-frame-transaction.unit.test.ts +36 -0
  237. package/src/temporal/frame-coordinator.ts +8 -1
  238. package/src/temporal/frame.ts +20 -0
  239. package/src/temporal/view.ts +13 -2
  240. package/src/typed-render-graph-primitives.ts +121 -48
  241. package/src/volume/component.ts +0 -1
  242. package/src/volume/passes.ts +8 -4
  243. package/src/volume/resources.ts +1 -1
  244. package/src/volume/temporal.ts +11 -0
  245. package/dist/chunk-4NNZQCL3.mjs.map +0 -1
  246. package/dist/chunk-6TFBXLRJ.mjs.map +0 -1
  247. package/dist/chunk-DRR7D3KX.mjs.map +0 -1
  248. package/dist/chunk-GWCEMDVR.mjs.map +0 -1
  249. package/dist/chunk-JCIRYQ4V.mjs.map +0 -1
  250. package/dist/chunk-JRIPCMYC.mjs.map +0 -1
@@ -3,6 +3,7 @@ import { createShaderModule, rhi } from '@forgeax/engine-rhi-webgpu';
3
3
  import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
4
4
  import { describe, expect, it } from 'vitest';
5
5
  import { BYTES_PER_DIRECT_LIGHT_SLOT } from '../light-buffer-layout';
6
+ import { VOLUMETRIC_FOG_PARAMS_BYTES } from '../volume/resources';
6
7
 
7
8
  const MAP_READ = 0x0001;
8
9
  const BUFFER_COPY_DST = 0x0008;
@@ -19,6 +20,13 @@ const BYTES_PER_PIXEL = 8;
19
20
  const MAX_CLUSTER_LIGHTS = 256;
20
21
  const CLUSTER_LIGHT_DATA_BYTES = MAX_CLUSTER_LIGHTS * BYTES_PER_DIRECT_LIGHT_SLOT;
21
22
  const CLUSTER_UNIFORM_BYTES = 32;
23
+ const VOLUME_BOUNDS_MIN_Z = 0.1;
24
+ const VOLUME_BOUNDS_MAX_Z = 0.9;
25
+ const NON_UNIFORM_BOUNDS_MAX_Z = 8.1;
26
+ const MAX_NON_UNIFORM_BEER_ERROR = 0.001;
27
+ const NON_UNIFORM_EXTINCTION = 0.05;
28
+ const MIN_NON_UNIFORM_REFERENCE_SEPARATION = 0.05;
29
+ const MIN_NON_UNIFORM_GPU_SEPARATION = 0.05;
22
30
  const engineManifest = await buildEngineShaderManifest();
23
31
 
24
32
  interface StageValues {
@@ -33,6 +41,106 @@ interface VolumeStageReadbacks {
33
41
  readonly integrate: StageValues;
34
42
  }
35
43
 
44
+ interface StageReadbackOptions {
45
+ readonly sceneDepthClear?: number;
46
+ readonly densityByte?: number;
47
+ /** Optional RGBA8 density field for the production integrate stage. */
48
+ readonly densityData?: Uint8Array;
49
+ readonly boundsMinZ?: number;
50
+ readonly boundsMaxZ?: number;
51
+ readonly maxDistance?: number;
52
+ readonly viewDepthScale?: number;
53
+ readonly extinction?: number;
54
+ readonly frameIndex?: number;
55
+ /** Depth clear used by the stable shadow receiver fixture. */
56
+ readonly shadowDepthClear?: number;
57
+ /** Unshadowed punctual light isolates ray integration from packed visibility. */
58
+ readonly localLight?: 'point' | 'spot';
59
+ /** Uniform-step diagnostic; omitted means the untouched production shader. */
60
+ readonly integratorStepCount?: number;
61
+ }
62
+
63
+ interface DensityField {
64
+ readonly data: Uint8Array;
65
+ readonly zRed: readonly number[];
66
+ }
67
+
68
+ function createNonUniformDensityField(redValues?: readonly number[]): DensityField {
69
+ const data = new Uint8Array(4 * SIZE * SIZE * SIZE);
70
+ const zRed: number[] = [];
71
+ for (let z = 0; z < SIZE; z += 1) {
72
+ // Keep X/Y constant. The default field changes only the first Z texel;
73
+ // the fine-density probe supplies a faster, seam-matched sequence.
74
+ // The exercised camera ray crosses the first-to-second texel transition
75
+ // under all three production density scales, so flattening the field to
76
+ // its first texel produces a measurable, independently falsifiable result.
77
+ const red = redValues?.[z] ?? (z === 0 ? 128 : 255);
78
+ zRed.push(red / 255);
79
+ for (let y = 0; y < SIZE; y += 1) {
80
+ for (let x = 0; x < SIZE; x += 1) {
81
+ const offset = ((z * SIZE + y) * SIZE + x) * 4;
82
+ data[offset] = red;
83
+ data[offset + 1] = 255;
84
+ data[offset + 2] = 255;
85
+ data[offset + 3] = 255;
86
+ }
87
+ }
88
+ }
89
+ return { data, zRed };
90
+ }
91
+
92
+ function createFlattenedDensityField(field: DensityField): DensityField {
93
+ const red = Math.round((field.zRed[0] ?? 0) * 255);
94
+ const data = new Uint8Array(4 * SIZE * SIZE * SIZE);
95
+ for (let z = 0; z < SIZE; z += 1) {
96
+ for (let y = 0; y < SIZE; y += 1) {
97
+ for (let x = 0; x < SIZE; x += 1) {
98
+ const offset = ((z * SIZE + y) * SIZE + x) * 4;
99
+ data[offset] = red;
100
+ data[offset + 1] = 255;
101
+ data[offset + 2] = 255;
102
+ data[offset + 3] = 255;
103
+ }
104
+ }
105
+ }
106
+ return { data, zRed: Array.from({ length: SIZE }, () => red / 255) };
107
+ }
108
+
109
+ function sampleClampedLinear(values: readonly number[], coordinate: number): number {
110
+ const scaled = coordinate * values.length - 0.5;
111
+ const lower = Math.floor(scaled);
112
+ const upper = lower + 1;
113
+ const interpolation = scaled - lower;
114
+ const clampIndex = (index: number): number => Math.max(0, Math.min(values.length - 1, index));
115
+ const lowerValue = values[clampIndex(lower)] ?? 0;
116
+ const upperValue = values[clampIndex(upper)] ?? 0;
117
+ return lowerValue + (upperValue - lowerValue) * interpolation;
118
+ }
119
+
120
+ function nonUniformScatteringDensity(field: DensityField, worldZ: number): number {
121
+ let grain = sampleClampedLinear(field.zRed, (worldZ * 0.1) % 1) + 0.5;
122
+ grain *= sampleClampedLinear(field.zRed, (worldZ * 0.05) % 1) + 0.5;
123
+ grain *= sampleClampedLinear(field.zRed, (worldZ * 0.02) % 1) + 0.5;
124
+ return Math.max(2 * grain - 1, 0);
125
+ }
126
+
127
+ function nonUniformBeerReference(
128
+ field: DensityField,
129
+ extinction = 1,
130
+ sampleCount = 16_384,
131
+ boundsMinZ = VOLUME_BOUNDS_MIN_Z,
132
+ boundsMaxZ = VOLUME_BOUNDS_MAX_Z,
133
+ ): number {
134
+ const interval = boundsMaxZ - boundsMinZ;
135
+ const segmentLength = interval / sampleCount;
136
+ let opticalDepth = 0;
137
+ for (let sample = 0; sample < sampleCount; sample += 1) {
138
+ const worldZ = boundsMinZ + (sample + 0.5) * segmentLength;
139
+ opticalDepth += nonUniformScatteringDensity(field, worldZ) * segmentLength;
140
+ }
141
+ return Math.exp(-extinction * opticalDepth);
142
+ }
143
+
36
144
  function halfToFloat(bits: number): number {
37
145
  const sign = (bits & 0x8000) === 0 ? 1 : -1;
38
146
  const exponent = (bits >>> 10) & 0x1f;
@@ -42,12 +150,50 @@ function halfToFloat(bits: number): number {
42
150
  return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
43
151
  }
44
152
 
153
+ function firstStageValue(values: readonly number[], label: string): number {
154
+ const value = values[0];
155
+ if (value === undefined) throw new Error(`missing stage readback value: ${label}`);
156
+ return value;
157
+ }
158
+
45
159
  function manifestShader(marker: string): string {
46
160
  const entry = engineManifest.entries.find(({ wgsl }) => wgsl.includes(marker));
47
161
  if (entry === undefined) throw new Error(`missing cooked shader entry: ${marker}`);
48
162
  return entry.wgsl;
49
163
  }
50
164
 
165
+ function productionIntegratorShader(stepCount?: number): string {
166
+ const source = manifestShader('fn volume_integrate');
167
+ if (stepCount === undefined) return source;
168
+ if (!Number.isInteger(stepCount) || stepCount < 1) {
169
+ throw new Error(`invalid production integrator step count: ${stepCount}`);
170
+ }
171
+ const functionStart = source.indexOf('fn volume_integrate');
172
+ if (functionStart < 0) throw new Error('production integrator function missing');
173
+ const prefix = source.slice(0, functionStart);
174
+ const body = source.slice(functionStart);
175
+ // The cooked manifest constant-folds the source-level loop count into the
176
+ // step-length divisor and loop guard. Replace both literals in that one
177
+ // production function to compile the same WGSL with a diagnostic schedule;
178
+ // an omitted override always exercises the unmodified production manifest.
179
+ // Diagnostic schedules disable local refinement to expose the old defect.
180
+ const productionCount = body.match(/< (\d+)u\b/)?.[1];
181
+ if (productionCount === undefined) throw new Error('production ray loop count missing');
182
+ const stepLengthMarker = new RegExp(`f32\\(${productionCount}u\\)`, 'g');
183
+ const loopGuardMarker = new RegExp(`< ${productionCount}u\\b`, 'g');
184
+ const stepLengthOccurrences = body.match(stepLengthMarker)?.length ?? 0;
185
+ const loopGuardOccurrences = body.match(loopGuardMarker)?.length ?? 0;
186
+ if (stepLengthOccurrences !== 1 || loopGuardOccurrences !== 1) {
187
+ throw new Error(
188
+ `production integrator step markers: length=${stepLengthOccurrences}, guard=${loopGuardOccurrences}`,
189
+ );
190
+ }
191
+ return `${prefix}${body
192
+ .replace(stepLengthMarker, `f32(${stepCount}u)`)
193
+ .replace(loopGuardMarker, `< ${stepCount}u`)
194
+ .replace(/select\(1u, 4u,/, 'select(1u, 1u,')} `;
195
+ }
196
+
51
197
  function identityMatrix(target: Float32Array, offset: number): void {
52
198
  target[offset] = 1;
53
199
  target[offset + 5] = 1;
@@ -74,12 +220,18 @@ async function requestDevice(): Promise<RhiDevice> {
74
220
  }
75
221
 
76
222
  async function runInjectReadback(
77
- sceneDepthClear = 1,
78
- densitySource: number | 'height-gradient' | 'depth-gradient' = 255,
79
- heightOffset = 0,
80
- frameIndex = 0,
81
- slopedShadow = false,
223
+ options: StageReadbackOptions = {},
82
224
  ): Promise<VolumeStageReadbacks> {
225
+ const sceneDepthClear = options.sceneDepthClear ?? 1;
226
+ const densityByte = options.densityByte ?? 255;
227
+ const boundsMinZ = options.boundsMinZ ?? VOLUME_BOUNDS_MIN_Z;
228
+ const boundsMaxZ = options.boundsMaxZ ?? VOLUME_BOUNDS_MAX_Z;
229
+ const maxDistance = options.maxDistance ?? 2;
230
+ const viewDepthScale = options.viewDepthScale ?? 1;
231
+ const extinction = options.extinction ?? 1;
232
+ const frameIndex = options.frameIndex ?? 0;
233
+ const shadowDepthClear = options.shadowDepthClear ?? 1;
234
+ const integratorStepCount = options.integratorStepCount;
83
235
  const device = await requestDevice();
84
236
  const moduleResult = await createShaderModule(device, {
85
237
  code: manifestShader('fn volume_inject'),
@@ -91,7 +243,7 @@ async function runInjectReadback(
91
243
  label: 'stage-readback.volume-temporal',
92
244
  });
93
245
  const integrateModule = await createShaderModule(device, {
94
- code: manifestShader('fn volume_integrate'),
246
+ code: productionIntegratorShader(integratorStepCount),
95
247
  label: 'stage-readback.volume-integrate',
96
248
  });
97
249
  if (!temporalModule.ok) throw temporalModule.error;
@@ -191,7 +343,7 @@ async function runInjectReadback(
191
343
  });
192
344
  const paramsBuffer = device.createBuffer({
193
345
  label: 'stage-readback.params',
194
- size: 128,
346
+ size: VOLUMETRIC_FOG_PARAMS_BYTES,
195
347
  usage: 0x0040 | BUFFER_COPY_DST,
196
348
  });
197
349
  const lightDataBuffer = device.createBuffer({
@@ -232,28 +384,36 @@ async function runInjectReadback(
232
384
 
233
385
  const viewData = new Float32Array(240);
234
386
  for (const offset of [0, 28, 44, 60, 76, 92, 196, 212]) identityMatrix(viewData, offset);
387
+ viewData[44 + 10] = viewDepthScale;
235
388
  viewData[24] = 0;
236
- viewData[25] = heightOffset;
237
- viewData[44 + 13] = heightOffset;
389
+ viewData[25] = 0;
238
390
  viewData[26] = -1;
239
- viewData[124] = slopedShadow ? 1 : 0;
240
- viewData[108] = 10;
241
- if (slopedShadow) viewData[28 + 2] = 0.5;
391
+ viewData[124] = 0;
242
392
  viewData[228] = 0.1;
243
393
  viewData[229] = 10;
244
394
  viewData[230] = 0;
245
395
  viewData[235] = 1;
246
- const paramsData = new Float32Array(32);
247
- paramsData.set([-1, heightOffset - 1, 0.1, 0], 0);
248
- paramsData.set([1, heightOffset + 1, 0.9, 0], 4);
249
- paramsData.set([1, 1, 1, 0], 8);
396
+ const paramsData = new Float32Array(VOLUMETRIC_FOG_PARAMS_BYTES / 4);
397
+ paramsData.set([-1, -1, boundsMinZ, 0], 0);
398
+ paramsData.set([1, 1, boundsMaxZ, 0], 4);
399
+ paramsData.set([extinction, extinction, extinction, 0], 8);
250
400
  paramsData.set([1, 1, 1, 0], 12);
251
401
  paramsData.set([0, 0, 0, 0], 16);
252
- paramsData.set([0, -1, 0, 0], 20);
402
+ paramsData.set([0, -1, 0, frameIndex], 20);
253
403
  paramsData.set([1, 1, 1, 0], 24);
254
- paramsData.set([2, 0, 0, 0], 28);
255
- paramsData[23] = frameIndex;
404
+ paramsData.set([maxDistance, 0, 0, 0], 28);
256
405
  const lightData = new Uint8Array(CLUSTER_LIGHT_DATA_BYTES);
406
+ if (options.localLight !== undefined) {
407
+ paramsData[30] = options.localLight === 'point' ? 1 : 2;
408
+ const light = new Float32Array(lightData.buffer);
409
+ light.set([0, 0, 5.37, 0, 1, 1, 1, Math.cos(Math.PI / 8), 0, 0, -1, Math.cos(Math.PI / 6)]);
410
+ // Zero shadow intensity makes this a pure punctual-light integral. No
411
+ // projected image or shadow map can hide an undersampled light peak.
412
+ new Uint32Array(lightData.buffer).set(
413
+ [options.localLight === 'point' ? 0 : 1, 0xffffffff, 0xffffffff, 0xffffffff],
414
+ 16,
415
+ );
416
+ }
257
417
  const clusterUniformData = new Float32Array(8);
258
418
  const clusterGrid = new Uint32Array(clusterUniformData.buffer);
259
419
  clusterGrid[0] = 1;
@@ -270,22 +430,12 @@ async function runInjectReadback(
270
430
  );
271
431
  if (!viewWrite.ok || !paramsWrite.ok || !lightDataWrite.ok || !clusterUniformWrite.ok)
272
432
  throw new Error('Dawn volume stage readback unavailable: uniform upload failed');
273
- const densityData = new Uint8Array((BYTES_PER_PIXEL / 2) * SIZE * SIZE * SIZE);
274
- for (let z = 0; z < SIZE; z++)
275
- for (let y = 0; y < SIZE; y++)
276
- for (let x = 0; x < SIZE; x++) {
277
- const value =
278
- densitySource === 'height-gradient'
279
- ? Math.round((y / (SIZE - 1)) * 255)
280
- : densitySource === 'depth-gradient'
281
- ? Math.round((z / (SIZE - 1)) * 255)
282
- : densitySource;
283
- densityData.fill(
284
- value,
285
- ((z * SIZE + y) * SIZE + x) * 4,
286
- ((z * SIZE + y) * SIZE + x + 1) * 4,
287
- );
288
- }
433
+ const densityData =
434
+ options.densityData ?? new Uint8Array((BYTES_PER_PIXEL / 2) * SIZE * SIZE * SIZE);
435
+ if (options.densityData === undefined) densityData.fill(densityByte);
436
+ if (densityData.length !== (BYTES_PER_PIXEL / 2) * SIZE * SIZE * SIZE) {
437
+ throw new Error(`Dawn volume stage readback density field length: ${densityData.length}`);
438
+ }
289
439
  const densityWrite = device.queue.writeTexture(
290
440
  { texture: densityTexture.value },
291
441
  densityData,
@@ -462,7 +612,7 @@ async function runInjectReadback(
462
612
  colorAttachments: [],
463
613
  depthStencilAttachment: {
464
614
  view,
465
- depthClearValue: index === 0 ? sceneDepthClear : slopedShadow ? 0.5 : 1,
615
+ depthClearValue: index === 0 ? sceneDepthClear : shadowDepthClear,
466
616
  depthLoadOp: 'clear',
467
617
  depthStoreOp: 'store',
468
618
  },
@@ -543,23 +693,112 @@ async function runInjectReadback(
543
693
  };
544
694
  }
545
695
 
696
+ // Independent radiative-transfer quadrature: a homogeneous medium and an
697
+ // unshadowed punctual source. The near-light peak and spot cone make the old
698
+ // 24 midpoint schedule visibly wrong even though its Beer transmittance passes.
699
+ function punctualReference(kind: 'point' | 'spot', samples = 16_384): number[] {
700
+ const sigma = 0.01 * (2 * (128 / 255 + 0.5) ** 3 - 1);
701
+ return Array.from({ length: SIZE * SIZE }, (_, pixel) => {
702
+ const x = (((pixel % SIZE) + 0.5) / SIZE) * 2 - 1;
703
+ const y = 1 - ((Math.floor(pixel / SIZE) + 0.5) / SIZE) * 2;
704
+ const ds = 20 / samples;
705
+ let radiance = 0;
706
+ for (let i = 0; i < samples; i += 1) {
707
+ const s = (i + 0.5) * ds;
708
+ const z = 0.1 + s;
709
+ const distanceSquared = x * x + y * y + (5.37 - z) ** 2;
710
+ const cosine = (5.37 - z) / Math.sqrt(distanceSquared);
711
+ const t = Math.max(
712
+ 0,
713
+ Math.min(
714
+ 1,
715
+ (cosine - Math.cos(Math.PI / 6)) / (Math.cos(Math.PI / 8) - Math.cos(Math.PI / 6)),
716
+ ),
717
+ );
718
+ const cone = kind === 'point' ? 1 : t * t * (3 - 2 * t);
719
+ radiance += ((sigma * Math.exp(-sigma * s) * cone) / distanceSquared) * ds;
720
+ }
721
+ return radiance;
722
+ });
723
+ }
724
+
546
725
  describe('volumetric fog production stage readback on Dawn', () => {
547
726
  it('keeps static shadow visibility stable across renderer frame phases', async () => {
548
- const first = await runInjectReadback(1, 255, 0, 0, true);
549
- const next = await runInjectReadback(1, 255, 0, 1, true);
727
+ const first = await runInjectReadback({ frameIndex: 0, shadowDepthClear: 0.5 });
728
+ const next = await runInjectReadback({ frameIndex: 1, shadowDepthClear: 0.5 });
729
+ expect(first.inject).toEqual(next.inject);
550
730
  expect(first.inject.red.some((value) => value === 0)).toBe(true);
551
731
  expect(first.inject.red.some((value) => value > 0.9)).toBe(true);
552
- expect(next.inject).toEqual(first.inject);
553
- });
554
- it('keeps a static depth-density field stable across renderer frame phases', async () => {
555
- const first = await runInjectReadback(1, 'depth-gradient', 0, 0);
556
- for (const frameIndex of [1, 7]) {
557
- const next = await runInjectReadback(1, 'depth-gradient', 0, frameIndex);
558
- expect(next.integrate.alpha).toEqual(first.integrate.alpha);
559
- expect(next.integrate.red).toEqual(first.integrate.red);
560
- }
561
- expect(first.integrate.alpha[0]).toBeCloseTo(Math.exp(-0.4), 2);
562
732
  });
733
+
734
+ it.each([
735
+ 'point',
736
+ 'spot',
737
+ ] as const)('resolves the %s light peak without coherent ray bands', async (localLight) => {
738
+ const reference = punctualReference(localLight);
739
+ const refined = punctualReference(localLight, 32_768);
740
+ expect(Math.max(...reference.map((v, i) => Math.abs(v - (refined[i] ?? 0)) / v))).toBeLessThan(
741
+ 0.0001,
742
+ );
743
+ const options = {
744
+ localLight,
745
+ densityByte: 128,
746
+ extinction: 0.01,
747
+ boundsMaxZ: 20.1,
748
+ maxDistance: 20.1,
749
+ viewDepthScale: 21,
750
+ };
751
+ const maximumRelativeError = (values: VolumeStageReadbacks): number =>
752
+ Math.max(
753
+ ...values.integrate.red.map(
754
+ (v, i) => Math.abs(v - (reference[i] ?? 0)) / (reference[i] ?? 1),
755
+ ),
756
+ );
757
+ const production = await runInjectReadback(options);
758
+ const undersampled = await runInjectReadback({ ...options, integratorStepCount: 24 });
759
+ const resolved = await runInjectReadback({ ...options, integratorStepCount: 96 });
760
+ const refinedGpu = await runInjectReadback({ ...options, integratorStepCount: 384 });
761
+ const errors = {
762
+ refined: maximumRelativeError(refinedGpu),
763
+ production: maximumRelativeError(production),
764
+ undersampled: maximumRelativeError(undersampled),
765
+ resolved: maximumRelativeError(resolved),
766
+ };
767
+ // biome-ignore lint/suspicious/noConsole: production GPU spatial regression evidence.
768
+ console.info('[volumetric-fog-stage-readback] punctual-convergence', localLight, errors);
769
+ expect(errors.undersampled).toBeGreaterThan(0.1);
770
+ expect(errors.refined).toBeLessThan(0.003);
771
+ expect(errors.production).toBeLessThan(0.03);
772
+ });
773
+
774
+ it('retains fine density variation away from punctual light refinement', async () => {
775
+ // Matching end texels avoid an artificial discontinuity at the repeated
776
+ // texture seam. The interior stripes expose coarse-step density aliasing.
777
+ const field = createNonUniformDensityField([0, 255, 0, 255, 0, 255, 0, 0]);
778
+ const reference = nonUniformBeerReference(field, 0.05, 16_384, 0.1, 20.1);
779
+ const refined = nonUniformBeerReference(field, 0.05, 32_768, 0.1, 20.1);
780
+ expect(Math.abs(reference - refined)).toBeLessThan(0.0001);
781
+ const options = {
782
+ densityData: field.data,
783
+ extinction: 0.05,
784
+ boundsMaxZ: 20.1,
785
+ maxDistance: 20.1,
786
+ viewDepthScale: 21,
787
+ };
788
+ const production = firstStageValue(
789
+ (await runInjectReadback(options)).integrate.alpha,
790
+ 'fine density',
791
+ );
792
+ const coarse = firstStageValue(
793
+ (await runInjectReadback({ ...options, integratorStepCount: 24 })).integrate.alpha,
794
+ 'coarse density',
795
+ );
796
+ // biome-ignore lint/suspicious/noConsole: independent fine-density regression evidence.
797
+ console.info('[volumetric-fog-stage-readback] fine-density', { reference, production, coarse });
798
+ expect(Math.abs(coarse - reference)).toBeGreaterThan(0.005);
799
+ expect(Math.abs(production - reference)).toBeLessThan(0.002);
800
+ });
801
+
563
802
  it('injects raw density then resolves a 2d integrated volume', async () => {
564
803
  const values = await runInjectReadback();
565
804
  // biome-ignore lint/suspicious/noConsole: stage readback is machine evidence
@@ -580,23 +819,170 @@ describe('volumetric fog production stage readback on Dawn', () => {
580
819
  });
581
820
 
582
821
  it('clips the final ray step to the scene-depth segment', async () => {
583
- const values = await runInjectReadback(0.57);
822
+ const values = await runInjectReadback({ sceneDepthClear: 0.57 });
584
823
  const transmittance = values.integrate.alpha[0];
585
- // White encodes density one; integrate only the visible 0.47-unit segment.
586
- expect(transmittance).toBeCloseTo(Math.exp(-0.47), 2);
824
+ // The fixture uploads an all-white source texture. The authored Three
825
+ // smoke expression therefore evaluates to 2 * 1.5^3 - 1 = 5.75 before
826
+ // Beer-Lambert consumes it; only the final 0.47-unit segment is allowed
827
+ // to contribute after the scene-depth clip.
828
+ const sourceDensity = 2 * 1.5 ** 3 - 1;
829
+ expect(transmittance).toBeCloseTo(Math.exp(-0.47 * sourceDensity), 2);
587
830
  expect(values.integrate.red[0]).toBeGreaterThan(0);
588
831
  });
589
- it.each([0, 64, 128])('preserves authored uniform density %s/255', async (densityByte) => {
590
- const values = await runInjectReadback(0.57, densityByte);
591
- expect(values.integrate.alpha[0]).toBeCloseTo(Math.exp((-0.47 * densityByte) / 255), 2);
592
- if (densityByte === 0) expect(values.integrate.red.every((value) => value === 0)).toBe(true);
832
+
833
+ it('keeps a zero medium transparent and applies Beer-Lambert monotonically', async () => {
834
+ const empty = await runInjectReadback({ densityByte: 0 });
835
+ const medium = await runInjectReadback({ densityByte: 128 });
836
+ const dense = await runInjectReadback({ densityByte: 255 });
837
+ const emptyScattering = firstStageValue(empty.integrate.red, 'empty scattering');
838
+ const denseScattering = firstStageValue(dense.integrate.red, 'dense scattering');
839
+ const mediumScattering = firstStageValue(medium.integrate.red, 'medium scattering');
840
+ const emptyTransmission = firstStageValue(empty.integrate.alpha, 'empty transmission');
841
+ const mediumTransmission = firstStageValue(medium.integrate.alpha, 'medium transmission');
842
+ const denseTransmission = firstStageValue(dense.integrate.alpha, 'dense transmission');
843
+ expect(emptyScattering).toBeCloseTo(0, 2);
844
+ expect(emptyTransmission).toBeCloseTo(1, 2);
845
+ expect(denseTransmission).toBeLessThan(mediumTransmission);
846
+ expect(mediumTransmission).toBeLessThan(emptyTransmission);
847
+ expect(denseScattering).toBeGreaterThan(mediumScattering);
593
848
  });
594
849
 
595
- it.each([
596
- 0, 20,
597
- ])('maps height density through world bounds translated by %s', async (heightOffset) => {
598
- const values = await runInjectReadback(0.57, 'height-gradient', heightOffset);
599
- expect(values.integrate.alpha[0]).toBeCloseTo(Math.exp(-0.47), 2);
600
- expect(values.integrate.alpha[(SIZE - 1) * SIZE]).toBeCloseTo(1, 3);
850
+ it('keeps extinction monotonic and bounds temporal jitter spread', async () => {
851
+ const lowExtinction = await runInjectReadback({ densityByte: 128, extinction: 0.25 });
852
+ const highExtinction = await runInjectReadback({ densityByte: 128, extinction: 1 });
853
+ const lowTransmission = firstStageValue(lowExtinction.integrate.alpha, 'low extinction');
854
+ const highTransmission = firstStageValue(highExtinction.integrate.alpha, 'high extinction');
855
+ expect(highTransmission).toBeLessThan(lowTransmission);
856
+
857
+ const samples: number[] = [];
858
+ for (let frameIndex = 0; frameIndex < 4; frameIndex += 1) {
859
+ const value = await runInjectReadback({ densityByte: 128, frameIndex });
860
+ const transmission = firstStageValue(value.integrate.alpha, `frame ${frameIndex}`);
861
+ expect(Number.isFinite(transmission)).toBe(true);
862
+ samples.push(transmission);
863
+ }
864
+ const spread = Math.max(...samples) - Math.min(...samples);
865
+ // Frame ordinals must not change a fixed World-time density integral.
866
+ // This remains separate from continuous-frame history and spatial accuracy.
867
+ expect(spread).toBeLessThan(0.2);
868
+ });
869
+
870
+ it('converges production step-count variants to the homogeneous Beer oracle', async () => {
871
+ const sceneDepthClear = 0.57;
872
+ const sourceDensity = 2 * 1.5 ** 3 - 1;
873
+ const oracle = Math.exp(-0.47 * sourceDensity);
874
+ const transmissions = await Promise.all(
875
+ [6, 12, 24].map(async (integratorStepCount) => {
876
+ const values = await runInjectReadback({
877
+ sceneDepthClear,
878
+ integratorStepCount,
879
+ });
880
+ return firstStageValue(values.integrate.alpha, `${integratorStepCount}-step transmission`);
881
+ }),
882
+ );
883
+ for (const transmission of transmissions) {
884
+ expect(transmission).toBeCloseTo(oracle, 2);
885
+ }
886
+ expect(Math.max(...transmissions) - Math.min(...transmissions)).toBeLessThan(0.01);
887
+ });
888
+
889
+ it('converges production steps against an independent non-uniform Beer reference', async () => {
890
+ const field = createNonUniformDensityField();
891
+ const flattenedField = createFlattenedDensityField(field);
892
+ const reference = nonUniformBeerReference(
893
+ field,
894
+ NON_UNIFORM_EXTINCTION,
895
+ 16_384,
896
+ VOLUME_BOUNDS_MIN_Z,
897
+ NON_UNIFORM_BOUNDS_MAX_Z,
898
+ );
899
+ const refinedReference = nonUniformBeerReference(
900
+ field,
901
+ NON_UNIFORM_EXTINCTION,
902
+ 32_768,
903
+ VOLUME_BOUNDS_MIN_Z,
904
+ NON_UNIFORM_BOUNDS_MAX_Z,
905
+ );
906
+ const flattenedReference = nonUniformBeerReference(
907
+ flattenedField,
908
+ NON_UNIFORM_EXTINCTION,
909
+ 16_384,
910
+ VOLUME_BOUNDS_MIN_Z,
911
+ NON_UNIFORM_BOUNDS_MAX_Z,
912
+ );
913
+ expect(Math.abs(reference - refinedReference)).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR / 10);
914
+ expect(Math.abs(reference - flattenedReference)).toBeGreaterThan(
915
+ MIN_NON_UNIFORM_REFERENCE_SEPARATION,
916
+ );
917
+ const stepCounts = [24, 48, 96] as const;
918
+ const transmissions = await Promise.all(
919
+ stepCounts.map(async (integratorStepCount) => {
920
+ const values = await runInjectReadback({
921
+ boundsMaxZ: NON_UNIFORM_BOUNDS_MAX_Z,
922
+ densityData: field.data,
923
+ extinction: NON_UNIFORM_EXTINCTION,
924
+ integratorStepCount,
925
+ maxDistance: NON_UNIFORM_BOUNDS_MAX_Z,
926
+ viewDepthScale: 10,
927
+ });
928
+ return firstStageValue(
929
+ values.integrate.alpha,
930
+ `${integratorStepCount}-step non-uniform transmission`,
931
+ );
932
+ }),
933
+ );
934
+ const flattenedValues = await runInjectReadback({
935
+ boundsMaxZ: NON_UNIFORM_BOUNDS_MAX_Z,
936
+ densityData: flattenedField.data,
937
+ extinction: NON_UNIFORM_EXTINCTION,
938
+ integratorStepCount: 96,
939
+ maxDistance: NON_UNIFORM_BOUNDS_MAX_Z,
940
+ viewDepthScale: 10,
941
+ });
942
+ const flattenedTransmission = firstStageValue(
943
+ flattenedValues.integrate.alpha,
944
+ 'flattened-field transmission',
945
+ );
946
+ const defaultValues = await runInjectReadback({
947
+ boundsMaxZ: NON_UNIFORM_BOUNDS_MAX_Z,
948
+ densityData: field.data,
949
+ extinction: NON_UNIFORM_EXTINCTION,
950
+ maxDistance: NON_UNIFORM_BOUNDS_MAX_Z,
951
+ viewDepthScale: 10,
952
+ });
953
+ const defaultTransmission = firstStageValue(
954
+ defaultValues.integrate.alpha,
955
+ 'production transmission',
956
+ );
957
+ const errors = transmissions.map((transmission) => Math.abs(transmission - reference));
958
+ const flattenedError = Math.abs(flattenedTransmission - flattenedReference);
959
+ const defaultError = Math.abs(defaultTransmission - reference);
960
+ // biome-ignore lint/suspicious/noConsole: non-uniform production convergence receipt.
961
+ console.info(
962
+ '[volumetric-fog-stage-readback] non-uniform-convergence',
963
+ JSON.stringify({
964
+ reference,
965
+ refinedReference,
966
+ flattenedReference,
967
+ stepCounts,
968
+ transmissions,
969
+ errors,
970
+ flattenedTransmission,
971
+ flattenedError,
972
+ defaultTransmission,
973
+ defaultError,
974
+ }),
975
+ );
976
+ for (const error of errors) expect(error).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
977
+ expect(flattenedError).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
978
+ expect(defaultError).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
979
+ expect(Math.abs((transmissions[2] ?? NaN) - flattenedTransmission)).toBeGreaterThan(
980
+ MIN_NON_UNIFORM_GPU_SEPARATION,
981
+ );
982
+ const spread = Math.max(...transmissions) - Math.min(...transmissions);
983
+ // rgba16float readback quantizes nearby step variants onto the same value,
984
+ // so strict error ordering is not a stable production contract. Keep the
985
+ // independent reference and require a bounded cross-step spread instead.
986
+ expect(spread).toBeLessThan(MAX_NON_UNIFORM_BEER_ERROR);
601
987
  });
602
988
  });
@@ -8,6 +8,7 @@ const signature: VolumeTemporalSignature = {
8
8
  densityGeneration: 3,
9
9
  width: 1280,
10
10
  height: 720,
11
+ worldTimeSeconds: 4,
11
12
  };
12
13
 
13
14
  describe('volumetric fog temporal contract', () => {
@@ -23,11 +24,37 @@ describe('volumetric fog temporal contract', () => {
23
24
  ['fog-revision', { ...signature, fogRevision: 3 }],
24
25
  ['light-revision', { ...signature, lightRevision: 8 }],
25
26
  ['density-generation', { ...signature, densityGeneration: 4 }],
27
+ ['simulation-time', { ...signature, worldTimeSeconds: 4.5 }],
26
28
  ['resize', { ...signature, width: 640, height: 360 }],
27
29
  ] as const)('resets history for %s', (reason, next) => {
28
30
  expect(resolveVolumeTemporalReset(signature, next)).toEqual({ reset: true, reason });
29
31
  });
30
32
 
33
+ it.each([1 / 144, 1 / 60, 1 / 30, 1 / 15])('retains animated history at dt=%s', (delta) => {
34
+ expect(
35
+ resolveVolumeTemporalReset(signature, { ...signature, worldTimeSeconds: 4 + delta }),
36
+ ).toEqual({ reset: false });
37
+ });
38
+
39
+ it.each([3.9, 4.101, NaN, Infinity])('rejects discontinuous time %s', (worldTimeSeconds) => {
40
+ expect(resolveVolumeTemporalReset(signature, { ...signature, worldTimeSeconds })).toEqual({
41
+ reset: true,
42
+ reason: 'simulation-time',
43
+ });
44
+ });
45
+
46
+ it('rejects changed clock availability', () => {
47
+ const { worldTimeSeconds: _time, ...withoutTime } = signature;
48
+ expect(resolveVolumeTemporalReset(signature, withoutTime)).toEqual({
49
+ reset: true,
50
+ reason: 'simulation-time',
51
+ });
52
+ expect(resolveVolumeTemporalReset(withoutTime, signature)).toEqual({
53
+ reset: true,
54
+ reason: 'simulation-time',
55
+ });
56
+ });
57
+
31
58
  it('rejects out-of-screen reprojection and depth discontinuity without NaN', () => {
32
59
  expect(
33
60
  resolveVolumeTemporalReset(signature, signature, { reprojection: 'out-of-screen' }),