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 { x: 0, y: 0, width: 0, height: 0, present: false }
47    }
48
49    /// A dirty region covering the entire screen.
50    pub fn full(width: u32, height: u32) -> Self {
51        Self { x: 0, y: 0, width, height, present: true }
52    }
53
54    /// Create a dirty region at (x, y) with the given dimensions.
55    pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
56        Self { x, y, width, height, present: true }
57    }
58
59    /// Union this region with another, producing the bounding box of both.
60    pub fn union(&self, other: &DirtyRegion) -> DirtyRegion {
61        if !self.present {
62            return *other;
63        }
64        if !other.present {
65            return *self;
66        }
67        let x1 = self.x.min(other.x);
68        let y1 = self.y.min(other.y);
69        let x2 = (self.x + self.width as i32).max(other.x + other.width as i32);
70        let y2 = (self.y + self.height as i32).max(other.y + other.height as i32);
71        DirtyRegion::new(x1, y1, (x2 - x1).max(0) as u32, (y2 - y1).max(0) as u32)
72    }
73
74    /// Check whether a pixel at (px, py) is within this dirty region.
75    /// Returns `false` when the region is not present (nothing to redraw).
76    #[inline]
77    pub fn contains_pixel(&self, px: u32, py: u32) -> bool {
78        if !self.present {
79            return false;
80        }
81        px as i32 >= self.x
82            && (px as i32) < self.x + self.width as i32
83            && py as i32 >= self.y
84            && (py as i32) < self.y + self.height as i32
85    }
86
87    /// Convert the dirty region to tile-space coordinates, rounding outward.
88    /// Returns (tile_x, tile_y, tile_width, tile_height).
89    pub fn to_tile_rect(&self, tile_size: u32) -> (i32, i32, u32, u32) {
90        if !self.present {
91            return (0, 0, 0, 0);
92        }
93        let ts = tile_size as i32;
94        let tx = if self.x >= 0 { self.x / ts } else { (self.x - ts + 1) / ts };
95        let ty = if self.y >= 0 { self.y / ts } else { (self.y - ts + 1) / ts };
96        let right = self.x + self.width as i32;
97        let bottom = self.y + self.height as i32;
98        let tw = ((right + ts - 1) / ts - tx).max(0) as u32;
99        let th = ((bottom + ts - 1) / ts - ty).max(0) as u32;
100        (tx, ty, tw, th)
101    }
102}
103
104impl Default for DirtyRegion {
105    fn default() -> Self {
106        Self::empty()
107    }
108}
109
110// ---------------------------------------------------------------------------
111// FrameBuffer
112// ---------------------------------------------------------------------------
113
114/// A pixel RGBA framebuffer with configurable dimensions.
115///
116/// The internal buffer is a flat array of RGBA bytes in row-major order.
117/// Pixel (x, y) starts at byte offset `(y * width + x) * 4`.
118#[derive(Debug, Clone)]
119pub struct FrameBuffer {
120    /// Raw RGBA pixel data, `width * height * 4` bytes.
121    pub data: Vec<u8>,
122    /// Screen width in pixels.
123    pub width: u32,
124    /// Screen height in pixels.
125    pub height: u32,
126    /// Accumulated dirty region for incremental rendering.
127    /// Callers mark areas that need redrawing; render functions
128    /// may skip pixels outside this region.
129    pub dirty_region: DirtyRegion,
130}
131
132impl FrameBuffer {
133    /// Create a new framebuffer with the given render config, cleared to the given color.
134    pub fn new(config: RenderConfig, clear_color: Rgba) -> Self {
135        let fb_size = (config.screen_width as usize)
136            * (config.screen_height as usize)
137            * BYTES_PER_PIXEL;
138        let mut fb = Self {
139            data: vec![0; fb_size],
140            width: config.screen_width,
141            height: config.screen_height,
142            dirty_region: DirtyRegion::full(config.screen_width, config.screen_height),
143        };
144        fb.clear(clear_color);
145        fb
146    }
147
148    /// Mark the entire framebuffer as dirty (force full redraw).
149    pub fn mark_all_dirty(&mut self) {
150        self.dirty_region = DirtyRegion::full(self.width, self.height);
151    }
152
153    /// Mark a rectangular region as dirty.
154    pub fn mark_dirty_rect(&mut self, x: i32, y: i32, w: u32, h: u32) {
155        let r = DirtyRegion::new(x, y, w, h);
156        self.dirty_region = self.dirty_region.union(&r);
157    }
158
159    /// Mark a tile as dirty given its tile coordinates (tx, ty).
160    pub fn mark_dirty_tile(&mut self, tx: i32, ty: i32) {
161        let tile_size = TILE_SIZE as i32;
162        self.mark_dirty_rect(tx * tile_size, ty * tile_size, TILE_SIZE, TILE_SIZE);
163    }
164
165    /// Clear all dirty regions (nothing to redraw).
166    pub fn clear_dirty(&mut self) {
167        self.dirty_region = DirtyRegion::empty();
168    }
169
170    /// Check whether a pixel is in a dirty region (needs redrawing).
171    /// If no dirty region is present, all pixels are considered dirty.
172    #[inline]
173    pub fn is_dirty_pixel(&self, x: u32, y: u32) -> bool {
174        self.dirty_region.contains_pixel(x, y)
175    }
176
177    /// Returns the width of this framebuffer in pixels.
178    #[inline]
179    pub fn width(&self) -> u32 {
180        self.width
181    }
182
183    /// Returns the height of this framebuffer in pixels.
184    #[inline]
185    pub fn height(&self) -> u32 {
186        self.height
187    }
188
189    /// Clear the entire framebuffer to a single color.
190    pub fn clear(&mut self, color: Rgba) {
191        let rgba = color.to_array();
192        for pixel in self.data.chunks_exact_mut(BYTES_PER_PIXEL) {
193            pixel.copy_from_slice(&rgba);
194        }
195    }
196
197    /// Set a single pixel. Returns false if out of bounds.
198    pub fn set_pixel(&mut self, x: u32, y: u32, color: Rgba) -> bool {
199        if x >= self.width || y >= self.height {
200            return false;
201        }
202        let offset = ((y as usize) * (self.width as usize) + (x as usize)) * BYTES_PER_PIXEL;
203        self.data[offset..offset + BYTES_PER_PIXEL].copy_from_slice(&color.to_array());
204        true
205    }
206
207    /// Get the color of a single pixel. Returns None if out of bounds.
208    pub fn get_pixel(&self, x: u32, y: u32) -> Option<Rgba> {
209        if x >= self.width || y >= self.height {
210            return None;
211        }
212        let offset = ((y as usize) * (self.width as usize) + (x as usize)) * BYTES_PER_PIXEL;
213        let mut c = [0u8; 4];
214        c.copy_from_slice(&self.data[offset..offset + BYTES_PER_PIXEL]);
215        Some(Rgba::from(c))
216    }
217
218    /// Fill a rectangular region with a color. Coordinates are clamped to screen bounds.
219    pub fn fill_rect(&mut self, x: u32, y: u32, rect_width: u32, rect_height: u32, color: Rgba) {
220        let x_start = x.min(self.width);
221        let y_start = y.min(self.height);
222        let x_end = (x + rect_width).min(self.width);
223        let y_end = (y + rect_height).min(self.height);
224        let rgba = color.to_array();
225
226        for row in y_start..y_end {
227            let row_offset = (row as usize) * (self.width as usize) * BYTES_PER_PIXEL;
228            for col in x_start..x_end {
229                let offset = row_offset + (col as usize) * BYTES_PER_PIXEL;
230                self.data[offset..offset + BYTES_PER_PIXEL].copy_from_slice(&rgba);
231            }
232        }
233    }
234
235    /// Get a slice of one pixel row's RGBA data. Returns None if y is out of bounds.
236    pub fn row_slice(&self, y: u32) -> Option<&[u8]> {
237        if y >= self.height {
238            return None;
239        }
240        let start = (y as usize) * (self.width as usize) * BYTES_PER_PIXEL;
241        let end = start + (self.width as usize) * BYTES_PER_PIXEL;
242        Some(&self.data[start..end])
243    }
244
245    /// Get a mutable slice of one pixel row's RGBA data. Returns None if y is out of bounds.
246    pub fn row_slice_mut(&mut self, y: u32) -> Option<&mut [u8]> {
247        if y >= self.height {
248            return None;
249        }
250        let start = (y as usize) * (self.width as usize) * BYTES_PER_PIXEL;
251        let end = start + (self.width as usize) * BYTES_PER_PIXEL;
252        Some(&mut self.data[start..end])
253    }
254
255    /// Copy a horizontal line of RGBA data into the framebuffer.
256    /// `src` must be exactly `count * 4` bytes.
257    /// Returns false if the line goes out of bounds.
258    pub fn blit_row(&mut self, x: u32, y: u32, src: &[u8], count: u32) -> bool {
259        if y >= self.height || x >= self.width {
260            return false;
261        }
262        let actual_count = count.min(self.width - x) as usize;
263        let src_bytes = actual_count * BYTES_PER_PIXEL;
264        if src.len() < src_bytes {
265            return false;
266        }
267        let offset = ((y as usize) * (self.width as usize) + (x as usize)) * BYTES_PER_PIXEL;
268        self.data[offset..offset + src_bytes].copy_from_slice(&src[..src_bytes]);
269        true
270    }
271
272    /// Save the framebuffer as a PNG file.
273    ///
274    /// Uses the `image` crate to encode the raw RGBA data.
275    pub fn save_png(&self, path: &std::path::Path) -> std::io::Result<()> {
276        use image::{ImageBuffer, Rgba as ImgRgba};
277        let img: ImageBuffer<ImgRgba<u8>, _> =
278            ImageBuffer::from_raw(self.width, self.height, self.data.clone())
279                .expect("FrameBuffer data size mismatch");
280        img.save(path)
281            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
282    }
283}
284
285