diff options
| author | 3gg <3gg@shellblade.net> | 2026-04-26 13:06:44 -0700 |
|---|---|---|
| committer | 3gg <3gg@shellblade.net> | 2026-04-26 13:06:44 -0700 |
| commit | 48422e313b31b79d76dd8f027b4d934994168859 (patch) | |
| tree | 8235fe036e556ee3810d0a57846be7f666b1a894 | |
| parent | e014d3cc269c636767528f2cdd716fc6badcaa89 (diff) | |
Test for catch-up. Document
| -rw-r--r-- | simloop/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | simloop/README.md | 99 | ||||
| -rw-r--r-- | simloop/include/simloop.h | 46 | ||||
| -rw-r--r-- | simloop/test/simloop_test.c | 54 |
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 | ||
| 27 | target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -Wpedantic) | 27 | target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -pedantic) |
| 28 | 28 | ||
| 29 | add_test(NAME simloop_test COMMAND simloop_test --unittest) | 29 | add_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 | |||
| 3 | Simulation 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 | |||
| 13 | Control flow: the client steps the loop and then checks whether the simulation | ||
| 14 | must be updated and/or the result rendered. Time readings are external to the | ||
| 15 | library 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 | |||
| 25 | Generally, the simulation's update logic should be able to keep up with the | ||
| 26 | requested frame rate; it is the application's responsibility to ensure this. | ||
| 27 | Specifically, the frequency with which the application loops must be higher | ||
| 28 | than the requested update frequency, given by the update delta time. | ||
| 29 | |||
| 30 | However, occasional time spikes may occur, for example when switching to the | ||
| 31 | desktop or when pausing the application in a debugger. The library handles this | ||
| 32 | simply by requesting an update from the application. Under the assumption that | ||
| 33 | the loop frequency is higher than the update frequency, the simulation will | ||
| 34 | catch up with the real-time clock. | ||
| 35 | |||
| 36 | ### Time Spikes in Detail | ||
| 37 | |||
| 38 | When a time spike occurs, the simulation clock falls significantly behind the | ||
| 39 | real-time clock. Ideally, the simulation should be able to recover and catch up | ||
| 40 | to the real-time clock when this occurs. | ||
| 41 | |||
| 42 | Under a variable time delta, the loop could simply update the simulation with | ||
| 43 | a large delta that puts the simulation back into the current clock time. | ||
| 44 | Under a fixed time delta, this isn't possible, and we seem to have the | ||
| 45 | following 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 | |||
| 52 | The issue with (a) is that, if the simulation is never able to catch up, then | ||
| 53 | the number of requested updates at every loop iteration diverges and the | ||
| 54 | simulation eventually appears to freeze. | ||
| 55 | |||
| 56 | (b) only works if: | ||
| 57 | |||
| 58 | - clock time added per iter < desired update delta time | ||
| 59 | |||
| 60 | Where: | ||
| 61 | |||
| 62 | - clock time added per iter = update time + render time + vsync + etc | ||
| 63 | - desired delta time = 1 / update frequency | ||
| 64 | |||
| 65 | If the clock time added per iteration is greater or equal to the desired delta, | ||
| 66 | then the simulation can never "catch up" and recover from the spike. | ||
| 67 | |||
| 68 | The middle ground is to perform only some number of updates in each loop | ||
| 69 | iteration N. The simulation catches up only if: | ||
| 70 | |||
| 71 | - clock time added per iter < N * desired update delta time | ||
| 72 | |||
| 73 | The ideal value of N depends on how many frames the application can actually | ||
| 74 | render. For example, if the application is vsync'ed to a 240hz monitor and is | ||
| 75 | able to render that many frames, then: | ||
| 76 | |||
| 77 | - N = ceil(1/240hz / desired update delta time) | ||
| 78 | |||
| 79 | Realistically, the actual frame rate will be variable. Moreover, if we queued | ||
| 80 | as many frames as possible, then we would risk the freeze in option (a) if the | ||
| 81 | actual update time were too large for the application to catch up. So the | ||
| 82 | library can only guess the value of N. | ||
| 83 | |||
| 84 | The library picks a small constant value of N, implementation-defined, that the | ||
| 85 | application 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. | ||
| 256 | static 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. | ||
| 295 | TEST_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. | ||
| 301 | TEST_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 | |||
| 253 | int main() { return 0; } | 307 | int main() { return 0; } |
