Skip to main content

renew_frame/
schedule.rs

1//! The schedule itself: the accumulator, the step budget, the plan a
2//! frame produces, and the interpolation factor for rendering between
3//! steps.
4//!
5//! [`FrameLoop`] owns no loop and drives no application. Its whole job is
6//! one total function — [`FrameLoop::begin_frame`] answers *given the
7//! schedule so far and this instant, how many fixed steps are due, how
8//! many did the budget refuse, and how far between steps is the
9//! renderer.* The caller reads the one clock, executes the steps, and
10//! renders.
11
12use crate::time::{Nanos, StepBudget, Timestamp, Timestep};
13
14/// The fixed-timestep schedule: a passive integer state machine.
15///
16/// It never reads a clock — it *cannot*, having no dependency that offers
17/// one — so a run is reproducible exactly to the extent that the sequence
18/// of timestamps handed to [`FrameLoop::begin_frame`] is.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct FrameLoop {
21    timestep: Timestep,
22    budget: StepBudget,
23    /// The instant the previous frame was planned at.
24    last: Timestamp,
25    /// Elapsed time not yet consumed by a step. Always below `timestep`
26    /// once a frame has been planned.
27    bank: Nanos,
28    /// Steps executed since construction. The simulation's own clock is
29    /// derived from this, never measured.
30    tick: u64,
31}
32
33impl FrameLoop {
34    /// A schedule anchored at `start`, with an empty bank and tick zero.
35    ///
36    /// Anchor *after* expensive bring-up (device creation, asset load):
37    /// time banked before the first frame is time the budget has to
38    /// refuse, so a schedule anchored too early opens with a clamped
39    /// burst and a nonzero drop count that means nothing.
40    #[must_use]
41    pub const fn new(timestep: Timestep, budget: StepBudget, start: Timestamp) -> Self {
42        Self {
43            timestep,
44            budget,
45            last: start,
46            bank: Nanos::ZERO,
47            tick: 0,
48        }
49    }
50
51    /// Advance the schedule to `now` and report what this frame must do.
52    ///
53    /// A pure state transition: a function of the timestep, the budget,
54    /// prior state, and `now` — nothing else. It cannot fail, so it
55    /// returns no `Result`; an uninhabitable error variant would be a lie
56    /// about the API. No clock is read here or anywhere in this crate.
57    pub fn begin_frame(&mut self, now: Timestamp) -> FramePlan {
58        let dt = self.timestep.nanos().get();
59        let bank = self
60            .bank
61            .get()
62            .saturating_add(now.saturating_since(self.last).get());
63        let due = bank / dt;
64        // `due` is a `u64` and reaches 1.1e12 on a saturated bank, but the
65        // executed count is bounded by the budget, so it is exactly a
66        // `u32` and `step_count` needs no saturation of its own.
67        let steps = u32::try_from(due)
68            .unwrap_or(u32::MAX)
69            .min(self.budget.get().get());
70        let run = u64::from(steps);
71        // THE DISCARD. Keeping the surplus banked is the spiral of death:
72        // the next frame is also saturated, the bank never drains, and the
73        // loop never recovers. Discarding means simulation time falls
74        // permanently behind the wall — the game visibly slows — but the
75        // loop recovers the instant the frame rate does. What makes that
76        // honest rather than a lie is that the loss is reported: `dropped`
77        // is exact and flows into the frame statistics, so a frame with a
78        // nonzero drop count is a measurable budget violation.
79        let remainder = Nanos::from_nanos(bank % dt);
80        // `run` is `min(due, ..)`, so the difference cannot underflow.
81        let plan = FramePlan {
82            first_tick: self.tick,
83            steps,
84            dropped: due - run,
85            remainder,
86            dt: self.timestep,
87        };
88        self.bank = remainder;
89        self.tick = self.tick.saturating_add(run);
90        self.last = now;
91        plan
92    }
93
94    /// Discard the gap since the last frame, keeping the sub-timestep
95    /// remainder and the tick count.
96    ///
97    /// For pauses the caller *knows* about — a finished load, a resumed
98    /// dormant window, a breakpoint. Never automatic: a "delta over
99    /// threshold implies resync" heuristic would hide exactly the stall
100    /// the step budget exists to expose.
101    pub fn resync(&mut self, now: Timestamp) {
102        self.last = now;
103    }
104
105    /// Steps executed since construction.
106    #[must_use]
107    pub const fn tick(&self) -> u64 {
108        self.tick
109    }
110
111    /// The simulation's own clock: `tick × timestep`, saturating. Exact by
112    /// construction, and therefore never the measured wall time.
113    #[must_use]
114    pub const fn simulated(&self) -> Nanos {
115        Nanos::from_nanos(self.tick.saturating_mul(self.timestep.nanos().get()))
116    }
117
118    /// Elapsed time banked but not yet consumed by a step; below
119    /// [`FrameLoop::timestep`] once a frame has been planned.
120    #[must_use]
121    pub const fn remainder(&self) -> Nanos {
122        self.bank
123    }
124
125    #[must_use]
126    pub const fn timestep(&self) -> Timestep {
127        self.timestep
128    }
129
130    #[must_use]
131    pub const fn budget(&self) -> StepBudget {
132        self.budget
133    }
134}
135
136/// What one frame must do: the steps to execute, the steps the budget
137/// refused, and how far past the last step the renderer stands.
138///
139/// A `Copy` value that borrows nothing, so iterating its steps never
140/// conflicts with touching the rest of the caller's state.
141#[must_use = "a frame plan's steps must be executed and its alpha rendered"]
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct FramePlan {
144    first_tick: u64,
145    steps: u32,
146    dropped: u64,
147    remainder: Nanos,
148    dt: Timestep,
149}
150
151impl FramePlan {
152    /// The steps to execute, in tick order — exactly
153    /// [`FramePlan::step_count`] of them.
154    #[must_use]
155    pub const fn steps(&self) -> Steps {
156        Steps {
157            next: self.first_tick,
158            remaining: self.steps,
159            dt: self.dt,
160        }
161    }
162
163    #[must_use]
164    pub const fn step_count(&self) -> u32 {
165        self.steps
166    }
167
168    /// The timestep this plan was cut against.
169    ///
170    /// Public because the digest absorbs it: anything reconstructing
171    /// what was hashed — a test, a comparison lane, a tool diffing two
172    /// runs — needs every field that went in, and a digested field with
173    /// no accessor is one a consumer has to guess at.
174    #[must_use]
175    pub const fn dt(&self) -> Timestep {
176        self.dt
177    }
178
179    /// The tick index of the first step, which is also the loop's tick
180    /// count before this frame.
181    #[must_use]
182    pub const fn first_tick(&self) -> u64 {
183        self.first_tick
184    }
185
186    /// Steps the budget refused: simulation time fell behind wall time by
187    /// `dropped × timestep`, permanently. Reported, never silently banked.
188    #[must_use]
189    pub const fn dropped(&self) -> u64 {
190        self.dropped
191    }
192
193    /// Elapsed time carried into the next frame; always below
194    /// [`FramePlan::timestep`].
195    ///
196    /// This and [`FramePlan::timestep`] are the exact rational a renderer
197    /// interpolates by — `renew_math::Alpha::new(remainder, timestep)`
198    /// turns them into a blend factor. **The division deliberately does
199    /// not happen here.** This crate is simulation-designated and
200    /// contains no floating-point arithmetic at all; a ratio computed in
201    /// this crate would be the single exception to that, and an exception
202    /// defended by the cost of removing it is one that becomes permanent.
203    /// A consumer that wants the exact rational never goes through a
204    /// float.
205    #[must_use]
206    pub const fn remainder(&self) -> Nanos {
207        self.remainder
208    }
209
210    #[must_use]
211    pub const fn timestep(&self) -> Timestep {
212        self.dt
213    }
214}
215
216/// One simulation step.
217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
218pub struct Step {
219    /// The tick this step advances, counted from the loop's construction
220    /// and monotonically increasing across the whole run.
221    pub tick: u64,
222    /// The fixed timestep, repeated here so a world function needs only
223    /// the step.
224    pub dt: Nanos,
225    /// The simulation clock at the *start* of this step: `tick × dt`,
226    /// saturating. Defined rather than measured, so it is exact whatever
227    /// the wall clock does.
228    pub sim_time: Nanos,
229}
230
231/// The steps of one [`FramePlan`], in tick order.
232///
233/// Borrows nothing from the loop or the plan: the plan is `Copy`, so a
234/// caller can touch the rest of its own state inside the step loop
235/// without fighting a partial borrow.
236#[derive(Clone, Debug)]
237pub struct Steps {
238    next: u64,
239    remaining: u32,
240    dt: Timestep,
241}
242
243impl Iterator for Steps {
244    type Item = Step;
245
246    fn next(&mut self) -> Option<Step> {
247        if self.remaining == 0 {
248            return None;
249        }
250        self.remaining -= 1;
251        let tick = self.next;
252        self.next = tick.saturating_add(1);
253        let dt = self.dt.nanos().get();
254        Some(Step {
255            tick,
256            dt: Nanos::from_nanos(dt),
257            sim_time: Nanos::from_nanos(tick.saturating_mul(dt)),
258        })
259    }
260
261    fn size_hint(&self) -> (usize, Option<usize>) {
262        let remaining = usize::try_from(self.remaining).unwrap_or(usize::MAX);
263        (remaining, Some(remaining))
264    }
265}
266
267impl ExactSizeIterator for Steps {}
268
269impl core::iter::FusedIterator for Steps {}
270
271#[cfg(test)]
272mod tests {
273    use super::{FrameLoop, StepBudget, Timestamp, Timestep};
274    use crate::time::Nanos;
275    use core::num::{NonZeroU32, NonZeroU64};
276
277    /// 60 Hz in whole nanoseconds — the value every case below is written
278    /// against.
279    const DT: u64 = 16_666_667;
280
281    fn at(nanos: u64) -> Timestamp {
282        Timestamp::from_nanos(nanos)
283    }
284
285    fn timestep(nanos: u64) -> Timestep {
286        Timestep::from_nanos(NonZeroU64::new(nanos).expect("non-zero"))
287    }
288
289    fn budget(steps: u32) -> StepBudget {
290        StepBudget::new(NonZeroU32::new(steps).expect("non-zero"))
291    }
292
293    fn loop_at_60hz() -> FrameLoop {
294        FrameLoop::new(Timestep::HZ_60, StepBudget::DEFAULT, at(0))
295    }
296
297    #[test]
298    fn a_fresh_loop_reports_its_configuration_and_an_empty_schedule() {
299        let frame = loop_at_60hz();
300        assert_eq!(frame.timestep(), Timestep::HZ_60);
301        assert_eq!(frame.budget(), StepBudget::DEFAULT);
302        assert_eq!(frame.tick(), 0);
303        assert_eq!(frame.remainder(), Nanos::ZERO);
304        assert_eq!(frame.simulated(), Nanos::ZERO);
305    }
306
307    #[test]
308    fn no_elapsed_time_yields_no_steps() {
309        let mut frame = loop_at_60hz();
310        let plan = frame.begin_frame(at(0));
311        assert_eq!(plan.step_count(), 0);
312        assert_eq!(plan.dropped(), 0);
313        assert_eq!(plan.first_tick(), 0);
314        assert_eq!(plan.remainder(), Nanos::ZERO);
315        assert_eq!(plan.timestep(), Timestep::HZ_60);
316        assert_eq!(plan.steps().count(), 0);
317    }
318
319    #[test]
320    fn exactly_one_timestep_yields_one_step_and_an_empty_bank() {
321        let mut frame = loop_at_60hz();
322        let plan = frame.begin_frame(at(DT));
323        assert_eq!(plan.step_count(), 1);
324        assert_eq!(plan.remainder(), Nanos::ZERO);
325        assert_eq!(frame.tick(), 1);
326        assert_eq!(frame.simulated(), Nanos::from_nanos(DT));
327    }
328
329    #[test]
330    fn the_remainder_carries_across_frames() {
331        let mut frame = loop_at_60hz();
332        let first = frame.begin_frame(at(DT + 5));
333        assert_eq!(first.step_count(), 1);
334        assert_eq!(first.remainder(), Nanos::from_nanos(5));
335        // Five nanoseconds short of a step: the bank must be consulted,
336        // not thrown away.
337        let second = frame.begin_frame(at(DT + 5 + DT - 5));
338        assert_eq!(second.step_count(), 1);
339        assert_eq!(second.remainder(), Nanos::ZERO);
340        assert_eq!(frame.tick(), 2);
341    }
342
343    #[test]
344    fn sub_timestep_frames_bank_until_a_step_is_due() {
345        let mut frame = loop_at_60hz();
346        let half = DT / 2;
347        assert_eq!(frame.begin_frame(at(half)).step_count(), 0);
348        assert_eq!(frame.remainder(), Nanos::from_nanos(half));
349        // The timestep is odd, so two halves fall one nanosecond short.
350        assert_eq!(frame.begin_frame(at(2 * half)).step_count(), 0);
351        assert_eq!(frame.remainder(), Nanos::from_nanos(DT - 1));
352        assert_eq!(frame.begin_frame(at(2 * half + 1)).step_count(), 1);
353        assert_eq!(frame.remainder(), Nanos::ZERO);
354    }
355
356    #[test]
357    fn several_whole_timesteps_run_in_one_frame_while_the_budget_allows() {
358        let mut frame = loop_at_60hz();
359        let plan = frame.begin_frame(at(3 * DT + 7));
360        assert_eq!(plan.step_count(), 3);
361        assert_eq!(plan.dropped(), 0);
362        assert_eq!(plan.remainder(), Nanos::from_nanos(7));
363    }
364
365    /// The measured stall case: a 200 ms hitch at 60 Hz owes twelve steps
366    /// and the default budget runs five.
367    #[test]
368    fn a_stall_is_clamped_and_the_refused_steps_are_reported() {
369        let mut frame = loop_at_60hz();
370        let plan = frame.begin_frame(at(200_000_000));
371        assert_eq!(plan.step_count(), 5);
372        assert_eq!(plan.dropped(), 200_000_000 / DT - 5);
373        // Clamp-and-discard, not clamp-and-keep: the surplus is gone, so
374        // the very next frame starts from the sub-timestep remainder and
375        // the loop recovers immediately.
376        assert_eq!(plan.remainder(), Nanos::from_nanos(200_000_000 % DT));
377        let recovered = frame.begin_frame(at(200_000_000 + DT));
378        assert_eq!(recovered.step_count(), 1);
379        assert_eq!(recovered.dropped(), 0);
380    }
381
382    #[test]
383    fn a_saturated_bank_drops_billions_of_steps_without_wrapping() {
384        let mut frame = loop_at_60hz();
385        let plan = frame.begin_frame(at(u64::MAX));
386        assert_eq!(plan.step_count(), 5);
387        assert_eq!(plan.dropped(), u64::MAX / DT - 5);
388        assert_eq!(plan.remainder(), Nanos::from_nanos(u64::MAX % DT));
389        assert_eq!(frame.tick(), 5);
390    }
391
392    /// The refused count must be a `u64`: at one-nanosecond steps a
393    /// saturated bank owes more steps than a `u32` can name.
394    #[test]
395    fn the_refused_count_exceeds_the_thirty_two_bit_range() {
396        let mut frame = FrameLoop::new(timestep(1), budget(1), at(0));
397        let plan = frame.begin_frame(at(u64::MAX));
398        assert_eq!(plan.step_count(), 1);
399        assert_eq!(plan.dropped(), u64::MAX - 1);
400        assert!(plan.dropped() > u64::from(u32::MAX));
401    }
402
403    #[test]
404    fn a_backwards_clock_advances_nothing_and_leaves_the_bank_alone() {
405        let mut frame = loop_at_60hz();
406        let _ = frame.begin_frame(at(DT + 11));
407        let backwards = frame.begin_frame(at(1));
408        assert_eq!(backwards.step_count(), 0);
409        assert_eq!(backwards.dropped(), 0);
410        assert_eq!(backwards.remainder(), Nanos::from_nanos(11));
411        assert_eq!(frame.tick(), 1);
412        // The loop is now anchored at the backwards instant, so the next
413        // forward frame is measured from there — defined behaviour, not a
414        // wrapped `u64` worth 1.1 trillion phantom steps.
415        assert_eq!(frame.begin_frame(at(1 + DT)).step_count(), 1);
416    }
417
418    #[test]
419    fn resync_discards_the_gap_but_keeps_the_tick_and_the_remainder() {
420        let mut frame = loop_at_60hz();
421        let _ = frame.begin_frame(at(DT + 11));
422        assert_eq!(frame.tick(), 1);
423        // A ten-second pause the caller knows about.
424        frame.resync(at(10_000_000_000));
425        assert_eq!(frame.tick(), 1);
426        assert_eq!(frame.remainder(), Nanos::from_nanos(11));
427        let plan = frame.begin_frame(at(10_000_000_000 + DT - 11));
428        assert_eq!(plan.step_count(), 1, "the pause was not banked");
429        assert_eq!(plan.dropped(), 0);
430        assert_eq!(plan.remainder(), Nanos::ZERO);
431    }
432
433    #[test]
434    fn the_simulated_clock_is_tick_times_timestep_and_saturates() {
435        let half = u64::MAX / 2;
436        let mut frame = FrameLoop::new(timestep(half), budget(2), at(0));
437        let _ = frame.begin_frame(at(u64::MAX));
438        assert_eq!(frame.tick(), 2);
439        assert_eq!(frame.simulated(), Nanos::from_nanos(2 * half));
440
441        // Re-anchor at the origin (a backwards clock banks nothing) and
442        // run the timeline again. The tick count now outruns what
443        // `tick × dt` can represent, and both the loop's clock and the
444        // step's own `sim_time` saturate rather than wrapping.
445        let _ = frame.begin_frame(at(0));
446        let plan = frame.begin_frame(at(u64::MAX));
447        assert_eq!(plan.step_count(), 2);
448        assert_eq!(frame.tick(), 4);
449        assert_eq!(frame.simulated(), Nanos::from_nanos(u64::MAX));
450        let last = plan.steps().last().expect("two steps");
451        assert_eq!(last.tick, 3);
452        assert_eq!(last.sim_time, Nanos::from_nanos(u64::MAX));
453    }
454
455    /// A bank owing more steps than a `u32` can name still produces an
456    /// exact `u32` step count, because the budget bounds it first.
457    #[test]
458    fn a_due_count_beyond_the_thirty_two_bit_range_is_still_budget_bounded() {
459        let mut frame = FrameLoop::new(timestep(1), budget(u32::MAX), at(0));
460        let plan = frame.begin_frame(at(u64::MAX));
461        assert_eq!(plan.step_count(), u32::MAX);
462        assert_eq!(plan.dropped(), u64::MAX - u64::from(u32::MAX));
463        assert_eq!(frame.tick(), u64::from(u32::MAX));
464    }
465
466    #[test]
467    fn the_steps_of_a_plan_are_consecutive_ticks_with_exact_simulation_times() {
468        let mut frame = loop_at_60hz();
469        let _ = frame.begin_frame(at(DT));
470        let plan = frame.begin_frame(at(4 * DT));
471        assert_eq!(plan.first_tick(), 1);
472        let steps: Vec<_> = plan.steps().collect();
473        assert_eq!(steps.len(), 3);
474        for (offset, step) in steps.iter().enumerate() {
475            let tick = 1 + offset as u64;
476            assert_eq!(step.tick, tick);
477            assert_eq!(step.dt, Nanos::from_nanos(DT));
478            assert_eq!(step.sim_time, Nanos::from_nanos(tick * DT));
479        }
480        // The last step's start plus one timestep is where the loop's own
481        // clock now stands: the two definitions agree.
482        assert_eq!(frame.simulated(), Nanos::from_nanos(4 * DT));
483    }
484
485    #[test]
486    fn the_step_iterator_reports_its_exact_length_and_then_stays_empty() {
487        let mut frame = loop_at_60hz();
488        let plan = frame.begin_frame(at(2 * DT));
489        let mut steps = plan.steps();
490        assert_eq!(steps.len(), 2);
491        assert_eq!(steps.size_hint(), (2, Some(2)));
492        assert!(steps.next().is_some());
493        assert_eq!(steps.len(), 1);
494        assert!(steps.next().is_some());
495        assert_eq!(steps.len(), 0);
496        assert!(steps.next().is_none());
497        assert!(steps.next().is_none(), "fused");
498        // The plan is `Copy`, so asking again yields the same steps.
499        assert_eq!(plan.steps().count(), 2);
500    }
501
502    /// What this crate hands a renderer, now that the division lives in
503    /// `renew-math`: the exact rational, as two integers.
504    ///
505    /// Three tests stood here and asserted alpha's own behaviour — zero
506    /// on a boundary, one half between steps, and a rounding table
507    /// proving the clamp below one is mandatory rather than defensive.
508    /// They moved to `renew-math` beside the type, where they cover the
509    /// whole `(step, remainder)` domain instead of only the pairs a loop
510    /// can produce. What belongs here is the loop's half of the contract.
511    #[test]
512    fn the_remainder_is_the_exact_position_between_two_steps() {
513        let mut frame = loop_at_60hz();
514
515        // On a boundary: nothing pending, so nothing to interpolate.
516        let plan = frame.begin_frame(at(DT));
517        assert_eq!(plan.remainder(), Nanos::from_nanos(0));
518        assert_eq!(plan.timestep().nanos().get(), DT);
519
520        // Half a step past it, exactly — no rounding anywhere, because
521        // no division has happened yet.
522        let plan = frame.begin_frame(at(DT + DT / 2));
523        assert_eq!(plan.remainder(), Nanos::from_nanos(DT / 2));
524
525        // And one nanosecond short of a whole step, which is the pair
526        // that used to make a naive `f32` division return exactly one.
527        // Here it is just an integer, and an exact one.
528        for dt in [
529            16_666_667_u64,
530            4_166_667,
531            8_000_000,
532            33_333_333,
533            1_000_000_000,
534        ] {
535            let mut frame = FrameLoop::new(timestep(dt), budget(1), at(0));
536            let plan = frame.begin_frame(at(dt - 1));
537            assert_eq!(plan.remainder(), Nanos::from_nanos(dt - 1));
538            assert!(
539                plan.remainder().get() < plan.timestep().nanos().get(),
540                "the remainder must stay a proper fraction of the step"
541            );
542        }
543    }
544
545    /// The parity oracle for absorbing hello-engine's `Accumulator`: its
546    /// committed quick-start scenario — a fast frame, an exact frame, a
547    /// slow frame and a two-tick spike, cycled over sixty frames — driven
548    /// through this crate's semantics instead. The numbers asserted here
549    /// are the ones its README quotes as observed output, so the
550    /// absorption is output-preserving or this test says so.
551    ///
552    /// The two implementations are not equivalent in general: the old
553    /// `advance` executed every whole timestep in the bank and saturated
554    /// its *count* at `u32::MAX`, where this one clamps and discards. For
555    /// this pattern (at most two ticks per frame, budget five) the paths
556    /// coincide, which the drop count below asserts rather than assumes.
557    #[test]
558    fn the_absorbed_accumulator_reproduces_hello_engines_committed_output() {
559        let pattern = [15_000_000_u64, 16_666_667, 18_000_000, 33_333_334];
560        let mut frame = loop_at_60hz();
561        let mut now = 0u64;
562        let mut dropped = 0u64;
563        for delta in pattern.iter().copied().cycle().take(60) {
564            now += delta;
565            dropped += frame.begin_frame(at(now)).dropped();
566        }
567        assert_eq!(now, 1_245_000_015, "time submitted");
568        assert_eq!(frame.tick(), 74, "ticks executed");
569        assert_eq!(
570            frame.remainder(),
571            Nanos::from_nanos(11_666_657),
572            "time pending"
573        );
574        assert_eq!(dropped, 0, "the pattern never reaches the budget");
575        // Submitted time is accounted for exactly: every nanosecond either
576        // became a step or is still banked.
577        assert_eq!(frame.simulated().get() + frame.remainder().get(), now);
578    }
579}