From 3ecc1e51048138de50c79a4e595a9d195286c3c4 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 14:19:50 -0700 Subject: [PATCH] fix(boot): only redraw what changed in the graphical menu Each frame redrew the whole screen and blitted the entire framebuffer. The static background and panel are now cached, and only the changed regions are blitted. --- crates/boot/src/menu/graphical.rs | 61 ++++++++++++++++++++------- crates/eficore/src/framebuffer.rs | 69 ++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 16 deletions(-) diff --git a/crates/boot/src/menu/graphical.rs b/crates/boot/src/menu/graphical.rs index 71a524f..6d02845 100644 --- a/crates/boot/src/menu/graphical.rs +++ b/crates/boot/src/menu/graphical.rs @@ -359,6 +359,12 @@ struct Screen { gop: ScopedProtocol, /// What is drawn before it is blitted to the display. fb: Framebuffer, + /// What was last blitted to the display, which only has to be updated where it differs. + shown: Framebuffer, + /// The parts of the menu that never change, which each frame starts from. + base: Framebuffer, + /// Whether the display has had a frame blitted to it. + drawn: bool, /// Where the entries are on this display. layout: Layout, /// The index of the first entry that is on this display. @@ -399,13 +405,21 @@ impl Screen { continue; }; let (width, height) = gop.current_mode_info().resolution(); - let Ok(fb) = Framebuffer::new(width, height) else { + let (Ok(fb), Ok(shown), Ok(mut base)) = ( + Framebuffer::new(width, height), + Framebuffer::new(width, height), + Framebuffer::new(width, height), + ) else { continue; }; let layout = Layout::new((width, height), entries); + render_base(&mut base, &layout); screens.push(Self { gop, fb, + shown, + base, + drawn: false, layout, offset: 0, }); @@ -598,9 +612,9 @@ fn draw_logo(fb: &mut Framebuffer, layout: &Layout, ticks: usize) { let y = floor - rise; for row in 0..height { + let source_row = row * LOGO_HEIGHT / height * LOGO_WIDTH; for column in 0..width { - let source = - ((row * LOGO_HEIGHT / height) * LOGO_WIDTH + column * LOGO_WIDTH / width) * 4; + let source = (source_row + column * LOGO_WIDTH / width) * 4; let [red, green, blue, alpha] = LOGO[source..source + 4] else { continue; }; @@ -657,16 +671,9 @@ fn draw_cursor(fb: &mut Framebuffer, scale: usize, position: (usize, usize)) { } } -/// Draw the whole menu to the framebuffer. -fn render( - fb: &mut Framebuffer, - layout: &Layout, - scene: &Scene, - offset: usize, - cursor: Option<(usize, usize)>, -) { +/// Draw the parts of the menu that never change to the framebuffer. +fn render_base(fb: &mut Framebuffer, layout: &Layout) { draw_background(fb, BACKGROUND_TOP, BACKGROUND_BOTTOM); - draw_logo(fb, layout, scene.ticks); if let Some((x, y, scale)) = layout.title { draw_text(fb, x, y, scale, TITLE.len(), TITLE, ACCENT); } @@ -692,6 +699,23 @@ fn render( PANEL, ); + // The line between the entries and the status line. + let inner = panel_width.saturating_sub(layout.cell * 3); + let inner_x = panel_x + layout.cell * 3 / 2; + fb.fill_rect(inner_x, layout.footer, inner, layout.scale, PANEL_EDGE); +} + +/// Draw the whole menu to the framebuffer, which already has the [render_base] drawn. +fn render( + fb: &mut Framebuffer, + layout: &Layout, + scene: &Scene, + offset: usize, + cursor: Option<(usize, usize)>, +) { + draw_logo(fb, layout, scene.ticks); + + let (panel_x, _, panel_width, _) = layout.panel; for (slot, (index, entry)) in scene .entries .iter() @@ -739,10 +763,9 @@ fn render( draw_scrollbar(fb, layout, offset, scene.entries.len()); } - // The status line is below a line across the panel, with the position when it scrolls. + // The status line is below the line across the panel, with the position when it scrolls. let inner = panel_width.saturating_sub(layout.cell * 3); let inner_x = panel_x + layout.cell * 3 / 2; - fb.fill_rect(inner_x, layout.footer, inner, layout.scale, PANEL_EDGE); let status_row = layout.footer + layout.scale + layout.cell / 2; // The position is at the right edge, so the status keeps clear of it on both sides. let keep_clear = if layout.scrolls { layout.cell * 8 } else { 0 }; @@ -818,6 +841,7 @@ fn run( for screen in screens.iter_mut() { screen.scroll_to(scene.selected); let cursor = cursor.map(|position| screen.locate(position, bounds)); + screen.fb.copy_from(&screen.base); render( &mut screen.fb, &screen.layout, @@ -825,7 +849,14 @@ fn run( screen.offset, cursor, ); - screen.fb.blit(&mut screen.gop)?; + // Only the first frame has to fill the display, the rest only change parts of it. + if screen.drawn { + screen.fb.blit_changes(&screen.shown, &mut screen.gop)?; + } else { + screen.fb.blit(&mut screen.gop)?; + screen.drawn = true; + } + core::mem::swap(&mut screen.fb, &mut screen.shown); } // Wake up every tick to move the logo and update the countdown. diff --git a/crates/eficore/src/framebuffer.rs b/crates/eficore/src/framebuffer.rs index 3944551..16109a0 100644 --- a/crates/eficore/src/framebuffer.rs +++ b/crates/eficore/src/framebuffer.rs @@ -1,8 +1,11 @@ use alloc::vec; use alloc::vec::Vec; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use uefi::proto::console::gop::{BltOp, BltPixel, BltRegion, GraphicsOutput}; +/// How many unchanged rows may sit between changed rows before they are blitted separately. +const BAND_GAP: usize = 8; + /// Represents the EFI framebuffer. pub struct Framebuffer { /// The width of the framebuffer in pixels. @@ -68,6 +71,11 @@ impl Framebuffer { self.pixels.get_mut(index) } + /// Replace the pixels of the framebuffer with those of `other`, which must be the same size. + pub fn copy_from(&mut self, other: &Framebuffer) { + self.pixels.copy_from_slice(&other.pixels); + } + /// Blit the framebuffer to the specified `gop` [GraphicsOutput]. pub fn blit(&self, gop: &mut GraphicsOutput) -> Result<()> { gop.blt(BltOp::BufferToVideo { @@ -79,4 +87,63 @@ impl Framebuffer { .context("unable to blit framebuffer")?; Ok(()) } + + /// Blit only what is different from `previous`, which must be the same size, to the specified + /// `gop` [GraphicsOutput]. Changes that are far apart vertically are blitted separately, so + /// that the pixels between them are not sent to the display. + pub fn blit_changes(&self, previous: &Framebuffer, gop: &mut GraphicsOutput) -> Result<()> { + if self.width != previous.width || self.height != previous.height { + bail!("framebuffer sizes differ"); + } + + // The top, bottom, left and right of the changed rows that haven't been blitted yet. + let mut band: Option<(usize, usize, usize, usize)> = None; + let mut quiet = 0; + for row in 0..self.height { + let range = row * self.width..(row + 1) * self.width; + let (now, before) = (&self.pixels[range.clone()], &previous.pixels[range]); + let differs = |(now, before): (&BltPixel, &BltPixel)| { + (now.red, now.green, now.blue) != (before.red, before.green, before.blue) + }; + let first = now.iter().zip(before).position(differs); + let Some(first) = first else { + quiet += 1; + if quiet > BAND_GAP + && let Some(band) = band.take() + { + self.blit_region(gop, band)?; + } + continue; + }; + let last = now.iter().zip(before).rposition(differs).unwrap_or(first); + quiet = 0; + band = Some(match band { + Some((top, _, left, right)) => (top, row + 1, left.min(first), right.max(last + 1)), + None => (row, row + 1, first, last + 1), + }); + } + if let Some(band) = band { + self.blit_region(gop, band)?; + } + Ok(()) + } + + /// Blit the region of the `top`, `bottom`, `left` and `right` to the specified `gop`. + fn blit_region( + &self, + gop: &mut GraphicsOutput, + (top, bottom, left, right): (usize, usize, usize, usize), + ) -> Result<()> { + gop.blt(BltOp::BufferToVideo { + buffer: &self.pixels, + src: BltRegion::SubRectangle { + coords: (left, top), + px_stride: self.width, + }, + dest: (left, top), + dims: (right - left, bottom - top), + }) + .context("unable to blit framebuffer region")?; + Ok(()) + } }