renew-frame 0.1.0

Fixed-timestep frame scheduling: the accumulator, the step budget, and the render interpolation factor
Documentation
  • Coverage
  • 100%
    17 out of 17 items documented1 out of 1 items with examples
  • Size
  • Source code size: 110.58 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.31 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 2s Average build duration of successful builds.
  • all releases: 4s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • renew-engine/renew
    5 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • CagdasErturk
renew-frame-0.1.0 has been yanked.

renew-frame

Fixed-timestep frame scheduling: the deterministic accumulator, the step budget that bounds a stall, and the interpolation factor for rendering between steps.

The loop is a passive integer state machine. It owns no loop, drives no application, knows nothing of rendering, GPUs or windows, and never reads a clock — it cannot, having no dependency that offers one. Its whole job is one total function:

let plan = frame.begin_frame(now);          // how many steps, how many refused, how far between
for step in plan.steps() { world.step(step); }
let alpha = plan.alpha();                   // render between steps with this
stats.absorb(&plan);

The caller reads the one clock, executes the steps, and renders. Every question about the operating-system loop, the RHI, removability and headless operation resolves the same way: the application owns those things and the loop never learns their names. That is why this crate compiles identically with the GPU crate deleted, with windowing compiled out, and with no GPU present — there is no edge to remove and not one #[cfg] in the crate.

  • Nanos / Timestamp — a duration and an instant, integer nanoseconds in separate newtypes, so passing one where the other belongs is a compile error rather than a simulation frozen forever.
  • Timestep / StepBudget — the fixed step (Timestep::HZ_60) and the most steps one frame may run (StepBudget::DEFAULT = 5). Both non-zero by type: no division can trap and no constructor can fail.
  • FrameLoop — the schedule. begin_frame(now), plus resync(now) for pauses the caller knows about.
  • FramePlan / Steps / Step / Alpha — what a frame must do. A Copy value that borrows nothing.
  • StateHash — FNV-1a-64 by explicit ordered absorption; the answer to "did two runs produce the same state".
  • FrameStats / FrameTiming — the deterministic tally and the measured timing, deliberately separate types, each with a JSON adapter.

Contract

  • Deterministic. For a fixed build and platform, FrameLoop is a pure function of (timestep, budget, start, the sequence of timestamps passed to begin_frame). It reads no clock, allocates nothing, spawns nothing, and holds no iteration-order-dependent state. A headless run supplies that sequence synthetically and is reproducible; a realtime run supplies a measured one, which is a different input trace, not nondeterministic code.
  • Nothing can fail. Non-zero types, a saturating bank and a saturating delta leave no error to report, so begin_frame returns no Result — an uninhabitable error variant would be a lie about the API. Nothing here panics and nothing unwinds.
  • The plan must be executed. A caller that ignores its plan silently desynchronizes the simulation from the tick counter, and that is unobservable from inside the loop. #[must_use] and the iterator shape are the mitigation; the guarantee is the caller's.
  • Clamp and discard, always reported. Steps beyond the budget are discarded, never banked — keeping the surplus is the spiral of death. Simulation time therefore falls permanently behind the wall clock, and FramePlan::dropped() is the exact, non-optional record of by how much. A frame with a nonzero drop count is a measurable budget violation.
  • alpha is never an input to simulation. It is a render-side hint in [0, 1), and it is deliberately excluded from the schedule digest — it is a pure function of the remainder and the timestep, both of which are digested. remainder() and timestep() stay public so an exact consumer never goes through the float.
  • Zero dependencies, and this crate never logs. A dropped step is reported through the returned plan; whether that becomes a log line is the caller's decision.

Binding to a windowing seam

Two properties of an inverted-control window loop that a reader will otherwise get wrong:

  • The render lags the step phase by one iteration. A redraw requested from the update callback arrives on the next iteration, so the frame that draws consumes the alpha stored by the previous update. Harmless — alpha is a hint, and an operating-system repaint with no intervening update correctly re-renders at the same alpha — but it must be stated or someone will "fix" it.
  • Close requests are latched, not acted on. The event callback has no control handle, so a close request is recorded and acted on in the next update.

Anchor the schedule after bring-up. Device creation costs on the order of 100 ms and must not be banked as frame one, or the loop opens with a clamped burst and a drop count that means nothing.

