Skip to main content

dotzuki_engine/render/
framebuffer.rs

1//! Pixel framebuffer with incremental dirty-region tracking.
2//!
3//! This module provides [`FrameBuffer`], a configurable-resolution RGBA pixel
4//! buffer, and [`DirtyRegion`], which tracks which screen areas need
5//! re-rendering for incremental update.
6
7use crate::render::Rgba;
8use crate::render_config::RenderConfig;
9
10// ---------------------------------------------------------------------------
11// Constants
12// ---------------------------------------------------------------------------
13
14/// Game Boy tile size in pixels (8×8).
15///
16/// This is a tile-format constant — not a screen-resolution value.
17/// All positions and dimensions in this module are in pixel units,
18/// derived from [`RenderConfig`].
19pub const TILE_SIZE: u32 = 8;
20
21/// Bytes per pixel in the RGBA framebuffer.
22pub const BYTES_PER_PIXEL: usize = 4;
23
24// ---------------------------------------------------------------------------
25// DirtyRegion
26// ---------------------------------------------------------------------------
27
28/// A rectangular region of the screen that needs redrawing.
29///
30/// Dirty regions are used to implement incremental rendering: only pixels
31/// within dirty regions are re-rendered each frame, skipping unchanged areas.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct DirtyRegion {
34    pub x: i32,
35    pub y: i32,
36    pub width: u32,
37    pub height: u32,
38    /// Whether this region contains any dirty area. When `present` is false,
39    /// the entire screen is considered clean (no redraw needed).
40    pub present: bool,
41}
42
43impl DirtyRegion {
44    /// An empty dirty region (nothing to redraw).
45    pub fn empty() -> Self {
46        Self {
47            x: 0,
48            y: 0,
49            width: 0,
50            height: 0,
51            present: false,
52        }
53    }
54
55    /// A dirty region covering the entire screen.
56    pub fn full(width: u32, height: u32) -> Self {
57        Self {
58            x: 0,
59            y: 0,
60            width,
61            height,
62            present: true,
63        }
64    }
65
66    /// Create a dirty region at (x, y) with the given dimensions.
67    pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
68        Self {
69            x,
70            y,
71            width,
72            height,
73            present: true,
74        }
75    }
76
77    /// Union this region with another, producing the bounding box of both.
78    pub fn union(&self, other: &DirtyRegion) -> DirtyRegion {
79        if !self.present {
80            return *other;
81        }
82        if !other.present {
83            return *self;
84        }
85        let x1 = self.x.min(other.x);
86        let y1 = self.y.min(other.y);
87        let x2 = (self.x + self.width as i32).max(other.x + other.width as i32);
88        let y2 = (self.y + self.height as i32).max(other.y + other.height as i32);
89        DirtyRegion::new(x1, y1, (x2 - x1).max(0) as u32, (y2 - y1).max(0) as u32)
90    }
91
92    /// Check whether a pixel at (px, py) is within this dirty region.
93    /// Returns `false` when the region is not present (nothing to redraw).
94    #[inline]
95    pub fn contains_pixel(&self, px: u32, py: u32) -> bool {
96        if !self.present {
97            return false;
98        }
99        px as i32 >= self.x
100            && (px as i32) < self.x + self.width as i32
101            && py as i32 >= self.y
102            && (py as i32) < self.y + self.height as i32
103    }
104
105    /// Convert the dirty region to tile-space coordinates, rounding outward.
106    /// Returns (tile_x, tile_y, tile_width, tile_height).
107    pub fn to_tile_rect(&self, tile_size: u32) -> (i32, i32, u32, u32) {
108        if !self.present {
109            return (0, 0, 0, 0);
110        }
111        let ts = tile_size as i32;
112        let tx = if self.x >= 0 {
113            self.x / ts
114        } else {
115            (self.x - ts + 1) / ts
116        };
117        let ty = if self.y >= 0 {
118            self.y / ts
119        } else {
120            (self.y - ts + 1) / ts
121        };
122        let right = self.x + self.width as i32;
123        let bottom = self.y + self.height as i32;
124        let tw = ((right + ts - 1) / ts - tx).max(0) as u32;
125        let th = ((bottom + ts - 1) / ts - ty).max(0) as u32;
126        (tx, ty, tw, th)
127    }
128}
129
130impl Default for DirtyRegion {
131    fn default() -> Self {
132        Self::empty()
133    }
134}
135
136// ---------------------------------------------------------------------------
137// FrameBuffer
138// ---------------------------------------------------------------------------
139
140/// A pixel RGBA framebuffer with configurable dimensions.
141///
142/// The internal buffer is a flat array of RGBA bytes in row-major order.
143/// Pixel (x, y) starts at byte offset `(y * width + x) * 4`.
144#[derive(Debug, Clone)]
145pub struct FrameBuffer {
146    /// Raw RGBA pixel data, `width * height * 4` bytes.
147    pub data: Vec<u8>,
148    /// Screen width in pixels.
149    pub width: u32,
150    /// Screen height in pixels.
151    pub height: u32,
152    /// Accumulated dirty region for incremental rendering.
153    /// Callers mark areas that need redrawing; render functions
154    /// may skip pixels outside this region.
155    pub dirty_region: DirtyRegion,
156}
157
158impl FrameBuffer {
159    /// Create a new framebuffer with the given render config, cleared to the given color.
160    pub fn new(config: RenderConfig, clear_color: Rgba) -> Self {
161        let fb_size =
162            (config.screen_width as usize) * (config.screen_height as usize) * BYTES_PER_PIXEL;
163        let mut fb = Self {
164            data: vec![0; fb_size],
165            width: config.screen_width,
166            height: config.screen_height,
167            dirty_region: DirtyRegion::full(config.screen_width, config.screen_height),
168        };
169        fb.clear(clear_color);
170        fb
171    }
172
173    /// Mark the entire framebuffer as dirty (force full redraw).
174    pub fn mark_all_dirty(&mut self) {
175        self.dirty_region = DirtyRegion::full(self.width, self.height);
176    }
177
178    /// Mark a rectangular region as dirty.
179    pub fn mark_dirty_rect(&mut self, x: i32, y: i32, w: u32, h: u32) {
180        let r = DirtyRegion::new(x, y, w, h);
181        self.dirty_region = self.dirty_region.union(&r);
182    }
183
184    /// Mark a tile as dirty given its tile coordinates (tx, ty).
185    pub fn mark_dirty_tile(&mut self, tx: i32, ty: i32) {
186        let tile_size = TILE_SIZE as i32;
187        self.mark_dirty_rect(tx * tile_size, ty * tile_size, TILE_SIZE, TILE_SIZE);
188    }
189
190    /// Clear all dirty regions (nothing to redraw).
191    pub fn clear_dirty(&mut self) {
192        self.dirty_region = DirtyRegion::empty();
193    }
194
195    /// Check whether a pixel is in a dirty region (needs redrawing).
196    /// If no dirty region is present, all pixels are considered dirty.
197    #[inline]
198    pub fn is_dirty_pixel(&self, x: u32, y: u32) -> bool {
199        self.dirty_region.contains_pixel(x, y)
200    }
201
202    /// Returns the width of this framebuffer in pixels.
203    #[inline]
204    pub fn width(&self) -> u32 {
205        self.width
206    }
207
208    /// Returns the height of this framebuffer in pixels.
209    #[inline]
210    pub fn height(&self) -> u32 {
211        self.height
212    }
213
214    /// Clear the entire framebuffer to a single color.
215    pub fn clear(&mut self, color: Rgba) {
216        let rgba = color.to_array();
217        for pixel in self.data.chunks_exact_mut(BYTES_PER_PIXEL) {
218            pixel.copy_from_slice(&rgba);
219        }
220    }
221
222    /// Set a single pixel. Returns false if out of bounds.
223    pub fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
224        if x >= self.width || y >= self.height {
225            return false;
226        }
227        let offset = ((y as usize) * (self.width as usize) + (x as usize)) * BYTES_PER_PIXEL;
228        self.data[offset..offset + BYTES_PER_PIXEL].copy_from_slice(&color.to_array());
229        true
230    }
231
232    /// Get the color of a single pixel. Returns None if out of bounds.
233    pub fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
234        if x >= self.width || y >= self.height {
235            return None;
236        }
237        let offset = ((y as usize) * (self.width as usize) + (x as usize)) * BYTES_PER_PIXEL;
238        let mut c = [0u8; 4];
239        c.copy_from_slice(&self.data[offset..offset + BYTES_PER_PIXEL]);
240        Some(Rgba::from(c))
241    }
242
243    /// Fill a rectangular region with a color. Coordinates are clamped to screen bounds.
244    pub fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
245        let x_start = x.min(self.width);
246        let y_start = y.min(self.height);
247        let x_end = (x + rect_width).min(self.width);
248        let y_end = (y + rect_height).min(self.height);
249        let rgba = color.to_array();
250
251        for row in y_start..y_end {
252            let row_offset = (row as usize) * (self.width as usize) * BYTES_PER_PIXEL;
253            for col in x_start..x_end {
254                let offset = row_offset + (col as usize) * BYTES_PER_PIXEL;
255                self.data[offset..offset + BYTES_PER_PIXEL].copy_from_slice(&rgba);
256            }
257        }
258    }
259
260    /// Get a slice of one pixel row's RGBA data. Returns None if y is out of bounds.
261    pub fn row_slice(&self, y: u32) -> Option<&[u8]> {
262        if y >= self.height {
263            return None;
264        }
265        let start = (y as usize) * (self.width as usize) * BYTES_PER_PIXEL;
266        let end = start + (self.width as usize) * BYTES_PER_PIXEL;
267        Some(&self.data[start..end])
268    }
269
270    /// Get a mutable slice of one pixel row's RGBA data. Returns None if y is out of bounds.
271    pub fn row_slice_mut(&mut self, y: u32) -> Option<&mut [u8]> {
272        if y >= self.height {
273            return None;
274        }
275        let start = (y as usize) * (self.width as usize) * BYTES_PER_PIXEL;
276        let end = start + (self.width as usize) * BYTES_PER_PIXEL;
277        Some(&mut self.data[start..end])
278    }
279
280    /// Copy a horizontal line of RGBA data into the framebuffer.
281    /// `src` must be exactly `count * 4` bytes.
282    /// Returns false if the line goes out of bounds.
283    pub fn blit_row(&mut self, x: u32, y: u32, src: &[u8], count: u32) -> bool {
284        if y >= self.height || x >= self.width {
285            return false;
286        }
287        let actual_count = count.min(self.width - x) as usize;
288        let src_bytes = actual_count * BYTES_PER_PIXEL;
289        if src.len() < src_bytes {
290            return false;
291        }
292        let offset = ((y as usize) * (self.width as usize) + (x as usize)) * BYTES_PER_PIXEL;
293        self.data[offset..offset + src_bytes].copy_from_slice(&src[..src_bytes]);
294        true
295    }
296
297    /// Save the framebuffer as a PNG file.
298    ///
299    /// Uses the `image` crate to encode the raw RGBA data.
300    ///
301    /// Host-only (behind the `image` feature): bare-metal targets have no
302    /// filesystem and no std::io; a GBA build never enables this.
303    #[cfg(feature = "image")]
304    pub fn save_png(&self, path: &std::path::Path) -> std::io::Result<()> {
305        use image::{ImageBuffer, Rgba as ImgRgba};
306        let img: ImageBuffer<ImgRgba<u8>, _> =
307            ImageBuffer::from_raw(self.width, self.height, self.data.clone())
308                .expect("FrameBuffer data size mismatch");
309        img.save(path)
310            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
311    }
312}