Skip to main content

primase_core/
atmosphere.rs

1//! What a source says about the air between the camera and the scene, and about how the camera sees it
2//! (`core.atmosphere`, a world resource beside `core.environment`): fog, exposure, the camera's auto exposure, and a
3//! source's volumetric fog in the source's own units (shown, not drawn). A tabled tone curve and colour lookup describe the established display processing.
4
5mod post;
6pub use post::{ColourLut, ColourMatrix, ReinhardCurve, ToneMap};
7mod status;
8pub use status::{PostProcessingStatus, PostStage, PostStageStatus, PostStageStatusKind};
9
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::{JsonError, Resource, World};
14
15/// Fog and exposure as the source authored them; each `None` when the source states none (or a lowering has not
16/// established its meaning). A scene-wide fact, kept by every part of a split world as `core.environment`'s ambient is.
17#[derive(Clone, Debug, Default, PartialEq, Serialize)]
18pub struct Atmosphere {
19    /// The display processing after exposure and bloom, when fully established by a source.
20    pub tone_map: Option<ToneMap>,
21    pub fog: Option<Fog>,
22    pub exposure: Option<Exposure>,
23    /// A froxel-volume fog as the source stores it, in the source's own units: shown, never drawn (see [`VolumetricFog`]).
24    pub volumetric: Option<VolumetricFog>,
25    /// The camera's exposure as the source's renderer sets it from the frame it sees ([`AutoExposure`]), in place of
26    /// `exposure`, which a source stating this leaves `None`.
27    pub auto_exposure: Option<AutoExposure>,
28}
29
30/// A source's volumetric (froxel) fog, every field as the source stores it and named as the source names it. The units
31/// are the source's: the densities are not established as per metre (the source states a `game_scale`), the colours are
32/// not established as linear light at core's scale, and `directional_light_bank` names a light bank of the source, not a
33/// core light. Nothing here is converted, so a consumer must not draw it as [`Fog`]: a renderer draws no fog from it
34/// until a source has established its unit, and a lowering that states it says so in its notices. A field the source
35/// file does not store is `None` (a later file of a map stores only what it changes).
36#[derive(Clone, Debug, Default, PartialEq, Serialize)]
37pub struct VolumetricFog {
38    pub enabled: Option<bool>,
39    pub game_scale: Option<f32>,
40    pub near_depth: Option<f32>,
41    pub far_depth: Option<f32>,
42    pub near_offset: Option<f32>,
43    pub fixed_density: Option<f32>,
44    pub scattering_g: Option<f32>,
45    pub local_light_scale: Option<f32>,
46    pub local_light_dist_scale: Option<f32>,
47    pub height_fog_distribution: Option<f32>,
48    pub height_fog_offset: Option<f32>,
49    pub height_fog_density_scale: Option<f32>,
50    /// Four floats as stored; the fourth is 0 in every census file of the source we measured.
51    pub sky_colour: Option<[f32; 4]>,
52    pub sky_start_dist: Option<f32>,
53    pub directional_light_bank: Option<u32>,
54    pub directional_direct_colour: Option<[f32; 4]>,
55    pub directional_opposite_colour: Option<[f32; 4]>,
56    pub directional_start_dist: Option<f32>,
57}
58
59impl VolumetricFog {
60    /// The fields, by the source's name, as stored, for a listing (a colour as its four floats).
61    pub fn fields(&self) -> Vec<(&'static str, String)> {
62        let f = |v: Option<f32>| v.map(|x| x.to_string());
63        let c = |v: Option<[f32; 4]>| v.map(|x| format!("{x:?}"));
64        [
65            ("Enable", self.enabled.map(|x| x.to_string())),
66            ("GameScale", f(self.game_scale)),
67            ("NearDepth", f(self.near_depth)),
68            ("FarDepth", f(self.far_depth)),
69            ("NearOffset", f(self.near_offset)),
70            ("Global FixedDensity", f(self.fixed_density)),
71            ("ScatteringG", f(self.scattering_g)),
72            ("Global LocalLightScale", f(self.local_light_scale)),
73            ("Global LocalLightDistScale", f(self.local_light_dist_scale)),
74            ("Global HeightFogDistribution", f(self.height_fog_distribution)),
75            ("Global HeightFogOffset", f(self.height_fog_offset)),
76            ("Global HeightFogDensity Scale", f(self.height_fog_density_scale)),
77            ("SkyLight SkyColor", c(self.sky_colour)),
78            ("SkyLight StartDist", f(self.sky_start_dist)),
79            (
80                "DirLight LightBankId",
81                self.directional_light_bank.map(|x| x.to_string()),
82            ),
83            ("DirLight DirectColor", c(self.directional_direct_colour)),
84            ("DirLight OppositeColor", c(self.directional_opposite_colour)),
85            ("DirLight StartDist", f(self.directional_start_dist)),
86        ]
87        .into_iter()
88        .filter_map(|(name, value)| Some((name, value?)))
89        .collect()
90    }
91
92    fn check(&self) -> Result<(), String> {
93        let scalars = [
94            ("GameScale", self.game_scale),
95            ("NearDepth", self.near_depth),
96            ("FarDepth", self.far_depth),
97            ("NearOffset", self.near_offset),
98            ("Global FixedDensity", self.fixed_density),
99            ("ScatteringG", self.scattering_g),
100            ("Global LocalLightScale", self.local_light_scale),
101            ("Global LocalLightDistScale", self.local_light_dist_scale),
102            ("Global HeightFogDistribution", self.height_fog_distribution),
103            ("Global HeightFogOffset", self.height_fog_offset),
104            ("Global HeightFogDensity Scale", self.height_fog_density_scale),
105            ("SkyLight StartDist", self.sky_start_dist),
106            ("DirLight StartDist", self.directional_start_dist),
107        ];
108        if let Some((name, v)) = scalars
109            .into_iter()
110            .find_map(|(n, v)| v.filter(|x| !x.is_finite()).map(|x| (n, x)))
111        {
112            return Err(format!("volumetric fog {name} {v} is not finite"));
113        }
114        let colours = [
115            ("SkyLight SkyColor", self.sky_colour),
116            ("DirLight DirectColor", self.directional_direct_colour),
117            ("DirLight OppositeColor", self.directional_opposite_colour),
118        ];
119        if let Some((name, v)) = colours
120            .into_iter()
121            .find_map(|(n, v)| v.filter(|c| !c.iter().all(|x| x.is_finite())).map(|c| (n, c)))
122        {
123            return Err(format!("volumetric fog {name} {v:?} is not finite"));
124        }
125        Ok(())
126    }
127}
128
129/// Distance fog: the light of a surface at distance `d` metres from the camera is mixed with `colour` by the fraction
130/// the [`FogFalloff`] gives along the camera ray. This does not describe scattering towards the sun.
131#[derive(Clone, Debug, PartialEq, Serialize)]
132pub struct Fog {
133    /// Linear RGB of the fog where it is fully opaque, at the scale of `core.light` colours times intensity of 1 lux
134    /// (a source whose fog colour is relative to its exposure states it here as stored and reports the approximation).
135    pub colour: [f32; 3],
136    pub falloff: FogFalloff,
137}
138
139/// How much of a surface's light the fog replaces, by its distance `d` from the camera in metres.
140#[derive(Clone, Debug, PartialEq, Serialize)]
141pub enum FogFalloff {
142    /// `1 - exp(-density * d)`; `density` per metre.
143    Exponential { density: f32 },
144    /// 0 up to `start`, 1 from `end`, linear between; metres, `start < end`.
145    Linear { start: f32, end: f32 },
146    /// Uniform density inside a horizontal slab, zero outside it. `bottom` and `top` are absolute world Y heights
147    /// in metres (`bottom < top`), in core's Y-up frame. Replace by `1 - exp(-density * inside)` where `inside` is
148    /// the length in metres of the camera-to-surface segment inside that slab; `density` is per metre. This is
149    /// extinction and a constant fog colour, not a height-dependent density gradient or anisotropic scattering.
150    ExponentialHeightSlab { density: f32, bottom: f32, top: f32 },
151}
152
153/// The camera's exposure: scene light is scaled by `2^stops` before any tone curve. 0 leaves it as it is. This is a
154/// relative setting, not a measured camera: a source whose exposure is another quantity (a multiplier, an EV at ISO 100)
155/// converts it and reports the approximation.
156#[derive(Clone, Debug, PartialEq, Serialize)]
157pub struct Exposure {
158    pub stops: f32,
159}
160
161/// The camera's automatic exposure: the frame's light is metered ([`ExposureMeter`]), compared with middle grey, and the
162/// exposure moved from `stops` against the difference, by at most `max` stops down and `-min` stops up, approaching
163/// those bounds smoothly ([`AutoExposure::correction`]). The scene is shown at `2^(stops - correction)` times its light
164/// before any tone curve ([`AutoExposure::scale`]).
165///
166/// Light is metered in the world's light unit: where `core.directional_light_unit` states one, a frame lit in lux is
167/// metered at lux divided by its `lux_per_unit` (the source's own radiance unit, which `middle_grey` is in); where it
168/// states none, in lux. A renderer whose frame differs from the source's (another sky, other lights) meters another
169/// value, and so settles at another exposure, for that stated reason.
170///
171/// The metered value adapts over time ([`AutoExposure::adapt`]); a still frame is shown at the adaptation's fixed point,
172/// where the adapted value equals the frame's metered one.
173#[derive(Clone, Debug, PartialEq, Serialize)]
174pub struct AutoExposure {
175    /// The exposure, in stops, of a frame metered at middle grey.
176    pub stops: f32,
177    /// The metered value a frame is shown at `stops` for: a log2 luminance in the world's light unit.
178    pub middle_grey: f32,
179    /// The most the exposure is lowered for a frame brighter than middle grey, in stops; at least 0.
180    pub max: f32,
181    /// The most it is raised for a darker frame, as a negative number of stops (the source's minimum); at most 0.
182    pub min: f32,
183    /// How the frame's light is metered.
184    pub meter: ExposureMeter,
185    /// How fast the adapted value rises towards a brighter frame's metered value: per `dt` seconds it moves the
186    /// fraction `1 - 10^(-speed * dt)` of the way. At least 0.
187    pub brighten_speed: f32,
188    /// How fast it falls towards a darker frame's, the same way. At least 0.
189    pub darken_speed: f32,
190    /// The adaptation is held: the exposure is `stops`, whatever the frame.
191    pub frozen: bool,
192}
193
194/// How a frame's light is metered for [`AutoExposure`].
195#[derive(Clone, Debug, PartialEq, Serialize)]
196pub enum ExposureMeter {
197    /// The centre-weighted mean of log2 luminance. The frame is halved (each pixel of the half-size frame a bilinear
198    /// sample at its centre: the mean of four), then cut into a 64 x 64 grid of cells over the frame; each cell takes
199    /// 16 bilinear samples of the half-size frame ([`ExposureMeter::SAMPLE_OFFSETS`] from its centre in the frame's 0..1
200    /// coordinates, each held under `1 - half a texel`, and to the frame's edge), each sample's luminance
201    /// ([`ExposureMeter::LUMINANCE`], 0 where not finite) taken as `log2(Y + 1e-5)`. A cell's value is the mean of its 16;
202    /// the frame's is the mean of its cells' values weighted by [`ExposureMeter::weight`] at their centres.
203    CentreWeightedLog2 {
204        /// How sharply the weight falls from the frame's centre; at least 0 (0 weighs every cell alike).
205        centre_weight: f32,
206    },
207}
208
209impl ExposureMeter {
210    /// A cell's 4 x 4 samples' offsets from its centre, in the frame's 0..1 coordinates, x fastest.
211    pub const SAMPLE_OFFSETS: [[f32; 2]; 16] = {
212        let mut out = [[0.0; 2]; 16];
213        let mut i = 0;
214        while i < 16 {
215            out[i] = [((i % 4) as f32 - 1.5) / 256.0, ((i / 4) as f32 - 1.5) / 256.0];
216            i += 1;
217        }
218        out
219    };
220    /// What is added to a sample's luminance before its log is taken.
221    pub const LUMINANCE_FLOOR: f32 = 1e-5;
222    /// The luminance of linear red, green and blue.
223    pub const LUMINANCE: [f32; 3] = [0.299, 0.587, 0.114];
224
225    /// The weight of a cell centred at `(u, v)` (0..1 across the frame) of a frame `aspect` wide per unit high:
226    /// `1 - (1 - 2^-r)^3`, `r` the length of `((2u - 1) c, (2v - 1) c / aspect)` for the centre weight `c`.
227    pub fn weight(&self, u: f32, v: f32, aspect: f32) -> f32 {
228        match *self {
229            ExposureMeter::CentreWeightedLog2 { centre_weight } => {
230                let r = ((2.0 * u - 1.0) * centre_weight).hypot((2.0 * v - 1.0) * centre_weight / aspect);
231                1.0 - (1.0 - (-r).exp2()).powi(3)
232            }
233        }
234    }
235}
236
237impl AutoExposure {
238    /// The stops the exposure is lowered by for a frame metered `x` above middle grey (negative: raised):
239    /// `max (1 - e^(-x / max))` for `x > 0`, `min (1 - e^(-x / min))` for `x < 0`, 0 at 0, where that bound is 0, or
240    /// where the adaptation is held. It follows `x` with slope 1 near 0 and approaches `max` above and `min` below.
241    pub fn correction(&self, x: f32) -> f32 {
242        let bound = if x > 0.0 { self.max } else { self.min };
243        if self.frozen || x == 0.0 || bound == 0.0 {
244            return 0.0;
245        }
246        bound * (1.0 - (-x / bound).exp())
247    }
248
249    /// The factor the scene's light is shown at for a frame whose adapted metered value is `adapted`.
250    pub fn scale(&self, adapted: f32) -> f32 {
251        (self.stops - self.correction(adapted - self.middle_grey)).exp2()
252    }
253
254    /// The adapted value after `dt` seconds at a frame metered `target`, from `adapted`. `adapted == target` is the
255    /// fixed point: the value a still frame settles at.
256    pub fn adapt(&self, adapted: f32, target: f32, dt: f32) -> f32 {
257        let speed = if target > adapted {
258            self.brighten_speed
259        } else {
260            self.darken_speed
261        };
262        adapted + (target - adapted) * (1.0 - 10f32.powf(-speed * dt))
263    }
264
265    fn check(&self) -> Result<(), String> {
266        let ExposureMeter::CentreWeightedLog2 { centre_weight } = self.meter;
267        for (name, v, ok) in [
268            ("stops", self.stops, true),
269            ("middle grey", self.middle_grey, true),
270            ("max", self.max, self.max >= 0.0),
271            ("min", self.min, self.min <= 0.0),
272            ("centre weight", centre_weight, centre_weight >= 0.0),
273            ("brighten speed", self.brighten_speed, self.brighten_speed >= 0.0),
274            ("darken speed", self.darken_speed, self.darken_speed >= 0.0),
275        ] {
276            if !(v.is_finite() && ok) {
277                return Err(format!("auto exposure {name} {v} is out of range"));
278            }
279        }
280        Ok(())
281    }
282}
283
284impl Atmosphere {
285    /// Finite values in range: fog colour and density not negative, a linear fog from a start at least 0 to a later end,
286    /// exposure any finite number of stops, an auto exposure's fields as [`AutoExposure`] states them, and not both an
287    /// exposure and an auto exposure.
288    fn check(&self) -> Result<(), String> {
289        if let Some(tone_map) = &self.tone_map {
290            tone_map.check()?;
291        }
292        if let Some(fog) = &self.fog {
293            if let Some(c) = (!fog.colour.iter().all(|x| x.is_finite() && *x >= 0.)).then_some(fog.colour) {
294                return Err(format!("fog colour {c:?} is not finite and at least 0"));
295            }
296            match fog.falloff {
297                FogFalloff::Exponential { density } if !(density.is_finite() && density >= 0.) => {
298                    return Err(format!("fog density {density} is not finite and at least 0"));
299                }
300                FogFalloff::Linear { start, end }
301                    if !(start.is_finite() && end.is_finite() && start >= 0. && start < end) =>
302                {
303                    return Err(format!(
304                        "linear fog from {start} to {end} is not finite, from at least 0 to a later end"
305                    ));
306                }
307                FogFalloff::ExponentialHeightSlab { density, bottom, top }
308                    if !(density.is_finite()
309                        && density >= 0.
310                        && bottom.is_finite()
311                        && top.is_finite()
312                        && bottom < top) =>
313                {
314                    return Err(format!(
315                        "fog slab density {density}, bottom {bottom}, top {top} requires finite nonnegative density and bottom < top"
316                    ));
317                }
318                _ => {}
319            }
320        }
321        if let Some(e) = self.exposure.as_ref().filter(|e| !e.stops.is_finite()) {
322            return Err(format!("exposure {} stops is not finite", e.stops));
323        }
324        if let Some(auto) = &self.auto_exposure {
325            if self.exposure.is_some() {
326                return Err("both an exposure and an auto exposure".to_owned());
327            }
328            auto.check()?;
329        }
330        self.volumetric.as_ref().map_or(Ok(()), VolumetricFog::check)
331    }
332}
333
334impl Resource for Atmosphere {
335    const NAME: &'static str = "core.atmosphere";
336    const VERSION: u32 = 6;
337    fn to_json(&self) -> Result<Option<Value>, JsonError> {
338        crate::json::to_value(self).map(Some)
339    }
340    fn verify(&self, _w: &World) -> Result<(), String> {
341        self.check()
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::collections::BTreeSet;
349
350    fn fog(falloff: FogFalloff) -> Atmosphere {
351        Atmosphere {
352            fog: Some(Fog {
353                colour: [0.5, 0.6, 0.7],
354                falloff,
355            }),
356            exposure: Some(Exposure { stops: -1.5 }),
357            volumetric: None,
358            auto_exposure: None,
359            tone_map: None,
360        }
361    }
362
363    /// erK1's Tone Map at clock 13.06 (the live block the game's sampler filled), centre weight and speeds as m10_00_0000
364    /// stores them.
365    fn noon() -> AutoExposure {
366        AutoExposure {
367            stops: 3.4042,
368            middle_grey: -6.4,
369            max: 0.4,
370            min: -0.8933,
371            meter: ExposureMeter::CentreWeightedLog2 { centre_weight: 7.0 },
372            brighten_speed: 0.2,
373            darken_speed: 0.2,
374            frozen: false,
375        }
376    }
377
378    #[test]
379    fn a_valid_atmosphere_is_kept_by_every_part_and_shown_as_json() {
380        let mut w = World::new();
381        let e = w.spawn();
382        w.insert_resource(fog(FogFalloff::Linear {
383            start: 10.,
384            end: 400.,
385        }));
386        assert!(w.verify_resources().is_empty());
387        let part = w.subset(&BTreeSet::from([e]));
388        assert_eq!(part.resource::<Atmosphere>(), w.resource::<Atmosphere>());
389        assert_eq!(
390            w.resource::<Atmosphere>().unwrap().to_json().unwrap(),
391            Some(serde_json::json!({
392                "fog": {"colour": [0.5, 0.6000000238418579, 0.699999988079071],
393                        "falloff": {"Linear": {"start": 10.0, "end": 400.0}}},
394                "exposure": {"stops": -1.5},
395                "volumetric": null,
396                "auto_exposure": null, "tone_map": null
397            }))
398        );
399        assert_eq!(
400            Atmosphere::default().to_json().unwrap(),
401            Some(
402                serde_json::json!({"fog": null, "exposure": null, "volumetric": null, "auto_exposure": null, "tone_map": null})
403            )
404        );
405    }
406
407    #[test]
408    fn a_volumetric_fog_keeps_the_sources_fields_unconverted_and_lists_what_it_states() {
409        let volumetric = VolumetricFog {
410            game_scale: Some(12.),
411            fixed_density: Some(0.004),
412            sky_colour: Some([0.062745, 0.070588, 0.078431, 0.]),
413            directional_light_bank: Some(111010000),
414            ..Default::default()
415        };
416        let a = Atmosphere {
417            volumetric: Some(volumetric.clone()),
418            ..Default::default()
419        };
420        let mut w = World::new();
421        let e = w.spawn();
422        w.insert_resource(a);
423        assert!(w.verify_resources().is_empty());
424        assert_eq!(
425            w.subset(&BTreeSet::from([e])).resource::<Atmosphere>(),
426            w.resource::<Atmosphere>()
427        );
428        assert_eq!(
429            volumetric.fields(),
430            [
431                ("GameScale", "12".to_owned()),
432                ("Global FixedDensity", "0.004".to_owned()),
433                (
434                    "SkyLight SkyColor",
435                    "[0.062745, 0.070588, 0.078431, 0.0]".to_owned()
436                ),
437                ("DirLight LightBankId", "111010000".to_owned()),
438            ]
439        );
440        let json = w.resource::<Atmosphere>().unwrap().to_json().unwrap().unwrap();
441        assert_eq!(json["volumetric"]["game_scale"], serde_json::json!(12.0));
442        assert!(json["volumetric"]["far_depth"].is_null());
443        for bad in [
444            VolumetricFog {
445                fixed_density: Some(f32::NAN),
446                ..Default::default()
447            },
448            VolumetricFog {
449                sky_colour: Some([0., f32::INFINITY, 0., 0.]),
450                ..Default::default()
451            },
452        ] {
453            w.insert_resource(Atmosphere {
454                volumetric: Some(bad),
455                ..Default::default()
456            });
457            assert_eq!(w.verify_resources().len(), 1);
458        }
459    }
460
461    #[test]
462    fn values_out_of_range_are_refused_by_the_verifier() {
463        let mut w = World::new();
464        for (a, bad) in [
465            (
466                fog(FogFalloff::Exponential { density: -0.1 }),
467                "fog density -0.1 is not finite and at least 0",
468            ),
469            (
470                fog(FogFalloff::Exponential { density: f32::NAN }),
471                "fog density NaN is not finite and at least 0",
472            ),
473            (
474                fog(FogFalloff::Linear { start: 5., end: 5. }),
475                "linear fog from 5 to 5 is not finite, from at least 0 to a later end",
476            ),
477            (
478                fog(FogFalloff::Linear { start: -1., end: 5. }),
479                "linear fog from -1 to 5 is not finite, from at least 0 to a later end",
480            ),
481            (
482                Atmosphere {
483                    fog: Some(Fog {
484                        colour: [0.1, -0.1, 0.1],
485                        falloff: FogFalloff::Exponential { density: 0.01 },
486                    }),
487                    exposure: None,
488                    volumetric: None,
489                    auto_exposure: None,
490                    tone_map: None,
491                },
492                "fog colour [0.1, -0.1, 0.1] is not finite and at least 0",
493            ),
494            (
495                Atmosphere {
496                    fog: None,
497                    exposure: Some(Exposure { stops: f32::INFINITY }),
498                    volumetric: None,
499                    auto_exposure: None,
500                    tone_map: None,
501                },
502                "exposure inf stops is not finite",
503            ),
504        ] {
505            w.insert_resource(a);
506            assert_eq!(w.verify_resources(), [("core.atmosphere", bad.to_owned())]);
507        }
508        w.insert_resource(Atmosphere::default());
509        assert!(w.verify_resources().is_empty());
510    }
511
512    /// The game's own readback is the oracle: erK1 frame 42768 showed its scene at `2^3.6826` (cbToneMap
513    /// g_ToneMapSceneLumScale 12.8402), from the adapted value written four frames before (its readback ring), 0.3336
514    /// below middle grey, at the Tone Map above.
515    #[test]
516    fn the_exposure_of_a_frame_of_the_game_is_its_stops_less_the_soft_clamped_metering() {
517        let auto = noon();
518        let shown = auto.scale(auto.middle_grey - 0.3336).log2();
519        assert!((shown - 3.6826).abs() < 2e-4, "{shown}");
520        // darker than middle grey raises it, towards -min stops and never past them (in f32 it reaches them)
521        assert!(auto.correction(-0.3336) < 0.0);
522        let near = auto.correction(-3.0);
523        assert!(near > auto.min && near < auto.min + 0.05, "{near}");
524        let far = auto.correction(-50.0);
525        assert!(far >= auto.min && far - auto.min < 1e-6, "{far}");
526        // brighter lowers it towards max the same way
527        let near = auto.correction(3.0);
528        assert!(near < auto.max && near > auto.max - 0.05, "{near}");
529        let far = auto.correction(50.0);
530        assert!(far <= auto.max && auto.max - far < 1e-6, "{far}");
531        // slope 1 through middle grey, from both sides
532        for x in [1e-3f32, -1e-3] {
533            assert!((auto.correction(x) / x - 1.0).abs() < 3e-3, "{x}");
534        }
535        assert_eq!(auto.correction(0.0), 0.0);
536        // held: the stops alone
537        let frozen = AutoExposure {
538            frozen: true,
539            ..noon()
540        };
541        assert_eq!(frozen.scale(-20.0), 3.4042f32.exp2());
542        // a bound of 0: no correction that way
543        let flat = AutoExposure { max: 0.0, ..noon() };
544        assert_eq!(flat.correction(2.0), 0.0);
545    }
546
547    #[test]
548    fn the_adapted_value_moves_by_its_speed_and_rests_at_the_frames_metering() {
549        let auto = AutoExposure {
550            brighten_speed: 0.2,
551            darken_speed: 0.5,
552            ..noon()
553        };
554        // a still frame's metering is the fixed point
555        assert_eq!(auto.adapt(-6.0, -6.0, 1.0 / 30.0), -6.0);
556        // per second at speed 0.2 the fraction 1 - 10^-0.2 of the way up, at 0.5 the fraction 1 - 10^-0.5 down
557        let up = auto.adapt(-8.0, -6.0, 1.0);
558        assert!((up - (-8.0 + 2.0 * (1.0 - 0.630_957_3))).abs() < 1e-5, "{up}");
559        let down = auto.adapt(-6.0, -8.0, 1.0);
560        assert!((down - (-6.0 - 2.0 * (1.0 - 0.316_227_8))).abs() < 1e-5, "{down}");
561        // iterated it approaches the frame's metering and does not pass it
562        let mut a = -10.0f32;
563        for _ in 0..2000 {
564            let next = auto.adapt(a, -6.0, 1.0 / 30.0);
565            assert!(next >= a && next <= -6.0);
566            a = next;
567        }
568        assert!((a + 6.0).abs() < 1e-4, "{a}");
569    }
570
571    #[test]
572    fn the_meter_weighs_the_centre_most_and_samples_a_four_by_four_grid_per_cell() {
573        let meter = ExposureMeter::CentreWeightedLog2 { centre_weight: 7.0 };
574        assert_eq!(meter.weight(0.5, 0.5, 16.0 / 9.0), 1.0);
575        // the game's 1280 x 720 constants: (7, 3.9375) across the frame's two axes, so a cell a quarter of the way
576        // across weighs 1 - (1 - 2^-3.5)^3
577        let quarter = meter.weight(0.25, 0.5, 1280.0 / 720.0);
578        assert!(
579            (quarter - (1.0 - (1.0 - 2f32.powf(-3.5)).powi(3))).abs() < 1e-6,
580            "{quarter}"
581        );
582        let vertical = meter.weight(0.5, 0.25, 1280.0 / 720.0);
583        assert!(
584            (vertical - (1.0 - (1.0 - 2f32.powf(-3.9375 / 2.0)).powi(3))).abs() < 1e-6,
585            "{vertical}"
586        );
587        assert!(meter.weight(0.0, 0.0, 1.0) < 0.01);
588        let flat = ExposureMeter::CentreWeightedLog2 { centre_weight: 0.0 };
589        assert_eq!(flat.weight(0.0, 1.0, 1.0), 1.0);
590        // the game's cbToneMapSampleOffset of the 64 x 64 pass (frame 7773): +-0.005859, +-0.001953, x fastest
591        let o = ExposureMeter::SAMPLE_OFFSETS;
592        assert_eq!(o[0], [-1.5 / 256.0, -1.5 / 256.0]);
593        assert_eq!(o[1], [-0.5 / 256.0, -1.5 / 256.0]);
594        assert_eq!(o[15], [1.5 / 256.0, 1.5 / 256.0]);
595        assert!((o[0][0] + 0.005859).abs() < 1e-6 && (o[1][0] + 0.001953).abs() < 1e-6);
596    }
597
598    #[test]
599    fn an_auto_exposure_out_of_range_or_beside_an_exposure_is_refused() {
600        let mut w = World::new();
601        for (auto, exposure, bad) in [
602            (
603                AutoExposure { max: -0.1, ..noon() },
604                None,
605                "auto exposure max -0.1 is out of range",
606            ),
607            (
608                AutoExposure { min: 0.5, ..noon() },
609                None,
610                "auto exposure min 0.5 is out of range",
611            ),
612            (
613                AutoExposure {
614                    stops: f32::NAN,
615                    ..noon()
616                },
617                None,
618                "auto exposure stops NaN is out of range",
619            ),
620            (
621                AutoExposure {
622                    darken_speed: -1.0,
623                    ..noon()
624                },
625                None,
626                "auto exposure darken speed -1 is out of range",
627            ),
628            (
629                AutoExposure {
630                    meter: ExposureMeter::CentreWeightedLog2 { centre_weight: -1.0 },
631                    ..noon()
632                },
633                None,
634                "auto exposure centre weight -1 is out of range",
635            ),
636            (
637                noon(),
638                Some(Exposure { stops: 1.0 }),
639                "both an exposure and an auto exposure",
640            ),
641        ] {
642            w.insert_resource(Atmosphere {
643                exposure,
644                auto_exposure: Some(auto),
645                ..Default::default()
646            });
647            assert_eq!(w.verify_resources(), [("core.atmosphere", bad.to_owned())]);
648        }
649        w.insert_resource(Atmosphere {
650            auto_exposure: Some(noon()),
651            ..Default::default()
652        });
653        assert!(w.verify_resources().is_empty());
654        let json = w.resource::<Atmosphere>().unwrap().to_json().unwrap().unwrap();
655        assert_eq!(
656            json["auto_exposure"]["meter"]["CentreWeightedLog2"]["centre_weight"],
657            serde_json::json!(7.0)
658        );
659    }
660
661    #[test]
662    fn height_slab_uses_absolute_heights_including_below_the_origin() {
663        assert!(
664            fog(FogFalloff::ExponentialHeightSlab {
665                density: 0.01,
666                bottom: -40.,
667                top: 200.
668            })
669            .check()
670            .is_ok()
671        );
672        for (density, bottom, top) in [(0.01, 2., 2.), (-0.01, 0., 2.), (0.01, f32::NAN, 2.)] {
673            assert!(
674                fog(FogFalloff::ExponentialHeightSlab { density, bottom, top })
675                    .check()
676                    .is_err()
677            );
678        }
679    }
680}