Skip to main content

primase_core/atmosphere/
post.rs

1//! A tabled tone curve, source-ordered colour correction and a three-dimensional colour lookup.
2use serde::Serialize;
3
4/// Display processing after exposure and bloom. Values describe operations, independent of a source's settings files.
5#[derive(Clone, Debug, PartialEq, Serialize)]
6pub struct ToneMap {
7    pub curve: ReinhardCurve,
8    /// A source-stated transform before or after the curve. None skips this stage.
9    pub colour_matrix: Option<ColourMatrix>,
10    /// At screen coordinate (u,v), r = length((2uv-1) * xy), t = 1-clamp(r*z+w,0,1).
11    /// Multiply RGB by (t*t*(3-2*t))^4 before the lookup. None skips this stage.
12    pub vignette: Option<[f32; 4]>,
13    /// None skips the lookup and leaves the curve/matrix/vignette result as linear RGB. A source must separately
14    /// report any stage it cannot establish; None is not a claim that its renderer uses an identity lookup.
15    pub lut: Option<ColourLut>,
16}
17
18/// Three rows applied to (R, G, B, 1), with negative results clamped to zero. Placement matters: a tone curve is
19/// nonlinear, so a colour transform of HDR before the curve is a different operation from a transform after it.
20#[derive(Clone, Debug, PartialEq, Serialize)]
21pub enum ColourMatrix {
22    /// Transform exposed HDR before forming the tone-table coordinates.
23    BeforeCurve([[f32; 4]; 3]),
24    /// Transform the tone-table result, before vignette and lookup.
25    AfterCurve([[f32; 4]; 3]),
26}
27
28impl ColourMatrix {
29    /// The transform's three rows, independent of its placement.
30    pub fn rows(&self) -> &[[f32; 4]; 3] {
31        match self {
32            Self::BeforeCurve(rows) | Self::AfterCurve(rows) => rows,
33        }
34    }
35}
36
37/// A 1024-entry R16F curve: at texel i, u=(i+.5)/1024, z=(.2*u/(1-u)*white_balance)^contrast,
38/// output=(z/(1+z))^(1/gamma). Positive values are quantized towards zero to binary16.
39/// HDR is sampled through the table at HDR/(HDR+.2), linearly with edge clamping.
40#[derive(Clone, Debug, PartialEq, Serialize)]
41pub struct ReinhardCurve {
42    pub contrast: f32,
43    pub white_balance: f32,
44    pub gamma: f32,
45}
46
47impl ReinhardCurve {
48    /// Evaluate the 1024 texel-centre samples as binary16 words, quantized towards zero.
49    /// Uses the documented table arithmetic in binary32, including logarithm/exponent powers.
50    pub fn table(&self) -> [u16; 1024] {
51        std::array::from_fn(|i| {
52            let u = (i as f32 + 0.5) / 1024.;
53            let x = u * 0.2 / (1. - u) * self.white_balance;
54            let z = (x.log2() * self.contrast).exp2();
55            let mapped = z / (z + 1.);
56            let value = (mapped.log2() * (1. / self.gamma)).exp2();
57            let rounded = half::f16::from_f32(value);
58            rounded.to_bits() - u16::from(rounded.to_f32() > value)
59        })
60    }
61}
62
63/// A cubic RGB lookup, red fastest, then green, then blue. Before lookup, raise RGB to `input_power`;
64/// sample with trilinear filtering at texel centres, clamping to the cube's edges. The values and filtered result are
65/// sRGB-encoded display RGB. Decode after filtering when writing to a target that encodes sRGB on write.
66#[derive(Clone, Debug, PartialEq, Serialize)]
67pub struct ColourLut {
68    pub side: u32,
69    pub values: Vec<[f32; 3]>,
70    pub input_power: f32,
71}
72
73impl ToneMap {
74    pub(super) fn check(&self) -> Result<(), String> {
75        for (name, value) in [
76            ("contrast", self.curve.contrast),
77            ("white balance", self.curve.white_balance),
78            ("gamma", self.curve.gamma),
79        ] {
80            if !(value.is_finite() && value > 0.) {
81                return Err(format!("tone map {name} {value} must be finite and positive"));
82            }
83        }
84        if !self
85            .colour_matrix
86            .iter()
87            .flat_map(|matrix| matrix.rows())
88            .flatten()
89            .chain(self.vignette.iter().flatten())
90            .all(|x| x.is_finite())
91        {
92            return Err("tone map colour matrix or vignette is not finite".into());
93        }
94        let Some(lut) = &self.lut else { return Ok(()) };
95        if !(lut.input_power.is_finite() && lut.input_power > 0.) {
96            return Err("tone map LUT input power must be finite and positive".into());
97        }
98        let count = u64::from(lut.side).checked_pow(3);
99        if lut.side < 2 || count != Some(lut.values.len() as u64) {
100            return Err("tone map LUT requires side >= 2 and side cubed RGB values".into());
101        }
102        if !lut.values.iter().flatten().all(|x| x.is_finite()) {
103            return Err("tone map LUT contains a nonfinite value".into());
104        }
105        Ok(())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::{Atmosphere, World};
113
114    #[test]
115    fn display_processing_survives_world_serialization_and_rejects_invalid_lut_sizes() {
116        let tone = ToneMap {
117            curve: ReinhardCurve {
118                contrast: 1.45,
119                white_balance: 1.7,
120                gamma: 1.,
121            },
122            colour_matrix: Some(ColourMatrix::AfterCurve([
123                [1., 0., 0., 0.],
124                [0., 1., 0., 0.],
125                [0., 0., 1., 0.],
126            ])),
127            vignette: Some([0.; 4]),
128            lut: Some(ColourLut {
129                side: 2,
130                values: vec![[0.; 3]; 8],
131                input_power: 1. / 2.2,
132            }),
133        };
134        let mut world = World::new();
135        world.insert_resource(Atmosphere {
136            tone_map: Some(tone.clone()),
137            ..Default::default()
138        });
139        assert!(world.verify_resources().is_empty());
140        use crate::Resource;
141        let json = world
142            .resource::<Atmosphere>()
143            .unwrap()
144            .to_json()
145            .unwrap()
146            .unwrap();
147        assert_eq!(json["tone_map"]["lut"]["side"], 2);
148        assert!(json["tone_map"]["colour_matrix"]["AfterCurve"].is_array());
149        let mut before = tone.clone();
150        before.colour_matrix = Some(ColourMatrix::BeforeCurve(
151            *tone.colour_matrix.as_ref().unwrap().rows(),
152        ));
153        assert!(before.check().is_ok());
154        assert!(serde_json::to_value(&before).unwrap()["colour_matrix"]["BeforeCurve"].is_array());
155        if let Some(ColourMatrix::BeforeCurve(rows)) = &mut before.colour_matrix {
156            rows[0][0] = f32::NAN;
157        }
158        assert!(before.check().unwrap_err().contains("matrix"));
159        let mut invalid = tone.clone();
160        invalid.lut.as_mut().unwrap().values.pop();
161        assert_eq!(
162            invalid.check().unwrap_err(),
163            "tone map LUT requires side >= 2 and side cubed RGB values"
164        );
165        let mut invalid = tone;
166        invalid.curve.gamma = 0.;
167        assert!(invalid.check().unwrap_err().contains("gamma"));
168    }
169
170    #[test]
171    fn independent_absent_stages_are_preserved_without_identity_data() {
172        let tone = ToneMap {
173            curve: ReinhardCurve {
174                contrast: 1.45,
175                white_balance: 1.7,
176                gamma: 1.,
177            },
178            colour_matrix: None,
179            vignette: None,
180            lut: None,
181        };
182        assert!(tone.check().is_ok());
183        let json = serde_json::to_value(&tone).unwrap();
184        for stage in ["colour_matrix", "vignette", "lut"] {
185            assert!(json[stage].is_null(), "{stage}");
186        }
187        let mut invalid = tone;
188        invalid.vignette = Some([f32::NAN; 4]);
189        assert!(invalid.check().is_err());
190    }
191}