ferravex_core/
lib.rs

1//! Small, platform-independent building blocks. Simulation time never comes from a wall clock.
2//!
3//! ```
4//! use ferravex_core::{FixedClock, World};
5//! let mut world = World::new();
6//! world.spawn((42_u32,));
7//! let mut clock = FixedClock::new(64.0, 8);
8//! let frame = clock.advance(1.0 / 60.0);
9//! for _ in 0..frame.steps { /* update systems with frame.dt */ }
10//! ```
11pub use ferravex_ecs as ecs;
12pub use ferravex_ecs::{Entity, World};
13pub mod collision;
14pub mod telemetry;
15
16/// A fixed simulation step budget with a fractional render interpolation factor.
17#[derive(Debug, Clone, Copy)]
18pub struct FrameStep {
19    pub steps: u32,
20    pub dt: f32,
21    pub alpha: f32,
22    /// Whole steps intentionally discarded to prevent a spiral of death.
23    pub dropped_seconds: f64,
24}
25
26/// Frame-rate independent simulation with bounded catch-up work.
27///
28/// Supply elapsed seconds from your platform. Non-finite/negative durations are ignored.
29/// Over-budget whole steps are discarded; fractional remainder is retained for interpolation.
30#[derive(Debug, Clone)]
31pub struct FixedClock {
32    step: f64,
33    accumulator: f64,
34    max_steps: u32,
35    tick: u64,
36}
37
38impl FixedClock {
39    pub fn new(hz: f64, max_steps: u32) -> Self {
40        assert!(
41            hz.is_finite() && (1.0..=1000.0).contains(&hz),
42            "tick rate must be in 1..=1000 Hz"
43        );
44        assert!(max_steps > 0, "catch-up budget must be nonzero");
45        Self {
46            step: 1.0 / hz,
47            accumulator: 0.0,
48            max_steps,
49            tick: 0,
50        }
51    }
52
53    pub fn advance(&mut self, frame_seconds: f64) -> FrameStep {
54        // Clamp absurd durations before addition to keep all arithmetic finite.
55        let elapsed = if frame_seconds.is_finite() {
56            frame_seconds.clamp(0.0, 3600.0)
57        } else {
58            0.0
59        };
60        self.accumulator += elapsed;
61        let available = ((self.accumulator + self.step * 1e-9) / self.step).floor() as u64;
62        let steps = available.min(self.max_steps as u64) as u32;
63        self.accumulator = (self.accumulator - available as f64 * self.step).max(0.0);
64        self.tick = self.tick.wrapping_add(steps as u64);
65        FrameStep {
66            steps,
67            dt: self.step as f32,
68            alpha: (self.accumulator / self.step).clamp(0.0, 1.0) as f32,
69            dropped_seconds: (available - steps as u64) as f64 * self.step,
70        }
71    }
72
73    pub fn tick(&self) -> u64 {
74        self.tick
75    }
76    pub fn dt(&self) -> f32 {
77        self.step as f32
78    }
79    pub fn reset(&mut self) {
80        self.accumulator = 0.0;
81        self.tick = 0;
82    }
83}
84
85/// Allocation-free rolling frame-time statistics. Zero samples are valid.
86pub struct FrameStats<const N: usize = 120> {
87    samples: [f32; N],
88    cursor: usize,
89    len: usize,
90}
91impl<const N: usize> Default for FrameStats<N> {
92    fn default() -> Self {
93        assert!(N > 0);
94        Self {
95            samples: [0.0; N],
96            cursor: 0,
97            len: 0,
98        }
99    }
100}
101impl<const N: usize> FrameStats<N> {
102    pub fn record(&mut self, seconds: f32) {
103        if !seconds.is_finite() || seconds < 0.0 {
104            return;
105        }
106        self.samples[self.cursor] = seconds * 1000.0;
107        self.cursor = (self.cursor + 1) % N;
108        self.len = (self.len + 1).min(N);
109    }
110    pub fn mean_ms(&self) -> f32 {
111        if self.len == 0 {
112            return 0.0;
113        }
114        self.samples[..self.len].iter().sum::<f32>() / self.len as f32
115    }
116    pub fn max_ms(&self) -> f32 {
117        self.samples[..self.len].iter().copied().fold(0.0, f32::max)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    #[test]
125    fn frame_rate_independent() {
126        for fps in [30, 60, 144, 240] {
127            let mut clock = FixedClock::new(64.0, 8);
128            for _ in 0..fps * 10 {
129                clock.advance(1.0 / fps as f64);
130            }
131            assert_eq!(clock.tick(), 640);
132        }
133    }
134    #[test]
135    fn catchup_is_bounded_and_fraction_retained() {
136        let mut clock = FixedClock::new(64.0, 4);
137        let f = clock.advance(10.5 / 64.0);
138        assert_eq!(f.steps, 4);
139        assert!((f.alpha - 0.5).abs() < 0.0001);
140        assert!((f.dropped_seconds - 6.0 / 64.0).abs() < 0.0001);
141        assert_eq!(clock.advance(0.5 / 64.0).steps, 1);
142    }
143    #[test]
144    fn invalid_durations_do_not_poison_clock() {
145        let mut c = FixedClock::new(64.0, 8);
146        for dt in [f64::NAN, f64::INFINITY, -5.0] {
147            assert_eq!(c.advance(dt).steps, 0);
148        }
149        assert_eq!(c.advance(1.0 / 64.0).steps, 1);
150    }
151    #[test]
152    fn stats_wrap_and_ignore_nan() {
153        let mut s = FrameStats::<2>::default();
154        for dt in [0.001, 0.003, 0.005, f32::NAN] {
155            s.record(dt);
156        }
157        assert!((s.mean_ms() - 4.0).abs() < 0.001);
158        assert_eq!(s.max_ms(), 5.0);
159    }
160}