From b3249bfb57d6739c3216783209012dbdcb71a58b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:21:26 -0400 Subject: [PATCH] =?UTF-8?q?Perf:=20cache=20the=20photo=20grid's=20layout?= =?UTF-8?q?=20=E2=80=94=2043=20=E2=86=92=205=20ms=20per=20frame=20at=20100?= =?UTF-8?q?k=20photos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The justified layout (and the aspect ratio of every photo it needs) was rebuilt every frame. It is now kept in LightcraftApp::caches, keyed by catalog revision, the photos in view, the width, the thumbnail size, the mode and the date groups. Headless measurement (ignored test grid_frame_100k, machine under load): 43.1 → 5.3 ms on the UI thread. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 4 ++- crates/ui-egui/src/panels/grid.rs | 39 +++++++++++++++++----------- crates/ui-egui/src/tests_masking.rs | 40 +++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5f421e84..bdc02363 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -634,6 +634,8 @@ pub struct Caches { counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, filter_values: Option<(u64, std::sync::Arc)>, + /// The grid's layout (by photos, shapes, width, thumbnail size, grouping). + pub grid_layout: Option<(u64, std::sync::Arc)>, } /// The left panel's counts. @@ -652,7 +654,7 @@ pub struct FilterValues { pub keywords: Vec, } -fn key_of(parts: impl std::hash::Hash) -> u64 { +pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; BuildHasherDefault::::default().hash_one(parts) } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 384c980b..21d6b116 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -59,20 +59,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let square = app.ui.view == ViewMode::SquareGrid; let target = app.ui.thumb_size; let avail_w = ui.available_width() - 8.0; - let aspects: Vec = if square { - vec![1.0; ids.len()] - } else { - ids.iter() - .map(|id| { - let p = app.session.catalog.photo(*id); - let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); - let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes()); - let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT); - let (w, h) = if swap { (h, w) } else { (w, h) }; - (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) - }) - .collect() - }; let by = resolve_group(app.session.sort.group, target); let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted { Vec::new() @@ -85,7 +71,30 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { (*app.caches.date_runs(&app.session.catalog, &ids, key, by)).clone() }; let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect(); - let lay = layout(&aspects, &spans, avail_w, target, square); + // the layout only changes with the photos, their shapes, the width and the thumbnail size + let lay_key = crate::key_of((app.session.catalog.revision, &ids, avail_w.to_bits(), target.to_bits(), square, &spans)); + let lay = match &app.caches.grid_layout { + Some((k, l)) if *k == lay_key => l.clone(), + _ => { + let aspects: Vec = if square { + vec![1.0; ids.len()] + } else { + ids.iter() + .map(|id| { + let p = app.session.catalog.photo(*id); + let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); + let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes()); + let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT); + let (w, h) = if swap { (h, w) } else { (w, h) }; + (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) + }) + .collect() + }; + let l = std::sync::Arc::new(layout(&aspects, &spans, avail_w, target, square)); + app.caches.grid_layout = Some((lay_key, l.clone())); + l + } + }; struct Cell { id: PhotoId, rect: Rect, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 94434405..5e25da0c 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -462,3 +462,43 @@ fn second_window_shows_the_active_photo() { assert!(!h.app.ui.second_window); h.settle(SETTLE); } + +/// Frame time of the photo grid on a 100k-photo library (ignored: +/// `cargo test --release -p lightcraft-ui-egui -- --ignored grid_frame_100k --nocapture`). +#[test] +#[ignore] +fn grid_frame_100k() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut session = lightcraft_engine::Session::new(); + let ops = (0..100_000u64) + .map(|i| { + let mut p = Photo::new( + PhotoId(i + 1), + Source::Demo { scene: (i % 20) as u32 }, + &format!("IMG_{i:06}.jpg"), + "JPEG", + 6000, + 4000 - (i % 3) as u32 * 1000, + "2026-01-01T00:00:00", + ); + p.captured = Some(format!("20{:02}-{:02}-{:02}T10:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28)); + p.meta.keywords = vec![format!("kw{}", i % 300)]; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + session.commit("Add", Op::Batch { ops }).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1600.0, 1000.0], 1.0); + h.app.ui.view = crate::state::ViewMode::PhotoGrid; + h.app.ui.left_panel = true; + for _ in 0..5 { + h.step(); + } + let t = std::time::Instant::now(); + let n = 30; + for _ in 0..n { + h.step(); + } + let ms = t.elapsed().as_secs_f64() * 1e3 / n as f64; + eprintln!("grid frame at 100k photos: {ms:.1} ms (UI thread, renders excluded)"); +}