@woosh/meep-engine 3.11.1 → 3.11.2

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 (73) hide show
  1. package/package.json +1 -1
  2. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  3. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +294 -290
  4. package/src/engine/graphics3/ParticipatingMedia.d.ts +111 -0
  5. package/src/engine/graphics3/ParticipatingMedia.d.ts.map +1 -0
  6. package/src/engine/graphics3/ParticipatingMedia.js +203 -0
  7. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts +34 -0
  8. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts.map +1 -0
  9. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.js +73 -0
  10. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +87 -0
  11. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -0
  12. package/src/engine/graphics3/ParticipatingMediaSystem3.js +184 -0
  13. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  14. package/src/shade/playground/volumetrics_froxel/README.md +104 -1
  15. package/src/shade/playground/volumetrics_froxel/index.html +20 -1
  16. package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -1
  17. package/src/shade/playground/volumetrics_froxel/main.js +298 -0
  18. package/src/shade/playground/volumetrics_froxel/measure.d.ts +119 -0
  19. package/src/shade/playground/volumetrics_froxel/measure.d.ts.map +1 -0
  20. package/src/shade/playground/volumetrics_froxel/measure.js +559 -0
  21. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  22. package/src/shade/renderer/Renderer.js +22 -18
  23. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  24. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts +14 -13
  25. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts.map +1 -1
  26. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.js +133 -129
  27. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  28. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +480 -315
  29. package/src/shade/renderer/volumetrics/NOTES.md +649 -8
  30. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts +5 -0
  31. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts.map +1 -1
  32. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.js +21 -2
  33. package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts +26 -0
  34. package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts.map +1 -1
  35. package/src/shade/renderer/volumetrics/SceneVolumetrics.js +64 -21
  36. package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.d.ts.map +1 -1
  37. package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.js +84 -28
  38. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts +98 -0
  39. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts.map +1 -0
  40. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.js +111 -0
  41. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts +84 -0
  42. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts.map +1 -0
  43. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.js +252 -0
  44. package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.js +75 -75
  45. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts +43 -0
  46. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts.map +1 -0
  47. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.js +145 -0
  48. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts +20 -0
  49. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts.map +1 -0
  50. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.js +48 -0
  51. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts +38 -0
  52. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts.map +1 -0
  53. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.js +107 -0
  54. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +4 -1
  55. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  56. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +577 -471
  57. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  58. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +64 -13
  59. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts +8 -0
  60. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts.map +1 -0
  61. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.js +174 -0
  62. package/src/shade/wgsl/emulator/CPUBitmapData.d.ts +25 -0
  63. package/src/shade/wgsl/emulator/CPUBitmapData.d.ts.map +1 -1
  64. package/src/shade/wgsl/emulator/CPUBitmapData.js +33 -0
  65. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +2221 -2215
  66. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  67. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +1297 -1117
  68. package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts +69 -0
  69. package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts.map +1 -0
  70. package/src/shade/wgsl/emulator/cpu_texture_gather.js +148 -0
  71. package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts +16 -0
  72. package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts.map +1 -1
  73. package/src/shade/wgsl/emulator/cpu_texture_sample.js +176 -172
