Skip to main content

dotzuki_renderer/
sprite.rs

1//! Sprite rendering — draws OAM sprites onto the framebuffer.
2//!
3//! Game Boy sprites (OBJ) are 8×8 or 8×16 tiles drawn from OAM entries.
4//! Each OAM entry has: Y position, X position, tile number, and attributes.
5//!
6//! Attribute byte bits:
7//!   bit 7: BG priority (0=above BG, 1=behind non-zero BG pixels)
8//!   bit 6: Y flip
9//!   bit 5: X flip
10//!   bit 4: Palette (0=OBP0, 1=OBP1)
11//!   bits 3-0: unused on DMG
12//!
13//! The classic Game Boy games use 8×8 sprites assembled into 16×16 characters
14//! (4 OAM entries per sprite). Color 0 in sprite palettes is transparent.
15
16use crate::palette::{ColorIndex, GbaColor, GbColor, Palette};
17use crate::tile::{TileFormat, TileSet, TILE_PIXELS};
18use crate::FbSurface;
19
20/// OAM attribute flags.
21pub const OAM_PRIORITY: u8 = 0x80; // bit 7: behind BG
22pub const OAM_Y_FLIP: u8 = 0x40; // bit 6: vertical flip
23pub const OAM_X_FLIP: u8 = 0x20; // bit 5: horizontal flip
24pub const OAM_PALETTE: u8 = 0x10; // bit 4: OBP1 select
25
26/// Maximum number of OAM entries on the Game Boy.
27pub const MAX_OAM_ENTRIES: usize = 40;
28
29/// An OAM entry for rendering purposes.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct SpriteOamEntry {
32    /// Y position (screen Y + 16 on real hardware, we store screen Y directly)
33    pub y: i32,
34    /// X position (screen X + 8 on real hardware, we store screen X directly)
35    pub x: i32,
36    /// Tile index into sprite tileset
37    pub tile_id: u8,
38    /// Attribute byte (priority, flip, palette)
39    pub attributes: u8,
40}
41
42impl SpriteOamEntry {
43    pub fn new(y: i32, x: i32, tile_id: u8, attributes: u8) -> Self {
44        Self {
45            y,
46            x,
47            tile_id,
48            attributes,
49        }
50    }
51
52    /// Create from raw OAM bytes (hardware format: Y+16, X+8)
53    pub fn from_raw(raw_y: u8, raw_x: u8, tile_id: u8, attributes: u8) -> Self {
54        Self {
55            y: raw_y as i32 - 16,
56            x: raw_x as i32 - 8,
57            tile_id,
58            attributes,
59        }
60    }
61
62    #[inline]
63    pub fn bg_priority(&self) -> bool {
64        self.attributes & OAM_PRIORITY != 0
65    }
66
67    #[inline]
68    pub fn y_flip(&self) -> bool {
69        self.attributes & OAM_Y_FLIP != 0
70    }
71
72    #[inline]
73    pub fn x_flip(&self) -> bool {
74        self.attributes & OAM_X_FLIP != 0
75    }
76
77    #[inline]
78    pub fn uses_obp1(&self) -> bool {
79        self.attributes & OAM_PALETTE != 0
80    }
81
82    /// Check if the sprite is visible on screen.
83    pub fn is_on_screen(&self, screen_width: u32, screen_height: u32) -> bool {
84        let x_end = self.x + TILE_PIXELS as i32;
85        let y_end = self.y + TILE_PIXELS as i32;
86        x_end > 0 && self.x < screen_width as i32 && y_end > 0 && self.y < screen_height as i32
87    }
88}
89
90/// Sprite layer: holds a list of OAM entries to render.
91#[derive(Debug, Clone)]
92pub struct SpriteLayer {
93    pub entries: Vec<SpriteOamEntry>,
94}
95
96impl SpriteLayer {
97    pub fn new() -> Self {
98        Self {
99            entries: Vec::with_capacity(MAX_OAM_ENTRIES),
100        }
101    }
102
103    pub fn clear(&mut self) {
104        self.entries.clear();
105    }
106
107    pub fn add(&mut self, entry: SpriteOamEntry) {
108        self.entries.push(entry);
109    }
110
111    /// Render all sprites onto the framebuffer.
112    ///
113    /// `tileset` - sprite tile data
114    /// `obp0` / `obp1` - the two sprite palettes
115    /// `bg_color_buffer` - optional: if provided, used for BG priority check.
116    ///   When a sprite has bg_priority set, it only draws over color-0 BG pixels.
117    ///   This buffer stores the BG color index (0-3) for each pixel.
118    pub fn render(
119        &self,
120        fb: &mut impl FbSurface,
121        tileset: &TileSet,
122        obp0: &Palette,
123        obp1: &Palette,
124        bg_color_buffer: Option<&[u8]>,
125    ) {
126        // Render in reverse order (lower index = higher priority on DMG)
127        for entry in self.entries.iter().rev() {
128            render_sprite(fb, tileset, obp0, obp1, entry, bg_color_buffer);
129        }
130    }
131}
132
133impl Default for SpriteLayer {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139/// Render a single 8×8 sprite tile.
140///
141/// Rendering path is chosen based on [`TileSet::tile_format`]:
142/// - [`TileFormat::FullColor`]: uses [`TileSet::get_rgba`] directly — no palette.
143/// - [`TileFormat::Gb2bpp`]: existing GB path with [`GbColor`] palette lookup (unchanged).
144/// - [`TileFormat::Gba4bpp`]: uses [`GbaColor`] palette lookup (accesses `palette.colors` directly).
145fn render_sprite(
146    fb: &mut impl FbSurface,
147    tileset: &TileSet,
148    obp0: &Palette,
149    obp1: &Palette,
150    entry: &SpriteOamEntry,
151    bg_color_buffer: Option<&[u8]>,
152) {
153    if !entry.is_on_screen(fb.width(), fb.height()) {
154        return;
155    }
156
157    let format = tileset.tile_format();
158    let fb_w = fb.width() as usize;
159    let fb_h = fb.height() as i32;
160
161    // FullColor path: render directly from RGBA tiles, no palette lookup.
162    if format == TileFormat::FullColor {
163        if let Some(rgba_tile) = tileset.get_rgba(entry.tile_id as usize) {
164            for row in 0..TILE_PIXELS {
165                let screen_y = entry.y + row as i32;
166                if screen_y < 0 || screen_y >= fb_h {
167                    continue;
168                }
169                let tile_row = if entry.y_flip() {
170                    TILE_PIXELS - 1 - row
171                } else {
172                    row
173                };
174                for col in 0..TILE_PIXELS {
175                    let screen_x = entry.x + col as i32;
176                    if screen_x < 0 || screen_x >= fb.width() as i32 {
177                        continue;
178                    }
179                    let tile_col = if entry.x_flip() {
180                        TILE_PIXELS - 1 - col
181                    } else {
182                        col
183                    };
184                    let rgba = rgba_tile.pixels[tile_row][tile_col];
185                    // Transparent pixel
186                    if rgba.a == 0 {
187                        continue;
188                    }
189                    // BG priority check
190                    if entry.bg_priority() {
191                        if let Some(bg_buf) = bg_color_buffer {
192                            let pixel_offset = screen_y as usize * fb_w + screen_x as usize;
193                            if pixel_offset < bg_buf.len() && bg_buf[pixel_offset] != 0 {
194                                continue;
195                            }
196                        }
197                    }
198                    fb.set_pixel(screen_x as u32, screen_y as u32, rgba);
199                }
200            }
201        }
202        return;
203    }
204
205    // Palette-based path (Gb2bpp and Gba4bpp).
206    let tile = tileset.get(entry.tile_id as usize);
207    let palette = if entry.uses_obp1() { obp1 } else { obp0 };
208    let is_gba = format == TileFormat::Gba4bpp;
209
210    for row in 0..TILE_PIXELS {
211        let screen_y = entry.y + row as i32;
212        if screen_y < 0 || screen_y >= fb_h {
213            continue;
214        }
215
216        let tile_row = if entry.y_flip() {
217            TILE_PIXELS - 1 - row
218        } else {
219            row
220        };
221
222        for col in 0..TILE_PIXELS {
223            let screen_x = entry.x + col as i32;
224            if screen_x < 0 || screen_x >= fb.width() as i32 {
225                continue;
226            }
227
228            let tile_col = if entry.x_flip() {
229                TILE_PIXELS - 1 - col
230            } else {
231                col
232            };
233
234            let color_idx = tile.get(tile_row, tile_col);
235
236            // Color 0 is always transparent for sprites
237            if color_idx == 0 {
238                continue;
239            }
240
241            // BG priority check: if set, only draw over BG color 0 pixels
242            if entry.bg_priority() {
243                if let Some(bg_buf) = bg_color_buffer {
244                    let pixel_offset =
245                        screen_y as usize * fb_w + screen_x as usize;
246                    if pixel_offset < bg_buf.len() && bg_buf[pixel_offset] != 0 {
247                        continue;
248                    }
249                }
250            }
251
252            let rgba = if is_gba {
253                palette.colors[GbaColor::from_u8(color_idx).to_index()]
254            } else {
255                palette.color(GbColor::from_u8(color_idx))
256            };
257            fb.set_pixel(screen_x as u32, screen_y as u32, rgba);
258        }
259    }
260}