Skip to main content

embedded_3dgfx/
renderer.rs

1use core::fmt::Debug;
2
3use embedded_graphics_core::{
4    Pixel,
5    draw_target::DrawTarget,
6    pixelcolor::{Rgb565, RgbColor},
7    prelude::{OriginDimensions, Point},
8};
9
10use crate::{
11    DrawPrimitive,
12    command_buffer::{CommandBuffer, RenderCommand},
13    draw::{DitherConfig, FogConfig, draw_zbuffered_with_effects},
14    error::{BudgetKind, RenderError},
15    retro::{PaletteMode, ScreenTint, SkyConfig, StippleMode},
16};
17
18pub struct FrameCtx<'a> {
19    pub zbuffer: &'a mut [u32],
20    pub width: usize,
21    pub height: usize,
22}
23
24impl<'a> FrameCtx<'a> {
25    pub fn validate(&self) -> Result<(), RenderError> {
26        let expected = self.width * self.height;
27        if self.zbuffer.len() != expected {
28            return Err(RenderError::OutOfBudget(BudgetKind::ZBufferLength {
29                expected,
30                got: self.zbuffer.len(),
31            }));
32        }
33        Ok(())
34    }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct DirtyRegion {
39    pub x: usize,
40    pub y: usize,
41    pub width: usize,
42    pub height: usize,
43}
44
45impl DirtyRegion {
46    fn from_bounds(min_x: i32, min_y: i32, max_x: i32, max_y: i32) -> Option<Self> {
47        if max_x < min_x || max_y < min_y {
48            return None;
49        }
50        Some(Self {
51            x: min_x as usize,
52            y: min_y as usize,
53            width: (max_x - min_x + 1) as usize,
54            height: (max_y - min_y + 1) as usize,
55        })
56    }
57}
58
59fn primitive_bounds(primitive: &DrawPrimitive) -> (i32, i32, i32, i32) {
60    match primitive {
61        DrawPrimitive::ColoredPoint(p, _) => (p.x, p.y, p.x, p.y),
62        DrawPrimitive::Line([a, b], _) => (a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)),
63        DrawPrimitive::ColoredTriangle(points, _)
64        | DrawPrimitive::ColoredTriangleWithDepth { points, .. }
65        | DrawPrimitive::TranslucentTriangleWithDepth { points, .. }
66        | DrawPrimitive::GouraudTriangle { points, .. }
67        | DrawPrimitive::GouraudTriangleWithDepth { points, .. }
68        | DrawPrimitive::TexturedTriangle { points, .. }
69        | DrawPrimitive::TexturedTriangleWithDepth { points, .. }
70        | DrawPrimitive::LightmappedTriangle { points, .. } => {
71            let min_x = points.iter().map(|p| p.x).min().unwrap_or(0);
72            let min_y = points.iter().map(|p| p.y).min().unwrap_or(0);
73            let max_x = points.iter().map(|p| p.x).max().unwrap_or(0);
74            let max_y = points.iter().map(|p| p.y).max().unwrap_or(0);
75            (min_x, min_y, max_x, max_y)
76        }
77    }
78}
79
80fn clamp_bounds_to_frame(
81    min_x: i32,
82    min_y: i32,
83    max_x: i32,
84    max_y: i32,
85    width: usize,
86    height: usize,
87) -> Option<(i32, i32, i32, i32)> {
88    let w = width as i32;
89    let h = height as i32;
90    let clamped_min_x = min_x.clamp(0, w.saturating_sub(1));
91    let clamped_min_y = min_y.clamp(0, h.saturating_sub(1));
92    let clamped_max_x = max_x.clamp(0, w.saturating_sub(1));
93    let clamped_max_y = max_y.clamp(0, h.saturating_sub(1));
94    if clamped_max_x < clamped_min_x || clamped_max_y < clamped_min_y {
95        return None;
96    }
97    Some((clamped_min_x, clamped_min_y, clamped_max_x, clamped_max_y))
98}
99
100#[inline]
101fn apply_post(color: Rgb565, tint: Option<ScreenTint>, palette_mode: PaletteMode) -> Rgb565 {
102    let tinted = if let Some(t) = tint {
103        t.apply(color)
104    } else {
105        color
106    };
107    palette_mode.apply(tinted)
108}
109
110fn tint_primitive(
111    primitive: &DrawPrimitive,
112    tint: Option<ScreenTint>,
113    palette_mode: PaletteMode,
114) -> DrawPrimitive {
115    match primitive.clone() {
116        DrawPrimitive::ColoredPoint(p, color) => {
117            DrawPrimitive::ColoredPoint(p, apply_post(color, tint, palette_mode))
118        }
119        DrawPrimitive::Line(points, color) => {
120            DrawPrimitive::Line(points, apply_post(color, tint, palette_mode))
121        }
122        DrawPrimitive::ColoredTriangle(points, color) => {
123            DrawPrimitive::ColoredTriangle(points, apply_post(color, tint, palette_mode))
124        }
125        DrawPrimitive::ColoredTriangleWithDepth {
126            points,
127            depths,
128            color,
129        } => DrawPrimitive::ColoredTriangleWithDepth {
130            points,
131            depths,
132            color: apply_post(color, tint, palette_mode),
133        },
134        DrawPrimitive::TranslucentTriangleWithDepth {
135            points,
136            depths,
137            color,
138            alpha,
139        } => DrawPrimitive::TranslucentTriangleWithDepth {
140            points,
141            depths,
142            color: apply_post(color, tint, palette_mode),
143            alpha,
144        },
145        DrawPrimitive::GouraudTriangle { points, colors } => DrawPrimitive::GouraudTriangle {
146            points,
147            colors: [
148                apply_post(colors[0], tint, palette_mode),
149                apply_post(colors[1], tint, palette_mode),
150                apply_post(colors[2], tint, palette_mode),
151            ],
152        },
153        DrawPrimitive::GouraudTriangleWithDepth {
154            points,
155            depths,
156            colors,
157        } => DrawPrimitive::GouraudTriangleWithDepth {
158            points,
159            depths,
160            colors: [
161                apply_post(colors[0], tint, palette_mode),
162                apply_post(colors[1], tint, palette_mode),
163                apply_post(colors[2], tint, palette_mode),
164            ],
165        },
166        DrawPrimitive::LightmappedTriangle {
167            points,
168            depths,
169            ws,
170            surface_uvs,
171            lm_uvs,
172            texture_id,
173            lightmap_id,
174            brightness,
175            dynamic_tint,
176        } => DrawPrimitive::LightmappedTriangle {
177            points,
178            depths,
179            ws,
180            surface_uvs,
181            lm_uvs,
182            texture_id,
183            lightmap_id,
184            brightness,
185            dynamic_tint: apply_post(dynamic_tint, tint, palette_mode),
186        },
187        other => other,
188    }
189}
190
191#[inline]
192fn blend_rgb565(a: Rgb565, b: Rgb565, t_q8: u16) -> Rgb565 {
193    let inv = 255u16.saturating_sub(t_q8);
194    let r = ((a.r() as u16 * inv + b.r() as u16 * t_q8) / 255) as u8;
195    let g = ((a.g() as u16 * inv + b.g() as u16 * t_q8) / 255) as u8;
196    let bch = ((a.b() as u16 * inv + b.b() as u16 * t_q8) / 255) as u8;
197    Rgb565::new(r, g, bch)
198}
199
200#[inline]
201fn stripe_on_at(x: i32, scroll: i32, stripe_w: i32) -> bool {
202    (((x + scroll).div_euclid(stripe_w)) & 1) == 0
203}
204
205fn draw_sky_background<D>(
206    fb: &mut D,
207    width: usize,
208    height: usize,
209    sky: SkyConfig,
210    camera_dir: [f32; 3],
211    screen_tint: Option<ScreenTint>,
212    palette_mode: PaletteMode,
213) -> Result<(), RenderError>
214where
215    D: DrawTarget<Color = Rgb565> + OriginDimensions,
216    D::Error: Debug,
217{
218    let w = width as i32;
219    let h = height as i32;
220    if w <= 0 || h <= 0 {
221        return Ok(());
222    }
223
224    let horizon = (h as f32 * (0.5 + camera_dir[1].clamp(-1.0, 1.0) * 0.25)) as i32;
225    let stripe_w = sky.stripe_width.max(1) as i32;
226    let scroll = (camera_dir[0] * 128.0) as i32;
227    let stripe_fade_span = (h / 6).max(1);
228
229    for y in 0..h {
230        let dy = (y - horizon + h / 2).clamp(0, h);
231        let t_q8 = ((dy as i64 * 255) / h.max(1) as i64) as u16;
232        let base = blend_rgb565(sky.top_color, sky.bottom_color, t_q8);
233        let below_horizon = (y - horizon).max(0);
234        let stripe_strength = if below_horizon == 0 {
235            sky.stripe_strength as u16
236        } else if below_horizon >= stripe_fade_span {
237            0
238        } else {
239            let rem = stripe_fade_span - below_horizon;
240            ((sky.stripe_strength as i32 * rem) / stripe_fade_span) as u16
241        };
242        for x in 0..w {
243            let stripe_on = stripe_on_at(x, scroll, stripe_w);
244            let mut color = if stripe_on && stripe_strength > 0 {
245                blend_rgb565(base, sky.stripe_color, stripe_strength)
246            } else {
247                base
248            };
249            color = apply_post(color, screen_tint, palette_mode);
250            fb.draw_iter([Pixel(Point::new(x, y), color)])
251                .map_err(|_| RenderError::InvalidInput("draw target rejected sky write"))?;
252        }
253    }
254    Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::stripe_on_at;
260
261    #[test]
262    fn stripe_phase_is_periodic_with_negative_scroll() {
263        let stripe_w = 10;
264        let scroll = -3;
265        for x in -40..40 {
266            assert_eq!(
267                stripe_on_at(x, scroll, stripe_w),
268                stripe_on_at(x + stripe_w * 2, scroll, stripe_w)
269            );
270        }
271    }
272
273    #[test]
274    fn stripe_runs_do_not_exceed_width() {
275        let stripe_w = 8;
276        let scroll = -5;
277        let mut max_run = 0usize;
278        let mut run = 0usize;
279        let mut prev = stripe_on_at(-64, scroll, stripe_w);
280        for x in -63..=64 {
281            let cur = stripe_on_at(x, scroll, stripe_w);
282            if cur == prev {
283                run += 1;
284            } else {
285                max_run = max_run.max(run);
286                run = 1;
287                prev = cur;
288            }
289        }
290        max_run = max_run.max(run);
291        assert!(max_run <= stripe_w as usize);
292    }
293}
294
295pub fn execute_commands<D, const MAX: usize>(
296    fb: &mut D,
297    frame: &mut FrameCtx<'_>,
298    cmd: &CommandBuffer<MAX>,
299    fog: Option<&FogConfig>,
300) -> Result<(), RenderError>
301where
302    D: DrawTarget<Color = Rgb565> + OriginDimensions,
303    D::Error: Debug,
304{
305    let _ = execute_commands_with_dirty_region_effects(
306        fb,
307        frame,
308        cmd,
309        fog,
310        None,
311        None,
312        StippleMode::Off,
313        PaletteMode::Off,
314        None,
315        [0.0, 0.0, -1.0],
316    )?;
317    Ok(())
318}
319
320pub fn execute_commands_with_dirty_region<D, const MAX: usize>(
321    fb: &mut D,
322    frame: &mut FrameCtx<'_>,
323    cmd: &CommandBuffer<MAX>,
324    fog: Option<&FogConfig>,
325) -> Result<Option<DirtyRegion>, RenderError>
326where
327    D: DrawTarget<Color = Rgb565> + OriginDimensions,
328    D::Error: Debug,
329{
330    execute_commands_with_dirty_region_effects(
331        fb,
332        frame,
333        cmd,
334        fog,
335        None,
336        None,
337        StippleMode::Off,
338        PaletteMode::Off,
339        None,
340        [0.0, 0.0, -1.0],
341    )
342}
343
344pub fn execute_commands_with_dirty_region_effects<D, const MAX: usize>(
345    fb: &mut D,
346    frame: &mut FrameCtx<'_>,
347    cmd: &CommandBuffer<MAX>,
348    fog: Option<&FogConfig>,
349    dither: Option<&DitherConfig>,
350    screen_tint: Option<ScreenTint>,
351    _stipple_mode: StippleMode,
352    palette_mode: PaletteMode,
353    sky: Option<SkyConfig>,
354    camera_dir: [f32; 3],
355) -> Result<Option<DirtyRegion>, RenderError>
356where
357    D: DrawTarget<Color = Rgb565> + OriginDimensions,
358    D::Error: Debug,
359{
360    frame.validate()?;
361    let mut dirty_bounds: Option<(i32, i32, i32, i32)> = None;
362    if let Some(sky_cfg) = sky {
363        draw_sky_background(
364            fb,
365            frame.width,
366            frame.height,
367            sky_cfg,
368            camera_dir,
369            screen_tint,
370            palette_mode,
371        )?;
372        dirty_bounds = Some((
373            0,
374            0,
375            frame.width.saturating_sub(1) as i32,
376            frame.height.saturating_sub(1) as i32,
377        ));
378    }
379
380    for c in cmd.iter() {
381        match c {
382            RenderCommand::ClearColor(color) => {
383                let w = frame.width as i32;
384                let h = frame.height as i32;
385                let clear_color = apply_post(*color, screen_tint, palette_mode);
386                for y in 0..h {
387                    for x in 0..w {
388                        fb.draw_iter([Pixel(Point::new(x, y), clear_color)])
389                            .map_err(|_| {
390                                RenderError::InvalidInput("draw target rejected clear write")
391                            })?;
392                    }
393                }
394            }
395            RenderCommand::ClearDepth(value) => {
396                frame.zbuffer.fill(*value);
397            }
398            RenderCommand::Draw(primitive) => {
399                let prim = tint_primitive(primitive, screen_tint, palette_mode);
400                draw_zbuffered_with_effects(prim, fb, frame.zbuffer, frame.width, fog, dither);
401                let (min_x, min_y, max_x, max_y) = primitive_bounds(primitive);
402                if let Some((min_x, min_y, max_x, max_y)) =
403                    clamp_bounds_to_frame(min_x, min_y, max_x, max_y, frame.width, frame.height)
404                {
405                    dirty_bounds = Some(match dirty_bounds {
406                        Some((cx0, cy0, cx1, cy1)) => (
407                            cx0.min(min_x),
408                            cy0.min(min_y),
409                            cx1.max(max_x),
410                            cy1.max(max_y),
411                        ),
412                        None => (min_x, min_y, max_x, max_y),
413                    });
414                }
415            }
416        }
417    }
418
419    let region = dirty_bounds.and_then(|(x0, y0, x1, y1)| DirtyRegion::from_bounds(x0, y0, x1, y1));
420    Ok(region)
421}
422
423/// Like [`execute_commands_with_dirty_region_effects`], but resolves
424/// [`DrawPrimitive::TexturedTriangleWithDepth`]/[`DrawPrimitive::LightmappedTriangle`]
425/// via `texture_manager` instead of silently dropping them (mirrors
426/// `bsp::execute_bsp_textured`'s dispatch pattern). Non-textured primitives
427/// still go through `tint_primitive` + `draw_zbuffered_with_effects`, same
428/// as the non-textured `execute*` functions, so mixing textured and
429/// flat-colored meshes in one scene keeps consistent tint/palette
430/// behavior.
431#[allow(clippy::too_many_arguments)]
432pub fn execute_commands_with_dirty_region_effects_textured<D, const MAX: usize, const N: usize>(
433    fb: &mut D,
434    frame: &mut FrameCtx<'_>,
435    cmd: &CommandBuffer<MAX>,
436    texture_manager: &crate::texture::TextureManager<N>,
437    fog: Option<&FogConfig>,
438    dither: Option<&DitherConfig>,
439    screen_tint: Option<ScreenTint>,
440    stipple_mode: StippleMode,
441    palette_mode: PaletteMode,
442    sky: Option<SkyConfig>,
443    camera_dir: [f32; 3],
444) -> Result<Option<DirtyRegion>, RenderError>
445where
446    D: DrawTarget<Color = Rgb565> + OriginDimensions,
447    D::Error: Debug,
448{
449    use crate::draw::{draw_zbuffered_lightmapped_mapped, draw_zbuffered_with_textures_mapped};
450    use crate::retro::TextureMapping;
451
452    frame.validate()?;
453    let mut dirty_bounds: Option<(i32, i32, i32, i32)> = None;
454    if let Some(sky_cfg) = sky {
455        draw_sky_background(
456            fb,
457            frame.width,
458            frame.height,
459            sky_cfg,
460            camera_dir,
461            screen_tint,
462            palette_mode,
463        )?;
464        dirty_bounds = Some((
465            0,
466            0,
467            frame.width.saturating_sub(1) as i32,
468            frame.height.saturating_sub(1) as i32,
469        ));
470    }
471
472    for c in cmd.iter() {
473        match c {
474            RenderCommand::ClearColor(color) => {
475                let w = frame.width as i32;
476                let h = frame.height as i32;
477                let clear_color = apply_post(*color, screen_tint, palette_mode);
478                for y in 0..h {
479                    for x in 0..w {
480                        fb.draw_iter([Pixel(Point::new(x, y), clear_color)])
481                            .map_err(|_| {
482                                RenderError::InvalidInput("draw target rejected clear write")
483                            })?;
484                    }
485                }
486            }
487            RenderCommand::ClearDepth(value) => {
488                frame.zbuffer.fill(*value);
489            }
490            RenderCommand::Draw(primitive) => {
491                match primitive {
492                    DrawPrimitive::LightmappedTriangle {
493                        points,
494                        depths,
495                        ws,
496                        surface_uvs,
497                        lm_uvs,
498                        texture_id,
499                        lightmap_id,
500                        brightness,
501                        dynamic_tint,
502                    } => {
503                        draw_zbuffered_lightmapped_mapped(
504                            *points,
505                            *depths,
506                            *ws,
507                            *surface_uvs,
508                            *lm_uvs,
509                            *texture_id,
510                            *lightmap_id,
511                            *brightness,
512                            *dynamic_tint,
513                            fog,
514                            texture_manager,
515                            fb,
516                            frame.zbuffer,
517                            frame.width,
518                            TextureMapping::PerspectiveCorrect,
519                            stipple_mode,
520                            screen_tint,
521                            palette_mode,
522                        );
523                    }
524                    DrawPrimitive::TexturedTriangle { .. }
525                    | DrawPrimitive::TexturedTriangleWithDepth { .. } => {
526                        draw_zbuffered_with_textures_mapped(
527                            primitive.clone(),
528                            fb,
529                            frame.zbuffer,
530                            frame.width,
531                            texture_manager,
532                            fog,
533                            dither,
534                            TextureMapping::PerspectiveCorrect,
535                            stipple_mode,
536                            screen_tint,
537                            palette_mode,
538                        );
539                    }
540                    _ => {
541                        let prim = tint_primitive(primitive, screen_tint, palette_mode);
542                        draw_zbuffered_with_effects(
543                            prim,
544                            fb,
545                            frame.zbuffer,
546                            frame.width,
547                            fog,
548                            dither,
549                        );
550                    }
551                }
552                let (min_x, min_y, max_x, max_y) = primitive_bounds(primitive);
553                if let Some((min_x, min_y, max_x, max_y)) =
554                    clamp_bounds_to_frame(min_x, min_y, max_x, max_y, frame.width, frame.height)
555                {
556                    dirty_bounds = Some(match dirty_bounds {
557                        Some((cx0, cy0, cx1, cy1)) => (
558                            cx0.min(min_x),
559                            cy0.min(min_y),
560                            cx1.max(max_x),
561                            cy1.max(max_y),
562                        ),
563                        None => (min_x, min_y, max_x, max_y),
564                    });
565                }
566            }
567        }
568    }
569
570    let region = dirty_bounds.and_then(|(x0, y0, x1, y1)| DirtyRegion::from_bounds(x0, y0, x1, y1));
571    Ok(region)
572}
573
574pub fn execute_commands_tiled<D, const MAX: usize, const BIN_CAP: usize>(
575    fb: &mut D,
576    frame: &mut FrameCtx<'_>,
577    cmd: &CommandBuffer<MAX>,
578    tile: crate::tilebin::TileConfig,
579    fog: Option<&FogConfig>,
580) -> Result<crate::tilebin::TileBinStats, RenderError>
581where
582    D: DrawTarget<Color = Rgb565> + OriginDimensions,
583    D::Error: Debug,
584{
585    execute_commands_tiled_effects::<D, MAX, BIN_CAP>(
586        fb,
587        frame,
588        cmd,
589        tile,
590        fog,
591        None,
592        None,
593        StippleMode::Off,
594        PaletteMode::Off,
595        None,
596        [0.0, 0.0, -1.0],
597    )
598}
599
600pub fn execute_commands_tiled_effects<D, const MAX: usize, const BIN_CAP: usize>(
601    fb: &mut D,
602    frame: &mut FrameCtx<'_>,
603    cmd: &CommandBuffer<MAX>,
604    tile: crate::tilebin::TileConfig,
605    fog: Option<&FogConfig>,
606    dither: Option<&DitherConfig>,
607    screen_tint: Option<ScreenTint>,
608    _stipple_mode: StippleMode,
609    palette_mode: PaletteMode,
610    sky: Option<SkyConfig>,
611    camera_dir: [f32; 3],
612) -> Result<crate::tilebin::TileBinStats, RenderError>
613where
614    D: DrawTarget<Color = Rgb565> + OriginDimensions,
615    D::Error: Debug,
616{
617    frame.validate()?;
618    if let Some(sky_cfg) = sky {
619        draw_sky_background(
620            fb,
621            frame.width,
622            frame.height,
623            sky_cfg,
624            camera_dir,
625            screen_tint,
626            palette_mode,
627        )?;
628    }
629    let (bins, stats) =
630        crate::tilebin::build_bins::<MAX, BIN_CAP>(cmd, frame.width, frame.height, tile)?;
631    let mut executed_draw = [false; MAX];
632
633    for command in cmd.iter() {
634        match command {
635            RenderCommand::ClearColor(color) => {
636                let w = frame.width as i32;
637                let h = frame.height as i32;
638                let clear_color = apply_post(*color, screen_tint, palette_mode);
639                for y in 0..h {
640                    for x in 0..w {
641                        fb.draw_iter([Pixel(Point::new(x, y), clear_color)])
642                            .map_err(|_| {
643                                RenderError::InvalidInput("draw target rejected clear write")
644                            })?;
645                    }
646                }
647            }
648            RenderCommand::ClearDepth(value) => frame.zbuffer.fill(*value),
649            RenderCommand::Draw(_) => {}
650        }
651    }
652
653    for bin in bins.iter() {
654        for idx in bin.iter().copied() {
655            if idx >= MAX || executed_draw[idx] {
656                continue;
657            }
658            let Some(RenderCommand::Draw(primitive)) = cmd.get(idx) else {
659                continue;
660            };
661            let prim = tint_primitive(primitive, screen_tint, palette_mode);
662            draw_zbuffered_with_effects(prim, fb, frame.zbuffer, frame.width, fog, dither);
663            executed_draw[idx] = true;
664        }
665    }
666
667    Ok(stats)
668}
669
670/// Execute commands using 2xSSAA (Super-Sampling Anti-Aliasing) scanline rasterization.
671#[cfg(feature = "aa")]
672pub fn execute_commands_2xssaa<D, const MAX: usize>(
673    fb: &mut D,
674    frame: &mut FrameCtx<'_>,
675    cmd_buf: &CommandBuffer<MAX>,
676) -> Result<(), RenderError>
677where
678    D: DrawTarget<Color = Rgb565> + crate::draw::ReadPixel,
679    <D as DrawTarget>::Error: Debug,
680{
681    frame.validate()?;
682    for c in cmd_buf.iter() {
683        if let RenderCommand::Draw(primitive) = c {
684            crate::draw::draw_zbuffered_2xssaa(primitive.clone(), fb, frame.zbuffer, frame.width);
685        }
686    }
687    Ok(())
688}