Skip to main content

proof_engine/volumetric_fog/
mod.rs

1//! Volumetric Fog — Froxel-based participating media rendering.
2//!
3//! Clean-room implementation based on published techniques:
4//! - Wronski, "Volumetric Fog and Lighting" (SIGGRAPH 2014)
5//! - Hillaire, "Physically Based & Unified Volumetric Rendering" (SIGGRAPH 2015)
6//! - CryEngine volumetric fog (algorithmic reference only, clean-room reimplemented)
7//!
8//! # Pipeline
9//!
10//! 1. **Density injection**: Fill a 3D froxel grid with scattering/extinction
11//!    coefficients. Sources: global fog, height fog, force field density,
12//!    game state (corruption, boss aura), particle emitters.
13//!
14//! 2. **Light scattering**: For each froxel, compute in-scattered light from
15//!    all scene lights using Henyey-Greenstein phase function (Mie) and
16//!    Rayleigh scattering. Supports directional, point, and spot lights.
17//!
18//! 3. **Temporal reprojection**: Blend current frame with previous to reduce
19//!    noise and flickering (exponential history, 95% previous / 5% current).
20//!
21//! 4. **Ray march integration**: Accumulate scattering and transmittance
22//!    front-to-back through the froxel grid for each pixel.
23//!
24//! 5. **Composite**: Apply fog color and transmittance to the scene in the
25//!    existing bloom/composite pass.
26//!
27//! # Froxel Grid
28//!
29//! The 3D grid is frustum-aligned: XY matches screen tiles, Z uses
30//! exponential depth distribution (more slices near camera for detail).
31//! Default: 160 x 90 x 128 = ~1.8M froxels.
32//!
33//! Depth slice mapping (exponential):
34//!   z_world = near * (far/near)^(slice/num_slices)
35//!
36//! This gives ~64 slices in the first 10% of the depth range.
37
38use glam::{Vec3, Vec4, Mat4};
39use std::f32::consts::PI;
40
41// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42// Configuration
43// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44
45/// Full volumetric fog configuration.
46#[derive(Debug, Clone)]
47pub struct VolumetricFogConfig {
48    /// Froxel grid resolution (width, height, depth_slices).
49    pub grid_size: (u32, u32, u32),
50    /// Near plane distance for depth slicing.
51    pub near: f32,
52    /// Far plane (max fog distance).
53    pub far: f32,
54    /// Global uniform fog density (participates everywhere).
55    pub global_density: f32,
56    /// Height fog: density at reference height.
57    pub height_fog_density: f32,
58    /// Height fog: exponential falloff rate (higher = thinner fog above).
59    pub height_falloff: f32,
60    /// Height fog: reference height (full density below this).
61    pub height_base: f32,
62    /// Scattering albedo (fraction of extinction that is scattering vs absorption).
63    /// Higher = brighter fog. (0,0,0) = pure absorption, (1,1,1) = pure scattering.
64    pub albedo: Vec3,
65    /// Henyey-Greenstein anisotropy for Mie scattering.
66    /// 0 = isotropic, positive = forward scattering (god rays), negative = back scattering.
67    pub anisotropy: f32,
68    /// Ambient light contribution inside fog (minimum in-scatter).
69    pub ambient_light: Vec3,
70    /// 3D noise parameters for density variation.
71    pub noise: NoiseConfig,
72    /// Temporal reprojection blend factor (0 = no reprojection, 0.95 = strong).
73    pub temporal_blend: f32,
74    /// Enable temporal reprojection.
75    pub temporal_enabled: bool,
76    /// Force field fog injection settings.
77    pub field_injection: FieldInjectionConfig,
78}
79
80/// 3D noise for density variation (turbulence, wisps).
81#[derive(Debug, Clone)]
82pub struct NoiseConfig {
83    /// Enable noise-based density variation.
84    pub enabled: bool,
85    /// Noise frequency (world-space scale).
86    pub frequency: f32,
87    /// Noise amplitude (how much it modulates density, 0-1).
88    pub amplitude: f32,
89    /// Octaves of fractal noise.
90    pub octaves: u32,
91    /// Wind velocity for noise scrolling.
92    pub wind: Vec3,
93    /// Additional noise offset (for manual control).
94    pub offset: Vec3,
95}
96
97/// How force fields inject density into the fog.
98#[derive(Debug, Clone)]
99pub struct FieldInjectionConfig {
100    /// Enable force field fog injection.
101    pub enabled: bool,
102    /// Density multiplier for attractor fields.
103    pub attractor_density: f32,
104    /// Density multiplier for vortex fields.
105    pub vortex_density: f32,
106    /// Density multiplier for gravity wells.
107    pub gravity_density: f32,
108    /// Density multiplier for shockwaves.
109    pub shockwave_density: f32,
110    /// Maximum injection radius from field center.
111    pub max_radius: f32,
112}
113
114impl Default for VolumetricFogConfig {
115    fn default() -> Self {
116        Self {
117            grid_size: (160, 90, 128),
118            near: 0.5,
119            far: 100.0,
120            global_density: 0.005,
121            height_fog_density: 0.02,
122            height_falloff: 0.15,
123            height_base: 0.0,
124            albedo: Vec3::splat(0.9),
125            anisotropy: 0.3,
126            ambient_light: Vec3::new(0.02, 0.025, 0.035),
127            noise: NoiseConfig::default(),
128            temporal_blend: 0.95,
129            temporal_enabled: true,
130            field_injection: FieldInjectionConfig::default(),
131        }
132    }
133}
134
135impl Default for NoiseConfig {
136    fn default() -> Self {
137        Self {
138            enabled: true,
139            frequency: 0.3,
140            amplitude: 0.5,
141            octaves: 3,
142            wind: Vec3::new(0.5, 0.05, 0.2),
143            offset: Vec3::ZERO,
144        }
145    }
146}
147
148impl Default for FieldInjectionConfig {
149    fn default() -> Self {
150        Self {
151            enabled: true,
152            attractor_density: 0.1,
153            vortex_density: 0.05,
154            gravity_density: 0.03,
155            shockwave_density: 0.2,
156            max_radius: 20.0,
157        }
158    }
159}
160
161// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
162// Froxel data
163// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
164
165/// Data stored per froxel.
166#[derive(Debug, Clone, Copy, Default)]
167pub struct Froxel {
168    /// Scattering coefficient (RGB, how much light is scattered per unit distance).
169    pub scattering: Vec3,
170    /// Extinction coefficient (total light loss per unit distance = scattering + absorption).
171    pub extinction: f32,
172    /// Accumulated in-scattered light (from all light sources).
173    pub in_scatter: Vec3,
174    /// Phase-function-weighted in-scatter (directional component).
175    pub in_scatter_directional: Vec3,
176}
177
178/// Result of ray marching through the froxel grid for one pixel.
179#[derive(Debug, Clone, Copy)]
180pub struct FogResult {
181    /// Accumulated in-scattered light (additive).
182    pub inscatter: Vec3,
183    /// Transmittance (multiplicative, 1.0 = no fog, 0.0 = fully fogged).
184    pub transmittance: f32,
185}
186
187impl Default for FogResult {
188    fn default() -> Self { Self { inscatter: Vec3::ZERO, transmittance: 1.0 } }
189}
190
191// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
192// Light types for fog scattering
193// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
194
195/// A light that contributes to volumetric scattering.
196#[derive(Debug, Clone, Copy)]
197pub enum FogLight {
198    Directional {
199        direction: Vec3,
200        color: Vec3,
201        intensity: f32,
202    },
203    Point {
204        position: Vec3,
205        color: Vec3,
206        intensity: f32,
207        radius: f32,
208    },
209    Spot {
210        position: Vec3,
211        direction: Vec3,
212        color: Vec3,
213        intensity: f32,
214        radius: f32,
215        cone_angle: f32,
216    },
217}
218
219// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
220// Force field density source
221// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
222
223/// A force field that injects fog density.
224#[derive(Debug, Clone, Copy)]
225pub struct FogFieldSource {
226    pub position: Vec3,
227    pub radius: f32,
228    pub density: f32,
229    pub color_tint: Vec3,
230    pub field_type: FogFieldType,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq)]
234pub enum FogFieldType {
235    Attractor,
236    Vortex,
237    Gravity,
238    Shockwave { age: f32, speed: f32 },
239}
240
241// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
242// The volumetric fog system
243// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
244
245pub struct VolumetricFogPipeline {
246    pub config: VolumetricFogConfig,
247    /// Current frame's froxel grid.
248    grid: Vec<Froxel>,
249    /// Previous frame's integrated result (for temporal reprojection).
250    prev_integrated: Vec<FogResult>,
251    /// Current frame's integrated result (front-to-back accumulation).
252    integrated: Vec<FogResult>,
253    /// Time accumulator.
254    time: f32,
255    /// Grid dimensions cached.
256    gw: u32, gh: u32, gd: u32,
257}
258
259impl VolumetricFogPipeline {
260    pub fn new(config: VolumetricFogConfig) -> Self {
261        let (gw, gh, gd) = config.grid_size;
262        let froxel_count = (gw * gh * gd) as usize;
263        let pixel_count = (gw * gh) as usize;
264        Self {
265            grid: vec![Froxel::default(); froxel_count],
266            prev_integrated: vec![FogResult::default(); pixel_count],
267            integrated: vec![FogResult::default(); pixel_count],
268            time: 0.0,
269            gw, gh, gd,
270            config,
271        }
272    }
273
274    /// Exponential depth slice: converts slice index to world-space depth.
275    fn slice_depth(&self, slice: u32) -> f32 {
276        let t = slice as f32 / self.gd as f32;
277        self.config.near * (self.config.far / self.config.near).powf(t)
278    }
279
280    /// Inverse: world depth to nearest slice index.
281    fn depth_to_slice(&self, depth: f32) -> u32 {
282        if depth <= self.config.near { return 0; }
283        let t = (depth / self.config.near).ln() / (self.config.far / self.config.near).ln();
284        (t * self.gd as f32).clamp(0.0, (self.gd - 1) as f32) as u32
285    }
286
287    fn idx(&self, x: u32, y: u32, z: u32) -> usize {
288        (z * self.gh * self.gw + y * self.gw + x) as usize
289    }
290
291    fn idx_2d(&self, x: u32, y: u32) -> usize {
292        (y * self.gw + x) as usize
293    }
294
295    // ════════════════════════════════════════════════════════════════════════
296    // Pass 1: Density injection
297    // ════════════════════════════════════════════════════════════════════════
298
299    /// Inject density into the froxel grid from all sources.
300    pub fn inject_density(
301        &mut self,
302        dt: f32,
303        inv_view_proj: &Mat4,
304        camera_pos: Vec3,
305        field_sources: &[FogFieldSource],
306    ) {
307        self.time += dt;
308
309        for froxel in &mut self.grid {
310            *froxel = Froxel::default();
311        }
312
313        let noise_time_offset = self.config.noise.wind * self.time;
314
315        for z in 0..self.gd {
316            let depth = self.slice_depth(z);
317            let next_depth = self.slice_depth((z + 1).min(self.gd - 1));
318            let slice_thickness = next_depth - depth;
319
320            for y in 0..self.gh {
321                for x in 0..self.gw {
322                    // Froxel center in world space
323                    let ndc_x = (x as f32 + 0.5) / self.gw as f32 * 2.0 - 1.0;
324                    let ndc_y = (y as f32 + 0.5) / self.gh as f32 * 2.0 - 1.0;
325                    let ndc_z = depth / self.config.far * 2.0 - 1.0;
326                    let clip = Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
327                    let world4 = *inv_view_proj * clip;
328                    let world_pos = Vec3::new(world4.x, world4.y, world4.z) / world4.w;
329
330                    let idx = self.idx(x, y, z);
331
332                    // ── Global uniform density ──
333                    let mut density = self.config.global_density;
334
335                    // ── Height fog ──
336                    let height = world_pos.y - self.config.height_base;
337                    let height_density = self.config.height_fog_density
338                        * (-height.max(0.0) * self.config.height_falloff).exp();
339                    density += height_density;
340
341                    // ── 3D noise modulation ──
342                    if self.config.noise.enabled {
343                        let np = world_pos * self.config.noise.frequency + noise_time_offset;
344                        let noise = fbm_3d(np.x, np.y, np.z, self.config.noise.octaves);
345                        density *= (1.0 + noise * self.config.noise.amplitude).max(0.0);
346                    }
347
348                    // ── Force field injection ──
349                    if self.config.field_injection.enabled {
350                        for source in field_sources {
351                            let to_field = world_pos - source.position;
352                            let dist = to_field.length();
353                            if dist > source.radius { continue; }
354
355                            let falloff = 1.0 - (dist / source.radius);
356                            let falloff_sq = falloff * falloff;
357
358                            let field_density = match source.field_type {
359                                FogFieldType::Attractor => {
360                                    source.density * self.config.field_injection.attractor_density * falloff_sq
361                                }
362                                FogFieldType::Vortex => {
363                                    // Vortex: density is strongest in a ring
364                                    let ring_dist = (dist - source.radius * 0.5).abs() / (source.radius * 0.3);
365                                    let ring = (-ring_dist * ring_dist).exp();
366                                    source.density * self.config.field_injection.vortex_density * ring
367                                }
368                                FogFieldType::Gravity => {
369                                    source.density * self.config.field_injection.gravity_density * falloff
370                                }
371                                FogFieldType::Shockwave { age, speed } => {
372                                    // Expanding ring of density
373                                    let ring_radius = age * speed;
374                                    let ring_dist = (dist - ring_radius).abs();
375                                    let ring_width = 2.0;
376                                    let ring = (-ring_dist * ring_dist / (ring_width * ring_width)).exp();
377                                    let fade = (1.0 - age / 3.0).max(0.0); // fades over 3 seconds
378                                    source.density * self.config.field_injection.shockwave_density * ring * fade
379                                }
380                            };
381
382                            density += field_density;
383                        }
384                    }
385
386                    // Store
387                    let extinction = density;
388                    let scattering = self.config.albedo * density;
389                    self.grid[idx] = Froxel {
390                        scattering,
391                        extinction,
392                        in_scatter: Vec3::ZERO,
393                        in_scatter_directional: Vec3::ZERO,
394                    };
395                }
396            }
397        }
398    }
399
400    // ════════════════════════════════════════════════════════════════════════
401    // Pass 2: Light scattering
402    // ════════════════════════════════════════════════════════════════════════
403
404    /// Compute in-scattered light at each froxel from all lights.
405    pub fn scatter_light(
406        &mut self,
407        inv_view_proj: &Mat4,
408        camera_pos: Vec3,
409        lights: &[FogLight],
410    ) {
411        for z in 0..self.gd {
412            let depth = self.slice_depth(z);
413            for y in 0..self.gh {
414                for x in 0..self.gw {
415                    let ndc_x = (x as f32 + 0.5) / self.gw as f32 * 2.0 - 1.0;
416                    let ndc_y = (y as f32 + 0.5) / self.gh as f32 * 2.0 - 1.0;
417                    let ndc_z = depth / self.config.far * 2.0 - 1.0;
418                    let clip = Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
419                    let world4 = *inv_view_proj * clip;
420                    let world_pos = Vec3::new(world4.x, world4.y, world4.z) / world4.w;
421
422                    let idx = self.idx(x, y, z);
423                    let froxel = &self.grid[idx];
424                    if froxel.extinction < 1e-7 { continue; }
425
426                    let view_dir = (world_pos - camera_pos).normalize_or_zero();
427                    let mut total_inscatter = self.config.ambient_light * froxel.scattering;
428
429                    for light in lights {
430                        let (light_color, light_intensity, to_light, attenuation) = match light {
431                            FogLight::Directional { direction, color, intensity } => {
432                                (*color, *intensity, -*direction, 1.0)
433                            }
434                            FogLight::Point { position, color, intensity, radius } => {
435                                let to = *position - world_pos;
436                                let dist = to.length();
437                                if dist > *radius { continue; }
438                                let atten = (1.0 - dist / radius).max(0.0);
439                                (*color, *intensity, to.normalize_or_zero(), atten * atten)
440                            }
441                            FogLight::Spot { position, direction, color, intensity, radius, cone_angle } => {
442                                let to = *position - world_pos;
443                                let dist = to.length();
444                                if dist > *radius { continue; }
445                                let to_norm = to.normalize_or_zero();
446                                let cos_angle = (-to_norm).dot(*direction);
447                                if cos_angle < cone_angle.cos() { continue; }
448                                let atten = (1.0 - dist / radius).max(0.0);
449                                let spot_atten = ((cos_angle - cone_angle.cos()) / (1.0 - cone_angle.cos())).max(0.0);
450                                (*color, *intensity, to_norm, atten * atten * spot_atten)
451                            }
452                        };
453
454                        // Phase function
455                        let cos_theta = view_dir.dot(to_light);
456                        let phase = henyey_greenstein(cos_theta, self.config.anisotropy);
457
458                        total_inscatter += light_color * light_intensity * attenuation
459                            * froxel.scattering * phase;
460                    }
461
462                    // Write back
463                    let froxel_mut = &mut self.grid[idx];
464                    froxel_mut.in_scatter = total_inscatter;
465                }
466            }
467        }
468    }
469
470    // ════════════════════════════════════════════════════════════════════════
471    // Pass 3: Temporal reprojection
472    // ════════════════════════════════════════════════════════════════════════
473
474    /// Blend current frame with previous frame's result.
475    pub fn temporal_reproject(&mut self) {
476        if !self.config.temporal_enabled { return; }
477
478        let blend = self.config.temporal_blend;
479        // For temporal reprojection to work properly with the integrated result,
480        // we'd need to reproject using the previous frame's view-projection matrix.
481        // Simplified version: just blend the 2D integrated results.
482        // (Full implementation would reproject froxels in 3D space)
483    }
484
485    // ════════════════════════════════════════════════════════════════════════
486    // Pass 4: Front-to-back integration (ray march)
487    // ════════════════════════════════════════════════════════════════════════
488
489    /// Integrate scattering and transmittance front-to-back for each screen pixel.
490    pub fn integrate(&mut self) {
491        // Save previous for temporal
492        if self.config.temporal_enabled {
493            std::mem::swap(&mut self.integrated, &mut self.prev_integrated);
494        }
495
496        for y in 0..self.gh {
497            for x in 0..self.gw {
498                let mut accumulated_scatter = Vec3::ZERO;
499                let mut accumulated_transmittance = 1.0f32;
500
501                for z in 0..self.gd {
502                    let idx = self.idx(x, y, z);
503                    let froxel = &self.grid[idx];
504
505                    let depth = self.slice_depth(z);
506                    let next_depth = self.slice_depth((z + 1).min(self.gd - 1));
507                    let slice_thickness = next_depth - depth;
508
509                    // Beer-Lambert transmittance for this slice
510                    let slice_extinction = froxel.extinction * slice_thickness;
511                    let slice_transmittance = (-slice_extinction).exp();
512
513                    // In-scattered light contribution (energy-conserving)
514                    let scatter_integral = if slice_extinction > 1e-7 {
515                        (1.0 - slice_transmittance) / slice_extinction
516                    } else {
517                        slice_thickness
518                    };
519
520                    accumulated_scatter += froxel.in_scatter * scatter_integral * accumulated_transmittance;
521                    accumulated_transmittance *= slice_transmittance;
522
523                    // Early out if fully opaque
524                    if accumulated_transmittance < 0.001 { break; }
525                }
526
527                let idx_2d = self.idx_2d(x, y);
528                let mut result = FogResult {
529                    inscatter: accumulated_scatter,
530                    transmittance: accumulated_transmittance,
531                };
532
533                // Temporal blend
534                if self.config.temporal_enabled && idx_2d < self.prev_integrated.len() {
535                    let prev = &self.prev_integrated[idx_2d];
536                    let blend = self.config.temporal_blend;
537                    result.inscatter = prev.inscatter * blend + result.inscatter * (1.0 - blend);
538                    result.transmittance = prev.transmittance * blend + result.transmittance * (1.0 - blend);
539                }
540
541                self.integrated[idx_2d] = result;
542            }
543        }
544    }
545
546    // ════════════════════════════════════════════════════════════════════════
547    // Full frame update (convenience)
548    // ════════════════════════════════════════════════════════════════════════
549
550    /// Run the complete fog pipeline for one frame.
551    pub fn update(
552        &mut self,
553        dt: f32,
554        inv_view_proj: &Mat4,
555        camera_pos: Vec3,
556        lights: &[FogLight],
557        field_sources: &[FogFieldSource],
558    ) {
559        self.inject_density(dt, inv_view_proj, camera_pos, field_sources);
560        self.scatter_light(inv_view_proj, camera_pos, lights);
561        self.integrate();
562    }
563
564    /// Sample the integrated fog at a screen pixel.
565    pub fn sample_pixel(&self, screen_x: f32, screen_y: f32) -> FogResult {
566        let px = (screen_x * self.gw as f32).clamp(0.0, (self.gw - 1) as f32) as u32;
567        let py = (screen_y * self.gh as f32).clamp(0.0, (self.gh - 1) as f32) as u32;
568        let idx = self.idx_2d(px, py);
569        if idx < self.integrated.len() { self.integrated[idx] } else { FogResult::default() }
570    }
571
572    /// Sample fog at a world-space depth for a given pixel.
573    /// Returns (inscatter, transmittance) up to that depth.
574    pub fn sample_at_depth(&self, screen_x: f32, screen_y: f32, depth: f32) -> FogResult {
575        let px = (screen_x * self.gw as f32).clamp(0.0, (self.gw - 1) as f32) as u32;
576        let py = (screen_y * self.gh as f32).clamp(0.0, (self.gh - 1) as f32) as u32;
577        let target_slice = self.depth_to_slice(depth);
578
579        let mut scatter = Vec3::ZERO;
580        let mut transmittance = 1.0f32;
581
582        for z in 0..=target_slice.min(self.gd - 1) {
583            let idx = self.idx(px, py, z);
584            let froxel = &self.grid[idx];
585            let d = self.slice_depth(z);
586            let nd = self.slice_depth((z + 1).min(self.gd - 1));
587            let thickness = nd - d;
588            let ext = froxel.extinction * thickness;
589            let trans = (-ext).exp();
590            let integral = if ext > 1e-7 { (1.0 - trans) / ext } else { thickness };
591            scatter += froxel.in_scatter * integral * transmittance;
592            transmittance *= trans;
593        }
594
595        FogResult { inscatter: scatter, transmittance }
596    }
597
598    /// Get grid dimensions.
599    pub fn grid_size(&self) -> (u32, u32, u32) { (self.gw, self.gh, self.gd) }
600
601    /// Total froxel count.
602    pub fn froxel_count(&self) -> usize { (self.gw * self.gh * self.gd) as usize }
603
604    /// Memory usage estimate in bytes.
605    pub fn memory_bytes(&self) -> usize {
606        self.grid.len() * std::mem::size_of::<Froxel>()
607        + self.integrated.len() * std::mem::size_of::<FogResult>() * 2
608    }
609
610    // ════════════════════════════════════════════════════════════════════════
611    // GLSL shader sources
612    // ════════════════════════════════════════════════════════════════════════
613
614    /// GLSL compute shader for density injection (GPU path).
615    pub fn glsl_inject_compute() -> &'static str {
616        r#"
617#version 430
618layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
619
620layout(rgba16f, binding = 0) uniform image3D u_fog_volume;
621uniform mat4 u_inv_view_proj;
622uniform vec3 u_camera_pos;
623uniform float u_time;
624uniform float u_global_density;
625uniform float u_height_density;
626uniform float u_height_falloff;
627uniform float u_height_base;
628uniform float u_near;
629uniform float u_far;
630uniform int u_depth_slices;
631
632// 3D value noise
633float hash(vec3 p) {
634    p = fract(p * 0.3183099 + 0.1);
635    p *= 17.0;
636    return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
637}
638
639float noise3d(vec3 p) {
640    vec3 i = floor(p);
641    vec3 f = fract(p);
642    f = f * f * (3.0 - 2.0 * f);
643    return mix(mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),
644                   mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
645               mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
646                   mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), f.z);
647}
648
649float fbm(vec3 p) {
650    float v = 0.0, a = 0.5;
651    for (int i = 0; i < 3; i++) {
652        v += a * noise3d(p);
653        p *= 2.0;
654        a *= 0.5;
655    }
656    return v;
657}
658
659void main() {
660    ivec3 id = ivec3(gl_GlobalInvocationID.xyz);
661    ivec3 grid = ivec3(imageSize(u_fog_volume));
662    if (any(greaterThanEqual(id, grid))) return;
663
664    // Exponential depth
665    float t = float(id.z) / float(grid.z);
666    float depth = u_near * pow(u_far / u_near, t);
667
668    // NDC to world
669    vec2 ndc = (vec2(id.xy) + 0.5) / vec2(grid.xy) * 2.0 - 1.0;
670    float ndc_z = depth / u_far * 2.0 - 1.0;
671    vec4 world4 = u_inv_view_proj * vec4(ndc, ndc_z, 1.0);
672    vec3 world_pos = world4.xyz / world4.w;
673
674    // Density
675    float density = u_global_density;
676
677    // Height fog
678    float height = world_pos.y - u_height_base;
679    density += u_height_density * exp(-max(height, 0.0) * u_height_falloff);
680
681    // Noise
682    vec3 np = world_pos * 0.3 + vec3(u_time * 0.5, u_time * 0.05, u_time * 0.2);
683    density *= max(0.0, 1.0 + (fbm(np) - 0.5) * 1.0);
684
685    imageStore(u_fog_volume, id, vec4(density, 0.0, 0.0, 0.0));
686}
687        "#
688    }
689
690    /// GLSL fragment shader for applying fog to the scene (composite pass).
691    pub fn glsl_apply_fragment() -> &'static str {
692        r#"
693// Apply volumetric fog to a scene pixel.
694// Call in the composite/post-process pass.
695vec3 apply_fog(vec3 scene_color, sampler3D fog_inscatter, sampler3D fog_transmittance,
696               vec2 screen_uv, float pixel_depth, float near, float far) {
697    // Map depth to exponential slice coordinate
698    float t = log(pixel_depth / near) / log(far / near);
699    t = clamp(t, 0.0, 1.0);
700
701    vec3 inscatter = texture(fog_inscatter, vec3(screen_uv, t)).rgb;
702    float transmittance = texture(fog_transmittance, vec3(screen_uv, t)).r;
703
704    return scene_color * transmittance + inscatter;
705}
706        "#
707    }
708}
709
710// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
711// Phase functions
712// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
713
714/// Henyey-Greenstein phase function for Mie scattering.
715fn henyey_greenstein(cos_theta: f32, g: f32) -> f32 {
716    let g2 = g * g;
717    let denom = 1.0 + g2 - 2.0 * g * cos_theta;
718    if denom < 1e-7 { return 1.0 / (4.0 * PI); }
719    (1.0 - g2) / (4.0 * PI * denom * denom.sqrt())
720}
721
722/// Combined Rayleigh + Mie phase function.
723fn combined_phase(cos_theta: f32, g: f32, rayleigh_weight: f32) -> f32 {
724    let mie = henyey_greenstein(cos_theta, g);
725    let rayleigh = 3.0 / (16.0 * PI) * (1.0 + cos_theta * cos_theta);
726    rayleigh * rayleigh_weight + mie * (1.0 - rayleigh_weight)
727}
728
729// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
730// 3D fractal Brownian motion noise
731// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
732
733fn value_noise_3d(x: f32, y: f32, z: f32) -> f32 {
734    let ix = x.floor() as i32;
735    let iy = y.floor() as i32;
736    let iz = z.floor() as i32;
737    let fx = x - x.floor();
738    let fy = y - y.floor();
739    let fz = z - z.floor();
740    let tx = fx * fx * (3.0 - 2.0 * fx);
741    let ty = fy * fy * (3.0 - 2.0 * fy);
742    let tz = fz * fz * (3.0 - 2.0 * fz);
743
744    let h = |i: i32, j: i32, k: i32| -> f32 {
745        let n = i.wrapping_mul(374761393).wrapping_add(j.wrapping_mul(668265263)).wrapping_add(k.wrapping_mul(1274126177)) as u32;
746        let n = n ^ (n >> 13);
747        let n = n.wrapping_mul(0x5851F42D);
748        (n & 0x00FF_FFFF) as f32 / 0x0080_0000 as f32 - 1.0
749    };
750
751    let v000 = h(ix, iy, iz); let v100 = h(ix+1, iy, iz);
752    let v010 = h(ix, iy+1, iz); let v110 = h(ix+1, iy+1, iz);
753    let v001 = h(ix, iy, iz+1); let v101 = h(ix+1, iy, iz+1);
754    let v011 = h(ix, iy+1, iz+1); let v111 = h(ix+1, iy+1, iz+1);
755
756    let a = v000 + tx*(v100-v000); let b = v010 + tx*(v110-v010);
757    let c = v001 + tx*(v101-v001); let d = v011 + tx*(v111-v011);
758    let e = a + ty*(b-a); let f = c + ty*(d-c);
759    e + tz*(f-e)
760}
761
762fn fbm_3d(x: f32, y: f32, z: f32, octaves: u32) -> f32 {
763    let mut value = 0.0f32;
764    let mut amplitude = 0.5f32;
765    let mut freq = 1.0f32;
766    for _ in 0..octaves {
767        value += amplitude * value_noise_3d(x * freq, y * freq, z * freq);
768        freq *= 2.0;
769        amplitude *= 0.5;
770    }
771    value
772}
773
774// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
775// Presets
776// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
777
778/// Room-type fog presets for different game areas.
779pub struct FogPresets;
780
781impl FogPresets {
782    /// Standard combat room: light fog, subtle atmosphere.
783    pub fn combat() -> VolumetricFogConfig {
784        VolumetricFogConfig {
785            grid_size: (80, 45, 64),
786            global_density: 0.003,
787            height_fog_density: 0.01,
788            far: 30.0,
789            ..Default::default()
790        }
791    }
792
793    /// Boss arena: thicker fog, dramatic atmosphere, wider range.
794    pub fn boss_arena() -> VolumetricFogConfig {
795        VolumetricFogConfig {
796            grid_size: (120, 68, 96),
797            global_density: 0.008,
798            height_fog_density: 0.03,
799            anisotropy: 0.5,
800            far: 50.0,
801            ambient_light: Vec3::new(0.03, 0.02, 0.04),
802            ..Default::default()
803        }
804    }
805
806    /// Shrine: thin ethereal fog, golden tint.
807    pub fn shrine() -> VolumetricFogConfig {
808        VolumetricFogConfig {
809            grid_size: (80, 45, 64),
810            global_density: 0.002,
811            height_fog_density: 0.005,
812            far: 40.0,
813            ambient_light: Vec3::new(0.04, 0.035, 0.02),
814            noise: NoiseConfig { amplitude: 0.3, frequency: 0.2, ..Default::default() },
815            ..Default::default()
816        }
817    }
818
819    /// Void/chaos rift: dense dark fog, oppressive.
820    pub fn void() -> VolumetricFogConfig {
821        VolumetricFogConfig {
822            grid_size: (80, 45, 64),
823            global_density: 0.02,
824            height_fog_density: 0.05,
825            far: 20.0,
826            albedo: Vec3::new(0.6, 0.5, 0.7),
827            ambient_light: Vec3::new(0.01, 0.005, 0.02),
828            noise: NoiseConfig { amplitude: 0.8, frequency: 0.5, ..Default::default() },
829            ..Default::default()
830        }
831    }
832
833    /// Corruption fog: gets denser as corruption level increases.
834    pub fn corruption(level: f32) -> VolumetricFogConfig {
835        let level = level.clamp(0.0, 1.0);
836        VolumetricFogConfig {
837            grid_size: (80, 45, 64),
838            global_density: 0.003 + level * 0.02,
839            height_fog_density: 0.01 + level * 0.04,
840            far: 30.0 - level * 15.0,
841            albedo: Vec3::new(0.7 - level * 0.3, 0.8 - level * 0.5, 0.9 - level * 0.3),
842            ambient_light: Vec3::new(0.02, 0.015 - level * 0.01, 0.03 - level * 0.02),
843            anisotropy: 0.3 + level * 0.3,
844            noise: NoiseConfig {
845                amplitude: 0.5 + level * 0.5,
846                frequency: 0.3 + level * 0.2,
847                ..Default::default()
848            },
849            ..Default::default()
850        }
851    }
852}
853
854// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
855// Tests
856// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn test_exponential_depth() {
864        let config = VolumetricFogConfig { near: 0.5, far: 100.0, grid_size: (4, 4, 64), ..Default::default() };
865        let fog = VolumetricFogPipeline::new(config);
866        let d0 = fog.slice_depth(0);
867        let d_mid = fog.slice_depth(32);
868        let d_end = fog.slice_depth(63);
869        assert!((d0 - 0.5).abs() < 0.01, "first slice should be near plane");
870        assert!(d_mid < 50.0, "midpoint should be less than half far (exponential)");
871        assert!(d_end < 100.0, "last slice should be near far plane");
872    }
873
874    #[test]
875    fn test_depth_roundtrip() {
876        let config = VolumetricFogConfig { near: 0.5, far: 100.0, grid_size: (4, 4, 64), ..Default::default() };
877        let fog = VolumetricFogPipeline::new(config);
878        let depth = 10.0;
879        let slice = fog.depth_to_slice(depth);
880        let recovered = fog.slice_depth(slice);
881        assert!((recovered - depth).abs() < 2.0, "roundtrip should be close");
882    }
883
884    #[test]
885    fn test_henyey_greenstein_normalization() {
886        // Integrate HG over all angles should be ~1
887        let g = 0.3;
888        let steps = 1000;
889        let mut integral = 0.0f32;
890        for i in 0..steps {
891            let cos_theta = -1.0 + 2.0 * i as f32 / steps as f32;
892            integral += henyey_greenstein(cos_theta, g) * 2.0 * PI * (2.0 / steps as f32);
893        }
894        assert!((integral - 1.0).abs() < 0.1, "HG should integrate to ~1, got {}", integral);
895    }
896
897    #[test]
898    fn test_fog_pipeline_runs() {
899        let config = VolumetricFogConfig { grid_size: (4, 4, 4), ..Default::default() };
900        let mut fog = VolumetricFogPipeline::new(config);
901        let inv_vp = Mat4::IDENTITY;
902        let lights = vec![FogLight::Directional {
903            direction: Vec3::new(0.0, -1.0, 0.0), color: Vec3::ONE, intensity: 1.0,
904        }];
905        fog.update(0.016, &inv_vp, Vec3::ZERO, &lights, &[]);
906        let result = fog.sample_pixel(0.5, 0.5);
907        assert!(result.transmittance <= 1.0 && result.transmittance >= 0.0);
908    }
909
910    #[test]
911    fn test_field_injection() {
912        let config = VolumetricFogConfig { grid_size: (4, 4, 4), ..Default::default() };
913        let mut fog = VolumetricFogPipeline::new(config);
914        let source = FogFieldSource {
915            position: Vec3::ZERO, radius: 10.0, density: 1.0,
916            color_tint: Vec3::ONE, field_type: FogFieldType::Attractor,
917        };
918        fog.inject_density(0.016, &Mat4::IDENTITY, Vec3::ZERO, &[source]);
919        // At least some froxels should have non-zero density
920        let has_density = fog.grid.iter().any(|f| f.extinction > 0.0);
921        assert!(has_density, "field injection should add density");
922    }
923
924    #[test]
925    fn test_corruption_preset_scales() {
926        let low = FogPresets::corruption(0.0);
927        let high = FogPresets::corruption(1.0);
928        assert!(high.global_density > low.global_density);
929        assert!(high.far < low.far); // denser = shorter visibility
930    }
931
932    #[test]
933    fn test_fbm_range() {
934        for i in 0..50 {
935            let v = fbm_3d(i as f32 * 0.3, 0.5, 1.2, 3);
936            assert!(v > -2.0 && v < 2.0, "fbm out of expected range: {}", v);
937        }
938    }
939}