@woosh/meep-engine 3.54.0 → 3.56.0

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 (101) hide show
  1. package/package.json +1 -1
  2. package/src/engine/ecs/transform/Transform64.d.ts +21 -0
  3. package/src/engine/ecs/transform/Transform64.d.ts.map +1 -1
  4. package/src/engine/ecs/transform/Transform64.js +26 -0
  5. package/src/shade/RENDERER_CONTRACT.md +5 -2
  6. package/src/shade/device/SHADE_DEVICE_FLOOR.d.ts.map +1 -1
  7. package/src/shade/device/SHADE_DEVICE_FLOOR.js +2 -0
  8. package/src/shade/renderer/Renderer.d.ts +5 -0
  9. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  10. package/src/shade/renderer/Renderer.js +26 -3
  11. package/src/shade/renderer/hiz/generate_downsampler_code.js +1 -1
  12. package/src/shade/renderer/hiz/shader_hzb_reduce_mip0_clip.js +1 -1
  13. package/src/shade/renderer/particles/GPUParticleSystem.d.ts +2 -1
  14. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  15. package/src/shade/renderer/particles/GPUParticleSystem.js +1 -1
  16. package/src/shade/renderer/particles/graph_particles_avboit.d.ts +2 -2
  17. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  18. package/src/shade/renderer/particles/graph_particles_avboit.js +4 -9
  19. package/src/shade/renderer/postprocess/denoise/fragment_shader_denoise_ao.d.ts.map +1 -1
  20. package/src/shade/renderer/postprocess/denoise/fragment_shader_denoise_ao.js +154 -147
  21. package/src/shade/renderer/postprocess/gtao/GTAO.d.ts +2 -1
  22. package/src/shade/renderer/postprocess/gtao/GTAO.d.ts.map +1 -1
  23. package/src/shade/renderer/postprocess/gtao/GTAO.js +5 -1
  24. package/src/shade/renderer/postprocess/gtao/GTAO_CONSTANTS.d.ts +8 -0
  25. package/src/shade/renderer/postprocess/gtao/GTAO_CONSTANTS.d.ts.map +1 -1
  26. package/src/shade/renderer/postprocess/gtao/GTAO_CONSTANTS.js +10 -1
  27. package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.d.ts.map +1 -1
  28. package/src/shade/renderer/postprocess/gtao/ao_reproject_shader.js +5 -0
  29. package/src/shade/renderer/postprocess/gtao/chunk_gtao_compute_mip_level.d.ts +22 -0
  30. package/src/shade/renderer/postprocess/gtao/chunk_gtao_compute_mip_level.d.ts.map +1 -0
  31. package/src/shade/renderer/postprocess/gtao/chunk_gtao_compute_mip_level.js +33 -0
  32. package/src/shade/renderer/postprocess/gtao/chunk_gtao_resolve_hzb_depth.d.ts +22 -0
  33. package/src/shade/renderer/postprocess/gtao/chunk_gtao_resolve_hzb_depth.d.ts.map +1 -0
  34. package/src/shade/renderer/postprocess/gtao/chunk_gtao_resolve_hzb_depth.js +56 -0
  35. package/src/shade/renderer/postprocess/gtao/chunk_gtao_sample_depth.d.ts +13 -0
  36. package/src/shade/renderer/postprocess/gtao/chunk_gtao_sample_depth.d.ts.map +1 -0
  37. package/src/shade/renderer/postprocess/gtao/chunk_gtao_sample_depth.js +42 -0
  38. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.d.ts +2 -1
  39. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.d.ts.map +1 -1
  40. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao.js +382 -357
  41. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.d.ts.map +1 -1
  42. package/src/shade/renderer/postprocess/gtao/fragment_shader_gtao_with_irradiance.js +414 -410
  43. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  44. package/src/shade/renderer/postprocess/ssr/prefilter/NOTES.md +0 -7
  45. package/src/shade/renderer/view/GPUViewContext.d.ts +0 -14
  46. package/src/shade/renderer/view/GPUViewContext.d.ts.map +1 -1
  47. package/src/shade/renderer/view/GPUViewContext.js +0 -29
  48. package/src/shade/renderer/view/ViewManager.d.ts +1 -1
  49. package/src/shade/renderer/view/ViewManager.js +1 -1
  50. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts +40 -3
  51. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  52. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +660 -583
  53. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_local_light.d.ts +10 -5
  54. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_local_light.d.ts.map +1 -1
  55. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_local_light.js +30 -14
  56. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_sample.d.ts.map +1 -1
  57. package/src/shade/renderer/volumetrics/irradiance_clipmap/chunk_irradiance_clipmap_sample.js +17 -3
  58. package/src/shade/renderer/wind/GPUSceneWind.d.ts +1 -1
  59. package/src/engine/graphics3/decal/DecalAtlas.d.ts +0 -43
  60. package/src/engine/graphics3/decal/DecalAtlas.d.ts.map +0 -1
  61. package/src/engine/graphics3/decal/DecalAtlasFill.d.ts +0 -12
  62. package/src/engine/graphics3/decal/DecalAtlasFill.d.ts.map +0 -1
  63. package/src/engine/graphics3/decal/decal_atlas_border.d.ts +0 -52
  64. package/src/engine/graphics3/decal/decal_atlas_border.d.ts.map +0 -1
  65. package/src/shade/renderer/postprocess/gtao/graph_build_depth_mipmap.d.ts +0 -26
  66. package/src/shade/renderer/postprocess/gtao/graph_build_depth_mipmap.d.ts.map +0 -1
  67. package/src/shade/renderer/postprocess/gtao/graph_build_depth_mipmap.js +0 -268
  68. package/src/shade/renderer/postprocess/ssr/copy_shader_f32.d.ts +0 -3
  69. package/src/shade/renderer/postprocess/ssr/copy_shader_f32.d.ts.map +0 -1
  70. package/src/shade/renderer/postprocess/ssr/copy_shader_f32.js +0 -32
  71. package/src/shade/renderer/postprocess/ssr/depth_max_shader.d.ts +0 -17
  72. package/src/shade/renderer/postprocess/ssr/depth_max_shader.d.ts.map +0 -1
  73. package/src/shade/renderer/postprocess/ssr/depth_max_shader.js +0 -98
  74. package/src/shade/renderer/postprocess/ssr/graph_build_depth_max_pyramid.d.ts +0 -23
  75. package/src/shade/renderer/postprocess/ssr/graph_build_depth_max_pyramid.d.ts.map +0 -1
  76. package/src/shade/renderer/postprocess/ssr/graph_build_depth_max_pyramid.js +0 -118
  77. package/src/shade/renderer/volumetrics/VOLUMETRICS_RESOLUTION_Z.d.ts +0 -2
  78. package/src/shade/renderer/volumetrics/VOLUMETRICS_RESOLUTION_Z.d.ts.map +0 -1
  79. package/src/shade/renderer/volumetrics/VOLUMETRICS_RESOLUTION_Z.js +0 -1
  80. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_sample.d.ts +0 -24
  81. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_sample.d.ts.map +0 -1
  82. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_froxel_centre_ws.d.ts +0 -15
  83. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_froxel_centre_ws.d.ts.map +0 -1
  84. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.d.ts +0 -17
  85. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi.d.ts.map +0 -1
  86. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi_sample.d.ts +0 -35
  87. package/src/shade/renderer/volumetrics/gi/chunk_volumetrics_gi_sample.d.ts.map +0 -1
  88. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.d.ts +0 -35
  89. package/src/shade/renderer/volumetrics/gi/graph_build_froxel_gi.d.ts.map +0 -1
  90. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi.d.ts +0 -10
  91. package/src/shade/renderer/volumetrics/gi/shader_volumetrics_gi.d.ts.map +0 -1
  92. package/src/shade/renderer/volumetrics/gi/volumetrics_gi_resources.d.ts +0 -28
  93. package/src/shade/renderer/volumetrics/gi/volumetrics_gi_resources.d.ts.map +0 -1
  94. package/src/shade/renderer/volumetrics/graph_build_froxel_irradiance.d.ts +0 -48
  95. package/src/shade/renderer/volumetrics/graph_build_froxel_irradiance.d.ts.map +0 -1
  96. package/src/shade/renderer/volumetrics/shader_volumetrics_build_irradiance.d.ts +0 -28
  97. package/src/shade/renderer/volumetrics/shader_volumetrics_build_irradiance.d.ts.map +0 -1
  98. package/src/shade/renderer/volumetrics/shader_volumetrics_classify_froxels.d.ts +0 -16
  99. package/src/shade/renderer/volumetrics/shader_volumetrics_classify_froxels.d.ts.map +0 -1
  100. package/src/shade/renderer/volumetrics/shader_volumetrics_lighting_regimes.d.ts +0 -23
  101. package/src/shade/renderer/volumetrics/shader_volumetrics_lighting_regimes.d.ts.map +0 -1
