bevy_elements 0.7.8

Living environments for Bevy: sun, moon and stars, climate-driven weather, GPU wind, volumetric clouds, rain, snow and fog, lightning, terrain with hydraulic and thermal erosion, flooding, snow cover, wetness and dust — simulated in compute shaders.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
// The cloud ray march, one texel per k×k block of pixels (k = 4 or 2), one pixel of each block
// per frame. Output: premultiplied, exposed radiance (rgb) and transmittance (a). See
// docs/research/clouds.md ("Ray march", "Lighting at a fine sample", "Aerial perspective").
//
// A second entry point, `cloud_sky_probe`, marches the same clouds into a cube around the camera
// and composes it with Bevy's atmosphere cube: the environment map the scene's reflections and
// diffuse sky light come from (docs/research/sky-light.md). It binds no view, depth or aerial
// LUT; the functions it shares with the screen march take no screen position and return
// luminance (unexposed).

import bevy_render::view::View;
import bevy_pbr::render::utils::sample_cube_dir;
import bevy_elements::noise::hash31;
import bevy_elements::clouds::common::{
    CloudParams, Medium, MAX_MARCH_M, PI, altitude, cloud_medium, coarse_shape, detail_weight,
    ground_before_layer, is_orthographic, light_at, phase, sample_transmittance, scene_distance,
    shell_range, sky_through_clouds, slot_offset, view_ray,
};

@group(0) @binding(0) var<uniform> view: View;
@group(0) @binding(1) var<uniform> params: CloudParams;
@group(0) @binding(2) var base_tex: texture_3d<f32>;
@group(0) @binding(3) var detail_tex: texture_3d<f32>;
@group(0) @binding(4) var weather_tex: texture_2d<f32>;
@group(0) @binding(5) var transmittance_lut: texture_2d<f32>;
@group(0) @binding(6) var aerial_lut: texture_3d<f32>;
@group(0) @binding(7) var depth_tex: texture_depth_2d;
@group(0) @binding(8) var smp_repeat: sampler;
@group(0) @binding(9) var smp_clamp: sampler;
@group(0) @binding(10) var march_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(11) var sky_probe: texture_cube<f32>;
// The sky probe's own: its cloud state of the last frame and of this one (premultiplied
// luminance and transmittance per cube texel), and the composed cube.
@group(0) @binding(12) var probe_prev: texture_2d_array<f32>;
@group(0) @binding(13) var probe_next: texture_storage_2d_array<rgba16float, write>;
@group(0) @binding(14) var probe_out: texture_storage_2d_array<rgba16float, write>;
// The horizon ring `cloud_horizon_ring` writes: the sky's radiance at the horizon in 32
// azimuths, `w` 1 once written (a buffer nothing has written reads 0).
@group(0) @binding(15) var<storage, read_write> horizon_ring: array<vec4<f32>, 32>;

/// Directions over the upper hemisphere the sky's light is averaged from.
const SKY_SAMPLES: u32 = 16u;

/// The night sky's own colour: the *scotopic* one, a cool blue-green. Jensen et al. 2001 §7
/// on the blue shift — night scenes "exhibit a bluish tint" — and the rod's curve, whose peak
/// is at 507 nm.
const NIGHT_SKY_COLOUR: vec3<f32> = vec3<f32>(0.72, 0.95, 1.28);

/// The clear sky's irradiance on the layer, `π L̄`, with `L̄` the mean radiance of Bevy's sky over
/// the upper hemisphere (clouds.md Deviation 30): a spherical Fibonacci set uniform in solid
/// angle over the camera's atmosphere environment map (z flipped, cubemaps are left-handed; a
/// white cube without one), undimmed by the cloud shadow at the camera (`air.y`), plus the night
/// sky's neutral background behind the atmosphere (`atmo.w`).
fn sky_irradiance() -> vec3<f32> {
    var sum = vec3<f32>(0.0);
    for (var i = 0u; i < SKY_SAMPLES; i += 1u) {
        let y = (f32(i) + 0.5) / f32(SKY_SAMPLES);
        let r = sqrt(max(1.0 - y * y, 0.0));
        let phi = 2.3999632 * f32(i);
        let d = vec3<f32>(r * cos(phi), y, -r * sin(phi));
        sum += textureSampleLevel(sky_probe, smp_clamp, d, 0.0).rgb;
    }
    return PI * (sum / f32(SKY_SAMPLES) * params.air.y + vec3<f32>(params.atmo.w));
}

/// The ambient source of luminance `s` in the colour of the sky's irradiance `sky_e`: the
/// skylight in the cloud. The sunlight the cloud has scattered out of the beam is
/// `cloud_scatter`'s diffusion term (cloud-lighting.md Deviation 3).
fn ambient_light(sky_e: vec3<f32>, s: f32) -> vec3<f32> {
    let lum = dot(sky_e, vec3<f32>(0.2126, 0.7152, 0.0722));
    if !(lum > 1e-12) {
        return vec3<f32>(s);
    }
    return sky_e * (s / lum);
}

