GPU background rendering (EGL/GLES2) with software chrome, plus expanded tests

The Ken Burns wallpaper pan and dim veil move to the GPU: the lock
surface is wrapped in a wl_egl_window, the wallpaper is a textured quad
whose shader applies the pan transform and veil (GPU bilinear makes
sub-pixel panning free — the ~19ms/frame software bilinear is gone,
idle CPU drops from 40% to ~0%), and the clock/pill/status chrome is
still composed with tiny-skia and composited over. EGL init failure
falls back to the fully-software path unchanged.

Notable fixes along the way:
- Text blending is now destination-aware: the old blend forced output
  alpha to 255, which is correct over the opaque background but made
  every glyph opaque in the chrome-only pixmap the GPU path composites.
- wayland-client switches to its C backend for raw surface pointers,
  and khronos-egl/glow replace a hand-rolled EGL loader.

Tests grew from 72 to 88: GPU pan_region parity with the software path
(and edge/bounds/corner invariants), shader-uniform/EGL-attrib sanity,
chrome bounding-rect tracking, and a pixel-level parity test proving
the GPU split stays within double-rounding ULP range of the single-pass
compose.
This commit is contained in:
Breadway 2026-08-21 15:02:09 +08:00
parent f0b66cd791
commit 3d2e35e003
11 changed files with 1774 additions and 89 deletions

62
Cargo.lock generated
View file

