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/// Configuration for distance-based texture LOD and flat-color shedding.
99#[derive(Debug, Clone, Copy, PartialEq)]
100pub struct TextureLodConfig {
101    /// Distance below which textures render with full detail.
102    pub near_distance: f32,
103    /// Distance at or beyond which textures are dropped and rendered as solid color.
104    pub far_distance: f32,
105    /// Fallback flat material color when texture is dropped.
106    pub fallback_color: Rgb565,
107}
108
109impl TextureLodConfig {
110    /// Create a new texture LOD threshold configuration.
111    pub const fn new(near_distance: f32, far_distance: f32, fallback_color: Rgb565) -> Self {
112        Self {
113            near_distance,
114            far_distance,
115            fallback_color,
116        }
117    }
118
119    /// Evaluates whether a triangle at depth `z` should drop texture sampling.
120    #[inline]
121    pub fn should_drop_texture(&self, z: f32) -> bool {
122        z >= self.far_distance
123    }
124
125    /// Evaluates if `z` is in the transition crossfade band [near_distance, far_distance).
126    #[inline]
127    pub fn is_in_transition(&self, z: f32) -> bool {
128        z >= self.near_distance && z < self.far_distance
129    }
130
131    /// Compute blend factor [0.0, 1.0] toward flat color.
132    #[inline]
133    pub fn flat_blend_factor(&self, z: f32) -> f32 {
134        if z <= self.near_distance {
135            0.0
136        } else if z >= self.far_distance {
137            1.0
138        } else {
139            (z - self.near_distance) / (self.far_distance - self.near_distance)
140        }
141    }
142}
143
144/// Dynamic color palette with runtime cycling and animation support.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub struct AnimatedPalette<const N: usize> {
147    /// Base palette colors.
148    pub colors: [Rgb565; N],
149    /// Cycle start index (inclusive).
150    pub cycle_start: usize,
151    /// Cycle end index (exclusive).
152    pub cycle_end: usize,
153    /// Current cycle offset.
154    pub offset: usize,
155}
156
157impl<const N: usize> AnimatedPalette<N> {
158    /// Create a new animated palette from static colors.
159    pub const fn new(colors: [Rgb565; N]) -> Self {
160        Self {
161            colors,
162            cycle_start: 0,
163            cycle_end: N,
164            offset: 0,
165        }
166    }
167
168    /// Constrain palette cycling to a sub-range [start, end).
169    pub const fn with_cycle_range(mut self, start: usize, end: usize) -> Self {
170        self.cycle_start = start;
171        self.cycle_end = if end <= N { end } else { N };
172        self
173    }
174
175    /// Step the palette cycle by `steps` positions.
176    pub fn step(&mut self, steps: usize) {
177        let range_len = self.cycle_end.saturating_sub(self.cycle_start);
178        if range_len > 1 {
179            self.offset = (self.offset + steps) % range_len;
180        }
181    }
182
183    /// Look up a color by palette index, applying the active animation offset if within cycle range.
184    #[inline]
185    pub fn get_color(&self, index: usize) -> Rgb565 {
186        if index >= N {
187            return Rgb565::BLACK;
188        }
189        if index >= self.cycle_start && index < self.cycle_end {
190            let range_len = self.cycle_end - self.cycle_start;
191            let cycled_idx =
192                self.cycle_start + (index - self.cycle_start + self.offset) % range_len;
193            self.colors[cycled_idx]
194        } else {
195            self.colors[index]
196        }
197    }
198}
199
200/// Cycling direction for retro animated palette slices.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum CycleDirection {
203    /// Forward looping cycle (0 -> 1 -> 2 -> ... -> N-1 -> 0).
204    #[default]
205    Forward,
206    /// Reverse looping cycle (N-1 -> N-2 -> ... -> 0 -> N-1).
207    Reverse,
208    /// Ping-pong alternating cycle (0 -> 1 -> 2 -> 1 -> 0).
209    PingPong,
210}
211
212/// A cycling slice configuration within an indexed color palette.
213#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct PaletteSlice {
215    /// Start index in palette (inclusive).
216    pub start: usize,
217    /// End index in palette (exclusive).
218    pub end: usize,
219    /// Cycling rate in cycles/steps per second (Hz).
220    pub rate_hz: f32,
221    /// Cycling direction.
222    pub direction: CycleDirection,
223    /// Accumulated time in seconds.
224    pub timer: f32,
225    /// Current step offset within the slice.
226    pub current_step: usize,
227    /// Ping-pong traversal state (true = forward, false = backward).
228    pub ping_pong_forward: bool,
229}
230
231impl PaletteSlice {
232    /// Create a new cycling palette slice.
233    pub const fn new(start: usize, end: usize, rate_hz: f32, direction: CycleDirection) -> Self {
234        Self {
235            start,
236            end,
237            rate_hz,
238            direction,
239            timer: 0.0,
240            current_step: 0,
241            ping_pong_forward: true,
242        }
243    }
244
245    /// Advance time by `dt` seconds. Returns `true` if the cycle stepped.
246    pub fn advance(&mut self, dt: f32) -> bool {
247        let span = self.end.saturating_sub(self.start);
248        if span <= 1 || self.rate_hz <= 0.0 {
249            return false;
250        }
251
252        self.timer += dt;
253        let period = 1.0 / self.rate_hz;
254        let mut changed = false;
255
256        while self.timer >= period {
257            self.timer -= period;
258            changed = true;
259
260            match self.direction {
261                CycleDirection::Forward => {
262                    self.current_step = (self.current_step + 1) % span;
263                }
264                CycleDirection::Reverse => {
265                    self.current_step = if self.current_step == 0 {
266                        span - 1
267                    } else {
268                        self.current_step - 1
269                    };
270                }
271                CycleDirection::PingPong => {
272                    if self.ping_pong_forward {
273                        if self.current_step + 1 >= span {
274                            self.ping_pong_forward = false;
275                            self.current_step = self.current_step.saturating_sub(1);
276                        } else {
277                            self.current_step += 1;
278                        }
279                    } else if self.current_step == 0 {
280                        self.ping_pong_forward = true;
281                        self.current_step = 1.min(span - 1);
282                    } else {
283                        self.current_step -= 1;
284                    }
285                }
286            }
287        }
288
289        changed
290    }
291
292    /// Maps an indexed palette color index through this slice's current animation step.
293    #[inline]
294    pub fn map_index(&self, index: usize) -> usize {
295        let span = self.end.saturating_sub(self.start);
296        if span <= 1 || index < self.start || index >= self.end {
297            return index;
298        }
299        self.start + (index - self.start + self.current_step) % span
300    }
301}
302
303/// Multi-slice palette cycler for zero-allocation, zero-geometry retro animations.
304///
305/// Cycles indexed color ranges in real time (e.g. water cascades, fire glows, force field pulses,
306/// and neon flashing) by shifting indices or updating palette tables without modifying mesh buffers.
307#[derive(Debug, Clone, Copy)]
308pub struct PaletteCycler<const MAX_SLICES: usize = 4> {
309    /// Active animation slices.
310    pub slices: [Option<PaletteSlice>; MAX_SLICES],
311    /// Number of configured slices.
312    pub slice_count: usize,
313}
314
315impl<const MAX_SLICES: usize> Default for PaletteCycler<MAX_SLICES> {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321impl<const MAX_SLICES: usize> PaletteCycler<MAX_SLICES> {
322    /// Create a new empty palette cycler.
323    pub const fn new() -> Self {
324        Self {
325            slices: [None; MAX_SLICES],
326            slice_count: 0,
327        }
328    }
329
330    /// Add an animated slice. Returns `true` if added successfully, `false` if full.
331    pub fn add_slice(&mut self, slice: PaletteSlice) -> bool {
332        if self.slice_count < MAX_SLICES {
333            self.slices[self.slice_count] = Some(slice);
334            self.slice_count += 1;
335            true
336        } else {
337            false
338        }
339    }
340
341    /// Add a slice by specifying parameters directly.
342    pub fn add_range(
343        &mut self,
344        start: usize,
345        end: usize,
346        rate_hz: f32,
347        direction: CycleDirection,
348    ) -> bool {
349        self.add_slice(PaletteSlice::new(start, end, rate_hz, direction))
350    }
351
352    /// Advance all slices by delta time `dt` in seconds.
353    ///
354    /// Returns `true` if any slice advanced a step (indicating palette redraw/sync is needed).
355    pub fn advance(&mut self, dt: f32) -> bool {
356        let mut any_changed = false;
357        for slice in self.slices.iter_mut().take(self.slice_count).flatten() {
358            if slice.advance(dt) {
359                any_changed = true;
360            }
361        }
362        any_changed
363    }
364
365    /// Map an index through all active slices.
366    #[inline]
367    pub fn map_index(&self, index: usize) -> usize {
368        let mut mapped = index;
369        for slice in self.slices.iter().take(self.slice_count).flatten() {
370            if index >= slice.start && index < slice.end {
371                mapped = slice.map_index(index);
372                break;
373            }
374        }
375        mapped
376    }
377
378    /// Read colors from `base` palette and write cycled colors into `dest` palette.
379    pub fn cycle_palette<const N: usize>(&self, base: &[Rgb565; N], dest: &mut [Rgb565; N]) {
380        for i in 0..N {
381            dest[i] = base[self.map_index(i)];
382        }
383    }
384
385    /// Cycle slice in-place using a small temporary buffer.
386    pub fn cycle_slice(&self, base: &[Rgb565], dest: &mut [Rgb565]) {
387        let count = base.len().min(dest.len());
388        for i in 0..count {
389            dest[i] = base[self.map_index(i)];
390        }
391    }
392}
393
394/// Coarse visual-style controls for retro rendering presets.
395#[derive(Debug, Clone, Copy)]
396pub struct RetroStyle {
397    /// Optional depth fog.
398    pub fog: Option<FogConfig>,
399    /// Optional ordered dithering.
400    pub dither: Option<DitherConfig>,
401    /// NDC snap precision in fractional bits. `0` disables snapping.
402    pub vertex_snap_bits: u8,
403    /// UV interpolation mode for textured surfaces.
404    pub texture_mapping: TextureMapping,
405    /// Sector light behavior.
406    pub light_levels: LightLevels,
407    /// Optional checkerboard stippling for fake transparency.
408    pub stipple_mode: StippleMode,
409    /// Optional full-screen tint.
410    pub screen_tint: Option<ScreenTint>,
411    /// Optional palette quantization.
412    pub palette_mode: PaletteMode,
413    /// Optional procedural sky.
414    pub sky: Option<SkyConfig>,
415}
416
417impl Default for RetroStyle {
418    fn default() -> Self {
419        Self::modern()
420    }
421}
422
423impl RetroStyle {
424    /// Neutral defaults: no extra post-process and perspective-correct texturing.
425    pub const fn modern() -> Self {
426        Self {
427            fog: None,
428            dither: None,
429            vertex_snap_bits: 0,
430            texture_mapping: TextureMapping::PerspectiveCorrect,
431            light_levels: LightLevels::Linear,
432            stipple_mode: StippleMode::Off,
433            screen_tint: None,
434            palette_mode: PaletteMode::Off,
435            sky: None,
436        }
437    }
438
439    /// Doom-leaning preset for coarse affine textures without fog.
440    pub const fn doom_walkable() -> Self {
441        Self {
442            fog: None,
443            dither: Some(DitherConfig { intensity: 20 }),
444            vertex_snap_bits: 0,
445            texture_mapping: TextureMapping::Affine,
446            light_levels: LightLevels::Doom32,
447            stipple_mode: StippleMode::Off,
448            screen_tint: None,
449            palette_mode: PaletteMode::Rgb332,
450            sky: Some(SkyConfig::retro_blue()),
451        }
452    }
453
454    /// PSX-leaning preset: snapped vertices + affine textures + visible dither.
455    pub fn psx() -> Self {
456        Self {
457            fog: Some(FogConfig::new(Rgb565::new(2, 2, 4), 6.0, 20.0)),
458            dither: Some(DitherConfig::new(72)),
459            vertex_snap_bits: 6,
460            texture_mapping: TextureMapping::Affine,
461            light_levels: LightLevels::Linear,
462            stipple_mode: StippleMode::Off,
463            screen_tint: None,
464            palette_mode: PaletteMode::Rgb332,
465            sky: Some(SkyConfig::retro_blue()),
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn palette_off_is_identity() {
476        let c = Rgb565::new(17, 45, 23);
477        assert_eq!(PaletteMode::Off.apply(c), c);
478    }
479
480    #[test]
481    fn palette_rgb332_quantizes_channels() {
482        let c = Rgb565::new(17, 45, 23);
483        let q = PaletteMode::Rgb332.apply(c);
484        // Blue channel is reduced to 2 bits; red/green map to 3 bits.
485        assert_eq!(q, Rgb565::new(17, 45, 20));
486    }
487
488    #[test]
489    fn screen_tint_strength_extremes() {
490        let base = Rgb565::new(7, 20, 5);
491        let tint = Rgb565::new(31, 0, 31);
492
493        let off = ScreenTint {
494            color: tint,
495            strength: 0,
496        };
497        assert_eq!(off.apply(base), base);
498
499        let full = ScreenTint {
500            color: tint,
501            strength: 255,
502        };
503        assert_eq!(full.apply(base), tint);
504    }
505
506    #[test]
507    fn doom_walkable_preset_matches_expected_profile() {
508        let s = RetroStyle::doom_walkable();
509        assert!(s.fog.is_none());
510        assert_eq!(s.vertex_snap_bits, 0);
511        assert_eq!(s.texture_mapping, TextureMapping::Affine);
512        assert_eq!(s.light_levels, LightLevels::Doom32);
513        assert_eq!(s.stipple_mode, StippleMode::Off);
514        assert_eq!(s.palette_mode, PaletteMode::Rgb332);
515        assert!(s.sky.is_some());
516        assert!(s.dither.is_some());
517    }
518
519    #[test]
520    fn psx_preset_enables_snap_and_fog() {
521        let s = RetroStyle::psx();
522        assert_eq!(s.vertex_snap_bits, 6);
523        assert_eq!(s.texture_mapping, TextureMapping::Affine);
524        assert_eq!(s.light_levels, LightLevels::Linear);
525        assert_eq!(s.palette_mode, PaletteMode::Rgb332);
526        assert!(s.fog.is_some());
527        assert!(s.dither.is_some());
528        assert!(s.sky.is_some());
529    }
530
531    #[test]
532    fn test_texture_lod_config() {
533        let lod = TextureLodConfig::new(50.0, 150.0, Rgb565::new(16, 32, 16));
534        assert!(!lod.should_drop_texture(40.0));
535        assert!(!lod.should_drop_texture(100.0));
536        assert!(lod.should_drop_texture(150.0));
537        assert!(lod.should_drop_texture(200.0));
538
539        assert!(!lod.is_in_transition(40.0));
540        assert!(lod.is_in_transition(100.0));
541        assert!(!lod.is_in_transition(150.0));
542
543        assert_eq!(lod.flat_blend_factor(50.0), 0.0);
544        assert_eq!(lod.flat_blend_factor(100.0), 0.5);
545        assert_eq!(lod.flat_blend_factor(150.0), 1.0);
546    }
547
548    #[test]
549    fn test_animated_palette() {
550        let colors = [Rgb565::RED, Rgb565::GREEN, Rgb565::BLUE, Rgb565::WHITE];
551        let mut pal = AnimatedPalette::new(colors).with_cycle_range(1, 3);
552
553        assert_eq!(pal.get_color(0), Rgb565::RED);
554        assert_eq!(pal.get_color(1), Rgb565::GREEN);
555        assert_eq!(pal.get_color(2), Rgb565::BLUE);
556        assert_eq!(pal.get_color(3), Rgb565::WHITE);
557
558        pal.step(1);
559        assert_eq!(pal.get_color(0), Rgb565::RED); // Uncycled
560        assert_eq!(pal.get_color(1), Rgb565::BLUE); // Cycled
561        assert_eq!(pal.get_color(2), Rgb565::GREEN); // Cycled
562        assert_eq!(pal.get_color(3), Rgb565::WHITE); // Uncycled
563    }
564
565    #[test]
566    fn test_palette_cycler_forward_and_reverse() {
567        let mut cycler = PaletteCycler::<2>::new();
568        // Forward water cycle: indices [2..5] at 10 Hz
569        cycler.add_range(2, 5, 10.0, CycleDirection::Forward);
570
571        assert_eq!(cycler.map_index(0), 0);
572        assert_eq!(cycler.map_index(2), 2);
573        assert_eq!(cycler.map_index(3), 3);
574        assert_eq!(cycler.map_index(4), 4);
575        assert_eq!(cycler.map_index(5), 5);
576
577        // Advance 0.1s -> 1 step
578        assert!(cycler.advance(0.1));
579        assert_eq!(cycler.map_index(2), 3);
580        assert_eq!(cycler.map_index(3), 4);
581        assert_eq!(cycler.map_index(4), 2);
582
583        // Reverse fire cycle
584        let mut rev_slice = PaletteSlice::new(0, 3, 5.0, CycleDirection::Reverse);
585        assert_eq!(rev_slice.current_step, 0);
586        rev_slice.advance(0.2); // 1 step backward
587        assert_eq!(rev_slice.current_step, 2);
588    }
589
590    #[test]
591    fn test_palette_cycler_ping_pong() {
592        let mut slice = PaletteSlice::new(0, 4, 10.0, CycleDirection::PingPong);
593        assert_eq!(slice.current_step, 0);
594        slice.advance(0.1);
595        assert_eq!(slice.current_step, 1);
596        slice.advance(0.1);
597        assert_eq!(slice.current_step, 2);
598        slice.advance(0.1);
599        assert_eq!(slice.current_step, 3);
600        slice.advance(0.1);
601        assert_eq!(slice.current_step, 2); // Reversing
602        slice.advance(0.1);
603        assert_eq!(slice.current_step, 1);
604        slice.advance(0.1);
605        assert_eq!(slice.current_step, 0); // At start, switches to forward
606        slice.advance(0.1);
607        assert_eq!(slice.current_step, 1);
608    }
609
610    #[test]
611    fn test_palette_cycler_cycle_palette() {
612        let mut cycler = PaletteCycler::<1>::new();
613        cycler.add_range(1, 3, 20.0, CycleDirection::Forward);
614
615        let base = [Rgb565::RED, Rgb565::GREEN, Rgb565::BLUE, Rgb565::WHITE];
616        let mut dest = [Rgb565::BLACK; 4];
617
618        cycler.cycle_palette(&base, &mut dest);
619        assert_eq!(dest[0], Rgb565::RED);
620        assert_eq!(dest[1], Rgb565::GREEN);
621        assert_eq!(dest[2], Rgb565::BLUE);
622        assert_eq!(dest[3], Rgb565::WHITE);
623
624        cycler.advance(0.05); // 1 step
625        cycler.cycle_palette(&base, &mut dest);
626        assert_eq!(dest[1], Rgb565::BLUE);
627        assert_eq!(dest[2], Rgb565::GREEN);
628    }
629}