diff options
Diffstat (limited to 'simloop')
| -rw-r--r-- | simloop/CMakeLists.txt | 29 | ||||
| -rw-r--r-- | simloop/README.md | 99 | ||||
| -rw-r--r-- | simloop/include/simloop.h | 40 | ||||
| -rw-r--r-- | simloop/src/simloop.c | 78 | ||||
| -rw-r--r-- | simloop/test/simloop_test.c | 306 |
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 @@ | |||
| 1 | cmake_minimum_required(VERSION 3.5) | ||
| 2 | |||
| 3 | project(simloop) | ||
| 4 | |||
| 5 | set(CMAKE_C_STANDARD 23) | ||
| 6 | set(CMAKE_C_STANDARD_REQUIRED On) | ||
| 7 | set(CMAKE_C_EXTENSIONS Off) | ||
| 8 | |||
| 9 | add_library(simloop | ||
| 10 | include/simloop.h | ||
| 11 | src/simloop.c) | ||
| 12 | |||
| 13 | target_include_directories(simloop PUBLIC | ||
| 14 | include) | ||
| 15 | |||
| 16 | target_compile_options(simloop PRIVATE -Wall -Wextra -Wpedantic) | ||
| 17 | |||
| 18 | # Test | ||
| 19 | |||
| 20 | add_executable(simloop_test | ||
| 21 | test/simloop_test.c) | ||
| 22 | |||
| 23 | target_link_libraries(simloop_test | ||
| 24 | simloop | ||
| 25 | ctest) | ||
| 26 | |||
| 27 | target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -pedantic) | ||
| 28 | |||
| 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 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 | |||
| 5 | typedef uint64_t simloop_time_t; ///< Time delta in nanoseconds. | ||
| 6 | |||
| 7 | typedef 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 | |||
| 12 | typedef 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 | |||
| 22 | typedef 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 | |||
| 27 | typedef 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. | ||
| 35 | Simloop simloop_make(const SimloopArgs*); | ||
| 36 | |||
| 37 | /// Step the simulation loop. | ||
| 38 | /// | ||
| 39 | /// The simulation always triggers a render of the initial state of simulation. | ||
| 40 | void 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 | |||
| 5 | static double min(double a, double b) { return a <= b ? a : b; } | ||
| 6 | |||
| 7 | static 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 | |||
| 12 | Simloop 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 | |||
| 27 | void 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 | |||
| 10 | static 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 | |||
| 18 | typedef struct { | ||
| 19 | uint64_t a; | ||
| 20 | } XorShift64State; | ||
| 21 | |||
| 22 | static 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). | ||
| 33 | TEST_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. | ||
| 45 | TEST_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. | ||
| 64 | TEST_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. | ||
| 93 | TEST_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. | ||
| 124 | TEST_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. | ||
| 159 | TEST_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. | ||
| 233 | static 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. | ||
| 273 | TEST_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. | ||
| 279 | TEST_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. | ||
| 287 | TEST_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 | |||
| 306 | int main() { return 0; } | ||