/// Consecutive empty fine samples after which the march returns to coarse steps (Nubis).
const EMPTY_LIMIT: u32 = 10u;
/// Transmittance below which the march stops (Högfeldt, Schneider's alpha → 1).
const MIN_TRANSMITTANCE: f32 = 0.01;
/// Tangent of the light-sample cone half angle.
const CONE_TAN: f32 = 0.1;
/// Bottom weight of the ambient gradient (ground bounce).
const AMBIENT_BOTTOM: f32 = 0.2;
/// Distance at which cloud extinction starts to fade toward the march limit, m.
const FADE_START_M: f32 = 16000.0;
/// Path length through the layer beyond `FADE_START_M` over which the far field builds up to its
/// mean cover (1 − 1/e of it at this length), m: a few cloud widths, so that under full cover
/// the sky does not show through.
const FAR_LENGTH_M: f32 = 4000.0;
/// Optical depth toward the light from the surface of the far cloud, along the vertical: the
/// order of the model stratus's column depth (6.04, Validation 7), set to 5 by comparison with the
/// marched cloud (Deviation 27 of the research note).
const FAR_TAU: f32 = 5.0;
/// Cover over which the far field turns from broken cloud to a uniform deck, whose light path
/// slants with the light.
const DECK_COVER_FROM: f32 = 0.6;
const DECK_COVER_TO: f32 = 0.95;
/// Lowest sine of the light's elevation the deck's slanted light path is built for.
const MIN_LIGHT_SINE: f32 = 0.1;
/// Ray elevation (sine) below the horizon over which the far veil fades out.
const HORIZON_BAND: f32 = 0.03;

fn medium_at(h: f32, xz: vec2<f32>, detail: f32) -> Medium {
    return cloud_medium(h, xz, detail, params, base_tex, detail_tex, weather_tex, smp_repeat);
}

/// Optical depth toward the light from the point `off` (offset from the camera), m⁻¹·m: six
/// samples in a cone, base shape only. Segment lengths 30, 60, 120, 240, 480 m and a far sample
/// at 1800 m covering 1740 m; times (1 + g) per sample. Each sample sits at a fixed golden-angle
/// azimuth around the light direction (Schneider's fixed cone kernel): the lighting is a smooth
/// function of position, so pixels do not carry independent noise.
fn light_optical_depth(off: vec3<f32>, l: vec3<f32>, cam: vec3<f32>) -> f32 {
    var dist = array<f32, 6>(15.0, 60.0, 150.0, 330.0, 690.0, 1800.0);
    var len = array<f32, 6>(30.0, 60.0, 120.0, 240.0, 480.0, 1740.0);
    let helper = select(vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(1.0, 0.0, 0.0), abs(l.y) > 0.99);
    let e1 = normalize(cross(l, helper));
    let e2 = cross(l, e1);
    var tau = 0.0;
    for (var j = 0u; j < 6u; j += 1u) {
        let phi = 2.39996323 * f32(j + 1u);
        let perp = cos(phi) * e1 + sin(phi) * e2;
        let o = off + l * dist[j] + perp * (CONE_TAN * dist[j]);
        let h = altitude(o.xz, cam.y + o.y, params.camera.w);
        let m = medium_at(h, cam.xz + o.xz, 0.0);
        tau += (1.0 + m.g) * m.sigma * len[j];
    }
    return tau;
}

/// Diffusion's extrapolation length in optical depths, `2 / (3 (1 − g))` with the forward lobe's
/// `g = 0.8` (Jensen et al. 2001, the boundary condition `φ − 2AD ∂φ/∂n = 0`, A = 1);
/// cloud-lighting.md fits 3.39 to the Monte Carlo slabs.
const DIFFUSE_EXTRAPOLATION: f32 = 3.3333333;
/// Albedo of the diffuse source, fitted to the Monte Carlo slabs (cloud-lighting.md).
const DIFFUSE_ALBEDO: f32 = 0.68;
/// How far below a sample (away from the light) the cloud behind it is sampled, m; the sample
/// stands for twice this.
const BELOW_M: f32 = 300.0;

/// Radiance of cloud lit by `sun` through a light path of optical depth `tau_l`, with `tau_b` of
/// cloud behind the sample along the light, seen at angle `cos_t` to the light that makes
/// cosine `mu_l` with the local vertical: single scattering with the dual-lobe phase (the
/// powder darkening near the lit surface) plus the multiply scattered light as photon diffusion
/// in a conservative slab, whose fluence falls linearly between extrapolated boundaries
/// `(τ_b + z) / (τ_l + τ_b + 2z)` (cloud-lighting.md, Model), and the ambient source `ambient`.
fn cloud_scatter(
    sun: vec3<f32>,
    tau_l: f32,
    tau_b: f32,
    mu_l: f32,
    cos_t: f32,
    powder_weight: f32,
    ambient: vec3<f32>,
) -> vec3<f32> {
    let powder = mix(1.0, 1.0 - exp(-2.0 * tau_l), powder_weight);
    let z = DIFFUSE_EXTRAPOLATION;
    let diffuse =
        DIFFUSE_ALBEDO / PI * sqrt(max(mu_l, 0.0)) * (tau_b + z) / (tau_l + tau_b + 2.0 * z);
    return sun * (powder * exp(-tau_l) * phase(cos_t) + diffuse) + ambient;
}

