perf: replace CPU blur with GPU blur via GskBlurNode (v0.5.0)

Replace image crate + disk cache blur with GPU-side GskBlurNode,
symmetric with moonlock and moongreet. Removes ~15 transitive
dependencies and ~160 lines of caching code. Blur now happens once
on the GPU at widget realization — zero startup latency, no cache
management needed.
This commit is contained in:
2026-03-28 22:35:18 +01:00
parent 4d8e306b74
commit 14affb1533
5 changed files with 50 additions and 349 deletions
+4 -3
View File
@@ -54,12 +54,13 @@ fn activate(app: &gtk::Application) {
load_css(&display);
// Resolve wallpaper once, decode texture once, share across all windows
// Blur is applied on the GPU via GskBlurNode at widget realization time.
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
let texture = panel::load_background_texture(&bg_path, config.background_blur);
let texture = panel::load_background_texture(&bg_path);
// Panel on focused output (no set_monitor → compositor picks focused)
let panel = panel::create_panel_window(&texture, app);
let panel = panel::create_panel_window(&texture, config.background_blur, app);
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
panel.present();
@@ -67,7 +68,7 @@ fn activate(app: &gtk::Application) {
let monitors = display.monitors();
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) {
let wallpaper = panel::create_wallpaper_window(&texture, app);
let wallpaper = panel::create_wallpaper_window(&texture, config.background_blur, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();
+37 -207
View File
@@ -6,14 +6,10 @@ use gdk_pixbuf::Pixbuf;
use glib::clone;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use image::imageops;
use std::cell::RefCell;
use std::fs;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::rc::Rc;
use std::time::{Duration, SystemTime};
use std::time::Duration;
use crate::i18n::{load_strings, Strings};
use crate::power::{self, PowerError};
@@ -91,11 +87,11 @@ pub fn action_definitions() -> Vec<ActionDef> {
}
/// Load the wallpaper as a texture once, for sharing across all windows.
/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied.
pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk::Texture {
/// Blur is applied on the GPU via GskBlurNode at widget realization time.
pub fn load_background_texture(bg_path: &Path) -> gdk::Texture {
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX);
let texture = if bg_path.starts_with(crate::GRESOURCE_PREFIX) {
if bg_path.starts_with(crate::GRESOURCE_PREFIX) {
let resource_path = bg_path.to_str().unwrap_or(&fallback);
gdk::Texture::from_resource(resource_path)
} else {
@@ -103,148 +99,28 @@ pub fn load_background_texture(bg_path: &Path, blur_radius: Option<f32>) -> gdk:
gdk::Texture::from_file(&file).unwrap_or_else(|_| {
gdk::Texture::from_resource(&fallback)
})
};
match blur_radius {
Some(sigma) if sigma > 0.0 => load_blurred_with_cache(bg_path, &texture, sigma),
_ => texture,
}
}
// -- Blur cache ----------------------------------------------------------------
// -- GPU blur via GskBlurNode -------------------------------------------------
const CACHE_PNG: &str = "blur-cache.png";
const CACHE_META: &str = "blur-cache.meta";
fn blur_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("moonset"))
}
/// Build the cache key string for the current wallpaper + sigma.
fn build_cache_meta(bg_path: &Path, sigma: f32) -> Option<String> {
if bg_path.starts_with("/dev/moonarch/") {
let binary = std::env::current_exe().ok()?;
let binary_mtime = fs::metadata(&binary)
.ok()?
.modified()
.ok()?
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?
.as_secs();
Some(format!(
"path={}\nbinary_mtime={}\nsigma={}\n",
bg_path.display(), binary_mtime, sigma,
))
} else {
let meta = fs::metadata(bg_path).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?
.as_secs();
Some(format!(
"path={}\nsize={}\nmtime={}\nsigma={}\n",
bg_path.display(), meta.len(), mtime, sigma,
))
}
}
/// Try to load a cached blurred texture if the cache key matches.
fn load_cached_blur(cache_dir: &Path, expected_meta: &str) -> Option<gdk::Texture> {
let stored_meta = fs::read_to_string(cache_dir.join(CACHE_META)).ok()?;
if stored_meta != expected_meta {
log::debug!("Blur cache meta mismatch, will re-blur");
return None;
}
let file = gio::File::for_path(cache_dir.join(CACHE_PNG));
match gdk::Texture::from_file(&file) {
Ok(texture) => {
log::debug!("Loaded blurred wallpaper from cache");
Some(texture)
}
Err(e) => {
log::debug!("Failed to load cached blur PNG: {e}");
None
}
}
}
/// Save a blurred texture to the cache directory.
fn save_blur_cache(cache_dir: &Path, texture: &gdk::Texture, meta: &str) {
if let Err(e) = save_blur_cache_inner(cache_dir, texture, meta) {
log::debug!("Failed to save blur cache: {e}");
}
}
fn save_blur_cache_inner(
cache_dir: &Path,
/// Render a blurred texture using the GPU via GskBlurNode.
fn render_blurred_texture(
widget: &impl IsA<gtk::Widget>,
texture: &gdk::Texture,
meta: &str,
) -> Result<(), Box<dyn std::error::Error>> {
fs::create_dir_all(cache_dir)?;
let png_bytes = texture.save_to_png_bytes();
let mut f = fs::OpenOptions::new()
.create(true).write(true).truncate(true).mode(0o600)
.open(cache_dir.join(CACHE_PNG))?;
f.write_all(&png_bytes)?;
// Meta last — incomplete cache is treated as a miss on next start
let mut f = fs::OpenOptions::new()
.create(true).write(true).truncate(true).mode(0o600)
.open(cache_dir.join(CACHE_META))?;
f.write_all(meta.as_bytes())?;
log::debug!("Saved blur cache to {}", cache_dir.display());
Ok(())
}
/// Load blurred texture, using disk cache when available.
fn load_blurred_with_cache(bg_path: &Path, texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
if let Some(cache_dir) = blur_cache_dir() {
if let Some(meta) = build_cache_meta(bg_path, sigma) {
if let Some(cached) = load_cached_blur(&cache_dir, &meta) {
return cached;
}
let blurred = apply_blur(texture, sigma);
save_blur_cache(&cache_dir, &blurred, &meta);
return blurred;
}
}
apply_blur(texture, sigma)
}
// -- Blur implementation -------------------------------------------------------
/// Apply Gaussian blur to a texture and return a blurred texture.
fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
let width = texture.width() as u32;
let height = texture.height() as u32;
let stride = width as usize * 4;
let mut pixel_data = vec![0u8; stride * height as usize];
// download() yields GDK_MEMORY_DEFAULT = B8G8R8A8_PREMULTIPLIED (BGRA byte order).
texture.download(&mut pixel_data, stride);
// Swap B↔R so image::RgbaImage channel semantics are correct.
for pixel in pixel_data.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
let img = image::RgbaImage::from_raw(width, height, pixel_data)
.expect("pixel buffer size matches texture dimensions");
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
let bytes = glib::Bytes::from(blurred.as_raw());
let mem_texture = gdk::MemoryTexture::new(
width as i32,
height as i32,
gdk::MemoryFormat::R8g8b8a8Premultiplied,
&bytes,
stride,
sigma: f32,
) -> Option<gdk::Texture> {
let native = widget.native()?;
let renderer = native.renderer()?;
let snapshot = gtk::Snapshot::new();
let bounds = graphene_rs::Rect::new(
0.0, 0.0, texture.width() as f32, texture.height() as f32,
);
mem_texture.upcast()
snapshot.push_blur(sigma as f64);
snapshot.append_texture(texture, &bounds);
snapshot.pop();
let node = snapshot.to_node()?;
Some(renderer.render_texture(&node, None))
}
/// Fade out all windows and quit the app after the CSS transition completes.
@@ -259,13 +135,13 @@ fn fade_out_and_quit(app: &gtk::Application) {
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_wallpaper_window(texture: &gdk::Texture, blur_radius: Option<f32>, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(texture);
let background = create_background_picture(texture, blur_radius);
window.set_child(Some(&background));
// Fade-in on map
@@ -283,7 +159,7 @@ pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -
}
/// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
pub fn create_panel_window(texture: &gdk::Texture, blur_radius: Option<f32>, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
@@ -305,7 +181,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
window.set_child(Some(&overlay));
// Background wallpaper
let background = create_background_picture(texture);
let background = create_background_picture(texture, blur_radius);
overlay.set_child(Some(&background));
// Click on background dismisses the menu
@@ -409,12 +285,22 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
window
}
/// Create a Picture widget for the wallpaper background from a shared texture.
fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture {
/// Create a Picture widget for the wallpaper background, optionally with GPU blur.
fn create_background_picture(texture: &gdk::Texture, blur_radius: Option<f32>) -> gtk::Picture {
let background = gtk::Picture::for_paintable(texture);
background.set_content_fit(gtk::ContentFit::Cover);
background.set_hexpand(true);
background.set_vexpand(true);
if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) {
let texture = texture.clone();
background.connect_realize(move |picture| {
if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) {
picture.set_paintable(Some(&blurred));
}
});
}
background
}
@@ -777,60 +663,4 @@ mod tests {
}
}
// -- Blur cache tests --
#[test]
fn build_cache_meta_for_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("wallpaper.jpg");
fs::write(&file, b"fake image").unwrap();
let meta = build_cache_meta(&file, 20.0);
assert!(meta.is_some());
let meta = meta.unwrap();
assert!(meta.contains("path="));
assert!(meta.contains("size=10"));
assert!(meta.contains("sigma=20"));
assert!(meta.contains("mtime="));
}
#[test]
fn build_cache_meta_for_gresource() {
let path = Path::new("/dev/moonarch/moonset/wallpaper.jpg");
let meta = build_cache_meta(path, 15.0);
assert!(meta.is_some());
let meta = meta.unwrap();
assert!(meta.contains("binary_mtime="));
assert!(meta.contains("sigma=15"));
assert!(!meta.contains("size="));
}
#[test]
fn build_cache_meta_missing_file() {
let meta = build_cache_meta(Path::new("/nonexistent/wallpaper.jpg"), 20.0);
assert!(meta.is_none());
}
#[test]
fn cache_meta_mismatch_returns_none() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join(CACHE_META),
"path=/old.jpg\nsize=100\nmtime=1\nsigma=20\n",
).unwrap();
let result = load_cached_blur(
dir.path(),
"path=/new.jpg\nsize=200\nmtime=2\nsigma=20\n",
);
assert!(result.is_none());
}
#[test]
fn cache_missing_meta_returns_none() {
let dir = tempfile::tempdir().unwrap();
let result = load_cached_blur(
dir.path(),
"path=/any.jpg\nsize=1\nmtime=1\nsigma=20\n",
);
assert!(result.is_none());
}
}