mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
//! The accumulator behind `tick`, and the timings a frame reads.

use core::time::Duration;

use crate::platform::Instant;

/// Tick cap per frame before the rest are dropped, `8` by default.
const MAX_TICKS_PER_FRAME: u32 = 8;

/// The least tick interval the accumulator uses.
pub(crate) const MIN_TICK_INTERVAL: Duration = Duration::from_micros(1);

/// The step ticks run at: the one running ticks take, and the one the
/// game last requested, which the next batch of ticks takes.
pub(crate) struct TickInterval {
    current: Duration,
    requested: Duration,
}

impl TickInterval {
    /// Starts at `interval`, held to at least [`MIN_TICK_INTERVAL`].
    pub(crate) fn new(interval: Duration) -> Self {
        let interval = interval.max(MIN_TICK_INTERVAL);
        Self {
            current: interval,
            requested: interval,
        }
    }

    /// The step running ticks take.
    pub(crate) fn current(&self) -> Duration {
        self.current
    }

    /// Requests `interval` from the next batch of ticks on, held to at least
    /// [`MIN_TICK_INTERVAL`].
    pub(crate) fn set(&mut self, interval: Duration) {
        self.requested = interval.max(MIN_TICK_INTERVAL);
    }

    /// Starts a batch of ticks: they take the step last requested.
    pub(crate) fn advance(&mut self) {
        self.current = self.requested;
    }
}

/// The timings a frame receives.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct FrameTime {
    pub(crate) dt: Duration,
    pub(crate) elapsed: Duration,
    pub(crate) alpha: f32,
}

/// Turns clock readings into whole simulation steps, plus what is left over
/// for the next frame.
pub(crate) struct Clock {
    started: Instant,
    frame_started: Instant,
    unsimulated: Duration,
    frame_dt: Duration,
    elapsed: Duration,
}

impl Clock {
    /// Starts at `now`.
    pub(crate) fn new(now: Instant) -> Self {
        Self {
            started: now,
            frame_started: now,
            unsimulated: Duration::ZERO,
            frame_dt: Duration::ZERO,
            elapsed: Duration::ZERO,
        }
    }

    /// Folds `now` in, and returns the number of steps of `tick_interval`
    /// this frame runs, up to eight.
    pub(crate) fn frame_at(&mut self, now: Instant, tick_interval: Duration) -> u32 {
        self.frame_dt = now.saturating_duration_since(self.frame_started);
        self.frame_started = now;
        self.elapsed = now.saturating_duration_since(self.started);
        self.unsimulated += self.frame_dt;

        let interval = tick_interval.max(MIN_TICK_INTERVAL).as_nanos();
        let owed = self.unsimulated.as_nanos() / interval;
        let leftover = self.unsimulated.as_nanos() % interval;
        self.unsimulated = Duration::from_nanos(leftover as u64);
        owed.min(u128::from(MAX_TICKS_PER_FRAME)) as u32
    }

    /// The timings the frame about to run reads, over the `tick_interval`
    /// its ticks took. `alpha` is how far into the next tick the
    /// accumulator is, `0.0..1.0`, for interpolating between two simulation
    /// states.
    pub(crate) fn frame_time(&self, tick_interval: Duration) -> FrameTime {
        FrameTime {
            dt: self.frame_dt,
            elapsed: self.elapsed,
            alpha: self
                .unsimulated
                .div_duration_f32(tick_interval.max(MIN_TICK_INTERVAL)),
        }
    }