/// Optical depth of the cloud behind the point `off` along the light (away from it), m⁻¹·m: one
/// base-shape sample `BELOW_M` beyond it standing for `2 · BELOW_M`, times `1 + g`.
fn below_optical_depth(off: vec3<f32>, l: vec3<f32>, cam: vec3<f32>) -> f32 {
    let o = off - l * BELOW_M;
    let h = altitude(o.xz, cam.y + o.y, params.camera.w);
    let m = medium_at(h, cam.xz + o.xz, 0.0);
    return (1.0 + m.g) * m.sigma * 2.0 * BELOW_M;
}

/// Aerial perspective from the camera to `depth` along `dir`: the transmittance of the air
/// (rgb) and its in-scatter (rgb of the second return), luminance.
struct Aerial {
    transmittance: vec3<f32>,
    in_scatter: vec3<f32>,
}

/// Transmittance of the air from the camera at radius `o_r` to `depth` along `dir`. The LUT holds
/// the transmittance to the top of the atmosphere, which a descending ray never reaches (it meets
/// the ground): the air between two points is then taken along the reversed ray, which leaves
/// the atmosphere through the camera (Bruneton's `GetTransmittance`). A camera above the clouds
/// looking down sees them through that much air, not through the ground's (cloud-lighting.md
/// Deviation 1).
fn air_transmittance(dir: vec3<f32>, depth: f32, o_r: f32) -> vec3<f32> {
    let r_d = sqrt(max(o_r * o_r + 2.0 * o_r * depth * dir.y + depth * depth, 1.0));
    let mu_d = (o_r * dir.y + depth) / r_d;
    if dir.y < 0.0 {
        let t_from_far = sample_transmittance(transmittance_lut, smp_clamp, r_d, -mu_d, params);
        let t_from_cam = sample_transmittance(transmittance_lut, smp_clamp, o_r, -dir.y, params);
        return min(t_from_far / max(t_from_cam, vec3<f32>(1e-6)), vec3<f32>(1.0));
    }
    let t_cam = sample_transmittance(transmittance_lut, smp_clamp, o_r, dir.y, params);
    let t_far = sample_transmittance(transmittance_lut, smp_clamp, r_d, mu_d, params);
    return min(t_cam / max(t_far, vec3<f32>(1e-6)), vec3<f32>(1.0));
}

/// Aerial perspective through the screen position `uv`. In-scatter is Bevy's aerial-view LUT,
/// lit by the sun light that carries the beam only, plus the air lit by the cloud-scattered
/// diffuse light, `(1 − T) · L` for the uniform sky radiance `L` (Deviation 25 of the research
/// note).
fn aerial_perspective(uv: vec2<f32>, dir: vec3<f32>, depth: f32, o_r: f32) -> Aerial {
    let slices = params.atmo.z;
    let range = params.atmo.y;
    let slice = clamp(depth / range - 0.5 / slices, 0.0, 1.0);
    let lut = textureSampleLevel(aerial_lut, smp_clamp, vec3<f32>(uv, slice), 0.0);
    let t_ap = air_transmittance(dir, depth, o_r);
    let beam = exp(lut.rgb) * clamp(depth / (range / slices), 0.0, 1.0);
    return Aerial(t_ap, beam + (vec3<f32>(1.0) - t_ap) * params.sky.yzw);
}

/// Aerial perspective along a sky-probe direction, where there is no screen to index Bevy's
/// aerial-view LUT by (sky-light.md Model 2): the in-scatter is the clear sky's radiance `sky`
/// along `dir` (the in-scatter of the whole path to the top of the atmosphere, lit as the LUT
/// is) times the share of the path's extinction that lies in front of the cloud, plus the air
/// lit by the cloud-scattered diffuse light as on the screen.
fn aerial_along(dir: vec3<f32>, depth: f32, o_r: f32, sky: vec3<f32>) -> Aerial {
    let t_ap = air_transmittance(dir, depth, o_r);
    let t_top = sample_transmittance(transmittance_lut, smp_clamp, o_r, dir.y, params);
    let share = clamp(
        (vec3<f32>(1.0) - t_ap) / max(vec3<f32>(1.0) - t_top, vec3<f32>(1e-4)),
        vec3<f32>(0.0),
        vec3<f32>(1.0),
    );
    return Aerial(t_ap, sky * share + (vec3<f32>(1.0) - t_ap) * params.sky.yzw);
}

