aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author3gg <3gg@shellblade.net>2026-04-20 18:03:09 -0700
committer3gg <3gg@shellblade.net>2026-04-20 18:03:09 -0700
commitdadaf61c45d675f0e8b88fbc231748ad8247a736 (patch)
treee37af6f9accaa6b68ab76344ee4caa0ef6056a5d
parent5125d6788f7765a14fbcdeb6d4f6f67742c98596 (diff)
Percent frame interpolation factor for smooth animation
-rw-r--r--simloop/include/simloop.h17
-rw-r--r--simloop/src/simloop.c49
-rw-r--r--simloop/test/simloop_test.c108
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
64typedef struct SimloopTimeline { 69typedef 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
5static double min(double a, double b) { return a <= b ? a : b; }
6
5static simloop_time_t ddt_from_fps(int fps) { 7static 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
48TEST_CASE(simloop_initial_render_not_retriggered) { 48/// whether we are in the initial frame or a subsequent one.
49 Simloop simloop = 49void 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}
75TEST_CASE(simloop_render_not_retriggered_capped_initial_frame) {
76 simloop_render_not_retriggered(metadata, 10, true);
77}
78TEST_CASE(simloop_render_not_retriggered_unlimited_initial_frame) {
79 simloop_render_not_retriggered(metadata, 0, true);
80}
81TEST_CASE(simloop_render_not_retriggered_capped_subsequent_frame) {
82 simloop_render_not_retriggered(metadata, 10, false);
83}
84TEST_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.
71TEST_CASE(simloop_no_render_frame_cap) { 91TEST_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.
153TEST_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) {