From c8f676252feef41d09f607a2db2289d2053941fa Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Tue, 13 Oct 2026 00:39:32 +0200 Subject: [PATCH] M5.6: with a GPU, a slider drag's preview is drawn in the frame of the drag tick A drag tick requested the loupe's draft and the frame ended; the draft, done in 1.5-3 ms on the GPU, was picked up at the start of the next frame, a frame after the slider moved. Now, in a drag, the loupe waits up to 40 % of a frame for its draft when the last loupe render took well under that (not with a render offload, nor zoomed in, where what changes on screen is the window's draft). A CPU render, which would only make the frame late, is not waited for. JobPool::wait waits for the next finished job, which try_recv then hands out; Renderer::poll's collection of finished jobs is split out so the wait can turn the draft into the texture. Co-Authored-By: Claude --- crates/preview/src/pool.rs | 15 +++++++- crates/preview/src/tests.rs | 13 +++++++ crates/ui-egui/src/panels/detail.rs | 9 +++++ crates/ui-egui/src/render.rs | 59 ++++++++++++++++++++++++++--- docs/whats-new.md | 7 ++++ 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs index 4dbd3566..afdf2741 100644 --- a/crates/preview/src/pool.rs +++ b/crates/preview/src/pool.rs @@ -56,6 +56,8 @@ pub struct JobPool { shared: Arc>, tx: Sender>, rx: Receiver>, + /// A finished job [`Self::wait`] took off the channel, handed out first by [`Self::try_recv`]. + early: Option>, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] threads: usize, started: bool, @@ -82,6 +84,7 @@ impl JobPool { }), tx, rx, + early: None, threads, started: false, seq: 0, @@ -210,8 +213,16 @@ impl JobPool { } /// A finished job, if any. - pub fn try_recv(&self) -> Option> { - self.rx.try_recv().ok() + pub fn try_recv(&mut self) -> Option> { + self.early.take().or_else(|| self.rx.try_recv().ok()) + } + + /// Wait up to `timeout` for a job to finish; whether one has (it is the next [`Self::try_recv`]). + pub fn wait(&mut self, timeout: Duration) -> bool { + if self.early.is_none() { + self.early = self.rx.recv_timeout(timeout).ok(); + } + self.early.is_some() } /// Run up to `n` jobs on the calling thread (wasm, tests). Returns how many ran. diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index d733ad82..690c60b4 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -351,3 +351,16 @@ fn disk_cache_never_touches_foreign_files() { assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); let _ = std::fs::remove_dir_all(&dir); } + +/// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or +/// after the timeout. +#[test] +fn pool_wait_keeps_the_finished_job_for_try_recv() { + let mut p: JobPool = JobPool::new(1); + assert!(!p.wait(std::time::Duration::from_millis(10)), "nothing runs"); + p.submit(1, 0, 1, Box::new(|| 7)); + assert!(p.wait(std::time::Duration::from_secs(10))); + assert!(p.wait(std::time::Duration::ZERO), "kept until taken"); + assert_eq!(p.try_recv().map(|d| d.result), Some(7)); + assert!(p.try_recv().is_none()); +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 23447616..e93d727d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -473,6 +473,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); + // a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed + // in, what changes on screen is the window's draft) + #[cfg(not(target_arch = "wasm32"))] + if interacting && plan.window_edge.is_none() { + let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0); + // (a frame time that is not a number waits for nothing) + let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default(); + app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget); + } } // hovering a preset or profile: the photo with that look, shown instead of the loupe render // once it is ready (nothing is committed) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01f..246decfe 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -649,6 +649,37 @@ impl Renderer { }; #[cfg(not(target_arch = "wasm32"))] let inline_ms = 0.0; + let changed = self.collect(ctx, session, inline_ms); + self.evict_variants(); + #[cfg(not(target_arch = "wasm32"))] + self.trim_when_idle(ctx); + if !self.pending.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + changed + } + + /// In a slider drag, give the loupe's draft (just requested) until `budget` to finish, so that it + /// is drawn in this frame rather than picked up at the start of the next one. Only when the last + /// loupe render took well under the budget (a GPU draft takes a few milliseconds; a CPU render + /// that would only make the frame late is not waited for). + #[cfg(not(target_arch = "wasm32"))] + pub fn wait_for_draft(&mut self, ctx: &egui::Context, session: &mut Session, budget: std::time::Duration) { + if self.offload.is_some() || self.last_main_ms * 1.5 >= budget.as_secs_f64() * 1e3 { + return; + } + let Some(deadline) = std::time::Instant::now().checked_add(budget) else { return }; + while self.is_pending(Slot::Main) { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() || !self.pool.wait(left) { + break; + } + self.collect(ctx, session, 0.0); + } + } + + /// Turn finished jobs into textures. Returns true if anything changed. + fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool { let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); @@ -757,12 +788,6 @@ impl Renderer { } changed = true; } - self.evict_variants(); - #[cfg(not(target_arch = "wasm32"))] - self.trim_when_idle(ctx); - if !self.pending.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } changed } @@ -953,6 +978,28 @@ mod thumbnail_tests { assert!(!r.thumb_current(&p, 384, 10)); } + #[test] + fn a_fast_draft_is_drawn_in_the_frame_that_asked_for_it() { + let mut s = Session::new(); + s.media.file_loader = Some(Arc::new(|_, _| Ok((lightcraft_raster::Rgb32f::new(8, 8), Default::default())))); + s.catalog.apply(Op::AddPhoto { photo: Box::new((*photo()).clone()) }).unwrap(); + let mut r = Renderer::with_worker_threads(1); + let ctx = egui::Context::default(); + let budget = std::time::Duration::from_secs(10); + // the last loupe render took longer than the budget (a CPU render): not waited for + r.last_main_ms = 1e6; + r.request(Slot::Main, s.render_job(PhotoId(1), 64, 64, false, true).unwrap(), 100); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(r.is_pending(Slot::Main) && !r.textures.contains_key(&Slot::Main)); + // a fast one (GPU drafts): the texture is the new render when the wait returns + r.last_main_ms = 1.0; + r.request(Slot::Main, s.render_job(PhotoId(1), 32, 32, false, true).unwrap(), 100); + let want = r.wanted(Slot::Main); + r.wait_for_draft(&ctx, &mut s, budget); + assert!(!r.is_pending(Slot::Main)); + assert_eq!(r.textures.get(&Slot::Main).map(|t| t.key), want); + } + #[derive(Default)] struct Work { jobs: Vec<(Slot, RenderJob)>, diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a..c9e3fc3b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-13 + +### Slider drags follow the pointer more closely +- With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later, + when the preview renders in a few milliseconds (it waits at most 40 % of a frame for it; slower renders, such as + on the CPU, are not waited for). + ## 2026-10-11 ### JPEG XL export