Skip to main content

proof_engine/render/postfx/
chromatic.rs

1//! Chromatic aberration pass — RGB channel spatial offset.
2//!
3//! Splits the R, G, B channels and samples them at slightly offset UV coordinates,
4//! creating the color fringing effect seen in cheap lenses, corrupted displays,
5//! and high-trauma screen shake. The offset direction is radial from the screen center.
6//!
7//! Scales with trauma, entropy fields, and Chaos Rift proximity.
8
9/// Chromatic aberration pass parameters.
10#[derive(Clone, Debug)]
11pub struct ChromaticParams {
12    pub enabled: bool,
13    /// Radial offset amount in UV units for the red channel (0.002 = subtle).
14    pub red_offset: f32,
15    /// Radial offset for the blue channel (usually slightly more than red).
16    pub blue_offset: f32,
17    /// Green channel offset (usually 0 — green is the reference channel).
18    pub green_offset: f32,
19    /// Whether to scale the offset with distance from screen center (true = realistic lens).
20    pub radial_scale: bool,
21    /// Direction distortion: 0.0 = purely radial, 1.0 = tangential (rotational aberration).
22    pub tangential: f32,
23    /// Fringe color mixing: 0.0 = clean R/G/B, 1.0 = smeared spectrum.
24    pub spectrum_spread: f32,
25    /// Barrel distortion applied before chromatic split (0.0 = none, 0.1 = visible).
26    pub barrel_distortion: f32,
27}
28
29impl Default for ChromaticParams {
30    fn default() -> Self {
31        Self {
32            enabled:           true,
33            red_offset:        0.002,
34            blue_offset:       0.003,
35            green_offset:      0.0,
36            radial_scale:      true,
37            tangential:        0.0,
38            spectrum_spread:   0.0,
39            barrel_distortion: 0.0,
40        }
41    }
42}
43
44impl ChromaticParams {
45    /// Disabled chromatic aberration.
46    pub fn none() -> Self { Self { enabled: false, ..Default::default() } }
47
48    /// Subtle lens fringing (high quality glass).
49    pub fn subtle() -> Self {
50        Self {
51            enabled: true,
52            red_offset: 0.001, blue_offset: 0.0015, green_offset: 0.0,
53            radial_scale: true, tangential: 0.0, spectrum_spread: 0.0,
54            barrel_distortion: 0.0,
55        }
56    }
57
58    /// Cheap plastic lens (pronounced aberration at edges).
59    pub fn cheap_lens() -> Self {
60        Self {
61            enabled: true,
62            red_offset: 0.006, blue_offset: 0.008, green_offset: 0.0,
63            radial_scale: true, tangential: 0.15, spectrum_spread: 0.3,
64            barrel_distortion: 0.04,
65        }
66    }
67
68    /// Glitch / digital corruption (non-radial, strong).
69    pub fn glitch() -> Self {
70        Self {
71            enabled: true,
72            red_offset: 0.015, blue_offset: 0.012, green_offset: 0.005,
73            radial_scale: false, tangential: 0.5, spectrum_spread: 0.7,
74            barrel_distortion: 0.0,
75        }
76    }
77
78    /// Chaos Rift proximity effect — scales with entropy [0, 1].
79    pub fn chaos_rift(entropy: f32) -> Self {
80        let s = entropy.clamp(0.0, 1.0);
81        Self {
82            enabled: s > 0.01,
83            red_offset:  0.002 + s * 0.02,
84            blue_offset: 0.003 + s * 0.025,
85            green_offset: s * 0.005,
86            radial_scale: true,
87            tangential: s * 0.4,
88            spectrum_spread: s * 0.6,
89            barrel_distortion: s * 0.08,
90        }
91    }
92
93    /// Trauma shake chromatic (scales with camera trauma [0, 1]).
94    pub fn trauma_shake(trauma: f32) -> Self {
95        let t = (trauma * trauma).clamp(0.0, 1.0);  // quadratic for natural falloff
96        Self {
97            enabled: t > 0.01,
98            red_offset:  t * 0.012,
99            blue_offset: t * 0.015,
100            green_offset: 0.0,
101            radial_scale: true,
102            tangential: 0.0,
103            spectrum_spread: t * 0.2,
104            barrel_distortion: 0.0,
105        }
106    }
107
108    /// Lerp between two chromatic configs.
109    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
110        let t = t.clamp(0.0, 1.0);
111        Self {
112            enabled:           if t < 0.5 { a.enabled } else { b.enabled },
113            red_offset:        lerp_f32(a.red_offset,        b.red_offset,        t),
114            blue_offset:       lerp_f32(a.blue_offset,       b.blue_offset,       t),
115            green_offset:      lerp_f32(a.green_offset,      b.green_offset,      t),
116            radial_scale:      a.radial_scale,
117            tangential:        lerp_f32(a.tangential,        b.tangential,        t),
118            spectrum_spread:   lerp_f32(a.spectrum_spread,   b.spectrum_spread,   t),
119            barrel_distortion: lerp_f32(a.barrel_distortion, b.barrel_distortion, t),
120        }
121    }
122
123    /// CPU preview: compute the UV offset for each channel at a given screen UV.
124    ///
125    /// Returns (r_uv, g_uv, b_uv) — sample the texture at each of these UVs for the
126    /// corresponding channel.
127    /// `uv` is in [0, 1], center is (0.5, 0.5).
128    pub fn channel_uvs(&self, uv: [f32; 2]) -> ([f32; 2], [f32; 2], [f32; 2]) {
129        if !self.enabled {
130            return (uv, uv, uv);
131        }
132
133        let cx = uv[0] - 0.5;
134        let cy = uv[1] - 0.5;
135        let radial_dist = (cx * cx + cy * cy).sqrt();
136
137        // Radial direction (outward from center)
138        let (rx, ry) = if radial_dist > 0.0001 {
139            (cx / radial_dist, cy / radial_dist)
140        } else {
141            (0.0, 0.0)
142        };
143
144        // Tangential direction (perpendicular, clockwise)
145        let (tx, ty) = (-ry, rx);
146
147        // Scale factor
148        let scale = if self.radial_scale { radial_dist * 2.0 } else { 1.0 };
149
150        // Apply barrel distortion
151        let barrel_r = self.barrel_distortion;
152        let barrel_factor = |u: f32, v: f32| -> [f32; 2] {
153            let dx = u - 0.5;
154            let dy = v - 0.5;
155            let r2 = dx * dx + dy * dy;
156            let bd = 1.0 + barrel_r * r2;
157            [0.5 + dx * bd, 0.5 + dy * bd]
158        };
159
160        let offset_uv = |channel_offset: f32| -> [f32; 2] {
161            let radial_component = channel_offset * scale;
162            let tang_component = channel_offset * self.tangential * scale;
163            let ou = cx + (rx * radial_component + tx * tang_component);
164            let ov = cy + (ry * radial_component + ty * tang_component);
165            let base = [0.5 + ou, 0.5 + ov];
166            barrel_factor(base[0], base[1])
167        };
168
169        (
170            offset_uv( self.red_offset),
171            offset_uv( self.green_offset),
172            offset_uv(-self.blue_offset),  // blue shifts opposite to red
173        )
174    }
175}
176
177fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
178
179// ── Tests ─────────────────────────────────────────────────────────────────────
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn disabled_returns_same_uv() {
187        let params = ChromaticParams::none();
188        let uv = [0.3, 0.7];
189        let (r, g, b) = params.channel_uvs(uv);
190        assert_eq!(r, uv);
191        assert_eq!(g, uv);
192        assert_eq!(b, uv);
193    }
194
195    #[test]
196    fn enabled_offsets_channels_differently() {
197        let params = ChromaticParams::cheap_lens();
198        let uv = [0.8, 0.5];  // Right side of screen (non-zero radial)
199        let (r, g, b) = params.channel_uvs(uv);
200        // R and B should be at different positions
201        assert!((r[0] - b[0]).abs() > 0.001, "R and B should differ at r={:?} b={:?}", r, b);
202    }
203
204    #[test]
205    fn center_pixel_has_zero_offset() {
206        let params = ChromaticParams::cheap_lens();
207        let uv = [0.5, 0.5];  // exact center
208        let (r, g, b) = params.channel_uvs(uv);
209        // At center, radial direction is zero → no offset
210        assert!((r[0] - 0.5).abs() < 0.001);
211        assert!((b[0] - 0.5).abs() < 0.001);
212        let _ = g;
213    }
214
215    #[test]
216    fn chaos_rift_scales_with_entropy() {
217        let low  = ChromaticParams::chaos_rift(0.1);
218        let high = ChromaticParams::chaos_rift(0.9);
219        assert!(high.red_offset > low.red_offset);
220    }
221
222    #[test]
223    fn trauma_quadratic_at_zero_is_disabled() {
224        let params = ChromaticParams::trauma_shake(0.0);
225        assert!(!params.enabled);
226    }
227}