Skip to main content

concinnity_render/
volumetric_fog.rs

1//! Backend-agnostic resolution of the authored `VolumetricFog` asset into a
2//! clamped settings struct plus the per-frame `FogParams` uniform the Metal
3//! fog fragment shader consumes. Pure CPU; unit-testable without a GPU.
4
5use crate::render_types::FogParams;
6
7// Upper bound on the volumetric density. The integral
8// `1 - exp(-density * step)` saturates near 1.0 well before this cap, so
9// anything higher just wastes precision and risks numeric blowups for the
10// Henyey-Greenstein factor. 10/world-unit is already pea-soup territory.
11const MAX_DENSITY: f32 = 10.0;
12// Largest sensible height-falloff rate. Beyond this the density drops to
13// nothing within centimetres above the reference height, which is not
14// useful (and is rounding-error fragile in the shader's `exp`).
15const MAX_HEIGHT_FALLOFF: f32 = 4.0;
16// Cap on the ray-march distance. The marcher takes a fixed number of steps,
17// so a longer ray spends more world units per step rather than more samples.
18// Going past this trades shadow / phase accuracy for distance with no
19// real visual win.
20const MAX_DISTANCE_CAP: f32 = 2_000.0;
21// Floor on the ray-march distance. The shader divides by it, and the
22// per-step length collapses to zero past about a millimetre.
23const MIN_DISTANCE: f32 = 1.0;
24// Floor on the viewport short edge so a zero-sized swapchain (initial layout)
25// cannot poison the reciprocal the shader uses to convert screen to NDC.
26const MIN_VIEWPORT: f32 = 1.0;
27
28/// Resolved and clamped fog tunables, threaded into the backend at init.
29/// `None` from `FogSettings::resolve_optional` means the world declared no
30/// `VolumetricFog`: the renderer then skips the fog pass entirely.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct FogSettings {
33    /// Linear RGB colour.
34    pub color: [f32; 3],
35    /// Fog density at the height reference.
36    pub density: f32,
37    /// How fast density falls off with height.
38    pub height_falloff: f32,
39    /// World height at which density equals `density`.
40    pub height_reference: f32,
41    /// Furthest world distance the march travels.
42    pub max_distance: f32,
43    /// Henyey-Greenstein anisotropy in `(-1, 1)`; 0 is isotropic.
44    pub phase_g: f32,
45    /// Ambient radiance added to the in-scattered term.
46    pub ambient: f32,
47}
48
49impl FogSettings {
50    /// Clamp the authored fields into a safe range. Mirrors `VolumetricFog::from_args`;
51    /// those clamps are the asset-side floor; this is the gfx-side ceiling.
52    pub fn resolve(
53        color: [f32; 3],
54        density: f32,
55        height_falloff: f32,
56        height_reference: f32,
57        max_distance: f32,
58        phase_g: f32,
59        ambient: f32,
60    ) -> Self {
61        let max_distance = if max_distance.is_finite() {
62            max_distance.clamp(MIN_DISTANCE, MAX_DISTANCE_CAP)
63        } else {
64            MIN_DISTANCE
65        };
66        let color = [color[0].max(0.0), color[1].max(0.0), color[2].max(0.0)];
67        Self {
68            color,
69            density: density.clamp(0.0, MAX_DENSITY),
70            height_falloff: height_falloff.clamp(0.0, MAX_HEIGHT_FALLOFF),
71            height_reference,
72            max_distance,
73            // Mirror the asset clamp so a settings built from out-of-range
74            // raw floats (e.g. in tests) still produces stable HG output.
75            phase_g: phase_g.clamp(-0.95, 0.95),
76            ambient: ambient.clamp(0.0, MAX_DENSITY),
77        }
78    }
79
80    /// Build the per-frame GPU uniform from these settings and the active
81    /// camera. `inv_vp` is the inverse view-projection used to reconstruct
82    /// world positions from depth; `cam_pos` is the camera origin; `sun_dir`
83    /// and `sun_color` are the first directional light's direction (toward
84    /// the light) and `intensity * colour`. `viewport` is the HDR resolve
85    /// target's pixel dimensions.
86    pub fn params(
87        &self,
88        inv_vp: [[f32; 4]; 4],
89        cam_pos: [f32; 3],
90        sun_dir: [f32; 3],
91        sun_color: [f32; 3],
92        viewport: [f32; 2],
93    ) -> FogParams {
94        let viewport = [viewport[0].max(MIN_VIEWPORT), viewport[1].max(MIN_VIEWPORT)];
95        FogParams {
96            inv_vp,
97            color: [self.color[0], self.color[1], self.color[2], 1.0],
98            cam_pos,
99            _pad0: 0.0,
100            sun_dir,
101            _pad1: 0.0,
102            sun_color,
103            _pad2: 0.0,
104            density: self.density,
105            height_falloff: self.height_falloff,
106            height_reference: self.height_reference,
107            max_distance: self.max_distance,
108            phase_g: self.phase_g,
109            ambient: self.ambient,
110            viewport,
111            inv_max_distance: 1.0 / self.max_distance,
112            _pad3: [0.0; 3],
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    const IDENTITY: [[f32; 4]; 4] = [
122        [1.0, 0.0, 0.0, 0.0],
123        [0.0, 1.0, 0.0, 0.0],
124        [0.0, 0.0, 1.0, 0.0],
125        [0.0, 0.0, 0.0, 1.0],
126    ];
127
128    #[test]
129    fn resolve_clamps_density_falloff_and_distance() {
130        let s = FogSettings::resolve([1.0, 1.0, 1.0], 100.0, 20.0, 0.0, 1e9, 1.5, -1.0);
131        assert_eq!(s.density, MAX_DENSITY);
132        assert_eq!(s.height_falloff, MAX_HEIGHT_FALLOFF);
133        assert_eq!(s.max_distance, MAX_DISTANCE_CAP);
134        assert!(s.phase_g <= 0.95 && s.phase_g > 0.0);
135        assert_eq!(s.ambient, 0.0);
136    }
137
138    #[test]
139    fn resolve_passes_through_in_range_values() {
140        let s = FogSettings::resolve([0.6, 0.7, 0.8], 0.08, 0.25, 1.5, 120.0, 0.4, 0.2);
141        assert_eq!(s.color, [0.6, 0.7, 0.8]);
142        assert!((s.density - 0.08).abs() < 1e-6);
143        assert!((s.phase_g - 0.4).abs() < 1e-6);
144        assert!((s.max_distance - 120.0).abs() < 1e-6);
145    }
146
147    #[test]
148    fn resolve_handles_non_finite_distance() {
149        let s = FogSettings::resolve([0.6; 3], 0.05, 0.2, 0.0, f32::NAN, 0.4, 0.15);
150        assert!(s.max_distance.is_finite());
151        assert!(s.max_distance >= MIN_DISTANCE);
152    }
153
154    #[test]
155    fn params_derive_inverse_max_distance() {
156        let s = FogSettings::resolve([0.7; 3], 0.05, 0.2, 0.0, 50.0, 0.4, 0.15);
157        let p = s.params(
158            IDENTITY,
159            [0.0; 3],
160            [0.0, 1.0, 0.0],
161            [1.0; 3],
162            [1280.0, 720.0],
163        );
164        assert!((p.inv_max_distance - (1.0 / 50.0)).abs() < 1e-6);
165        assert_eq!(p.viewport, [1280.0, 720.0]);
166    }
167
168    #[test]
169    fn params_floor_a_degenerate_viewport() {
170        let s = FogSettings::resolve([0.7; 3], 0.05, 0.2, 0.0, 50.0, 0.4, 0.15);
171        let p = s.params(IDENTITY, [0.0; 3], [0.0, 1.0, 0.0], [1.0; 3], [0.0, 0.0]);
172        assert!(p.viewport[0] >= MIN_VIEWPORT);
173        assert!(p.viewport[1] >= MIN_VIEWPORT);
174    }
175
176    #[test]
177    fn params_zero_padding_words_are_zero() {
178        let s = FogSettings::resolve([0.7; 3], 0.05, 0.2, 0.0, 50.0, 0.4, 0.15);
179        let p = s.params(IDENTITY, [0.0; 3], [0.0, 1.0, 0.0], [1.0; 3], [1.0, 1.0]);
180        assert_eq!(p._pad0, 0.0);
181        assert_eq!(p._pad1, 0.0);
182        assert_eq!(p._pad2, 0.0);
183        assert_eq!(p._pad3, [0.0; 3]);
184    }
185}