diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index bdfd5b3ea..ee88430b4 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -55,15 +55,40 @@ fn env_disabled() -> bool { *OFF.get_or_init(|| std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no"))) } +#[cfg(not(target_arch = "wasm32"))] +static GPU: std::sync::OnceLock> = std::sync::OnceLock::new(); + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn device() -> Option<&'static ctx::Gpu> { - static GPU: std::sync::OnceLock> = std::sync::OnceLock::new(); if env_disabled() { return None; } GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref() } +/// Create the device and compile the kernels on a background thread now (app start), so the +/// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. +pub fn warm_up() { + #[cfg(not(target_arch = "wasm32"))] + if !env_disabled() && GPU.get().is_none() { + let _ = std::thread::Builder::new().name("lc-gpu-init".into()).spawn(|| { + let _ = device(); + }); + } +} + +/// Has device creation finished (successfully or not)? Never blocks — for status displays. +pub fn ready() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + env_disabled() || GPU.get().is_some() + } + #[cfg(target_arch = "wasm32")] + { + true + } +} + /// Whether a usable GPU adapter exists and GPU rendering is enabled (creates the device on first /// use). pub fn available() -> bool { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5ee69e57e..c92203028 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "status": app.ui.status, }) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7718c169..757301ea0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ pub struct LightcraftApp { impl LightcraftApp { pub fn new(session: Session, services: Services) -> Self { + // GPU device + kernels off the UI thread, before the first photo is opened + lightcraft_engine::gpu::warm_up(); Self { session, ui: UiState::default(), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f3a819ca4..5abcca6a0 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -146,6 +146,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = if interacting { job.draft() } else { job }; app.renderer.request(Slot::Main, job, 100); } + // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and + // kept, view render cached) so stepping to them is instant + if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { + let ids = app.session.visible_cloned(); + if let Some(i) = ids.iter().position(|p| *p == id) { + let next = ids.get(i + 1).copied(); + let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); + for (n, nid) in [next, prev].into_iter().enumerate() { + let Some(nid) = nid else { continue }; + let Some(np) = app.session.catalog.photo(nid).cloned() else { continue }; + let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); + let na = nf.aspect() as f32; + let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); + let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize; + let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; + if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { + app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); + } + } + } + } // until the loupe has this photo: show its cached render / embedded preview / a thumbnail if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) && let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool) @@ -215,6 +236,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. +const PREFETCH_PRIORITY: u32 = 4; + fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str { use lightcraft_engine::media::QuickSource; match q { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 2bb946534..e7f052f5c 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -32,6 +32,9 @@ pub enum Slot { Preview, Before, Compare(u8), + /// Background preparation of a neighbouring photo (no texture: its decoded source and view + /// render are cached by the engine). 0 = next, 1 = previous. + Prefetch(u8), } pub struct Tex { @@ -62,6 +65,8 @@ pub struct Renderer { stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, + /// Prefetch slot → key of the last job submitted (each runs once). + prefetched: HashMap, } impl Default for Renderer { @@ -76,6 +81,7 @@ impl Default for Renderer { keep_pixels: false, stages: HashMap::new(), quick_tried: HashMap::new(), + prefetched: HashMap::new(), } } } @@ -114,6 +120,18 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || job.run())); } + /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one + /// replaces it while queued). Memory stays bounded by the engine's source caches. + pub fn prefetch(&mut self, slot: Slot, job: RenderJob, priority: u32) { + if self.prefetched.get(&slot) == Some(&job.key) { + return; + } + self.prefetched.insert(slot, job.key); + self.pending.insert(slot, (job.key, priority)); + let key = job.key; + self.pool.submit(slot, key, priority, Box::new(move || job.run())); + } + /// Is a request for `slot` queued or running? pub fn is_pending(&self, slot: Slot) -> bool { self.pending.contains_key(&slot) @@ -160,6 +178,9 @@ impl Renderer { let Ok(rendered) = r.rendered else { continue; }; + if matches!(slot, Slot::Prefetch(_)) { + continue; + } if let Slot::ThumbQuick(id) = slot { if self.textures.contains_key(&Slot::Thumb(id)) { continue; // the real thumbnail won the race diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 3dc9299d2..88f19924f 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -74,7 +74,19 @@ so CPU numbers are pessimistic; the GPU numbers are less affected): | export render 6000×4000 | 1037–1414 ms | 291–330 ms | GPU timings include the readback of the 8-bit result and the histogram. Device creation + kernel -compilation: ~0.4 s once per process (lazily, on the first GPU render). +compilation: ~0.4 s once per process — the desktop app starts it on a background thread at launch +(`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create +the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking. + +## Opening a photo (M5.4) +- The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its + current settings, else (raws with their import look) the embedded camera JPEG, else a + thumbnail-level render; the full render replaces it in place. +- Raw sources for previews (2560 px) and thumbnails are binned straight from the mosaic + (`RawImage::develop_binned`, 2× for a 24 MP preview); only exports / 1:1 demosaic at full size. +- The next and previous photos in filmstrip order are prepared in the background once the current + one is rendered (decoded source kept, view render cached): stepping through photos shows the + developed image in ~50 ms. ## Open items - Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of