A stall needs no loop knowledge and that is the payoff: a slow present, a timeout, a driver hitch — the frame took 200 ms, the next begin_frame sees a 200 ms delta, the budget clamps, and dropped() reports the deficit (measured: due 11, run 5, dropped 6). After a stall the caller knows was not real time — a breakpoint, a load — it calls resync. A dormant window is per-application policy in one line at the call site: keep stepping and skip the render, or return before begin_frame and resync on resume.

Why integer nanoseconds

With f32 seconds the banked time accumulates representation error and the step count becomes a function of rounding history. With u64 every operation is exact, so the step count is a pure integer function of the input sequence.

Where it strains, stated rather than hidden: 60 Hz is not representable — 60 × 16_666_667 = 1_000_000_020, so sixty ticks run 20 ns long against the wall. That is closed by definition rather than by rounding. Step::sim_time and FrameLoop::simulated() are tick × dt, so the simulation's own clock is exact by construction and the 20 ns is a property of the wall clock's relation to the simulation, never of the simulation's own arithmetic.

The one float in the crate is Alpha, derived and clamped in one place. The clamp is mandatory, not defensive: a naive rem as f32 / dt as f32 returns exactly 1.0 at 30 Hz, and even with an f64 intermediate the 1 Hz case still rounds up to 1.0. An alpha of 1.0 is a renderer popping a full tick ahead of the state it interpolates from.

Testing note

The simulation regime applies. Unit tests cover the accumulator, the budget, resynchronization and saturation at both ends; property tests cover the conservation law (every submitted nanosecond is executed, dropped, or banked), totality over the whole 64-bit domain, and the alpha bound over every (timestep, remainder) pair. tests/determinism.rs carries the evidence: eight in-process runs of one hostile trace, a frozen digest, a negative control that perturbs the anchor by one nanosecond, and a fixed-point reference world stepped by the plans. Every one of those asserts the trace was not vacuous — that it executed steps, engaged the budget, and moved the digest — before it compares anything. tests/zero_alloc.rs pins the allocation contract with a counting global allocator.

Deliberately not applicable, each for one reason: fuzzing (no parser of external data — the timestamp is an in-process u64), thread-sanitizer and stress testing (the crate spawns no threads and is not shared across them), and Miri (no unsafe anywhere in the crate).

Status

Early-stage. The [package.metadata.renew] table in Cargo.toml is authoritative for maturity and all manifest metadata. The crate's contract lints live in clippy.toml: clock reads, thread spawning, filesystem access and randomly seeded hash containers are rejected at lint time, because this is the tree's first crate designated as simulation code and one Instant::now inside begin_frame would destroy determinism for every consumer with no test failing anywhere until a replay diverged months later.

extension_points = [] is honest: no trait, no dyn, no runtime polymorphism. The growth point is named rather than pre-built — a trait arrives when a second implementation exists.

Key decisions

  • The loop plans, it does not drive. A host trait with in-crate windowed and headless runners was considered and rejected: the windowed host must read a clock, which forfeits the simulation designation on the one crate that needs it, and its only justification is a small amount of sample glue.
  • Absolute timestamps, not deltas. One subtraction in one place; a backwards clock becomes zero instead of 1.1 trillion phantom steps; the first-frame branch disappears into the constructor's start; and resync is trivially correct.
  • One guard, not two. An elapsed-time clamp in front of the budget only changes how much time is discarded versus reported, destroying the information about how big the hitch was in exchange for a second knob, a second branch and a second coverage obligation.
  • Two report types. A single type hashing all its fields would absorb measured wall time into the determinism digest — silently, since the gate would simply never go green and someone would "fix" it by loosening the comparison.
  • Hand-rolled FNV-1a-64. RandomState is seeded per process and can never back a cross-run claim; SipHasher13 has no cross-version stability guarantee; #[derive(Hash)] absorbs in declaration order implicitly, so reordering two fields would silently change every digest.

Known gaps

  • Percentiles are absent from FrameTiming: p50/p99 need a reservoir or a histogram, which is a real design. Count, minimum, maximum and sum are enough for a first baseline.
  • Step execution is unenforced. A passive plan cannot prove the caller ran its steps; #[must_use] and the iterator shape are the mitigation.
  • Nothing here paces frames. No sleeping, no vsync targeting, no render-rate limiter. A caller that polls without waiting busy-spins; fixing that needs a platform addition, not a loop change.