Merge pull request #3200 from Taha-Mahmoodi/fix/3198-fps-readout

Fix #3198: the status bar's fps is the rate the window draws at, not an average of idle bursts
This commit is contained in:
BFlat
2026-10-21 16:12:55 +07:00
committed by GitHub
3 changed files with 118 additions and 8 deletions
+2 -1
View File
@@ -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);
+111
View File
@@ -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<f64>,
/// Their sum.
span: f64,
/// When the last frame started.
last: Option<f64>,
/// 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);
}
}
+5 -7
View File
@@ -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<egui::Rect>,
/// 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