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    /// No scanlines: disabled and at zero intensity, so blending from
48    /// `none()` fades in from nothing (it used to start at the default 0.05).
49    pub fn none() -> Self { Self { enabled: false, intensity: 0.0, ..Default::default() } }
50
51    /// Subtle scanlines (barely visible, just adds texture).
52    pub fn subtle() -> Self {
53        Self {
54            enabled:   true,
55            intensity: 0.05,
56            line_width: 1.0,
57            spacing:   1,
58            smoothness: 0.7,
59            ..Default::default()
60        }
61    }
62
63    /// Classic arcade CRT (strong scanlines, slight green tint, minimal wobble).
64    pub fn arcade() -> Self {
65        Self {
66            enabled:   true,
67            intensity: 0.25,
68            line_width: 1.0,
69            spacing:   1,
70            smoothness: 0.3,
71            tint:      [0.9, 1.0, 0.85],  // slight phosphor green
72            ..Default::default()
73        }
74    }
75
76    /// Damaged CRT (heavy scanlines, V-sync wobble, strong persistence).
77    pub fn damaged() -> Self {
78        Self {
79            enabled:      true,
80            intensity:    0.45,
81            line_width:   1.5,
82            spacing:      1,
83            vsync_wobble: 2.5,
84            persistence:  0.4,
85            smoothness:   0.2,
86            tint:         [0.8, 0.9, 0.8],
87            ..Default::default()
88        }
89    }
90
91    /// Wide-spaced scanlines for a lo-fi effect.
92    pub fn lofi() -> Self {
93        Self {
94            enabled:   true,
95            intensity: 0.35,
96            line_width: 2.0,
97            spacing:   2,
98            smoothness: 0.1,
99            ..Default::default()
100        }
101    }
102
103    /// Lerp between two scanline configs.
104    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
105        let t = t.clamp(0.0, 1.0);
106        Self {
107            enabled:      if t < 0.5 { a.enabled } else { b.enabled },
108            intensity:    lerp_f32(a.intensity,    b.intensity,    t),
109            line_width:   lerp_f32(a.line_width,   b.line_width,   t),
110            spacing:      if t < 0.5 { a.spacing } else { b.spacing },
111            horizontal:   a.horizontal,
112            vsync_wobble: lerp_f32(a.vsync_wobble, b.vsync_wobble, t),
113            persistence:  lerp_f32(a.persistence,  b.persistence,  t),
114            smoothness:   lerp_f32(a.smoothness,   b.smoothness,   t),
115            tint: [
116                lerp_f32(a.tint[0], b.tint[0], t),
117                lerp_f32(a.tint[1], b.tint[1], t),
118                lerp_f32(a.tint[2], b.tint[2], t),
119            ],
120        }
121    }
122
123    /// CPU preview: evaluate the scanline dimming factor for a pixel at screen Y position.
124    ///
125    /// Returns a multiplier in [0, 1] — multiply pixel brightness by this value.
126    /// `pixel_y` is the pixel row (0 = top), `screen_height` is total height.
127    /// `time` drives V-sync wobble animation.
128    pub fn evaluate(&self, pixel_y: f32, screen_height: f32, time: f32) -> f32 {
129        if !self.enabled { return 1.0; }
130
131        let mut y = pixel_y;
132
133        // V-sync wobble: sine-wave vertical offset
134        if self.vsync_wobble > 0.0 {
135            y += (time * 60.0).sin() * self.vsync_wobble;
136        }
137
138        // Normalize position within a scanline period
139        let period = (self.spacing as f32 + 1.0) * self.line_width;
140        let phase = (y / period).fract();
141
142        // Scanline darkening: phase near 0.5 gets darkened
143        let darkened = if self.smoothness > 0.0 {
144            // Smooth: use a cosine dip
145            let dip = (phase * std::f32::consts::TAU).cos() * 0.5 + 0.5;
146            let alpha = self.smoothness;
147            dip * alpha + (1.0 - alpha) * (if phase < 0.5 { 1.0 } else { 0.0 })
148        } else {
149            if phase < 0.5 { 0.0 } else { 1.0 }
150        };
151
152        1.0 - darkened * self.intensity
153    }
154}
155
156fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
157
158// ── Scanline pattern generator ─────────────────────────────────────────────────
159
160/// Generates a 1D scanline pattern texture for GPU upload.
161///
162/// Returns a Vec of f32 values (one per row), normalized to [0, 1].
163/// Upload as a 1D texture sampled by the fragment shader.
164pub fn generate_scanline_lut(height: u32, params: &ScanlineParams) -> Vec<f32> {
165    (0..height)
166        .map(|y| params.evaluate(y as f32, height as f32, 0.0))
167        .collect()
168}
169
170// ── Tests ─────────────────────────────────────────────────────────────────────
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn disabled_returns_one() {
178        let params = ScanlineParams::none();
179        assert_eq!(params.evaluate(5.0, 100.0, 0.0), 1.0);
180    }
181
182    #[test]
183    fn enabled_dims_some_pixels() {
184        let params = ScanlineParams::arcade();
185        let values: Vec<f32> = (0..20).map(|y| params.evaluate(y as f32, 100.0, 0.0)).collect();
186        // Some pixels should be dimmed (< 1.0)
187        let any_dimmed = values.iter().any(|&v| v < 0.99);
188        assert!(any_dimmed, "Expected some pixels to be dimmed");
189    }
190
191    #[test]
192    fn lut_has_correct_length() {
193        let params = ScanlineParams::subtle();
194        let lut = generate_scanline_lut(256, &params);
195        assert_eq!(lut.len(), 256);
196    }
197
198    #[test]
199    fn all_values_in_range() {
200        let params = ScanlineParams::damaged();
201        let lut = generate_scanline_lut(480, &params);
202        for v in &lut {
203            assert!(*v >= 0.0 && *v <= 1.0, "Out of range: {v}");
204        }
205    }
206
207    #[test]
208    fn lerp_halfway() {
209        let a = ScanlineParams::none();
210        let b = ScanlineParams { enabled: true, intensity: 0.4, ..Default::default() };
211        let mid = ScanlineParams::lerp(&a, &b, 0.5);
212        assert!((mid.intensity - 0.2).abs() < 0.001);
213    }
214}