    /// How long the run has been running as of the last reading.
    pub(crate) fn elapsed(&self) -> Duration {
        self.elapsed
    }
}

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

    /// A round step, for exact test arithmetic.
    const STEP: Duration = Duration::from_millis(30);

    /// The instant test readings count from.
    fn epoch() -> Instant {
        Instant::now()
    }

    fn clock() -> (Clock, Instant) {
        let start = epoch();
        (Clock::new(start), start)
    }

    /// One frame at `now` under the test step.
    fn frame_at(clock: &mut Clock, now: Instant) -> u32 {
        clock.frame_at(now, STEP)
    }

    #[test]
    fn frames_shorter_than_a_step_tick_zero_or_one_time() {
        let (mut clock, start) = clock();
        let frame = STEP * 3 / 5;

        let ticks: Vec<u32> = (1..=10)
            .map(|n| frame_at(&mut clock, start + frame * n))
            .collect();

        assert!(ticks.iter().all(|&ticks| ticks <= 1), "{ticks:?}");
        assert_eq!(ticks.iter().sum::<u32>(), 6, "{ticks:?}");
    }

    #[test]
    fn long_frames_tick_several_times() {
        let (mut clock, start) = clock();

        assert_eq!(frame_at(&mut clock, start + STEP * 9 / 2), 4);
        assert_eq!(
            frame_at(&mut clock, start + STEP * 5),
            1,
            "the half step completed"
        );
        assert_eq!(frame_at(&mut clock, start + STEP * 5), 0, "no time passed");
    }

    #[test]
    fn a_stall_is_clamped_and_its_backlog_abandoned() {
        let (mut clock, start) = clock();
        let stall = Duration::from_secs(10);

        assert_eq!(frame_at(&mut clock, start + stall), MAX_TICKS_PER_FRAME);
        assert_eq!(
            frame_at(&mut clock, start + stall + STEP),
            1,
            "the backlog did not carry"
        );
    }

    #[test]
    fn alpha_stays_within_one_step() {
        let (mut clock, start) = clock();

        for frame in 1..=200 {
            frame_at(&mut clock, start + STEP * 37 / 100 * frame);
            let alpha = clock.frame_time(STEP).alpha;
            assert!((0.0..1.0).contains(&alpha), "{alpha}");
        }
    }

    #[test]
    fn timings_track_the_readings() {
        let start = epoch() + Duration::from_secs(100);
        let mut clock = Clock::new(start);
        frame_at(&mut clock, start + Duration::from_millis(500));

        let time = clock.frame_time(STEP);
        assert_eq!(time.dt, Duration::from_millis(500));
        assert_eq!(time.elapsed, Duration::from_millis(500));
    }

    #[test]
    fn steps_accumulate_without_drift() {
        let (mut clock, start) = clock();
        let mut since_start = Duration::ZERO;

        // A third of a step per frame must land on one tick per three frames
        // for as long as it runs; summed `f32` seconds would drift off it.
        let frame = STEP / 3;
        let ticks: u32 = (0..30_000)
            .map(|_| {
                since_start += frame;
                frame_at(&mut clock, start + since_start)
            })
            .sum();

        assert_eq!(ticks, 10_000);
    }

    #[test]
    fn a_step_requested_is_taken_from_the_next_batch_on() {
        let mut interval = TickInterval::new(STEP);
        interval.advance();

        interval.set(STEP * 2);
        assert_eq!(interval.current(), STEP, "the running batch keeps its step");
        interval.advance();
        assert_eq!(interval.current(), STEP * 2, "the next takes the new one");

        interval.set(Duration::ZERO);
        interval.advance();
        assert_eq!(
            interval.current(),
            MIN_TICK_INTERVAL,
            "and never a zero step"
        );
    }

    #[test]
    fn a_zero_step_never_reaches_the_accumulator() {
        let (mut clock, start) = clock();
        assert_eq!(
            clock.frame_at(start + STEP, Duration::ZERO),
            MAX_TICKS_PER_FRAME
        );
        assert!(clock.frame_time(Duration::ZERO).alpha.is_finite());
    }

    #[test]
    fn a_clock_that_never_advances_never_ticks() {
        let start = epoch() + Duration::from_secs(7);
        let mut clock = Clock::new(start);

        assert_eq!(frame_at(&mut clock, start), 0);
        assert_eq!(
            frame_at(&mut clock, start - Duration::from_secs(1)),
            0,
            "time going backwards is ignored"
        );
        assert_eq!(clock.frame_time(STEP).alpha, 0.0);
    }
}