Skip to main content

embedded_3dgfx/
raycast.rs

1//! High-performance 2.5D DDA Raycasting and Mode 7 True 3D Perspective Floorcasting Engine.
2//!
3//! Provides ultra-fast 60 FPS 2.5D environment rendering for microcontrollers and embedded displays,
4//! including Mode 7 perspective floor/ceiling projection, DDA wall column raycasting, distance shading,
5//! and billboard sprite depth sorting.
6
7use embedded_graphics_core::pixelcolor::IntoStorage;
8use embedded_graphics_core::pixelcolor::Rgb565;
9use embedded_graphics_core::pixelcolor::RgbColor;
10#[allow(unused_imports)]
11use micromath::F32Ext;
12
13/// 16x16 Texture Sampler for 2.5D Walls, Floors, and Ceilings.
14#[derive(Debug, Clone, Copy)]
15pub struct RaycastTexture {
16    pub pixels: [Rgb565; 256],
17}
18
19impl RaycastTexture {
20    /// Creates a 16x16 texture from a flat array of 256 Rgb565 pixels.
21    pub const fn new(pixels: [Rgb565; 256]) -> Self {
22        Self { pixels }
23    }
24
25    /// Samples a pixel at (u, v) normalized coordinates [0..15].
26    #[inline(always)]
27    pub fn sample(&self, u: usize, v: usize) -> Rgb565 {
28        self.pixels[(v & 15) * 16 + (u & 15)]
29    }
30}
31
32/// Billboard sprite instance for 3D world placement.
33#[derive(Debug, Clone, Copy)]
34pub struct RaycastSprite {
35    pub x: f32,
36    pub y: f32,
37    pub texture_id: u8,
38    pub active: bool,
39}
40
41/// Mode 7 True 3D Perspective Floor and Ceiling Renderer.
42#[derive(Debug, Clone)]
43pub struct Mode7Renderer {
44    width: usize,
45    height: usize,
46    fov_scale: f32,
47}
48
49impl Mode7Renderer {
50    pub fn new(width: usize, height: usize) -> Self {
51        Self {
52            width,
53            height,
54            fov_scale: 0.66,
55        }
56    }
57
58    /// Set field of view scale factor (default: 0.66 for ~66 deg FOV).
59    pub fn set_fov_scale(&mut self, fov_scale: f32) {
60        self.fov_scale = fov_scale;
61    }
62
63    /// Render perspective-correct floor and ceiling into a 32-bit packed Rgb565 buffer (`u32` pairs).
64    pub fn render_floor_and_ceiling(
65        &self,
66        pos_x: f32,
67        pos_y: f32,
68        angle: f32,
69        head_bob: i32,
70        floor_color_a: Rgb565,
71        floor_color_b: Rgb565,
72        ceil_color_a: Rgb565,
73        ceil_color_b: Rgb565,
74        framebuf_u32: &mut [u32],
75    ) {
76        let dir_x = angle.cos();
77        let dir_y = angle.sin();
78
79        let plane_x = -dir_y * self.fov_scale;
80        let plane_y = dir_x * self.fov_scale;
81
82        // Frustum boundary ray vectors aligned with display orientation
83        let ray_dir_x0 = dir_x + plane_x;
84        let ray_dir_y0 = dir_y + plane_y;
85        let ray_dir_x1 = dir_x - plane_x;
86        let ray_dir_y1 = dir_y - plane_y;
87
88        let center_y = (self.height / 2) as i32 + head_bob;
89        let stride_u32 = self.width / 2;
90
91        // 1. Mode 7 Floor Projection (horizon down to bottom)
92        let floor_start = center_y.clamp(0, self.height as i32) as usize;
93        for y in floor_start..self.height {
94            let p = (y as i32 - center_y).max(1);
95            let row_dist = (0.5 * self.height as f32) / (p as f32);
96
97            let floor_step_x = row_dist * (ray_dir_x1 - ray_dir_x0) / (self.width as f32);
98            let floor_step_y = row_dist * (ray_dir_y1 - ray_dir_y0) / (self.width as f32);
99
100            let mut floor_x = pos_x + row_dist * ray_dir_x0;
101            let mut floor_y = pos_y + row_dist * ray_dir_y0;
102
103            let f_shade = (1.0 / (1.0 + row_dist * 0.18)).clamp(0.08, 0.85);
104            let row_u32 = y * stride_u32;
105
106            for x_u32 in 0..stride_u32 {
107                let cell_x = floor_x as i32;
108                let cell_y = floor_y as i32;
109
110                let tx = ((floor_x - cell_x as f32) * 16.0) as usize & 15;
111                let ty = ((floor_y - cell_y as f32) * 16.0) as usize & 15;
112
113                let is_grout = (tx == 0) || (ty == 0);
114                let f_base = if is_grout {
115                    Rgb565::new(4, 3, 2)
116                } else if (cell_x + cell_y) % 2 == 0 {
117                    floor_color_a
118                } else {
119                    floor_color_b
120                };
121
122                let shaded = apply_shade(f_base, f_shade);
123                framebuf_u32[row_u32 + x_u32] = pack_rgb565_u32(shaded);
124
125                floor_x += floor_step_x * 2.0;
126                floor_y += floor_step_y * 2.0;
127            }
128        }
129
130        // 2. Mode 7 Ceiling Projection (top down to horizon)
131        let ceil_end = center_y.clamp(0, self.height as i32) as usize;
132        for y in 0..ceil_end {
133            let p = (center_y - y as i32).max(1);
134            let row_dist = (0.5 * self.height as f32) / (p as f32);
135
136            let ceil_step_x = row_dist * (ray_dir_x1 - ray_dir_x0) / (self.width as f32);
137            let ceil_step_y = row_dist * (ray_dir_y1 - ray_dir_y0) / (self.width as f32);
138
139            let mut ceil_x = pos_x + row_dist * ray_dir_x0;
140            let mut ceil_y = pos_y + row_dist * ray_dir_y0;
141
142            let c_shade = (1.0 / (1.0 + row_dist * 0.22)).clamp(0.08, 0.7);
143            let row_u32 = y * stride_u32;
144
145            for x_u32 in 0..stride_u32 {
146                let cell_x = ceil_x as i32;
147                let cell_y = ceil_y as i32;
148
149                let tx = ((ceil_x - cell_x as f32) * 16.0) as usize & 15;
150                let ty = ((ceil_y - cell_y as f32) * 16.0) as usize & 15;
151
152                let is_beam = (tx == 0) || (ty == 0);
153                let c_base = if is_beam {
154                    Rgb565::new(2, 4, 8)
155                } else if (cell_x + cell_y) % 2 == 0 {
156                    ceil_color_a
157                } else {
158                    ceil_color_b
159                };
160
161                let shaded = apply_shade(c_base, c_shade);
162                framebuf_u32[row_u32 + x_u32] = pack_rgb565_u32(shaded);
163
164                ceil_x += ceil_step_x * 2.0;
165                ceil_y += ceil_step_y * 2.0;
166            }
167        }
168    }
169
170    /// Fast-path floor and ceiling renderer for flat/checkerboard patterns without per-pixel distance shading.
171    pub fn render_floor_and_ceiling_fast(
172        &self,
173        pos_x: f32,
174        pos_y: f32,
175        angle: f32,
176        head_bob: i32,
177        floor_color_a: Rgb565,
178        floor_color_b: Rgb565,
179        ceil_color_a: Rgb565,
180        ceil_color_b: Rgb565,
181        framebuf_u32: &mut [u32],
182    ) {
183        let dir_x = angle.cos();
184        let dir_y = angle.sin();
185        let plane_x = -dir_y * self.fov_scale;
186        let plane_y = dir_x * self.fov_scale;
187
188        let horizon = (self.height / 2) as i32 + head_bob;
189
190        let floor_a_u32 = pack_rgb565_u32(floor_color_a);
191        let floor_b_u32 = pack_rgb565_u32(floor_color_b);
192        let ceiling_a_u32 = pack_rgb565_u32(ceil_color_a);
193        let ceiling_b_u32 = pack_rgb565_u32(ceil_color_b);
194
195        let fov_inv = 2.0 / (self.width as f32);
196        let stride_u32 = self.width / 2;
197
198        for y in 0..self.height {
199            let p = (y as i32 - horizon) as f32;
200            if p == 0.0 {
201                continue;
202            }
203
204            let is_floor = p > 0.0;
205            let row_distance = if is_floor {
206                (self.height as f32 * 0.625) / p
207            } else {
208                (self.height as f32 * 0.625) / -p
209            };
210
211            let step_x = -row_distance * (plane_x * fov_inv) * 2.0;
212            let step_y = -row_distance * (plane_y * fov_inv) * 2.0;
213
214            let mut curr_x = pos_x + row_distance * (dir_x + plane_x);
215            let mut curr_y = pos_y + row_distance * (dir_y + plane_y);
216
217            let row_u32 = y * stride_u32;
218            let (col_a, col_b) = if is_floor {
219                (floor_a_u32, floor_b_u32)
220            } else {
221                (ceiling_a_u32, ceiling_b_u32)
222            };
223
224            for x2 in 0..stride_u32 {
225                let tx = (curr_x as usize) & 1;
226                let ty = (curr_y as usize) & 1;
227                let pixel_u32 = if (tx ^ ty) == 0 { col_a } else { col_b };
228
229                if row_u32 + x2 < framebuf_u32.len() {
230                    framebuf_u32[row_u32 + x2] = pixel_u32;
231                }
232                curr_x += step_x;
233                curr_y += step_y;
234            }
235        }
236    }
237}
238
239/// DDA Raycaster Engine for 2.5D Grid Maps.
240#[derive(Debug, Clone)]
241pub struct Raycaster2D {
242    width: usize,
243    height: usize,
244    fov_scale: f32,
245}
246
247impl Raycaster2D {
248    pub fn new(width: usize, height: usize) -> Self {
249        Self {
250            width,
251            height,
252            fov_scale: 0.66,
253        }
254    }
255
256    /// Render 3D textured walls over an existing Mode 7 / background framebuffer.
257    pub fn render_walls(
258        &self,
259        pos_x: f32,
260        pos_y: f32,
261        angle: f32,
262        head_bob: i32,
263        map: &[u8],
264        map_size: usize,
265        wall_colors: &[Rgb565],
266        z_buffer: &mut [f32],
267        framebuf_u32: &mut [u32],
268    ) {
269        let dir_x = angle.cos();
270        let dir_y = angle.sin();
271
272        let plane_x = -dir_y * self.fov_scale;
273        let plane_y = dir_x * self.fov_scale;
274
275        let stride_u32 = self.width / 2;
276
277        for x in (0..self.width).step_by(4) {
278            let camera_x = -(2.0 * (x as f32) / (self.width as f32) - 1.0);
279            let ray_dir_x = dir_x + plane_x * camera_x;
280            let ray_dir_y = dir_y + plane_y * camera_x;
281
282            let mut map_x = pos_x as i32;
283            let mut map_y = pos_y as i32;
284
285            let delta_dist_x = if ray_dir_x == 0.0 {
286                1e30
287            } else {
288                (1.0 / ray_dir_x).abs()
289            };
290            let delta_dist_y = if ray_dir_y == 0.0 {
291                1e30
292            } else {
293                (1.0 / ray_dir_y).abs()
294            };
295
296            let (step_x, mut side_dist_x) = if ray_dir_x < 0.0 {
297                (-1, (pos_x - map_x as f32) * delta_dist_x)
298            } else {
299                (1, (map_x as f32 + 1.0 - pos_x) * delta_dist_x)
300            };
301
302            let (step_y, mut side_dist_y) = if ray_dir_y < 0.0 {
303                (-1, (pos_y - map_y as f32) * delta_dist_y)
304            } else {
305                (1, (map_y as f32 + 1.0 - pos_y) * delta_dist_y)
306            };
307
308            let mut hit_wall = 0u8;
309            let mut side = 0u8;
310            let mut steps = 0;
311
312            while hit_wall == 0 && steps < 24 {
313                if side_dist_x < side_dist_y {
314                    side_dist_x += delta_dist_x;
315                    map_x += step_x;
316                    side = 0;
317                } else {
318                    side_dist_y += delta_dist_y;
319                    map_y += step_y;
320                    side = 1;
321                }
322
323                if map_x >= 0 && map_x < map_size as i32 && map_y >= 0 && map_y < map_size as i32 {
324                    let tile = map[(map_y as usize) * map_size + (map_x as usize)];
325                    if tile > 0 {
326                        hit_wall = tile;
327                    }
328                } else {
329                    hit_wall = 1;
330                }
331                steps += 1;
332            }
333
334            let perp_wall_dist = if side == 0 {
335                side_dist_x - delta_dist_x
336            } else {
337                side_dist_y - delta_dist_y
338            }
339            .max(0.1);
340
341            for i in 0..4 {
342                if x + i < self.width {
343                    z_buffer[x + i] = perp_wall_dist;
344                }
345            }
346
347            let line_height = (self.height as f32 / perp_wall_dist) as i32;
348            let center_y = (self.height / 2) as i32 + head_bob;
349
350            let draw_start = (center_y - line_height / 2).clamp(0, self.height as i32 - 1) as usize;
351            let draw_end = (center_y + line_height / 2).clamp(0, self.height as i32 - 1) as usize;
352
353            let mut wall_x = if side == 0 {
354                pos_y + perp_wall_dist * ray_dir_y
355            } else {
356                pos_x + perp_wall_dist * ray_dir_x
357            };
358            wall_x -= wall_x.floor();
359            let tex_x = ((wall_x * 16.0) as usize).clamp(0, 15);
360
361            let shade_factor = 1.0 / (1.0 + perp_wall_dist * 0.18);
362            let color_idx = (hit_wall as usize).saturating_sub(1) % wall_colors.len().max(1);
363            let base_color = if wall_colors.is_empty() {
364                Rgb565::RED
365            } else {
366                wall_colors[color_idx]
367            };
368            let base_color = if side == 1 {
369                apply_shade(base_color, 0.7)
370            } else {
371                base_color
372            };
373
374            let x_u32 = x / 2;
375            let tex_step = 16.0 / (line_height as f32).max(1.0);
376            let mut tex_pos = ((draw_start as i32 - center_y + line_height / 2) as f32) * tex_step;
377
378            for y in draw_start..=draw_end {
379                let tex_y = (tex_pos as usize) & 15;
380                tex_pos += tex_step;
381
382                let is_pattern = (tex_x == 0) || (tex_y == 0);
383                let pixel = if is_pattern {
384                    apply_shade(base_color, 0.5)
385                } else {
386                    base_color
387                };
388                let shaded = apply_shade(pixel, shade_factor);
389                let pixel_u32 = pack_rgb565_u32(shaded);
390
391                let idx = y * stride_u32 + x_u32;
392                if idx < framebuf_u32.len() {
393                    framebuf_u32[idx] = pixel_u32;
394                    if idx + 1 < framebuf_u32.len() {
395                        framebuf_u32[idx + 1] = pixel_u32;
396                    }
397                }
398            }
399        }
400    }
401
402    /// Render 3D textured walls using a **per-tile pixel callback** instead of a flat colour
403    /// palette.
404    ///
405    /// The `get_pixel` closure receives `(tile_id, tex_x, tex_y)` and returns the raw
406    /// `Rgb565` texel **before** distance shading is applied.  This lets callers supply
407    /// hand-painted bitmaps, procedural patterns (brick mortar, tech panels, hazard
408    /// stripes …) or atlas lookups without the overhead of a full texture object.
409    ///
410    /// All other parameters are identical to [`Self::render_walls`].
411    pub fn render_walls_textured<F>(
412        &self,
413        pos_x: f32,
414        pos_y: f32,
415        angle: f32,
416        head_bob: i32,
417        map: &[u8],
418        map_size: usize,
419        z_buffer: &mut [f32],
420        framebuf_u32: &mut [u32],
421        get_pixel: F,
422    ) where
423        F: Fn(u8, usize, usize) -> Rgb565,
424    {
425        let dir_x = angle.cos();
426        let dir_y = angle.sin();
427
428        let plane_x = -dir_y * self.fov_scale;
429        let plane_y = dir_x * self.fov_scale;
430
431        let stride_u32 = self.width / 2;
432
433        for x in (0..self.width).step_by(4) {
434            let camera_x = -(2.0 * (x as f32) / (self.width as f32) - 1.0);
435            let ray_dir_x = dir_x + plane_x * camera_x;
436            let ray_dir_y = dir_y + plane_y * camera_x;
437
438            let mut map_x = pos_x as i32;
439            let mut map_y = pos_y as i32;
440
441            let delta_dist_x = if ray_dir_x == 0.0 {
442                1e30
443            } else {
444                (1.0 / ray_dir_x).abs()
445            };
446            let delta_dist_y = if ray_dir_y == 0.0 {
447                1e30
448            } else {
449                (1.0 / ray_dir_y).abs()
450            };
451
452            let (step_x, mut side_dist_x) = if ray_dir_x < 0.0 {
453                (-1, (pos_x - map_x as f32) * delta_dist_x)
454            } else {
455                (1, (map_x as f32 + 1.0 - pos_x) * delta_dist_x)
456            };
457
458            let (step_y, mut side_dist_y) = if ray_dir_y < 0.0 {
459                (-1, (pos_y - map_y as f32) * delta_dist_y)
460            } else {
461                (1, (map_y as f32 + 1.0 - pos_y) * delta_dist_y)
462            };
463
464            let mut hit_wall = 0u8;
465            let mut side = 0u8;
466            let mut steps = 0;
467
468            while hit_wall == 0 && steps < 24 {
469                if side_dist_x < side_dist_y {
470                    side_dist_x += delta_dist_x;
471                    map_x += step_x;
472                    side = 0;
473                } else {
474                    side_dist_y += delta_dist_y;
475                    map_y += step_y;
476                    side = 1;
477                }
478
479                if map_x >= 0 && map_x < map_size as i32 && map_y >= 0 && map_y < map_size as i32 {
480                    let tile = map[(map_y as usize) * map_size + (map_x as usize)];
481                    if tile > 0 {
482                        hit_wall = tile;
483                    }
484                } else {
485                    hit_wall = 1;
486                }
487                steps += 1;
488            }
489
490            let perp_wall_dist = if side == 0 {
491                side_dist_x - delta_dist_x
492            } else {
493                side_dist_y - delta_dist_y
494            }
495            .max(0.1);
496
497            for i in 0..4 {
498                if x + i < self.width {
499                    z_buffer[x + i] = perp_wall_dist;
500                }
501            }
502
503            let line_height = (self.height as f32 / perp_wall_dist) as i32;
504            let center_y = (self.height / 2) as i32 + head_bob;
505
506            let draw_start = (center_y - line_height / 2).clamp(0, self.height as i32 - 1) as usize;
507            let draw_end = (center_y + line_height / 2).clamp(0, self.height as i32 - 1) as usize;
508
509            // Fractional wall-hit position → texture column
510            let mut wall_x = if side == 0 {
511                pos_y + perp_wall_dist * ray_dir_y
512            } else {
513                pos_x + perp_wall_dist * ray_dir_x
514            };
515            wall_x -= wall_x.floor();
516            let tex_x = ((wall_x * 16.0) as usize).clamp(0, 15);
517
518            // Distance shading: darker further away; side faces at 70% brightness
519            let base_shade = 1.0 / (1.0 + perp_wall_dist * 0.18);
520            let shade = if side == 1 {
521                base_shade * 0.7
522            } else {
523                base_shade
524            };
525            let shade_q8 = (shade.clamp(0.05, 1.0) * 256.0) as u32;
526
527            let x_u32 = x / 2;
528            let tex_step = 16.0 / (line_height as f32).max(1.0);
529            let mut tex_pos = ((draw_start as i32 - center_y + line_height / 2) as f32) * tex_step;
530
531            for y in draw_start..=draw_end {
532                let tex_y = (tex_pos as usize) & 15;
533                tex_pos += tex_step;
534
535                let raw_color = get_pixel(hit_wall, tex_x, tex_y);
536                let shaded = apply_shade_q8(raw_color, shade_q8);
537                let pixel_u32 = pack_rgb565_u32(shaded);
538
539                let idx = y * stride_u32 + x_u32;
540                if idx < framebuf_u32.len() {
541                    framebuf_u32[idx] = pixel_u32;
542                    if idx + 1 < framebuf_u32.len() {
543                        framebuf_u32[idx + 1] = pixel_u32;
544                    }
545                }
546            }
547        }
548    }
549
550    /// Fast-path sprite rendering using a **per-sprite setup callback** and per-pixel color getter.
551    ///
552    /// `prepare_sprite(sprite, transform_y)` is called **ONCE PER SPRITE** with the perpendicular
553    /// distance `transform_y`. Return `None` to skip rendering the sprite, or `Some(data)` to pass
554    /// pre-calculated properties (such as pre-shaded colors) to `get_pixel`.
555    pub fn render_sprites_fast<P, F, T>(
556        &self,
557        pos_x: f32,
558        pos_y: f32,
559        angle: f32,
560        head_bob: i32,
561        sprites: &[RaycastSprite],
562        z_buffer: &[f32],
563        framebuf: &mut [Rgb565],
564        prepare_sprite: P,
565        get_pixel: F,
566    ) where
567        P: Fn(&RaycastSprite, f32) -> Option<T>,
568        F: Fn(&T, usize, usize, usize, usize) -> Option<Rgb565>,
569    {
570        let dir_x = angle.cos();
571        let dir_y = angle.sin();
572        let plane_x = -dir_y * self.fov_scale;
573        let plane_y = dir_x * self.fov_scale;
574        let inv_det = 1.0 / (plane_x * dir_y - dir_x * plane_y);
575        let center_y = (self.height / 2) as i32 + head_bob;
576
577        for sprite in sprites {
578            if !sprite.active {
579                continue;
580            }
581
582            let sx = sprite.x - pos_x;
583            let sy = sprite.y - pos_y;
584
585            let transform_x = inv_det * (dir_y * sx - dir_x * sy);
586            let transform_y = inv_det * (-plane_y * sx + plane_x * sy);
587
588            // Only render sprites in front of the camera
589            if transform_y <= 0.3 {
590                continue;
591            }
592
593            let sprite_data = match prepare_sprite(sprite, transform_y) {
594                Some(d) => d,
595                None => continue,
596            };
597
598            let sprite_screen_x =
599                ((self.width as f32 / 2.0) * (1.0 - transform_x / transform_y)) as i32;
600            let sprite_height = ((self.height as f32 / transform_y).abs()) as i32;
601            let sprite_width = sprite_height;
602
603            let draw_start_y =
604                (center_y - sprite_height / 2).clamp(0, self.height as i32 - 1) as usize;
605            let draw_end_y =
606                (center_y + sprite_height / 2).clamp(0, self.height as i32 - 1) as usize;
607
608            let draw_start_x =
609                (sprite_screen_x - sprite_width / 2).clamp(0, self.width as i32 - 1) as usize;
610            let draw_end_x =
611                (sprite_screen_x + sprite_width / 2).clamp(0, self.width as i32 - 1) as usize;
612
613            for stripe_x in draw_start_x..draw_end_x {
614                // Z-buffer occlusion: skip columns behind a closer wall
615                if stripe_x >= z_buffer.len() || transform_y >= z_buffer[stripe_x] {
616                    continue;
617                }
618
619                for y in draw_start_y..draw_end_y {
620                    if let Some(color) =
621                        get_pixel(&sprite_data, stripe_x, y, draw_start_y, draw_end_y)
622                    {
623                        let idx = y * self.width + stripe_x;
624                        if idx < framebuf.len() {
625                            framebuf[idx] = color;
626                        }
627                    }
628                }
629            }
630        }
631    }
632
633    /// Render billboarded 2.5D sprites (enemies, items, projectiles) into a **per-pixel**
634    /// `Rgb565` framebuffer with z-buffer occlusion against previously rendered walls.
635    pub fn render_sprites<F>(
636        &self,
637        pos_x: f32,
638        pos_y: f32,
639        angle: f32,
640        head_bob: i32,
641        sprites: &[RaycastSprite],
642        z_buffer: &[f32],
643        framebuf: &mut [Rgb565],
644        get_color: F,
645    ) where
646        F: Fn(&RaycastSprite, f32) -> Option<Rgb565>,
647    {
648        self.render_sprites_fast(
649            pos_x,
650            pos_y,
651            angle,
652            head_bob,
653            sprites,
654            z_buffer,
655            framebuf,
656            |sprite, _dist| Some(*sprite),
657            |sprite, _stripe_x, y, draw_start_y, draw_end_y| {
658                let norm_y = if draw_end_y > draw_start_y {
659                    (y - draw_start_y) as f32 / (draw_end_y - draw_start_y) as f32
660                } else {
661                    0.0
662                };
663                get_color(sprite, norm_y)
664            },
665        );
666    }
667}
668
669/// Apply a Q8 fixed-point integer distance-based shade factor `[0, 256]` to an `Rgb565` colour.
670#[inline(always)]
671pub fn apply_shade_q8(color: Rgb565, shade_q8: u32) -> Rgb565 {
672    let raw = color.into_storage() as u32;
673    let r = ((((raw >> 11) & 0x1F) * shade_q8) >> 8).min(31);
674    let g = ((((raw >> 5) & 0x3F) * shade_q8) >> 8).min(63);
675    let b = (((raw & 0x1F) * shade_q8) >> 8).min(31);
676    Rgb565::new(r as u8, g as u8, b as u8)
677}
678
679/// Apply a distance-based shade factor `[0.0, 1.0]` to an `Rgb565` colour using fixed-point integer math.
680#[inline(always)]
681pub fn apply_shade(color: Rgb565, factor: f32) -> Rgb565 {
682    let shade_q8 = (factor.clamp(0.05, 1.0) * 256.0) as u32;
683    apply_shade_q8(color, shade_q8)
684}
685
686/// Pack two identical `Rgb565` pixels into one `u32` for 32-bit-wide framebuffer writes.
687///
688/// Matches the internal packing used by [`Raycaster2D`] and [`Mode7Renderer`] so
689/// callers can fill adjacent pixel pairs in a single store operation.
690#[inline(always)]
691pub fn pack_rgb565_u32(color: Rgb565) -> u32 {
692    let raw = color.into_storage() as u32;
693    (raw << 16) | raw
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn test_mode7_renderer_execution() {
702        let renderer = Mode7Renderer::new(240, 256);
703        let mut framebuf_u32 = [0u32; (240 * 256) / 2];
704
705        renderer.render_floor_and_ceiling(
706            2.5,
707            2.5,
708            0.0,
709            0,
710            Rgb565::RED,
711            Rgb565::GREEN,
712            Rgb565::BLUE,
713            Rgb565::YELLOW,
714            &mut framebuf_u32,
715        );
716
717        let non_zero_pixels = framebuf_u32.iter().filter(|&&p| p != 0).count();
718        assert!(
719            non_zero_pixels > 0,
720            "Mode7Renderer should render non-zero pixels"
721        );
722    }
723
724    #[test]
725    fn test_raycaster2d_execution() {
726        let raycaster = Raycaster2D::new(240, 256);
727        let map = [1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1];
728        let wall_colors = [Rgb565::RED, Rgb565::GREEN];
729        let mut z_buffer = [0.0f32; 240];
730        let mut framebuf_u32 = [0u32; (240 * 256) / 2];
731
732        raycaster.render_walls(
733            1.5,
734            1.5,
735            0.0,
736            0,
737            &map,
738            4,
739            &wall_colors,
740            &mut z_buffer,
741            &mut framebuf_u32,
742        );
743
744        assert!(z_buffer[0] > 0.0, "Z-buffer should record wall distances");
745    }
746
747    #[test]
748    fn test_render_walls_textured_writes_pixels_and_z_buffer() {
749        let raycaster = Raycaster2D::new(240, 256);
750        // 4×4 map with a wall ring around the inside
751        let map = [1u8, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1];
752        let mut z_buffer = [0.0f32; 240];
753        let mut framebuf_u32 = [0u32; (240 * 256) / 2];
754
755        raycaster.render_walls_textured(
756            1.5,
757            1.5,
758            0.0,
759            0,
760            &map,
761            4,
762            &mut z_buffer,
763            &mut framebuf_u32,
764            |tile, _tex_x, _tex_y| {
765                // Tile 1 → green; anything else → red
766                if tile == 1 {
767                    Rgb565::GREEN
768                } else {
769                    Rgb565::RED
770                }
771            },
772        );
773
774        assert!(
775            z_buffer[0] > 0.0,
776            "render_walls_textured must write the z-buffer"
777        );
778        let written = framebuf_u32.iter().any(|&p| p != 0);
779        assert!(
780            written,
781            "render_walls_textured must write at least one pixel"
782        );
783    }
784
785    #[test]
786    fn test_render_sprites_draws_behind_z_buffer() {
787        let raycaster = Raycaster2D::new(240, 256);
788
789        // Place a sprite at (5, 1.5) while camera looks along +X from (1.5, 1.5).
790        // Set z_buffer to a large value so the sprite is NOT occluded.
791        let sprites = [RaycastSprite {
792            x: 5.0,
793            y: 1.5,
794            texture_id: 0,
795            active: true,
796        }];
797        let z_buffer = [100.0f32; 240];
798        let mut framebuf = [Rgb565::BLACK; 240 * 256];
799
800        raycaster.render_sprites(
801            1.5,
802            1.5,
803            0.0, // facing +X
804            0,
805            &sprites,
806            &z_buffer,
807            &mut framebuf,
808            |_sprite, _norm_y| Some(Rgb565::YELLOW),
809        );
810
811        // At least one pixel should have been coloured yellow
812        let yellow_pixels = framebuf.iter().filter(|&&p| p == Rgb565::YELLOW).count();
813        assert!(
814            yellow_pixels > 0,
815            "render_sprites should draw the sprite when unoccluded"
816        );
817    }
818
819    #[test]
820    fn test_render_sprites_occluded_by_z_buffer() {
821        let raycaster = Raycaster2D::new(240, 256);
822
823        // Same setup but z_buffer has tiny values → sprite is behind walls → nothing drawn
824        let sprites = [RaycastSprite {
825            x: 5.0,
826            y: 1.5,
827            texture_id: 0,
828            active: true,
829        }];
830        let z_buffer = [0.1f32; 240]; // all columns show a very close wall
831        let mut framebuf = [Rgb565::BLACK; 240 * 256];
832
833        raycaster.render_sprites(
834            1.5,
835            1.5,
836            0.0,
837            0,
838            &sprites,
839            &z_buffer,
840            &mut framebuf,
841            |_sprite, _norm_y| Some(Rgb565::YELLOW),
842        );
843
844        let yellow_pixels = framebuf.iter().filter(|&&p| p == Rgb565::YELLOW).count();
845        assert_eq!(
846            yellow_pixels, 0,
847            "Occluded sprite should not write any pixels"
848        );
849    }
850
851    #[test]
852    fn test_apply_shade_public() {
853        // Full brightness (factor = 1.0) should be identity (within rounding)
854        let c = Rgb565::new(20, 40, 15);
855        let out = apply_shade(c, 1.0);
856        assert_eq!(out.r(), 20);
857        assert_eq!(out.g(), 40);
858        assert_eq!(out.b(), 15);
859
860        // Clamped at 0.05 — channels should still be non-zero for a bright input
861        let dark = apply_shade(Rgb565::new(31, 63, 31), 0.0);
862        assert!(dark.r() > 0);
863    }
864
865    #[test]
866    fn test_pack_rgb565_u32_public() {
867        let c = Rgb565::new(10, 20, 10);
868        let raw = c.into_storage() as u32;
869        let packed = pack_rgb565_u32(c);
870        assert_eq!(packed, (raw << 16) | raw);
871    }
872}