@ -122,6 +122,8 @@ dependencies = [
"bread-utils", "bread-utils",
"breadlock-ui", "breadlock-ui",
"chrono", "chrono",
"glow",
"khronos-egl",
"pam-client2", "pam-client2",
"serde", "serde",
"serde_json", "serde_json",
@ -346,6 +348,15 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
@ -707,6 +718,18 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.22.6" version = "0.22.6"
@ -904,6 +927,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -916,6 +949,16 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" 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]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@ -1297,6 +1340,12 @@ dependencies = [
"unicode-script", "unicode-script",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -1969,6 +2018,7 @@ dependencies = [
"cc", "cc",
"downcast-rs", "downcast-rs",
"rustix", "rustix",
"scoped-tls",
"smallvec", "smallvec",
"wayland-sys", "wayland-sys",
] ]
@ -2075,9 +2125,21 @@ version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [ dependencies = [
"dlib",
"log",
"pkg-config", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"

View file

@ -162,32 +162,60 @@ impl TextRenderer {
if a == 0 { if a == 0 {
return; 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 /// Alpha-blends a straight-alpha `(r, g, b, a)` source pixel over a
/// **opaque** destination pixel (always true here — the lock screen /// destination of *any* alpha. Two paths use this:
/// background is painted fully opaque before any text or UI chrome). ///
/// Because the destination alpha is always 255, the blended result is also /// - **Full compose**: the background is painted fully opaque before any
/// opaque, so the `PremultipliedColorU8` invariant (`rgb <= a`) always holds. /// text, so the destination alpha is always 255 and the result is opaque
fn blend_over_opaque(pixmap: &mut Pixmap, x: u32, y: u32, r: u8, g: u8, b: u8, a: u8) { /// (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 idx = (y * pixmap.width() + x) as usize;
let pixels = pixmap.pixels_mut(); let pixels = pixmap.pixels_mut();
let Some(dst) = pixels.get(idx).copied() else { let Some(dst) = pixels.get(idx).copied() else {
return; return;
}; };
let a32 = a as u32; let sa = a as u32;
let mix = |s: u8, d: u8| -> u8 { ((s as u32 * a32 + d as u32 * (255 - a32)) / 255) as u8 }; if dst.alpha() == 255 {
let blended = PremultipliedColorU8::from_rgba( // Opaque destination: the classic exact blend. RGB mixes toward the
mix(r, dst.red()), // source, alpha stays 255 — identical to the pre-split behavior so
mix(g, dst.green()), // the single-pass software path doesn't move a single pixel.
mix(b, dst.blue()), let mix = |s: u8, d: u8| -> u8 { ((s as u32 * sa + d as u32 * (255 - sa)) / 255) as u8 };
255, if let Some(blended) = PremultipliedColorU8::from_rgba(
); mix(r, dst.red()),
if let Some(blended) = blended { 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; pixels[idx] = blended;
} }
} }
@ -260,4 +288,45 @@ mod tests {
"10%-alpha text must not render near-white, got {low_max}" "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"
);
}
} }

View file

@ -28,8 +28,10 @@ path = "src/bin/breadlock-preview.rs"
breadlock-ui = { path = "../breadlock-ui", features = ["paint"] } breadlock-ui = { path = "../breadlock-ui", features = ["paint"] }
bread-utils = { workspace = true, features = ["bread-client"] } bread-utils = { workspace = true, features = ["bread-client"] }
smithay-client-toolkit = "0.20" smithay-client-toolkit = "0.20"
wayland-client = "0.31" wayland-client = { version = "0.31", features = ["system"] }
tiny-skia = "0.12" tiny-skia = "0.12"
khronos-egl = { version = "6", features = ["dynamic"] }
glow = "0.16"
chrono = "0.4" chrono = "0.4"
pam-client2 = { version = "0.5", default-features = false } pam-client2 = { version = "0.5", default-features = false }
zeroize = { version = "1", features = ["std"] } zeroize = { version = "1", features = ["std"] }

View file

@ -4,8 +4,15 @@
//! warning in v1. `ken_burns = true` adds a slow, continuous pan+zoom to //! 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 //! image backgrounds (opt-in: it keeps the background redrawing at a low
//! frame rate while locked). //! 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 breadlock_ui::config::{Background as BackgroundConfig, BackgroundMode};
use std::cell::RefCell;
use std::f32::consts::TAU; use std::f32::consts::TAU;
use tiny_skia::{Pixmap, PixmapPaint, Transform}; use tiny_skia::{Pixmap, PixmapPaint, Transform};
@ -18,9 +25,183 @@ const KENBURNS_ZOOM: f32 = 1.06;
pub enum Background { pub enum Background {
Color(tiny_skia::Color), Color(tiny_skia::Color),
/// `(source, ken_burns)` — the flag decides whether `paint` pans over Image(ImageBg),
/// time or draws statically. }
Image(Pixmap, bool),
/// 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<Option<ScaledBg>>,
}
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 { impl Background {
@ -43,7 +224,11 @@ impl Background {
return fallback(); return fallback();
} }
match Pixmap::load_png(&cfg.path) { 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) => { Err(err) => {
tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color"); tracing::warn!(path = %cfg.path, %err, "failed to load background image (PNG only in v1), falling back to palette color");
fallback() fallback()
@ -55,7 +240,7 @@ impl Background {
/// True when this background needs continuous redraws (Ken Burns pan). /// True when this background needs continuous redraws (Ken Burns pan).
pub fn ken_burns(&self) -> bool { 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 /// 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 /// is the monotonic clock: with Ken Burns enabled the image slowly pans
/// and zooms along a smooth Lissajous-ish drift, so consecutive frames /// and zooms along a smooth Lissajous-ish drift, so consecutive frames
/// differ slightly but never jump. /// 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 { match self {
Background::Color(c) => target.fill(*c), 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 (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 { if sw <= 0.0 || sh <= 0.0 {
return; return;
} }
let cover = (tw / sw).max(th / sh); let mut cache = bg.cache.borrow_mut();
let (scale, tx, ty) = if *ken_burns { let stale = cache
let scale = cover * KENBURNS_ZOOM; .as_ref()
// Pan range: how far the scaled image overhangs each axis. .map(|c| c.target_w != target.width() || c.target_h != target.height())
let pan_x = (sw * scale - tw).max(0.0); .unwrap_or(true);
let pan_y = (sh * scale - th).max(0.0); 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; let phase = t_secs * TAU / KENBURNS_PERIOD_S;
// Sin/cos offset by a quarter cycle: the pan traces a slow // Sin/cos offset by a quarter cycle: the pan traces a slow
// ellipse, starting from a corner. // ellipse, starting from a corner.
( (
scale, -scaled.pan_x * (0.5 + 0.5 * phase.sin()),
-pan_x * (0.5 + 0.5 * phase.sin()), -scaled.pan_y * (0.5 + 0.5 * phase.cos()),
-pan_y * (0.5 + 0.5 * phase.cos()),
) )
} else { } else {
(cover, 0.0, 0.0) (0.0, 0.0)
}; };
target.fill(tiny_skia::Color::BLACK); // The cached pixmap is already output-sized, so this per-frame
target.draw_pixmap( // draw is a 1:1 copy with at most a translation. `draw_pixmap`
0, // runs the full raster pipeline per pixel (~20 ms for a
0, // full-screen layer), which is the dominant software-render
source.as_ref(), // cost — so do the blit directly instead: rows are memcpy'd
&PixmapPaint::default(), // (nearest sampling on an already-correct-size image is
// scale first (image coords → scaled), then translate into // pixel-identical, and the pan offsets quantize the same way
// the pan position. // tiny-skia's nearest filter does).
Transform::from_translate(tx, ty).pre_concat(Transform::from_scale(scale, scale)), blit_translate(target, &scaled.pixmap, tx, ty, smooth);
None,
);
} }
} }
} }
} }
#[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));
}
}

