Skip to main content

pfx_post/
view.rs

1#[derive(Clone, Copy, Debug, Default, PartialEq)]
2pub enum View {
3    #[default]
4    Standard,
5    Agx,
6}
7
8impl View {
9    pub fn parse(name: &str) -> Option<Self> {
10        match name {
11            "Standard" | "standard" => Some(Self::Standard),
12            "AgX" | "agx" => Some(Self::Agx),
13            _ => None,
14        }
15    }
16
17    pub fn display(self, linear: [f32; 3], exposure: f32) -> [f32; 3] {
18        let gain = exposure.exp2();
19        let c = linear.map(|v| v * gain);
20        match self {
21            Self::Standard => c.map(|v| srgb_encode(v.clamp(0.0, 1.0))),
22            Self::Agx => agx_base(c),
23        }
24    }
25}
26
27#[allow(clippy::excessive_precision)]
28const REC709_TO_EGAMUT: [[f32; 3]; 3] = [
29    [0.5593711138, 0.3047833443, 0.1358455569],
30    [0.0762207061, 0.7879717946, 0.1358074695],
31    [0.0655267090, 0.1645467579, 0.7699264884],
32];
33
34#[allow(clippy::excessive_precision)]
35const EGAMUT_TO_REC709: [[f32; 3]; 3] = [
36    [1.907248252, -0.692966607, -0.214281704],
37    [-0.16249786, 1.376655301, -0.214157407],
38    [-0.127592967, -0.235238491, 1.362831514],
39];
40
41#[allow(clippy::excessive_precision)]
42const INSET: [[f32; 3]; 3] = [
43    [1.097260907, -0.021517093, -0.075743814],
44    [0.067291392, 0.982423383, -0.049714776],
45    [0.043548643, -0.020188738, 0.976640096],
46];
47
48#[allow(clippy::excessive_precision)]
49const OUTSET: [[f32; 3]; 3] = [
50    [0.861870058, 0.041879752, 0.096250189],
51    [-0.029085198, 0.868039669, 0.161045529],
52    [-0.042304484, 0.010352563, 1.031951921],
53];
54
55const HUE_MIX: f32 = 0.645_491_36;
56const LOG2_MIN: f32 = -10.0;
57const LOG2_MAX: f32 = 6.5;
58const MIDDLE_GREY: f32 = 0.18;
59const SLOPE: f32 = 2.4;
60const POWER: f32 = 1.5;
61
62fn apply(m: [[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
63    std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2])
64}
65
66pub fn agx_curve(x: f32) -> f32 {
67    let pivot_x = -LOG2_MIN / (LOG2_MAX - LOG2_MIN);
68    let pivot_y = MIDDLE_GREY.powf(1.0 / 2.4);
69    let upper = x >= pivot_x;
70    let (a, b, t) = if upper {
71        (SLOPE * (1.0 - pivot_x), 1.0 - pivot_y, x - pivot_x)
72    } else {
73        (SLOPE * pivot_x, pivot_y, pivot_x - x)
74    };
75    let scale = a / ((a / b).powf(POWER) - 1.0).powf(1.0 / POWER);
76    let u = SLOPE * t / scale;
77    let h = u / (1.0 + u.powf(POWER)).powf(1.0 / POWER);
78    if upper {
79        pivot_y + scale * h
80    } else {
81        pivot_y - scale * h
82    }
83}
84
85fn hue(v: [f32; 3]) -> f32 {
86    let high = v[0].max(v[1]).max(v[2]);
87    let low = v[0].min(v[1]).min(v[2]);
88    let d = high - low + 1e-12;
89    let h = if high == v[0] {
90        ((v[1] - v[2]) / d).rem_euclid(6.0)
91    } else if high == v[1] {
92        (v[2] - v[0]) / d + 2.0
93    } else {
94        (v[0] - v[1]) / d + 4.0
95    };
96    h / 6.0
97}
98
99fn with_hue(v: [f32; 3], h: f32) -> [f32; 3] {
100    let high = v[0].max(v[1]).max(v[2]);
101    let low = v[0].min(v[1]).min(v[2]);
102    let h6 = h.rem_euclid(1.0) * 6.0;
103    let f = |n: f32| {
104        let k = (n + h6).rem_euclid(6.0);
105        high - (high - low) * k.min(4.0 - k).clamp(0.0, 1.0)
106    };
107    [f(5.0), f(3.0), f(1.0)]
108}
109
110pub fn agx_base(linear: [f32; 3]) -> [f32; 3] {
111    let e = apply(REC709_TO_EGAMUT, linear).map(|v| v.max(1e-10));
112    let inset = apply(INSET, e).map(|v| v.max(1e-10));
113    let formed = inset.map(|v| {
114        let x = ((v / MIDDLE_GREY).log2() - LOG2_MIN) / (LOG2_MAX - LOG2_MIN);
115        agx_curve(x.clamp(0.0, 1.0))
116    });
117    let start = hue(e);
118    let shift = (hue(formed) - start + 0.5).rem_euclid(1.0) - 0.5;
119    let formed = with_hue(formed, start + shift * HUE_MIX);
120    let display = apply(OUTSET, formed.map(|v| v.max(0.0).powf(2.4)));
121    apply(EGAMUT_TO_REC709, display).map(|v| srgb_encode(v.clamp(0.0, 1.0)))
122}
123
124pub use pfx_materials::{encode_channel as srgb_encode, linear_channel as srgb_decode};
125
126pub fn straight(texel: [f32; 4]) -> [f32; 4] {
127    let alpha = texel[3].clamp(0.0, 1.0);
128    if alpha <= 1.0 / 65535.0 {
129        return [0.0; 4];
130    }
131    [texel[0] / alpha, texel[1] / alpha, texel[2] / alpha, alpha]
132}
133
134pub fn finish(premultiplied: &[[f32; 4]], view: View, exposure: f32) -> Vec<[f32; 4]> {
135    premultiplied
136        .iter()
137        .map(|&texel| {
138            let s = straight(texel);
139            let [r, g, b] = view.display([s[0], s[1], s[2]], exposure);
140            [r, g, b, s[3]]
141        })
142        .collect()
143}
144
145pub fn master16(display: &[[f32; 4]]) -> Vec<[u16; 4]> {
146    display
147        .iter()
148        .map(|texel| texel.map(|v| (v.clamp(0.0, 1.0) * 65535.0).round() as u16))
149        .collect()
150}
151
152pub fn delivery8(display: &[[f32; 4]], width: u32) -> Vec<[u8; 4]> {
153    display
154        .iter()
155        .enumerate()
156        .map(|(i, texel)| {
157            let x = i as u32 % width.max(1);
158            let y = i as u32 / width.max(1);
159            let dither = (crate::hash::bayer(x, y) - 0.5) / 255.0;
160            let mut out = [0u8; 4];
161            for (k, v) in texel.iter().enumerate() {
162                let d = if k < 3 { dither } else { 0.0 };
163                out[k] = ((v + d).clamp(0.0, 1.0) * 255.0).round() as u8;
164            }
165            out
166        })
167        .collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[allow(clippy::type_complexity)]
175    const BLENDER_AGX: [([f32; 3], [f32; 3]); 31] = [
176        ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]),
177        ([0.0005, 0.0005, 0.0005], [0.000732, 0.000732, 0.000733]),
178        ([0.002, 0.002, 0.002], [0.010077, 0.010077, 0.010079]),
179        ([0.01, 0.01, 0.01], [0.072499, 0.072498, 0.072509]),
180        ([0.03, 0.03, 0.03], [0.164741, 0.16474, 0.164758]),
181        ([0.08, 0.08, 0.08], [0.29982, 0.299819, 0.299848]),
182        ([0.18, 0.18, 0.18], [0.46132, 0.461318, 0.461354]),
183        ([0.35, 0.35, 0.35], [0.600012, 0.60001, 0.600042]),
184        ([0.6, 0.6, 0.6], [0.695528, 0.695527, 0.695554]),
185        ([1.0, 1.0, 1.0], [0.770958, 0.770958, 0.77098]),
186        ([2.0, 2.0, 2.0], [0.851784, 0.851782, 0.8518]),
187        ([4.0, 4.0, 4.0], [0.913671, 0.91367, 0.913683]),
188        ([8.0, 8.0, 8.0], [0.961562, 0.961561, 0.961572]),
189        ([16.0, 16.0, 16.0], [0.998549, 0.998549, 0.99855]),
190        ([64.0, 64.0, 64.0], [1.000017, 1.000017, 1.000017]),
191        ([0.8, 0.1, 0.1], [0.789464, 0.401136, 0.375474]),
192        ([0.1, 0.8, 0.1], [0.483805, 0.735357, 0.429935]),
193        ([0.1, 0.1, 0.8], [0.368879, 0.444189, 0.773296]),
194        ([0.9, 0.6, 0.2], [0.767627, 0.680943, 0.536122]),
195        ([0.2, 0.5, 0.7], [0.537109, 0.66711, 0.725383]),
196        ([0.5, 0.5, 0.1], [0.671588, 0.651082, 0.403987]),
197        ([0.6, 0.2, 0.6], [0.696516, 0.540479, 0.701796]),
198        ([0.02, 0.03, 0.05], [0.122676, 0.17071, 0.225231]),
199        ([3.0, 1.0, 0.3], [0.921538, 0.760323, 0.665898]),
200        ([0.3, 1.2, 4.0], [0.710663, 0.80541, 0.934785]),
201        ([1.0, 0.0, 0.0], [0.857989, 0.224088, 0.129189]),
202        ([0.0, 1.0, 0.0], [0.432454, 0.772627, 0.318423]),
203        ([0.0, 0.0, 1.0], [0.079088, 0.3376, 0.821487]),
204        ([0.7, 0.72, 0.75], [0.719741, 0.724708, 0.730622]),
205        ([0.04, 0.2, 0.04], [0.224339, 0.463853, 0.196446]),
206        ([12.0, 6.0, 2.0], [0.993931, 0.936061, 0.885321]),
207    ];
208
209    #[test]
210    fn agx_matches_blenders_agx_base() {
211        for (input, want) in BLENDER_AGX {
212            let got = agx_base(input);
213            let grey = input[0] == input[1] && input[1] == input[2];
214            let primary = input.iter().filter(|&&v| v == 0.0).count() == 2;
215            let tolerance = if grey {
216                0.001
217            } else if primary {
218                0.045
219            } else {
220                0.008
221            };
222            for k in 0..3 {
223                assert!(
224                    (got[k] - want[k]).abs() <= tolerance,
225                    "{input:?}: {got:?} against Blender's {want:?}"
226                );
227            }
228        }
229    }
230
231    #[test]
232    fn the_curve_holds_middle_grey_and_its_ends() {
233        let pivot = 10.0 / 16.5;
234        assert!((agx_curve(pivot) - 0.18f32.powf(1.0 / 2.4)).abs() < 1e-6);
235        assert!(agx_curve(0.0).abs() < 1e-5);
236        assert!((agx_curve(1.0) - 1.0).abs() < 1e-5);
237        let mut last = -1.0;
238        for i in 0..=100 {
239            let y = agx_curve(i as f32 / 100.0);
240            assert!(y >= last);
241            last = y;
242        }
243    }
244
245    #[test]
246    fn standard_is_srgb_and_exposure_doubles() {
247        assert_eq!(View::Standard.display([0.0; 3], 0.0), [0.0; 3]);
248        assert!((View::Standard.display([0.18; 3], 0.0)[0] - 0.461_356).abs() < 1e-4);
249        assert!((View::Standard.display([0.09; 3], 1.0)[1] - 0.461_356).abs() < 1e-4);
250        assert!(
251            View::Standard
252                .display([4.0; 3], 0.0)
253                .iter()
254                .all(|v| (v - 1.0).abs() < 1e-6)
255        );
256        assert!((srgb_decode(srgb_encode(0.3)) - 0.3).abs() < 1e-6);
257        assert_eq!(View::parse("AgX"), Some(View::Agx));
258        assert_eq!(View::parse("Filmic"), None);
259    }
260
261    #[test]
262    fn finishing_gives_straight_alpha() {
263        let out = finish(&[[0.09, 0.09, 0.09, 0.5], [0.0; 4]], View::Standard, 0.0);
264        assert!((out[0][0] - 0.461_356).abs() < 1e-4);
265        assert_eq!(out[0][3], 0.5);
266        assert_eq!(out[1], [0.0; 4]);
267        let master = master16(&out);
268        assert_eq!(master[0][3], 32768);
269        let delivery = delivery8(&[[0.5, 0.5, 0.5, 1.0]; 64], 8);
270        assert!(
271            delivery
272                .iter()
273                .all(|t| t[3] == 255 && (127..=129).contains(&t[0]))
274        );
275        let mean = delivery.iter().map(|t| f32::from(t[0])).sum::<f32>() / 64.0;
276        assert!((mean - 127.5).abs() < 0.6, "{mean}");
277    }
278}