/// A ray that meets the planet before the layer (past the edge of a finite ground): no cloud, and
/// the air to the ground is the camera's distance fog, as on every mesh, `scene · T + fog · (1 −
/// T)` with `T = exp(−σ d)` (bevy_pbr's atmospheric fog with equal extinction and in-scattering).
/// Bevy's sky pixel there is the planet lit by the sun light, which under cloud carries only the
/// beam; the fog's colour carries the diffuse light. The transmittance LUT cannot give this path:
/// it holds rays that leave the atmosphere (Deviation 28 of the research note). The distance is
/// floored at the march range: a below-horizon pixel with no mesh shows ground the fog has fully
/// carried — nothing nearer than the far field is visible beyond a finite terrain, and the bare
/// planet within the march range would otherwise show through as a dark band at the horizon
/// (measured 68 DN on `ground_cover` at 6 m eye). Luminance: the fog colour is display-referred,
/// so it is divided by the camera's exposure.
fn ground_haze(ground_m: f32, sky: vec3<f32>) -> vec4<f32> {
    // The weather's air over the far field, as the composite fogs a sky pixel (`air.x` is the
    // extinction above clear air, so a clear sky leaves this exactly 0): what the slab shows is
    // the atmosphere in that column seen through the weather, converging on the fog colour as
    // the weather thickens — the same law the ground below obeys.
    let air = 1.0 - exp(-params.air.x * MAX_MARCH_M);
    let colour = mix(sky, params.haze.xyz / max(params.probe.x, 1e-30), air);
    let t = exp(-params.haze.w * max(ground_m, MAX_MARCH_M));
    return vec4<f32>(colour * (1.0 - t), t);
}

/// The sky's own radiance at the horizon in the ray's azimuth: the air a below-horizon ray
/// converges on (`ground_haze`). Read from the sky cube, which holds the atmosphere (and the
/// cloud, once composed), so a slab that fills the frame past a finite terrain is the sky in
/// *that column* rather than one mean over the whole horizon: a dusk horizon is not the same
/// colour every way round, and a mean of it is a band of its own (`docs/research/horizon-colour.md`).
/// The two taps are raised above the horizon so they are sky and not the cube's own ground, and
/// the radiance at zero elevation is extrapolated from them.
fn horizon_sky(dir: vec3<f32>) -> vec3<f32> {
    let flat = vec3<f32>(dir.x, 0.0, dir.z);
    let along = select(vec3<f32>(0.0, 0.0, -1.0), normalize(flat), length(flat) > 1e-4);
    let world = along * cos(HORIZON_SAMPLE_BAND) + vec3<f32>(0.0, sin(HORIZON_SAMPLE_BAND), 0.0);
    // Cubemaps are left-handed: the cube direction is the world's with z negated.
    let cube = vec3<f32>(world.x, world.y, -world.z);
    // The cube is lit by the sun light, which carries only the cloud's beam `T̄`, so it holds the
    // clear sky dimmed to that; `sky_gain` (`params.sky.x`) restores the dome to the total light,
    // exactly as the composite restores a sky pixel (`clouds.md` Deviation 25). The night sky's
    // own light is **not** added here, though the environment cube adds it: that term exists so
    // the lighting cube is not black at night, and it is not radiance of the sky the screen draws
    // — read back into the slab it drew a grey sea under a black sky (measured 9.6, 12.4, 15.5 DN
    // against a sky of 2, 1, 1 at midnight).
    let lower = textureSampleLevel(sky_probe, smp_clamp, cube, 0.0).rgb;
    // Extrapolate down to the horizon: the sample sits `HORIZON_SAMPLE_BAND` up the atmosphere's
    // gradient, which is steep in twilight (the Earth's shadow rising), and reading it as the
    // horizon's own colour drew a bright band above the slab at dusk. The sky is near-linear over
    // a couple of degrees, so the radiance at zero elevation is `2 · r(h) − r(2h)`.
    let upper_world =
        along * cos(2.0 * HORIZON_SAMPLE_BAND) + vec3<f32>(0.0, sin(2.0 * HORIZON_SAMPLE_BAND), 0.0);
    let upper = textureSampleLevel(
        sky_probe,
        smp_clamp,
        vec3<f32>(upper_world.x, upper_world.y, -upper_world.z),
        0.0,
    )
    .rgb;
    return max(lower * 2.0 - upper, vec3<f32>(0.0)) * params.sky.x;
}

/// The horizon's radiance in each of the ring's 32 azimuths, for the main world to average into
/// the distance fog's colour (`docs/research/fog-colour.md`): the same `horizon_sky` the slab
/// past a finite terrain reads, so the mesh fog and the slab are one colour. Azimuth `i` is
/// `(i + 0.5)/32` of a turn from -Z toward +X, the way `atan2(x, -z)` runs.
@compute @workgroup_size(32, 1, 1)
fn cloud_horizon_ring(@builtin(local_invocation_id) id: vec3<u32>) {
    let a = (f32(id.x) + 0.5) / 32.0 * 6.2831853;
    horizon_ring[id.x] = vec4<f32>(horizon_sky(vec3<f32>(sin(a), 0.0, -cos(a))), 1.0);
}

