diff options
| author | 3gg <3gg@shellblade.net> | 2026-04-20 18:03:09 -0700 |
|---|---|---|
| committer | 3gg <3gg@shellblade.net> | 2026-04-20 18:03:09 -0700 |
| commit | dadaf61c45d675f0e8b88fbc231748ad8247a736 (patch) | |
| tree | e37af6f9accaa6b68ab76344ee4caa0ef6056a5d | |
| parent | 5125d6788f7765a14fbcdeb6d4f6f67742c98596 (diff) | |
Percent frame interpolation factor for smooth animation
| -rw-r--r-- | simloop/include/simloop.h | 17 | ||||
| -rw-r--r-- | simloop/src/simloop.c | 49 | ||||
| -rw-r--r-- | simloop/test/simloop_test.c | 108 |
3 files changed, 126 insertions, 48 deletions
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 @@ | |||
| 5 | * simulation must be updated and/or the result rendered. | 5 | * simulation must be updated and/or the result rendered. |
| 6 | * | 6 | * |
| 7 | * The simulation is updated at a fixed time step given a desired frame rate. | 7 | * The simulation is updated at a fixed time step given a desired frame rate. |
| 8 | * Rendering frame rate can likewise be capped or be unlimited. In any case, the | 8 | * Rendering frame rate can likewise be capped or be unlimited. |
| 9 | * loop guarantees that the same frame is not rendered twice. | 9 | * In any case, an interpolation factor is computed for smooth animation between |
| 10 | * updates. | ||
| 11 | * The implementation also guarantees that the same frame is not rendered twice | ||
| 12 | * if time does not advance. | ||
| 10 | * | 13 | * |
| 11 | * Generally, the simulation's update logic should be able to keep up with the | 14 | * Generally, the simulation's update logic should be able to keep up with the |
| 12 | * requested frame rate; it is the application's responsibility to ensure this. | 15 | * requested frame rate; it is the application's responsibility to ensure this. |
| 13 | * Should the update logic not be able to keep up, then the loop requests a | 16 | * Should the update logic not be able to keep up, then the loop requests a |
| 14 | * single update per iteration, effectively "degrading" to match the update | 17 | * single update per iteration, effectively "degrading" to match the update |
| 15 | * logic frame rate, and giving the update logic a chance to catch up with | 18 | * logic frame rate, giving the update logic a chance to catch up with |
| 16 | * subsequent loop iterations. | 19 | * subsequent loop iterations. |
| 17 | * | 20 | * |
| 18 | * Under a variable time delta, the loop could simply update the simulation | 21 | * Under a variable time delta, the loop could simply update the simulation |
| @@ -57,8 +60,10 @@ typedef struct SimloopOut { | |||
| 57 | simloop_time_t render_elapsed; ///< Amount of time elapsed in the rendering. | 60 | simloop_time_t render_elapsed; ///< Amount of time elapsed in the rendering. |
| 58 | simloop_time_t update_elapsed; ///< Amount of time elapsed in the simulation. | 61 | simloop_time_t update_elapsed; ///< Amount of time elapsed in the simulation. |
| 59 | simloop_time_t update_dt; ///< Delta time for simulation updates. | 62 | simloop_time_t update_dt; ///< Delta time for simulation updates. |
| 60 | bool should_update; ///< Whether the simulation should update. | 63 | double percent_frame; ///< Percent progress between this frame and |
| 61 | bool should_render; ///< Whether the simulation should be rendered. | 64 | ///< the next. Used for smooth animation. |
| 65 | bool should_update; ///< Whether the simulation should update. | ||
| 66 | bool should_render; ///< Whether the simulation should be rendered. | ||
| 62 | } SimloopOut; | 67 | } SimloopOut; |
| 63 | 68 | ||
| 64 | typedef struct SimloopTimeline { | 69 | typedef struct SimloopTimeline { |
| @@ -71,8 +76,8 @@ typedef struct Simloop { | |||
| 71 | uint64_t frame; ///< Frame counter, number of updates done. | 76 | uint64_t frame; ///< Frame counter, number of updates done. |
| 72 | SimloopTimeline update; ///< Update timeline. | 77 | SimloopTimeline update; ///< Update timeline. |
| 73 | SimloopTimeline render; ///< Render timeline. | 78 | SimloopTimeline render; ///< Render timeline. |
| 79 | double percent_frame; | ||
| 74 | bool first_iter; | 80 | bool first_iter; |
| 75 | bool updates_since_last_render; | ||
| 76 | } Simloop; | 81 | } Simloop; |
| 77 | 82 | ||
| 78 | /// Create a simulation loop. | 83 | /// 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 @@ | |||
| 2 | 2 | ||
| 3 | #include <assert.h> | 3 | #include <assert.h> |
| 4 | 4 | ||
| 5 | static double min(double a, double b) { return a <= b ? a : b; } | ||
| 6 | |||
| 5 | static simloop_time_t ddt_from_fps(int fps) { | 7 | static simloop_time_t ddt_from_fps(int fps) { |
| 6 | static constexpr double NANOSECONDS = 1e9; | 8 | static constexpr double NANOSECONDS = 1e9; |
| 7 | return (fps == 0) ? 0 : (simloop_time_t)(NANOSECONDS / (double)fps); | 9 | return (fps == 0) ? 0 : (simloop_time_t)(NANOSECONDS / (double)fps); |
| @@ -23,8 +25,8 @@ Simloop simloop_make(const SimloopArgs* args) { | |||
| 23 | .ddt = ddt_from_fps(args->max_render_fps), | 25 | .ddt = ddt_from_fps(args->max_render_fps), |
| 24 | .time = 0, | 26 | .time = 0, |
| 25 | }, | 27 | }, |
| 26 | .first_iter = true, | 28 | .percent_frame = 0., |
| 27 | .updates_since_last_render = false, | 29 | .first_iter = true, |
| 28 | }; | 30 | }; |
| 29 | } | 31 | } |
| 30 | 32 | ||
| @@ -33,6 +35,12 @@ static bool step_update(const Simloop* sim, SimloopTimeline* timeline) { | |||
| 33 | assert(timeline); | 35 | assert(timeline); |
| 34 | assert(timeline->ddt > 0); | 36 | assert(timeline->ddt > 0); |
| 35 | 37 | ||
| 38 | // If the update falls behind the clock, we advance by a single ddt increment | ||
| 39 | // per loop iteration here and give it a chance to catch up over subsequent | ||
| 40 | // iterations. | ||
| 41 | // This has the implication that percent_frame can fall out of range (>1) if | ||
| 42 | // we are not careful with how it is defined. See the general update function | ||
| 43 | // below. | ||
| 36 | const simloop_time_t dt = sim->clock - timeline->time; | 44 | const simloop_time_t dt = sim->clock - timeline->time; |
| 37 | const bool should_step = dt >= timeline->ddt; | 45 | const bool should_step = dt >= timeline->ddt; |
| 38 | timeline->time += should_step ? timeline->ddt : 0; | 46 | timeline->time += should_step ? timeline->ddt : 0; |
| @@ -47,8 +55,8 @@ static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { | |||
| 47 | if (timeline->ddt > 0) { | 55 | if (timeline->ddt > 0) { |
| 48 | render = step_update(sim, timeline); | 56 | render = step_update(sim, timeline); |
| 49 | } else { | 57 | } else { |
| 58 | render = timeline->time < sim->clock; | ||
| 50 | timeline->time = sim->clock; | 59 | timeline->time = sim->clock; |
| 51 | render = true; | ||
| 52 | } | 60 | } |
| 53 | return render; | 61 | return render; |
| 54 | } | 62 | } |
| @@ -60,24 +68,39 @@ void simloop_update(Simloop* sim, simloop_time_t dt, SimloopOut* out) { | |||
| 60 | sim->clock += dt; | 68 | sim->clock += dt; |
| 61 | 69 | ||
| 62 | // Simulation update. | 70 | // Simulation update. |
| 63 | const bool updated = step_update(sim, &sim->update); | 71 | const bool update_this_tick = step_update(sim, &sim->update); |
| 64 | sim->updates_since_last_render = sim->updates_since_last_render || updated; | ||
| 65 | 72 | ||
| 66 | // Simulation render. | 73 | // Simulation render. |
| 67 | const bool rendered = | 74 | const bool render_this_tick = |
| 68 | (sim->updates_since_last_render && step_render(sim, &sim->render)) || | 75 | step_render(sim, &sim->render) || |
| 69 | (sim->first_iter); // Trigger an initial render on the first frame. | 76 | sim->first_iter; // Trigger an initial render on the first frame. |
| 70 | sim->updates_since_last_render = | 77 | |
| 71 | sim->updates_since_last_render && !out->should_render; | 78 | // Interpolator for smooth animation. |
| 79 | // If rendering is not frame-rate capped, then its timeline should always be | ||
| 80 | // at least as recent as the update's. Otherwise, it is possible for the | ||
| 81 | // rendering timeline to be behind. | ||
| 82 | // If the update falls behind the clock, then percent_frame can fall out of | ||
| 83 | // range (>1) if we are not careful. We impose that it is strictly never >1 | ||
| 84 | // to account for this case. | ||
| 85 | assert(sim->update.ddt > 0); | ||
| 86 | assert( | ||
| 87 | (sim->render.ddt == 0) ? (sim->update.time <= sim->render.time) : true); | ||
| 88 | sim->percent_frame = | ||
| 89 | (sim->render.time >= sim->update.time) | ||
| 90 | ? min(1., ((double)(sim->render.time - sim->update.time) / | ||
| 91 | (double)sim->update.ddt)) | ||
| 92 | : sim->percent_frame; | ||
| 93 | assert((0. <= sim->percent_frame) && (sim->percent_frame <= 1.)); | ||
| 72 | 94 | ||
| 73 | // Loop state update. | 95 | // Loop state update. |
| 74 | sim->frame += (updated ? 1 : 0); | 96 | sim->frame += (update_this_tick ? 1 : 0); |
| 75 | sim->first_iter = false; | 97 | sim->first_iter = false; |
| 76 | 98 | ||
| 77 | out->frame = sim->frame; | 99 | out->frame = sim->frame; |
| 78 | out->render_elapsed = sim->render.time; | 100 | out->render_elapsed = sim->render.time; |
| 79 | out->update_elapsed = sim->update.time; | 101 | out->update_elapsed = sim->update.time; |
| 80 | out->update_dt = sim->update.ddt; | 102 | out->update_dt = sim->update.ddt; |
| 81 | out->should_update = updated; | 103 | out->percent_frame = sim->percent_frame; |
| 82 | out->should_render = rendered; | 104 | out->should_update = update_this_tick; |
| 105 | out->should_render = render_this_tick; | ||
| 83 | } | 106 | } |
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) { | |||
| 43 | TEST_EQUAL(simout.frame, 0); | 43 | TEST_EQUAL(simout.frame, 0); |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | /// The initial render is not re-triggered if there is a render frame rate cap | 46 | /// A frame is not re-rendered if time does not advance. |
| 47 | /// and time does not advance. | 47 | /// This applies whether rendering is frame-rate capped or unlimited, and |
| 48 | TEST_CASE(simloop_initial_render_not_retriggered) { | 48 | /// whether we are in the initial frame or a subsequent one. |
| 49 | Simloop simloop = | 49 | void simloop_render_not_retriggered( |
| 50 | simloop_make(&(SimloopArgs){.update_fps = 10, .max_render_fps = 10}); | 50 | struct test_case_metadata* metadata, int max_render_fps, |
| 51 | bool initial_frame) { | ||
| 52 | Simloop simloop = simloop_make( | ||
| 53 | &(SimloopArgs){.update_fps = 10, .max_render_fps = max_render_fps}); | ||
| 51 | SimloopOut simout; | 54 | SimloopOut simout; |
| 52 | 55 | ||
| 53 | simloop_update(&simloop, 0, &simout); | 56 | simloop_update(&simloop, 0, &simout); |
| @@ -56,40 +59,58 @@ TEST_CASE(simloop_initial_render_not_retriggered) { | |||
| 56 | TEST_TRUE(!simout.should_update); | 59 | TEST_TRUE(!simout.should_update); |
| 57 | TEST_EQUAL(simout.frame, 0); | 60 | TEST_EQUAL(simout.frame, 0); |
| 58 | 61 | ||
| 62 | if (!initial_frame) { | ||
| 63 | // Advance time beyond the initial frame. | ||
| 64 | simloop_update(&simloop, 1, &simout); | ||
| 65 | } | ||
| 66 | |||
| 59 | for (int i = 0; i < 10; i++) { | 67 | for (int i = 0; i < 10; i++) { |
| 60 | // Note that time does not advance. | 68 | // Note that time does not advance here. |
| 61 | simloop_update(&simloop, 0, &simout); | 69 | simloop_update(&simloop, 0, &simout); |
| 62 | TEST_TRUE(!simout.should_render); | 70 | TEST_TRUE(!simout.should_render); |
| 63 | TEST_TRUE(!simout.should_update); | 71 | TEST_TRUE(!simout.should_update); |
| 64 | TEST_EQUAL(simout.frame, 0); | 72 | TEST_EQUAL(simout.frame, 0); |
| 65 | } | 73 | } |
| 66 | } | 74 | } |
| 75 | TEST_CASE(simloop_render_not_retriggered_capped_initial_frame) { | ||
| 76 | simloop_render_not_retriggered(metadata, 10, true); | ||
| 77 | } | ||
| 78 | TEST_CASE(simloop_render_not_retriggered_unlimited_initial_frame) { | ||
| 79 | simloop_render_not_retriggered(metadata, 0, true); | ||
| 80 | } | ||
| 81 | TEST_CASE(simloop_render_not_retriggered_capped_subsequent_frame) { | ||
| 82 | simloop_render_not_retriggered(metadata, 10, false); | ||
| 83 | } | ||
| 84 | TEST_CASE(simloop_render_not_retriggered_unlimited_subsequent_frame) { | ||
| 85 | simloop_render_not_retriggered(metadata, 0, false); | ||
| 86 | } | ||
| 67 | 87 | ||
| 68 | /// A simulation loop with no render frame cap: | 88 | /// A simulation loop with no render frame cap: |
| 69 | /// 1. Updates based on the desired update frame rate. | 89 | /// 1. Updates based on the desired update frame rate. |
| 70 | /// 2. Renders at every loop (provided there are updates). | 90 | /// 2. Renders at every step. |
| 71 | TEST_CASE(simloop_no_render_frame_cap) { | 91 | TEST_CASE(simloop_no_render_frame_cap) { |
| 72 | constexpr int UPDATE_FPS = 10; // 100ms delta | 92 | constexpr int UPDATE_FPS = 10; // 100ms delta |
| 73 | const simloop_time_t UPDATE_DDT = | 93 | const simloop_time_t UPDATE_DDT = |
| 74 | time_delta_from_sec(1.0 / (double)UPDATE_FPS); | 94 | time_delta_from_sec(1.0 / (double)UPDATE_FPS); |
| 75 | const simloop_time_t STEP = time_delta_from_sec(1); | 95 | const simloop_time_t STEP = time_delta_from_sec(0.05); // 50ms |
| 76 | const simloop_time_t SIM_TIME_SEC = time_delta_from_sec(30); | 96 | const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); |
| 77 | 97 | ||
| 78 | // We need simulation time to be an exact multiple of the desired deltas for | 98 | // We need simulation time to be an exact multiple of the desired deltas for |
| 79 | // the modulo comparisons below. | 99 | // the modulo comparison below. |
| 80 | TEST_TRUE((STEP % UPDATE_DDT) == 0); | 100 | TEST_TRUE((UPDATE_DDT % STEP) == 0); |
| 81 | 101 | ||
| 82 | simloop_time_t dt = 0; | ||
| 83 | Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); | 102 | Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); |
| 84 | SimloopOut simout; | 103 | SimloopOut simout; |
| 85 | 104 | ||
| 86 | for (simloop_time_t t = 0; t <= SIM_TIME_SEC; t += STEP) { | 105 | simloop_update(&simloop, 0, &simout); |
| 87 | simloop_update(&simloop, dt, &simout); | 106 | TEST_TRUE(!simout.should_update); // Time has not advanced. |
| 88 | const bool expect_update = (t > 0) && ((t % UPDATE_DDT) == 0); | 107 | TEST_TRUE(simout.should_render); // Initial render. |
| 89 | // A render is still expected at time 0. | 108 | |
| 90 | TEST_EQUAL(simout.should_render, (t == 0) || expect_update); | 109 | for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { |
| 110 | simloop_update(&simloop, STEP, &simout); | ||
| 111 | const bool expect_update = (t % UPDATE_DDT) == 0; | ||
| 91 | TEST_EQUAL(simout.should_update, expect_update); | 112 | TEST_EQUAL(simout.should_update, expect_update); |
| 92 | dt = STEP; | 113 | TEST_TRUE(simout.should_render); // Always renders. |
| 93 | } | 114 | } |
| 94 | } | 115 | } |
| 95 | 116 | ||
| @@ -103,25 +124,54 @@ TEST_CASE(simloop_with_render_frame_cap) { | |||
| 103 | time_delta_from_sec(1.0 / (double)UPDATE_FPS); | 124 | time_delta_from_sec(1.0 / (double)UPDATE_FPS); |
| 104 | const simloop_time_t RENDER_DDT = | 125 | const simloop_time_t RENDER_DDT = |
| 105 | time_delta_from_sec(1.0 / (double)RENDER_FPS); | 126 | time_delta_from_sec(1.0 / (double)RENDER_FPS); |
| 106 | const simloop_time_t STEP = time_delta_from_sec(0.1); // 100ms | 127 | const simloop_time_t STEP = time_delta_from_sec(0.1); // 100ms |
| 107 | const simloop_time_t SIM_TIME_SEC = time_delta_from_sec(30); | 128 | const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); |
| 108 | 129 | ||
| 109 | // We need simulation time to be an exact multiple of the desired deltas for | 130 | // We need simulation time to be an exact multiple of the desired deltas for |
| 110 | // the modulo comparisons below. | 131 | // the modulo comparisons below. |
| 111 | TEST_TRUE((UPDATE_DDT % STEP) == 0); | 132 | TEST_TRUE((UPDATE_DDT % STEP) == 0); |
| 112 | TEST_TRUE((RENDER_DDT % STEP) == 0); | 133 | TEST_TRUE((RENDER_DDT % STEP) == 0); |
| 113 | 134 | ||
| 114 | simloop_time_t dt = 0; | 135 | Simloop simloop = simloop_make( |
| 115 | Simloop simloop = simloop_make( | ||
| 116 | &(SimloopArgs){.update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS}); | 136 | &(SimloopArgs){.update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS}); |
| 117 | SimloopOut simout; | 137 | SimloopOut simout; |
| 118 | 138 | ||
| 119 | for (simloop_time_t t = 0; t <= SIM_TIME_SEC; t += STEP) { | 139 | simloop_update(&simloop, 0, &simout); |
| 120 | simloop_update(&simloop, dt, &simout); | 140 | TEST_TRUE(!simout.should_update); // Time has not advanced. |
| 141 | TEST_TRUE(simout.should_render); // Initial render. | ||
| 142 | |||
| 143 | for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { | ||
| 144 | simloop_update(&simloop, STEP, &simout); | ||
| 121 | // A render is still expected at time 0. | 145 | // A render is still expected at time 0. |
| 122 | TEST_EQUAL(simout.should_render, (t % RENDER_DDT) == 0); | 146 | TEST_EQUAL(simout.should_render, (t % RENDER_DDT) == 0); |
| 123 | TEST_EQUAL(simout.should_update, (t > 0) && ((t % UPDATE_DDT) == 0)); | 147 | TEST_EQUAL(simout.should_update, (t % UPDATE_DDT) == 0); |
| 124 | dt = STEP; | 148 | } |
| 149 | } | ||
| 150 | |||
| 151 | /// If the update falls behind the clock, then percent_frame can fall out of | ||
| 152 | /// range (>1) if we are not careful. This tests for this condition. | ||
| 153 | TEST_CASE(simloop_percent_frame_01_large_jump) { | ||
| 154 | constexpr int UPDATE_FPS = 10; // 100ms delta | ||
| 155 | const simloop_time_t UPDATE_DDT = | ||
| 156 | time_delta_from_sec(1.0 / (double)UPDATE_FPS); | ||
| 157 | const simloop_time_t STEP = time_delta_from_sec(1); | ||
| 158 | const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30); | ||
| 159 | |||
| 160 | // We need simulation time to be an exact multiple of the desired deltas for | ||
| 161 | // the modulo comparison below. | ||
| 162 | TEST_TRUE((STEP % UPDATE_DDT) == 0); | ||
| 163 | |||
| 164 | Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); | ||
| 165 | SimloopOut simout; | ||
| 166 | |||
| 167 | simloop_update(&simloop, 0, &simout); | ||
| 168 | TEST_TRUE(!simout.should_update); // Time has not advanced. | ||
| 169 | TEST_TRUE(simout.should_render); // Initial render. | ||
| 170 | |||
| 171 | for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { | ||
| 172 | simloop_update(&simloop, STEP, &simout); | ||
| 173 | TEST_TRUE(simout.should_update); // Tries to catch up to clock. | ||
| 174 | TEST_TRUE(simout.should_render); | ||
| 125 | } | 175 | } |
| 126 | } | 176 | } |
| 127 | 177 | ||
| @@ -144,8 +194,8 @@ TEST_CASE(simloop_determinism) { | |||
| 144 | }; | 194 | }; |
| 145 | constexpr uint64_t NUM_RANDOM_STEPS = | 195 | constexpr uint64_t NUM_RANDOM_STEPS = |
| 146 | sizeof(RANDOM_STEPS) / sizeof(RANDOM_STEPS[0]); | 196 | sizeof(RANDOM_STEPS) / sizeof(RANDOM_STEPS[0]); |
| 147 | const simloop_time_t SIM_TIME_SEC = time_delta_from_sec(10); | 197 | const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(10); |
| 148 | constexpr float ADD = 0.123f; | 198 | constexpr float ADD = 0.123f; |
| 149 | 199 | ||
| 150 | typedef struct Simulation { | 200 | typedef struct Simulation { |
| 151 | int iter_count; | 201 | int iter_count; |
| @@ -167,7 +217,7 @@ TEST_CASE(simloop_determinism) { | |||
| 167 | Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); | 217 | Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS}); |
| 168 | SimloopOut simout; | 218 | SimloopOut simout; |
| 169 | 219 | ||
| 170 | for (simloop_time_t t = 0; t <= SIM_TIME_SEC;) { | 220 | for (simloop_time_t t = 0; t <= SIM_DURATION_SEC;) { |
| 171 | simloop_update(&simloop, dt, &simout); | 221 | simloop_update(&simloop, dt, &simout); |
| 172 | 222 | ||
| 173 | if (simout.should_update) { | 223 | if (simout.should_update) { |
