#[derive(Clone, Debug)]
pub struct ScanlineParams {
pub enabled: bool,
pub intensity: f32,
pub line_width: f32,
pub spacing: u32,
pub horizontal: bool,
pub vsync_wobble: f32,
pub persistence: f32,
pub smoothness: f32,
pub tint: [f32; 3],
}
impl Default for ScanlineParams {
fn default() -> Self {
Self {
enabled: false,
intensity: 0.05,
line_width: 1.0,
spacing: 1,
horizontal: true,
vsync_wobble: 0.0,
persistence: 0.0,
smoothness: 0.5,
tint: [1.0, 1.0, 1.0],
}
}
}
impl ScanlineParams {
pub fn none() -> Self { Self::default() }
pub fn subtle() -> Self {
Self {
enabled: true,
intensity: 0.05,
line_width: 1.0,
spacing: 1,
smoothness: 0.7,
..Default::default()
}
}
pub fn arcade() -> Self {
Self {
enabled: true,
intensity: 0.25,
line_width: 1.0,
spacing: 1,
smoothness: 0.3,
tint: [0.9, 1.0, 0.85], ..Default::default()
}
}
pub fn damaged() -> Self {
Self {
enabled: true,
intensity: 0.45,
line_width: 1.5,
spacing: 1,
vsync_wobble: 2.5,
persistence: 0.4,
smoothness: 0.2,
tint: [0.8, 0.9, 0.8],
..Default::default()
}
}
pub fn lofi() -> Self {
Self {
enabled: true,
intensity: 0.35,
line_width: 2.0,
spacing: 2,
smoothness: 0.1,
..Default::default()
}
}
pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
let t = t.clamp(0.0, 1.0);
Self {
enabled: if t < 0.5 { a.enabled } else { b.enabled },
intensity: lerp_f32(a.intensity, b.intensity, t),
line_width: lerp_f32(a.line_width, b.line_width, t),
spacing: if t < 0.5 { a.spacing } else { b.spacing },
horizontal: a.horizontal,
vsync_wobble: lerp_f32(a.vsync_wobble, b.vsync_wobble, t),
persistence: lerp_f32(a.persistence, b.persistence, t),
smoothness: lerp_f32(a.smoothness, b.smoothness, t),
tint: [
lerp_f32(a.tint[0], b.tint[0], t),
lerp_f32(a.tint[1], b.tint[1], t),
lerp_f32(a.tint[2], b.tint[2], t),
],
}
}
pub fn evaluate(&self, pixel_y: f32, screen_height: f32, time: f32) -> f32 {
if !self.enabled { return 1.0; }
let mut y = pixel_y;
if self.vsync_wobble > 0.0 {
y += (time * 60.0).sin() * self.vsync_wobble;
}
let period = (self.spacing as f32 + 1.0) * self.line_width;
let phase = (y / period).fract();
let darkened = if self.smoothness > 0.0 {
let dip = (phase * std::f32::consts::TAU).cos() * 0.5 + 0.5;
let alpha = self.smoothness;
dip * alpha + (1.0 - alpha) * (if phase < 0.5 { 1.0 } else { 0.0 })
} else {
if phase < 0.5 { 0.0 } else { 1.0 }
};
1.0 - darkened * self.intensity
}
}
fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
pub fn generate_scanline_lut(height: u32, params: &ScanlineParams) -> Vec<f32> {
(0..height)
.map(|y| params.evaluate(y as f32, height as f32, 0.0))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_returns_one() {
let params = ScanlineParams::none();
assert_eq!(params.evaluate(5.0, 100.0, 0.0), 1.0);
}
#[test]
fn enabled_dims_some_pixels() {
let params = ScanlineParams::arcade();
let values: Vec<f32> = (0..20).map(|y| params.evaluate(y as f32, 100.0, 0.0)).collect();
let any_dimmed = values.iter().any(|&v| v < 0.99);
assert!(any_dimmed, "Expected some pixels to be dimmed");
}
#[test]
fn lut_has_correct_length() {
let params = ScanlineParams::subtle();
let lut = generate_scanline_lut(256, ¶ms);
assert_eq!(lut.len(), 256);
}
#[test]
fn all_values_in_range() {
let params = ScanlineParams::damaged();
let lut = generate_scanline_lut(480, ¶ms);
for v in &lut {
assert!(*v >= 0.0 && *v <= 1.0, "Out of range: {v}");
}
}
#[test]
fn lerp_halfway() {
let a = ScanlineParams::none();
let b = ScanlineParams { enabled: true, intensity: 0.4, ..Default::default() };
let mid = ScanlineParams::lerp(&a, &b, 0.5);
assert!((mid.intensity - 0.2).abs() < 0.001);
}
}