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