aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author3gg <3gg@shellblade.net>2026-04-26 13:06:44 -0700
committer3gg <3gg@shellblade.net>2026-04-26 13:06:44 -0700
commit48422e313b31b79d76dd8f027b4d934994168859 (patch)
tree8235fe036e556ee3810d0a57846be7f666b1a894
parente014d3cc269c636767528f2cdd716fc6badcaa89 (diff)
Test for catch-up. Document
-rw-r--r--simloop/CMakeLists.txt2
-rw-r--r--simloop/README.md99
-rw-r--r--simloop/include/simloop.h46
-rw-r--r--simloop/test/simloop_test.c54
4 files changed, 154 insertions, 47 deletions
diff --git a/simloop/CMakeLists.txt b/simloop/CMakeLists.txt
index 528e65f..2e0114b 100644
--- a/simloop/CMakeLists.txt
+++ b/simloop/CMakeLists.txt
@@ -24,6 +24,6 @@ target_link_libraries(simloop_test
24 simloop 24 simloop
25 ctest) 25 ctest)
26 26
27target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -Wpedantic) 27target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -pedantic)
28 28
29add_test(NAME simloop_test COMMAND simloop_test --unittest) 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
index 7774c35..4e3ed20 100644
--- a/simloop/include/simloop.h
+++ b/simloop/include/simloop.h
@@ -1,49 +1,3 @@
1/* Simulation loop module.
2 *
3 * This implements a simulation loop but in a way that the client retains
4 * control flow. The client steps the loop and then checks whether the
5 * simulation must be updated and/or the result rendered.
6 *
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.
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.
13 *
14 * Generally, the simulation's update logic should be able to keep up with the
15 * requested frame rate; it is the application's responsibility to ensure this.
16 * Should the update logic not be able to keep up, then the loop requests a
17 * single update per iteration, effectively "degrading" to match the update
18 * logic frame rate, giving the update logic a chance to catch up with
19 * subsequent loop iterations.
20 *
21 * Under a variable time delta, the loop could simply update the simulation
22 * with a large delta that puts the simulation back into the current clock
23 * time. Under a fixed time delta this isn't possible, and we seem to have two
24 * choices instead:
25 *
26 * a) Queue as many updates as necessary to bring the simulation back to the
27 * current clock time (time_difference / fixed_delta).
28 *
29 * b) Queue a single update.
30 *
31 * The issue with (a) is that, if the simulation is never able to catch up, then
32 * the number of requested updates at every loop iteration diverges and
33 * eventually the simulation appears to freeze. Example:
34 *
35 * desired delta = 10ms (100 fps)
36 * actual delta = 20ms ( 50 fps)
37 * ---------------------------
38 * iter, sim time, clock time
39 * ---------------------------
40 * 0, 0, 0, initial state
41 * 1, 0, 10, queue 1 update
42 * 2, 10, 30, queue (30-10)/10 = 2 updates
43 * 3, 30, 70, queue (70-30)/10 = 4 updates
44 * 4, 70, 150, queue (150-70)/10 = 8 updates
45 * ...
46 */
47#pragma once 1#pragma once
48 2
49#include <stdint.h> 3#include <stdint.h>
diff --git a/simloop/test/simloop_test.c b/simloop/test/simloop_test.c
index 50e0852..790ad73 100644
--- a/simloop/test/simloop_test.c
+++ b/simloop/test/simloop_test.c
@@ -250,4 +250,58 @@ TEST_CASE(simloop_determinism) {
250 TEST_EQUAL(sim[0].sum, sim[1].sum); 250 TEST_EQUAL(sim[0].sum, sim[1].sum);
251} 251}
252 252
253/// The simulation loop attempts to catch up with the clock in the event of a
254/// time spike. This is possible only if the simulation loops with a frequency
255/// higher than the requested update frequency given by the update delta time.
256static void simloop_catch_up(
257 struct test_case_metadata* metadata, int update_ddt_ms, int loop_step_ms,
258 bool expect_catchup) {
259 const int UPDATE_FPS = 1000 / update_ddt_ms;
260 const simloop_time_t UPDATE_DDT =
261 time_delta_from_sec(1.0 / (double)UPDATE_FPS);
262 const simloop_time_t STEP =
263 time_delta_from_sec((double)loop_step_ms / 1000.0);
264 const simloop_time_t SIM_DURATION_SEC = time_delta_from_sec(30);
265 const int EXPECTED_TOTAL_FRAMES_WITH_CATCHUP =
266 (int)(SIM_DURATION_SEC / UPDATE_DDT);
267
268 Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = UPDATE_FPS});
269 SimloopOut simout;
270 int frames = 0;
271
272 // Simulate a time spike.
273 // Advance time to t=10s. That is a lag of 10,000ms / 100ms = 100 frames.
274 // The simulation now has 20s to catch up.
275 simloop_time_t dt = time_delta_from_sec(10);
276 for (simloop_time_t t = dt; t <= SIM_DURATION_SEC;) {
277 simloop_update(&simloop, dt, &simout);
278
279 if (simout.should_update) {
280 frames++;
281 }
282
283 // New delta is as usual.
284 dt = STEP;
285 t += dt;
286 }
287
288 if (expect_catchup) {
289 TEST_EQUAL(frames, EXPECTED_TOTAL_FRAMES_WITH_CATCHUP);
290 } else {
291 TEST_TRUE(frames < EXPECTED_TOTAL_FRAMES_WITH_CATCHUP);
292 }
293}
294/// (Loop frequency > update frequency) => successful catch-up.
295TEST_CASE(simloop_catch_up_success) {
296 constexpr int UPDATE_DDT_MS = 100;
297 constexpr int LOOP_DDT_MS = 10;
298 simloop_catch_up(metadata, UPDATE_DDT_MS, LOOP_DDT_MS, true);
299}
300/// (Loop frequency < update frequency) => failed catch-up.
301TEST_CASE(simloop_catch_up_failure) {
302 constexpr int UPDATE_DDT_MS = 10;
303 constexpr int LOOP_DDT_MS = 100;
304 simloop_catch_up(metadata, UPDATE_DDT_MS, LOOP_DDT_MS, false);
305}
306
253int main() { return 0; } 307int main() { return 0; }