crates/veilvoice-gui/src/pace.rs
what this file is for · veilvoice-gui · 509 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! How often the window draws while something in it is moving, and what that
//! actually came to.
//!
//! # The number this replaces
//!
//! The animations ran at twenty frames a second by design. The mark in the
//! header carried its own constant, the busy path asked for a frame every
//! fifty milliseconds, and the veiling path every sixteen. Each was a
//! reasonable number on its own, and together they meant that on any display
//! somebody had bought in the last five years the window moved at a fraction
//! of the display's rate, with the busy path visibly juddering against the
//! mark beside it.
//!
//! Sixteen milliseconds is the interesting one. It is the number everybody
//! writes for sixty a second, and it is wrong for a window that waits for the
//! display: a display at sixty draws every 16.67 ms, so a request for a frame
//! "no later than sixteen milliseconds from now" wakes the loop just after the
//! frame it could have joined and the drawing lands on the one after. That is
//! thirty a second, asked for as sixty, and it is where "forty frames a second
//! on a good machine" came from.
//!
//! # What this does instead
//!
//! While something is moving, the window asks for the next frame **now**, and
//! lets vsync decide when that is. Under vsync a frame cannot be drawn faster
//! than the display shows it, so this costs one frame per display refresh and
//! not one more, and the rate is the display's own, whatever it is. Somebody
//! who wants fewer frames than that, on a battery or a machine that struggles,
//! sets a target in Settings and the window asks for a frame every `1/target`
//! seconds instead, which is the old behaviour with the number chosen rather
//! than hard-coded. Idle still draws nothing; this only decides the spacing of
//! frames that were going to be drawn anyway.
//!
//! # The display's rate is measured, not asked for
//!
//! Neither `egui` nor `eframe` says what the display's refresh rate is. It can
//! be measured: a run of frames requested back to back under vsync settles at
//! the display's rate, and the median interval over the last thirty-two frames
//! is a number a single slow frame cannot move. That median, rounded and
//! clamped to 30..=1000, is what the About tab reports as the display and what
//! "match the display" means in Settings.
//!
//! # Dropped frames
//!
//! A frame that arrives more than one and a half times the expected interval
//! after the one before it is counted as dropped. The count is shown beside
//! the frame rate, and when more than a handful drop inside one second the
//! window says so in the header, with whether it is on software rendering,
//! because that is the first thing to check and the About tab is not where
//! somebody looks while it is happening.
//!
//! # Realtime
//!
//! `frame` runs once per drawn frame on the thread that draws. It allocates
//! nothing, locks nothing and prints nothing: the interval history is a fixed
//! ring, the median is taken over a copy of it on the stack, and the target
//! that other modules read is an atomic. The guard from roadmap item 126 reads this
//! file.
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
/// The lowest rate a display is believed to have. Anything measured under this
/// is the window being starved, not a display.
pub const DISPLAY_FLOOR: u32 = 30;
/// The highest rate the window will run at, display or setting.
///
/// This was 240, which was wrong twice. It was a cap on what the measurement
/// could *report*, so a 360 Hz display was described as a 240 Hz one in the
/// About tab, and it was a cap on what "match the display" could ask for, so
/// that display was driven at 240. Both are now well clear of any panel sold:
/// 500 Hz exists, 1000 Hz is the number the research displays quote, and a
/// ceiling that has to be raised again in two years is a ceiling in the wrong
/// place.
///
/// It stays a ceiling rather than becoming no limit because the measurement is
/// a median of observed intervals, and a window starved to a handful of
/// microsecond frames should be reported as fast, not as impossible.
pub const DISPLAY_CEILING: u32 = 1000;
/// The rate assumed until the display has been measured.
pub const ASSUMED: u32 = 60;
/// The targets Settings offers, besides "match the display".
///
/// The rates panels are actually sold at, plus the two at the top for displays
/// that are ahead of that list. "Match the display" remains the default and
/// covers every one of these without being asked, so this is for somebody who
/// wants to pin it lower to save power, or higher than their panel to see what
/// the window can do.
pub const TARGETS: &[u32] = &[
30, 60, 90, 120, 144, 165, 180, 240, 280, 360, 390, 480, 500, 540, 750, 1000,
];
/// How many intervals the median is taken over.
const WINDOW: usize = 32;
/// A frame this much later than expected is a dropped one.
const DROPPED_AT: f32 = 1.5;
/// More drops than this inside one second is worth saying out loud.
const NOTICE_AT: u32 = 5;
/// The interval every animation in the window paces itself by, in
/// microseconds. Zero means "ask for the next frame now and let vsync pace it".
///
/// An atomic rather than a field passed down, because the mark is drawn from
/// Settings' preview as well as from the header and neither of those has the
/// application in hand. Written once per frame by `Pace::frame`, read by
/// anything that moves.
static INTERVAL_MICROS: AtomicU32 = AtomicU32::new(0);
/// Set by [`next_frame`], read and cleared once per frame by [`Pace::frame`].
///
/// This is how the measurement knows whether the frame it is looking at was
/// asked for by an animation or by somebody moving the mouse, without every
/// animation in the window having to report itself. The mark in the header
/// asks for its own frames and is drawn from two places, neither of which has
/// the application in hand; this way it counts like everything else.
static ANIMATING: AtomicBool = AtomicBool::new(false);
/// Ask for the next frame the way the current target wants it asked for.
///
/// The one call an animation makes. With the target on the display, this is
/// `request_repaint`, and vsync spaces the frames; with a lower target it is
/// `request_repaint_after` the target's interval.
pub fn next_frame(ctx: &egui::Context) {
ANIMATING.store(true, Ordering::Relaxed);
let micros = INTERVAL_MICROS.load(Ordering::Relaxed);
if micros == 0 {
ctx.request_repaint();
} else {
ctx.request_repaint_after(Duration::from_micros(u64::from(micros)));
}
}
/// What a person chose in Settings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
/// As fast as the display shows frames, whatever that is.
Display,
/// This many a second, at most.
Fixed(u32),
}
impl Target {
/// From the preference as stored: zero is the display.
pub fn from_setting(value: u32) -> Self {
if value == 0 {
Self::Display
} else {
Self::Fixed(value.clamp(DISPLAY_FLOOR, DISPLAY_CEILING))
}
}
/// The preference to store.
pub fn to_setting(self) -> u32 {
match self {
Self::Display => 0,
Self::Fixed(hz) => hz,
}
}
/// The words Settings shows for it.
pub fn label(self) -> String {
match self {
Self::Display => "match the display".to_string(),
Self::Fixed(hz) => format!("{hz} a second"),
}
}
}
/// The measurement, kept across frames.
#[derive(Debug)]
pub struct Pace {
target: Target,
/// Seconds between recent frames, oldest overwritten first.
intervals: [f32; WINDOW],
filled: usize,
next: usize,
last_time: Option<f64>,
/// The display's rate as measured, once enough frames have been seen.
display_hz: Option<u32>,
/// Frames a second over the last whole second, as drawn.
fps: f32,
frames_this_second: u32,
second_started: f64,
dropped_total: u64,
dropped_this_second: u32,
dropped_last_second: u32,
/// Consecutive whole seconds that dropped more than [`NOTICE_AT`] frames.
dropping_seconds: u32,
}
impl Default for Pace {
fn default() -> Self {
Self::new(Target::Display)
}
}
impl Pace {
/// A fresh measurement with this target.
pub fn new(target: Target) -> Self {
let pace = Self {
target,
intervals: [0.0; WINDOW],
filled: 0,
next: 0,
last_time: None,
display_hz: None,
fps: 0.0,
frames_this_second: 0,
second_started: 0.0,
dropped_total: 0,
dropped_this_second: 0,
dropped_last_second: 0,
dropping_seconds: 0,
};
pace.publish();
pace
}
/// Change the target, keeping what has been measured.
pub fn set_target(&mut self, target: Target) {
self.target = target;
self.publish();
}
/// The target as chosen.
pub fn target(&self) -> Target {
self.target
}
/// The rate the window is aiming at right now, in frames a second.
pub fn target_hz(&self) -> u32 {
match self.target {
Target::Display => self.display_hz.unwrap_or(ASSUMED),
Target::Fixed(hz) => hz,
}
}
/// The display's rate as measured, if it has been.
pub fn display_hz(&self) -> Option<u32> {
self.display_hz
}
/// Frames a second over the last whole second of drawing.
pub fn fps(&self) -> f32 {
self.fps
}
/// Every frame counted as dropped since the window opened.
pub fn dropped_total(&self) -> u64 {
self.dropped_total
}
/// Frames dropped in the last whole second.
pub fn dropped_last_second(&self) -> u32 {
self.dropped_last_second
}
/// Whether frames are being dropped steadily enough to be worth saying.
///
/// Two consecutive seconds rather than one. Opening a window costs a
/// hitch: fonts are rasterised, the first textures are uploaded, and the
/// first second of almost any launch drops frames. Telling somebody their
/// machine is struggling because of that would be crying wolf on every
/// start.
pub fn is_dropping(&self) -> bool {
self.dropping_seconds >= 2
}
/// How many consecutive seconds have been dropping frames.
pub fn dropping_seconds(&self) -> u32 {
self.dropping_seconds
}
/// Record that a frame is being drawn at `time`, egui's clock in seconds.
///
/// Called once per frame from the draw path, before anything else reads
/// the numbers. Whether this frame was asked for by an animation is taken
/// from the flag [`next_frame`] set at the end of the frame before, and
/// only those frames are used to measure the display: the interval between
/// two frames somebody caused by moving the mouse says nothing about how
/// fast the screen is.
pub fn frame(&mut self, time: f64) {
let moving = ANIMATING.swap(false, Ordering::Relaxed);
if self.second_started == 0.0 {
self.second_started = time;
}
self.frames_this_second += 1;
let elapsed = time - self.second_started;
if elapsed >= 1.0 {
self.fps = self.frames_this_second as f32 / elapsed as f32;
self.frames_this_second = 0;
self.second_started = time;
self.dropped_last_second = self.dropped_this_second;
if self.dropped_this_second > NOTICE_AT {
self.dropping_seconds += 1;
} else {
self.dropping_seconds = 0;
}
self.dropped_this_second = 0;
}
let Some(last) = self.last_time else {
self.last_time = Some(time);
return;
};
self.last_time = Some(time);
let interval = (time - last) as f32;
// `is_finite` and a positive test rather than a negated comparison:
// the clock can hand back the same instant twice, and a NaN here would
// otherwise be counted as a frame.
if !moving || !interval.is_finite() || interval <= 0.0 {
// An idle gap is not a dropped frame, and neither is a clock that
// did not move.
return;
}
// Late against what was asked for.
let expected = 1.0 / self.target_hz() as f32;
if interval > expected * DROPPED_AT {
self.dropped_total += 1;
self.dropped_this_second += 1;
}
// Only frames paced by vsync say anything about the display. A fixed
// target below it is the timer being measured, not the screen.
if self.target == Target::Display {
self.intervals[self.next] = interval;
self.next = (self.next + 1) % WINDOW;
self.filled = (self.filled + 1).min(WINDOW);
if self.filled == WINDOW {
let measured = self.median_hz();
if self.display_hz != Some(measured) {
self.display_hz = Some(measured);
self.publish();
}
}
}
}
/// The display's rate from the median interval, clamped to sense.
fn median_hz(&self) -> u32 {
// A copy on the stack: the ring must not be reordered.
//
// `select_nth_unstable_by` rather than a sort, and that is not a
// micro-optimisation, it is the difference between allocating and
// not. The stable `sort_by` takes a scratch buffer for a slice this
// long, which would put an allocation in a function this file's own
// notes promise does not allocate; the unstable selection is in
// place and only has to get the middle element right, which is all a
// median needs.
let mut window = self.intervals;
let (_, median, _) = window.select_nth_unstable_by(WINDOW / 2, |a, b| {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
});
let median = *median;
if !median.is_finite() || median <= 0.0 {
return ASSUMED;
}
let hz = (1.0 / median).round();
(hz as u32).clamp(DISPLAY_FLOOR, DISPLAY_CEILING)
}
/// Tell the animations what to ask for.
fn publish(&self) {
let micros = match self.target {
Target::Display => 0,
Target::Fixed(hz) => 1_000_000 / hz.max(1),
};
INTERVAL_MICROS.store(micros, Ordering::Relaxed);
}
/// The interval animations currently pace by, for tests and the About
/// tab. `None` is "vsync decides".
pub fn interval(&self) -> Option<Duration> {
match self.target {
Target::Display => None,
Target::Fixed(hz) => Some(Duration::from_micros(1_000_000 / u64::from(hz.max(1)))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Frames an animation asked for, at `hz`.
fn run(pace: &mut Pace, hz: f64, frames: usize) {
let mut t = 0.0;
for _ in 0..frames {
ANIMATING.store(true, Ordering::Relaxed);
pace.frame(t);
t += 1.0 / hz;
}
}
/// One frame nothing asked for: somebody moved the mouse.
fn idle_frame(pace: &mut Pace, t: f64) {
ANIMATING.store(false, Ordering::Relaxed);
pace.frame(t);
}
#[test]
fn the_display_is_measured_from_the_frames_it_paced() {
for hz in [60.0, 120.0, 144.0, 165.0, 240.0, 360.0, 500.0, 1000.0] {
let mut pace = Pace::new(Target::Display);
run(&mut pace, hz, WINDOW + 2);
assert_eq!(pace.display_hz(), Some(hz as u32), "{hz}");
assert_eq!(pace.target_hz(), hz as u32);
}
}
#[test]
fn one_slow_frame_does_not_move_the_measurement() {
let mut pace = Pace::new(Target::Display);
run(&mut pace, 144.0, WINDOW);
let t = WINDOW as f64 / 144.0;
ANIMATING.store(true, Ordering::Relaxed);
pace.frame(t + 0.2);
assert_eq!(pace.display_hz(), Some(144));
assert_eq!(pace.dropped_total(), 1, "the slow frame is a dropped one");
}
#[test]
fn a_fixed_target_paces_by_its_own_interval_and_measures_nothing() {
let mut pace = Pace::new(Target::Fixed(30));
assert_eq!(pace.interval(), Some(Duration::from_micros(33_333)));
run(&mut pace, 30.0, WINDOW + 2);
assert_eq!(pace.display_hz(), None, "a timer is not a display");
assert_eq!(pace.target_hz(), 30);
assert_eq!(pace.dropped_total(), 0);
}
#[test]
fn the_setting_round_trips_and_is_clamped() {
assert_eq!(Target::from_setting(0), Target::Display);
assert_eq!(Target::from_setting(144), Target::Fixed(144));
assert_eq!(Target::from_setting(1), Target::Fixed(DISPLAY_FLOOR));
assert_eq!(Target::from_setting(1000), Target::Fixed(DISPLAY_CEILING));
for hz in TARGETS {
assert_eq!(Target::from_setting(*hz).to_setting(), *hz);
}
assert_eq!(Target::Display.to_setting(), 0);
}
#[test]
fn idle_gaps_are_not_dropped_frames() {
let mut pace = Pace::new(Target::Display);
run(&mut pace, 60.0, 8);
// The window sat idle for a while, then drew because the mouse moved.
idle_frame(&mut pace, 30.0);
assert_eq!(pace.dropped_total(), 0);
}
#[test]
fn a_second_of_late_frames_is_worth_saying() {
let mut pace = Pace::new(Target::Fixed(60));
let mut t = 0.0;
// Every frame at half the rate asked for, for more than two seconds:
// one bad second is a hitch and deliberately does not raise this.
for _ in 0..70 {
ANIMATING.store(true, Ordering::Relaxed);
pace.frame(t);
t += 1.0 / 30.0;
}
assert!(pace.dropped_last_second() > NOTICE_AT);
assert!(
pace.is_dropping(),
"two seconds of late frames is worth saying"
);
assert!((pace.fps() - 30.0).abs() < 2.0, "{}", pace.fps());
}
#[test]
fn one_bad_second_at_launch_is_not_an_alarm() {
// Opening a window costs a hitch. One second of it must not tell
// somebody their machine is struggling.
let mut pace = Pace::new(Target::Fixed(60));
let mut t = 0.0;
// One whole second of late frames and a little more, which is what a
// launch looks like.
for _ in 0..40 {
ANIMATING.store(true, Ordering::Relaxed);
pace.frame(t);
t += 1.0 / 30.0;
}
assert!(
pace.dropped_last_second() > NOTICE_AT,
"the second did drop frames"
);
assert_eq!(pace.dropping_seconds(), 1);
assert!(!pace.is_dropping(), "one second is a hitch, not a struggle");
}
#[test]
fn the_published_interval_follows_the_target() {
let mut pace = Pace::new(Target::Display);
assert_eq!(INTERVAL_MICROS.load(Ordering::Relaxed), 0);
pace.set_target(Target::Fixed(120));
assert_eq!(INTERVAL_MICROS.load(Ordering::Relaxed), 8_333);
pace.set_target(Target::Display);
assert_eq!(INTERVAL_MICROS.load(Ordering::Relaxed), 0);
}
}