/// Elevation of the band a below-horizon ray reads the sky from, rad: 0.5°. The cube's texel at
/// the horizon of a 128² face spans about 0.7°, so a fetch closer than that bilinearly averages
/// the cube's own ground in and the slab goes dark (measured at 0.1°: 12.9 against a sky of 14.2).
/// The horizon's own value is extrapolated from the taps here and at twice this elevation; a wider
/// pair is worse, not better, because the sky's gradient steepens toward the horizon as the sun
/// sets, so extrapolating from 1° and 2° overshot it (measured 5.8 DN bright at 20 h against 3.1
/// with this pair).
const HORIZON_SAMPLE_BAND: f32 = 0.0087;

/// Where along a ray the far veil's cloud lies: about one build-up length past where it starts.
fn veil_depth(shell: vec2<f32>) -> f32 {
    return max(shell.x, FADE_START_M) + FAR_LENGTH_M;
}

/// The cloud beyond the march and its fade: a uniform veil of the layer's mean cover over the
/// path through the layer past `FADE_START_M`, lit as the marched base is lit where the march
/// fades and seen through the air `air` to the cloud's own distance (`veil_depth`).
/// Premultiplied luminance (rgb) and transmittance (a). Under full cover the horizon keeps the
/// cloud; at cover 0.05 it stays clear sky. Smooth in the ray, so it cannot alias (Deviations 26
/// and 27 of the research note). A ray that meets the planet first sees no cloud, only the air
/// to the ground (`ground_haze`).
fn far_veil(dir: vec3<f32>, shell: vec2<f32>, o_r: f32, air: Aerial, sky_e: vec3<f32>) -> vec4<f32> {
    let ground = ground_before_layer(o_r, dir.y, shell.x, params.camera.w);
    if ground > 0.0 {
        return ground_haze(ground, horizon_sky(dir));
    }
    let t_start = max(shell.x, FADE_START_M);
    let length = shell.y - t_start;
    let above = smoothstep(-HORIZON_BAND, 0.0, dir.y);
    let a = params.layer.w * (1.0 - exp(-max(length, 0.0) / FAR_LENGTH_M)) * above;
    if a <= 0.0 {
        return vec4<f32>(0.0, 0.0, 0.0, 1.0);
    }
    let l = params.light.xyz;
    // The surface the march sees at grazing angles is the base of the cloud: the optical depth
    // toward the viewer reaches one within metres of it, so the sample is at the base, where the
    // march's ambient gradient is `AMBIENT_BOTTOM`. A uniform deck lets the light in along the
    // slant of its column; a broken layer lights its sides through about the column depth.
    let h_b = params.layer.x;
    let off = dir * t_start;
    let mu_s = (dot(off.xz, l.xz) + (o_r + off.y) * l.y) / (params.camera.w + h_b);
    let sun = light_at(transmittance_lut, smp_clamp, h_b, mu_s, params);
    let deck = smoothstep(DECK_COVER_FROM, DECK_COVER_TO, params.layer.w);
    let tau_l = FAR_TAU * mix(1.0, 1.0 / max(l.y, MIN_LIGHT_SINE), deck) * (1.0 + params.weather.w);
    let cos_t = dot(dir, l);
    // Behind a broken layer's lit side lies about as much cloud as in front of it; below a
    // uniform deck's base, none.
    let radiance = cloud_scatter(
        sun,
        tau_l,
        FAR_TAU * (1.0 - deck),
        mu_s,
        cos_t,
        clamp(0.5 - 0.5 * cos_t, 0.0, 1.0),
        ambient_light(sky_e, params.light_e.w * AMBIENT_BOTTOM),
    );
    return vec4<f32>(a * (radiance * air.transmittance + air.in_scatter), 1.0 - a);
}

/// What a march along a ray gathered: premultiplied scattered luminance and transmittance, and
/// the transmittance-weighted sum of its sample depths.
struct Integrated {
    radiance: vec3<f32>,
    transmittance: f32,
    depth_sum: f32,
    depth_weight: f32,
}

