Skip to main content

proof_engine/render/pbr/
atmosphere.rs

1//! Atmospheric and environmental rendering mathematics.
2//!
3//! Covers Rayleigh/Mie scattering, physical sky models, volumetric fog,
4//! procedural clouds, Gerstner ocean waves, and shadow-mapping helpers.
5//!
6//! All math is CPU-side, using `glam::{Vec2, Vec3, Vec4, Mat4}`.
7
8use glam::{Mat4, Vec2, Vec3, Vec4};
9use std::f32::consts::{FRAC_1_PI, PI};
10
11// ─────────────────────────────────────────────────────────────────────────────
12// Rayleigh and Mie scattering coefficients
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// Rayleigh scattering parameters for Earth's atmosphere.
16///
17/// `beta` — scattering coefficient per wavelength channel (m⁻¹).
18///          Default: (5.8e-6, 1.35e-5, 3.31e-5) for R, G, B.
19/// `scale_height` — characteristic scale height (m).  Default: 8000 m.
20#[derive(Debug, Clone)]
21pub struct RayleighScattering {
22    pub beta: Vec3,
23    pub scale_height: f32,
24}
25
26impl RayleighScattering {
27    /// Earth-atmosphere defaults.
28    pub fn earth() -> Self {
29        Self {
30            beta: Vec3::new(5.8e-6, 1.35e-5, 3.31e-5),
31            scale_height: 8000.0,
32        }
33    }
34
35    /// Density relative to sea level at altitude `h` (metres).
36    #[inline]
37    pub fn density(&self, h: f32) -> f32 {
38        (-h / self.scale_height).exp()
39    }
40
41    /// Scattering coefficient at altitude `h`.
42    #[inline]
43    pub fn beta_at(&self, h: f32) -> Vec3 {
44        self.beta * self.density(h)
45    }
46}
47
48impl Default for RayleighScattering {
49    fn default() -> Self {
50        Self::earth()
51    }
52}
53
54/// Mie scattering parameters for Earth's atmosphere.
55///
56/// `beta` — scalar scattering coefficient (m⁻¹).  Default: 21e-6.
57/// `scale_height` — scale height (m).  Default: 1200 m (aerosol layer).
58/// `g` — Henyey-Greenstein asymmetry parameter.  Default: 0.758 (forward-scattering).
59#[derive(Debug, Clone)]
60pub struct MieScattering {
61    pub beta: f32,
62    pub scale_height: f32,
63    /// Asymmetry parameter in (-1, 1).  Positive = forward scattering.
64    pub g: f32,
65}
66
67impl MieScattering {
68    /// Earth-atmosphere defaults.
69    pub fn earth() -> Self {
70        Self {
71            beta: 21e-6,
72            scale_height: 1200.0,
73            g: 0.758,
74        }
75    }
76
77    /// Density at altitude `h`.
78    #[inline]
79    pub fn density(&self, h: f32) -> f32 {
80        (-h / self.scale_height).exp()
81    }
82
83    /// Scattering coefficient at altitude `h`.
84    #[inline]
85    pub fn beta_at(&self, h: f32) -> f32 {
86        self.beta * self.density(h)
87    }
88}
89
90impl Default for MieScattering {
91    fn default() -> Self {
92        Self::earth()
93    }
94}
95
96// ─────────────────────────────────────────────────────────────────────────────
97// Phase functions
98// ─────────────────────────────────────────────────────────────────────────────
99
100/// Rayleigh phase function.
101///
102/// `cos_theta` — cosine of the scattering angle.
103#[inline]
104pub fn rayleigh_phase(cos_theta: f32) -> f32 {
105    let ct2 = cos_theta * cos_theta;
106    3.0 / (16.0 * PI) * (1.0 + ct2)
107}
108
109/// Henyey-Greenstein phase function for Mie scattering.
110///
111/// `g` — asymmetry parameter.
112#[inline]
113pub fn mie_phase(cos_theta: f32, g: f32) -> f32 {
114    let g2 = g * g;
115    let t = 1.0 + g2 - 2.0 * g * cos_theta;
116    (1.0 - g2) / (4.0 * PI * t.max(1e-10).powf(1.5))
117}
118
119/// Cornette-Shanks phase function — more accurate than HG for large g.
120#[inline]
121pub fn cornette_shanks_phase(cos_theta: f32, g: f32) -> f32 {
122    let g2 = g * g;
123    let t = 1.0 + g2 - 2.0 * g * cos_theta;
124    3.0 * (1.0 - g2) * (1.0 + cos_theta * cos_theta)
125        / (8.0 * PI * (2.0 + g2) * t.max(1e-10).powf(1.5))
126}
127
128// ─────────────────────────────────────────────────────────────────────────────
129// Optical depth integration
130// ─────────────────────────────────────────────────────────────────────────────
131
132/// Compute the optical depth (column density) from `start` to the atmosphere
133/// boundary, and also toward the sun for shadow computation.
134///
135/// Returns `Vec2(rayleigh_depth, mie_depth)`.
136///
137/// # Parameters
138/// - `start`    — starting position in world space (planet-centred, metres)
139/// - `dir`      — ray direction (normalised)
140/// - `atm`      — atmosphere parameters
141/// - `n_steps`  — number of integration steps
142pub fn optical_depth(
143    start: Vec3,
144    dir: Vec3,
145    atm: &AtmosphereParams,
146    n_steps: usize,
147) -> Vec2 {
148    let ray_len = ray_sphere_intersect(start, dir, atm.radius_atm)
149        .unwrap_or(0.0)
150        .max(0.0);
151
152    if ray_len < 1e-3 {
153        return Vec2::ZERO;
154    }
155
156    let step_len = ray_len / n_steps as f32;
157    let mut rayleigh = 0.0f32;
158    let mut mie = 0.0f32;
159
160    for i in 0..n_steps {
161        let t = (i as f32 + 0.5) * step_len;
162        let pos = start + dir * t;
163        let h = (pos.length() - atm.radius_planet).max(0.0);
164        rayleigh += atm.rayleigh.density(h) * step_len;
165        mie += atm.mie.density(h) * step_len;
166    }
167
168    Vec2::new(rayleigh, mie)
169}
170
171/// Intersect a ray with a sphere centred at the origin.
172/// Returns the far intersection distance `t`, or `None` if no intersection.
173fn ray_sphere_intersect(origin: Vec3, dir: Vec3, radius: f32) -> Option<f32> {
174    let b = origin.dot(dir);
175    let c = origin.dot(origin) - radius * radius;
176    let disc = b * b - c;
177    if disc < 0.0 {
178        return None;
179    }
180    let sq = disc.sqrt();
181    let t1 = -b - sq;
182    let t2 = -b + sq;
183    if t2 < 0.0 {
184        None
185    } else {
186        Some(t2.max(0.0))
187    }
188}
189
190// ─────────────────────────────────────────────────────────────────────────────
191// AtmosphereParams + SkyModel
192// ─────────────────────────────────────────────────────────────────────────────
193
194/// Full atmosphere parameter set for physical sky rendering.
195#[derive(Debug, Clone)]
196pub struct AtmosphereParams {
197    /// Planet (ground) radius in metres.  Default: 6_371_000 m (Earth).
198    pub radius_planet: f32,
199    /// Atmosphere outer radius in metres.  Default: 6_471_000 m.
200    pub radius_atm: f32,
201    pub rayleigh: RayleighScattering,
202    pub mie: MieScattering,
203    /// Irradiance of the sun disc (W/m²/sr).  Default: 20.
204    pub sun_intensity: f32,
205    /// Unit vector pointing toward the sun.
206    pub sun_dir: Vec3,
207}
208
209impl AtmosphereParams {
210    pub fn earth_default(sun_dir: Vec3) -> Self {
211        Self {
212            radius_planet: 6_371_000.0,
213            radius_atm: 6_471_000.0,
214            rayleigh: RayleighScattering::earth(),
215            mie: MieScattering::earth(),
216            sun_intensity: 20.0,
217            sun_dir: sun_dir.normalize(),
218        }
219    }
220}
221
222impl Default for AtmosphereParams {
223    fn default() -> Self {
224        Self::earth_default(Vec3::new(0.0, 1.0, 0.0))
225    }
226}
227
228/// Physical sky colour model using single-scattering path integrals.
229pub struct SkyModel {
230    pub params: AtmosphereParams,
231}
232
233impl SkyModel {
234    pub fn new(params: AtmosphereParams) -> Self {
235        Self { params }
236    }
237
238    /// Compute sky colour for a view ray `ray_dir` (unit vector).
239    ///
240    /// Uses `n_steps` integration steps along the view ray, and `n_light_steps`
241    /// for each secondary ray toward the sun.
242    pub fn sky_color(&self, ray_dir: Vec3, n_steps: usize) -> Vec3 {
243        let p = &self.params;
244        let origin = Vec3::new(0.0, p.radius_planet + 1.0, 0.0);
245        let ray_dir = ray_dir.normalize();
246
247        let ray_len = match ray_sphere_intersect(origin, ray_dir, p.radius_atm) {
248            Some(t) => t,
249            None => return Vec3::ZERO,
250        };
251
252        // If ray hits the planet, shorten the path
253        let ray_len = if let Some(t_ground) = ray_sphere_intersect(origin, ray_dir, p.radius_planet) {
254            ray_len.min(t_ground)
255        } else {
256            ray_len
257        };
258
259        let step_len = ray_len / n_steps as f32;
260        let mut rayleigh_sum = Vec3::ZERO;
261        let mut mie_sum = Vec3::ZERO;
262
263        // Accumulated optical depth along view ray
264        let mut od_view_r = 0.0f32;
265        let mut od_view_m = 0.0f32;
266
267        let n_light = 8;
268
269        for i in 0..n_steps {
270            let t = (i as f32 + 0.5) * step_len;
271            let pos = origin + ray_dir * t;
272            let h = (pos.length() - p.radius_planet).max(0.0);
273
274            let rho_r = p.rayleigh.density(h);
275            let rho_m = p.mie.density(h);
276
277            od_view_r += rho_r * step_len;
278            od_view_m += rho_m * step_len;
279
280            // Light ray optical depth to sun
281            let od_light = optical_depth(pos, p.sun_dir, p, n_light);
282
283            // Combined optical depth
284            let tau_r = p.rayleigh.beta * (od_view_r + od_light.x);
285            let tau_m = Vec3::splat(p.mie.beta * 1.1) * (od_view_m + od_light.y);
286            let attenuation = (-(tau_r + tau_m)).exp();
287
288            rayleigh_sum += attenuation * rho_r * step_len;
289            mie_sum += attenuation * Vec3::splat(rho_m * step_len);
290        }
291
292        let cos_theta = ray_dir.dot(p.sun_dir);
293        let phase_r = rayleigh_phase(cos_theta);
294        let phase_m = mie_phase(cos_theta, p.mie.g);
295
296        p.sun_intensity
297            * (p.rayleigh.beta * phase_r * rayleigh_sum
298                + Vec3::splat(p.mie.beta * phase_m) * mie_sum)
299    }
300
301    /// Compute transmittance along a ray from `origin` toward the sun.
302    pub fn sun_transmittance(&self, ray_dir: Vec3) -> Vec3 {
303        let p = &self.params;
304        let origin = Vec3::new(0.0, p.radius_planet + 1.0, 0.0);
305        let od = optical_depth(origin, ray_dir.normalize(), p, 32);
306        let tau_r = p.rayleigh.beta * od.x;
307        let tau_m = Vec3::splat(p.mie.beta * 1.1 * od.y);
308        (-(tau_r + tau_m)).exp()
309    }
310
311    /// Approximate sky irradiance on a surface with the given `normal`.
312    ///
313    /// Integrates the sky radiance over the upper hemisphere and weights by
314    /// NdotL.  Uses a coarse stratified sample.
315    pub fn sky_irradiance(&self, normal: Vec3) -> Vec3 {
316        let n_theta = 8;
317        let n_phi = 16;
318        let mut irradiance = Vec3::ZERO;
319        let n = normal.normalize();
320
321        for it in 0..n_theta {
322            let theta = (it as f32 + 0.5) / n_theta as f32 * std::f32::consts::FRAC_PI_2;
323            let sin_t = theta.sin();
324            let cos_t = theta.cos();
325            for ip in 0..n_phi {
326                let phi = (ip as f32 + 0.5) / n_phi as f32 * 2.0 * PI;
327                let dir = Vec3::new(sin_t * phi.cos(), cos_t, sin_t * phi.sin());
328                let n_dot_l = n.dot(dir).max(0.0);
329                let d_omega = sin_t
330                    * (std::f32::consts::FRAC_PI_2 / n_theta as f32)
331                    * (2.0 * PI / n_phi as f32);
332                irradiance += self.sky_color(dir, 6) * n_dot_l * d_omega;
333            }
334        }
335        irradiance
336    }
337}
338
339// ─────────────────────────────────────────────────────────────────────────────
340// Preetham analytic sky model
341// ─────────────────────────────────────────────────────────────────────────────
342
343/// Simplified Preetham sky model.
344pub struct Preetham;
345
346impl Preetham {
347    /// Zenith luminance (kcd/m²) given turbidity `t` and sun elevation `theta_s` (radians).
348    pub fn zenith_luminance(turbidity: f32, theta_s: f32) -> f32 {
349        let chi = (4.0 / 9.0 - turbidity / 120.0) * (PI - 2.0 * theta_s);
350        (4.0453 * turbidity - 4.9710) * chi.tan() - 0.2155 * turbidity + 2.4192
351    }
352
353    /// Preetham distribution function F.
354    fn perez(theta: f32, gamma: f32, a: f32, b: f32, c: f32, d: f32, e: f32) -> f32 {
355        (1.0 + a * (-b / theta.cos().max(1e-3)).exp())
356            * (1.0 + c * (-d * gamma).exp() + e * gamma.cos() * gamma.cos())
357    }
358
359    /// Compute sky luminance (Y, x, y in Yxy colour space) for a view direction.
360    ///
361    /// `view_dir` — unit vector pointing from camera into sky
362    /// `sun_dir`  — unit vector toward sun
363    /// `turbidity` — atmosphere turbidity (1 = pure air, 10 = heavy haze)
364    pub fn sky_luminance(view_dir: Vec3, sun_dir: Vec3, turbidity: f32) -> Vec3 {
365        let view_dir = view_dir.normalize();
366        let sun_dir = sun_dir.normalize();
367
368        // Elevation angle of sun
369        let theta_s = sun_dir.y.clamp(-1.0, 1.0).acos();
370
371        // Perez coefficients for Y, x, y
372        let t = turbidity;
373        let ay = 0.1787 * t - 1.4630;
374        let by = -0.3554 * t + 0.4275;
375        let cy = -0.0227 * t + 5.3251;
376        let dy = 0.1206 * t - 2.5771;
377        let ey = -0.0670 * t + 0.3703;
378
379        let ax = -0.0193 * t - 0.2592;
380        let bx = -0.0665 * t + 0.0008;
381        let cx = -0.0004 * t + 0.2125;
382        let dx = -0.0641 * t - 0.8989;
383        let ex = -0.0033 * t + 0.0452;
384
385        let az = -0.0167 * t - 0.2608;
386        let bz = -0.0950 * t + 0.0092;
387        let cz = -0.0079 * t + 0.2102;
388        let dz = -0.0441 * t - 1.6537;
389        let ez = -0.0109 * t + 0.0529;
390
391        // Angle between view and zenith
392        let theta = view_dir.y.clamp(-1.0, 1.0).acos();
393        // Angle between view and sun
394        let cos_gamma = view_dir.dot(sun_dir).clamp(-1.0, 1.0);
395        let gamma = cos_gamma.acos();
396
397        let yz = Self::zenith_luminance(t, theta_s);
398        let xz = t * (0.0026 + 0.00008 * t - 0.000021 * theta_s * theta_s)
399            + (-0.0669 + 0.00209 * t + 0.000028 * theta_s * theta_s);
400        let zz = t * (-0.0065 + 0.0 + 0.000001 * theta_s * theta_s)
401            + (0.0659 - 0.0015 * t + 0.000047 * theta_s * theta_s);
402
403        let y_val = yz
404            * Self::perez(theta, gamma, ay, by, cy, dy, ey)
405            / Self::perez(0.0, theta_s, ay, by, cy, dy, ey);
406        let x_val = xz
407            * Self::perez(theta, gamma, ax, bx, cx, dx, ex)
408            / Self::perez(0.0, theta_s, ax, bx, cx, dx, ex);
409        let z_val = zz
410            * Self::perez(theta, gamma, az, bz, cz, dz, ez)
411            / Self::perez(0.0, theta_s, az, bz, cz, dz, ez);
412
413        Vec3::new(y_val, x_val, z_val)
414    }
415
416    /// Convert CIE Yxy to linear sRGB.
417    pub fn yxy_to_rgb(yxy: Vec3) -> Vec3 {
418        let y = yxy.x;
419        let x_chrom = yxy.y;
420        let y_chrom = yxy.z;
421
422        if y < 1e-5 {
423            return Vec3::ZERO;
424        }
425
426        // Yxy to XYZ
427        let xyz_x = x_chrom / y_chrom * y;
428        let xyz_y = y;
429        let xyz_z = (1.0 - x_chrom - y_chrom) / y_chrom * y;
430
431        // XYZ to linear sRGB (D65 illuminant)
432        let r = 3.2406 * xyz_x - 1.5372 * xyz_y - 0.4986 * xyz_z;
433        let g = -0.9689 * xyz_x + 1.8758 * xyz_y + 0.0415 * xyz_z;
434        let b = 0.0557 * xyz_x - 0.2040 * xyz_y + 1.0570 * xyz_z;
435
436        Vec3::new(r, g, b).max(Vec3::ZERO)
437    }
438}
439
440// ─────────────────────────────────────────────────────────────────────────────
441// Transmittance LUT (Bruneton-style precomputed atmosphere)
442// ─────────────────────────────────────────────────────────────────────────────
443
444/// Precomputed transmittance lookup table.
445///
446/// Parameterised by `(cos_zenith, altitude)` where:
447/// - `cos_zenith` in [-1, 1] — cosine of zenith angle
448/// - `altitude` in [0, radius_atm - radius_planet] — height above surface (m)
449pub struct TransmittanceLut {
450    pub width: usize,
451    pub height: usize,
452    pub data: Vec<Vec3>,
453    pub params: AtmosphereParams,
454}
455
456impl TransmittanceLut {
457    /// Compute the transmittance LUT.
458    pub fn compute(params: &AtmosphereParams) -> Self {
459        let width = 256;
460        let height = 64;
461        let mut data = vec![Vec3::ONE; width * height];
462
463        for row in 0..height {
464            let altitude = row as f32 / (height - 1) as f32
465                * (params.radius_atm - params.radius_planet);
466            let h = params.radius_planet + altitude;
467
468            for col in 0..width {
469                let cos_z = col as f32 / (width - 1) as f32 * 2.0 - 1.0;
470                let sin_z = (1.0 - cos_z * cos_z).max(0.0).sqrt();
471                let dir = Vec3::new(sin_z, cos_z, 0.0);
472                let origin = Vec3::new(0.0, h, 0.0);
473
474                let od = optical_depth(origin, dir, params, 32);
475                let tau_r = params.rayleigh.beta * od.x;
476                let tau_m = Vec3::splat(params.mie.beta * 1.1 * od.y);
477                let transmittance = (-(tau_r + tau_m)).exp();
478
479                data[row * width + col] = transmittance;
480            }
481        }
482
483        Self {
484            width,
485            height,
486            data,
487            params: params.clone(),
488        }
489    }
490
491    /// Bilinearly sample the LUT.
492    ///
493    /// `cos_zenith` in [-1, 1], `altitude` in metres [0, atm_thickness].
494    pub fn sample(&self, cos_zenith: f32, altitude: f32) -> Vec3 {
495        let atm_thickness = self.params.radius_atm - self.params.radius_planet;
496        let alt_norm = (altitude / atm_thickness).clamp(0.0, 1.0);
497        let cz_norm = (cos_zenith * 0.5 + 0.5).clamp(0.0, 1.0);
498
499        let row_f = alt_norm * (self.height - 1) as f32;
500        let col_f = cz_norm * (self.width - 1) as f32;
501
502        let row0 = (row_f as usize).min(self.height - 1);
503        let col0 = (col_f as usize).min(self.width - 1);
504        let row1 = (row0 + 1).min(self.height - 1);
505        let col1 = (col0 + 1).min(self.width - 1);
506
507        let tr = row_f - row0 as f32;
508        let tc = col_f - col0 as f32;
509
510        let s00 = self.data[row0 * self.width + col0];
511        let s10 = self.data[row0 * self.width + col1];
512        let s01 = self.data[row1 * self.width + col0];
513        let s11 = self.data[row1 * self.width + col1];
514
515        s00.lerp(s10, tc).lerp(s01.lerp(s11, tc), tr)
516    }
517}
518
519// ─────────────────────────────────────────────────────────────────────────────
520// Volumetric fog
521// ─────────────────────────────────────────────────────────────────────────────
522
523/// Homogeneous volumetric fog parameters.
524#[derive(Debug, Clone)]
525pub struct VolumetricFog {
526    /// Extinction (scattering + absorption) density coefficient.
527    pub density: f32,
528    /// Per-channel absorption coefficient (κ_a).
529    pub absorption: Vec3,
530    /// Per-channel scattering coefficient (κ_s).
531    pub scattering: Vec3,
532    /// Henyey-Greenstein g parameter for phase function.
533    pub anisotropy: f32,
534}
535
536impl VolumetricFog {
537    pub fn new(density: f32, absorption: Vec3, scattering: Vec3, anisotropy: f32) -> Self {
538        Self {
539            density,
540            absorption,
541            scattering,
542            anisotropy,
543        }
544    }
545
546    /// Uniform white fog (scattering-only).
547    pub fn uniform_white(density: f32) -> Self {
548        Self::new(density, Vec3::ZERO, Vec3::ONE, 0.0)
549    }
550
551    /// Extinction coefficient.
552    pub fn extinction(&self) -> Vec3 {
553        (self.absorption + self.scattering) * self.density
554    }
555}
556
557impl Default for VolumetricFog {
558    fn default() -> Self {
559        Self::uniform_white(0.02)
560    }
561}
562
563/// Beer-Lambert transmittance through homogeneous fog of `distance`.
564pub fn fog_transmittance(distance: f32, fog: &VolumetricFog) -> f32 {
565    let ext = fog.extinction().length(); // scalar approximation
566    (-ext * distance).exp()
567}
568
569/// In-scattering colour accumulated along a ray through volumetric fog toward a
570/// single directional light.
571///
572/// Returns the inscattered radiance contribution.
573pub fn fog_inscattering(
574    start: Vec3,
575    end: Vec3,
576    light_dir: Vec3,
577    light_color: Vec3,
578    fog: &VolumetricFog,
579    n_steps: usize,
580) -> Vec3 {
581    let seg = end - start;
582    let dist = seg.length();
583    if dist < 1e-5 {
584        return Vec3::ZERO;
585    }
586    let dir = seg / dist;
587    let step_len = dist / n_steps as f32;
588
589    let cos_theta = dir.dot(light_dir.normalize());
590    let phase = mie_phase(cos_theta, fog.anisotropy);
591    let ext = fog.extinction();
592
593    let mut inscattered = Vec3::ZERO;
594    for i in 0..n_steps {
595        let t = (i as f32 + 0.5) * step_len;
596        // Transmittance from start to sample point
597        let t_view = (-ext * t).exp();
598        // Transmittance from sample point toward light (assume the same fog, no scene geometry)
599        let t_light = (-ext * step_len).exp();
600        let sample_inscatter = fog.scattering * fog.density * phase * light_color * t_view * t_light * step_len;
601        inscattered += sample_inscatter;
602    }
603    inscattered
604}
605
606/// Exponential height-fog density at world position `pos`.
607///
608/// `density`    — base fog density at `fog_height`
609/// `falloff`    — controls how quickly density decreases above `fog_height`
610/// `fog_height` — altitude (world Y) at which density == `density`
611pub fn exponential_height_fog(pos: Vec3, density: f32, falloff: f32, fog_height: f32) -> f32 {
612    let h = pos.y - fog_height;
613    density * (-falloff * h).exp()
614}
615
616// ─────────────────────────────────────────────────────────────────────────────
617// Clouds
618// ─────────────────────────────────────────────────────────────────────────────
619
620/// A single cloud layer definition.
621#[derive(Debug, Clone)]
622pub struct CloudLayer {
623    /// Base altitude (metres above sea level).
624    pub altitude: f32,
625    /// Vertical extent of the layer (metres).
626    pub thickness: f32,
627    /// Cloud coverage factor [0, 1].
628    pub coverage: f32,
629    /// Maximum density within the layer.
630    pub density: f32,
631    /// Wind vector (moves the cloud pattern over time).
632    pub wind: Vec3,
633}
634
635impl CloudLayer {
636    pub fn new(altitude: f32, thickness: f32, coverage: f32, density: f32, wind: Vec3) -> Self {
637        Self {
638            altitude,
639            thickness,
640            coverage,
641            density,
642            wind,
643        }
644    }
645
646    /// Cumulus layer preset.
647    pub fn cumulus() -> Self {
648        Self::new(2000.0, 1500.0, 0.5, 0.8, Vec3::new(10.0, 0.0, 5.0))
649    }
650
651    /// Cirrus layer preset.
652    pub fn cirrus() -> Self {
653        Self::new(8000.0, 500.0, 0.3, 0.4, Vec3::new(30.0, 0.0, 15.0))
654    }
655}
656
657/// Evaluate procedural cloud density at `pos` using layered FBM noise.
658///
659/// `time` advances the wind offset.
660pub fn sample_cloud_density(pos: Vec3, time: f32, layer: &CloudLayer) -> f32 {
661    // Height gradient: density falls off at layer boundaries
662    let rel_h = (pos.y - layer.altitude) / layer.thickness.max(1.0);
663    if rel_h < 0.0 || rel_h > 1.0 {
664        return 0.0;
665    }
666    // Gaussian height profile
667    let h_weight = {
668        let x = rel_h * 2.0 - 1.0;
669        (-x * x * 4.0).exp()
670    };
671
672    // Wind offset
673    let wind_offset = layer.wind * time;
674    let sample_pos = pos + wind_offset;
675
676    // Multi-octave hash-based noise (no external crate)
677    let density_noise = fbm_noise(sample_pos, 5);
678
679    // Remap and apply coverage
680    let raw = (density_noise - (1.0 - layer.coverage)).max(0.0) / layer.coverage.max(1e-4);
681    raw * h_weight * layer.density
682}
683
684/// Simple 3D hash-based FBM noise (no hardware textures).
685fn fbm_noise(p: Vec3, octaves: usize) -> f32 {
686    let mut value = 0.0f32;
687    let mut amplitude = 0.5f32;
688    let mut frequency = 1.0f32;
689
690    for _ in 0..octaves {
691        value += amplitude * hash31(p * frequency);
692        amplitude *= 0.5;
693        frequency *= 2.0;
694    }
695    value
696}
697
698/// Hash function Vec3 → [0,1].
699fn hash31(p: Vec3) -> f32 {
700    let mut h = p.x * 127.1 + p.y * 311.7 + p.z * 74.7;
701    h = (h.sin() * 43758.5453123).fract();
702    h.abs()
703}
704
705/// Raymarche through cloud layers and return `Vec4(rgb_color, alpha)`.
706///
707/// `ray_origin` — world-space ray start
708/// `ray_dir`    — normalised ray direction
709/// `layers`     — cloud layer definitions
710/// `sun_dir`    — direction toward the sun
711/// `n_steps`    — number of marching steps
712pub fn raymarch_cloud(
713    ray_origin: Vec3,
714    ray_dir: Vec3,
715    layers: &[CloudLayer],
716    sun_dir: Vec3,
717    n_steps: usize,
718) -> Vec4 {
719    if layers.is_empty() {
720        return Vec4::ZERO;
721    }
722    let ray_dir = ray_dir.normalize();
723
724    // Find the altitude range of all layers
725    let min_alt = layers.iter().map(|l| l.altitude).fold(f32::INFINITY, f32::min);
726    let max_alt = layers.iter().map(|l| l.altitude + l.thickness).fold(0.0f32, f32::max);
727
728    // Compute entry and exit t values for the altitude slab
729    if ray_dir.y.abs() < 1e-5 {
730        return Vec4::ZERO; // Ray is horizontal — skip
731    }
732    let t_min = (min_alt - ray_origin.y) / ray_dir.y;
733    let t_max = (max_alt - ray_origin.y) / ray_dir.y;
734    let (t_enter, t_exit) = if t_min < t_max {
735        (t_min.max(0.0), t_max.max(0.0))
736    } else {
737        (t_max.max(0.0), t_min.max(0.0))
738    };
739
740    if t_enter >= t_exit {
741        return Vec4::ZERO;
742    }
743
744    let step_len = (t_exit - t_enter) / n_steps as f32;
745    let mut accumulated_density = 0.0f32;
746    let mut light_energy = Vec3::ZERO;
747
748    for i in 0..n_steps {
749        let t = t_enter + (i as f32 + 0.5) * step_len;
750        let pos = ray_origin + ray_dir * t;
751
752        let mut total_density = 0.0f32;
753        for layer in layers {
754            total_density += sample_cloud_density(pos, 0.0, layer);
755        }
756        if total_density < 1e-4 {
757            continue;
758        }
759
760        // Beer's law transmittance along view ray so far
761        let transmittance_view = (-accumulated_density * 0.1).exp();
762
763        // Cheap light ray — step toward sun
764        let mut sun_density = 0.0f32;
765        for ls in 0..4 {
766            let sun_pos = pos + sun_dir * (ls as f32 * 200.0);
767            for layer in layers {
768                sun_density += sample_cloud_density(sun_pos, 0.0, layer);
769            }
770        }
771        let transmittance_sun = (-sun_density * 0.1 * 200.0).exp();
772
773        // Henyey-Greenstein phase toward sun
774        let cos_theta = ray_dir.dot(sun_dir.normalize());
775        let phase = mie_phase(cos_theta, 0.3);
776
777        // White sun colour
778        let sun_col = Vec3::new(1.0, 0.95, 0.8) * 3.0;
779
780        light_energy += sun_col * transmittance_sun * transmittance_view * phase * total_density * step_len;
781        accumulated_density += total_density * step_len;
782    }
783
784    let alpha = 1.0 - (-accumulated_density * 0.1).exp();
785    let color = light_energy + Vec3::new(0.8, 0.85, 0.95) * (1.0 - alpha); // sky tint
786
787    Vec4::new(color.x, color.y, color.z, alpha.clamp(0.0, 1.0))
788}
789
790// ─────────────────────────────────────────────────────────────────────────────
791// Ocean / water waves
792// ─────────────────────────────────────────────────────────────────────────────
793
794/// A single Gerstner wave component.
795#[derive(Debug, Clone)]
796pub struct OceanWave {
797    /// Wave amplitude (metres).
798    pub amplitude: f32,
799    /// Wavelength (metres).
800    pub wavelength: f32,
801    /// Horizontal direction of propagation (unit Vec2).
802    pub direction: Vec2,
803    /// Phase speed (m/s).  Deep-water: `sqrt(g * wavelength / 2π)`.
804    pub phase_speed: f32,
805}
806
807impl OceanWave {
808    pub fn new(amplitude: f32, wavelength: f32, direction: Vec2, phase_speed: f32) -> Self {
809        Self {
810            amplitude,
811            wavelength,
812            direction: direction.normalize(),
813            phase_speed,
814        }
815    }
816
817    /// Create a physically-based deep-water wave.
818    pub fn deep_water(amplitude: f32, wavelength: f32, direction: Vec2) -> Self {
819        let g = 9.81_f32;
820        let phase_speed = (g * wavelength / (2.0 * PI)).sqrt();
821        Self::new(amplitude, wavelength, direction, phase_speed)
822    }
823
824    /// Wave number k = 2π / λ.
825    #[inline]
826    pub fn wave_number(&self) -> f32 {
827        2.0 * PI / self.wavelength.max(1e-5)
828    }
829
830    /// Angular frequency ω = k * c.
831    #[inline]
832    pub fn omega(&self) -> f32 {
833        self.wave_number() * self.phase_speed
834    }
835}
836
837/// Compute Gerstner wave displacement at horizontal position `pos` and time `time`.
838///
839/// Returns a Vec3 where (x, y, z) correspond to (horizontal offset x, height y,
840/// horizontal offset z).
841pub fn gerstner_wave(pos: Vec2, time: f32, waves: &[OceanWave]) -> Vec3 {
842    let mut displacement = Vec3::ZERO;
843
844    for wave in waves {
845        let k = wave.wave_number();
846        let omega = wave.omega();
847        let phase = wave.direction.dot(pos) * k - omega * time;
848        let sin_phase = phase.sin();
849        let cos_phase = phase.cos();
850
851        // Steepness Q — limits wave crest sharpness (0 = sine wave, 1 = trochoidal)
852        let q = (wave.amplitude * k).min(0.9);
853
854        displacement.x += q * wave.amplitude * wave.direction.x * cos_phase;
855        displacement.y += wave.amplitude * sin_phase;
856        displacement.z += q * wave.amplitude * wave.direction.y * cos_phase;
857    }
858
859    displacement
860}
861
862/// Compute the analytic surface normal of a Gerstner wave field.
863///
864/// Returns a unit normal pointing upward from the ocean surface.
865pub fn ocean_normal(pos: Vec2, time: f32, waves: &[OceanWave]) -> Vec3 {
866    let mut b = Vec3::ZERO; // ∂P/∂x (tangent x)
867    let mut t = Vec3::ZERO; // ∂P/∂z (tangent z)
868
869    for wave in waves {
870        let k = wave.wave_number();
871        let omega = wave.omega();
872        let phase = wave.direction.dot(pos) * k - omega * time;
873        let sin_phase = phase.sin();
874        let cos_phase = phase.cos();
875
876        let q = (wave.amplitude * k).min(0.9);
877        let wa = wave.amplitude * k;
878
879        // Partial derivatives of Gerstner displacement
880        b.x += -q * wa * wave.direction.x * wave.direction.x * sin_phase;
881        b.y += wa * wave.direction.x * cos_phase;
882        b.z += -q * wa * wave.direction.x * wave.direction.y * sin_phase;
883
884        t.x += -q * wa * wave.direction.x * wave.direction.y * sin_phase;
885        t.y += wa * wave.direction.y * cos_phase;
886        t.z += -q * wa * wave.direction.y * wave.direction.y * sin_phase;
887    }
888
889    // Tangent x: (1, 0, 0) + b; Tangent z: (0, 0, 1) + t
890    let tx = Vec3::new(1.0 + b.x, b.y, b.z);
891    let tz = Vec3::new(t.x, t.y, 1.0 + t.z);
892    tx.cross(tz).normalize()
893}
894
895/// Fresnel reflectance for water (IOR = 1.333) using Schlick approximation.
896///
897/// `view_angle` — angle between view ray and surface normal (radians).
898pub fn water_fresnel(view_angle: f32) -> f32 {
899    let cos_theta = view_angle.cos().clamp(0.0, 1.0);
900    let ior = 1.333_f32;
901    let f0_val = {
902        let t = (ior - 1.0) / (ior + 1.0);
903        t * t
904    };
905    f0_val + (1.0 - f0_val) * (1.0 - cos_theta).powi(5)
906}
907
908/// Simple deep-water colour model.
909///
910/// Composites subsurface scattering, sun reflectance, and depth absorption.
911pub fn water_color(depth: f32, sun_pos: Vec3, view_dir: Vec3) -> Vec3 {
912    let view_dir = view_dir.normalize();
913    let sun_dir = sun_pos.normalize();
914
915    // Absorption coefficient (red attenuates fastest)
916    let absorption = Vec3::new(0.45, 0.12, 0.05);
917    let transmitted = (-absorption * depth.max(0.0)).exp();
918
919    // Deep-water base colour (blue-green)
920    let deep_color = Vec3::new(0.01, 0.12, 0.25);
921    let subsurface = deep_color * transmitted;
922
923    // Specular sun reflection
924    let n = Vec3::Y; // assume flat water
925    let h = (sun_dir - view_dir).normalize();
926    let n_dot_h = n.dot(h).max(0.0);
927    let sun_spec = Vec3::splat(200.0 * n_dot_h.powf(256.0));
928
929    // Fresnel blend: above water surface
930    let cos_theta = (-view_dir).dot(n).clamp(0.0, 1.0);
931    let fresnel = water_fresnel(cos_theta.acos());
932
933    subsurface * (1.0 - fresnel) + sun_spec * fresnel
934}
935
936// ─────────────────────────────────────────────────────────────────────────────
937// Shadow mapping helpers
938// ─────────────────────────────────────────────────────────────────────────────
939
940/// Compute cascade split distances for Cascaded Shadow Maps (CSM).
941///
942/// Uses a blend between linear and logarithmic splits controlled by `lambda`.
943///
944/// `near`, `far`   — camera frustum near/far planes
945/// `n_cascades`    — number of cascade levels
946/// `lambda`        — blend factor: 0 = uniform, 1 = logarithmic
947///
948/// Returns a `Vec<f32>` of length `n_cascades + 1` (near…far boundaries).
949pub fn cascade_shadow_splits(near: f32, far: f32, n_cascades: usize, lambda: f32) -> Vec<f32> {
950    let n = n_cascades as f32;
951    let mut splits = Vec::with_capacity(n_cascades + 1);
952    splits.push(near);
953
954    for i in 1..n_cascades {
955        let p = i as f32 / n;
956        let log_split = near * (far / near).powf(p);
957        let uni_split = near + (far - near) * p;
958        splits.push(lambda * log_split + (1.0 - lambda) * uni_split);
959    }
960    splits.push(far);
961    splits
962}
963
964/// Compute a light-space view matrix and ortho projection for a directional
965/// light, tightly fitted to a scene AABB.
966///
967/// Returns `(view, proj)` matrices.
968pub fn light_space_matrix(
969    light_dir: Vec3,
970    scene_aabb: (Vec3, Vec3),
971) -> (Mat4, Mat4) {
972    let (aabb_min, aabb_max) = scene_aabb;
973    let center = (aabb_min + aabb_max) * 0.5;
974    let extent = aabb_max - aabb_min;
975    let radius = extent.length() * 0.5;
976
977    let light_dir = light_dir.normalize();
978    // Pick an up vector not parallel to light_dir
979    let up = if light_dir.y.abs() < 0.99 {
980        Vec3::Y
981    } else {
982        Vec3::Z
983    };
984
985    let eye = center - light_dir * radius;
986    let view = Mat4::look_at_rh(eye, center, up);
987
988    // Tight ortho based on AABB extent
989    let half = radius * 1.05; // small margin
990    let proj = Mat4::orthographic_rh(-half, half, -half, half, -half * 2.0, half * 2.0);
991
992    (view, proj)
993}
994
995/// Bias matrix to map NDC [-1,1] to UV [0,1] and depth [0,1].
996pub fn bias_matrix() -> Mat4 {
997    Mat4::from_cols_array(&[
998        0.5, 0.0, 0.0, 0.0, // col 0
999        0.0, 0.5, 0.0, 0.0, // col 1
1000        0.0, 0.0, 0.5, 0.0, // col 2
1001        0.5, 0.5, 0.5, 1.0, // col 3 (translation)
1002    ])
1003}
1004
1005/// Generate a Poisson-disc PCF kernel of `size` taps.
1006///
1007/// Returns UV offsets in `[-1, 1]²`.
1008pub fn pcf_kernel(size: usize) -> Vec<Vec2> {
1009    // Use a deterministic Poisson disc via jittered grid
1010    let mut samples = Vec::with_capacity(size);
1011    let sqrt_size = (size as f32).sqrt().ceil() as usize;
1012
1013    for i in 0..sqrt_size {
1014        for j in 0..sqrt_size {
1015            if samples.len() >= size {
1016                break;
1017            }
1018            let u = (i as f32 + 0.5) / sqrt_size as f32;
1019            let v = (j as f32 + 0.5) / sqrt_size as f32;
1020            // Jitter with hash
1021            let jx = hash31(Vec3::new(i as f32, j as f32, 0.3)) * 0.5 / sqrt_size as f32;
1022            let jy = hash31(Vec3::new(j as f32, i as f32, 0.7)) * 0.5 / sqrt_size as f32;
1023            samples.push(Vec2::new(u + jx, v + jy) * 2.0 - Vec2::ONE);
1024        }
1025        if samples.len() >= size {
1026            break;
1027        }
1028    }
1029
1030    samples.truncate(size);
1031    samples
1032}
1033
1034/// Compute the PCSS blocker-search UV radius.
1035///
1036/// `light_size_uv` — angular size of the light in shadow-map UV space
1037/// `depth`         — receiver depth in [0, 1]
1038pub fn pcss_blocker_search_uv(light_size_uv: f32, depth: f32) -> f32 {
1039    // Penumbra estimate: larger when receiver is far from blocker
1040    light_size_uv * depth / (1.0 - depth).max(0.001)
1041}
1042
1043// ─────────────────────────────────────────────────────────────────────────────
1044// Tests
1045// ─────────────────────────────────────────────────────────────────────────────
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050    use glam::Vec3;
1051
1052    #[test]
1053    fn rayleigh_phase_integrates_to_one() {
1054        // ∫ rayleigh_phase(cos θ) dΩ = 1
1055        // dΩ = sin θ dθ dφ; integrate over sphere
1056        let n = 1000;
1057        let mut sum = 0.0f32;
1058        for i in 0..n {
1059            let theta = (i as f32 + 0.5) / n as f32 * PI;
1060            let cos_t = theta.cos();
1061            let sin_t = theta.sin();
1062            sum += rayleigh_phase(cos_t) * sin_t * (PI / n as f32) * 2.0 * PI;
1063        }
1064        assert!((sum - 1.0).abs() < 0.01, "Rayleigh phase integral = {sum}");
1065    }
1066
1067    #[test]
1068    fn mie_phase_integrates_to_one() {
1069        let n = 1000;
1070        let g = 0.7;
1071        let mut sum = 0.0f32;
1072        for i in 0..n {
1073            let theta = (i as f32 + 0.5) / n as f32 * PI;
1074            let cos_t = theta.cos();
1075            let sin_t = theta.sin();
1076            sum += mie_phase(cos_t, g) * sin_t * (PI / n as f32) * 2.0 * PI;
1077        }
1078        assert!((sum - 1.0).abs() < 0.02, "Mie phase integral = {sum}");
1079    }
1080
1081    #[test]
1082    fn sky_color_is_non_negative() {
1083        let sky = SkyModel::new(AtmosphereParams::default());
1084        let color = sky.sky_color(Vec3::Y, 16);
1085        assert!(color.x >= 0.0 && color.y >= 0.0 && color.z >= 0.0);
1086    }
1087
1088    #[test]
1089    fn sky_color_is_blue_biased() {
1090        let sky = SkyModel::new(AtmosphereParams::default());
1091        let color = sky.sky_color(Vec3::new(0.2, 0.8, 0.0).normalize(), 16);
1092        // Sky should be more blue than red
1093        assert!(color.z >= color.x, "Sky should be blue-biased: {color:?}");
1094    }
1095
1096    #[test]
1097    fn transmittance_lut_values_in_range() {
1098        let params = AtmosphereParams::default();
1099        let lut = TransmittanceLut::compute(&params);
1100        for &t in &lut.data {
1101            assert!(t.x >= 0.0 && t.x <= 1.0, "Transmittance out of [0,1]: {t:?}");
1102        }
1103    }
1104
1105    #[test]
1106    fn fog_transmittance_zero_distance_is_one() {
1107        let fog = VolumetricFog::default();
1108        let t = fog_transmittance(0.0, &fog);
1109        assert!((t - 1.0).abs() < 1e-5);
1110    }
1111
1112    #[test]
1113    fn fog_transmittance_decreases_with_distance() {
1114        let fog = VolumetricFog::default();
1115        let t1 = fog_transmittance(10.0, &fog);
1116        let t2 = fog_transmittance(100.0, &fog);
1117        assert!(t1 > t2, "Transmittance should decrease with distance");
1118    }
1119
1120    #[test]
1121    fn gerstner_wave_zero_time_is_finite() {
1122        let waves = vec![OceanWave::deep_water(0.5, 10.0, Vec2::X)];
1123        let d = gerstner_wave(Vec2::new(5.0, 5.0), 0.0, &waves);
1124        assert!(d.is_finite(), "Gerstner displacement should be finite: {d:?}");
1125    }
1126
1127    #[test]
1128    fn ocean_normal_is_unit_length() {
1129        let waves = vec![
1130            OceanWave::deep_water(0.3, 8.0, Vec2::X),
1131            OceanWave::deep_water(0.2, 5.0, Vec2::Y),
1132        ];
1133        let n = ocean_normal(Vec2::new(3.0, 7.0), 2.5, &waves);
1134        assert!((n.length() - 1.0).abs() < 1e-4, "Normal should be unit: {n:?}");
1135    }
1136
1137    #[test]
1138    fn water_fresnel_at_grazing_is_one() {
1139        let f = water_fresnel(std::f32::consts::FRAC_PI_2);
1140        assert!((f - 1.0).abs() < 0.01, "Grazing fresnel ~1, got {f}");
1141    }
1142
1143    #[test]
1144    fn cascade_splits_monotone() {
1145        let splits = cascade_shadow_splits(0.1, 1000.0, 4, 0.5);
1146        assert_eq!(splits.len(), 5);
1147        for w in splits.windows(2) {
1148            assert!(w[1] > w[0], "Splits should be monotone increasing");
1149        }
1150    }
1151
1152    #[test]
1153    fn bias_matrix_maps_neg1_to_0() {
1154        let bm = bias_matrix();
1155        let p = bm.transform_point3(Vec3::new(-1.0, -1.0, -1.0));
1156        assert!((p - Vec3::ZERO).length() < 1e-4, "Bias matrix: {p:?}");
1157    }
1158
1159    #[test]
1160    fn pcf_kernel_correct_count() {
1161        let k = pcf_kernel(16);
1162        assert_eq!(k.len(), 16);
1163    }
1164
1165    #[test]
1166    fn exponential_height_fog_decreases_with_height() {
1167        let f0 = exponential_height_fog(Vec3::new(0.0, 0.0, 0.0), 0.1, 0.5, 0.0);
1168        let f1 = exponential_height_fog(Vec3::new(0.0, 100.0, 0.0), 0.1, 0.5, 0.0);
1169        assert!(f0 > f1, "Fog should be denser near the ground");
1170    }
1171
1172    #[test]
1173    fn cloud_density_outside_layer_is_zero() {
1174        let layer = CloudLayer::cumulus();
1175        let pos_below = Vec3::new(100.0, layer.altitude - 100.0, 100.0);
1176        let d = sample_cloud_density(pos_below, 0.0, &layer);
1177        assert_eq!(d, 0.0, "Cloud density below layer should be 0");
1178    }
1179
1180    #[test]
1181    fn pcss_blocker_search_grows_with_depth() {
1182        let uv0 = pcss_blocker_search_uv(0.01, 0.3);
1183        let uv1 = pcss_blocker_search_uv(0.01, 0.7);
1184        assert!(uv1 > uv0, "PCSS blocker radius should grow with depth");
1185    }
1186
1187    #[test]
1188    fn preetham_sky_luminance_is_finite() {
1189        let view_dir = Vec3::new(0.3, 0.8, 0.1).normalize();
1190        let sun_dir = Vec3::new(0.5, 0.6, 0.3).normalize();
1191        let yxy = Preetham::sky_luminance(view_dir, sun_dir, 2.5);
1192        assert!(
1193            yxy.x.is_finite() && yxy.y.is_finite() && yxy.z.is_finite(),
1194            "Preetham luminance should be finite: {yxy:?}"
1195        );
1196    }
1197}