From bbc5f9889d3571717fd20995a5d283a00595fa01 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Tue, 10 Nov 2026 14:16:51 +0100 Subject: [PATCH] Preview: a cached end of a preview that doesn't loop plays in real time The paced clock counted the cached lead out of twice the prefetch depth even when fewer frames were left before the end, so a fully cached preview that doesn't loop slowed down over its last frames, and one shorter than that depth played slow throughout (and could step down). The lead now counts out of the frames left. --- crates/ui-egui/src/app/prefetch.rs | 45 +++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index dbde3f085..7f6494d3f 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -54,6 +54,15 @@ fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 { prev + (raw - prev) * k } +/// Cached frames (`cached`, sorted) among the next `max` frames `after(1..=max)` plays, and how +/// many frames it plays (`after` is `None` past the end). Counted, not the consecutive run: one +/// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock +/// for a moment when it gets there, rather than slowing the whole preview to a crawl. +fn lead_of(after: impl Fn(i64) -> Option, cached: &[i64], max: i64) -> (i64, i64) { + let ahead: Vec = (1..=max).map_while(after).collect(); + (ahead.iter().filter(|f| cached.binary_search(f).is_ok()).count() as i64, ahead.len() as i64) +} + impl PlaybackPass<'_> { /// The frame `k` played frames after `from` (wrapping as the preview loops), `None` once a /// preview that doesn't loop has reached its end. @@ -430,9 +439,8 @@ impl EffectcraftApp { self.playback.paced_at = Some(now); if video { // The lead counts out of twice the prefetch depth: the prefetch keeps that many - // frames coming, so a full lead means rendering keeps up. - let target = (pass.ahead * 2).max(1); - let lead = self.cached_lead(pass, target); + // frames coming (or the frames left before the end), so a full lead means rendering keeps up. + let (lead, target) = self.cached_lead(pass, (pass.ahead * 2).max(1)); let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps }; let pace = pace_for(prev, lead, target, fps, dt); self.playback.pace = pace; @@ -522,12 +530,11 @@ impl EffectcraftApp { ctx.request_repaint(); } - /// Cached frames among the next `max` in play order. Counted, not the consecutive run: one - /// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock - /// for a moment when it gets there, rather than slowing the whole preview to a crawl. - fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> i64 { - let cached = self.frames.cached_frames(&pass.series); - (1..=max).map_while(|k| pass.frame_after(pass.cur, k)).filter(|f| cached.binary_search(f).is_ok()).count() as i64 + /// Cached frames among the next `max` in play order, and how many of those there are (fewer + /// near the end of a preview that doesn't loop: its cached tail is a full lead). See + /// [`lead_of`]. + fn cached_lead(&self, pass: &PlaybackPass<'_>, max: i64) -> (i64, i64) { + lead_of(|k| pass.frame_after(pass.cur, k), &self.frames.cached_frames(&pass.series), max) } /// Settings ▸ Previews ▸ Lower Resolution When Playback Can't Keep Up: a preview at @@ -654,7 +661,7 @@ impl EffectcraftApp { #[cfg(test)] mod pace_tests { - use super::pace_for; + use super::{lead_of, pace_for}; /// The pace follows the cached lead: real time with a full lead, a third of it with a third, /// never under an eighth, unchanged with nothing ahead, and smoothed over time. @@ -671,6 +678,24 @@ mod pace_tests { assert_eq!(pace_for(30.0, 4, 12, 30.0, 0.0), 30.0, "no time passed: unchanged"); assert!(pace_for(10.0, 12, 12, 30.0, 0.5) > 10.0, "and up again as the lead fills"); } + + /// Near the end of a preview that doesn't loop, the lead counts out of the frames left: a + /// cached tail plays in real time. (It counted out of the full depth, and a cached preview + /// slowed down over its last frames, a short one for its whole length.) + #[test] + fn a_cached_end_is_a_full_lead() { + let cached: Vec = (0..=20).collect(); + let to_20 = |cur: i64| move |k: i64| Some(cur + k).filter(|f| *f <= 20); + assert_eq!(lead_of(to_20(5), &cached, 12), (12, 12)); + assert_eq!(lead_of(to_20(16), &cached, 12), (4, 4)); + assert_eq!(lead_of(to_20(20), &cached, 12), (0, 0)); + let (lead, target) = lead_of(to_20(16), &cached, 12); + assert!((pace_for(30.0, lead, target, 30.0, 10.0) - 30.0).abs() < 1e-6); + // Frames left uncached still count against it. + assert_eq!(lead_of(to_20(16), &[17, 19], 12), (2, 4)); + // Looping: the walk wraps, always `max` frames. + assert_eq!(lead_of(|k| Some((18 + k) % 21), &cached, 12), (12, 12)); + } } #[cfg(test)]