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}