/// March the cloud layer from `cam` along `dir` between `t0` and `t1` (metres) in about `steps`
/// primary steps, the samples offset by `jitter` (0–1) of a step, the ambient in the colour of
/// the sky irradiance `sky_e`.
fn integrate(
    cam: vec3<f32>,
    dir: vec3<f32>,
    t0: f32,
    t1: f32,
    jitter: f32,
    sky_e: vec3<f32>,
    steps: u32,
) -> Integrated {
    let big_r = params.camera.w;
    let h_b = params.layer.x;
    let h_t = params.layer.y;
    let l = params.light.xyz;
    let n = f32(steps);
    let dt = max(clamp((t1 - t0) / n, 25.0, 400.0), (t1 - t0) / (1.5 * n));
    let cos_t = dot(dir, l);
    let detail = detail_weight(dt);
    let powder_weight = clamp(0.5 - 0.5 * cos_t, 0.0, 1.0);

    var radiance = vec3<f32>(0.0);
    var transmittance = 1.0;
    var depth_sum = 0.0;
    var depth_weight = 0.0;
    var t = t0 + dt * jitter;
    var coarse = true;
    var empty = 0u;
    var resume = t;
    let max_iterations = steps * 3u;
    for (var it = 0u; it < max_iterations; it += 1u) {
        if t >= t1 || transmittance < MIN_TRANSMITTANCE {
            break;
        }
        let off = dir * t;
        let h = altitude(off.xz, cam.y + off.y, big_r);
        let xz = cam.xz + off.xz;
        if coarse {
            let shape = coarse_shape(h, xz, params, base_tex, weather_tex, smp_repeat);
            if shape > 0.0 {
                coarse = false;
                empty = 0u;
                t = max(t - 2.0 * dt, resume);
            } else {
                t += 2.0 * dt;
            }
            continue;
        }
        var m = medium_at(h, xz, detail);
        // Distant cloud thins out with distance instead of ending in a hard wall at the march
        // limit (Deviation 11 of the research note).
        m.sigma *= 1.0 - smoothstep(FADE_START_M, MAX_MARCH_M, t);
        if m.sigma <= 0.0 {
            empty += 1u;
            if empty >= EMPTY_LIMIT {
                coarse = true;
                resume = t + dt;
            }
            t += dt;
            continue;
        }
        empty = 0u;
        // Each sample stands for the segment of length dt it starts (the jitter shifts the
        // sample inside its segment, not the segment).
        let seg = clamp(t1 - (t - dt * jitter), 0.0, dt);
        let step_t = exp(-m.sigma * seg);

        let mu_s = (dot(off.xz, l.xz) + (big_r + cam.y + off.y) * l.y) / (big_r + h);
        let sun = light_at(transmittance_lut, smp_clamp, h, mu_s, params);
        let tau_l = light_optical_depth(off, l, cam);
        let tau_b = below_optical_depth(off, l, cam);
        let f = (h - h_b) / (h_t - h_b);
        var scatter = cloud_scatter(
            sun,
            tau_l,
            tau_b,
            mu_s,
            cos_t,
            powder_weight,
            ambient_light(sky_e, params.light_e.w * (AMBIENT_BOTTOM + (1.0 - AMBIENT_BOTTOM) * f)),
        );
        if params.flash.w > 0.0 {
            let d = length(cam + off - params.flash.xyz);
            let source = params.flash.w / (4.0 * PI * max(d * d, 2500.0))
                * exp(-0.5 * m.sigma * min(d, 150.0));
            scatter += vec3<f32>(source);
        }
        radiance += transmittance * scatter * (1.0 - step_t);
        depth_sum += transmittance * t;
        depth_weight += transmittance;
        transmittance *= step_t;
        t += dt;
    }
    // A march that stopped on its transmittance floor met an opaque cloud: the rest of the
    // column is assumed to scatter like what was met, and nothing behind shows through — the
    // sun disc is 10⁵ times the sky, so its last 1 % would still burn white in the cloud
    // (Schneider & Vos 2015: alpha 1 at the exit; broken-cloud-light.md, Model 4).
    if transmittance < MIN_TRANSMITTANCE {
        radiance /= 1.0 - transmittance;
        transmittance = 0.0;
    }
    return Integrated(radiance, transmittance, depth_sum, depth_weight);
}

/// The marched cloud seen through the air `air` to its mean depth, fading into the haze toward
/// the march range, over the far field `veil` behind it: premultiplied luminance and
/// transmittance. `r` met cloud (`depth_weight > 0`).
fn finish_ray(r: Integrated, air: Aerial, veil: vec4<f32>) -> vec4<f32> {
    let depth = r.depth_sum / r.depth_weight;
    let fade = smoothstep(20000.0, 32000.0, depth);
    let rgb = (1.0 - fade) * (r.radiance * air.transmittance + air.in_scatter * (1.0 - r.transmittance));
    let alpha = 1.0 - (1.0 - fade) * (1.0 - r.transmittance);
    // The far field behind it: the cloud in front covers it by `1 − alpha`.
    return vec4<f32>(rgb + alpha * veil.rgb, alpha * veil.a);
}

/// Luminance to the view's display-referred value.
fn exposed(c: vec4<f32>) -> vec4<f32> {
    return vec4<f32>(c.rgb * view.exposure, c.a);
}

