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 <noreply@anthropic.com>
This commit is contained in:
dev-null-undefined 2026-10-13 00:39:32 +02:00
parent 701f78ea2e
commit c8f676252f
No known key found for this signature in database
GPG Key ID: B1C4FAB94F0F1443
5 changed files with 95 additions and 8 deletions

View File

@ -56,6 +56,8 @@ pub struct JobPool<S, R> {
shared: Arc<Shared<S, R>>,
tx: Sender<Done<S, R>>,
rx: Receiver<Done<S, R>>,
/// A finished job [`Self::wait`] took off the channel, handed out first by [`Self::try_recv`].
early: Option<Done<S, R>>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
threads: usize,
started: bool,
@ -82,6 +84,7 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
}),
tx,
rx,
early: None,
threads,
started: false,
seq: 0,
@ -210,8 +213,16 @@ impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
}
/// A finished job, if any.
pub fn try_recv(&self) -> Option<Done<S, R>> {
self.rx.try_recv().ok()
pub fn try_recv(&mut self) -> Option<Done<S, R>> {
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.

View File

@ -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<u8, u8> = 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());
}

View File

@ -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)

View File

@ -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)>,

View File

@ -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