Skip to main content

agg_gui/
timestep.rs

1//! Fixed-timestep scheduler for deterministic time-stepped simulation.
2//!
3//! Rendering can happen at any cadence, but physics, gameplay, or any other
4//! deterministic simulation runs in fixed slices (60 Hz by default). When
5//! rendering falls behind, the scheduler catches up by running multiple
6//! simulation steps before the next draw, capped so a long pause or slow frame
7//! never turns into one huge step.
8//!
9//! Useful well beyond games: gesture inertia, physics-based UI animations
10//! (fling-scroll, spring-damped panels), demos/visualizations, and any retained
11//! widget that wants stable simulation independent of render rate.
12//!
13//! # Example
14//! ```
15//! use agg_gui::timestep::FixedTimestep;
16//! let mut timestep = FixedTimestep::new();
17//! // Inside the host's per-frame callback, with `elapsed` the wall-clock
18//! // delta since the previous frame:
19//! # let elapsed = 1.0_f32 / 60.0;
20//! let batch = timestep.advance(elapsed);
21//! for _ in 0..batch.steps {
22//!     // step_simulation(batch.dt);
23//! }
24//! ```
25
26/// Default simulation frequency (Hz).
27pub const SIMULATION_HZ: f32 = 60.0;
28
29/// Default fixed simulation step in seconds (`1.0 / SIMULATION_HZ`).
30pub const FIXED_DT: f32 = 1.0 / SIMULATION_HZ;
31
32/// Default maximum simulation work before one draw.
33///
34/// Four 60 Hz updates per draw is equivalent to drawing at 15 fps. If the app
35/// falls further behind, excess wall-clock time is dropped and the simulation
36/// slows down instead of making collision-unsafe jumps.
37pub const MAX_STEPS_PER_DRAW: u32 = 4;
38
39const MAX_ACCUMULATED_TIME: f32 = FIXED_DT * MAX_STEPS_PER_DRAW as f32;
40
41/// One scheduling decision: how many fixed steps to run, with what `dt`, plus
42/// any wall-clock time that was dropped to avoid catastrophic catch-up.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct StepBatch {
45    pub steps: u32,
46    pub dt: f32,
47    pub dropped_time: f32,
48}
49
50/// Accumulating fixed-timestep scheduler.
51///
52/// Defaults to 60 Hz with a 4-step catch-up cap. Pass elapsed wall time into
53/// [`FixedTimestep::advance`]; it returns a [`StepBatch`] describing the
54/// simulation work for the upcoming draw.
55#[derive(Debug, Clone)]
56pub struct FixedTimestep {
57    accumulated: f32,
58}
59
60impl FixedTimestep {
61    pub fn new() -> Self {
62        Self { accumulated: 0.0 }
63    }
64
65    /// Accumulate elapsed wall time and return how many fixed-`FIXED_DT` updates
66    /// to run before the next draw.
67    pub fn advance(&mut self, elapsed_seconds: f32) -> StepBatch {
68        let elapsed = elapsed_seconds.max(0.0);
69        self.accumulated += elapsed;
70
71        let dropped_time = if self.accumulated > MAX_ACCUMULATED_TIME {
72            let dropped = self.accumulated - MAX_ACCUMULATED_TIME;
73            self.accumulated = MAX_ACCUMULATED_TIME;
74            dropped
75        } else {
76            0.0
77        };
78
79        let steps = ((self.accumulated / FIXED_DT).floor() as u32).min(MAX_STEPS_PER_DRAW);
80        self.accumulated -= steps as f32 * FIXED_DT;
81
82        StepBatch {
83            steps,
84            dt: FIXED_DT,
85            dropped_time,
86        }
87    }
88
89    pub fn reset(&mut self) {
90        self.accumulated = 0.0;
91    }
92}
93
94impl Default for FixedTimestep {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    fn approx_eq(a: f32, b: f32) {
105        assert!((a - b).abs() < 0.000_01, "{a} != {b}");
106    }
107
108    #[test]
109    fn runs_one_step_for_one_sixtieth() {
110        let mut timestep = FixedTimestep::new();
111        let batch = timestep.advance(FIXED_DT);
112
113        assert_eq!(batch.steps, 1);
114        approx_eq(batch.dt, FIXED_DT);
115        approx_eq(batch.dropped_time, 0.0);
116    }
117
118    #[test]
119    fn accumulates_fractional_frames() {
120        let mut timestep = FixedTimestep::new();
121
122        assert_eq!(timestep.advance(FIXED_DT * 0.5).steps, 0);
123        assert_eq!(timestep.advance(FIXED_DT * 0.5).steps, 1);
124    }
125
126    #[test]
127    fn catches_up_to_fifteen_fps_and_drops_the_rest() {
128        let mut timestep = FixedTimestep::new();
129        let batch = timestep.advance(1.0);
130
131        assert_eq!(batch.steps, MAX_STEPS_PER_DRAW);
132        assert!(batch.dropped_time > 0.9);
133        assert_eq!(timestep.advance(0.0).steps, 0);
134    }
135
136    #[test]
137    fn ignores_negative_elapsed_time() {
138        let mut timestep = FixedTimestep::new();
139        let batch = timestep.advance(-1.0);
140
141        assert_eq!(batch.steps, 0);
142        approx_eq(batch.dropped_time, 0.0);
143    }
144}