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",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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.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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ impl OutputHandler for AppState {
|
||||||
output,
|
output,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
|
gpu: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue