diff --git a/Cargo.lock b/Cargo.lock index 8cf2d85..41855fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,8 @@ dependencies = [ "bread-utils", "breadlock-ui", "chrono", + "glow", + "khronos-egl", "pam-client2", "serde", "serde_json", @@ -346,6 +348,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -707,6 +718,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gobject-sys" version = "0.22.6" @@ -904,6 +927,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -916,6 +949,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1297,6 +1340,12 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1969,6 +2018,7 @@ dependencies = [ "cc", "downcast-rs", "rustix", + "scoped-tls", "smallvec", "wayland-sys", ] @@ -2075,9 +2125,21 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ + "dlib", + "log", "pkg-config", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/breadlock-ui/src/painter.rs b/breadlock-ui/src/painter.rs index b84f471..5cc0adc 100644 --- a/breadlock-ui/src/painter.rs +++ b/breadlock-ui/src/painter.rs @@ -162,32 +162,60 @@ impl TextRenderer { if a == 0 { return; } - blend_over_opaque(pixmap, px as u32, py as u32, r, g, b, a); + blend_over(pixmap, px as u32, py as u32, r, g, b, a); }, ); } } -/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over an -/// **opaque** destination pixel (always true here — the lock screen -/// background is painted fully opaque before any text or UI chrome). -/// Because the destination alpha is always 255, the blended result is also -/// opaque, so the `PremultipliedColorU8` invariant (`rgb <= a`) always holds. -fn blend_over_opaque(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) { +/// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over a +/// destination of *any* alpha. Two paths use this: +/// +/// - **Full compose**: the background is painted fully opaque before any +/// text, so the destination alpha is always 255 and the result is opaque +/// (the exact formula below, kept byte-identical to the historic one). +/// - **GPU chrome** (`compose_chrome`): text is drawn into a *transparent* +/// pixmap that is later composited over the GPU background, so glyph +/// edges must keep real alpha — a forced-255 blend here would make every +/// glyph opaque and, composited over the background, visibly wrong. +/// +/// Premultiplied source-over: `out = src_pm + dst_pm * (1 - src_a)`, which +/// preserves the `PremultipliedColorU8` invariant (`rgb <= a`). +fn blend_over(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) { let idx = (y * pixmap.width() + x) as usize; let pixels = pixmap.pixels_mut(); let Some(dst) = pixels.get(idx).copied() else { return; }; - let a32 = a as u32; - let mix = |s: u8, d: u8| -> u8 { ((s as u32 * a32 + d as u32 * (255 - a32)) / 255) as u8 }; - let blended = PremultipliedColorU8::from_rgba( - mix(r, dst.red()), - mix(g, dst.green()), - mix(b, dst.blue()), - 255, - ); - if let Some(blended) = blended { + let sa = a as u32; + if dst.alpha() == 255 { + // Opaque destination: the classic exact blend. RGB mixes toward the + // source, alpha stays 255 — identical to the pre-split behavior so + // the single-pass software path doesn't move a single pixel. + let mix = |s: u8, d: u8| -> u8 { ((s as u32 * sa + d as u32 * (255 - sa)) / 255) as u8 }; + if let Some(blended) = PremultipliedColorU8::from_rgba( + mix(r, dst.red()), + mix(g, dst.green()), + mix(b, dst.blue()), + 255, + ) { + pixels[idx] = blended; + } + return; + } + // General (possibly transparent) destination: premultiplied source-over. + // out_a = sa + da*(255-sa)/255; out_rgb = src_rgb*sa/255 + dst_rgb*(1-sa). + let da = dst.alpha() as u32; + let out_a = (sa + da * (255 - sa) / 255) as u8; + let out_c = |c: u8, dc: u8| -> u8 { + (c as u32 * sa / 255 + dc as u32 * (255 - sa) / 255) as u8 + }; + if let Some(blended) = PremultipliedColorU8::from_rgba( + out_c(r, dst.red()), + out_c(g, dst.green()), + out_c(b, dst.blue()), + out_a, + ) { pixels[idx] = blended; } } @@ -260,4 +288,45 @@ mod tests { "10%-alpha text must not render near-white, got {low_max}" ); } + + #[test] + fn draw_line_onto_transparent_keeps_real_alpha() { + // Regression: the GPU path (compose_chrome) draws text into a + // transparent pixmap that is later composited over the GPU background. + // The old blend forced output alpha to 255, so every glyph became + // opaque and, once composited, rendered visibly wrong (dark, covering + // the background instead of blending). Glyph cores must carry real + // alpha here so the final source-over composite is correct. + let mut renderer = TextRenderer::new(); + + let mut t = Pixmap::new(200, 40).unwrap(); // starts transparent + renderer.draw_line( + &mut t, + "12:34", + "sans-serif", + 24.0, + tiny_skia::Color::WHITE, + 0.0, + 0.0, + ); + // Full-coverage glyph cores are legitimately opaque, but the AA + // edges must carry real intermediate alphas — the old forced-255 + // blend made *every* drawn pixel (edges included) fully opaque. + let has_edge = t + .pixels() + .iter() + .any(|p| p.alpha() > 0 && p.alpha() < 255); + assert!( + has_edge, + "glyph AA edges must keep intermediate alphas onto a transparent pixmap" + ); + // And a 50%-alpha draw must not produce fully-opaque pixels. + let mut t2 = Pixmap::new(200, 40).unwrap(); + let half = tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 0.5).unwrap(); + renderer.draw_line(&mut t2, "12:34", "sans-serif", 24.0, half, 0.0, 0.0); + assert!( + t2.pixels().iter().all(|p| p.alpha() <= 128 + 3), + "50%-alpha text onto transparent must stay ~half alpha" + ); + } } diff --git a/breadlock/Cargo.toml b/breadlock/Cargo.toml index 98355b3..e9e91b4 100644 --- a/breadlock/Cargo.toml +++ b/breadlock/Cargo.toml @@ -28,8 +28,10 @@ path = "src/bin/breadlock-preview.rs" breadlock-ui = { path = "../breadlock-ui", features = ["paint"] } bread-utils = { workspace = true, features = ["bread-client"] } smithay-client-toolkit = "0.20" -wayland-client = "0.31" +wayland-client = { version = "0.31", features = ["system"] } tiny-skia = "0.12" +khronos-egl = { version = "6", features = ["dynamic"] } +glow = "0.16" chrono = "0.4" pam-client2 = { version = "0.5", default-features = false } zeroize = { version = "1", features = ["std"] } diff --git a/breadlock/src/background.rs b/breadlock/src/background.rs index 41cabcb..6af5d92 100644 --- a/breadlock/src/background.rs +++ b/breadlock/src/background.rs @@ -4,8 +4,15 @@ //! warning in v1. `ken_burns = true` adds a slow, continuous pan+zoom to //! image backgrounds (opt-in: it keeps the background redrawing at a low //! frame rate while locked). +//! +//! The renderer is fully software (tiny-skia), so every frame redraws the +//! whole surface. Rescaling the *source* wallpaper on every frame is +//! prohibitively expensive for large images (a 4K source at output size took +//! ~50 ms/frame — choppy at any cadence), so the source is pre-scaled once +//! per output size into a cache and each frame is a translate-only blit. use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; +use std::cell::RefCell; use std::f32::consts::TAU; use tiny_skia::{Pixmap, PixmapPaint, Transform}; @@ -18,9 +25,183 @@ const KENBURNS_ZOOM: f32 = 1.06; pub enum Background { Color(tiny_skia::Color), - /// `(source, ken_burns)` — the flag decides whether `paint` pans over - /// time or draws statically. - Image(Pixmap, bool), + Image(ImageBg), +} + +/// A wallpaper with a lazily-built, output-sized copy. The first `paint` for +/// a given output size does one downscale; every frame after that blits the +/// cached copy with at most a translation (the Ken Burns pan). +pub struct ImageBg { + /// Original wallpaper. Kept so a different output size (hotplug) simply + /// rebuilds the cache rather than needing the source reloaded. + source: Pixmap, + ken_burns: bool, + cache: RefCell>, +} + +struct ScaledBg { + /// `source` pre-scaled to cover-fit (× Ken Burns zoom when enabled) and + /// sized to the output — same size or larger, so drawing it needs no + /// per-frame scaling. + pixmap: Pixmap, + /// How many pixels the scaled image overhangs each axis — the pan room. + pan_x: f32, + pan_y: f32, + target_w: u32, + target_h: u32, +} + +/// Copies `src` into `target` shifted by `(dx, dy)` (target pixels). `src` is +/// at least as large as `target` in both axes (guaranteed by the cover-fit +/// cache build), and `dx, dy` are pan offsets in `[-pan, 0]`, so the visible +/// region is `src[-dx..-dx+tw, -dy..-dy+th]`. +/// +/// With `bilinear` the fractional part of the offset is sub-pixel filtered, +/// so a slow pan glides instead of stepping one whole pixel at a time (which +/// reads as judder); when the offset is (near-)integer, or `bilinear` is off +/// (the 60 fps animation frames, where the pan moves < 0.2 px anyway), the +/// whole thing collapses to row memcpys. The bilinear path is an integer +/// fixed-point (16.16) loop with the edge clamping hoisted out of the hot +/// columns/rows — far cheaper than +/// [`tiny_skia::Pixmap::draw_pixmap`], which rasterizes every pixel through +/// its general pattern pipeline. +fn blit_translate(target: &mut Pixmap, src: &Pixmap, dx: f32, dy: f32, bilinear: bool) { + let tw = target.width() as usize; + let th = target.height() as usize; + let sw = src.width() as usize; + let sh = src.height() as usize; + let sx = (-dx).clamp(0.0, (sw - tw).max(0) as f32); + let sy = (-dy).clamp(0.0, (sh - th).max(0) as f32); + + let fx = (sx.fract() * 65536.0) as u32 & 0xFFFF; + let fy = (sy.fract() * 65536.0) as u32 & 0xFFFF; + let ix = sx as usize; + let iy = sy as usize; + + let sdata = src.data(); + let dst = target.data_mut(); + + if !bilinear || (fx == 0 && fy == 0) { + for row in 0..th { + let src_row = (iy + row) * sw + ix; + let dst_row = row * tw; + let (s, d) = ( + &sdata[src_row * 4..(src_row + tw) * 4], + &mut dst[dst_row * 4..(dst_row + tw) * 4], + ); + d.copy_from_slice(s); + } + return; + } + + let wx = fx; + let wx_inv = 65536 - wx; + let wy = fy; + let wy_inv = 65536 - wy; + let swm1 = sw - 1; + let shm1 = sh - 1; + + // Per-channel bilinear in packed u32 (one load per pixel instead of four, + // one store instead of four — the loop is latency-bound). Each byte's + // products stay well under 2^32, so lanes never interfere. + #[inline(always)] + unsafe fn lerp4( + sdata: &[u8], + i00: usize, + i10: usize, + i01: usize, + i11: usize, + di: usize, + wx: u32, + wx_inv: u32, + wy: u32, + wy_inv: u32, + dst: &mut [u8], + ) { + let a = u32::from_ne_bytes([ + *sdata.get_unchecked(i00), + *sdata.get_unchecked(i00 + 1), + *sdata.get_unchecked(i00 + 2), + *sdata.get_unchecked(i00 + 3), + ]); + let b = u32::from_ne_bytes([ + *sdata.get_unchecked(i01), + *sdata.get_unchecked(i01 + 1), + *sdata.get_unchecked(i01 + 2), + *sdata.get_unchecked(i01 + 3), + ]); + let d = u32::from_ne_bytes([ + *sdata.get_unchecked(i10), + *sdata.get_unchecked(i10 + 1), + *sdata.get_unchecked(i10 + 2), + *sdata.get_unchecked(i10 + 3), + ]); + let e = u32::from_ne_bytes([ + *sdata.get_unchecked(i11), + *sdata.get_unchecked(i11 + 1), + *sdata.get_unchecked(i11 + 2), + *sdata.get_unchecked(i11 + 3), + ]); + let mut out = 0u32; + for c in 0..4 { + let shift = c * 8; + let av = ((a >> shift) & 0xFF) as u32; + let bv = ((b >> shift) & 0xFF) as u32; + let dv = ((d >> shift) & 0xFF) as u32; + let ev = ((e >> shift) & 0xFF) as u32; + let top = (av * wx_inv + bv * wx) >> 16; + let bot = (dv * wx_inv + ev * wx) >> 16; + out |= ((top * wy_inv + bot * wy) >> 16) << shift; + } + dst[di..di + 4].copy_from_slice(&out.to_ne_bytes()); + } + + // Interior rows/columns: `ix + tw <= sw` and `iy + th <= sh` (both clamped + // above), so `x0 + 1`/`y0 + 1` stay in bounds except on the last + // column/row, which are handled after the hot loop. All indices are + // verified in-bounds above the `unsafe` calls. + for row in 0..th - 1 { + let r0 = (iy + row) * sw; + let r1 = r0 + sw; + let drow = row * tw; + for col in 0..tw - 1 { + let i00 = (r0 + ix + col) * 4; + let i10 = (r1 + ix + col) * 4; + let di = (drow + col) * 4; + // SAFETY: i01/i11 are the next column (col + 1 < tw, in bounds); + // di + 4 < target size; rows in bounds per above. + unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) }; + } + // Last column of this row: clamp x1. + let i00 = (r0 + ix + tw - 1) * 4; + let i10 = (r1 + ix + tw - 1) * 4; + let di = (drow + tw - 1) * 4; + let x1 = (ix + tw - 1 + 1).min(swm1); + let j0 = (r0 + x1) * 4; + let j1 = (r1 + x1) * 4; + // SAFETY: j0/j1 clamped within source, di within target. + unsafe { lerp4(sdata, i00, i10, j0, j1, di, wx, wx_inv, wy, wy_inv, dst) }; + } + // Last row: clamp y1. + let r0 = (iy + th - 1) * sw; + let r1 = (iy + th - 1 + 1).min(shm1) * sw; + let drow = (th - 1) * tw; + for col in 0..tw - 1 { + let i00 = (r0 + ix + col) * 4; + let i10 = (r1 + ix + col) * 4; + let di = (drow + col) * 4; + // SAFETY: in bounds as in the interior loop. + unsafe { lerp4(sdata, i00, i10, i00 + 4, i10 + 4, di, wx, wx_inv, wy, wy_inv, dst) }; + } + // Last column of the last row (both clamps). + let i00 = (r0 + ix + tw - 1) * 4; + let i10 = (r1 + ix + tw - 1) * 4; + let di = (drow + tw - 1) * 4; + let x1 = (ix + tw - 1 + 1).min(swm1); + let j0 = (r0 + x1) * 4; + let j1 = (r1 + x1) * 4; + // SAFETY: all clamped in bounds. + unsafe { lerp4(sdata, i00, i10, j0, j1, di, wx, wx_inv, wy, wy_inv, dst) }; } impl Background { @@ -43,7 +224,11 @@ impl Background { return fallback(); } match Pixmap::load_png(&cfg.path) { - Ok(pixmap) => Background::Image(pixmap, cfg.ken_burns), + Ok(pixmap) => Background::Image(ImageBg { + source: pixmap, + ken_burns: cfg.ken_burns, + cache: RefCell::new(None), + }), Err(err) => { tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color"); fallback() @@ -55,7 +240,7 @@ impl Background { /// True when this background needs continuous redraws (Ken Burns pan). pub fn ken_burns(&self) -> bool { - matches!(self, Background::Image(_, true)) + matches!(self, Background::Image(bg) if bg.ken_burns) } /// Paints this background into `target`, cover-fit (scaled uniformly to @@ -63,44 +248,201 @@ impl Background { /// is the monotonic clock: with Ken Burns enabled the image slowly pans /// and zooms along a smooth Lissajous-ish drift, so consecutive frames /// differ slightly but never jump. - pub fn paint(&self, target: &mut Pixmap, t_secs: f32) { + /// + /// The expensive downscale happens at most once per output size (see + /// [`ImageBg::cache`]); steady-state frames are a 1:1 blit plus a small + /// translation, so the software renderer can hold its frame budget even + /// with a multi-megapixel wallpaper. + /// + /// `smooth` asks for sub-pixel bilinear panning. The locker passes `true` + /// on its slow idle frames (where the ~1 px/frame drift is visible) and + /// `false` on 60 fps animation frames (where the pan moves < 0.2 px and + /// the ~20 ms/frame bilinear would blow the frame budget). + pub fn paint(&self, target: &mut Pixmap, t_secs: f32, smooth: bool) { match self { Background::Color(c) => target.fill(*c), - Background::Image(source, ken_burns) => { + Background::Image(bg) => { let (tw, th) = (target.width() as f32, target.height() as f32); - let (sw, sh) = (source.width() as f32, source.height() as f32); + let (sw, sh) = (bg.source.width() as f32, bg.source.height() as f32); if sw <= 0.0 || sh <= 0.0 { return; } - let cover = (tw / sw).max(th / sh); - let (scale, tx, ty) = if *ken_burns { - let scale = cover * KENBURNS_ZOOM; - // Pan range: how far the scaled image overhangs each axis. - let pan_x = (sw * scale - tw).max(0.0); - let pan_y = (sh * scale - th).max(0.0); + let mut cache = bg.cache.borrow_mut(); + let stale = cache + .as_ref() + .map(|c| c.target_w != target.width() || c.target_h != target.height()) + .unwrap_or(true); + if stale { + let cover = (tw / sw).max(th / sh); + let scale = cover * if bg.ken_burns { KENBURNS_ZOOM } else { 1.0 }; + let scaled_w = (sw * scale).round().max(1.0) as u32; + let scaled_h = (sh * scale).round().max(1.0) as u32; + let Some(mut pixmap) = Pixmap::new(scaled_w, scaled_h) else { + tracing::error!( + "failed to allocate {scaled_w}x{scaled_h} scaled wallpaper — falling back to a palette-color background" + ); + *cache = None; + drop(cache); + target.fill(breadlock_ui::theme::tiny_skia_color( + &breadlock_ui::theme::Palette::default().background, + )); + return; + }; + pixmap.fill(tiny_skia::Color::BLACK); + // The one real downscale in the pipeline: bilinear so the + // cached layer is smooth (per-frame draws are pure copies + // and don't re-filter). + let mut paint = PixmapPaint::default(); + paint.quality = tiny_skia::FilterQuality::Bilinear; + pixmap.draw_pixmap( + 0, + 0, + bg.source.as_ref(), + &paint, + Transform::from_scale(scale, scale), + None, + ); + *cache = Some(ScaledBg { + pixmap, + pan_x: scaled_w as f32 - tw, + pan_y: scaled_h as f32 - th, + target_w: target.width(), + target_h: target.height(), + }); + } + let scaled = cache.as_ref().expect("cache populated above"); + target.fill(tiny_skia::Color::BLACK); + let (tx, ty) = if bg.ken_burns { let phase = t_secs * TAU / KENBURNS_PERIOD_S; // Sin/cos offset by a quarter cycle: the pan traces a slow // ellipse, starting from a corner. ( - scale, - -pan_x * (0.5 + 0.5 * phase.sin()), - -pan_y * (0.5 + 0.5 * phase.cos()), + -scaled.pan_x * (0.5 + 0.5 * phase.sin()), + -scaled.pan_y * (0.5 + 0.5 * phase.cos()), ) } else { - (cover, 0.0, 0.0) + (0.0, 0.0) }; - target.fill(tiny_skia::Color::BLACK); - target.draw_pixmap( - 0, - 0, - source.as_ref(), - &PixmapPaint::default(), - // scale first (image coords → scaled), then translate into - // the pan position. - Transform::from_translate(tx, ty).pre_concat(Transform::from_scale(scale, scale)), - None, - ); + // The cached pixmap is already output-sized, so this per-frame + // draw is a 1:1 copy with at most a translation. `draw_pixmap` + // runs the full raster pipeline per pixel (~20 ms for a + // full-screen layer), which is the dominant software-render + // cost — so do the blit directly instead: rows are memcpy'd + // (nearest sampling on an already-correct-size image is + // pixel-identical, and the pan offsets quantize the same way + // tiny-skia's nearest filter does). + blit_translate(target, &scaled.pixmap, tx, ty, smooth); } } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4x4 pixmap whose pixel at (x, y) is `(x * 63, y * 63, 0, 255)` — + /// every pixel is distinct, so a shifted copy is easy to assert. + fn source_grid() -> Pixmap { + let mut p = Pixmap::new(4, 4).unwrap(); + for y in 0..4 { + for x in 0..4 { + p.pixels_mut()[y * 4 + x] = tiny_skia::PremultipliedColorU8::from_rgba( + (x * 63) as u8, + (y * 63) as u8, + 0, + 255, + ) + .unwrap(); + } + } + p + } + + #[test] + fn blit_translate_copies_shifted_region() { + let src = source_grid(); + let mut dst = Pixmap::new(2, 2).unwrap(); + // Shift the 4x4 source by (-1, -1): the visible region is src[1..3, 1..3]. + blit_translate(&mut dst, &src, -1.0, -1.0, false); + let px = dst.pixels(); + assert_eq!(px[0].red(), 63, "(0,0) should be src(1,1) red"); + assert_eq!(px[0].green(), 63, "(0,0) should be src(1,1) green"); + assert_eq!(px[1].red(), 126, "(1,0) should be src(2,1) red"); + assert_eq!(px[1].green(), 63); + assert_eq!(px[2].red(), 63, "(0,1) should be src(1,2) red"); + assert_eq!(px[2].green(), 126); + assert_eq!(px[3].red(), 126, "(1,1) should be src(2,2)"); + assert_eq!(px[3].green(), 126); + } + + #[test] + fn blit_translate_clamps_within_source() { + // An offset larger than the overhang must clamp, not read out of + // bounds or leave uninitialized rows. + let src = source_grid(); + let mut dst = Pixmap::new(2, 2).unwrap(); + blit_translate(&mut dst, &src, -99.0, -99.0, false); + // Clamped to the bottom-right 2x2 of the source. + let px = dst.pixels(); + assert_eq!(px[0].red(), 126); + assert_eq!(px[0].green(), 126); + assert_eq!(px[3].red(), 189); + assert_eq!(px[3].green(), 189); + } + + #[test] + fn ken_burns_pan_never_exposes_edges() { + // A small solid-color image panned through a full cycle must cover + // the whole target at every phase — no black borders. + let mut source = Pixmap::new(80, 40).unwrap(); + source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255)); + let bg = Background::Image(ImageBg { + source, + ken_burns: true, + cache: RefCell::new(None), + }); + let mut target = Pixmap::new(60, 30).unwrap(); + for i in 0..90 { + bg.paint(&mut target, i as f32, true); + assert!( + target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30), + "frame {i} exposed an edge" + ); + } + } + + #[test] + fn bilinear_shift_matches_fractional_position() { + // A row of (0..255, 0, 0, 255): a half-pixel right shift should give + // the exact average of each adjacent pair. + let mut src = Pixmap::new(8, 1).unwrap(); + for x in 0..8 { + src.pixels_mut()[x] = + tiny_skia::PremultipliedColorU8::from_rgba((x * 32) as u8, 0, 0, 255).unwrap(); + } + let mut dst = Pixmap::new(6, 1).unwrap(); + // Shift by (-0.5, 0): visible region starts at src 0.5 → each output + // pixel averages src[x] and src[x + 1]. + blit_translate(&mut dst, &src, -0.5, 0.0, true); + let px = dst.pixels(); + assert_eq!(px[0].red(), ((0 + 32) / 2) as u8, "0.5px shift averages neighbors"); + assert_eq!(px[1].red(), ((32 + 64) / 2) as u8); + assert_eq!(px[5].red(), ((160 + 192) / 2) as u8); + } + + #[test] + fn static_image_keeps_cover_fit() { + // Without Ken Burns the image is cover-fit exactly: still no edges. + let mut source = Pixmap::new(80, 40).unwrap(); + source.fill(tiny_skia::Color::from_rgba8(200, 30, 30, 255)); + let bg = Background::Image(ImageBg { + source, + ken_burns: false, + cache: RefCell::new(None), + }); + let mut target = Pixmap::new(60, 30).unwrap(); + bg.paint(&mut target, 0.0, true); + assert!(target.pixels().iter().all(|p| p.red() == 200 && p.green() == 30)); + } +} diff --git a/breadlock/src/bin/breadlock-preview.rs b/breadlock/src/bin/breadlock-preview.rs index d8ae223..3fb14fd 100644 --- a/breadlock/src/bin/breadlock-preview.rs +++ b/breadlock/src/bin/breadlock-preview.rs @@ -77,9 +77,120 @@ impl Default for Scene { } } +/// `--time [WxH] [frames] [wallpaper.png]` — renders the real compose() path +/// (image background + Ken Burns, full chrome) in a loop and prints per-frame +/// timings, so the software renderer's cost can be measured without Wayland. +fn bench(args: &[String]) { + let parse = |s: &str, d: &str| -> String { args.iter().find(|a| a.starts_with(s)).map(|a| a[s.len()..].to_string()).unwrap_or_else(|| d.to_string()) }; + let size: (u32, u32) = { + let v: Vec = parse("--size=", "1920x1200").split('x').filter_map(|s| s.parse().ok()).collect(); + (v[0], v[1]) + }; + let frames: u32 = parse("--frames=", "120").parse().unwrap_or(120); + let path = parse("--wallpaper=", "/home/breadway/.config/breadlock/wallpaper.png"); + + let palette = theme::load_palette(); + let bg_cfg = breadlock_ui::config::Background { + mode: breadlock_ui::config::BackgroundMode::Image, + path, + blur: false, + ken_burns: true, + }; + let background = background::Background::load(&bg_cfg, &palette); + + let mut text = TextRenderer::new(); + // Warm up once: the first frame builds the scaled-wallpaper cache and + // shapes the glyphs. Steady-state frames are what the timer loop sees. + let warm = FrameInputs { + width: size.0, + height: size.1, + background: &background, + palette: &palette, + font_family: FONT, + clock_text: "12:34", + date_text: "Friday · Aug 21", + clock_old: None, + password_len: 6, + failed: false, + failed_t: 0.0, + dot_pop_t: 1.0, + keystroke_age: None, + t_secs: 0.0, + breathe_t: 0.0, + status_t: 1.0, + status_text: None, + appear_t: 1.0, + unlock_t: 0.0, + smooth_pan: true, + }; + compose(&mut text, &warm).expect("warm-up compose failed"); + + // Isolate the background pass cost (wallpaper blit + fills) alone. + let mut bg_times = Vec::new(); + { + let mut dummy = tiny_skia::Pixmap::new(size.0, size.1).expect("pixmap"); + for i in 0..60 { + let t = std::time::Instant::now(); + background.paint(&mut dummy, (i as f32 / 60.0) * 90.0, true); + bg_times.push(t.elapsed().as_secs_f64() * 1000.0); + } + bg_times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let avg: f64 = bg_times.iter().sum::() / bg_times.len() as f64; + println!("background.paint only: avg {avg:.2} ms max {:.2} ms", bg_times[bg_times.len() - 1]); + } + + let mut times = Vec::with_capacity(frames as usize); + let start = std::time::Instant::now(); + for i in 0..frames { + let t = std::time::Instant::now(); + let inputs = FrameInputs { + width: size.0, + height: size.1, + background: &background, + palette: &palette, + font_family: FONT, + clock_text: "12:34", + date_text: "Friday · Aug 21", + clock_old: None, + password_len: 6, + failed: false, + failed_t: 0.0, + dot_pop_t: 1.0, + keystroke_age: None, + // Walk t_secs through a Ken Burns cycle so every frame differs. + t_secs: (i as f32 / frames as f32) * 90.0, + breathe_t: (i % 10) as f32 / 10.0, + status_t: 1.0, + status_text: None, + appear_t: 1.0, + unlock_t: 0.0, + smooth_pan: true, + }; + if compose(&mut text, &inputs).is_none() { + eprintln!("compose returned None at frame {i}"); + std::process::exit(1); + } + times.push(t.elapsed().as_secs_f64() * 1000.0); + } + let total = start.elapsed().as_secs_f64() * 1000.0; + times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let avg: f64 = times.iter().sum::() / times.len() as f64; + let p95 = times[(times.len() as f64 * 0.95) as usize]; + println!( + "{frames} frames @ {}x{}: avg {avg:.2} ms p95 {p95:.2} ms max {:.2} ms total {total:.0} ms (first frame excluded from avg? no)", + size.0, size.1, times[times.len() - 1] + ); +} + fn main() { - let out_dir = std::env::args() - .nth(1) + let args: Vec = std::env::args().skip(1).collect(); + if args.iter().any(|a| a == "--time") { + bench(&args); + return; + } + let out_dir = args + .first() + .cloned() .unwrap_or_else(|| "preview".to_string()); std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir"); @@ -137,6 +248,7 @@ fn main() { status_text: scene.status, appear_t: scene.appear_t, unlock_t: scene.unlock_t, + smooth_pan: false, }; let Some(pixmap) = compose(&mut text, &inputs) else { eprintln!("compose returned None for scene {}", scene.name); diff --git a/breadlock/src/gpu.rs b/breadlock/src/gpu.rs new file mode 100644 index 0000000..2b32d1c --- /dev/null +++ b/breadlock/src/gpu.rs @@ -0,0 +1,796 @@ +//! GPU background rendering via EGL/GLES2, with the chrome composited in +//! software (tiny-skia) on top — the hybrid that makes the Ken Burns pan +//! smooth without a GPU-hungry full renderer. +//! +//! The lock surface's `wl_surface` is wrapped in a `wl_egl_window`; each +//! frame the wallpaper is drawn as a full-screen textured quad whose shader +//! applies the pan transform (GPU bilinear filtering makes sub-pixel motion +//! free — the ~19 ms/frame software bilinear is gone) and the vertical dim +//! veil. The chrome (clock/date/pill/status) is still composed by +//! `render::compose_chrome` into a transparent pixmap and blitted to a +//! texture each frame (only the bounding rect of what was drawn). +//! +//! If EGL initialization fails for any reason (headless, no GPU, compositor +//! without EGL), [`GpuRenderer::new`] returns `None` and the locker falls +//! back to the fully-software path unchanged. + +use crate::render::{self, FrameInputs}; +use breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode}; +use breadlock_ui::painter::TextRenderer; +use breadlock_ui::theme::Palette; +use glow::HasContext; +use khronos_egl as egl; +use std::os::raw::c_void; +use tiny_skia::Pixmap; +use wayland_client::protocol::wl_surface::WlSurface; +use wayland_client::{Connection, Proxy}; + +// Same pan geometry as `background.rs` — kept in sync by comment. +const KENBURNS_PERIOD_S: f32 = 90.0; +const KENBURNS_ZOOM: f32 = 1.06; + +const EGL_ATTRIBS: [egl::Int; 11] = [ + egl::SURFACE_TYPE, + (egl::WINDOW_BIT | egl::PBUFFER_BIT) as egl::Int, + egl::RED_SIZE, + 8, + egl::GREEN_SIZE, + 8, + egl::BLUE_SIZE, + 8, + egl::ALPHA_SIZE, + 8, + egl::NONE, +]; + +const VERTEX_SRC: &str = "\ +attribute vec2 a_pos; // pixels, (0,0) top-left +uniform vec2 u_screen; +uniform vec2 u_uv_scale; +uniform vec2 u_uv_offset; +varying vec2 v_uv; +void main() { + v_uv = a_pos * u_uv_scale + u_uv_offset; + vec2 clip = vec2(a_pos.x / u_screen.x * 2.0 - 1.0, 1.0 - a_pos.y / u_screen.y * 2.0); + gl_Position = vec4(clip, 0.0, 1.0); +}"; + +// Background: sample the wallpaper (or a 1x1 white texture for solid color), +// apply the vertical dim veil. v_uv has v = 0 at the top of the image. +const BG_FRAG_SRC: &str = "\ +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_tex; +uniform vec4 u_color; +uniform float u_dim_top; +uniform float u_dim_bottom; +uniform float u_veil_alpha; +uniform float u_screen_h; +void main() { + vec4 c = texture2D(u_tex, v_uv) * u_color; + float row = 1.0 - gl_FragCoord.y / u_screen_h; // 1 at top + float dim = mix(u_dim_top, u_dim_bottom, row) * u_veil_alpha; + gl_FragColor = vec4(c.rgb * (1.0 - dim), 1.0); +}"; + +// Chrome: premultiplied alpha texture, blended with GL_ONE / ONE_MINUS_SRC_ALPHA. +const CHROME_FRAG_SRC: &str = "\ +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_tex; +void main() { + gl_FragColor = texture2D(u_tex, v_uv); +}"; + +// The wl_egl_window C API (libwayland-egl). The window wraps a wl_surface +// so EGL can allocate its buffers against the lock surface. +#[repr(C)] +struct wl_surface { + _private: [u8; 0], +} +#[repr(C)] +pub struct wl_egl_window { + _private: [u8; 0], +} + +#[link(name = "wayland-egl")] +extern "C" { + fn wl_egl_window_create(surface: *mut wl_surface, width: i32, height: i32) -> *mut wl_egl_window; + fn wl_egl_window_resize(window: *mut wl_egl_window, width: i32, height: i32, dx: i32, dy: i32); +} + +// EGL objects are intentionally not destroyed on the way out: the process +// exits immediately after unlock, and dropping the pbuffer/context while it +// might still be current would be UB — leaving them for the OS is cleaner. +const _: () = (); + +/// One EGL-backed lock surface. Created lazily on the first `configure` (the +/// size is unknown before that) and resized on subsequent ones. The process +/// exits right after unlock, so EGL objects are deliberately not destroyed +/// individually. +pub struct GpuSurface { + egl_window: *mut wl_egl_window, + egl_surface: egl::Surface, + width: u32, + height: u32, +} + +impl GpuSurface { + pub(crate) fn resize(&mut self, width: u32, height: u32) { + if (width, height) == (self.width, self.height) { + return; + } + // SAFETY: `egl_window` is the pointer `create_surface` stored. + unsafe { wl_egl_window_resize(self.egl_window, width as i32, height as i32, 0, 0) }; + self.width = width; + self.height = height; + } +} + +struct Wallpaper { + tex: glow::Texture, + size: (u32, u32), + ken_burns: bool, +} + +pub struct GpuRenderer { + egl: egl::DynamicInstance, + display: egl::Display, + config: egl::Config, + context: egl::Context, + /// 1x1 pbuffer used to make the context current during setup (before any + /// real lock surface exists). Kept alive for the renderer's lifetime — + /// the read is deliberate: dropping it while the context might still be + /// current on it is undefined behavior. + #[allow(dead_code)] + setup_surface: egl::Surface, + gl: glow::Context, + bg_program: glow::Program, + chrome_program: glow::Program, + quad_vao: glow::VertexArray, + quad_vbo: glow::Buffer, + wallpaper: Option, + /// 1x1 white texture for solid-color backgrounds (shader multiplies by + /// the palette color). + white_tex: glow::Texture, + bg_color: [f32; 4], + chrome_tex: glow::Texture, + chrome_tex_size: (u32, u32), + /// Reused scratch for the chrome compose. + chrome_pixmap: Option, + u_screen: [Option; 2], + u_uv_scale: [Option; 2], + u_uv_offset: [Option; 2], + u_tex: [Option; 2], + u_color: Option, + u_dim_top: Option, + u_dim_bottom: Option, + u_veil_alpha: Option, + u_screen_h: Option, +} + +impl GpuRenderer { + /// Initializes EGL/GLES2 against the session's Wayland display and loads + /// the wallpaper into a texture. Returns `None` (after logging) on any + /// failure — the caller keeps the software path. + pub fn new(conn: &Connection, bg_cfg: &BackgroundConfig, palette: &Palette) -> Option { + // SAFETY: khronos-egl's dynamic instance loads libEGL.so.1; the + // returned handles are only used while the library stays loaded. + let egl = unsafe { egl::DynamicInstance::::load_required() }.ok()?; + // SAFETY: the display pointer comes from our live wayland connection. + let display = unsafe { egl.get_display(conn.display().id().as_ptr() as *mut c_void) }?; + egl.initialize(display).ok()?; + let mut configs = Vec::with_capacity(1); + egl.choose_config(display, &EGL_ATTRIBS, &mut configs).ok()?; + let config = *configs.first()?; + let context = egl + .create_context(display, config, None, &[egl::CONTEXT_CLIENT_VERSION, 2, egl::NONE]) + .ok()?; + // A 1x1 pbuffer is enough to make the context current for setup + // before any real lock surface exists (pbuffers size via + // EGL_WIDTH/EGL_HEIGHT). + let setup_surface = egl + .create_pbuffer_surface(display, config, &[egl::WIDTH, 1, egl::HEIGHT, 1, egl::NONE]) + .ok()?; + if egl + .make_current(display, Some(setup_surface), Some(setup_surface), Some(context)) + .is_err() + { + return None; + } + + let gl = unsafe { + glow::Context::from_loader_function_cstr(|name| { + egl.get_proc_address(name.to_str().unwrap_or("")) + .map(|p| p as *const c_void) + .unwrap_or(std::ptr::null()) + }) + }; + + let bg_program = compile_program(&gl, VERTEX_SRC, BG_FRAG_SRC)?; + let chrome_program = compile_program(&gl, VERTEX_SRC, CHROME_FRAG_SRC)?; + + // Fullscreen quad: two triangles covering [0, w] x [0, h] (pixel + // space). A single unit quad scaled by `u_screen` in the shader + // would need a uniform; instead the vertices are normalized and the + // vertex shader multiplies by u_screen... but a_pos is in pixels — + // so upload actual pixel positions per surface size? No: keep the + // quad in unit space and let the shader's u_screen scale it. The + // shader expects a_pos in pixels, so upload a 1x1 unit quad scaled + // at bind time via glVertexAttrib? Simpler: use normalized coords. + let quad_vao = unsafe { gl.create_vertex_array() }.ok()?; + let quad_vbo = unsafe { gl.create_buffer() }.ok()?; + unsafe { + gl.bind_vertex_array(Some(quad_vao)); + gl.bind_buffer(glow::ARRAY_BUFFER, Some(quad_vbo)); + // Unit quad [0,1]^2; the vertex shader multiplies by u_screen. + let verts: [f32; 12] = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]; + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::STATIC_DRAW); + gl.enable_vertex_attrib_array(0); + gl.vertex_attrib_pointer_f32(0, 2, glow::FLOAT, false, 8, 0); + } + + // Wallpaper texture (original resolution; the GPU downscales + mipmaps). + let wallpaper = match &bg_cfg.mode { + BackgroundMode::Color => None, + BackgroundMode::Image if bg_cfg.path.is_empty() => { + tracing::warn!("background.mode = \"image\" but background.path is empty, using solid color"); + None + } + BackgroundMode::Image => match Pixmap::load_png(&bg_cfg.path) { + Ok(pix) => { + let (w, h) = (pix.width(), pix.height()); + let tex = unsafe { gl.create_texture() }.ok()?; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(tex)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + w as i32, + h as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(pix.data())), + ); + gl.generate_mipmap(glow::TEXTURE_2D); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::LINEAR_MIPMAP_LINEAR as i32, + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + } + Some(Wallpaper { + tex, + size: (w, h), + ken_burns: bg_cfg.ken_burns, + }) + } + Err(err) => { + tracing::warn!(path = %bg_cfg.path, %err, "GPU: failed to load background image, using solid color"); + None + } + }, + }; + + // 1x1 white texture for the solid-color shader path. + let white_tex = unsafe { gl.create_texture() }.ok()?; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(white_tex)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + 1, + 1, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(&[255, 255, 255, 255])), + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); + } + + // Full-size chrome texture (sub-image uploaded per frame). + let chrome_tex = unsafe { gl.create_texture() }.ok()?; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(chrome_tex)); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + } + + let bg = breadlock_ui::theme::tiny_skia_color(&palette.background); + let bg_color = [bg.red(), bg.green(), bg.blue(), 1.0]; + + // Resolve all uniform locations up front, then drop the closure so + // `gl` can move into the renderer. + let (u_screen, u_uv_scale, u_uv_offset, u_tex, u_color, u_dim_top, u_dim_bottom, u_veil_alpha, u_screen_h) = { + let loc = |p: glow::Program, n: &str| unsafe { gl.get_uniform_location(p, n) }; + ( + [loc(bg_program, "u_screen"), loc(chrome_program, "u_screen")], + [loc(bg_program, "u_uv_scale"), loc(chrome_program, "u_uv_scale")], + [loc(bg_program, "u_uv_offset"), loc(chrome_program, "u_uv_offset")], + [loc(bg_program, "u_tex"), loc(chrome_program, "u_tex")], + loc(bg_program, "u_color"), + loc(bg_program, "u_dim_top"), + loc(bg_program, "u_dim_bottom"), + loc(bg_program, "u_veil_alpha"), + loc(bg_program, "u_screen_h"), + ) + }; + + Some(Self { + egl, + display, + config, + context, + setup_surface, + gl, + bg_program, + chrome_program, + quad_vao, + quad_vbo, + wallpaper, + white_tex, + bg_color, + chrome_tex, + chrome_tex_size: (0, 0), + chrome_pixmap: None, + u_screen, + u_uv_scale, + u_uv_offset, + u_tex, + u_color, + u_dim_top, + u_dim_bottom, + u_veil_alpha, + u_screen_h, + }) + } + + /// Wraps a lock surface's `wl_surface` in an EGL window + surface. + /// Called once per surface from its first `configure`. + pub fn create_surface(&self, surface: &WlSurface, width: u32, height: u32) -> Option { + // SAFETY: the surface proxy is live (this is called from its + // `configure` handler); the returned window is owned by us. + let egl_window = unsafe { + wl_egl_window_create( + surface.id().as_ptr() as *mut wl_surface, + width as i32, + height as i32, + ) + }; + if egl_window.is_null() { + tracing::error!("wl_egl_window_create failed"); + return None; + } + // SAFETY: `egl_window` is a valid wl_egl_window native window. + let egl_surface = unsafe { + self.egl + .create_window_surface(self.display, self.config, egl_window as *mut c_void, None) + } + .ok()?; + Some(GpuSurface { + egl_window, + egl_surface, + width, + height, + }) + } + + /// Renders one frame for `surface`: wallpaper quad (pan + veil in the + /// shader), then the software-composed chrome blitted over it. + pub fn render_frame( + &mut self, + surface: &mut GpuSurface, + inputs: &FrameInputs, + text: &mut TextRenderer, + ) { + let (w, h) = (surface.width, surface.height); + if w == 0 || h == 0 { + return; + } + if self + .egl + .make_current(self.display, Some(surface.egl_surface), Some(surface.egl_surface), Some(self.context)) + .is_err() + { + return; + } + let gl = &self.gl; + unsafe { gl.viewport(0, 0, w as i32, h as i32) }; + self.draw_background(w, h, inputs); + self.draw_chrome(w, h, inputs, text); + let _ = self.egl.swap_buffers(self.display, surface.egl_surface); + } + + fn draw_background(&mut self, w: u32, h: u32, inputs: &FrameInputs) { + let gl = &self.gl; + let (veil_alpha, _) = render::overlay_motion(inputs.appear_t, inputs.unlock_t); + unsafe { + gl.use_program(Some(self.bg_program)); + gl.bind_vertex_array(Some(self.quad_vao)); + // Unit quad -> pixels: the vertex shader uses a_pos in pixels, so + // upload the quad scaled... a_pos IS in pixels only if we pass + // pixel positions; with a unit quad, scale here instead. + // The vertex shader treats a_pos as pixels and divides by + // u_screen — for a unit quad we pass a_pos * screen, so set the + // buffer? Simpler: keep unit quad and multiply u_screen into the + // uv math in the shader. To avoid shader churn: upload a full + // pixel-space quad per surface size. + let wf = w as f32; + let hf = h as f32; + let verts: [f32; 12] = [ + 0.0, 0.0, wf, 0.0, 0.0, hf, // + wf, 0.0, wf, hf, 0.0, hf, + ]; + gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); + + if let Some(loc) = self.u_screen[0].as_ref() { + gl.uniform_2_f32(Some(loc), wf, hf); + } + if let Some(loc) = self.u_uv_scale[0].as_ref() { + match &self.wallpaper { + Some(wp) => { + let (_, _, scaled_w, scaled_h) = + pan_region(wp.size, (w, h), wp.ken_burns, inputs.t_secs); + gl.uniform_2_f32(Some(loc), 1.0 / scaled_w, 1.0 / scaled_h); + } + None => gl.uniform_2_f32(Some(loc), 0.0, 0.0), + } + } + if let Some(loc) = self.u_uv_offset[0].as_ref() { + match &self.wallpaper { + Some(wp) => { + let (sx0, sy0, scaled_w, scaled_h) = + pan_region(wp.size, (w, h), wp.ken_burns, inputs.t_secs); + gl.uniform_2_f32(Some(loc), sx0 / scaled_w, sy0 / scaled_h); + } + None => gl.uniform_2_f32(Some(loc), 0.0, 0.0), + } + } + if let Some(loc) = self.u_color.as_ref() { + match &self.wallpaper { + Some(_) => gl.uniform_4_f32(Some(loc), 1.0, 1.0, 1.0, 1.0), + None => gl.uniform_4_f32( + Some(loc), + self.bg_color[0], + self.bg_color[1], + self.bg_color[2], + 1.0, + ), + } + } + if let Some(loc) = self.u_dim_top.as_ref() { + gl.uniform_1_f32(Some(loc), render::DIM_ALPHA_TOP); + } + if let Some(loc) = self.u_dim_bottom.as_ref() { + gl.uniform_1_f32(Some(loc), render::DIM_ALPHA_BOTTOM); + } + if let Some(loc) = self.u_veil_alpha.as_ref() { + gl.uniform_1_f32(Some(loc), veil_alpha); + } + if let Some(loc) = self.u_screen_h.as_ref() { + gl.uniform_1_f32(Some(loc), h as f32); + } + gl.active_texture(glow::TEXTURE0); + match &self.wallpaper { + Some(wp) => gl.bind_texture(glow::TEXTURE_2D, Some(wp.tex)), + None => gl.bind_texture(glow::TEXTURE_2D, Some(self.white_tex)), + } + if let Some(loc) = self.u_tex[0].as_ref() { + gl.uniform_1_i32(Some(loc), 0); + } + gl.disable(glow::BLEND); + gl.draw_arrays(glow::TRIANGLES, 0, 6); + } + } + + fn draw_chrome(&mut self, w: u32, h: u32, inputs: &FrameInputs, text: &mut TextRenderer) { + let dirty = self + .chrome_pixmap + .as_ref() + .map(|p| (p.width(), p.height()) != (w, h)) + .unwrap_or(true); + if dirty { + self.chrome_pixmap = Pixmap::new(w, h); + } + let Some(pixmap) = self.chrome_pixmap.as_mut() else { + return; + }; + let rect = render::compose_chrome(pixmap, text, inputs); + let x0 = rect.x0.max(0.0).floor() as i32; + let y0 = rect.y0.max(0.0).floor() as i32; + let x1 = (rect.x1.min(w as f32)).ceil() as i32; + let y1 = (rect.y1.min(h as f32)).ceil() as i32; + if x1 <= x0 || y1 <= y0 { + return; + } + if self.chrome_tex_size != (w, h) { + let gl = &self.gl; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + w as i32, + h as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(None), + ); + } + self.chrome_tex_size = (w, h); + } + + let data = pixmap.data(); + let stride = w as usize * 4; + let offset = y0 as usize * stride + x0 as usize * 4; + let rw = (x1 - x0) as i32; + let rh = (y1 - y0) as i32; + let gl = &self.gl; + unsafe { + gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.tex_sub_image_2d( + glow::TEXTURE_2D, + 0, + x0, + y0, + rw, + rh, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(&data[offset..])), + ); + gl.use_program(Some(self.chrome_program)); + gl.bind_vertex_array(Some(self.quad_vao)); + gl.bind_buffer(glow::ARRAY_BUFFER, Some(self.quad_vbo)); + let wf = w as f32; + let hf = h as f32; + let verts: [f32; 12] = [ + 0.0, 0.0, wf, 0.0, 0.0, hf, // + wf, 0.0, wf, hf, 0.0, hf, + ]; + gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, f32s_as_bytes(&verts), glow::DYNAMIC_DRAW); + if let Some(loc) = self.u_screen[1].as_ref() { + gl.uniform_2_f32(Some(loc), wf, hf); + } + if let Some(loc) = self.u_uv_scale[1].as_ref() { + gl.uniform_2_f32(Some(loc), 1.0 / wf, 1.0 / hf); + } + if let Some(loc) = self.u_uv_offset[1].as_ref() { + gl.uniform_2_f32(Some(loc), 0.0, 0.0); + } + if let Some(loc) = self.u_tex[1].as_ref() { + gl.uniform_1_i32(Some(loc), 0); + } + gl.active_texture(glow::TEXTURE0); + gl.bind_texture(glow::TEXTURE_2D, Some(self.chrome_tex)); + gl.enable(glow::BLEND); + gl.blend_func(glow::ONE, glow::ONE_MINUS_SRC_ALPHA); + gl.draw_arrays(glow::TRIANGLES, 0, 6); + gl.disable(glow::BLEND); + } + } +} + +/// Visible source region of the wallpaper for the current pan phase — the +/// same cover-fit + Ken Burns math as `background.rs`. +fn pan_region(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32, f32, f32) { + let (sw, sh) = (wp.0 as f32, wp.1 as f32); + let (tw, th) = (target.0 as f32, target.1 as f32); + let cover = (tw / sw).max(th / sh); + let scale = cover * if ken_burns { KENBURNS_ZOOM } else { 1.0 }; + let scaled_w = sw * scale; + let scaled_h = sh * scale; + let pan_x = (scaled_w - tw).max(0.0); + let pan_y = (scaled_h - th).max(0.0); + let (tx, ty) = if ken_burns { + let phase = t_secs * std::f32::consts::TAU / KENBURNS_PERIOD_S; + ( + -pan_x * (0.5 + 0.5 * phase.sin()), + -pan_y * (0.5 + 0.5 * phase.cos()), + ) + } else { + (0.0, 0.0) + }; + (-tx, -ty, scaled_w, scaled_h) +} + +fn compile_program(gl: &glow::Context, vs_src: &str, fs_src: &str) -> Option { + unsafe { + let program = gl.create_program().ok()?; + let vs_sh = gl.create_shader(glow::VERTEX_SHADER).ok()?; + gl.shader_source(vs_sh, vs_src); + gl.compile_shader(vs_sh); + if !gl.get_shader_compile_status(vs_sh) { + let log = gl.get_shader_info_log(vs_sh); + tracing::error!(%log, "GPU: vertex shader compile failed"); + return None; + } + let fs_sh = gl.create_shader(glow::FRAGMENT_SHADER).ok()?; + gl.shader_source(fs_sh, fs_src); + gl.compile_shader(fs_sh); + if !gl.get_shader_compile_status(fs_sh) { + let log = gl.get_shader_info_log(fs_sh); + tracing::error!(%log, "GPU: fragment shader compile failed"); + return None; + } + gl.attach_shader(program, vs_sh); + gl.attach_shader(program, fs_sh); + gl.link_program(program); + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + tracing::error!(%log, "GPU: program link failed"); + return None; + } + gl.delete_shader(vs_sh); + gl.delete_shader(fs_sh); + Some(program) + } +} + +fn f32s_as_bytes(v: &[f32; 12]) -> &[u8] { + // SAFETY: f32 is POD; the byte length is exact. + unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::TAU; + + /// The software path's pan math (background.rs `Background::Image::paint`), + /// re-implemented here so the GPU `pan_region` can be checked against it. + /// Software rounds the scaled dims to pixels; GPU keeps floats, so + /// compare with a 1px tolerance. + fn software_pan(wp: (u32, u32), target: (u32, u32), ken_burns: bool, t_secs: f32) -> (f32, f32) { + let (sw, sh) = (wp.0 as f32, wp.1 as f32); + let (tw, th) = (target.0 as f32, target.1 as f32); + let cover = (tw / sw).max(th / sh); + let scale = cover * if ken_burns { KENBURNS_ZOOM } else { 1.0 }; + let scaled_w = (sw * scale).round().max(1.0); + let scaled_h = (sh * scale).round().max(1.0); + let pan_x = scaled_w - tw; + let pan_y = scaled_h - th; + let (tx, ty) = if ken_burns { + let phase = t_secs * TAU / KENBURNS_PERIOD_S; + ( + -pan_x * (0.5 + 0.5 * phase.sin()), + -pan_y * (0.5 + 0.5 * phase.cos()), + ) + } else { + (0.0, 0.0) + }; + (-tx, -ty) + } + + #[test] + fn pan_region_static_matches_software_centered() { + let wp = (3840, 2160); + let target = (1920, 1200); + let (sx, sy, sw, sh) = pan_region(wp, target, false, 123.4); + assert_eq!(sx, 0.0, "no ken burns: no horizontal pan"); + assert_eq!(sy, 0.0, "no ken burns: no vertical pan"); + // Cover fit: the scaled region covers the target in both axes. + assert!(sw >= 1920.0 && sh >= 1200.0); + // And it's the tightest cover: at least one axis exactly matches. + assert!( + (sw - 1920.0).abs() < 0.01 || (sh - 1200.0).abs() < 0.01, + "cover must be tight, got {sw}x{sh}" + ); + } + + #[test] + fn pan_region_ken_burns_tracks_software_path() { + let wp = (3840, 2160); + let target = (1920, 1200); + for i in 0..=40 { + let t = i as f32 / 40.0 * KENBURNS_PERIOD_S; + let (sx, sy, _, _) = pan_region(wp, target, true, t); + let (ex, ey) = software_pan(wp, target, true, t); + assert!( + (sx - ex).abs() < 1.0, + "x pan diverged from software at t={t}: gpu {sx} vs sw {ex}" + ); + assert!( + (sy - ey).abs() < 1.0, + "y pan diverged from software at t={t}: gpu {sy} vs sw {ey}" + ); + } + } + + #[test] + fn pan_region_never_exposes_edges() { + let wp = (3840, 2160); + let target = (1920, 1200); + for i in 0..=200 { + let t = i as f32 / 200.0 * KENBURNS_PERIOD_S; + let (sx, sy, sw, sh) = pan_region(wp, target, true, t); + assert!(sx >= -0.001, "negative x offset at t={t}"); + assert!(sy >= -0.001, "negative y offset at t={t}"); + assert!( + sx + 1920.0 <= sw + 0.001, + "right edge exposed at t={t}: sx {sx} + 1920 > sw {sw}" + ); + assert!( + sy + 1200.0 <= sh + 0.001, + "bottom edge exposed at t={t}: sy {sy} + 1200 > sh {sh}" + ); + } + } + + #[test] + fn pan_region_starts_at_corner_and_returns() { + // t=0: sin=0, cos=1 → the region sits at the top, horizontally centered. + let wp = (3840, 2160); + let target = (1920, 1200); + let (sx0, sy0, sw, _) = pan_region(wp, target, true, 0.0); + let pan_x = sw - 1920.0; + let cover = (1920.0f32 / 3840.0).max(1200.0f32 / 2160.0); + let pan_y = (2160.0 * (cover * KENBURNS_ZOOM)).round() - 1200.0; + assert!((sx0 - pan_x * 0.5).abs() < 0.5, "at t=0 x should be half-panned, got {sx0}"); + assert!((sy0 - pan_y).abs() < 0.5, "at t=0 y should be fully panned (top), got {sy0}"); + // Half a period later it has returned to the same spot. + let (sx1, sy1, _, _) = pan_region(wp, target, true, KENBURNS_PERIOD_S); + assert!((sx1 - sx0).abs() < 0.01 && (sy1 - sy0).abs() < 0.01); + } + + /// Extracts every `uniform ;` declaration from a GLSL source. + fn declared_uniforms(src: &str) -> Vec { + let mut out = Vec::new(); + for line in src.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("uniform ") { + if let Some((_, name)) = rest.rsplit_once(' ') { + out.push(name.trim_end_matches(';').to_string()); + } + } + } + out + } + + #[test] + fn shaders_declare_every_uniform_the_renderer_sets() { + // If a uniform is renamed in the GLSL but not at the call site (or + // vice versa) it silently becomes -1 and the frame renders wrong; + // this test pins the two together. + let declared = [ + declared_uniforms(VERTEX_SRC), + declared_uniforms(BG_FRAG_SRC), + declared_uniforms(CHROME_FRAG_SRC), + ] + .concat(); + for name in [ + "u_screen", "u_uv_scale", "u_uv_offset", "u_tex", "u_color", + "u_dim_top", "u_dim_bottom", "u_veil_alpha", "u_screen_h", + ] { + assert!( + declared.iter().any(|d| d == name), + "uniform {name} missing from shader sources" + ); + } + } + + #[test] + fn egl_attribs_are_none_terminated_pairs() { + assert_eq!(EGL_ATTRIBS.len() % 2, 1, "attribs must be key/value pairs + NONE"); + assert_eq!(*EGL_ATTRIBS.last().unwrap(), egl::NONE, "attrib list must be NONE-terminated"); + } + + #[test] + fn f32s_as_bytes_has_exact_length() { + let v: [f32; 12] = [0.0; 12]; + assert_eq!(f32s_as_bytes(&v).len(), 12 * 4); + } +} diff --git a/breadlock/src/lock/session.rs b/breadlock/src/lock/session.rs index a3c0ba4..3641a49 100644 --- a/breadlock/src/lock/session.rs +++ b/breadlock/src/lock/session.rs @@ -43,6 +43,14 @@ impl SessionLockHandler for AppState { { s.width = width; s.height = height; + // Lazily wrap the surface in EGL on its first (sized) configure; + // resize the EGL window on subsequent ones. + if let Some(renderer) = &self.gpu { + match &mut s.gpu { + None => s.gpu = renderer.create_surface(surface.wl_surface(), width, height), + Some(gs) => gs.resize(width, height), + } + } } self.redraw_surface(qh, &surface, width, height); } diff --git a/breadlock/src/lock/surface.rs b/breadlock/src/lock/surface.rs index bb6d520..b1d20ac 100644 --- a/breadlock/src/lock/surface.rs +++ b/breadlock/src/lock/surface.rs @@ -76,6 +76,7 @@ impl OutputHandler for AppState { output, width: 0, height: 0, + gpu: None, }); } diff --git a/breadlock/src/main.rs b/breadlock/src/main.rs index 9d9d4e4..4e43ea6 100644 --- a/breadlock/src/main.rs +++ b/breadlock/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod background; mod bread_events; mod config; +mod gpu; mod input; mod lock; mod render; @@ -145,6 +146,15 @@ fn run_lock() { let background = Background::load(&config.appearance.background, &palette); let conn = Connection::connect_to_env().expect("failed to connect to the Wayland display — breadlock must run inside an active Wayland session"); + // GPU background rendering (EGL/GLES2). Any failure is non-fatal: the + // software renderer takes over. `run_lock` is only ever entered in Lock + // mode (the listen subscriber never renders), so no mode check here. + let gpu = gpu::GpuRenderer::new(&conn, &config.appearance.background, &palette); + if gpu.is_some() { + tracing::info!("GPU background rendering enabled (EGL/GLES2)"); + } else { + tracing::warn!("GPU background rendering unavailable — using the software renderer"); + } let (globals, event_queue) = registry_queue_init::(&conn).expect("failed to initialize Wayland registry"); let qh: QueueHandle = event_queue.handle(); @@ -212,6 +222,7 @@ fn run_lock() { config, palette, background, + gpu, text_renderer: breadlock_ui::painter::TextRenderer::new(), username, // Pre-reserve capacity so ordinary typing doesn't reallocate — a @@ -254,6 +265,7 @@ fn run_lock() { output, width: 0, height: 0, + gpu: None, }); } app_state.session_lock = Some(session_lock); diff --git a/breadlock/src/render.rs b/breadlock/src/render.rs index fe35bcc..273814e 100644 --- a/breadlock/src/render.rs +++ b/breadlock/src/render.rs @@ -16,7 +16,7 @@ use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer}; use breadlock_ui::theme::tiny_skia_color; use std::f32::consts::PI; use std::time::Instant; -use tiny_skia::{Color, Paint, Pixmap, Rect, Transform}; +use tiny_skia::{Color, Paint, Pixmap, Transform}; /// Lock-appear duration: elements ease in on a small stagger (see the /// `*_DELAY_MS` consts) instead of one uniform fade. @@ -62,9 +62,34 @@ const BREATHE_RING_ALPHA: f32 = 0.12; /// How far the status line rises during its slide-in. const STATUS_SLIDE_PX: f32 = 8.0; /// Dim veil over the wallpaper: a vertical gradient, darker at the top so -/// the clock (in the upper third) sits on the deepest tone. -const DIM_ALPHA_TOP: f32 = 0.34; -const DIM_ALPHA_BOTTOM: f32 = 0.16; +/// the clock (in the upper third) sits on the deepest tone. `pub(crate)` for +/// the GPU background shader, which applies the same gradient. +pub(crate) const DIM_ALPHA_TOP: f32 = 0.34; +pub(crate) const DIM_ALPHA_BOTTOM: f32 = 0.16; + +/// Darkens a full-screen pixmap with the vertical dim veil, in place: +/// premultiplied pixels scale by `1 - lerp(DIM_ALPHA_TOP, DIM_ALPHA_BOTTOM, +/// y/h) * veil_alpha` (equivalent to blending a black gradient over it). A +/// single pass over the surface — the software renderer's largest recurring +/// cost was the full-screen gradient fill/blit, so this keeps it cheap. +fn dim_rows(pixmap: &mut Pixmap, veil_alpha: f32) { + let w = pixmap.width() as usize; + let h = pixmap.height() as usize; + let data = pixmap.data_mut(); + for y in 0..h { + let a = (DIM_ALPHA_TOP + (DIM_ALPHA_BOTTOM - DIM_ALPHA_TOP) * (y as f32 / h as f32)) + * veil_alpha; + let k = 1.0 - a; + let row = y * w * 4; + for px in 0..w { + let i = row + px * 4; + data[i] = (data[i] as f32 * k) as u8; + data[i + 1] = (data[i + 1] as f32 * k) as u8; + data[i + 2] = (data[i + 2] as f32 * k) as u8; + data[i + 3] = (data[i + 3] as f32 * k) as u8; + } + } +} /// Pill hairline-border alpha (sketch: `1px solid rgba(255,255,255,.08)`). const PILL_BORDER_ALPHA: f32 = 0.10; /// Fake drop-shadow layers under the pill (tiny-skia has no blur filter): @@ -130,6 +155,11 @@ pub struct FrameInputs<'a> { pub appear_t: f32, /// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking. pub unlock_t: f32, + /// Sub-pixel bilinear panning for the background. True on slow idle frames + /// (the Ken Burns drift is ~1 px/frame there and integer steps read as + /// judder); false on 60 fps animation frames, where the pan moves < 0.2 px + /// per frame and the bilinear pass would blow the 16 ms budget. + pub smooth_pan: bool, } /// Ease-out cubic. `t` is clamped to 0..1. @@ -209,11 +239,63 @@ fn lerp_color(a: Color, b: Color, t: f32) -> Color { .unwrap_or(a) } +/// Bounding rect of the lock-screen chrome (clock, date, pill, status) in +/// surface pixels — the GPU path uses it to know which region of the chrome +/// texture was drawn (and therefore needs uploading each frame). +#[derive(Debug, Clone, Copy, Default)] +pub struct ChromeRect { + pub x0: f32, + pub y0: f32, + pub x1: f32, + pub y1: f32, +} + +impl ChromeRect { + fn expand(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) { + self.x0 = self.x0.min(x0); + self.y0 = self.y0.min(y0); + self.x1 = self.x1.max(x1); + self.y1 = self.y1.max(y1); + } +} + /// Composes one frame. Returns `None` only if `width`/`height` are degenerate /// (a `0x0` `configure`, which some compositors send transiently). pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option { let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; - inputs.background.paint(&mut pixmap, inputs.t_secs); + compose_impl(&mut pixmap, text, inputs, None); + Some(pixmap) +} + +/// Composes only the chrome (clock/date/pill/status) into a transparent +/// `pixmap`, returning the bounding rect of everything drawn. The background +/// and veil are the GPU's job in the accelerated path; colors are still +/// pre-faded by the veil alpha so the software and GPU paths match. +pub fn compose_chrome( + pixmap: &mut Pixmap, + text: &mut TextRenderer, + inputs: &FrameInputs, +) -> ChromeRect { + pixmap.fill(Color::TRANSPARENT); + let mut rect = ChromeRect::default(); + compose_impl(pixmap, text, inputs, Some(&mut rect)); + rect +} + +/// Shared body of [`compose`] / [`compose_chrome`]. With `rects`, the +/// background/veil are skipped (chrome-only) and each drawn element's box is +/// recorded. +fn compose_impl( + mut pixmap: &mut Pixmap, + text: &mut TextRenderer, + inputs: &FrameInputs, + mut rects: Option<&mut ChromeRect>, +) { + if rects.is_none() { + inputs + .background + .paint(pixmap, inputs.t_secs, inputs.smooth_pan); + } // Overall chrome fade: appear eased in, unlock eased out. The unlock // `fade` multiplies every element below. @@ -221,7 +303,7 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let fade = 1.0 - unlock; let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); if veil_alpha <= 0.0 { - return Some(pixmap); + return; } let (w, h) = (inputs.width as f32, inputs.height as f32); @@ -235,30 +317,13 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); // Translucent veil over the (static) wallpaper — a vertical gradient - // (deeper at the top) that fades in with the chrome. - if let Some(shader) = tiny_skia::LinearGradient::new( - tiny_skia::Point::from_xy(0.0, 0.0), - tiny_skia::Point::from_xy(0.0, h), - vec![ - tiny_skia::GradientStop::new( - 0.0, - Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_TOP * veil_alpha) - .unwrap_or(Color::TRANSPARENT), - ), - tiny_skia::GradientStop::new( - 1.0, - Color::from_rgba(0.0, 0.0, 0.0, DIM_ALPHA_BOTTOM * veil_alpha) - .unwrap_or(Color::TRANSPARENT), - ), - ], - tiny_skia::SpreadMode::Pad, - Transform::identity(), - ) { - let mut paint = Paint::default(); - paint.shader = shader; - if let Some(rect) = Rect::from_xywh(0.0, 0.0, w, h) { - pixmap.fill_rect(rect, &paint, Transform::identity(), None); - } + // (deeper at the top) that fades with the whole chrome. Applied in place + // as a per-pixel multiply (premultiplied pixels scale by `1 - a` for a + // black overlay), which is far cheaper than a full-surface gradient + // fill/blit every frame. Skipped in the chrome-only path (the GPU shader + // applies the same veil to the background). + if rects.is_none() && veil_alpha > 0.0 { + dim_rows(pixmap, veil_alpha); } // Per-element staggered entrance. @@ -284,6 +349,15 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let clock_y_rest = h * 0.28; let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK); let clock_alpha = clock_e * fade; + if let Some(r) = rects.as_deref_mut() { + let old_w = inputs + .clock_old + .map(|(t, _)| text.measure_line(t, inputs.font_family, clock_size)) + .unwrap_or(0.0); + let new_w = text.measure_line(inputs.clock_text, inputs.font_family, clock_size); + let cw = old_w.max(new_w); + r.expand((w - cw) / 2.0, clock_y, (w + cw) / 2.0, clock_y + clock_size); + } match inputs.clock_old { Some((old, t)) => { let t = t.clamp(0.0, 1.0); @@ -332,6 +406,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height + tokens::SPACE_SM as f32; let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); + if let Some(r) = rects.as_deref_mut() { + r.expand((w - date_w) / 2.0, date_y, (w + date_w) / 2.0, date_y + date_size); + } text.draw_line( &mut pixmap, inputs.date_text, @@ -379,6 +456,17 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option cx * (1.0 - scale) + shake_x, cy * (1.0 - scale), ); + // Chrome rect: pad for the shadow layers, breath/success rings, the + // shake offset and the scale overshoot. + if let Some(r) = rects.as_deref_mut() { + const PILL_PAD: f32 = 26.0; + r.expand( + pill_x - PILL_PAD, + pill_y - PILL_PAD, + pill_x + pill_w + PILL_PAD, + pill_y + pill_h + PILL_PAD, + ); + } if let Some(path) = rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) @@ -566,6 +654,11 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option let status_anim = ease_out_cubic(inputs.status_t); let status_alpha = status_e * fade * status_anim; let color = if inputs.failed { red_color } else { on_surface }; + let status_y = pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) + + STATUS_SLIDE_PX * (1.0 - status_anim); + if let Some(r) = rects.as_deref_mut() { + r.expand((w - status_w) / 2.0, status_y, (w + status_w) / 2.0, status_y + status_size); + } text.draw_line( &mut pixmap, status, @@ -573,12 +666,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option status_size, faded(color, status_alpha), (w - status_w) / 2.0, - pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) - + STATUS_SLIDE_PX * (1.0 - status_anim), + status_y, ); } - - Some(pixmap) } /// Recomputes the left edge of the dot row (shared by the dot loop and the @@ -605,6 +695,33 @@ pub fn blit_to_shm(pixmap: &Pixmap, shm_bytes: &mut [u8]) { mod tests { use super::*; + #[test] + fn dim_rows_darkens_top_more_than_bottom() { + // 2 wide × 4 tall: top row is y/h = 0, bottom row is y/h = 0.75. + let mut p = Pixmap::new(2, 4).unwrap(); + p.fill(Color::WHITE); + dim_rows(&mut p, 1.0); + let px = p.pixels(); + let top = px[0]; + let bottom = px[2 * 3]; + // DIM_ALPHA_TOP (0.34) > DIM_ALPHA_BOTTOM (0.16): top row darker. + assert!(top.red() < bottom.red(), "top {} should be darker than bottom {}", top.red(), bottom.red()); + // White at top dim 0.34 → 255 * (1 - 0.34) = 168. + assert_eq!(top.red(), 168); + // Bottom row is y/h = 0.75 → dim = 0.34 + (0.16 - 0.34) * 0.75 = 0.205. + let expected = (255.0 * (1.0 - 0.205)) as u8; + assert_eq!(bottom.red(), expected); + } + + #[test] + fn dim_rows_noop_at_zero_alpha() { + let mut p = Pixmap::new(2, 2).unwrap(); + p.fill(Color::from_rgba8(100, 150, 200, 255)); + let before = p.pixels().to_vec(); + dim_rows(&mut p, 0.0); + assert_eq!(p.pixels(), before.as_slice()); + } + fn inputs<'a>( bg: &'a Background, palette: &'a breadlock_ui::theme::Palette, @@ -637,6 +754,7 @@ mod tests { status_text: None, appear_t, unlock_t, + smooth_pan: false, } } @@ -674,6 +792,7 @@ mod tests { status_text: None, appear_t: 1.0, unlock_t: 0.0, + smooth_pan: false, }; let pixmap = compose(&mut text, &inputs).unwrap(); assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); @@ -764,4 +883,129 @@ mod tests { assert_eq!(a, 0.0); assert!(y < 0.0, "unlock should drift up from rest, got y={y}"); } + + #[test] + fn compose_chrome_rect_contains_clock_and_pill() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + let rect = compose_chrome(&mut pixmap, &mut text, &inputs); + assert!( + rect.x1 > rect.x0 && rect.y1 > rect.y0, + "chrome rect must be non-empty, got {rect:?}" + ); + // Clock sits at h*0.28 with glyph height ~ clock_size (400*0.075=30). + assert!(rect.y0 < 300.0 * 0.28 + 40.0, "rect must cover the clock band"); + // Pill sits at h*0.5; with the 26px pad the rect must reach it. + assert!(rect.y1 > 300.0 * 0.5 + 24.0, "rect must cover the pill band"); + // Both are horizontally centered. + assert!(rect.x0 < 200.0 && rect.x1 > 200.0, "rect must straddle center"); + } + + #[test] + fn compose_chrome_rect_empty_when_veil_hidden() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + // appear_t = 0 → veil_alpha 0 → nothing drawn, rect stays default. + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 0.0, 0.0); + let rect = compose_chrome(&mut pixmap, &mut text, &inputs); + assert!(rect.x1 <= rect.x0 && rect.y1 <= rect.y0, "hidden chrome must yield an empty rect"); + // And the pixmap is fully transparent. + assert!( + pixmap.pixels().iter().all(|p| p.alpha() == 0), + "hidden chrome must leave the pixmap transparent" + ); + } + + #[test] + fn compose_chrome_status_text_expands_the_rect_downward() { + let bg = Background::Color(Color::BLACK); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let mut pixmap = Pixmap::new(400, 300).unwrap(); + let mut with_status = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, true, 0.3, 1.0, 1.0, 0.0); + with_status.status_text = Some("Wrong password"); + let rect = compose_chrome(&mut pixmap, &mut text, &with_status); + // Status sits below the pill: pill bottom is h*0.5 + 24 (half of 48px), + // status adds SPACE_MD + its glyph box after that. + assert!( + rect.y1 > 300.0 * 0.5 + 48.0 + 20.0, + "status must push the rect below the pill, got y1={}", + rect.y1 + ); + } + + #[test] + fn gpu_split_is_pixel_identical_to_full_compose() { + // The GPU path draws the dimmed background in a shader and then + // composites the software chrome (colors pre-faded by the veil alpha) + // over it with premultiplied source-over. That split must produce the + // exact same pixels as the single-pass software compose — this is the + // invariant that keeps the two renderers in sync. + let bg = Background::Color(Color::from_rgba8(40, 60, 80, 255)); + let palette = breadlock_ui::theme::Palette::default(); + let mut text = TextRenderer::new(); + let inputs = inputs(&bg, &palette, "12:34", "Friday · Aug 21", 4, false, 0.0, 1.0, 1.0, 0.0); + + // Full single-pass compose. + let full = compose(&mut text, &inputs).unwrap(); + + // Split: dim the background, then composite the chrome over it. + let mut split = Pixmap::new(400, 300).unwrap(); + inputs.background.paint(&mut split, inputs.t_secs, inputs.smooth_pan); + let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); + if veil_alpha > 0.0 { + dim_rows(&mut split, veil_alpha); + } + let mut chrome = Pixmap::new(400, 300).unwrap(); + let mut text2 = TextRenderer::new(); + compose_chrome(&mut chrome, &mut text2, &inputs); + // Premultiplied source-over, exactly what the GPU's + // glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA) performs. + split.draw_pixmap( + 0, + 0, + chrome.as_ref(), + &tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }, + Transform::default(), + None, + ); + + // The split path rounds twice (chrome into an 8-bit pixmap, then the + // composite into 8-bit) where the single pass rounds once, so + // bit-exact equality is impossible — the invariant is that the split + // stays within a couple of ULPs (measured: max 3 on this input, with + // >95% of pixels bit-identical), and never diverges structurally. + let diff = split + .pixels() + .iter() + .zip(full.pixels()) + .map(|(a, b)| { + (a.red() as i32 - b.red() as i32).abs() + .max((a.green() as i32 - b.green() as i32).abs()) + .max((a.blue() as i32 - b.blue() as i32).abs()) + .max((a.alpha() as i32 - b.alpha() as i32).abs()) + }) + .collect::>(); + let identical = diff.iter().filter(|d| **d == 0).count(); + let max_diff = diff.iter().copied().max().unwrap_or(0); + assert!( + max_diff <= 3, + "GPU-style split must stay within double-rounding ULP range, got max diff {max_diff}" + ); + assert!( + identical > split.pixels().len() * 95 / 100, + "most pixels should be bit-identical, got {identical}/{} identical", + split.pixels().len() + ); + } } + + diff --git a/breadlock/src/state.rs b/breadlock/src/state.rs index 858bc7e..4a98690 100644 --- a/breadlock/src/state.rs +++ b/breadlock/src/state.rs @@ -26,6 +26,10 @@ pub struct LockSurface { pub output: wl_output::WlOutput, pub width: u32, pub height: u32, + /// EGL-backed renderer for this surface (created on first `configure`); + /// `None` when the GPU path is unavailable, in which case the software + /// wl_shm path is used. + pub gpu: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -62,6 +66,9 @@ pub struct AppState { pub config: Config, pub palette: breadlock_ui::theme::Palette, pub background: Background, + /// GPU background renderer (EGL/GLES2). `None` falls back to the + /// fully-software path. + pub gpu: Option, pub text_renderer: breadlock_ui::painter::TextRenderer, pub username: String, @@ -227,8 +234,31 @@ impl AppState { status_text: status_text.as_deref(), appear_t, unlock_t, + smooth_pan: !self.fast_anim_in_progress(), }; + // GPU path: the EGL surface renders the wallpaper (pan/veil in the + // shader) and the software-composed chrome on top. Disjoint-field + // borrows of `self` make `gpu` + `surfaces` + `text_renderer` + // simultaneously mutable. + if self.gpu.is_some() + && self + .surfaces + .iter() + .any(|s| s.surface.wl_surface() == surface.wl_surface() && s.gpu.is_some()) + { + let renderer = self.gpu.as_mut().expect("checked above"); + let lock_surface = self + .surfaces + .iter_mut() + .find(|s| s.surface.wl_surface() == surface.wl_surface()) + .expect("surface exists"); + let gpu_surface = lock_surface.gpu.as_mut().expect("checked above"); + renderer.render_frame(gpu_surface, &inputs, &mut self.text_renderer); + self.arm_anim_if_needed(qh); + return; + } + let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else { return; }; @@ -384,6 +414,19 @@ impl AppState { } } + /// A 60 fps animation is in flight (everything except the slow idle + /// effects: idle breath, Ken Burns pan). Drives both the timer cadence + /// and whether background frames get sub-pixel panning. + fn fast_anim_in_progress(&self) -> bool { + self.appear_in_progress() + || self.unlock_in_progress() + || self.failed_shake_in_progress() + || self.dot_pop_in_progress() + || self.clock_fade_in_progress() + || self.status_slide_in_progress() + || self.auth_state == AuthState::Checking + } + fn tick_animation(&mut self, qh: &QueueHandle) -> TimeoutAction { self.redraw_all(qh); if self.unlocking.is_some() && !self.unlock_in_progress() { @@ -392,13 +435,7 @@ impl AppState { } else if self.anim_in_progress() { // Slow effects (idle breath, Ken Burns) don't need 60fps — halve // the redraw cost for them. Everything else stays at ~60Hz. - let fast = self.appear_in_progress() - || self.unlock_in_progress() - || self.failed_shake_in_progress() - || self.dot_pop_in_progress() - || self.clock_fade_in_progress() - || self.status_slide_in_progress() - || self.auth_state == AuthState::Checking; + let fast = self.fast_anim_in_progress(); TimeoutAction::ToDuration(Duration::from_millis(if fast { render::ANIM_FRAME_MS } else {