Skip to main content

embedded_3dgfx/
retro.rs

1use crate::draw::{DitherConfig, FogConfig};
2use embedded_graphics_core::pixelcolor::Rgb565;
3use embedded_graphics_core::pixelcolor::RgbColor;
4
5/// Sector brightness model.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LightLevels {
8    /// Direct linear brightness scale.
9    Linear,
10    /// Doom-like 32-step distance banding.
11    Doom32,
12}
13
14/// Screen-space stipple transparency mode.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum StippleMode {
17    Off,
18    Checkerboard,
19}
20
21/// Palette quantization mode.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PaletteMode {
24    Off,
25    /// 3-3-2 bit palette approximation (256 colors).
26    Rgb332,
27}
28
29impl PaletteMode {
30    #[inline]
31    pub fn apply(self, color: Rgb565) -> Rgb565 {
32        match self {
33            PaletteMode::Off => color,
34            PaletteMode::Rgb332 => {
35                let r3 = ((color.r() as u16 * 7 + 15) / 31) as u8;
36                let g3 = ((color.g() as u16 * 7 + 31) / 63) as u8;
37                let b2 = ((color.b() as u16 * 3 + 15) / 31) as u8;
38                let r = (r3 as u16 * 31 / 7) as u8;
39                let g = (g3 as u16 * 63 / 7) as u8;
40                let b = (b2 as u16 * 31 / 3) as u8;
41                Rgb565::new(r, g, b)
42            }
43        }
44    }
45}
46
47/// Procedural sky background rendered before world geometry.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct SkyConfig {
50    pub top_color: Rgb565,
51    pub bottom_color: Rgb565,
52    pub stripe_color: Rgb565,
53    pub stripe_strength: u8,
54    pub stripe_width: u8,
55}
56
57impl SkyConfig {
58    pub const fn retro_blue() -> Self {
59        Self {
60            top_color: Rgb565::new(6, 16, 31),
61            bottom_color: Rgb565::new(1, 4, 12),
62            stripe_color: Rgb565::new(18, 30, 31),
63            stripe_strength: 18,
64            stripe_width: 16,
65        }
66    }
67}
68
69/// Full-screen tint blended onto final raster colors.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ScreenTint {
72    pub color: Rgb565,
73    /// Blend strength in [0, 255].
74    pub strength: u8,
75}
76
77impl ScreenTint {
78    #[inline]
79    pub fn apply(&self, base: Rgb565) -> Rgb565 {
80        let a = self.strength as u16;
81        let inv = 255u16.saturating_sub(a);
82        let r = ((base.r() as u16 * inv + self.color.r() as u16 * a) / 255) as u8;
83        let g = ((base.g() as u16 * inv + self.color.g() as u16 * a) / 255) as u8;
84        let b = ((base.b() as u16 * inv + self.color.b() as u16 * a) / 255) as u8;
85        Rgb565::new(r, g, b)
86    }
87}
88
89/// Texture coordinate interpolation style for textured triangles.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum TextureMapping {
92    /// Perspective-correct interpolation using clip-space W.
93    PerspectiveCorrect,
94    /// Affine interpolation in screen space (retro "texture swim").
95    Affine,
96}
97
98/// Coarse visual-style controls for retro rendering presets.
99#[derive(Debug, Clone, Copy)]
100pub struct RetroStyle {
101    /// Optional depth fog.
102    pub fog: Option<FogConfig>,
103    /// Optional ordered dithering.
104    pub dither: Option<DitherConfig>,
105    /// NDC snap precision in fractional bits. `0` disables snapping.
106    pub vertex_snap_bits: u8,
107    /// UV interpolation mode for textured surfaces.
108    pub texture_mapping: TextureMapping,
109    /// Sector light behavior.
110    pub light_levels: LightLevels,
111    /// Optional checkerboard stippling for fake transparency.
112    pub stipple_mode: StippleMode,
113    /// Optional full-screen tint.
114    pub screen_tint: Option<ScreenTint>,
115    /// Optional palette quantization.
116    pub palette_mode: PaletteMode,
117    /// Optional procedural sky.
118    pub sky: Option<SkyConfig>,
119}
120
121impl Default for RetroStyle {
122    fn default() -> Self {
123        Self::modern()
124    }
125}
126
127impl RetroStyle {
128    /// Neutral defaults: no extra post-process and perspective-correct texturing.
129    pub const fn modern() -> Self {
130        Self {
131            fog: None,
132            dither: None,
133            vertex_snap_bits: 0,
134            texture_mapping: TextureMapping::PerspectiveCorrect,
135            light_levels: LightLevels::Linear,
136            stipple_mode: StippleMode::Off,
137            screen_tint: None,
138            palette_mode: PaletteMode::Off,
139            sky: None,
140        }
141    }
142
143    /// Doom-leaning preset for coarse affine textures without fog.
144    pub const fn doom_walkable() -> Self {
145        Self {
146            fog: None,
147            dither: Some(DitherConfig { intensity: 20 }),
148            vertex_snap_bits: 0,
149            texture_mapping: TextureMapping::Affine,
150            light_levels: LightLevels::Doom32,
151            stipple_mode: StippleMode::Off,
152            screen_tint: None,
153            palette_mode: PaletteMode::Rgb332,
154            sky: Some(SkyConfig::retro_blue()),
155        }
156    }
157
158    /// PSX-leaning preset: snapped vertices + affine textures + visible dither.
159    pub fn psx() -> Self {
160        Self {
161            fog: Some(FogConfig::new(Rgb565::new(2, 2, 4), 6.0, 20.0)),
162            dither: Some(DitherConfig::new(72)),
163            vertex_snap_bits: 6,
164            texture_mapping: TextureMapping::Affine,
165            light_levels: LightLevels::Linear,
166            stipple_mode: StippleMode::Off,
167            screen_tint: None,
168            palette_mode: PaletteMode::Rgb332,
169            sky: Some(SkyConfig::retro_blue()),
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn palette_off_is_identity() {
180        let c = Rgb565::new(17, 45, 23);
181        assert_eq!(PaletteMode::Off.apply(c), c);
182    }
183
184    #[test]
185    fn palette_rgb332_quantizes_channels() {
186        let c = Rgb565::new(17, 45, 23);
187        let q = PaletteMode::Rgb332.apply(c);
188        // Blue channel is reduced to 2 bits; red/green map to 3 bits.
189        assert_eq!(q, Rgb565::new(17, 45, 20));
190    }
191
192    #[test]
193    fn screen_tint_strength_extremes() {
194        let base = Rgb565::new(7, 20, 5);
195        let tint = Rgb565::new(31, 0, 31);
196
197        let off = ScreenTint {
198            color: tint,
199            strength: 0,
200        };
201        assert_eq!(off.apply(base), base);
202
203        let full = ScreenTint {
204            color: tint,
205            strength: 255,
206        };
207        assert_eq!(full.apply(base), tint);
208    }
209
210    #[test]
211    fn doom_walkable_preset_matches_expected_profile() {
212        let s = RetroStyle::doom_walkable();
213        assert!(s.fog.is_none());
214        assert_eq!(s.vertex_snap_bits, 0);
215        assert_eq!(s.texture_mapping, TextureMapping::Affine);
216        assert_eq!(s.light_levels, LightLevels::Doom32);
217        assert_eq!(s.stipple_mode, StippleMode::Off);
218        assert_eq!(s.palette_mode, PaletteMode::Rgb332);
219        assert!(s.sky.is_some());
220        assert!(s.dither.is_some());
221    }
222
223    #[test]
224    fn psx_preset_enables_snap_and_fog() {
225        let s = RetroStyle::psx();
226        assert_eq!(s.vertex_snap_bits, 6);
227        assert_eq!(s.texture_mapping, TextureMapping::Affine);
228        assert_eq!(s.light_levels, LightLevels::Linear);
229        assert_eq!(s.palette_mode, PaletteMode::Rgb332);
230        assert!(s.fog.is_some());
231        assert!(s.dither.is_some());
232        assert!(s.sky.is_some());
233    }
234}