View file

@ -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<u32> = 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::<f64>() / 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::<f64>() / 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() { fn main() {
let out_dir = std::env::args() let args: Vec<String> = std::env::args().skip(1).collect();
.nth(1) if args.iter().any(|a| a == "--time") {
bench(&args);
return;
}
let out_dir = args
.first()
.cloned()
.unwrap_or_else(|| "preview".to_string()); .unwrap_or_else(|| "preview".to_string());
std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir"); std::fs::create_dir_all(&out_dir).expect("failed to create preview output dir");
@ -137,6 +248,7 @@ fn main() {
status_text: scene.status, status_text: scene.status,
appear_t: scene.appear_t, appear_t: scene.appear_t,
unlock_t: scene.unlock_t, unlock_t: scene.unlock_t,
smooth_pan: false,
}; };
let Some(pixmap) = compose(&mut text, &inputs) else { let Some(pixmap) = compose(&mut text, &inputs) else {
eprintln!("compose returned None for scene {}", scene.name); eprintln!("compose returned None for scene {}", scene.name);

796
breadlock/src/gpu.rs Normal file
View file

@ -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<egl::EGL1_4>,
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<Wallpaper>,
/// 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<Pixmap>,
u_screen: [Option<glow::UniformLocation>; 2],
u_uv_scale: [Option<glow::UniformLocation>; 2],
u_uv_offset: [Option<glow::UniformLocation>; 2],
u_tex: [Option<glow::UniformLocation>; 2],
u_color: Option<glow::UniformLocation>,
u_dim_top: Option<glow::UniformLocation>,
u_dim_bottom: Option<glow::UniformLocation>,
u_veil_alpha: Option<glow::UniformLocation>,
u_screen_h: Option<glow::UniformLocation>,
}
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<Self> {
// 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::<egl::EGL1_4>::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<GpuSurface> {
// 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<glow::Program> {
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 <type> <name>;` declaration from a GLSL source.
fn declared_uniforms(src: &str) -> Vec<String> {
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);
}
}

View file

@ -43,6 +43,14 @@ impl SessionLockHandler for AppState {
{ {
s.width = width; s.width = width;
s.height = height; 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); self.redraw_surface(qh, &surface, width, height);
} }

View file

@ -76,6 +76,7 @@ impl OutputHandler for AppState {
output, output,
width: 0, width: 0,
height: 0, height: 0,
gpu: None,
}); });
} }

View file

@ -2,6 +2,7 @@ mod auth;
mod background; mod background;
mod bread_events; mod bread_events;
mod config; mod config;
mod gpu;
mod input; mod input;
mod lock; mod lock;
mod render; mod render;
@ -145,6 +146,15 @@ fn run_lock() {
let background = Background::load(&config.appearance.background, &palette); 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"); 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) = let (globals, event_queue) =
registry_queue_init::<AppState>(&conn).expect("failed to initialize Wayland registry"); registry_queue_init::<AppState>(&conn).expect("failed to initialize Wayland registry");
let qh: QueueHandle<AppState> = event_queue.handle(); let qh: QueueHandle<AppState> = event_queue.handle();
@ -212,6 +222,7 @@ fn run_lock() {
config, config,
palette, palette,
background, background,
gpu,
text_renderer: breadlock_ui::painter::TextRenderer::new(), text_renderer: breadlock_ui::painter::TextRenderer::new(),
username, username,
// Pre-reserve capacity so ordinary typing doesn't reallocate — a // Pre-reserve capacity so ordinary typing doesn't reallocate — a
@ -254,6 +265,7 @@ fn run_lock() {
output, output,
width: 0, width: 0,
height: 0, height: 0,
gpu: None,
}); });
} }
app_state.session_lock = Some(session_lock); app_state.session_lock = Some(session_lock);

View file

@ -16,7 +16,7 @@ use breadlock_ui::painter::{rounded_rect, tokens, TextRenderer};
use breadlock_ui::theme::tiny_skia_color; use breadlock_ui::theme::tiny_skia_color;
use std::f32::consts::PI; use std::f32::consts::PI;
use std::time::Instant; 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 /// Lock-appear duration: elements ease in on a small stagger (see the
/// `*_DELAY_MS` consts) instead of one uniform fade. /// `*_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. /// How far the status line rises during its slide-in.
const STATUS_SLIDE_PX: f32 = 8.0; const STATUS_SLIDE_PX: f32 = 8.0;
/// Dim veil over the wallpaper: a vertical gradient, darker at the top so /// Dim veil over the wallpaper: a vertical gradient, darker at the top so
/// the clock (in the upper third) sits on the deepest tone. /// the clock (in the upper third) sits on the deepest tone. `pub(crate)` for
const DIM_ALPHA_TOP: f32 = 0.34; /// the GPU background shader, which applies the same gradient.
const DIM_ALPHA_BOTTOM: f32 = 0.16; 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)`). /// Pill hairline-border alpha (sketch: `1px solid rgba(255,255,255,.08)`).
const PILL_BORDER_ALPHA: f32 = 0.10; const PILL_BORDER_ALPHA: f32 = 0.10;
/// Fake drop-shadow layers under the pill (tiny-skia has no blur filter): /// 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, pub appear_t: f32,
/// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking. /// Raw 0..1 unlock-fade progress (pre-ease). 0 when not unlocking.
pub unlock_t: f32, 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. /// 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) .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 /// Composes one frame. Returns `None` only if `width`/`height` are degenerate
/// (a `0x0` `configure`, which some compositors send transiently). /// (a `0x0` `configure`, which some compositors send transiently).
pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap> { pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap> {
let mut pixmap = Pixmap::new(inputs.width, inputs.height)?; 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 // Overall chrome fade: appear eased in, unlock eased out. The unlock
// `fade` multiplies every element below. // `fade` multiplies every element below.
@ -221,7 +303,7 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
let fade = 1.0 - unlock; let fade = 1.0 - unlock;
let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t); let (veil_alpha, _) = overlay_motion(inputs.appear_t, inputs.unlock_t);
if veil_alpha <= 0.0 { if veil_alpha <= 0.0 {
return Some(pixmap); return;
} }
let (w, h) = (inputs.width as f32, inputs.height as f32); let (w, h) = (inputs.width as f32, inputs.height as f32);
@ -235,30 +317,13 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha); let red_color = faded(tiny_skia_color(&inputs.palette.color1), veil_alpha);
// Translucent veil over the (static) wallpaper — a vertical gradient // Translucent veil over the (static) wallpaper — a vertical gradient
// (deeper at the top) that fades in with the chrome. // (deeper at the top) that fades with the whole chrome. Applied in place
if let Some(shader) = tiny_skia::LinearGradient::new( // as a per-pixel multiply (premultiplied pixels scale by `1 - a` for a
tiny_skia::Point::from_xy(0.0, 0.0), // black overlay), which is far cheaper than a full-surface gradient
tiny_skia::Point::from_xy(0.0, h), // fill/blit every frame. Skipped in the chrome-only path (the GPU shader
vec![ // applies the same veil to the background).
tiny_skia::GradientStop::new( if rects.is_none() && veil_alpha > 0.0 {
0.0, dim_rows(pixmap, veil_alpha);
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);
}
} }
// Per-element staggered entrance. // Per-element staggered entrance.
@ -284,6 +349,15 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
let clock_y_rest = h * 0.28; let clock_y_rest = h * 0.28;
let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK); let clock_y = clock_y_rest + elem_y(clock_e, DRIFT_CLOCK);
let clock_alpha = clock_e * fade; 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 { match inputs.clock_old {
Some((old, t)) => { Some((old, t)) => {
let t = t.clamp(0.0, 1.0); let t = t.clamp(0.0, 1.0);
@ -332,6 +406,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height let date_y = clock_y_rest + elem_y(date_e, DRIFT_DATE) + clock_top + clock_height
+ tokens::SPACE_SM as f32; + tokens::SPACE_SM as f32;
let date_w = text.measure_line(inputs.date_text, inputs.font_family, date_size); 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( text.draw_line(
&mut pixmap, &mut pixmap,
inputs.date_text, inputs.date_text,
@ -379,6 +456,17 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
cx * (1.0 - scale) + shake_x, cx * (1.0 - scale) + shake_x,
cy * (1.0 - scale), 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) = if let Some(path) =
rounded_rect(pill_x, pill_y, pill_w, pill_h, tokens::RADIUS_SECONDARY as f32) 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<Pixmap>
let status_anim = ease_out_cubic(inputs.status_t); let status_anim = ease_out_cubic(inputs.status_t);
let status_alpha = status_e * fade * status_anim; let status_alpha = status_e * fade * status_anim;
let color = if inputs.failed { red_color } else { on_surface }; 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( text.draw_line(
&mut pixmap, &mut pixmap,
status, status,
@ -573,12 +666,9 @@ pub fn compose(text: &mut TextRenderer, inputs: &FrameInputs) -> Option<Pixmap>
status_size, status_size,
faded(color, status_alpha), faded(color, status_alpha),
(w - status_w) / 2.0, (w - status_w) / 2.0,
pill_y_rest + pill_h + tokens::SPACE_MD as f32 + elem_y(status_e, DRIFT_STATUS) status_y,
+ STATUS_SLIDE_PX * (1.0 - status_anim),
); );
} }
Some(pixmap)
} }
/// Recomputes the left edge of the dot row (shared by the dot loop and the /// 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 { mod tests {
use super::*; 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>( fn inputs<'a>(
bg: &'a Background, bg: &'a Background,
palette: &'a breadlock_ui::theme::Palette, palette: &'a breadlock_ui::theme::Palette,
@ -637,6 +754,7 @@ mod tests {
status_text: None, status_text: None,
appear_t, appear_t,
unlock_t, unlock_t,
smooth_pan: false,
} }
} }
@ -674,6 +792,7 @@ mod tests {
status_text: None, status_text: None,
appear_t: 1.0, appear_t: 1.0,
unlock_t: 0.0, unlock_t: 0.0,
smooth_pan: false,
}; };
let pixmap = compose(&mut text, &inputs).unwrap(); let pixmap = compose(&mut text, &inputs).unwrap();
assert_eq!((pixmap.width(), pixmap.height()), (400, 300)); assert_eq!((pixmap.width(), pixmap.height()), (400, 300));
@ -764,4 +883,129 @@ mod tests {
assert_eq!(a, 0.0); assert_eq!(a, 0.0);
assert!(y < 0.0, "unlock should drift up from rest, got y={y}"); 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::<Vec<_>>();
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()
);
}
} }

View file

@ -26,6 +26,10 @@ pub struct LockSurface {
pub output: wl_output::WlOutput, pub output: wl_output::WlOutput,
pub width: u32, pub width: u32,
pub height: 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<crate::gpu::GpuSurface>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -62,6 +66,9 @@ pub struct AppState {
pub config: Config, pub config: Config,
pub palette: breadlock_ui::theme::Palette, pub palette: breadlock_ui::theme::Palette,
pub background: Background, pub background: Background,
/// GPU background renderer (EGL/GLES2). `None` falls back to the
/// fully-software path.
pub gpu: Option<crate::gpu::GpuRenderer>,
pub text_renderer: breadlock_ui::painter::TextRenderer, pub text_renderer: breadlock_ui::painter::TextRenderer,
pub username: String, pub username: String,
@ -227,8 +234,31 @@ impl AppState {
status_text: status_text.as_deref(), status_text: status_text.as_deref(),
appear_t, appear_t,
unlock_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 { let Some(pixmap) = render::compose(&mut self.text_renderer, &inputs) else {
return; 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<Self>) -> TimeoutAction { fn tick_animation(&mut self, qh: &QueueHandle<Self>) -> TimeoutAction {
self.redraw_all(qh); self.redraw_all(qh);
if self.unlocking.is_some() && !self.unlock_in_progress() { if self.unlocking.is_some() && !self.unlock_in_progress() {
@ -392,13 +435,7 @@ impl AppState {
} else if self.anim_in_progress() { } else if self.anim_in_progress() {
// Slow effects (idle breath, Ken Burns) don't need 60fps — halve // Slow effects (idle breath, Ken Burns) don't need 60fps — halve
// the redraw cost for them. Everything else stays at ~60Hz. // the redraw cost for them. Everything else stays at ~60Hz.
let fast = self.appear_in_progress() let fast = self.fast_anim_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;
TimeoutAction::ToDuration(Duration::from_millis(if fast { TimeoutAction::ToDuration(Duration::from_millis(if fast {
render::ANIM_FRAME_MS render::ANIM_FRAME_MS
} else { } else {