Skip to main content

proof_engine/render/postfx/
scanlines.rs

1//! CRT scanline overlay — darkened horizontal lines mimicking a CRT phosphor display.
2//!
3//! Scanlines create the illusion of a retro CRT monitor by darkening every other
4//! (or N-th) row of pixels. Additional effects like vertical sync wobble and
5//! phosphor persistence can be emulated.
6
7/// CRT scanline pass parameters.
8#[derive(Clone, Debug)]
9pub struct ScanlineParams {
10    pub enabled: bool,
11    /// Brightness reduction for scanline pixels (0.0 = no effect, 0.5 = half brightness).
12    pub intensity: f32,
13    /// How many pixels per scanline period (1.0 = every other pixel, 2.0 = every 2 pixels).
14    pub line_width: f32,
15    /// Number of screen lines between darkened scanlines (1 = every other, 2 = every 3rd).
16    pub spacing: u32,
17    /// Scanline orientation: true = horizontal (CRT rows), false = vertical (rotated CRT).
18    pub horizontal: bool,
19    /// Vertical sync wobble amplitude in pixels (0.0 = none, simulates V-sync instability).
20    pub vsync_wobble: f32,
21    /// Phosphor persistence: darkened areas slightly glow after scan (0.0 = none, 1.0 = strong).
22    pub persistence: f32,
23    /// Curvature of the scanline intensity: 0.0 = sharp, 1.0 = smooth gradient.
24    pub smoothness: f32,
25    /// Scanline color tint (for color CRT monitors, slight green/cyan tint).
26    pub tint: [f32; 3],
27}
28
29impl Default for ScanlineParams {
30    fn default() -> Self {
31        Self {
32            enabled:      false,
33            intensity:    0.05,
34            line_width:   1.0,
35            spacing:      1,
36            horizontal:   true,
37            vsync_wobble: 0.0,
38            persistence:  0.0,
39            smoothness:   0.5,
40            tint:         [1.0, 1.0, 1.0],
41        }
42    }
43}
44
45impl ScanlineParams {
46    /// Disabled (no scanlines).
47    pub fn none() -> Self { Self::default() }
48
49    /// Subtle scanlines (barely visible, just adds texture).
50    pub fn subtle() -> Self {
51        Self {
52            enabled:   true,
53            intensity: 0.05,
54            line_width: 1.0,
55            spacing:   1,
56            smoothness: 0.7,
57            ..Default::default()
58        }
59    }
60
61    /// Classic arcade CRT (strong scanlines, slight green tint, minimal wobble).
62    pub fn arcade() -> Self {
63        Self {
64            enabled:   true,
65            intensity: 0.25,
66            line_width: 1.0,
67            spacing:   1,
68            smoothness: 0.3,
69            tint:      [0.9, 1.0, 0.85],  // slight phosphor green
70            ..Default::default()
71        }
72    }
73
74    /// Damaged CRT (heavy scanlines, V-sync wobble, strong persistence).
75    pub fn damaged() -> Self {
76        Self {
77            enabled:      true,
78            intensity:    0.45,
79            line_width:   1.5,
80            spacing:      1,
81            vsync_wobble: 2.5,
82            persistence:  0.4,
83            smoothness:   0.2,
84            tint:         [0.8, 0.9, 0.8],
85            ..Default::default()
86        }
87    }
88
89    /// Wide-spaced scanlines for a lo-fi effect.
90    pub fn lofi() -> Self {
91        Self {
92            enabled:   true,
93            intensity: 0.35,
94            line_width: 2.0,
95            spacing:   2,
96            smoothness: 0.1,
97            ..Default::default()
98        }
99    }
100
101    /// Lerp between two scanline configs.
102    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
103        let t = t.clamp(0.0, 1.0);
104        Self {
105            enabled:      if t < 0.5 { a.enabled } else { b.enabled },
106            intensity:    lerp_f32(a.intensity,    b.intensity,    t),
107            line_width:   lerp_f32(a.line_width,   b.line_width,   t),
108            spacing:      if t < 0.5 { a.spacing } else { b.spacing },
109            horizontal:   a.horizontal,
110            vsync_wobble: lerp_f32(a.vsync_wobble, b.vsync_wobble, t),
111            persistence:  lerp_f32(a.persistence,  b.persistence,  t),
112            smoothness:   lerp_f32(a.smoothness,   b.smoothness,   t),
113            tint: [
114                lerp_f32(a.tint[0], b.tint[0], t),
115                lerp_f32(a.tint[1], b.tint[1], t),
116                lerp_f32(a.tint[2], b.tint[2], t),
117            ],
118        }
119    }
120
121    /// CPU preview: evaluate the scanline dimming factor for a pixel at screen Y position.
122    ///
123    /// Returns a multiplier in [0, 1] — multiply pixel brightness by this value.
124    /// `pixel_y` is the pixel row (0 = top), `screen_height` is total height.
125    /// `time` drives V-sync wobble animation.
126    pub fn evaluate(&self, pixel_y: f32, screen_height: f32, time: f32) -> f32 {
127        if !self.enabled { return 1.0; }
128
129        let mut y = pixel_y;
130
131        // V-sync wobble: sine-wave vertical offset
132        if self.vsync_wobble > 0.0 {
133            y += (time * 60.0).sin() * self.vsync_wobble;
134        }
135
136        // Normalize position within a scanline period
137        let period = (self.spacing as f32 + 1.0) * self.line_width;
138        let phase = (y / period).fract();
139
140        // Scanline darkening: phase near 0.5 gets darkened
141        let darkened = if self.smoothness > 0.0 {
142            // Smooth: use a cosine dip
143            let dip = (phase * std::f32::consts::TAU).cos() * 0.5 + 0.5;
144            let alpha = self.smoothness;
145            dip * alpha + (1.0 - alpha) * (if phase < 0.5 { 1.0 } else { 0.0 })
146        } else {
147            if phase < 0.5 { 0.0 } else { 1.0 }
148        };
149
150        1.0 - darkened * self.intensity
151    }
152}
153
154fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
155
156// ── Scanline pattern generator ─────────────────────────────────────────────────
157
158/// Generates a 1D scanline pattern texture for GPU upload.
159///
160/// Returns a Vec of f32 values (one per row), normalized to [0, 1].
161/// Upload as a 1D texture sampled by the fragment shader.
162pub fn generate_scanline_lut(height: u32, params: &ScanlineParams) -> Vec<f32> {
163    (0..height)
164        .map(|y| params.evaluate(y as f32, height as f32, 0.0))
165        .collect()
166}
167
168// ── Tests ─────────────────────────────────────────────────────────────────────
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn disabled_returns_one() {
176        let params = ScanlineParams::none();
177        assert_eq!(params.evaluate(5.0, 100.0, 0.0), 1.0);
178    }
179
180    #[test]
181    fn enabled_dims_some_pixels() {
182        let params = ScanlineParams::arcade();
183        let values: Vec<f32> = (0..20).map(|y| params.evaluate(y as f32, 100.0, 0.0)).collect();
184        // Some pixels should be dimmed (< 1.0)
185        let any_dimmed = values.iter().any(|&v| v < 0.99);
186        assert!(any_dimmed, "Expected some pixels to be dimmed");
187    }
188
189    #[test]
190    fn lut_has_correct_length() {
191        let params = ScanlineParams::subtle();
192        let lut = generate_scanline_lut(256, &params);
193        assert_eq!(lut.len(), 256);
194    }
195
196    #[test]
197    fn all_values_in_range() {
198        let params = ScanlineParams::damaged();
199        let lut = generate_scanline_lut(480, &params);
200        for v in &lut {
201            assert!(*v >= 0.0 && *v <= 1.0, "Out of range: {v}");
202        }
203    }
204
205    #[test]
206    fn lerp_halfway() {
207        let a = ScanlineParams::none();
208        let b = ScanlineParams { enabled: true, intensity: 0.4, ..Default::default() };
209        let mid = ScanlineParams::lerp(&a, &b, 0.5);
210        assert!((mid.intensity - 0.2).abs() < 0.001);
211    }
212}