aboutsummaryrefslogtreecommitdiff
path: root/simloop
diff options
context:
space:
mode:
Diffstat (limited to 'simloop')
-rw-r--r--simloop/CMakeLists.txt29
-rw-r--r--simloop/README.md99
-rw-r--r--simloop/include/simloop.h40
-rw-r--r--simloop/src/simloop.c78
-rw-r--r--simloop/test/simloop_test.c306
5 files changed, 552 insertions, 0 deletions
diff --git a/simloop/CMakeLists.txt b/simloop/CMakeLists.txt
new file mode 100644
index 0000000..2e0114b
--- /dev/null
+++ b/simloop/CMakeLists.txt
@@ -0,0 +1,29 @@
1cmake_minimum_required(VERSION 3.5)
2
3project(simloop)
4
5set(CMAKE_C_STANDARD 23)
6set(CMAKE_C_STANDARD_REQUIRED On)
7set(CMAKE_C_EXTENSIONS Off)
8
9add_library(simloop
10 include/simloop.h
11 src/simloop.c)
12
13target_include_directories(simloop PUBLIC
14 include)
15
16target_compile_options(simloop PRIVATE -Wall -Wextra -Wpedantic)
17
18# Test
19
20add_executable(simloop_test
21 test/simloop_test.c)
22
23target_link_libraries(simloop_test
24 simloop
25 ctest)
26
27target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -pedantic)
28
29add_test(NAME simloop_test COMMAND simloop_test --unittest)
diff --git a/simloop/README.md b/simloop/README.md
new file mode 100644
index 0000000..f45ca05
--- /dev/null
+++ b/simloop/README.md
@@ -0,0 +1,99 @@
1# Simulation loop module
2
3Simulation loop for games and graphics applications.
4
5## Features
6
7- Client retains control flow.
8- Client-controlled time axis.
9- Updates are frame-rate capped and use a fixed time step for determinism.
10- Rendering is optionally frame-rate capped.
11- Interpolation factor for smooth animation and rendering between frames.
12
13Control flow: the client steps the loop and then checks whether the simulation
14must be updated and/or the result rendered. Time readings are external to the
15library and provided by the client.
16
17## Invariants
18
19- An initial render of the initial application state is always triggered.
20- The same frame is not re-rendered if time does not advance.
21- Animation interpolation factor in [0,1].
22
23## Handling Time Spikes
24
25Generally, the simulation's update logic should be able to keep up with the
26requested frame rate; it is the application's responsibility to ensure this.
27Specifically, the frequency with which the application loops must be higher
28than the requested update frequency, given by the update delta time.
29
30However, occasional time spikes may occur, for example when switching to the
31desktop or when pausing the application in a debugger. The library handles this
32simply by requesting an update from the application. Under the assumption that
33the loop frequency is higher than the update frequency, the simulation will
34catch up with the real-time clock.
35
36### Time Spikes in Detail
37
38When a time spike occurs, the simulation clock falls significantly behind the
39real-time clock. Ideally, the simulation should be able to recover and catch up
40to the real-time clock when this occurs.
41
42Under a variable time delta, the loop could simply update the simulation with
43a large delta that puts the simulation back into the current clock time.
44Under a fixed time delta, this isn't possible, and we seem to have the
45following choices instead:
46
47- a) Queue as many updates as necessary to bring the simulation back to the
48 current clock time (time_difference / fixed_delta).
49- b) Queue a single update.
50- c) Some middle ground between the two.
51
52The issue with (a) is that, if the simulation is never able to catch up, then
53the number of requested updates at every loop iteration diverges and the
54simulation eventually appears to freeze.
55
56(b) only works if:
57
58- clock time added per iter < desired update delta time
59
60Where:
61
62- clock time added per iter = update time + render time + vsync + etc
63- desired delta time = 1 / update frequency
64
65If the clock time added per iteration is greater or equal to the desired delta,
66then the simulation can never "catch up" and recover from the spike.
67
68The middle ground is to perform only some number of updates in each loop
69iteration N. The simulation catches up only if:
70
71- clock time added per iter < N * desired update delta time
72
73The ideal value of N depends on how many frames the application can actually
74render. For example, if the application is vsync'ed to a 240hz monitor and is
75able to render that many frames, then:
76
77- N = ceil(1/240hz / desired update delta time)
78
79Realistically, the actual frame rate will be variable. Moreover, if we queued
80as many frames as possible, then we would risk the freeze in option (a) if the
81actual update time were too large for the application to catch up. So the
82library can only guess the value of N.
83
84The library picks a small constant value of N, implementation-defined, that the
85application can override.
86
87### Example: Spike Handling with Option (A)
88
89- desired delta = 10ms (100 fps)
90- actual delta = 20ms ( 50 fps)
91
92| iter | sim time | clock time | comment |
93|------|----------|------------|-------------------------------|
94| 0 | 0 | 0 | initial state |
95| 1 | 0 | 10 | queue 1 update |
96| 2 | 10 | 30 | queue (30-10)/10 = 2 updates |
97| 3 | 30 | 70 | queue (70-30)/10 = 4 updates |
98| 4 | 70 | 150 | queue (150-70)/10 = 8 updates |
99| ... |
diff --git a/simloop/include/simloop.h b/simloop/include/simloop.h
new file mode 100644
index 0000000..6a83b23
--- /dev/null
+++ b/simloop/include/simloop.h
@@ -0,0 +1,40 @@
1#pragma once
2
3#include <stdint.h>
4
5typedef uint64_t simloop_time_t; ///< Time delta in nanoseconds.
6
7typedef struct SimloopArgs {
8 int update_fps; ///< Update frame rate. Must be >0.
9 int max_render_fps; ///< Render frame rate cap. 0 to disable.
10} SimloopArgs;
11
12typedef struct SimloopOut {
13 uint64_t frame; ///< Frame counter.
14 simloop_time_t update_elapsed; ///< Amount of time elapsed in the simulation.
15 simloop_time_t update_dt; ///< Delta time for simulation updates.
16 simloop_time_t throttle; ///< Render throttle if max render fps is given.
17 double percent_frame; ///< Percent progress between this frame and
18 ///< the next. Used for smooth animation.
19 bool should_update; ///< Whether the simulation should update.
20} SimloopOut;
21
22typedef struct SimloopTimeline {
23 simloop_time_t ddt; ///< Desired delta time.
24 simloop_time_t time; ///< Time point of the last simulation step.
25} SimloopTimeline;
26
27typedef struct Simloop {
28 simloop_time_t clock; ///< Tracks simulation time.
29 uint64_t frame; ///< Frame counter, number of updates done.
30 SimloopTimeline update; ///< Update timeline.
31 simloop_time_t render_ddt; ///< Desired render delta time.
32} Simloop;
33
34/// Create a simulation loop.
35Simloop simloop_make(const SimloopArgs*);
36
37/// Step the simulation loop.
38///
39/// The simulation always triggers a render of the initial state of simulation.
40void simloop_update(Simloop*, simloop_time_t dt, SimloopOut*);
diff --git a/simloop/src/simloop.c b/simloop/src/simloop.c
new file mode 100644
index 0000000..4fa5f62
--- /dev/null
+++ b/simloop/src/simloop.c
@@ -0,0 +1,78 @@
1#include <simloop.h>
2
3#include <assert.h>
4
5static double min(double a, double b) { return a <= b ? a : b; }
6
7static simloop_time_t ddt_from_fps(int fps) {
8 static constexpr double NANOSECONDS = 1e9;
9 return (fps == 0) ? 0 : (simloop_time_t)(NANOSECONDS / (double)fps);
10}
11
12Simloop simloop_make(const SimloopArgs* args) {
13 assert(args);
14 assert(args->update_fps > 0);
15
16 return (Simloop){
17 .frame = 0,
18 .update =
19 (SimloopTimeline){
20 .ddt = ddt_from_fps(args->update_fps),
21 .time = 0,
22 },
23 .render_ddt = ddt_from_fps(args->max_render_fps),
24 };
25}
26
27void simloop_update(Simloop* sim, simloop_time_t dt, SimloopOut* out) {
28 assert(sim);
29 assert(out);
30
31 sim->clock += dt;
32
33 // Simulation update.
34 // If the simulation falls behind the clock, we advance by a single ddt
35 // increment per loop iteration here and give it a chance to catch up over
36 // subsequent iterations.
37 // This has the implication that percent_frame can fall out of range (>1) if
38 // we are not careful with how it is defined. See the logic below.
39 // If the delta is too large, then we simply warp the simulation to the wall
40 // clock. This avoids the appearance of the simulation playing in fast-forward
41 // as it tries to catch up. Large time spikes can typically occur at the start
42 // of the simulation when the application loads assets, compiles shaders, etc.
43 static const uint64_t max_catchup_frames = 10;
44 const simloop_time_t delta = sim->clock - sim->update.time;
45 const uint64_t delta_frames = delta / sim->update.ddt;
46 const bool update_this_tick = delta >= sim->update.ddt;
47 const bool warp = delta_frames > max_catchup_frames;
48 sim->update.time =
49 warp ? sim->clock
50 : (sim->update.time + (update_this_tick ? sim->update.ddt : 0));
51
52 // Loop-state update.
53 sim->frame += (update_this_tick ? 1 : 0);
54
55 // Interpolator for smooth animation.
56 // If the update falls behind the clock, then percent_frame can fall out of
57 // range (>1) if we are not careful. We impose that it is strictly never >1
58 // to account for this case.
59 assert(sim->update.ddt > 0);
60 assert(sim->update.time <= sim->clock);
61 out->percent_frame = min(
62 1., (double)(sim->clock - sim->update.time) / (double)sim->update.ddt);
63 assert((0. <= out->percent_frame) && (out->percent_frame <= 1.));
64
65 // Render frame rate throttle.
66 // Note that if no max render fps is given, then render_ddt is 0. The logic
67 // works for both render_ddt>0 and =0.
68 // Need to be careful with subtraction since the quantities are unsigned.
69 // Subtract an epsilon to account for delays in thread scheduling.
70 static const simloop_time_t eps = 50'000; // 50us
71 out->throttle =
72 (sim->render_ddt > (dt - eps)) ? (sim->render_ddt - eps - dt) : 0;
73
74 out->frame = sim->frame;
75 out->update_elapsed = sim->update.time;
76 out->update_dt = sim->update.ddt;
77 out->should_update = update_this_tick;
78}
diff --git a/simloop/test/simloop_test.c b/simloop/test/simloop_test.c
new file mode 100644
index 0000000..bcf9d57
--- /dev/null
+++ b/simloop/test/simloop_test.c
@@ -0,0 +1,306 @@
1#include <simloop.h>
2
3#include <test.h>
4
5#include <stdint.h>
6
7// -----------------------------------------------------------------------------
8// Time.
9
10static simloop_time_t time_delta_from_sec(double seconds) {
11 static constexpr double NANOS_PER_SEC = 1e9;
12 return (simloop_time_t)(seconds * NANOS_PER_SEC);
13}
14
15// -----------------------------------------------------------------------------
16// Randomness.
17
18typedef struct {
19 uint64_t a;
20} XorShift64State;
21
22static uint64_t xorshift64(XorShift64State* state) {
23 uint64_t x = state->a;
24 x ^= x << 7;
25 x ^= x >> 9;
26 return state->a = x;
27}
28
29// -----------------------------------------------------------------------------
30// Tests.
31
32/// At time/frame 0, no update is triggered (not enough time passed).
33TEST_CASE(simloop_initial_render) {
34 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = 10});
35 SimloopOut simout;
36
37 simloop_update(&simloop, 0, &simout);
38
39 TEST_TRUE(!simout.should_update);
40 TEST_EQUAL(simout.frame, 0);
41}
42
43/// The simulation is not updated if time does not advance.
44/// This applies generally to any time > 0.
45TEST_CASE(simloop_render_not_retriggered) {
46 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = 10});
47 SimloopOut simout;
48
49 // Advance time by some amount to get past t=0.
50 simloop_update(&simloop, 1, &simout);
51
52 // Now "advance" by 0.
53 const uint64_t frame_before = simout.frame;
54 simloop_update(&simloop, 0, &simout);
55 const uint64_t frame_after = simout.frame;
56
57 TEST_TRUE(!simout.should_update);
58 TEST_EQUAL(frame_before, frame_after);
59}
60
61/// A simulation loop with no render frame cap:
62/// 1. Updates based on the desired update frame rate.
63/// 2. Does not throttle rendering.
64TEST_CASE(simloop_no_render_frame_cap) {
65 constexpr int UPDATE_FPS = 10; // 100ms delta
66 const simloop_time_t UPDATE_DDT =
67 time_delta_from_sec(1.0 / (double)UPDATE_FPS);
68 const simloop_time_t STEP = time_delta_from_sec(0.05); // 50ms
69 const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30);
70
71 // We need simulation time to be an exact multiple of the desired deltas for
72 // the modulo comparison below.
73 TEST_TRUE((UPDATE_DDT % STEP) == 0);
74
75 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS});
76 SimloopOut simout;
77
78 simloop_update(&simloop, 0, &simout);
79 TEST_TRUE(!simout.should_update); // Time has not advanced.
80 TEST_EQUAL(simout.throttle, 0); // No throttling with no render frame cap.
81
82 for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) {
83 simloop_update(&simloop, STEP, &simout);
84 const bool expect_update = (t % UPDATE_DDT) == 0;
85 TEST_EQUAL(simout.should_update, expect_update);
86 TEST_EQUAL(simout.throttle, 0);
87 }
88}
89
90/// A simulation loop with a render frame cap:
91/// 1. Updates based on the desired update frame rate.
92/// 2. Throttles rendering based on the desired render frame rate.
93TEST_CASE(simloop_with_render_frame_cap) {
94 constexpr int UPDATE_FPS = 10; // 100ms delta
95 constexpr int RENDER_FPS = 5; // 200ms delta
96 const simloop_time_t UPDATE_DDT =
97 time_delta_from_sec(1.0 / (double)UPDATE_FPS);
98 const simloop_time_t RENDER_DDT =
99 time_delta_from_sec(1.0 / (double)RENDER_FPS);
100 const simloop_time_t STEP = time_delta_from_sec(0.1); // 100ms
101 const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30);
102
103 // We need simulation time to be an exact multiple of the desired deltas for
104 // the modulo comparisons below.
105 TEST_TRUE((UPDATE_DDT % STEP) == 0);
106
107 Simloop simloop = simloop_make(
108 &(SimloopArgs){.update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS});
109 SimloopOut simout;
110
111 simloop_update(&simloop, 0, &simout);
112 TEST_TRUE(!simout.should_update); // Time has not advanced.
113 TEST_EQUAL(simout.throttle, 0); // No throttle since time has not advanced.
114
115 for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) {
116 simloop_update(&simloop, STEP, &simout);
117 TEST_EQUAL(simout.should_update, (t % UPDATE_DDT) == 0);
118 TEST_NOTEQUAL(simout.throttle, 0);
119 }
120}
121
122/// If the update falls behind the clock, then percent_frame can fall out of
123/// range (>1) if we are not careful. This tests for this condition.
124TEST_CASE(simloop_percent_frame_01_large_jump) {
125 constexpr int UPDATE_FPS = 10; // 100ms delta
126 const simloop_time_t UPDATE_DDT =
127 time_delta_from_sec(1.0 / (double)UPDATE_FPS);
128 const simloop_time_t STEP = time_delta_from_sec(1);
129 const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30);
130
131 // We need simulation time to be an exact multiple of the desired deltas for
132 // the modulo comparison below.
133 TEST_TRUE((STEP % UPDATE_DDT) == 0);
134
135 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS});
136 SimloopOut simout;
137
138 simloop_update(&simloop, 0, &simout);
139 TEST_TRUE(!simout.should_update); // Time has not advanced.
140
141 for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) {
142 simloop_update(&simloop, STEP, &simout);
143 TEST_TRUE(simout.should_update); // Tries to catch up to clock.
144 TEST_TRUE(0. <= simout.percent_frame);
145 TEST_TRUE(simout.percent_frame <= 1.);
146 }
147}
148
149/// One benefit of fixed over variable time deltas is determinism. Test for
150/// this by getting to t=10 by different clock time increments.
151///
152/// Note that the time increments must be able to keep up with the desired frame
153/// delta, otherwise determinism is not maintained. We can guarantee determinism
154/// at the expense of re-introducing divergence.
155/// TODO: Perhaps the API should return an update count instead of a boolean,
156/// advance simulation time per the number of updates, then leave it up to
157/// the client to decide whether to update just once or as many times as
158/// requested, depending on whether they want determinism or convergence.
159TEST_CASE(simloop_determinism) {
160 constexpr int UPDATE_FPS = 100; // 10ms delta
161 const simloop_time_t RANDOM_STEPS[] = {
162 time_delta_from_sec(0.007), // 7ms
163 time_delta_from_sec(0.005), // 5ms
164 time_delta_from_sec(0.003), // 3ms
165 };
166 constexpr uint64_t NUM_RANDOM_STEPS =
167 sizeof(RANDOM_STEPS) / sizeof(RANDOM_STEPS[0]);
168 const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(10);
169 constexpr float ADD = 0.123f;
170
171 typedef struct Simulation {
172 int iter_count;
173 float sum;
174 } Simulation;
175
176#define UPDATE_SIMULATION(SIM) \
177 { \
178 SIM.sum += ADD; \
179 SIM.iter_count++; \
180 }
181
182 Simulation sim[2] = {0};
183 XorShift64State xss = (XorShift64State){12069019817132197873ULL};
184
185 // Perform two simulations with random clock-time steps.
186 for (int s = 0; s < 2; ++s) {
187 simloop_time_t dt = 0;
188 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS});
189 SimloopOut simout;
190
191 for (simloop_time_t t = 0; t <= SIM_DURATION_SEC;) {
192 simloop_update(&simloop, dt, &simout);
193
194 if (simout.should_update) {
195 UPDATE_SIMULATION(sim[s]);
196 }
197
198 // Advance time with a random step.
199 const simloop_time_t step =
200 RANDOM_STEPS[xorshift64(&xss) % NUM_RANDOM_STEPS];
201 t += step;
202 dt = step;
203 }
204 }
205
206 // Make sure the simulations have advanced by the same number of updates so
207 // that we can compare them. They may not have had the same update count
208 // depending on the clock-time steps.
209 while (sim[0].iter_count < sim[1].iter_count) {
210 UPDATE_SIMULATION(sim[0]);
211 }
212 while (sim[1].iter_count < sim[0].iter_count) {
213 UPDATE_SIMULATION(sim[1]);
214 }
215 TEST_EQUAL(sim[0].iter_count, sim[1].iter_count);
216
217 // The sums should be exactly equal if determinism holds.
218 // Check also that they are non-zero to make sure the simulation actually
219 // advanced.
220 TEST_TRUE(sim[0].sum > 0.f);
221 TEST_EQUAL(sim[0].sum, sim[1].sum);
222}
223
224/// The simulation loop attempts to catch up with the clock in the event of a
225/// time spike.
226///
227/// Catch-up is possible only if the simulation loops with a frequency higher
228/// than the requested update frequency given by the update delta time.
229///
230/// Catch-up is performed only for sufficiently small time spikes. For large
231/// time spikes, the simulation clock is warped. This test is for the small
232/// time spike case.
233static void simloop_catch_up(
234 struct test_case_metadata* metadata, int update_ddt_ms, int loop_step_ms,
235 bool expect_catchup) {
236 const int UPDATE_FPS = 1000 / update_ddt_ms;
237 const simloop_time_t UPDATE_DDT =
238 time_delta_from_sec(1.0 / (double)UPDATE_FPS);
239 const simloop_time_t STEP =
240 time_delta_from_sec((double)loop_step_ms / 1000.0);
241 const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30);
242 const int EXPECTED_TOTAL_FRAMES_WITH_CATCHUP =
243 (int)(SIM_DURATION_SEC / UPDATE_DDT);
244
245 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS});
246 SimloopOut simout;
247 int frames = 0;
248
249 // Simulate a time spike.
250 // Advance time to t=1s. That is a lag of 1,000ms / 100ms = 10 frames.
251 // 10 frames is the maximum allowed catch-up.
252 // The simulation now has 29s to catch up.
253 simloop_time_t dt = time_delta_from_sec(1);
254 for (simloop_time_t t = dt; t <= SIM_DURATION_SEC;) {
255 simloop_update(&simloop, dt, &simout);
256
257 if (simout.should_update) {
258 frames++;
259 }
260
261 // New delta is as usual.
262 dt = STEP;
263 t += dt;
264 }
265
266 if (expect_catchup) {
267 TEST_EQUAL(frames, EXPECTED_TOTAL_FRAMES_WITH_CATCHUP);
268 } else {
269 TEST_TRUE(frames < EXPECTED_TOTAL_FRAMES_WITH_CATCHUP);
270 }
271}
272/// (Loop frequency > update frequency) => successful catch-up.
273TEST_CASE(simloop_catch_up_success) {
274 constexpr int UPDATE_DDT_MS = 100;
275 constexpr int LOOP_DDT_MS = 10;
276 simloop_catch_up(metadata, UPDATE_DDT_MS, LOOP_DDT_MS, true);
277}
278/// (Loop frequency < update frequency) => failed catch-up.
279TEST_CASE(simloop_catch_up_failure) {
280 constexpr int UPDATE_DDT_MS = 10;
281 constexpr int LOOP_DDT_MS = 100;
282 simloop_catch_up(metadata, UPDATE_DDT_MS, LOOP_DDT_MS, false);
283}
284
285/// This tests the large time spike case, where the simulation clock is warped
286/// to the wall clock.
287TEST_CASE(simloop_warp) {
288 const int UPDATE_FPS = 50;
289 const simloop_time_t UPDATE_DDT =
290 time_delta_from_sec(1.0 / (double)UPDATE_FPS);
291
292 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS});
293 SimloopOut simout;
294
295 // The maximum allowed catch-up is 10 frames. Simulate a time spike larger
296 // than that.
297 const simloop_time_t TIME_SPIKE = UPDATE_DDT * 20;
298 simloop_update(&simloop, TIME_SPIKE, &simout);
299 TEST_TRUE(simout.should_update); // Warp should still request update.
300
301 // Now "advance" by 0.
302 simloop_update(&simloop, 0, &simout);
303 TEST_TRUE(!simout.should_update); // No more updates after warp.
304}
305
306int main() { return 0; }