concinnity-engine 0.19.119

Runtime engine for Concinnity: ECS schedule, graphics, spawn, streaming
//! CPU frame pacer for the FPS cap. Runs at the runtime level, before the world
//! steps, so no system pays the sleep inside its own step time and the cap
//! applies whichever systems the world built. The cap value comes from the
//! `FrameRateCap` resource (published by GraphicsSystem from GraphicsConfig +
//! the live settings row), and is the only limit the pacer applies.

use concinnity_core::ecs::FrameRateCap;
use concinnity_core::ecs::World;
use std::time::{Duration, Instant};

// Frame-pacing math for the FPS cap, split from the sleep so the drift / no-burst
// logic is unit-testable. Given the current time, the previously scheduled
// deadline (if any), and the target frame interval, returns `(wait_until, next)`:
// the deadline to hold this frame's start to, and the deadline to store for the
// next frame. When the previous deadline is still in the future the cap is met by
// waiting to it and accumulating exactly (no drift); when it has already passed
// (a frame that overran the budget) it resets to `now` so a slow frame never
// triggers a catch-up burst of un-paced frames.
fn pace_deadline(now: Instant, prev: Option<Instant>, target: Duration) -> (Instant, Instant) {
    let wait_until = prev.filter(|d| *d > now).unwrap_or(now);
    (wait_until, wait_until + target)
}

// Holds each frame's start to the target interval so the loop runs at most
// `FrameRateCap` frames a second. One per `Runtime`, driven once per world step.
#[derive(Debug, Default)]
pub(crate) struct FramePacer {
    // The pacer's running target for the next frame's start.
    deadline: Option<Instant>,
    // The cap the deadline was accumulated under. A cap change re-bases
    // the pacer (clears the deadline) so switching caps never leaves one stale
    // long wait, mirroring the old in-step rebase on the settings change.
    last_cap: u32,
}

impl FramePacer {
    // Pace the upcoming world step from the world's published pacing state.
    // No-op (and deadline cleared) while no cap is published or the cap is 0.
    pub(crate) fn pace(&mut self, world: &World) {
        let cap = world.resource::<FrameRateCap>().map(|c| c.0).unwrap_or(0);
        if cap != self.last_cap {
            self.deadline = None;
            self.last_cap = cap;
        }
        if cap == 0 {
            self.deadline = None;
            return;
        }
        let target = Duration::from_secs_f64(1.0 / cap as f64);
        let (wait_until, next) = pace_deadline(Instant::now(), self.deadline, target);
        // Coarse-sleep to ~1ms before the deadline, then spin for
        // sub-millisecond precision.
        while let Some(remaining) = wait_until.checked_duration_since(Instant::now()) {
            if remaining > Duration::from_millis(1) {
                std::thread::sleep(remaining - Duration::from_millis(1));
            } else {
                std::hint::spin_loop();
            }
        }
        self.deadline = Some(next);
    }
}

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

    #[test]
    fn pace_deadline_first_frame_does_not_wait() {
        // No previous deadline: wait until now (no sleep), schedule now + target.
        let now = Instant::now();
        let target = Duration::from_millis(16);
        let (wait_until, next) = pace_deadline(now, None, target);
        assert_eq!(wait_until, now);
        assert_eq!(next, now + target);
    }

    #[test]
    fn pace_deadline_accumulates_exactly_when_ahead() {
        // A frame finished early: the previous deadline is still in the future,
        // so hold to it and accumulate exactly (no drift from `now`).
        let now = Instant::now();
        let target = Duration::from_millis(16);
        let prev = now + Duration::from_millis(10);
        let (wait_until, next) = pace_deadline(now, Some(prev), target);
        assert_eq!(wait_until, prev);
        assert_eq!(next, prev + target);
    }

    #[test]
    fn pace_deadline_resets_after_overrun_without_burst() {
        // The frame overran: the previous deadline is in the past, so re-base to
        // `now` (no wait this frame) and schedule from now -- never a catch-up
        // burst of un-paced frames.
        let now = Instant::now();
        let target = Duration::from_millis(16);
        let past = now - Duration::from_millis(5);
        let (wait_until, next) = pace_deadline(now, Some(past), target);
        assert_eq!(wait_until, now);
        assert_eq!(next, now + target);
    }

    // An open menu runs at the published cap like any other frame: with no
    // cap it stays unpaced.
    #[test]
    fn an_open_menu_follows_the_published_cap() {
        let mut world = World::new();
        world.insert_resource(concinnity_core::ecs::MenuActive(true));
        world.insert_resource(FrameRateCap(0));
        let mut pacer = FramePacer::default();
        pacer.pace(&world);
        assert!(pacer.deadline.is_none(), "an uncapped menu is not paced");
        world.insert_resource(FrameRateCap(1000));
        pacer.pace(&world);
        assert_eq!(pacer.last_cap, 1000, "the menu paces at the cap itself");
    }

    // A cap change re-bases the pacer: the accumulated deadline is dropped so
    // the new cap takes over from `now` rather than waiting out a stale long
    // interval.
    #[test]
    fn cap_change_rebases_the_deadline() {
        let mut world = World::new();
        world.insert_resource(FrameRateCap(1000));
        let mut pacer = FramePacer::default();
        pacer.pace(&world);
        assert!(
            pacer.deadline.is_some(),
            "a positive cap schedules a deadline"
        );
        world.insert_resource(FrameRateCap(0));
        pacer.pace(&world);
        assert!(pacer.deadline.is_none(), "cap 0 clears the schedule");
    }

    // A world with no published cap (headless, or graphics failed) never
    // paces.
    #[test]
    fn unpublished_cap_is_unlimited() {
        let world = World::new();
        let mut pacer = FramePacer::default();
        pacer.pace(&world);
        assert!(pacer.deadline.is_none());
    }
}