@@ -1,316 +1,481 @@
1
- import { GPUTypedBuffer } from "../buffer/GPUTypedBuffer.js";
2
- import { graph_compute_pass } from "../shader/graph/compute/graph_compute_pass.js";
3
- import { graph_import_buffer } from "../shader/graph/graph_import_buffer.js";
4
- import { graph_import_texture } from "../shader/graph/graph_import_texture.js";
5
- import { GPUTextureContext } from "../texture/GPUTextureContext.js";
6
- import { update_frustum_far_plane } from "../shadow/map/update_frustum_far_plane.js";
7
- import { slice_flat_array } from "../util/slice_flat_array.js";
8
- import { build_light_cluster_parameters } from "../light/cluster/build_light_cluster_parameters.js";
9
- import { graph_build_volumetrics } from "./graph_build_volumetrics.js";
10
- import { shader_volumetrics_taa } from "./taa/shader_volumetrics_taa.js";
11
- import { VOLUMETRICS_METADATA_STRUCT } from "./VOLUMETRICS_METADATA_STRUCT.js";
12
- import { VOLUMETRICS_RESOLUTION_Z } from "./VOLUMETRICS_RESOLUTION_Z.js";
13
-
14
- /**
15
- * resolution of a single froxel in XY, in pixels
16
- */
17
- const TILE_SIZE = 8;
18
-
19
- export class GPUViewVolumetrics {
20
-
21
- /**
22
- * @type {GPUViewContext}
23
- */
24
- #view
25
-
26
- /**
27
- * NOTE: Initialized in the constructor
28
- * @type {GPUTextureContext[]}
29
- */
30
- #volumetrics_textures = [];
31
-
32
- /**
33
- * Should we perform temporal integration, or not?
34
- * If set to true - the next update will discard history.
35
- * Useful for significant scene transitions and screen resizing.
36
- * @type {boolean}
37
- */
38
- #reset_history = true;
39
-
40
- /**
41
- * Whether to run the 3D temporal reprojection (volumetric TAA) over the per-froxel scattering
42
- * volume. When disabled, the aerial pass integrates the raw (noisier) scattering LUT directly —
43
- * a safe fallback if temporal artifacts appear.
44
- * @type {boolean}
45
- */
46
- #taa_enabled = true;
47
-
48
- /**
49
- * @returns {boolean}
50
- */
51
- get taa_enabled() {
52
- return this.#taa_enabled;
53
- }
54
-
55
- /**
56
- * @param {boolean} v
57
- */
58
- set taa_enabled(v) {
59
- if (v === this.#taa_enabled) {
60
- return;
61
- }
62
- this.#taa_enabled = v;
63
- // re-enabling must not blend against a stale / never-written history texture
64
- this.#reset_history = true;
65
- }
66
-
67
- /**
68
- * View-space distance at which the froxel grid ends and the aerial pass switches to a direct
69
- * RDR2-style volume ray-march. Clamped to camera.far at upload, so the default (Infinity) means
70
- * "grid covers the whole range" and the far march is a no-op.
71
- * @type {number}
72
- */
73
- #froxel_far = Infinity;
74
-
75
- /**
76
- * @returns {number}
77
- */
78
- get froxel_far() {
79
- return this.#froxel_far;
80
- }
81
-
82
- /**
83
- * @param {number} v
84
- */
85
- set froxel_far(v) {
86
- this.#froxel_far = v;
87
- }
88
-
89
- /**
90
- * @type {GPUTypedBuffer}
91
- */
92
- #metadata;
93
-
94
- /**
95
- * @type {GPUDevice}
96
- */
97
- #device
98
-
99
- /**
100
- *
101
- * @param {GPUViewContext} view
102
- * @param {GPUDevice} device
103
- */
104
- constructor(view, device) {
105
- this.#device = device;
106
- this.#view = view;
107
-
108
- // initialize textures
109
- for (let i = 0; i < 2; i++) {
110
- const texture = new GPUTextureContext(device);
111
-
112
- texture.descriptor.format = "rgba16float";
113
- texture.descriptor.dimension = "3d";
114
- texture.descriptor.usage = GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING;
115
-
116
- this.#volumetrics_textures[i] = texture;
117
- }
118
-
119
- this.#metadata = GPUTypedBuffer.create({
120
- type: VOLUMETRICS_METADATA_STRUCT,
121
- device,
122
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
123
- });
124
-
125
- }
126
-
127
- destroy() {
128
-
129
- const textures = this.#volumetrics_textures;
130
-
131
- for (let i = 0; i < 2; i++) {
132
-
133
- textures[i].destroy();
134
-
135
- textures[i] = null;
136
- }
137
-
138
- }
139
-
140
- #update_resolution() {
141
-
142
-
143
- const screen_resolution = this.#view.resolution;
144
-
145
- const output_resolution_x = Math.ceil(screen_resolution[0] / TILE_SIZE);
146
- const output_resolution_y = Math.ceil(screen_resolution[1] / TILE_SIZE);
147
-
148
- const t_first = this.#volumetrics_textures[0];
149
-
150
- if (t_first.width === output_resolution_x
151
- && t_first.height === output_resolution_y
152
- ) {
153
- // already at the right resolution
154
- return;
155
- }
156
-
157
- for (let i = 0; i < 2; i++) {
158
-
159
- this.#volumetrics_textures[i].resize(
160
- output_resolution_x,
161
- output_resolution_y,
162
- VOLUMETRICS_RESOLUTION_Z
163
- );
164
-
165
- }
166
-
167
- // drop history
168
- this.#reset_history = true;
169
- }
170
-
171
- #update_metadata() {
172
- const view = this.#view;
173
- const camera = view.camera;
174
-
175
- const resolution = this.#volumetrics_textures[0].size;
176
-
177
- const volumetrics_cluster_parameters = new Float32Array(3);
178
-
179
- // The depth-curve `scale` must grow with the slice count. The hard-coded 4.06 was tuned for
180
- // the light-cluster grid (24 slices); reusing it with 64 froxel slices collapses the log
181
- // curve so ~half the slices bunch into the first fraction of a unit of depth, leaving almost
182
- // none for the actual scene. Derive scale for a log-uniform distribution over
183
- // [near + near_offset, far] instead (gives O ~= 0, i.e. depth ~ offset * (far/offset)^(s/(N-1))).
184
- const NEAR_OFFSET = 0.1;
185
- const depth_offset = camera.camera.near + NEAR_OFFSET;
186
- const depth_scale = (VOLUMETRICS_RESOLUTION_Z - 1) / Math.log2(camera.camera.far / depth_offset);
187
-
188
- build_light_cluster_parameters(
189
- volumetrics_cluster_parameters,
190
- 0,
191
- VOLUMETRICS_RESOLUTION_Z,
192
- camera.camera.near,
193
- camera.camera.far,
194
- NEAR_OFFSET,
195
- depth_scale
196
- );
197
-
198
- // Froxel-grid curve: same construction but ending at froxel_far (<= camera.far). The grid's
199
- // slices pack into [near, froxel_far] for extra near detail; beyond froxel_far the aerial
200
- // pass ray-marches the volumes. froxel_far == camera.far -> identical to the LUT curve (no-op).
201
- const froxel_far = Math.min(this.#froxel_far, camera.camera.far);
202
- const froxel_depth_scale = (VOLUMETRICS_RESOLUTION_Z - 1) / Math.log2(froxel_far / depth_offset);
203
- const volumetrics_froxel_cluster_parameters = new Float32Array(3);
204
- build_light_cluster_parameters(
205
- volumetrics_froxel_cluster_parameters,
206
- 0,
207
- VOLUMETRICS_RESOLUTION_Z,
208
- camera.camera.near,
209
- froxel_far,
210
- NEAR_OFFSET,
211
- froxel_depth_scale
212
- );
213
-
214
- // The projection is infinite-far reverse-Z, so the camera frustum's far plane is zeroed and
215
- // can't be sliced into froxel slabs. Rebuild a finite far plane at the logical far for the
216
- // participating-media coverage test — exactly as the light-cluster assignment does.
217
- const clipped_frustum = new Float32Array(24);
218
- clipped_frustum.set(camera.camera.frustum);
219
- update_frustum_far_plane(clipped_frustum, camera.camera.view_matrix, camera.camera.far);
220
-
221
- this.#metadata.upload({
222
- cluster_parameters: volumetrics_cluster_parameters,
223
- froxel_cluster_parameters: volumetrics_froxel_cluster_parameters,
224
- resolution,
225
- frustum: slice_flat_array(clipped_frustum, 0, 4, 6),
226
- history_reset: this.#reset_history ? 1 : 0,
227
- froxel_far,
228
- // multiscatter knobs (GUI-tunable via globals); engine defaults 6 / 0.5
229
- ms_octave_count: globalThis.__VOL_MS_OCTAVES ?? 6,
230
- ms_extinction_decay: globalThis.__VOL_MS_EXT_DECAY ?? 0.5,
231
- }, this.#device.queue);
232
- }
233
-
234
- /**
235
- *
236
- * @param {FrameGraph} graph
237
- * @param {number} light_cluster_data
238
- * @param {number} light_cluster_lookup
239
- * @param {number} light_cluster_parameters
240
- * @param {number} shadowmap_atlas
241
- */
242
- graph_update({
243
- graph,
244
- light_cluster_data,
245
- light_cluster_lookup,
246
- light_cluster_parameters,
247
- shadowmap_atlas,
248
- }) {
249
-
250
- const view = this.#view;
251
- const lights = view.scene.lights;
252
- const camera = view.camera;
253
-
254
- // Optional runtime A/B toggle (e.g. a GUI checkbox) without per-view plumbing. The setter
255
- // forces a history reset on change, so flipping it back on never blends against stale data.
256
- const taa_requested = globalThis.__VOL_TAA_ENABLED ?? true;
257
- if (taa_requested !== this.#taa_enabled) {
258
- this.taa_enabled = taa_requested;
259
- }
260
-
261
- // runtime froxel-grid far override (GUI); Infinity -> grid covers the whole range (no far march)
262
- const froxel_far_requested = globalThis.__VOL_FROXEL_FAR ?? Infinity;
263
- if (froxel_far_requested !== this.#froxel_far) {
264
- this.#froxel_far = froxel_far_requested;
265
- // the froxel grid's depth curve just changed; the TAA history is on the old curve, reset it
266
- this.#reset_history = true;
267
- }
268
-
269
- this.#update_resolution();
270
- this.#update_metadata();
271
-
272
- const gr_metadata = graph_import_buffer(graph, this.#metadata.buffer, "volumetrics metadata");
273
-
274
- const resolution = this.#volumetrics_textures[0].size;
275
-
276
- // Ping-pong the two persistent froxel textures: this frame writes [parity] and reads the
277
- // previous frame's accumulation from [parity ^ 1]. On the first frame / after a resize the
278
- // history is invalid, which #update_metadata signals via history_reset (the TAA then emits
279
- // the fresh sample). graph_build_volumetrics inserts the reprojection pass only when both
280
- // history/output handles are supplied — so leaving them undefined disables it.
281
- let taa_history;
282
- let taa_output;
283
-
284
- if (this.#taa_enabled) {
285
- const parity = view.frame_index & 1;
286
- taa_history = graph_import_texture(graph, this.#volumetrics_textures[parity ^ 1], "volumetrics taa history");
287
- taa_output = graph_import_texture(graph, this.#volumetrics_textures[parity], "volumetrics taa output");
288
- }
289
-
290
- const { lut_aerial } = graph_build_volumetrics({
291
- graph,
292
- camera,
293
- lights,
294
- view,
295
- light_cluster_data,
296
- light_cluster_lookup,
297
- light_cluster_parameters,
298
- shadowmap_atlas,
299
-
300
- volumetrics_metadata: gr_metadata,
301
- volumetric_resolution: resolution,
302
-
303
- taa_history,
304
- taa_output,
305
- camera_previous: view.gpu_previous_camera_state,
306
- });
307
-
308
- this.#reset_history = false;
309
-
310
- return {
311
- resolved: lut_aerial,
312
- metadata: gr_metadata,
313
- };
314
- }
315
-
1
+ import { GPUTypedBuffer } from "../buffer/GPUTypedBuffer.js";
2
+ import { graph_compute_pass } from "../shader/graph/compute/graph_compute_pass.js";
3
+ import { graph_import_buffer } from "../shader/graph/graph_import_buffer.js";
4
+ import { graph_import_texture } from "../shader/graph/graph_import_texture.js";
5
+ import { GPUTextureContext } from "../texture/GPUTextureContext.js";
6
+ import { update_frustum_far_plane } from "../shadow/map/update_frustum_far_plane.js";
7
+ import { slice_flat_array } from "../util/slice_flat_array.js";
8
+ import { build_light_cluster_parameters } from "../light/cluster/build_light_cluster_parameters.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 {
15
+ VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT,
16
+ VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO,
17
+ VOLUMETRICS_SUN_SHADOW_LIGHT_NONE,
18
+ VOLUMETRICS_SUN_SHADOW_RESOLUTION
19
+ } from "./VOLUMETRICS_SUN_SHADOW_RESOLUTION.js";
20
+ import { shader_volumetrics_taa } from "./taa/shader_volumetrics_taa.js";
21
+ import { VOLUMETRICS_METADATA_STRUCT } from "./VOLUMETRICS_METADATA_STRUCT.js";
22
+ import { VOLUMETRICS_RESOLUTION_Z } from "./VOLUMETRICS_RESOLUTION_Z.js";
23
+
24
+ /**
25
+ * resolution of a single froxel in XY, in pixels
26
+ */
27
+ const TILE_SIZE = 8;
28
+
29
+ export class GPUViewVolumetrics {
30
+
31
+ /**
32
+ * @type {GPUViewContext}
33
+ */
34
+ #view
35
+
36
+ /**
37
+ * NOTE: Initialized in the constructor
38
+ * @type {GPUTextureContext[]}
39
+ */
40
+ #volumetrics_textures = [];
41
+
42
+ /**
43
+ * Should we perform temporal integration, or not?
44
+ * If set to true - the next update will discard history.
45
+ * Useful for significant scene transitions and screen resizing.
46
+ * @type {boolean}
47
+ */
48
+ #reset_history = true;
49
+
50
+ /**
51
+ * Whether to run the 3D temporal reprojection (volumetric TAA) over the per-froxel scattering
52
+ * volume. When disabled, the aerial pass integrates the raw (noisier) scattering LUT directly —
53
+ * a safe fallback if temporal artifacts appear.
54
+ * @type {boolean}
55
+ */
56
+ #taa_enabled = true;
57
+
58
+ /**
59
+ * @returns {boolean}
60
+ */
61
+ get taa_enabled() {
62
+ return this.#taa_enabled;
63
+ }
64
+
65
+ /**
66
+ * @param {boolean} v
67
+ */
68
+ set taa_enabled(v) {
69
+ if (v === this.#taa_enabled) {
70
+ return;
71
+ }
72
+ this.#taa_enabled = v;
73
+ // re-enabling must not blend against a stale / never-written history texture
74
+ this.#reset_history = true;
75
+ }
76
+
77
+ /**
78
+ * View-space distance at which the froxel grid ends and the aerial pass switches to a direct
79
+ * RDR2-style volume ray-march. Clamped to camera.far at upload, so the default (Infinity) means
80
+ * "grid covers the whole range" and the far march is a no-op.
81
+ * @type {number}
82
+ */
83
+ #froxel_far = Infinity;
84
+
85
+ /**
86
+ * @returns {number}
87
+ */
88
+ get froxel_far() {
89
+ return this.#froxel_far;
90
+ }
91
+
92
+ /**
93
+ * @param {number} v
94
+ */
95
+ set froxel_far(v) {
96
+ this.#froxel_far = v;
97
+ }
98
+
99
+ /**
100
+ * Q-04. World -> volume and back for each cascade, innermost first, rebuilt every frame by
101
+ * {@link build_volumetrics_sun_shadow_transform} and uploaded in the metadata.
102
+ * @type {Float32Array[]}
103
+ */
104
+ #sun_shadow_ws_to_uvw = Array.from({ length: VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT }, () => new Float32Array(16));
105
+
106
+ /**
107
+ * @type {Float32Array[]}
108
+ */
109
+ #sun_shadow_uvw_to_ws = Array.from({ length: VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT }, () => new Float32Array(16));
110
+
111
+ /**
112
+ * Voxel counts one sun optical-depth cascade is dispatched at this frame. Collapses to
113
+ * `[1, 1, 1]` when the froxels are marching instead, so the passes stay in the frame graph and
114
+ * cost nothing see `graph_build_sun_shadow_volume` for why they stay rather than going.
115
+ * @type {number[]}
116
+ */
117
+ #sun_shadow_resolution = [1, 1, 1];
118
+
119
+ /**
120
+ * World-space size of one voxel along the sun axis per cascade, i.e. each scan's step, packed
121
+ * for the `vec4f` the metadata carries.
122
+ * @type {Float32Array}
123
+ */
124
+ #sun_shadow_step_ws = new Float32Array(4);
125
+
126
+ /**
127
+ * Which directional light this frame's cascades were built for, or
128
+ * {@link VOLUMETRICS_SUN_SHADOW_LIGHT_NONE} when there was nothing to build for.
129
+ * @type {number}
130
+ */
131
+ #sun_shadow_light_index = VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
132
+
133
+ /**
134
+ * View depth each cascade covers out to, packed the same way. Trailing lanes repeat the
135
+ * outermost so a read past the end of the ladder still lands on a real cascade.
136
+ * @type {Float32Array}
137
+ */
138
+ #sun_shadow_cascade_far = new Float32Array(4);
139
+
140
+ /**
141
+ * @type {GPUTypedBuffer}
142
+ */
143
+ #metadata;
144
+
145
+ /**
146
+ * @type {GPUDevice}
147
+ */
148
+ #device
149
+
150
+ /**
151
+ *
152
+ * @param {GPUViewContext} view
153
+ * @param {GPUDevice} device
154
+ */
155
+ constructor(view, device) {
156
+ this.#device = device;
157
+ this.#view = view;
158
+
159
+ // initialize textures
160
+ for (let i = 0; i < 2; i++) {
161
+ const texture = new GPUTextureContext(device);
162
+
163
+ texture.descriptor.format = "rgba16float";
164
+ texture.descriptor.dimension = "3d";
165
+ texture.descriptor.usage = GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING;
166
+
167
+ this.#volumetrics_textures[i] = texture;
168
+ }
169
+
170
+ this.#metadata = GPUTypedBuffer.create({
171
+ type: VOLUMETRICS_METADATA_STRUCT,
172
+ device,
173
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
174
+ });
175
+
176
+ }
177
+
178
+ destroy() {
179
+
180
+ const textures = this.#volumetrics_textures;
181
+
182
+ for (let i = 0; i < 2; i++) {
183
+
184
+ textures[i].destroy();
185
+
186
+ textures[i] = null;
187
+ }
188
+
189
+ }
190
+
191
+ #update_resolution() {
192
+
193
+
194
+ const screen_resolution = this.#view.resolution;
195
+
196
+ const output_resolution_x = Math.ceil(screen_resolution[0] / TILE_SIZE);
197
+ const output_resolution_y = Math.ceil(screen_resolution[1] / TILE_SIZE);
198
+
199
+ const t_first = this.#volumetrics_textures[0];
200
+
201
+ if (t_first.width === output_resolution_x
202
+ && t_first.height === output_resolution_y
203
+ ) {
204
+ // already at the right resolution
205
+ return;
206
+ }
207
+
208
+ for (let i = 0; i < 2; i++) {
209
+
210
+ this.#volumetrics_textures[i].resize(
211
+ output_resolution_x,
212
+ output_resolution_y,
213
+ VOLUMETRICS_RESOLUTION_Z
214
+ );
215
+
216
+ }
217
+
218
+ // drop history
219
+ this.#reset_history = true;
220
+ }
221
+
222
+ /**
223
+ * Place this frame's sun optical-depth cascades, and say which light they cover.
224
+ *
225
+ * This is how the sun's media self-shadowing is computed — there is no second path to select
226
+ * between. The march in `chunk_integrate_optical_depth` remains for the lights the cascades do
227
+ * not cover: every point and spot light, whose rays are short enough that there is little to
228
+ * amortize, and any directional light past the first, since there is one ladder of boxes and it
229
+ * is aligned to one direction. Two situations leave the cascades covering nothing at all, and
230
+ * `VOLUMETRICS_SUN_SHADOW_LIGHT_NONE` is how they say so.
231
+ *
232
+ * The light is the first entry of the `light_directional` table, which is the first
233
+ * `DirectionalLight` in scene order — `GPULightCollection.#build` packs them in that order, so
234
+ * walking the same list here recovers the same index. In practice a scene has one sun.
235
+ *
236
+ * Every cascade is placed by the same call with a different `far`, which is what makes the
237
+ * stability argument transfer: each box is sized from the bounding sphere of the sub-frustum it
238
+ * covers, that sphere is a function of the projection alone, and the origin is snapped to whole
239
+ * voxels so each cascade's world-space lattice is the same set of points from frame to frame
240
+ * no matter how the camera moves.
241
+ *
242
+ * @returns {number} the light index the cascades cover, or the sentinel
243
+ */
244
+ #update_sun_shadow() {
245
+ const camera = this.#view.camera.camera;
246
+
247
+ // One voxel per cascade rather than no pass at all: the lighting pass binds these textures
248
+ // unconditionally and WebGPU has no optional bindings. Nothing samples them, because the
249
+ // sentinel below matches no light index.
250
+ this.#sun_shadow_resolution = [1, 1, 1];
251
+
252
+ // an orthographic view has no fov to derive a bounding sphere from, and no volumetrics
253
+ // camera path that reaches here today — bail rather than invent one
254
+ if (typeof camera.fov !== "number") {
255
+ return VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
256
+ }
257
+
258
+ const lights = this.#view.scene.lights.source.elements;
259
+
260
+ let sun = null;
261
+
262
+ for (let i = 0; i < lights.length; i++) {
263
+ if (lights[i].isDirectionalLight === true) {
264
+ sun = lights[i];
265
+ break;
266
+ }
267
+ }
268
+
269
+ if (sun === null) {
270
+ return VOLUMETRICS_SUN_SHADOW_LIGHT_NONE;
271
+ }
272
+
273
+ const resolution = VOLUMETRICS_SUN_SHADOW_RESOLUTION;
274
+
275
+ // `forward` is the direction light TRAVELS — the same vector light_record_directional
276
+ // writes into the table, which the shader then negates to point at the light.
277
+ const direction = sun.forward;
278
+
279
+ const view_matrix = camera.view_matrix;
280
+ const position = camera.transform.position;
281
+
282
+ // the medium lives in the froxel grid and the grid stops here, so there is nothing to
283
+ // integrate past it
284
+ const far = Math.min(this.#froxel_far, camera.far);
285
+
286
+ const cascade_count = VOLUMETRICS_SUN_SHADOW_CASCADE_COUNT;
287
+ const cascade_ratio = VOLUMETRICS_SUN_SHADOW_CASCADE_RATIO;
288
+
289
+ // the view matrix's z row, negated: view space looks down -Z, and the row is unit length
290
+ // because the linear part is a rotation
291
+ const camera_forward = [-view_matrix[2], -view_matrix[6], -view_matrix[10]];
292
+
293
+ for (let cascade = 0; cascade < cascade_count; cascade++) {
294
+
295
+ const cascade_far = volumetrics_sun_shadow_cascade_far(far, cascade, cascade_count, cascade_ratio);
296
+
297
+ this.#sun_shadow_step_ws[cascade] = build_volumetrics_sun_shadow_transform({
298
+ ws_to_uvw: this.#sun_shadow_ws_to_uvw[cascade],
299
+ uvw_to_ws: this.#sun_shadow_uvw_to_ws[cascade],
300
+ light_direction: [direction.x, direction.y, direction.z],
301
+ camera_position: [position.x, position.y, position.z],
302
+ camera_forward,
303
+ fov_y: camera.fov,
304
+ aspect: camera.aspect,
305
+ near: camera.near,
306
+ far: cascade_far,
307
+ resolution,
308
+ });
309
+
310
+ this.#sun_shadow_cascade_far[cascade] = cascade_far;
311
+ }
312
+
313
+ // Trailing lanes repeat the outermost cascade rather than holding zero: the read side walks
314
+ // the ladder comparing view depth against these, and a zero would read as a cascade that
315
+ // ends at the camera.
316
+ for (let lane = cascade_count; lane < 4; lane++) {
317
+ this.#sun_shadow_cascade_far[lane] = far;
318
+ this.#sun_shadow_step_ws[lane] = this.#sun_shadow_step_ws[cascade_count - 1];
319
+ }
320
+
321
+ this.#sun_shadow_resolution = resolution;
322
+
323
+ return 0;
324
+ }
325
+
326
+ #update_metadata() {
327
+ const view = this.#view;
328
+ const camera = view.camera;
329
+
330
+ const resolution = this.#volumetrics_textures[0].size;
331
+
332
+ this.#sun_shadow_light_index = this.#update_sun_shadow();
333
+
334
+ const volumetrics_cluster_parameters = new Float32Array(3);
335
+
336
+ // The depth-curve `scale` must grow with the slice count. The hard-coded 4.06 was tuned for
337
+ // the light-cluster grid (24 slices); reusing it with 64 froxel slices collapses the log
338
+ // curve so ~half the slices bunch into the first fraction of a unit of depth, leaving almost
339
+ // none for the actual scene. Derive scale for a log-uniform distribution over
340
+ // [near + near_offset, far] instead (gives O ~= 0, i.e. depth ~ offset * (far/offset)^(s/(N-1))).
341
+ const NEAR_OFFSET = 0.1;
342
+ const depth_offset = camera.camera.near + NEAR_OFFSET;
343
+ const depth_scale = (VOLUMETRICS_RESOLUTION_Z - 1) / Math.log2(camera.camera.far / depth_offset);
344
+
345
+ build_light_cluster_parameters(
346
+ volumetrics_cluster_parameters,
347
+ 0,
348
+ VOLUMETRICS_RESOLUTION_Z,
349
+ camera.camera.near,
350
+ camera.camera.far,
351
+ NEAR_OFFSET,
352
+ depth_scale
353
+ );
354
+
355
+ // Froxel-grid curve: same construction but ending at froxel_far (<= camera.far). The grid's
356
+ // slices pack into [near, froxel_far] for extra near detail; beyond froxel_far the aerial
357
+ // pass ray-marches the volumes. froxel_far == camera.far -> identical to the LUT curve (no-op).
358
+ const froxel_far = Math.min(this.#froxel_far, camera.camera.far);
359
+ const froxel_depth_scale = (VOLUMETRICS_RESOLUTION_Z - 1) / Math.log2(froxel_far / depth_offset);
360
+ const volumetrics_froxel_cluster_parameters = new Float32Array(3);
361
+ build_light_cluster_parameters(
362
+ volumetrics_froxel_cluster_parameters,
363
+ 0,
364
+ VOLUMETRICS_RESOLUTION_Z,
365
+ camera.camera.near,
366
+ froxel_far,
367
+ NEAR_OFFSET,
368
+ froxel_depth_scale
369
+ );
370
+
371
+ // The projection is infinite-far reverse-Z, so the camera frustum's far plane is zeroed and
372
+ // can't be sliced into froxel slabs. Rebuild a finite far plane at the logical far for the
373
+ // participating-media coverage test — exactly as the light-cluster assignment does.
374
+ const clipped_frustum = new Float32Array(24);
375
+ clipped_frustum.set(camera.camera.frustum);
376
+ update_frustum_far_plane(clipped_frustum, camera.camera.view_matrix, camera.camera.far);
377
+
378
+ this.#metadata.upload({
379
+ cluster_parameters: volumetrics_cluster_parameters,
380
+ froxel_cluster_parameters: volumetrics_froxel_cluster_parameters,
381
+ resolution,
382
+ frustum: slice_flat_array(clipped_frustum, 0, 4, 6),
383
+ history_reset: this.#reset_history ? 1 : 0,
384
+ froxel_far,
385
+ // multiscatter knobs (GUI-tunable via globals); engine defaults 6 / 0.5
386
+ ms_octave_count: globalThis.__VOL_MS_OCTAVES ?? 6,
387
+ ms_extinction_decay: globalThis.__VOL_MS_EXT_DECAY ?? 0.5,
388
+ // Q-04. The sun's media self-shadowing, out of a 16-step per-froxel march and into one
389
+ // fetch of the cascades built earlier this frame — see #update_sun_shadow.
390
+ sun_shadow_ws_to_uvw: this.#sun_shadow_ws_to_uvw,
391
+ sun_shadow_uvw_to_ws: this.#sun_shadow_uvw_to_ws,
392
+ sun_shadow_step_ws: this.#sun_shadow_step_ws,
393
+ sun_shadow_cascade_far: this.#sun_shadow_cascade_far,
394
+ sun_shadow_light_index: this.#sun_shadow_light_index,
395
+ }, this.#device.queue);
396
+ }
397
+
398
+ /**
399
+ *
400
+ * @param {FrameGraph} graph
401
+ * @param {number} light_cluster_data
402
+ * @param {number} light_cluster_lookup
403
+ * @param {number} light_cluster_parameters
404
+ * @param {number} shadowmap_atlas
405
+ */
406
+ graph_update({
407
+ graph,
408
+ light_cluster_data,
409
+ light_cluster_lookup,
410
+ light_cluster_parameters,
411
+ shadowmap_atlas,
412
+ }) {
413
+
414
+ const view = this.#view;
415
+ const lights = view.scene.lights;
416
+ const camera = view.camera;
417
+
418
+ // Optional runtime A/B toggle (e.g. a GUI checkbox) without per-view plumbing. The setter
419
+ // forces a history reset on change, so flipping it back on never blends against stale data.
420
+ const taa_requested = globalThis.__VOL_TAA_ENABLED ?? true;
421
+ if (taa_requested !== this.#taa_enabled) {
422
+ this.taa_enabled = taa_requested;
423
+ }
424
+
425
+ // runtime froxel-grid far override (GUI); Infinity -> grid covers the whole range (no far march)
426
+ const froxel_far_requested = globalThis.__VOL_FROXEL_FAR ?? Infinity;
427
+ if (froxel_far_requested !== this.#froxel_far) {
428
+ this.#froxel_far = froxel_far_requested;
429
+ // the froxel grid's depth curve just changed; the TAA history is on the old curve, reset it
430
+ this.#reset_history = true;
431
+ }
432
+
433
+ this.#update_resolution();
434
+ this.#update_metadata();
435
+
436
+ const gr_metadata = graph_import_buffer(graph, this.#metadata.buffer, "volumetrics metadata");
437
+
438
+ const resolution = this.#volumetrics_textures[0].size;
439
+
440
+ // Ping-pong the two persistent froxel textures: this frame writes [parity] and reads the
441
+ // previous frame's accumulation from [parity ^ 1]. On the first frame / after a resize the
442
+ // history is invalid, which #update_metadata signals via history_reset (the TAA then emits
443
+ // the fresh sample). graph_build_volumetrics inserts the reprojection pass only when both
444
+ // history/output handles are supplied — so leaving them undefined disables it.
445
+ let taa_history;
446
+ let taa_output;
447
+
448
+ if (this.#taa_enabled) {
449
+ const parity = view.frame_index & 1;
450
+ taa_history = graph_import_texture(graph, this.#volumetrics_textures[parity ^ 1], "volumetrics taa history");
451
+ taa_output = graph_import_texture(graph, this.#volumetrics_textures[parity], "volumetrics taa output");
452
+ }
453
+
454
+ const { lut_aerial } = graph_build_volumetrics({
455
+ graph,
456
+ camera,
457
+ lights,
458
+ view,
459
+ light_cluster_data,
460
+ light_cluster_lookup,
461
+ light_cluster_parameters,
462
+ shadowmap_atlas,
463
+
464
+ volumetrics_metadata: gr_metadata,
465
+ volumetric_resolution: resolution,
466
+ sun_shadow_resolution: this.#sun_shadow_resolution,
467
+
468
+ taa_history,
469
+ taa_output,
470
+ camera_previous: view.gpu_previous_camera_state,
471
+ });
472
+
473
+ this.#reset_history = false;
474
+
475
+ return {
476
+ resolved: lut_aerial,
477
+ metadata: gr_metadata,
478
+ };
479
+ }
480
+
316
481
  }