diff options
| author | 3gg <3gg@shellblade.net> | 2026-04-11 12:27:23 -0700 |
|---|---|---|
| committer | 3gg <3gg@shellblade.net> | 2026-04-11 12:27:23 -0700 |
| commit | 879e5af4eb1a8972fc944853a515e1003f94bd7c (patch) | |
| tree | 136864b506276998047374c28e5b24e3a5463b39 /simloop | |
| parent | af26c0503d698b7824055dc98207d6252d9875cf (diff) | |
Fix simloop divergence
Diffstat (limited to 'simloop')
| -rw-r--r-- | simloop/include/simloop.h | 38 | ||||
| -rw-r--r-- | simloop/src/simloop.c | 32 | ||||
| -rw-r--r-- | simloop/test/simloop_test.c | 72 |
3 files changed, 105 insertions, 37 deletions
diff --git a/simloop/include/simloop.h b/simloop/include/simloop.h index f267d40..6ee3b98 100644 --- a/simloop/include/simloop.h +++ b/simloop/include/simloop.h | |||
| @@ -3,6 +3,37 @@ | |||
| 3 | * This implements a simulation loop but in a way that the client retains | 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 | 4 | * control flow. The client steps the loop and then checks whether the |
| 5 | * simulation must be updated and/or the result rendered. | 5 | * simulation must be updated and/or the result rendered. |
| 6 | * | ||
| 7 | * If the simulation's update cannot keep up with the desired frame rate, then | ||
| 8 | * the loop degrades to match the simulation's rate by requesting a single | ||
| 9 | * update. | ||
| 10 | * | ||
| 11 | * Under a variable time delta, the loop could simply update the simulation | ||
| 12 | * with a large delta that puts the simulation back into the current clock | ||
| 13 | * time. Under a fixed time delta this isn't possible, and we seem to have two | ||
| 14 | * choices instead: | ||
| 15 | * | ||
| 16 | * a) Queue as many updates as necessary to bring the simulation back to the | ||
| 17 | * current clock time (time_diff / fixed_delta). | ||
| 18 | * | ||
| 19 | * b) Queue a single update. | ||
| 20 | * | ||
| 21 | * The issue with (a) is that the number of requested updates diverges and | ||
| 22 | * eventually the simulation appears to freeze. At every loop, the number of | ||
| 23 | * queued updates increases with respect to the last iteration as the | ||
| 24 | * simulation fails to keep up with the desired frame rate. Example: | ||
| 25 | * | ||
| 26 | * desired delta = 10ms (100 fps) | ||
| 27 | * actual delta = 20ms ( 50 fps) | ||
| 28 | * --------------------------- | ||
| 29 | * iter, sim time, clock time | ||
| 30 | * --------------------------- | ||
| 31 | * 0, 0, 0, initial state | ||
| 32 | * 1, 0, 10, queue 1 update | ||
| 33 | * 2, 10, 30, queue (30-10)/10 = 2 updates | ||
| 34 | * 3, 30, 70, queue (70-30)/10 = 4 updates | ||
| 35 | * 4, 70, 150, queue (150-70)/10 = 8 updates | ||
| 36 | * ... | ||
| 6 | */ | 37 | */ |
| 7 | #pragma once | 38 | #pragma once |
| 8 | 39 | ||
| @@ -21,8 +52,8 @@ typedef struct SimloopOut { | |||
| 21 | time_delta render_elapsed; ///< Amount of time elapsed in the rendering. | 52 | time_delta render_elapsed; ///< Amount of time elapsed in the rendering. |
| 22 | time_delta update_elapsed; ///< Amount of time elapsed in the simulation. | 53 | time_delta update_elapsed; ///< Amount of time elapsed in the simulation. |
| 23 | time_delta update_dt; ///< Delta time for simulation updates. | 54 | time_delta update_dt; ///< Delta time for simulation updates. |
| 24 | int updates_pending; ///< Number of frames the simulation should produce. | 55 | bool should_update; ///< Whether the simulation should update. |
| 25 | bool should_render; ///< Whether the simulation should be rendered. | 56 | bool should_render; ///< Whether the simulation should be rendered. |
| 26 | } SimloopOut; | 57 | } SimloopOut; |
| 27 | 58 | ||
| 28 | typedef struct SimloopTimeline { | 59 | typedef struct SimloopTimeline { |
| @@ -35,10 +66,13 @@ typedef struct Simloop { | |||
| 35 | SimloopTimeline render; ///< Render timeline. | 66 | SimloopTimeline render; ///< Render timeline. |
| 36 | uint64_t frame; ///< Frame counter. | 67 | uint64_t frame; ///< Frame counter. |
| 37 | Timer* timer; | 68 | Timer* timer; |
| 69 | bool first_iter; | ||
| 38 | } Simloop; | 70 | } Simloop; |
| 39 | 71 | ||
| 40 | /// Create a simulation loop. | 72 | /// Create a simulation loop. |
| 41 | Simloop simloop_make(const SimloopArgs*); | 73 | Simloop simloop_make(const SimloopArgs*); |
| 42 | 74 | ||
| 43 | /// Step the simulation loop. | 75 | /// Step the simulation loop. |
| 76 | /// | ||
| 77 | /// The simulation always triggers a render of the initial state of simulation. | ||
| 44 | void simloop_update(Simloop*, SimloopOut*); | 78 | void simloop_update(Simloop*, SimloopOut*); |
diff --git a/simloop/src/simloop.c b/simloop/src/simloop.c index 11f4d6d..aa2b6b7 100644 --- a/simloop/src/simloop.c +++ b/simloop/src/simloop.c | |||
| @@ -12,12 +12,13 @@ Simloop simloop_make(const SimloopArgs* args) { | |||
| 12 | assert(args->update_fps > 0); | 12 | assert(args->update_fps > 0); |
| 13 | 13 | ||
| 14 | return (Simloop){ | 14 | return (Simloop){ |
| 15 | .frame = 0, | 15 | .frame = 0, |
| 16 | .update = (SimloopTimeline){.ddt = ddt_from_fps(args->update_fps), | 16 | .update = (SimloopTimeline){.ddt = ddt_from_fps(args->update_fps), |
| 17 | .last_step = args->timer->start_time}, | 17 | .last_step = args->timer->start_time}, |
| 18 | .render = (SimloopTimeline){ .ddt = ddt_from_fps(args->max_render_fps), | 18 | .render = (SimloopTimeline){ .ddt = ddt_from_fps(args->max_render_fps), |
| 19 | .last_step = args->timer->start_time}, | 19 | .last_step = args->timer->start_time}, |
| 20 | .timer = args->timer, | 20 | .timer = args->timer, |
| 21 | .first_iter = true, | ||
| 21 | }; | 22 | }; |
| 22 | } | 23 | } |
| 23 | 24 | ||
| @@ -26,15 +27,16 @@ static time_delta time_elapsed(const Simloop* sim, time_point t) { | |||
| 26 | return time_diff(sim->timer->start_time, t); | 27 | return time_diff(sim->timer->start_time, t); |
| 27 | } | 28 | } |
| 28 | 29 | ||
| 29 | static int step_update(const Simloop* sim, SimloopTimeline* timeline) { | 30 | static bool step_update(const Simloop* sim, SimloopTimeline* timeline) { |
| 30 | assert(sim); | 31 | assert(sim); |
| 31 | assert(timeline); | 32 | assert(timeline); |
| 32 | assert(timeline->ddt > 0); | 33 | assert(timeline->ddt > 0); |
| 33 | 34 | ||
| 34 | const time_delta dt = time_diff(timeline->last_step, sim->timer->last_tick); | 35 | const time_delta dt = time_diff(timeline->last_step, sim->timer->last_tick); |
| 35 | const time_delta steps = dt / timeline->ddt; | 36 | const bool should_step = dt >= timeline->ddt; |
| 36 | timeline->last_step = time_add(timeline->last_step, dt); | 37 | timeline->last_step = |
| 37 | return (int)steps; | 38 | should_step ? time_add(timeline->last_step, dt) : timeline->last_step; |
| 39 | return should_step; | ||
| 38 | } | 40 | } |
| 39 | 41 | ||
| 40 | static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { | 42 | static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { |
| @@ -43,7 +45,7 @@ static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { | |||
| 43 | 45 | ||
| 44 | bool render = false; | 46 | bool render = false; |
| 45 | if (timeline->ddt > 0) { | 47 | if (timeline->ddt > 0) { |
| 46 | render = step_update(sim, timeline) > 0; | 48 | render = step_update(sim, timeline); |
| 47 | } else { | 49 | } else { |
| 48 | timeline->last_step = sim->timer->last_tick; | 50 | timeline->last_step = sim->timer->last_tick; |
| 49 | render = true; | 51 | render = true; |
| @@ -55,12 +57,12 @@ void simloop_update(Simloop* sim, SimloopOut* out) { | |||
| 55 | assert(sim); | 57 | assert(sim); |
| 56 | assert(out); | 58 | assert(out); |
| 57 | 59 | ||
| 58 | const int new_frames = step_update(sim, &sim->update); | 60 | out->should_update = step_update(sim, &sim->update); |
| 59 | out->updates_pending = new_frames; | ||
| 60 | out->should_render = | 61 | out->should_render = |
| 61 | step_render(sim, &sim->render) || | 62 | step_render(sim, &sim->render) || |
| 62 | (sim->frame == 0); // Trigger an initial render on the first frame. | 63 | (sim->first_iter); // Trigger an initial render on the first frame. |
| 63 | sim->frame += new_frames; | 64 | sim->frame += (out->should_update ? 1 : 0); |
| 65 | sim->first_iter = false; | ||
| 64 | out->frame = sim->frame; | 66 | out->frame = sim->frame; |
| 65 | out->render_elapsed = time_elapsed(sim, sim->render.last_step); | 67 | out->render_elapsed = time_elapsed(sim, sim->render.last_step); |
| 66 | out->update_elapsed = time_elapsed(sim, sim->update.last_step); | 68 | out->update_elapsed = time_elapsed(sim, sim->update.last_step); |
diff --git a/simloop/test/simloop_test.c b/simloop/test/simloop_test.c index 3f2aa46..c79ee32 100644 --- a/simloop/test/simloop_test.c +++ b/simloop/test/simloop_test.c | |||
| @@ -3,57 +3,89 @@ | |||
| 3 | #include <test.h> | 3 | #include <test.h> |
| 4 | #include <timer.h> | 4 | #include <timer.h> |
| 5 | 5 | ||
| 6 | /// An initial render should always trigger on frame 0. | 6 | /// At time/frame 0: |
| 7 | /// 1. An initial render is always triggered. | ||
| 8 | /// 2. No update is triggered (not enough time passed). | ||
| 7 | TEST_CASE(simloop_initial_render) { | 9 | TEST_CASE(simloop_initial_render) { |
| 8 | constexpr int UPDATE_FPS = 10; | 10 | Timer timer = {}; |
| 11 | Simloop simloop = simloop_make( | ||
| 12 | &(SimloopArgs){.update_fps = 10, .max_render_fps = 0, .timer = &timer}); | ||
| 13 | SimloopOut simout; | ||
| 9 | 14 | ||
| 10 | Timer timer = {}; | 15 | simloop_update(&simloop, &simout); |
| 11 | Simloop simloop = simloop_make(&(SimloopArgs){ | 16 | |
| 12 | .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); | 17 | TEST_TRUE(simout.should_render); |
| 18 | TEST_TRUE(!simout.should_update); | ||
| 19 | TEST_EQUAL(simout.frame, 0); | ||
| 20 | } | ||
| 21 | |||
| 22 | /// The initial render is not re-triggered if there is a render frame rate cap | ||
| 23 | /// and time does not advance. | ||
| 24 | TEST_CASE(simloop_initial_render_not_retriggered) { | ||
| 25 | Timer timer = {}; | ||
| 26 | Simloop simloop = simloop_make( | ||
| 27 | &(SimloopArgs){.update_fps = 10, .max_render_fps = 10, .timer = &timer}); | ||
| 13 | SimloopOut simout; | 28 | SimloopOut simout; |
| 14 | 29 | ||
| 15 | simloop_update(&simloop, &simout); | 30 | simloop_update(&simloop, &simout); |
| 31 | |||
| 16 | TEST_TRUE(simout.should_render); | 32 | TEST_TRUE(simout.should_render); |
| 33 | TEST_TRUE(!simout.should_update); | ||
| 34 | TEST_EQUAL(simout.frame, 0); | ||
| 35 | |||
| 36 | for (int i = 0; i < 10; i++) { | ||
| 37 | // Note that time does not advance. | ||
| 38 | simloop_update(&simloop, &simout); | ||
| 39 | TEST_TRUE(!simout.should_render); | ||
| 40 | TEST_TRUE(!simout.should_update); | ||
| 41 | TEST_EQUAL(simout.frame, 0); | ||
| 42 | } | ||
| 17 | } | 43 | } |
| 18 | 44 | ||
| 19 | /// A simulation loop with no render frame cap. | 45 | /// A simulation loop with no render frame cap: |
| 20 | TEST_CASE(simloop_test_no_render_frame_cap) { | 46 | /// 1. Updates based on the desired update frame rate. |
| 21 | constexpr int UPDATE_FPS = 10; | 47 | /// 2. Renders at every loop. |
| 22 | const time_delta STEP = sec_to_time_delta(1); | 48 | TEST_CASE(simloop_no_render_frame_cap) { |
| 23 | const time_delta SIM_TIME_SEC = sec_to_time_delta(30); | 49 | constexpr int UPDATE_FPS = 10; |
| 50 | const time_delta EXPECT_UPDATE = sec_to_time_delta(1.0 / (double)UPDATE_FPS); | ||
| 51 | const time_delta STEP = sec_to_time_delta(1); | ||
| 52 | const time_delta SIM_TIME_SEC = sec_to_time_delta(30); | ||
| 24 | 53 | ||
| 25 | Timer timer = {}; | 54 | Timer timer = {}; |
| 26 | Simloop simloop = simloop_make(&(SimloopArgs){ | 55 | Simloop simloop = simloop_make(&(SimloopArgs){ |
| 27 | .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); | 56 | .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); |
| 28 | SimloopOut simout; | 57 | SimloopOut simout; |
| 29 | 58 | ||
| 30 | for (time_delta t = STEP; t < SIM_TIME_SEC; t += STEP) { | 59 | for (time_delta t = 0; t < SIM_TIME_SEC; t += STEP) { |
| 31 | timer_advance(&timer, t); | 60 | timer_advance(&timer, t); |
| 32 | simloop_update(&simloop, &simout); | 61 | simloop_update(&simloop, &simout); |
| 33 | TEST_TRUE(simout.should_render); | 62 | TEST_TRUE(simout.should_render); |
| 34 | TEST_EQUAL(simout.updates_pending, UPDATE_FPS); | 63 | TEST_EQUAL((t > 0) && ((t % EXPECT_UPDATE) == 0), simout.should_update); |
| 35 | } | 64 | } |
| 36 | } | 65 | } |
| 37 | 66 | ||
| 38 | /// A simulation loop with a render frame cap. | 67 | /// A simulation loop with a render frame cap: |
| 39 | TEST_CASE(simloop_test_with_render_frame_cap) { | 68 | /// 1. Updates based on the desired update frame rate. |
| 69 | /// 2. Renders based on the desired render frame rate. | ||
| 70 | TEST_CASE(simloop_with_render_frame_cap) { | ||
| 40 | constexpr int UPDATE_FPS = 10; | 71 | constexpr int UPDATE_FPS = 10; |
| 41 | constexpr int RENDER_FPS = 5; | 72 | constexpr int RENDER_FPS = 5; |
| 42 | const time_delta STEP = sec_to_time_delta(0.1); | ||
| 43 | const time_delta SIM_TIME_SEC = sec_to_time_delta(30); | ||
| 44 | const time_delta EXPECT_UPDATE = sec_to_time_delta(1.0 / (double)UPDATE_FPS); | 73 | const time_delta EXPECT_UPDATE = sec_to_time_delta(1.0 / (double)UPDATE_FPS); |
| 45 | const time_delta EXPECT_RENDER = sec_to_time_delta(1.0 / (double)RENDER_FPS); | 74 | const time_delta EXPECT_RENDER = sec_to_time_delta(1.0 / (double)RENDER_FPS); |
| 75 | const time_delta STEP = sec_to_time_delta(0.1); | ||
| 76 | const time_delta SIM_TIME_SEC = sec_to_time_delta(30); | ||
| 46 | 77 | ||
| 47 | Timer timer = {}; | 78 | Timer timer = {}; |
| 48 | Simloop simloop = simloop_make(&(SimloopArgs){ | 79 | Simloop simloop = simloop_make(&(SimloopArgs){ |
| 49 | .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); | 80 | .update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS, .timer = &timer}); |
| 50 | SimloopOut simout; | 81 | SimloopOut simout; |
| 51 | 82 | ||
| 52 | for (time_delta t = STEP; t < SIM_TIME_SEC; t += STEP) { | 83 | for (time_delta t = 0; t < SIM_TIME_SEC; t += STEP) { |
| 53 | timer_advance(&timer, t); | 84 | timer_advance(&timer, t); |
| 54 | simloop_update(&simloop, &simout); | 85 | simloop_update(&simloop, &simout); |
| 55 | TEST_TRUE(((STEP % EXPECT_RENDER) == 0) ? simout.should_render : true); | 86 | // Also expecting initial render at t=0. |
| 56 | TEST_TRUE(((STEP % EXPECT_UPDATE) == 0) ? simout.updates_pending : true); | 87 | TEST_EQUAL((t % EXPECT_RENDER) == 0, simout.should_render); |
| 88 | TEST_EQUAL((t > 0) && ((t % EXPECT_UPDATE) == 0), simout.should_update); | ||
| 57 | } | 89 | } |
| 58 | } | 90 | } |
| 59 | 91 | ||