@@ -1,583 +1,660 @@
1
- import { GPUTypedBuffer } from "../buffer/GPUTypedBuffer.js";
2
- import { build_light_cluster_parameters } from "../light/cluster/build_light_cluster_parameters.js";
3
- import { ShaderSwitchSet } from "../shader/compiler/ShaderSwitchSet.js";
4
- import { graph_import_buffer } from "../shader/graph/graph_import_buffer.js";
5
- import { graph_import_texture } from "../shader/graph/graph_import_texture.js";
6
- import { update_frustum_far_plane } from "../shadow/map/update_frustum_far_plane.js";
7
- import { GPUTextureContext } from "../texture/GPUTextureContext.js";
8
- import { slice_flat_array } from "../util/slice_flat_array.js";
9
- import {
10
- build_volumetrics_sun_shadow_transform,
11
- volumetrics_sun_shadow_cascade_far
12
- } from "./build_volumetrics_sun_shadow_transform.js";
13
- import { graph_build_volumetrics } from "./graph_build_volumetrics.js";
14
- import { GPUIrradianceClipmap } from "./irradiance_clipmap/GPUIrradianceClipmap.js";
15
- import { VOLUMETRICS_METADATA_STRUCT } from "./VOLUMETRICS_METADATA_STRUCT.js";
16
- import { VOLUMETRICS_RESOLUTION_Z } from "./VOLUMETRICS_RESOLUTION_Z.js";
17
- import {
18
- VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT,
19
- VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO,
20
- VOLUMETRICS_SUN_SHADOW_LIGHT_NONE,
21
- VOLUMETRICS_SUN_SHADOW_RESOLUTION
22
- } from "./VOLUMETRICS_SUN_SHADOW_RESOLUTION.js";
23
-
24
- /**
25
- * @typedef {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} FrameGraph
26
- * @typedef {import("../view/GPUViewContext.js").GPUViewContext} GPUViewContext
27
- */
28
-
29
- /**
30
- * resolution of a single froxel in XY, in pixels
31
- */
32
- const TILE_SIZE = 16;
33
-
34
- export class GPUViewVolumetrics {
35
-
36
- /**
37
- * @type {GPUViewContext}
38
- */
39
- #view
40
-
41
- /**
42
- * NOTE: Initialized in the constructor
43
- * @type {GPUTextureContext[]}
44
- */
45
- #volumetrics_textures = [];
46
-
47
- /**
48
- * Should we perform temporal integration, or not?
49
- * If set to true - the next update will discard history.
50
- * Useful for significant scene transitions and screen resizing.
51
- * @type {boolean}
52
- */
53
- #reset_history = true;
54
-
55
- /**
56
- * Whether to run the 3D temporal reprojection (volumetric TAA) over the per-froxel scattering
57
- * volume. When disabled, the aerial pass integrates the raw (noisier) scattering LUT directly —
58
- * a safe fallback if temporal artifacts appear.
59
- * @type {boolean}
60
- */
61
- #taa_enabled = true;
62
-
63
- /**
64
- * @returns {boolean}
65
- */
66
- get taa_enabled() {
67
- return this.#taa_enabled;
68
- }
69
-
70
- /**
71
- * @param {boolean} v
72
- */
73
- set taa_enabled(v) {
74
- if (v === this.#taa_enabled) {
75
- return;
76
- }
77
- this.#taa_enabled = v;
78
- // re-enabling must not blend against a stale / never-written history texture
79
- this.#reset_history = true;
80
- }
81
-
82
- /**
83
- * View-space distance at which the froxel grid ends and the aerial pass switches to a direct
84
- * RDR2-style volume ray-march. Clamped to camera.far at upload, so the default (Infinity) means
85
- * "grid covers the whole range" and the far march is a no-op.
86
- * @type {number}
87
- */
88
- #froxel_far = Infinity;
89
-
90
- /**
91
- * The froxel depth curve uploaded last frame. The TAA history is addressed on the curve it was
92
- * accumulated with, so when the curve changes the history is wrong at every slice and must be
93
- * dropped. Comparing the built parameters catches every input to the curve at once -- camera
94
- * near, camera far, the froxel-far override -- rather than watching each separately.
95
- * @type {Float32Array}
96
- */
97
- #froxel_curve_previous = new Float32Array(3);
98
-
99
- /**
100
- * @returns {number}
101
- */
102
- get froxel_far() {
103
- return this.#froxel_far;
104
- }
105
-
106
- /**
107
- * @param {number} v
108
- */
109
- set froxel_far(v) {
110
- this.#froxel_far = v;
111
- }
112
-
113
- /**
114
- * Q-04. World -> volume and back for each cascade, innermost first, rebuilt every frame by
115
- * {@link build_volumetrics_sun_shadow_transform} and uploaded in the metadata.
116
- * @type {Float32Array[]}
117
- */
118
- #sun_shadow_ws_to_uvw = Array.from({ length: VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT }, () => new Float32Array(16));
119
-
120
- /**
121
- * @type {Float32Array[]}
122
- */
123
- #sun_shadow_uvw_to_ws = Array.from({ length: VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT }, () => new Float32Array(16));
124
-
125
- /**
126
- * Voxel counts one sun optical-depth cascade is dispatched at this frame. Collapses to
127
- * `[1, 1, 1]` when the froxels are marching instead, so the passes stay in the frame graph and
128
- * cost nothing — see `graph_build_sun_shadow_volume` for why they stay rather than going.
129
- * @type {number[]}
130
- */
131
- #sun_shadow_resolution = [1, 1, 1];
132
-
133
- /**
134
- * World-space size of one voxel along the sun axis per cascade, i.e. each scan's step, packed
135
- * for the `vec4f` the metadata carries.
136
- * @type {Float32Array}
137
- */
138
- #sun_shadow_step_ws = new Float32Array(4);
139
-
140
- /**
141
- * Which directional light this frame's cascades were built for, or
142
- * {@link VOLUMETRICS_SUN_SHADOW_LIGHT_NONE} when there was nothing to build for.
143
- * @type {number}
144
- */
145
- #sun_shadow_light_index = VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
146
-
147
- /**
148
- * View depth each cascade covers out to, packed the same way. Trailing lanes repeat the
149
- * outermost so a read past the end of the ladder still lands on a real cascade.
150
- * @type {Float32Array}
151
- */
152
- #sun_shadow_cascade_far = new Float32Array(4);
153
-
154
- /**
155
- * What the fog integrates to through no medium: nothing in-scattered (.rgb 0), everything
156
- * transmitted (.a 1). Handed out in place of the aerial LUT on a frame whose scene has no
157
- * participating-media volume, so every surface that samples the fog keeps doing so and reads
158
- * it as none, for one texel and no pass. Written once, at construction.
159
- * @type {GPUTextureContext}
160
- */
161
- #no_medium;
162
-
163
- /**
164
- * @type {GPUTypedBuffer}
165
- */
166
- #metadata;
167
-
168
- /**
169
- * World-space irradiance clipmap (`GPUIrradianceClipmap`) used by particle lighting.
170
- * @type {GPUIrradianceClipmap}
171
- */
172
- #irradiance_clipmap;
173
-
174
- /**
175
- * @type {GPUDevice}
176
- */
177
- #device
178
-
179
- /**
180
- *
181
- * @param {GPUViewContext} view
182
- * @param {GPUDevice} device
183
- */
184
- constructor(view, device) {
185
- this.#device = device;
186
- this.#view = view;
187
-
188
- // initialize textures
189
- for (let i = 0; i < 2; i++) {
190
- const texture = new GPUTextureContext(device);
191
-
192
- texture.descriptor.format = "rgba16float";
193
- texture.descriptor.dimension = "3d";
194
- texture.descriptor.usage = GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING;
195
-
196
- this.#volumetrics_textures[i] = texture;
197
- }
198
-
199
- const no_medium = new GPUTextureContext(device);
200
- no_medium.descriptor.label = "volumetrics/no medium";
201
- no_medium.descriptor.format = "rgba16float";
202
- no_medium.descriptor.dimension = "3d";
203
- no_medium.descriptor.size = [1, 1, 1];
204
- no_medium.descriptor.usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST;
205
-
206
- device.queue.writeTexture(
207
- { texture: no_medium.gpu_texture },
208
- new Float16Array([0, 0, 0, 1]),
209
- { bytesPerRow: 8, rowsPerImage: 1 },
210
- [1, 1, 1]
211
- );
212
- no_medium.incrementVersion();
213
-
214
- this.#no_medium = no_medium;
215
-
216
- this.#metadata = GPUTypedBuffer.create({
217
- type: VOLUMETRICS_METADATA_STRUCT,
218
- device,
219
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
220
- });
221
-
222
- this.#irradiance_clipmap = new GPUIrradianceClipmap(device);
223
-
224
- }
225
-
226
- /**
227
- * @returns {GPUIrradianceClipmap}
228
- */
229
- get irradiance_clipmap() {
230
- return this.#irradiance_clipmap;
231
- }
232
-
233
- destroy() {
234
-
235
- const textures = this.#volumetrics_textures;
236
-
237
- for (let i = 0; i < 2; i++) {
238
-
239
- textures[i].destroy();
240
-
241
- textures[i] = null;
242
- }
243
-
244
- this.#no_medium.destroy();
245
- this.#irradiance_clipmap.destroy();
246
-
247
- }
248
-
249
- #update_resolution() {
250
-
251
-
252
- const screen_resolution = this.#view.resolution;
253
-
254
- const output_resolution_x = Math.ceil(screen_resolution[0] / TILE_SIZE);
255
- const output_resolution_y = Math.ceil(screen_resolution[1] / TILE_SIZE);
256
-
257
- const t_first = this.#volumetrics_textures[0];
258
-
259
- if (t_first.width === output_resolution_x
260
- && t_first.height === output_resolution_y
261
- ) {
262
- // already at the right resolution
263
- return;
264
- }
265
-
266
- for (let i = 0; i < 2; i++) {
267
-
268
- this.#volumetrics_textures[i].resize(
269
- output_resolution_x,
270
- output_resolution_y,
271
- VOLUMETRICS_RESOLUTION_Z
272
- );
273
-
274
- }
275
-
276
- // drop history
277
- this.#reset_history = true;
278
- }
279
-
280
- /**
281
- * Place this frame's sun optical-depth cascades, and say which light they cover.
282
- *
283
- * This is how the sun's media self-shadowing is computed — there is no second path to select
284
- * between. The march in `chunk_integrate_optical_depth` remains for the lights the cascades do
285
- * not cover: every point and spot light, whose rays are short enough that there is little to
286
- * amortize, and any directional light past the first, since there is one ladder of boxes and it
287
- * is aligned to one direction. Two situations leave the cascades covering nothing at all, and
288
- * `VOLUMETRICS_SUN_SHADOW_LIGHT_NONE` is how they say so.
289
- *
290
- * The light is the first entry of the `light_directional` table, which is the first
291
- * `DirectionalLight` in scene order — `GPULightCollection.#build` packs them in that order, so
292
- * walking the same list here recovers the same index. In practice a scene has one sun.
293
- *
294
- * Every cascade is placed by the same call with a different `far`, which is what makes the
295
- * stability argument transfer: each box is sized from the bounding sphere of the sub-frustum it
296
- * covers, that sphere is a function of the projection alone, and the origin is snapped to whole
297
- * voxels — so each cascade's world-space lattice is the same set of points from frame to frame
298
- * no matter how the camera moves.
299
- *
300
- * @returns {number} the light index the cascades cover, or the sentinel
301
- */
302
- #update_sun_shadow() {
303
- const camera = this.#view.camera.camera;
304
-
305
- // One voxel per cascade rather than no pass at all: the lighting pass binds these textures
306
- // unconditionally and WebGPU has no optional bindings. Nothing samples them, because the
307
- // sentinel below matches no light index.
308
- this.#sun_shadow_resolution = [1, 1, 1];
309
-
310
- // an orthographic view has no fov to derive a bounding sphere from, and no volumetrics
311
- // camera path that reaches here today — bail rather than invent one
312
- if (typeof camera.fov !== "number") {
313
- return VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
314
- }
315
-
316
- const lights = this.#view.scene.lights.source.elements;
317
-
318
- let sun = null;
319
-
320
- for (let i = 0; i < lights.length; i++) {
321
- if (lights[i].isDirectionalLight === true) {
322
- sun = lights[i];
323
- break;
324
- }
325
- }
326
-
327
- if (sun === null) {
328
- return VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
329
- }
330
-
331
- const resolution = VOLUMETRICS_SUN_SHADOW_RESOLUTION;
332
-
333
- // `forward` is the direction light TRAVELS — the same vector light_record_directional
334
- // writes into the table, which the shader then negates to point at the light. Read off the
335
- // CPU copy of the pose: the cascade is built here, on this side.
336
- const direction = sun.transform.forward;
337
-
338
- const view_matrix = camera.view_matrix;
339
- const position = camera.transform;
340
-
341
- // the medium lives in the froxel grid and the grid stops here, so there is nothing to
342
- // integrate past it
343
- const far = Math.min(this.#froxel_far, camera.far);
344
-
345
- const cascade_count = VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT;
346
- const cascade_ratio = VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO;
347
-
348
- // the view matrix's z row, negated: view space looks down -Z, and the row is unit length
349
- // because the linear part is a rotation
350
- const camera_forward = [-view_matrix[2], -view_matrix[6], -view_matrix[10]];
351
-
352
- for (let cascade = 0; cascade < cascade_count; cascade++) {
353
-
354
- const cascade_far = volumetrics_sun_shadow_cascade_far(far, cascade, cascade_count, cascade_ratio);
355
-
356
- this.#sun_shadow_step_ws[cascade] = build_volumetrics_sun_shadow_transform({
357
- ws_to_uvw: this.#sun_shadow_ws_to_uvw[cascade],
358
- uvw_to_ws: this.#sun_shadow_uvw_to_ws[cascade],
359
- light_direction: [direction[0], direction[1], direction[2]],
360
- camera_position: [position.translation_x, position.translation_y, position.translation_z],
361
- camera_forward,
362
- fov_y: camera.fov,
363
- aspect: camera.aspect,
364
- near: camera.near,
365
- far: cascade_far,
366
- resolution,
367
- });
368
-
369
- this.#sun_shadow_cascade_far[cascade] = cascade_far;
370
- }
371
-
372
- // Trailing lanes repeat the outermost cascade rather than holding zero: the read side walks
373
- // the ladder comparing view depth against these, and a zero would read as a cascade that
374
- // ends at the camera.
375
- for (let lane = cascade_count; lane < 4; lane++) {
376
- this.#sun_shadow_cascade_far[lane] = far;
377
- this.#sun_shadow_step_ws[lane] = this.#sun_shadow_step_ws[cascade_count - 1];
378
- }
379
-
380
- this.#sun_shadow_resolution = resolution;
381
-
382
- return 0;
383
- }
384
-
385
- #update_metadata() {
386
- const view = this.#view;
387
- const camera = view.camera;
388
-
389
- const resolution = this.#volumetrics_textures[0].size;
390
-
391
- this.#sun_shadow_light_index = this.#update_sun_shadow();
392
-
393
- const volumetrics_cluster_parameters = new Float32Array(3);
394
-
395
- // The depth-curve `scale` must grow with the slice count. The hard-coded 4.06 was tuned for
396
- // the light-cluster grid (24 slices); reusing it with 64 froxel slices collapses the log
397
- // curve so ~half the slices bunch into the first fraction of a unit of depth, leaving almost
398
- // none for the actual scene. Derive scale for a log-uniform distribution over
399
- // [near + near_offset, far] instead (gives O ~= 0, i.e. depth ~ offset * (far/offset)^(s/(N-1))).
400
- const NEAR_OFFSET = 0.1;
401
- const depth_offset = camera.camera.near + NEAR_OFFSET;
402
- const depth_scale = (VOLUMETRICS_RESOLUTION_Z - 1) / Math.log2(camera.camera.far / depth_offset);
403
-
404
- build_light_cluster_parameters(
405
- volumetrics_cluster_parameters,
406
- 0,
407
- VOLUMETRICS_RESOLUTION_Z,
408
- camera.camera.near,
409
- camera.camera.far,
410
- NEAR_OFFSET,
411
- depth_scale
412
- );
413
-
414
- // Froxel-grid curve: same construction but ending at froxel_far (<= camera.far). The grid's
415
- // slices pack into [near, froxel_far] for extra near detail; beyond froxel_far the aerial
416
- // pass ray-marches the volumes. froxel_far == camera.far -> identical to the LUT curve (no-op).
417
- const froxel_far = Math.min(this.#froxel_far, camera.camera.far);
418
- const froxel_depth_scale = (VOLUMETRICS_RESOLUTION_Z - 1) / Math.log2(froxel_far / depth_offset);
419
- const volumetrics_froxel_cluster_parameters = new Float32Array(3);
420
- build_light_cluster_parameters(
421
- volumetrics_froxel_cluster_parameters,
422
- 0,
423
- VOLUMETRICS_RESOLUTION_Z,
424
- camera.camera.near,
425
- froxel_far,
426
- NEAR_OFFSET,
427
- froxel_depth_scale
428
- );
429
-
430
- // Recomputed deterministically, so a steady camera compares bitwise equal and never resets.
431
- {
432
- const curve = volumetrics_froxel_cluster_parameters;
433
- const previous = this.#froxel_curve_previous;
434
-
435
- if (curve[0] !== previous[0] || curve[1] !== previous[1] || curve[2] !== previous[2]) {
436
- previous.set(curve);
437
- this.#reset_history = true;
438
- }
439
- }
440
-
441
- // The projection is infinite-far reverse-Z, so the camera frustum's far plane is zeroed and
442
- // can't be sliced into froxel slabs. Rebuild a finite far plane at the logical far for the
443
- // participating-media coverage test — exactly as the light-cluster assignment does.
444
- const clipped_frustum = new Float32Array(24);
445
- clipped_frustum.set(camera.camera.frustum);
446
- update_frustum_far_plane(clipped_frustum, camera.camera.view_matrix, camera.camera.far);
447
-
448
- this.#metadata.upload({
449
- cluster_parameters: volumetrics_cluster_parameters,
450
- froxel_cluster_parameters: volumetrics_froxel_cluster_parameters,
451
- resolution,
452
- frustum: slice_flat_array(clipped_frustum, 0, 4, 6),
453
- history_reset: this.#reset_history ? 1 : 0,
454
- froxel_far,
455
- // multiscatter knobs (GUI-tunable via globals); engine defaults 6 / 0.5
456
- ms_octave_count: globalThis.__VOL_MS_OCTAVES ?? 6,
457
- ms_extinction_decay: globalThis.__VOL_MS_EXT_DECAY ?? 0.5,
458
- // Q-04. The sun's media self-shadowing, out of a 16-step per-froxel march and into one
459
- // fetch of the cascades built earlier this frame — see #update_sun_shadow.
460
- sun_shadow_ws_to_uvw: this.#sun_shadow_ws_to_uvw,
461
- sun_shadow_uvw_to_ws: this.#sun_shadow_uvw_to_ws,
462
- sun_shadow_step_ws: this.#sun_shadow_step_ws,
463
- sun_shadow_cascade_far: this.#sun_shadow_cascade_far,
464
- sun_shadow_light_index: this.#sun_shadow_light_index,
465
- }, this.#device.queue);
466
- }
467
-
468
- /**
469
- *
470
- * @param {Object} options
471
- * @param {FrameGraph} options.graph
472
- * @param {number} options.light_cluster_data
473
- * @param {number} options.light_cluster_lookup
474
- * @param {number} options.light_cluster_parameters
475
- * @param {number} options.shadowmap_atlas
476
- * @param {ShaderSwitchSet} [options.shader_switches]
477
- * @param {import("../global_illumination/brick4/Brick4LightMap.js").Brick4LightMap} [options.brick4]
478
- * @param {import("../global_illumination/lpv/GPULightProbeVolume.js").GPULightProbeVolume} [options.lpv]
479
- * @returns {{resolved: number, metadata: number, irradiance: {Y: number, CoCg: number, uniform: number}}}
480
- */
481
- graph_update({
482
- graph,
483
- light_cluster_data,
484
- light_cluster_lookup,
485
- light_cluster_parameters,
486
- shadowmap_atlas,
487
- shader_switches = ShaderSwitchSet.EMPTY,
488
- brick4,
489
- lpv,
490
- }) {
491
-
492
- const view = this.#view;
493
- const lights = view.scene.lights;
494
- const camera = view.camera;
495
-
496
- const taa_requested = globalThis.__VOL_TAA_ENABLED;
497
- if (taa_requested !== undefined && taa_requested !== this.#taa_enabled) {
498
- this.taa_enabled = taa_requested;
499
- }
500
-
501
- const froxel_far_requested = globalThis.__VOL_FROXEL_FAR;
502
- if (froxel_far_requested !== undefined && froxel_far_requested !== this.#froxel_far) {
503
- this.#froxel_far = froxel_far_requested;
504
- // the froxel grid's depth curve just changed; the TAA history is on the old curve, reset it
505
- this.#reset_history = true;
506
- }
507
-
508
- this.#update_resolution();
509
- this.#update_metadata();
510
-
511
- const gr_metadata = graph_import_buffer(graph, this.#metadata.buffer, "volumetrics metadata");
512
-
513
- const resolution = this.#volumetrics_textures[0].size;
514
-
515
- // World-space irradiance clipmap for particle lighting.
516
- // Culled by the frame graph when no lit particles read it.
517
- const irradiance = this.#irradiance_clipmap.graph_update({
518
- graph,
519
- camera,
520
- scene_ctx: view.scene,
521
- shadowmap_atlas,
522
- shader_switches,
523
- brick4,
524
- lpv,
525
- });
526
-
527
- if (view.scene.volumetrics.source.volumes.length === 0) {
528
- // No medium: nothing scatters and nothing absorbs, so the fog is the identity everywhere
529
- // and the passes that would integrate it to that are not recorded. Nothing accumulates
530
- // either, which leaves the history stale for whenever a volume arrives.
531
- this.#reset_history = true;
532
-
533
- return {
534
- resolved: graph_import_texture(graph, this.#no_medium, "volumetrics no medium"),
535
- metadata: gr_metadata,
536
- irradiance,
537
- };
538
- }
539
-
540
- let taa_history;
541
- let taa_output;
542
-
543
- if (this.#taa_enabled) {
544
- const parity = view.frame_index & 1;
545
- taa_history = graph_import_texture(graph, this.#volumetrics_textures[parity ^ 1], "volumetrics taa history");
546
- taa_output = graph_import_texture(graph, this.#volumetrics_textures[parity], "volumetrics taa output");
547
- }
548
-
549
- const sky = view.scene.sky;
550
-
551
- sky.update();
552
-
553
- const { lut_aerial } = graph_build_volumetrics({
554
- graph,
555
- camera,
556
- lights,
557
- view,
558
- sky,
559
- light_cluster_data,
560
- light_cluster_lookup,
561
- light_cluster_parameters,
562
- shadowmap_atlas,
563
-
564
- volumetrics_metadata: gr_metadata,
565
- volumetric_resolution: resolution,
566
- sun_shadow_resolution: this.#sun_shadow_resolution,
567
-
568
- taa_history,
569
- taa_output,
570
- camera_previous: view.gpu_previous_camera_state,
571
- shader_switches,
572
- });
573
-
574
- this.#reset_history = false;
575
-
576
- return {
577
- resolved: lut_aerial,
578
- metadata: gr_metadata,
579
- irradiance,
580
- };
581
- }
582
-
583
- }
1
+ import { assert } from "../../../core/assert.js";
2
+ import { GPUTypedBuffer } from "../buffer/GPUTypedBuffer.js";
3
+ import { build_light_cluster_parameters } from "../light/cluster/build_light_cluster_parameters.js";
4
+ import { ShaderSwitchSet } from "../shader/compiler/ShaderSwitchSet.js";
5
+ import { graph_import_buffer } from "../shader/graph/graph_import_buffer.js";
6
+ import { graph_import_texture } from "../shader/graph/graph_import_texture.js";
7
+ import { update_frustum_far_plane } from "../shadow/map/update_frustum_far_plane.js";
8
+ import { GPUTextureContext } from "../texture/GPUTextureContext.js";
9
+ import { slice_flat_array } from "../util/slice_flat_array.js";
10
+ import {
11
+ build_volumetrics_sun_shadow_transform,
12
+ volumetrics_sun_shadow_cascade_far
13
+ } from "./build_volumetrics_sun_shadow_transform.js";
14
+ import { graph_build_volumetrics } from "./graph_build_volumetrics.js";
15
+ import { GPUIrradianceClipmap } from "./irradiance_clipmap/GPUIrradianceClipmap.js";
16
+ import { VOLUMETRICS_METADATA_STRUCT } from "./VOLUMETRICS_METADATA_STRUCT.js";
17
+ import {
18
+ VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT,
19
+ VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO,
20
+ VOLUMETRICS_SUN_SHADOW_LIGHT_NONE,
21
+ VOLUMETRICS_SUN_SHADOW_RESOLUTION
22
+ } from "./VOLUMETRICS_SUN_SHADOW_RESOLUTION.js";
23
+
24
+ /**
25
+ * @typedef {import("../../../engine/graphics/render/frame_graph/FrameGraph.js").FrameGraph} FrameGraph
26
+ * @typedef {import("../view/GPUViewContext.js").GPUViewContext} GPUViewContext
27
+ */
28
+
29
+ export class GPUViewVolumetrics {
30
+
31
+ /**
32
+ * NOTE: Initialized in the constructor
33
+ * @type {GPUTextureContext[]}
34
+ */
35
+ #volumetrics_textures = [];
36
+
37
+ /**
38
+ * Should we perform temporal integration, or not?
39
+ * If set to true - the next update will discard history.
40
+ * Useful for significant scene transitions and screen resizing.
41
+ * @type {boolean}
42
+ */
43
+ #reset_history = true;
44
+
45
+ /**
46
+ * {@link GPUViewContext#id} of the view the history was accumulated from. The history is that
47
+ * view's fog; a frame from any other view must not blend against it.
48
+ * @type {number}
49
+ */
50
+ #history_view_id = -1;
51
+
52
+ /**
53
+ * Whether to run the 3D temporal reprojection (volumetric TAA) over the per-froxel scattering
54
+ * volume. When disabled, the aerial pass integrates the raw (noisier) scattering LUT directly —
55
+ * a safe fallback if temporal artifacts appear.
56
+ * @type {boolean}
57
+ */
58
+ #taa_enabled = true;
59
+
60
+ /**
61
+ * @returns {boolean}
62
+ */
63
+ get taa_enabled() {
64
+ return this.#taa_enabled;
65
+ }
66
+
67
+ /**
68
+ * @param {boolean} v
69
+ */
70
+ set taa_enabled(v) {
71
+ if (v === this.#taa_enabled) {
72
+ return;
73
+ }
74
+ this.#taa_enabled = v;
75
+ // re-enabling must not blend against a stale / never-written history texture
76
+ this.#reset_history = true;
77
+ }
78
+
79
+ /**
80
+ * Size of one froxel in XY, in screen pixels.
81
+ * @type {number}
82
+ */
83
+ #tile_size = 16;
84
+
85
+ /**
86
+ * Number of froxel slices along the view.
87
+ * @type {number}
88
+ */
89
+ #resolution_z = 64;
90
+
91
+ /**
92
+ * Size of one froxel in XY, in screen pixels. Smaller is finer fog detail at a cost that grows
93
+ * with the inverse square. The grid is resized on the next update and the TAA history dropped,
94
+ * so the frame after a change starts its accumulation over.
95
+ *
96
+ * The grid's XY extent, `ceil(view resolution / tile_size)`, must stay within the device's
97
+ * `maxTextureDimension3D`.
98
+ * @returns {number}
99
+ */
100
+ get tile_size() {
101
+ return this.#tile_size;
102
+ }
103
+
104
+ /**
105
+ * @param {number} v positive integer
106
+ */
107
+ set tile_size(v) {
108
+ assert.isPositiveInteger(v, 'tile_size');
109
+
110
+ this.#tile_size = v;
111
+ }
112
+
113
+ /**
114
+ * Number of froxel slices between the camera near and {@link froxel_far}. Cost grows linearly.
115
+ * The grid is resized on the next update and the TAA history dropped, as for {@link tile_size}.
116
+ * @returns {number}
117
+ */
118
+ get resolution_z() {
119
+ return this.#resolution_z;
120
+ }
121
+
122
+ /**
123
+ * @param {number} v integer, at least 2 (the depth curve needs a first and a last slice) and at
124
+ * most the device's `maxTextureDimension3D`
125
+ */
126
+ set resolution_z(v) {
127
+ assert.isPositiveInteger(v, 'resolution_z');
128
+ assert.greaterThanOrEqual(v, 2, 'resolution_z');
129
+ assert.lessThanOrEqual(v, this.#device.limits.maxTextureDimension3D, 'resolution_z must fit maxTextureDimension3D');
130
+
131
+ this.#resolution_z = v;
132
+ }
133
+
134
+ /**
135
+ * View-space distance at which the froxel grid ends and the aerial pass switches to a direct
136
+ * RDR2-style volume ray-march. Clamped to camera.far at upload, so the default (Infinity) means
137
+ * "grid covers the whole range" and the far march is a no-op.
138
+ * @type {number}
139
+ */
140
+ #froxel_far = Infinity;
141
+
142
+ /**
143
+ * The froxel depth curve uploaded last frame. The TAA history is addressed on the curve it was
144
+ * accumulated with, so when the curve changes the history is wrong at every slice and must be
145
+ * dropped. Comparing the built parameters catches every input to the curve at once -- camera
146
+ * near, camera far, the froxel-far override -- rather than watching each separately.
147
+ * @type {Float32Array}
148
+ */
149
+ #froxel_curve_previous = new Float32Array(3);
150
+
151
+ /**
152
+ * @returns {number}
153
+ */
154
+ get froxel_far() {
155
+ return this.#froxel_far;
156
+ }
157
+
158
+ /**
159
+ * @param {number} v
160
+ */
161
+ set froxel_far(v) {
162
+ this.#froxel_far = v;
163
+ }
164
+
165
+ /**
166
+ * Q-04. World -> volume and back for each cascade, innermost first, rebuilt every frame by
167
+ * {@link build_volumetrics_sun_shadow_transform} and uploaded in the metadata.
168
+ * @type {Float32Array[]}
169
+ */
170
+ #sun_shadow_ws_to_uvw = Array.from({ length: VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT }, () => new Float32Array(16));
171
+
172
+ /**
173
+ * @type {Float32Array[]}
174
+ */
175
+ #sun_shadow_uvw_to_ws = Array.from({ length: VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT }, () => new Float32Array(16));
176
+
177
+ /**
178
+ * Voxel counts one sun optical-depth cascade is dispatched at this frame. Collapses to
179
+ * `[1, 1, 1]` when the froxels are marching instead, so the passes stay in the frame graph and
180
+ * cost nothing — see `graph_build_sun_shadow_volume` for why they stay rather than going.
181
+ * @type {number[]}
182
+ */
183
+ #sun_shadow_resolution = [1, 1, 1];
184
+
185
+ /**
186
+ * World-space size of one voxel along the sun axis per cascade, i.e. each scan's step, packed
187
+ * for the `vec4f` the metadata carries.
188
+ * @type {Float32Array}
189
+ */
190
+ #sun_shadow_step_ws = new Float32Array(4);
191
+
192
+ /**
193
+ * Which directional light this frame's cascades were built for, or
194
+ * {@link VOLUMETRICS_SUN_SHADOW_LIGHT_NONE} when there was nothing to build for.
195
+ * @type {number}
196
+ */
197
+ #sun_shadow_light_index = VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
198
+
199
+ /**
200
+ * View depth each cascade covers out to, packed the same way. Trailing lanes repeat the
201
+ * outermost so a read past the end of the ladder still lands on a real cascade.
202
+ * @type {Float32Array}
203
+ */
204
+ #sun_shadow_cascade_far = new Float32Array(4);
205
+
206
+ /**
207
+ * What the fog integrates to through no medium: nothing in-scattered (.rgb 0), everything
208
+ * transmitted (.a 1). Handed out in place of the aerial LUT on a frame whose scene has no
209
+ * participating-media volume, so every surface that samples the fog keeps doing so and reads
210
+ * it as none, for one texel and no pass. Written once, at construction.
211
+ * @type {GPUTextureContext}
212
+ */
213
+ #no_medium;
214
+
215
+ /**
216
+ * @type {GPUTypedBuffer}
217
+ */
218
+ #metadata;
219
+
220
+ /**
221
+ * World-space irradiance clipmap (`GPUIrradianceClipmap`) used by particle lighting.
222
+ * @type {GPUIrradianceClipmap}
223
+ */
224
+ #irradiance_clipmap;
225
+
226
+ /**
227
+ * @type {GPUDevice}
228
+ */
229
+ #device
230
+
231
+ /**
232
+ *
233
+ * @param {GPUDevice} device
234
+ */
235
+ constructor(device) {
236
+ this.#device = device;
237
+
238
+ // initialize textures
239
+ for (let i = 0; i < 2; i++) {
240
+ const texture = new GPUTextureContext(device);
241
+
242
+ texture.descriptor.format = "rgba16float";
243
+ texture.descriptor.dimension = "3d";
244
+ texture.descriptor.usage = GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING;
245
+
246
+ this.#volumetrics_textures[i] = texture;
247
+ }
248
+
249
+ const no_medium = new GPUTextureContext(device);
250
+ no_medium.descriptor.label = "volumetrics/no medium";
251
+ no_medium.descriptor.format = "rgba16float";
252
+ no_medium.descriptor.dimension = "3d";
253
+ no_medium.descriptor.size = [1, 1, 1];
254
+ no_medium.descriptor.usage = GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST;
255
+
256
+ device.queue.writeTexture(
257
+ { texture: no_medium.gpu_texture },
258
+ new Float16Array([0, 0, 0, 1]),
259
+ { bytesPerRow: 8, rowsPerImage: 1 },
260
+ [1, 1, 1]
261
+ );
262
+ no_medium.incrementVersion();
263
+
264
+ this.#no_medium = no_medium;
265
+
266
+ this.#metadata = GPUTypedBuffer.create({
267
+ type: VOLUMETRICS_METADATA_STRUCT,
268
+ device,
269
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
270
+ });
271
+
272
+ this.#irradiance_clipmap = new GPUIrradianceClipmap(device);
273
+
274
+ }
275
+
276
+ /**
277
+ * @returns {GPUIrradianceClipmap}
278
+ */
279
+ get irradiance_clipmap() {
280
+ return this.#irradiance_clipmap;
281
+ }
282
+
283
+ /**
284
+ * Drop the TAA history, so the next frame integrates without blending against what came before
285
+ * it. For a cut the grid, the depth curve and the view cannot see: the camera jumped, or the
286
+ * scene changed significantly.
287
+ */
288
+ reset_history() {
289
+ this.#reset_history = true;
290
+ }
291
+
292
+ destroy() {
293
+
294
+ const textures = this.#volumetrics_textures;
295
+
296
+ for (let i = 0; i < 2; i++) {
297
+
298
+ textures[i].destroy();
299
+
300
+ textures[i] = null;
301
+ }
302
+
303
+ this.#no_medium.destroy();
304
+ this.#irradiance_clipmap.destroy();
305
+
306
+ }
307
+
308
+ /**
309
+ * @param {GPUViewContext} view
310
+ */
311
+ #update_resolution(view) {
312
+
313
+ const screen_resolution = view.resolution;
314
+
315
+ const output_resolution_x = Math.ceil(screen_resolution[0] / this.#tile_size);
316
+ const output_resolution_y = Math.ceil(screen_resolution[1] / this.#tile_size);
317
+ const output_resolution_z = this.#resolution_z;
318
+
319
+ const t_first = this.#volumetrics_textures[0];
320
+
321
+ if (t_first.width === output_resolution_x
322
+ && t_first.height === output_resolution_y
323
+ && t_first.depth === output_resolution_z
324
+ ) {
325
+ // already at the right resolution
326
+ return;
327
+ }
328
+
329
+ for (let i = 0; i < 2; i++) {
330
+
331
+ this.#volumetrics_textures[i].resize(
332
+ output_resolution_x,
333
+ output_resolution_y,
334
+ output_resolution_z
335
+ );
336
+
337
+ }
338
+
339
+ // drop history
340
+ this.#reset_history = true;
341
+ }
342
+
343
+ /**
344
+ * Place this frame's sun optical-depth cascades, and say which light they cover.
345
+ *
346
+ * This is how the sun's media self-shadowing is computed — there is no second path to select
347
+ * between. The march in `chunk_integrate_optical_depth` remains for the lights the cascades do
348
+ * not cover: every point and spot light, whose rays are short enough that there is little to
349
+ * amortize, and any directional light past the first, since there is one ladder of boxes and it
350
+ * is aligned to one direction. Two situations leave the cascades covering nothing at all, and
351
+ * `VOLUMETRICS_SUN_SHADOW_LIGHT_NONE` is how they say so.
352
+ *
353
+ * The light is the first entry of the `light_directional` table, which is the first
354
+ * `DirectionalLight` in scene order — `GPULightCollection.#build` packs them in that order, so
355
+ * walking the same list here recovers the same index. In practice a scene has one sun.
356
+ *
357
+ * Every cascade is placed by the same call with a different `far`, which is what makes the
358
+ * stability argument transfer: each box is sized from the bounding sphere of the sub-frustum it
359
+ * covers, that sphere is a function of the projection alone, and the origin is snapped to whole
360
+ * voxels — so each cascade's world-space lattice is the same set of points from frame to frame
361
+ * no matter how the camera moves.
362
+ *
363
+ * @param {GPUViewContext} view
364
+ * @returns {number} the light index the cascades cover, or the sentinel
365
+ */
366
+ #update_sun_shadow(view) {
367
+ const camera = view.camera.camera;
368
+
369
+ // One voxel per cascade rather than no pass at all: the lighting pass binds these textures
370
+ // unconditionally and WebGPU has no optional bindings. Nothing samples them, because the
371
+ // sentinel below matches no light index.
372
+ this.#sun_shadow_resolution = [1, 1, 1];
373
+
374
+ // an orthographic view has no fov to derive a bounding sphere from, and no volumetrics
375
+ // camera path that reaches here today — bail rather than invent one
376
+ if (typeof camera.fov !== "number") {
377
+ return VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
378
+ }
379
+
380
+ const lights = view.scene.lights.source.elements;
381
+
382
+ let sun = null;
383
+
384
+ for (let i = 0; i < lights.length; i++) {
385
+ if (lights[i].isDirectionalLight === true) {
386
+ sun = lights[i];
387
+ break;
388
+ }
389
+ }
390
+
391
+ if (sun === null) {
392
+ return VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
393
+ }
394
+
395
+ const resolution = VOLUMETRICS_SUN_SHADOW_RESOLUTION;
396
+
397
+ // `forward` is the direction light TRAVELS — the same vector light_record_directional
398
+ // writes into the table, which the shader then negates to point at the light. Read off the
399
+ // CPU copy of the pose: the cascade is built here, on this side.
400
+ const direction = sun.transform.forward;
401
+
402
+ const view_matrix = camera.view_matrix;
403
+ const position = camera.transform;
404
+
405
+ // the medium lives in the froxel grid and the grid stops here, so there is nothing to
406
+ // integrate past it
407
+ const far = Math.min(this.#froxel_far, camera.far);
408
+
409
+ const cascade_count = VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT;
410
+ const cascade_ratio = VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO;
411
+
412
+ // the view matrix's z row, negated: view space looks down -Z, and the row is unit length
413
+ // because the linear part is a rotation
414
+ const camera_forward = [-view_matrix[2], -view_matrix[6], -view_matrix[10]];
415
+
416
+ for (let cascade = 0; cascade < cascade_count; cascade++) {
417
+
418
+ const cascade_far = volumetrics_sun_shadow_cascade_far(far, cascade, cascade_count, cascade_ratio);
419
+
420
+ this.#sun_shadow_step_ws[cascade] = build_volumetrics_sun_shadow_transform({
421
+ ws_to_uvw: this.#sun_shadow_ws_to_uvw[cascade],
422
+ uvw_to_ws: this.#sun_shadow_uvw_to_ws[cascade],
423
+ light_direction: [direction[0], direction[1], direction[2]],
424
+ camera_position: [position.translation_x, position.translation_y, position.translation_z],
425
+ camera_forward,
426
+ fov_y: camera.fov,
427
+ aspect: camera.aspect,
428
+ near: camera.near,
429
+ far: cascade_far,
430
+ resolution,
431
+ });
432
+
433
+ this.#sun_shadow_cascade_far[cascade] = cascade_far;
434
+ }
435
+
436
+ // Trailing lanes repeat the outermost cascade rather than holding zero: the read side walks
437
+ // the ladder comparing view depth against these, and a zero would read as a cascade that
438
+ // ends at the camera.
439
+ for (let lane = cascade_count; lane < 4; lane++) {
440
+ this.#sun_shadow_cascade_far[lane] = far;
441
+ this.#sun_shadow_step_ws[lane] = this.#sun_shadow_step_ws[cascade_count - 1];
442
+ }
443
+
444
+ this.#sun_shadow_resolution = resolution;
445
+
446
+ return 0;
447
+ }
448
+
449
+ /**
450
+ * @param {GPUViewContext} view
451
+ */
452
+ #update_metadata(view) {
453
+ const camera = view.camera;
454
+
455
+ const resolution = this.#volumetrics_textures[0].size;
456
+ // the grid as allocated, so the curve always spans the slices the textures actually have
457
+ const resolution_z = resolution[2];
458
+
459
+ this.#sun_shadow_light_index = this.#update_sun_shadow(view);
460
+
461
+ const volumetrics_cluster_parameters = new Float32Array(3);
462
+
463
+ // The depth-curve `scale` must grow with the slice count. The hard-coded 4.06 was tuned for
464
+ // the light-cluster grid (24 slices); reusing it with 64 froxel slices collapses the log
465
+ // curve so ~half the slices bunch into the first fraction of a unit of depth, leaving almost
466
+ // none for the actual scene. Derive scale for a log-uniform distribution over
467
+ // [near + near_offset, far] instead (gives O ~= 0, i.e. depth ~ offset * (far/offset)^(s/(N-1))).
468
+ const NEAR_OFFSET = 0.1;
469
+ const depth_offset = camera.camera.near + NEAR_OFFSET;
470
+ const depth_scale = (resolution_z - 1) / Math.log2(camera.camera.far / depth_offset);
471
+
472
+ build_light_cluster_parameters(
473
+ volumetrics_cluster_parameters,
474
+ 0,
475
+ resolution_z,
476
+ camera.camera.near,
477
+ camera.camera.far,
478
+ NEAR_OFFSET,
479
+ depth_scale
480
+ );
481
+
482
+ // Froxel-grid curve: same construction but ending at froxel_far (<= camera.far). The grid's
483
+ // slices pack into [near, froxel_far] for extra near detail; beyond froxel_far the aerial
484
+ // pass ray-marches the volumes. froxel_far == camera.far -> identical to the LUT curve (no-op).
485
+ const froxel_far = Math.min(this.#froxel_far, camera.camera.far);
486
+ const froxel_depth_scale = (resolution_z - 1) / Math.log2(froxel_far / depth_offset);
487
+ const volumetrics_froxel_cluster_parameters = new Float32Array(3);
488
+ build_light_cluster_parameters(
489
+ volumetrics_froxel_cluster_parameters,
490
+ 0,
491
+ resolution_z,
492
+ camera.camera.near,
493
+ froxel_far,
494
+ NEAR_OFFSET,
495
+ froxel_depth_scale
496
+ );
497
+
498
+ // Recomputed deterministically, so a steady camera compares bitwise equal and never resets.
499
+ {
500
+ const curve = volumetrics_froxel_cluster_parameters;
501
+ const previous = this.#froxel_curve_previous;
502
+
503
+ if (curve[0] !== previous[0] || curve[1] !== previous[1] || curve[2] !== previous[2]) {
504
+ previous.set(curve);
505
+ this.#reset_history = true;
506
+ }
507
+ }
508
+
509
+ // The projection is infinite-far reverse-Z, so the camera frustum's far plane is zeroed and
510
+ // can't be sliced into froxel slabs. Rebuild a finite far plane at the logical far for the
511
+ // participating-media coverage test — exactly as the light-cluster assignment does.
512
+ const clipped_frustum = new Float32Array(24);
513
+ clipped_frustum.set(camera.camera.frustum);
514
+ update_frustum_far_plane(clipped_frustum, camera.camera.view_matrix, camera.camera.far);
515
+
516
+ this.#metadata.upload({
517
+ cluster_parameters: volumetrics_cluster_parameters,
518
+ froxel_cluster_parameters: volumetrics_froxel_cluster_parameters,
519
+ resolution,
520
+ frustum: slice_flat_array(clipped_frustum, 0, 4, 6),
521
+ history_reset: this.#reset_history ? 1 : 0,
522
+ froxel_far,
523
+ // multiscatter knobs (GUI-tunable via globals); engine defaults 6 / 0.5
524
+ ms_octave_count: globalThis.__VOL_MS_OCTAVES ?? 6,
525
+ ms_extinction_decay: globalThis.__VOL_MS_EXT_DECAY ?? 0.5,
526
+ // Q-04. The sun's media self-shadowing, out of a 16-step per-froxel march and into one
527
+ // fetch of the cascades built earlier this frame — see #update_sun_shadow.
528
+ sun_shadow_ws_to_uvw: this.#sun_shadow_ws_to_uvw,
529
+ sun_shadow_uvw_to_ws: this.#sun_shadow_uvw_to_ws,
530
+ sun_shadow_step_ws: this.#sun_shadow_step_ws,
531
+ sun_shadow_cascade_far: this.#sun_shadow_cascade_far,
532
+ sun_shadow_light_index: this.#sun_shadow_light_index,
533
+ }, this.#device.queue);
534
+ }
535
+
536
+ /**
537
+ *
538
+ * @param {Object} options
539
+ * @param {FrameGraph} options.graph
540
+ * @param {GPUViewContext} options.view what the fog is seen from: its resolution sizes the
541
+ * froxel grid, its camera places it, its scene fills it
542
+ * @param {number} options.light_cluster_data
543
+ * @param {number} options.light_cluster_lookup
544
+ * @param {number} options.light_cluster_parameters
545
+ * @param {number} options.shadowmap_atlas
546
+ * @param {ShaderSwitchSet} [options.shader_switches]
547
+ * @param {import("../global_illumination/brick4/Brick4LightMap.js").Brick4LightMap} [options.brick4]
548
+ * @param {import("../global_illumination/lpv/GPULightProbeVolume.js").GPULightProbeVolume} [options.lpv]
549
+ * @returns {{resolved: number, metadata: number, irradiance: {Y: number, CoCg: number, uniform: number}}}
550
+ */
551
+ graph_update({
552
+ graph,
553
+ view,
554
+ light_cluster_data,
555
+ light_cluster_lookup,
556
+ light_cluster_parameters,
557
+ shadowmap_atlas,
558
+ shader_switches = ShaderSwitchSet.EMPTY,
559
+ brick4,
560
+ lpv,
561
+ }) {
562
+
563
+ assert.defined(view, 'view');
564
+
565
+ const lights = view.scene.lights;
566
+ const camera = view.camera;
567
+
568
+ const taa_requested = globalThis.__VOL_TAA_ENABLED;
569
+ if (taa_requested !== undefined && taa_requested !== this.#taa_enabled) {
570
+ this.taa_enabled = taa_requested;
571
+ }
572
+
573
+ const froxel_far_requested = globalThis.__VOL_FROXEL_FAR;
574
+ if (froxel_far_requested !== undefined && froxel_far_requested !== this.#froxel_far) {
575
+ this.#froxel_far = froxel_far_requested;
576
+ // the froxel grid's depth curve just changed; the TAA history is on the old curve, reset it
577
+ this.#reset_history = true;
578
+ }
579
+
580
+ if (view.id !== this.#history_view_id) {
581
+ this.#history_view_id = view.id;
582
+ this.#reset_history = true;
583
+ }
584
+
585
+ this.#update_resolution(view);
586
+ this.#update_metadata(view);
587
+
588
+ const gr_metadata = graph_import_buffer(graph, this.#metadata.buffer, "volumetrics metadata");
589
+
590
+ const resolution = this.#volumetrics_textures[0].size;
591
+
592
+ // World-space irradiance clipmap for particle lighting.
593
+ // Culled by the frame graph when no lit particles read it.
594
+ const irradiance = this.#irradiance_clipmap.graph_update({
595
+ graph,
596
+ camera,
597
+ scene_ctx: view.scene,
598
+ shadowmap_atlas,
599
+ shader_switches,
600
+ brick4,
601
+ lpv,
602
+ });
603
+
604
+ if (view.scene.volumetrics.source.volumes.length === 0) {
605
+ // No medium: nothing scatters and nothing absorbs, so the fog is the identity everywhere
606
+ // and the passes that would integrate it to that are not recorded. Nothing accumulates
607
+ // either, which leaves the history stale for whenever a volume arrives.
608
+ this.#reset_history = true;
609
+
610
+ return {
611
+ resolved: graph_import_texture(graph, this.#no_medium, "volumetrics no medium"),
612
+ metadata: gr_metadata,
613
+ irradiance,
614
+ };
615
+ }
616
+
617
+ let taa_history;
618
+ let taa_output;
619
+
620
+ if (this.#taa_enabled) {
621
+ const parity = view.frame_index & 1;
622
+ taa_history = graph_import_texture(graph, this.#volumetrics_textures[parity ^ 1], "volumetrics taa history");
623
+ taa_output = graph_import_texture(graph, this.#volumetrics_textures[parity], "volumetrics taa output");
624
+ }
625
+
626
+ const sky = view.scene.sky;
627
+
628
+ sky.update();
629
+
630
+ const { lut_aerial } = graph_build_volumetrics({
631
+ graph,
632
+ camera,
633
+ lights,
634
+ view,
635
+ sky,
636
+ light_cluster_data,
637
+ light_cluster_lookup,
638
+ light_cluster_parameters,
639
+ shadowmap_atlas,
640
+
641
+ volumetrics_metadata: gr_metadata,
642
+ volumetric_resolution: resolution,
643
+ sun_shadow_resolution: this.#sun_shadow_resolution,
644
+
645
+ taa_history,
646
+ taa_output,
647
+ camera_previous: view.gpu_previous_camera_state,
648
+ shader_switches,
649
+ });
650
+
651
+ this.#reset_history = false;
652
+
653
+ return {
654
+ resolved: lut_aerial,
655
+ metadata: gr_metadata,
656
+ irradiance,
657
+ };
658
+ }
659
+
660
+ }