From fc644b9a9e1779a53fae27dd2e21e9f507593113 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:53:42 +0430 Subject: [PATCH] Fix #3198: the status bar's fps is the rate the window draws at, not an average of idle bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readout was a moving average of 1/dt over any two consecutive frames. An idle window wakes now and then (a timer, a control request, a repaint after input) and each wake-up draws two frames about 4 ms apart, the second one not waiting for vsync because the swapchain has a free image. Every wake-up pushed the average up by 0.1 x 240 and the idle gap pulled it down by only 0.1 x 2, so at rest it climbed past the display's refresh rate (105 fps on a 30 Hz monitor that was drawing 4 frames a second). As soon as the pointer moved and frames became vsync-paced, it fell back to the real rate, which looked like a slowdown. - frame_rate: frames counted over the last second of continuous drawing. A gap more than 100 ms longer than the previous frame's own work means the window was idle and starts a new count. The readout changes only after half a second of drawing, so a wake-up's frames keep the last rate. Slow frames still read slow, because their gap is their own work. - chrome: "– fps" until the window has drawn continuously, instead of 0. Measured on an Apple M4 driving a 30 Hz 4K display: drawing continuously reads 30; at rest with a wake-up every 0.5 s it stays 30 (before: 45 -> 105). --- crates/ui-egui/src/chrome.rs | 3 +- crates/ui-egui/src/frame_rate.rs | 111 +++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 12 ++-- 3 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/frame_rate.rs diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index efd99a045..4b882c511 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -798,7 +798,8 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { for (id, width, text) in [ ("render", render_width, format!("{render} {:.1} ms", app.perf.render_ms)), ("ui", ui_width, format!("{ui_name} {:.1} ms", app.perf.frame_ms)), - ("fps", fps_width, format!("{:.0} fps", app.perf.fps)), + // No rate until the window has drawn continuously for a moment (`frame_rate`). + ("fps", fps_width, if app.perf.fps > 0.0 { format!("{:.0} fps", app.perf.fps) } else { "– fps".into() }), ] { let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(width, area.height())); status_field(ui, id, rect, text, proportional.clone(), t.text_disabled); diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs new file mode 100644 index 000000000..1ba0c56a3 --- /dev/null +++ b/crates/ui-egui/src/frame_rate.rs @@ -0,0 +1,111 @@ +//! The status bar's frame rate: frames per second while the window draws continuously. +//! +//! An idle window draws only when something wakes it, and each wake-up draws a couple of frames a +//! few milliseconds apart (the swapchain has a free image, so the second doesn't wait for vsync). +//! An average of `1 / dt` over any two frames therefore climbed above the display's refresh rate at +//! rest and fell back to it as soon as the pointer moved, which looked like a slowdown. Here the +//! gaps the window spent idle start a new measurement, and the rate is the number of frames over +//! the last second of continuous drawing. + +use std::collections::VecDeque; + +/// The stretch of continuous drawing the rate is taken over, in seconds. +const WINDOW: f64 = 1.0; +/// Continuous drawing needed before the readout changes, in seconds: shorter bursts (a wake-up's +/// frames) keep the last rate. +const MIN_SPAN: f64 = 0.5; +/// Time between frames, beyond the previous frame's own work, that means the window was idle, in +/// seconds. Waiting for vsync takes at most one refresh (33 ms at 30 Hz), so a longer wait is the +/// window waiting for input, not for the display. +const IDLE: f64 = 0.1; + +#[derive(Debug, Default)] +pub struct FrameRate { + /// Seconds between consecutive frames of the current stretch of drawing, oldest first. + intervals: VecDeque, + /// Their sum. + span: f64, + /// When the last frame started. + last: Option, + /// The readout: frames per second, 0 until the window has drawn continuously for [`MIN_SPAN`]. + fps: f64, +} + +impl FrameRate { + /// A frame starts at `now` (seconds, monotonic); the previous frame's own work took `work` + /// seconds. → the frame rate to show. + pub fn frame(&mut self, now: f64, work: f64) -> f64 { + let Some(last) = self.last.replace(now) else { return self.fps }; + let dt = now - last; + if !dt.is_finite() || dt <= 0.0 { + // A second pass of the same frame, or a clock that went back: nothing to measure. + return self.fps; + } + if dt - work.max(0.0) > IDLE { + self.intervals.clear(); + self.span = 0.0; + return self.fps; + } + self.intervals.push_back(dt); + self.span += dt; + while self.span > WINDOW + && let Some(old) = self.intervals.pop_front() + { + self.span -= old; + } + if self.span >= MIN_SPAN { + self.fps = self.intervals.len() as f64 / self.span; + } + self.fps + } +} + +#[cfg(test)] +mod tests { + use super::FrameRate; + + /// Feed frames starting at `t` every `dt` seconds for `secs`, each taking `work`. → the time + /// after the last frame and the readout. + fn run(r: &mut FrameRate, mut t: f64, dt: f64, secs: f64, work: f64) -> (f64, f64) { + let mut fps = 0.0; + let end = t + secs; + while t < end { + fps = r.frame(t, work); + t += dt; + } + (t, fps) + } + + #[test] + fn idle_wake_ups_keep_the_rate_the_display_allowed() { + let mut r = FrameRate::default(); + // The pointer moves: the window draws at the 30 Hz display's rate. + let (mut t, fps) = run(&mut r, 0.0, 1.0 / 30.0, 3.0, 0.001); + assert!((fps - 30.0).abs() < 0.5, "{fps}"); + // It rests: a wake-up every half second draws two frames 4 ms apart. + for _ in 0..20 { + t += 0.5; + r.frame(t, 0.001); + let fps = r.frame(t + 0.004, 0.001); + assert!((fps - 30.0).abs() < 0.5, "an idle window read {fps} fps"); + } + // It moves again on a 60 Hz display: the readout follows once it has drawn for a while. + let (_, fps) = run(&mut r, t + 1.0, 1.0 / 60.0, 1.5, 0.001); + assert!((fps - 60.0).abs() < 0.5, "{fps}"); + } + + #[test] + fn slow_frames_read_slow() { + // Each frame works 200 ms: the long gaps are its own work, not idling. + let mut r = FrameRate::default(); + let (_, fps) = run(&mut r, 0.0, 0.21, 3.0, 0.2); + assert!((fps - 1.0 / 0.21).abs() < 0.05, "{fps}"); + } + + #[test] + fn no_reading_before_half_a_second_of_drawing() { + let mut r = FrameRate::default(); + let (_, fps) = run(&mut r, 0.0, 1.0 / 60.0, 0.4, 0.001); + assert_eq!(fps, 0.0); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59efd1bcd..69577b95c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ pub mod export_progress; pub mod find_font; pub mod floating; pub mod font_menu; +mod frame_rate; mod free_transform; pub mod graphics; pub mod i18n; @@ -420,7 +421,8 @@ pub struct VectorcraftApp { /// Installed fonts added to the UI's for characters its own fonts lack (CJK names…). ui_fonts: ui_fonts::UiFonts, frame: u64, - last_time: f64, + /// The status bar's frame rate ([`frame_rate`]). + frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, /// Hover position in document coordinates. @@ -563,7 +565,7 @@ impl VectorcraftApp { context: 0, ui_fonts: Default::default(), frame: 0, - last_time: 0.0, + frame_rate: Default::default(), canvas_rect: None, hover_doc: None, custom_titlebar: false, @@ -1194,11 +1196,7 @@ impl VectorcraftApp { } self.frame += 1; let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; + self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0); self.sync_views(); // Read the system clipboard only when that alone decides whether Paste is enabled. A // background thread reads it where the host installs one (an unresponsive owner must never