From dadaf61c45d675f0e8b88fbc231748ad8247a736 Mon Sep 17 00:00:00 2001 From: 3gg <3gg@shellblade.net> Date: Mon, 20 Apr 2026 18:03:09 -0700 Subject: Percent frame interpolation factor for smooth animation --- simloop/include/simloop.h | 17 ++++--- simloop/src/simloop.c | 49 ++++++++++++++------ simloop/test/simloop_test.c | 108 ++++++++++++++++++++++++++++++++------------ 3 files changed, 126 insertions(+), 48 deletions(-) (limited to 'simloop') diff --git a/simloop/include/simloop.h b/simloop/include/simloop.h index c5a0372..7774c35 100644 --- a/simloop/include/simloop.h +++ b/simloop/include/simloop.h @@ -5,14 +5,17 @@ * simulation must be updated and/or the result rendered. * * The simulation is updated at a fixed time step given a desired frame rate. - * Rendering frame rate can likewise be capped or be unlimited. In any case, the - * loop guarantees that the same frame is not rendered twice. + * Rendering frame rate can likewise be capped or be unlimited. + * In any case, an interpolation factor is computed for smooth animation between + * updates. + * The implementation also guarantees that the same frame is not rendered twice + * if time does not advance. * * Generally, the simulation's update logic should be able to keep up with the * requested frame rate; it is the application's responsibility to ensure this. * Should the update logic not be able to keep up, then the loop requests a * single update per iteration, effectively "degrading" to match the update - * logic frame rate, and giving the update logic a chance to catch up with + * logic frame rate, giving the update logic a chance to catch up with * subsequent loop iterations. * * Under a variable time delta, the loop could simply update the simulation @@ -57,8 +60,10 @@ typedef struct SimloopOut { simloop_time_t render_elapsed; ///< Amount of time elapsed in the rendering. simloop_time_t update_elapsed; ///< Amount of time elapsed in the simulation. simloop_time_t update_dt; ///< Delta time for simulation updates. - bool should_update; ///< Whether the simulation should update. - bool should_render; ///< Whether the simulation should be rendered. + double percent_frame; ///< Percent progress between this frame and + ///< the next. Used for smooth animation. + bool should_update; ///< Whether the simulation should update. + bool should_render; ///< Whether the simulation should be rendered. } SimloopOut; typedef struct SimloopTimeline { @@ -71,8 +76,8 @@ typedef struct Simloop { uint64_t frame; ///< Frame counter, number of updates done. SimloopTimeline update; ///< Update timeline. SimloopTimeline render; ///< Render timeline. + double percent_frame; bool first_iter; - bool updates_since_last_render; } Simloop; /// Create a simulation loop. diff --git a/simloop/src/simloop.c b/simloop/src/simloop.c index bd5a72d..b8547fd 100644 --- a/simloop/src/simloop.c +++ b/simloop/src/simloop.c @@ -2,6 +2,8 @@ #include +static double min(double a, double b) { return a <= b ? a : b; } + static simloop_time_t ddt_from_fps(int fps) { static constexpr double NANOSECONDS = 1e9; return (fps == 0) ? 0 : (simloop_time_t)(NANOSECONDS / (double)fps); @@ -23,8 +25,8 @@ Simloop simloop_make(const SimloopArgs* args) { .ddt = ddt_from_fps(args->max_render_fps), .time = 0, }, - .first_iter = true, - .updates_since_last_render = false, + .percent_frame = 0., + .first_iter = true, }; } @@ -33,6 +35,12 @@ static bool step_update(const Simloop* sim, SimloopTimeline* timeline) { assert(timeline); assert(timeline->ddt > 0); + // If the update falls behind the clock, we advance by a single ddt increment + // per loop iteration here and give it a chance to catch up over subsequent + // iterations. + // This has the implication that percent_frame can fall out of range (>1) if + // we are not careful with how it is defined. See the general update function + // below. const simloop_time_t dt = sim->clock - timeline->time; const bool should_step = dt >= timeline->ddt; timeline->time += should_step ? timeline->ddt : 0; @@ -47,8 +55,8 @@ static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { if (timeline->ddt > 0) { render = step_update(sim, timeline); } else { + render = timeline->time < sim->clock; timeline->time = sim->clock; - render = true; } return render; } @@ -60,24 +68,39 @@ void simloop_update(Simloop* sim, simloop_time_t dt, SimloopOut* out) { sim->clock += dt; // Simulation update. - const bool updated = step_update(sim, &sim->update); - sim->updates_since_last_render = sim->updates_since_last_render || updated; + const bool update_this_tick = step_update(sim, &sim->update); // Simulation render. - const bool rendered = - (sim->updates_since_last_render && step_render(sim, &sim->render)) || - (sim->first_iter); // Trigger an initial render on the first frame. - sim->updates_since_last_render = - sim->updates_since_last_render && !out->should_render; + const bool render_this_tick = + step_render(sim, &sim->render) || + sim->first_iter; // Trigger an initial render on the first frame. + + // Interpolator for smooth animation. + // If rendering is not frame-rate capped, then its timeline should always be + // at least as recent as the update's. Otherwise, it is possible for the + // rendering timeline to be behind. + // If the update falls behind the clock, then percent_frame can fall out of + // range (>1) if we are not careful. We impose that it is strictly never >1 + // to account for this case. + assert(sim->update.ddt > 0); + assert( + (sim->render.ddt == 0) ? (sim->update.time <= sim->render.time) : true); + sim->percent_frame = + (sim->render.time >= sim->update.time) + ? min(1., ((double)(sim->render.time - sim->update.time) / + (double)sim->update.ddt)) + : sim->percent_frame; + assert((0. <= sim->percent_frame) && (sim->percent_frame <= 1.)); // Loop state update. - sim->frame += (updated ? 1 : 0); + sim->frame += (update_this_tick ? 1 : 0); sim->first_iter = false; out->frame = sim->frame; out->render_elapsed = sim->render.time; out->update_elapsed = sim->update.time; out->update_dt = sim->update.ddt; - out->should_update = updated; - out->should_render = rendered; + out->percent_frame = sim->percent_frame; + out->should_update = update_this_tick; + out->should_render = render_this_tick; } diff --git a/simloop/test/simloop_test.c b/simloop/test/simloop_test.c index 603a38c..50e0852 100644 --- a/simloop/test/simloop_test.c +++ b/simloop/test/simloop_test.c @@ -43,11 +43,14 @@ TEST_CASE(simloop_initial_render) { TEST_EQUAL(simout.frame, 0); } -/// The initial render is not re-triggered if there is a render frame rate cap -/// and time does not advance. -TEST_CASE(simloop_initial_render_not_retriggered) { - Simloop simloop = - simloop_make(&(SimloopArgs){.update_fps = 10, .max_render_fps = 10}); +/// A frame is not re-rendered if time does not advance. +/// This applies whether rendering is frame-rate capped or unlimited, and +/// whether we are in the initial frame or a subsequent one. +void simloop_render_not_retriggered( + struct test_case_metadata* metadata, int max_render_fps, + bool initial_frame) { + Simloop simloop = simloop_make( + &(SimloopArgs){.update_fps = 10, .max_render_fps = max_render_fps}); SimloopOut simout; simloop_update(&simloop, 0, &simout); @@ -56,40 +59,58 @@ TEST_CASE(simloop_initial_render_not_retriggered) { TEST_TRUE(!simout.should_update); TEST_EQUAL(simout.frame, 0); + if (!initial_frame) { + // Advance time beyond the initial frame. + simloop_update(&simloop, 1, &simout); + } + for (int i = 0; i < 10; i++) { - // Note that time does not advance. + // Note that time does not advance here. simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_render); TEST_TRUE(!simout.should_update); TEST_EQUAL(simout.frame, 0); } } +TEST_CASE(simloop_render_not_retriggered_capped_initial_frame) { + simloop_render_not_retriggered(metadata, 10, true); +} +TEST_CASE(simloop_render_not_retriggered_unlimited_initial_frame) { + simloop_render_not_retriggered(metadata, 0, true); +} +TEST_CASE(simloop_render_not_retriggered_capped_subsequent_frame) { + simloop_render_not_retriggered(metadata, 10, false); +} +TEST_CASE(simloop_render_not_retriggered_unlimited_subsequent_frame) { + simloop_render_not_retriggered(metadata, 0, false); +} /// A simulation loop with no render frame cap: /// 1. Updates based on the desired update frame rate. -/// 2. Renders at every loop (provided there are updates). +/// 2. Renders at every step. TEST_CASE(simloop_no_render_frame_cap) { constexpr int UPDATE_FPS = 10; // 100ms delta const simloop_time_t UPDATE_DDT = time_delta_from_sec(1.0 / (double)UPDATE_FPS); - const simloop_time_t STEP = time_delta_from_sec(1); - const simloop_time_t SIM_TIME_SEC = time_delta_from_sec(30); + const simloop_time_t STEP = time_delta_from_sec(0.05); // 50ms + const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); // We need simulation time to be an exact multiple of the desired deltas for - // the modulo comparisons below. - TEST_TRUE((STEP % UPDATE_DDT) == 0); + // the modulo comparison below. + TEST_TRUE((UPDATE_DDT % STEP) == 0); - simloop_time_t dt = 0; Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; - for (simloop_time_t t = 0; t <= SIM_TIME_SEC; t += STEP) { - simloop_update(&simloop, dt, &simout); - const bool expect_update = (t > 0) && ((t % UPDATE_DDT) == 0); - // A render is still expected at time 0. - TEST_EQUAL(simout.should_render, (t == 0) || expect_update); + simloop_update(&simloop, 0, &simout); + TEST_TRUE(!simout.should_update); // Time has not advanced. + TEST_TRUE(simout.should_render); // Initial render. + + for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { + simloop_update(&simloop, STEP, &simout); + const bool expect_update = (t % UPDATE_DDT) == 0; TEST_EQUAL(simout.should_update, expect_update); - dt = STEP; + TEST_TRUE(simout.should_render); // Always renders. } } @@ -103,25 +124,54 @@ TEST_CASE(simloop_with_render_frame_cap) { time_delta_from_sec(1.0 / (double)UPDATE_FPS); const simloop_time_t RENDER_DDT = time_delta_from_sec(1.0 / (double)RENDER_FPS); - const simloop_time_t STEP = time_delta_from_sec(0.1); // 100ms - const simloop_time_t SIM_TIME_SEC = time_delta_from_sec(30); + const simloop_time_t STEP = time_delta_from_sec(0.1); // 100ms + const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); // We need simulation time to be an exact multiple of the desired deltas for // the modulo comparisons below. TEST_TRUE((UPDATE_DDT % STEP) == 0); TEST_TRUE((RENDER_DDT % STEP) == 0); - simloop_time_t dt = 0; - Simloop simloop = simloop_make( + Simloop simloop = simloop_make( &(SimloopArgs){.update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS}); SimloopOut simout; - for (simloop_time_t t = 0; t <= SIM_TIME_SEC; t += STEP) { - simloop_update(&simloop, dt, &simout); + simloop_update(&simloop, 0, &simout); + TEST_TRUE(!simout.should_update); // Time has not advanced. + TEST_TRUE(simout.should_render); // Initial render. + + for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { + simloop_update(&simloop, STEP, &simout); // A render is still expected at time 0. TEST_EQUAL(simout.should_render, (t % RENDER_DDT) == 0); - TEST_EQUAL(simout.should_update, (t > 0) && ((t % UPDATE_DDT) == 0)); - dt = STEP; + TEST_EQUAL(simout.should_update, (t % UPDATE_DDT) == 0); + } +} + +/// If the update falls behind the clock, then percent_frame can fall out of +/// range (>1) if we are not careful. This tests for this condition. +TEST_CASE(simloop_percent_frame_01_large_jump) { + constexpr int UPDATE_FPS = 10; // 100ms delta + const simloop_time_t UPDATE_DDT = + time_delta_from_sec(1.0 / (double)UPDATE_FPS); + const simloop_time_t STEP = time_delta_from_sec(1); + const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); + + // We need simulation time to be an exact multiple of the desired deltas for + // the modulo comparison below. + TEST_TRUE((STEP % UPDATE_DDT) == 0); + + Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); + SimloopOut simout; + + simloop_update(&simloop, 0, &simout); + TEST_TRUE(!simout.should_update); // Time has not advanced. + TEST_TRUE(simout.should_render); // Initial render. + + for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { + simloop_update(&simloop, STEP, &simout); + TEST_TRUE(simout.should_update); // Tries to catch up to clock. + TEST_TRUE(simout.should_render); } } @@ -144,8 +194,8 @@ TEST_CASE(simloop_determinism) { }; constexpr uint64_t NUM_RANDOM_STEPS = sizeof(RANDOM_STEPS) / sizeof(RANDOM_STEPS[0]); - const simloop_time_t SIM_TIME_SEC = time_delta_from_sec(10); - constexpr float ADD = 0.123f; + const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(10); + constexpr float ADD = 0.123f; typedef struct Simulation { int iter_count; @@ -167,7 +217,7 @@ TEST_CASE(simloop_determinism) { Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); SimloopOut simout; - for (simloop_time_t t = 0; t <= SIM_TIME_SEC;) { + for (simloop_time_t t = 0; t <= SIM_DURATION_SEC;) { simloop_update(&simloop, dt, &simout); if (simout.should_update) { -- cgit v1.2.3