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:
parent
f0b66cd791
commit
3d2e35e003
11 changed files with 1774 additions and 89 deletions
62
Cargo.lock
generated
62
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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,
|
||||
);
|
||||
if let Some(blended) = blended {
|
||||
) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
@ -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 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, 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 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
let out_dir = std::env::args()
|
||||
.nth(1)
|
||||
let args: Vec<String> = 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);
|
||||
|
|
|
|||
796
breadlock/src/gpu.rs
Normal file
796
breadlock/src/gpu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ impl OutputHandler for AppState {
|
|||
output,
|
||||
width: 0,
|
||||
height: 0,
|
||||
gpu: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<AppState>(&conn).expect("failed to initialize Wayland registry");
|
||||
let qh: QueueHandle<AppState> = 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);
|
||||
|
|
|
|||
|
|
@ -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<Pixmap> {
|
||||
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<Pixmap>
|
|||
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<Pixmap>
|
|||
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<Pixmap>
|
|||
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<Pixmap>
|
|||
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<Pixmap>
|
|||
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<Pixmap>
|
|||
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<Pixmap>
|
|||
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::<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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<crate::gpu::GpuSurface>,
|
||||
}
|
||||
|
||||
#[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<crate::gpu::GpuRenderer>,
|
||||
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<Self>) -> 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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue