Skip to main content

proof_engine/nishita_sky/
mod.rs

1//! Nishita atmospheric sky model.
2//!
3//! Clean-room implementation based on:
4//! - Nishita et al., "Display of the Earth Taking into Account Atmospheric Scattering" (SIGGRAPH 1993)
5//! - Bruneton & Neyret, "Precomputed Atmospheric Scattering" (EGSR 2008)
6//!
7//! Computes sky color and aerial perspective from physical Rayleigh and Mie scattering.
8
9use glam::{Vec3, Vec4};
10use std::f32::consts::PI;
11
12// Physical constants
13const EARTH_RADIUS: f32 = 6_371_000.0;      // meters
14const ATMO_RADIUS: f32 = 6_471_000.0;       // top of atmosphere
15const RAYLEIGH_SCALE_HEIGHT: f32 = 8_500.0;  // meters
16const MIE_SCALE_HEIGHT: f32 = 1_200.0;       // meters
17const RAYLEIGH_COEFF: Vec3 = Vec3::new(5.5e-6, 13.0e-6, 22.4e-6); // per meter (RGB)
18const MIE_COEFF: f32 = 21.0e-6;             // per meter
19const MIE_G: f32 = 0.76;                    // Mie scattering anisotropy
20
21/// Configuration for the sky renderer.
22#[derive(Debug, Clone)]
23pub struct SkyConfig {
24    pub sun_direction: Vec3,
25    pub sun_intensity: f32,
26    pub sun_color: Vec3,
27    pub num_samples: u32,
28    pub num_light_samples: u32,
29    /// Ground albedo for multi-scattering approximation.
30    pub ground_albedo: Vec3,
31    /// Observer height above ground in meters.
32    pub observer_height: f32,
33}
34
35impl Default for SkyConfig {
36    fn default() -> Self {
37        Self {
38            sun_direction: Vec3::new(0.0, 0.3, -1.0).normalize(),
39            sun_intensity: 22.0,
40            sun_color: Vec3::ONE,
41            num_samples: 16,
42            num_light_samples: 8,
43            ground_albedo: Vec3::splat(0.3),
44            observer_height: 1.0,
45        }
46    }
47}
48
49/// Rayleigh phase function.
50fn rayleigh_phase(cos_theta: f32) -> f32 {
51    3.0 / (16.0 * PI) * (1.0 + cos_theta * cos_theta)
52}
53
54/// Henyey-Greenstein phase function for Mie scattering.
55fn mie_phase(cos_theta: f32, g: f32) -> f32 {
56    let g2 = g * g;
57    let num = 3.0 * (1.0 - g2) * (1.0 + cos_theta * cos_theta);
58    let denom = (8.0 * PI) * (2.0 + g2) * (1.0 + g2 - 2.0 * g * cos_theta).powf(1.5);
59    num / denom
60}
61
62/// Ray-sphere intersection. Returns (near, far) or None.
63fn ray_sphere(origin: Vec3, dir: Vec3, center: Vec3, radius: f32) -> Option<(f32, f32)> {
64    let oc = origin - center;
65    let b = oc.dot(dir);
66    let c = oc.dot(oc) - radius * radius;
67    let disc = b * b - c;
68    if disc < 0.0 { return None; }
69    let sqrt_disc = disc.sqrt();
70    Some((-b - sqrt_disc, -b + sqrt_disc))
71}
72
73/// Compute sky color for a given view direction.
74pub fn compute_sky_color(view_dir: Vec3, config: &SkyConfig) -> Vec3 {
75    let origin = Vec3::new(0.0, EARTH_RADIUS + config.observer_height, 0.0);
76
77    // Intersect view ray with atmosphere
78    let (_, t_atmo) = match ray_sphere(origin, view_dir, Vec3::ZERO, ATMO_RADIUS) {
79        Some(t) => t,
80        None => return Vec3::ZERO,
81    };
82
83    // Check if ray hits earth
84    let t_max = if let Some((t_near, _)) = ray_sphere(origin, view_dir, Vec3::ZERO, EARTH_RADIUS) {
85        if t_near > 0.0 { t_near } else { t_atmo }
86    } else {
87        t_atmo
88    };
89
90    let segment_length = t_max / config.num_samples as f32;
91    let sun_dir = config.sun_direction.normalize();
92    let cos_theta = view_dir.dot(sun_dir);
93
94    let phase_r = rayleigh_phase(cos_theta);
95    let phase_m = mie_phase(cos_theta, MIE_G);
96
97    let mut total_rayleigh = Vec3::ZERO;
98    let mut total_mie = Vec3::ZERO;
99    let mut optical_depth_r = 0.0f32;
100    let mut optical_depth_m = 0.0f32;
101
102    for i in 0..config.num_samples {
103        let t = (i as f32 + 0.5) * segment_length;
104        let sample_pos = origin + view_dir * t;
105        let height = sample_pos.length() - EARTH_RADIUS;
106        if height < 0.0 { break; }
107
108        // Density at this height
109        let hr = (-height / RAYLEIGH_SCALE_HEIGHT).exp();
110        let hm = (-height / MIE_SCALE_HEIGHT).exp();
111
112        optical_depth_r += hr * segment_length;
113        optical_depth_m += hm * segment_length;
114
115        // Light ray toward sun from this sample
116        let (_, t_sun) = match ray_sphere(sample_pos, sun_dir, Vec3::ZERO, ATMO_RADIUS) {
117            Some(t) => t,
118            None => continue,
119        };
120
121        let light_segment = t_sun / config.num_light_samples as f32;
122        let mut od_light_r = 0.0f32;
123        let mut od_light_m = 0.0f32;
124        let mut shadow = false;
125
126        for j in 0..config.num_light_samples {
127            let tl = (j as f32 + 0.5) * light_segment;
128            let light_pos = sample_pos + sun_dir * tl;
129            let light_height = light_pos.length() - EARTH_RADIUS;
130            if light_height < 0.0 { shadow = true; break; }
131            od_light_r += (-light_height / RAYLEIGH_SCALE_HEIGHT).exp() * light_segment;
132            od_light_m += (-light_height / MIE_SCALE_HEIGHT).exp() * light_segment;
133        }
134
135        if shadow { continue; }
136
137        // Combined optical depth (view + light)
138        let tau_r = RAYLEIGH_COEFF * (optical_depth_r + od_light_r);
139        let tau_m = MIE_COEFF * (optical_depth_m + od_light_m);
140        let attenuation = Vec3::new(
141            (-tau_r.x - tau_m).exp(),
142            (-tau_r.y - tau_m).exp(),
143            (-tau_r.z - tau_m).exp(),
144        );
145
146        total_rayleigh += attenuation * hr * segment_length;
147        total_mie += attenuation * hm * segment_length;
148    }
149
150    let sun = config.sun_color * config.sun_intensity;
151    let sky = sun * (total_rayleigh * RAYLEIGH_COEFF * phase_r + total_mie * MIE_COEFF * phase_m);
152
153    sky
154}
155
156/// Generate a sky lookup table (256x128) for fast runtime sampling.
157pub fn generate_sky_lut(config: &SkyConfig) -> Vec<[f32; 3]> {
158    let w = 256;
159    let h = 128;
160    let mut lut = Vec::with_capacity(w * h);
161
162    for y in 0..h {
163        let phi = PI * y as f32 / (h - 1) as f32; // 0 = zenith, PI = nadir
164        for x in 0..w {
165            let theta = 2.0 * PI * x as f32 / (w - 1) as f32;
166            let dir = Vec3::new(
167                phi.sin() * theta.cos(),
168                phi.cos(),
169                phi.sin() * theta.sin(),
170            );
171            let color = compute_sky_color(dir, config);
172            lut.push([color.x, color.y, color.z]);
173        }
174    }
175
176    lut
177}
178
179/// Time-of-day presets.
180pub struct SkyPresets;
181
182impl SkyPresets {
183    pub fn noon() -> SkyConfig {
184        SkyConfig {
185            sun_direction: Vec3::new(0.0, 1.0, 0.0).normalize(),
186            sun_intensity: 22.0,
187            ..Default::default()
188        }
189    }
190
191    pub fn sunset() -> SkyConfig {
192        SkyConfig {
193            sun_direction: Vec3::new(0.5, 0.05, -0.5).normalize(),
194            sun_intensity: 20.0,
195            sun_color: Vec3::new(1.0, 0.6, 0.3),
196            ..Default::default()
197        }
198    }
199
200    pub fn night() -> SkyConfig {
201        SkyConfig {
202            sun_direction: Vec3::new(0.0, -0.5, -1.0).normalize(),
203            sun_intensity: 0.1,
204            sun_color: Vec3::new(0.3, 0.3, 0.5),
205            ..Default::default()
206        }
207    }
208
209    pub fn dawn() -> SkyConfig {
210        SkyConfig {
211            sun_direction: Vec3::new(-0.8, 0.1, -0.3).normalize(),
212            sun_intensity: 15.0,
213            sun_color: Vec3::new(1.0, 0.5, 0.3),
214            ..Default::default()
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_sky_color_noon() {
225        let config = SkyPresets::noon();
226        let zenith = compute_sky_color(Vec3::Y, &config);
227        assert!(zenith.x > 0.0 && zenith.y > 0.0 && zenith.z > 0.0);
228        // Sky should be bluish at zenith
229        assert!(zenith.z > zenith.x, "zenith should be blue-ish");
230    }
231
232    #[test]
233    fn test_sky_color_horizon() {
234        let config = SkyPresets::sunset();
235        let horizon = compute_sky_color(Vec3::new(0.0, 0.01, -1.0).normalize(), &config);
236        // Sunset horizon should be reddish
237        assert!(horizon.x > horizon.z * 0.5, "horizon at sunset should be warm");
238    }
239
240    #[test]
241    fn test_rayleigh_phase_symmetry() {
242        // Rayleigh is symmetric: f(cos) = f(-cos) for squared term
243        let a = rayleigh_phase(0.5);
244        let b = rayleigh_phase(-0.5);
245        assert!((a - b).abs() < 0.001);
246    }
247
248    #[test]
249    fn test_sky_lut_size() {
250        let config = SkyConfig { num_samples: 4, num_light_samples: 2, ..Default::default() };
251        let lut = generate_sky_lut(&config);
252        assert_eq!(lut.len(), 256 * 128);
253    }
254}