gled-core 2.31.1

The engine of gled without a user interface: inputs, timing, rendering, Art-Net/DMX output and storage
Documentation
//! Frame pacing and frame statistics of the render thread.

use gled_protocol::state::RenderStats;
use std::time::{Duration, Instant};
use tracing::debug;

/// The OS sleep ends this much before the frame deadline; the rest is spent
/// yielding, because sleeps overshoot by tens to hundreds of microseconds.
const SLEEP_MARGIN: Duration = Duration::from_micros(300);
/// How often frame statistics are logged and reported.
const REPORT_INTERVAL: Duration = Duration::from_secs(1);
/// Startup frames are much slower than steady-state frames (pipeline creation),
/// so they are left out of the worst-case frame time.
const WARMUP_FRAMES: u64 = 10;

/// Paces the render loop to the configured frame rate limit.
#[derive(Debug)]
pub(super) struct FramePacer {
    /// When the previous frame was due.
    last_deadline: Instant,
}

impl FramePacer {
    pub(super) fn new() -> Self {
        Self {
            last_deadline: Instant::now(),
        }
    }

    /// Block until the next deadline, `1 / fps_limit` after the previous one,
    /// so wake-up overshoot does not lower the rate. A frame later than a whole
    /// interval starts the next interval from now, so there is no catch-up
    /// burst. An infinite limit does not wait at all; the GPU then paces the
    /// loop through the frames-in-flight limit.
    pub(super) fn wait_for_next_frame(&mut self, fps_limit: f32) {
        let now = Instant::now();
        if fps_limit.is_infinite() {
            self.last_deadline = now;
            return;
        }
        let interval = Duration::from_secs_f32(1.0 / fps_limit.max(1.0));
        let deadline = next_deadline(self.last_deadline, now, interval);

        if let Some(remaining) = deadline.checked_duration_since(now) {
            if remaining > SLEEP_MARGIN {
                std::thread::sleep(remaining - SLEEP_MARGIN);
            }
            while Instant::now() < deadline {
                std::thread::yield_now();
            }
        }

        self.last_deadline = deadline;
    }
}

/// The deadline of the frame after the one due at `last`: one `interval`
/// later, or `now` once that is more than a whole interval in the past.
fn next_deadline(last: Instant, now: Instant, interval: Duration) -> Instant {
    let deadline = last + interval;
    if now > deadline + interval {
        now
    } else {
        deadline
    }
}

/// Average frame rate and worst frame time of the render thread.
#[derive(Debug)]
pub(super) struct FrameStats {
    window_start: Instant,
    window_frames: u32,
    last_frame: Instant,
    max_frame: Duration,
    total_frames: u64,
}

impl FrameStats {
    pub(super) fn new() -> Self {
        let now = Instant::now();
        Self {
            window_start: now,
            window_frames: 0,
            last_frame: now,
            max_frame: Duration::ZERO,
            total_frames: 0,
        }
    }

    /// Record a rendered frame. Once per `REPORT_INTERVAL` this logs the
    /// `frame_stats` line and returns the average fps and the worst frame.
    pub(super) fn record_frame(&mut self) -> Option<RenderStats> {
        let now = Instant::now();
        self.total_frames += 1;
        if self.total_frames > WARMUP_FRAMES {
            self.max_frame = self.max_frame.max(now - self.last_frame);
        }
        self.last_frame = now;
        self.window_frames += 1;

        let elapsed = now - self.window_start;
        if elapsed < REPORT_INTERVAL {
            return None;
        }

        let fps = self.window_frames as f32 / elapsed.as_secs_f32();
        let max_frame_ms = self.max_frame.as_secs_f32() * 1000.0;
        debug!("frame_stats: fps={fps:.1} max_frame_ms={max_frame_ms:.3}");

        self.window_start = now;
        self.window_frames = 0;
        self.max_frame = Duration::ZERO;

        Some(RenderStats { fps, max_frame_ms })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const INTERVAL: Duration = Duration::from_millis(10);

    /// Regression: on macOS, timing each frame from the previous wake-up
    /// ran ~15% below the limit. A late wake-up must not move the schedule.
    #[test]
    fn a_late_wake_up_keeps_the_schedule() {
        let last = Instant::now();
        let late = last + INTERVAL + INTERVAL / 2;

        assert_eq!(next_deadline(last, late, INTERVAL), last + INTERVAL);
    }

    /// No burst of frames to catch up after a long stall.
    #[test]
    fn a_frame_more_than_an_interval_late_starts_over() {
        let last = Instant::now();
        let stalled = last + INTERVAL * 3;

        assert_eq!(next_deadline(last, stalled, INTERVAL), stalled);
    }

    /// The same regression, measured on the real clock.
    #[test]
    #[ignore = "wall-clock timing; flaky on a loaded CI runner"]
    fn the_paced_rate_reaches_the_limit() {
        const FPS: f32 = 120.0;
        const FRAMES: u32 = 120;
        let mut pacer = FramePacer::new();
        pacer.wait_for_next_frame(FPS);
        let start = Instant::now();
        for _ in 0..FRAMES {
            pacer.wait_for_next_frame(FPS);
        }
        let fps = FRAMES as f32 / start.elapsed().as_secs_f32();
        assert!(fps > FPS * 0.98, "paced at {fps:.1} fps, limit {FPS}");
    }
}