@compute @workgroup_size(8, 8, 1)
fn cloud_march(@builtin(global_invocation_id) id: vec3<u32>) {
    let low = params.sizes.zw;
    if any(id.xy >= low) {
        return;
    }
    let k = params.counts.z;
    let size = params.sizes.xy;
    let frame = params.counts.x;
    let px = min(id.xy * k + slot_offset(frame, k), size - vec2<u32>(1u));
    let uv = (vec2<f32>(px) + 0.5) / vec2<f32>(size);
    let orthographic = is_orthographic(view.clip_from_view);
    let ray = view_ray(uv, view.world_from_clip, view.world_position, orthographic, params.camera.xyz, params.units.x);
    // The planet's centre lies straight below the ray's origin: the eye of a perspective view,
    // the pixel's point on the focus plane of an orthographic one.
    let cam = ray.origin;
    let dir = ray.dir;
    let big_r = params.camera.w;
    let o_r = big_r + cam.y;
    let h_b = params.layer.x;
    let h_t = params.layer.y;

    let shell = shell_range(o_r, dir.y, h_b, h_t, big_r);
    let t0 = shell.x;
    let t1 = min(shell.y, MAX_MARCH_M);
    if t1 <= t0 {
        let air = aerial_perspective(uv, dir, veil_depth(shell), o_r);
        textureStore(march_out, vec2<i32>(id.xy), exposed(far_veil(dir, shell, o_r, air, sky_irradiance())));
        return;
    }

    // A block whose pixels all show surfaces nearer than the cloud entry shows no cloud.
    var hidden = true;
    for (var j = 0u; j < k && hidden; j += 1u) {
        for (var i = 0u; i < k && hidden; i += 1u) {
            let q = min(id.xy * k + vec2<u32>(i, j), size - vec2<u32>(1u));
            let quv = (vec2<f32>(q) + 0.5) / vec2<f32>(size);
            let d = textureLoad(depth_tex, vec2<i32>(q), 0);
            let q_ray = view_ray(quv, view.world_from_clip, view.world_position, orthographic, params.camera.xyz, params.units.x);
            let q_shell = shell_range(big_r + q_ray.origin.y, q_ray.dir.y, h_b, h_t, big_r);
            if scene_distance(quv, d, view.world_from_clip, q_ray, params.units.x) >= q_shell.x {
                hidden = false;
            }
        }
    }
    if hidden && params.misc.y < 0.5 {
        textureStore(march_out, vec2<i32>(id.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
        return;
    }

    let l = params.light.xyz;
    if params.misc.y > 1.5 {
        // Debug: the light at the entry point, for the sun-colour test.
        let o = dir * t0;
        let h = altitude(o.xz, cam.y + o.y, big_r);
        let mu = (dot(o.xz, l.xz) + (big_r + cam.y + o.y) * l.y) / (big_r + h);
        let e = light_at(transmittance_lut, smp_clamp, h, mu, params) / max(params.light_e.xyz, vec3<f32>(1e-6));
        textureStore(march_out, vec2<i32>(id.xy), vec4<f32>(e, 1.0));
        return;
    }

    let jitter = hash31(vec3<i32>(i32(px.x), i32(px.y), i32(frame)));
    let sky_e = sky_irradiance();
    let r = integrate(cam, dir, t0, t1, jitter, sky_e, params.counts.y);
    let veil = far_veil(dir, shell, o_r, aerial_perspective(uv, dir, veil_depth(shell), o_r), sky_e);
    if r.depth_weight <= 0.0 {
        textureStore(march_out, vec2<i32>(id.xy), exposed(veil));
        return;
    }
    // Aerial perspective at the mean cloud depth, then the fade into haze.
    let air = aerial_perspective(uv, dir, r.depth_sum / r.depth_weight, o_r);
    textureStore(march_out, vec2<i32>(id.xy), exposed(finish_ray(r, air, veil)));
}

/// Primary steps of a sky-probe ray against a screen ray: the probe is prefiltered into a cube
/// for reflections and sky light, so its rays take half the steps (Unreal's reflection sample
/// count scale on volumetric clouds; Hillaire 2016 marches the reflection view at a quarter of
/// the resolution), never fewer than this floor.
const PROBE_STEP_DIVISOR: u32 = 2u;
const PROBE_MIN_STEPS: u32 = 16u;

/// The clouds along a sky-probe direction that reaches the layer: premultiplied luminance and
/// transmittance, as `cloud_march` gives a sky pixel before exposure.
fn probe_ray(
    cam: vec3<f32>,
    dir: vec3<f32>,
    shell: vec2<f32>,
    o_r: f32,
    sky: vec3<f32>,
    jitter: f32,
) -> vec4<f32> {
    let sky_e = sky_irradiance();
    let veil = far_veil(dir, shell, o_r, aerial_along(dir, veil_depth(shell), o_r, sky), sky_e);
    let t0 = shell.x;
    let t1 = min(shell.y, MAX_MARCH_M);
    if t1 <= t0 {
        return veil;
    }
    let steps = max(params.counts.y / PROBE_STEP_DIVISOR, PROBE_MIN_STEPS);
    let r = integrate(cam, dir, t0, t1, jitter, sky_e, steps);
    if r.depth_weight <= 0.0 {
        return veil;
    }
    return finish_ray(r, aerial_along(dir, r.depth_sum / r.depth_weight, o_r, sky), veil);
}

/// Sines of the elevations over which the night fill's lift fades in from the horizon (20° and
/// 60°). The specular lobe of a grazing sea reaches the lifted sky from about 10° over it, so a
/// ramp from 8° to 40° lit the sea's horizon 4.1x over the sky (2.3x with this one).
const NIGHT_LIFT_FROM: f32 = 0.34202;
const NIGHT_LIFT_TO: f32 = 0.86603;
/// Keeps a horizontal surface's irradiance what the uniform lift gave: `1 / (2 ∫ w s ds)` for the
/// ramp above, computed numerically.
const NIGHT_LIFT_NORMALISE: f32 = 1.608;
/// The night fill's share left at the horizon, under the drawn sky's own luminance there over its
/// zenith's (3.2e-4 over 3.3e-3 linear, measured at midnight, clear): the lobe of a reflection
/// carries what the ramp does not hold down.
const NIGHT_HORIZON_RATIO: f32 = 0.05;

/// The night sky's light in the cube toward a direction of elevation sine `up`, as a multiple of
/// the night sky's radiance colour. Bevy multiplies the cube by the camera's exposure, so a value
/// `exposure` lifts the light that falls on a surface (`docs/research/night-sky-light.md`,
/// Deviation 2) and a value of one is the sky the screen draws; a specular reflection of the
/// horizon (a sea at a grazing view) must see the latter, not a sky `exposure` times brighter
/// than the one over it (Deviation 5). The lift grows from the horizon's physical ratio to the
/// full lift as the direction rises, normalised so a horizontal surface is lit as before.
fn night_fill(up: f32, exposure: f32) -> f32 {
    let w = smoothstep(NIGHT_LIFT_FROM, NIGHT_LIFT_TO, up);
    return NIGHT_HORIZON_RATIO * (1.0 - w) + NIGHT_LIFT_NORMALISE * exposure * w;
}

/// The sky probe: one thread per texel of the cube (`z` the face). A texel in this frame's slot
/// of its k×k block marches its direction and blends the result into its history; every texel
/// is composed with Bevy's atmosphere cube as the screen composite composes a sky pixel
/// (`sky_through_clouds`), or, toward the ground, is that cube's ground restored to the dome's
/// total light by the sky gain (sky-light.md, Model 1–4 and 6). Luminance throughout.
@compute @workgroup_size(8, 8, 1)
fn cloud_sky_probe(@builtin(global_invocation_id) id: vec3<u32>) {
    let n = u32(params.probe.z);
    if id.x >= n || id.y >= n || id.z >= 6u {
        return;
    }
    let texel = vec2<i32>(id.xy);
    let face = i32(id.z);
    let uv = (vec2<f32>(id.xy) + 0.5) / f32(n);
    // Cubemaps are left-handed: the world direction of a texel is its cube direction with z
    // negated (Bevy's `atmosphere/environment.wesl`), and the cube is sampled at the cube one.
    let c = sample_cube_dir(uv, id.z);
    let dir = vec3<f32>(c.x, c.y, -c.z);
    let sky = textureSampleLevel(sky_probe, smp_clamp, c, 0.0).rgb;
    let cam = params.camera.xyz;
    let big_r = params.camera.w;
    let o_r = big_r + cam.y;
    let shell = shell_range(o_r, dir.y, params.layer.x, params.layer.y, big_r);
    if ground_before_layer(o_r, dir.y, shell.x, big_r) > 0.0 {
        textureStore(probe_next, texel, face, vec4<f32>(0.0, 0.0, 0.0, 1.0));
        textureStore(probe_out, texel, face, vec4<f32>(sky * params.sky.x, 1.0));
        return;
    }
    var cloud = textureLoad(probe_prev, texel, face, 0);
    let k = u32(params.probe.y);
    let frame = params.counts.x;
    if all(id.xy % k == slot_offset(frame, k)) {
        let jitter = hash31(vec3<i32>(i32(id.x + id.z * n), i32(id.y), i32(frame)));
        cloud = mix(cloud, probe_ray(cam, dir, shell, o_r, sky, jitter), params.probe.w);
    }
    textureStore(probe_next, texel, face, cloud);
    let haze = params.haze.xyz / max(params.probe.x, 1e-30);
    let s = sky_through_clouds(cloud, shell, params, haze);
    // The night sky's own light, behind the atmosphere, so the cloud does not dim it. The
    // cube is display-referred (`params.probe.x` is the camera's exposure), so the radiance
    // is converted with it, as the fog's haze is. Without it a moonless night is a black
    // screen: the sky is lit and the ground on it is not (`docs/research/night-sky-light.md`).
    let night = vec3<f32>(params.atmo.w) * NIGHT_SKY_COLOUR * night_fill(dir.y, params.probe.x);
    textureStore(probe_out, texel, face, vec4<f32>(s.rgb + sky * s.a + night, 1.0));
}