From 879e5af4eb1a8972fc944853a515e1003f94bd7c Mon Sep 17 00:00:00 2001 From: 3gg <3gg@shellblade.net> Date: Sat, 11 Apr 2026 12:27:23 -0700 Subject: Fix simloop divergence --- simloop/include/simloop.h | 38 ++++++++++++++++++++++-- simloop/src/simloop.c | 32 ++++++++++---------- simloop/test/simloop_test.c | 72 ++++++++++++++++++++++++++++++++------------- 3 files changed, 105 insertions(+), 37 deletions(-) (limited to 'simloop') 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 @@ * This implements a simulation loop but in a way that the client retains * control flow. The client steps the loop and then checks whether the * simulation must be updated and/or the result rendered. + * + * If the simulation's update cannot keep up with the desired frame rate, then + * the loop degrades to match the simulation's rate by requesting a single + * update. + * + * Under a variable time delta, the loop could simply update the simulation + * with a large delta that puts the simulation back into the current clock + * time. Under a fixed time delta this isn't possible, and we seem to have two + * choices instead: + * + * a) Queue as many updates as necessary to bring the simulation back to the + * current clock time (time_diff / fixed_delta). + * + * b) Queue a single update. + * + * The issue with (a) is that the number of requested updates diverges and + * eventually the simulation appears to freeze. At every loop, the number of + * queued updates increases with respect to the last iteration as the + * simulation fails to keep up with the desired frame rate. Example: + * + * desired delta = 10ms (100 fps) + * actual delta = 20ms ( 50 fps) + * --------------------------- + * iter, sim time, clock time + * --------------------------- + * 0, 0, 0, initial state + * 1, 0, 10, queue 1 update + * 2, 10, 30, queue (30-10)/10 = 2 updates + * 3, 30, 70, queue (70-30)/10 = 4 updates + * 4, 70, 150, queue (150-70)/10 = 8 updates + * ... */ #pragma once @@ -21,8 +52,8 @@ typedef struct SimloopOut { time_delta render_elapsed; ///< Amount of time elapsed in the rendering. time_delta update_elapsed; ///< Amount of time elapsed in the simulation. time_delta update_dt; ///< Delta time for simulation updates. - int updates_pending; ///< Number of frames the simulation should produce. - bool should_render; ///< Whether the simulation should be rendered. + bool should_update; ///< Whether the simulation should update. + bool should_render; ///< Whether the simulation should be rendered. } SimloopOut; typedef struct SimloopTimeline { @@ -35,10 +66,13 @@ typedef struct Simloop { SimloopTimeline render; ///< Render timeline. uint64_t frame; ///< Frame counter. Timer* timer; + bool first_iter; } Simloop; /// Create a simulation loop. Simloop simloop_make(const SimloopArgs*); /// Step the simulation loop. +/// +/// The simulation always triggers a render of the initial state of simulation. 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) { assert(args->update_fps > 0); return (Simloop){ - .frame = 0, - .update = (SimloopTimeline){.ddt = ddt_from_fps(args->update_fps), - .last_step = args->timer->start_time}, - .render = (SimloopTimeline){ .ddt = ddt_from_fps(args->max_render_fps), - .last_step = args->timer->start_time}, - .timer = args->timer, + .frame = 0, + .update = (SimloopTimeline){.ddt = ddt_from_fps(args->update_fps), + .last_step = args->timer->start_time}, + .render = (SimloopTimeline){ .ddt = ddt_from_fps(args->max_render_fps), + .last_step = args->timer->start_time}, + .timer = args->timer, + .first_iter = true, }; } @@ -26,15 +27,16 @@ static time_delta time_elapsed(const Simloop* sim, time_point t) { return time_diff(sim->timer->start_time, t); } -static int step_update(const Simloop* sim, SimloopTimeline* timeline) { +static bool step_update(const Simloop* sim, SimloopTimeline* timeline) { assert(sim); assert(timeline); assert(timeline->ddt > 0); const time_delta dt = time_diff(timeline->last_step, sim->timer->last_tick); - const time_delta steps = dt / timeline->ddt; - timeline->last_step = time_add(timeline->last_step, dt); - return (int)steps; + const bool should_step = dt >= timeline->ddt; + timeline->last_step = + should_step ? time_add(timeline->last_step, dt) : timeline->last_step; + return should_step; } static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { @@ -43,7 +45,7 @@ static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { bool render = false; if (timeline->ddt > 0) { - render = step_update(sim, timeline) > 0; + render = step_update(sim, timeline); } else { timeline->last_step = sim->timer->last_tick; render = true; @@ -55,12 +57,12 @@ void simloop_update(Simloop* sim, SimloopOut* out) { assert(sim); assert(out); - const int new_frames = step_update(sim, &sim->update); - out->updates_pending = new_frames; + out->should_update = step_update(sim, &sim->update); out->should_render = step_render(sim, &sim->render) || - (sim->frame == 0); // Trigger an initial render on the first frame. - sim->frame += new_frames; + (sim->first_iter); // Trigger an initial render on the first frame. + sim->frame += (out->should_update ? 1 : 0); + sim->first_iter = false; out->frame = sim->frame; out->render_elapsed = time_elapsed(sim, sim->render.last_step); 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 @@ #include #include -/// An initial render should always trigger on frame 0. +/// At time/frame 0: +/// 1. An initial render is always triggered. +/// 2. No update is triggered (not enough time passed). TEST_CASE(simloop_initial_render) { - constexpr int UPDATE_FPS = 10; + Timer timer = {}; + Simloop simloop = simloop_make( + &(SimloopArgs){.update_fps = 10, .max_render_fps = 0, .timer = &timer}); + SimloopOut simout; - Timer timer = {}; - Simloop simloop = simloop_make(&(SimloopArgs){ - .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); + simloop_update(&simloop, &simout); + + TEST_TRUE(simout.should_render); + TEST_TRUE(!simout.should_update); + TEST_EQUAL(simout.frame, 0); +} + +/// The initial render is not re-triggered if there is a render frame rate cap +/// and time does not advance. +TEST_CASE(simloop_initial_render_not_retriggered) { + Timer timer = {}; + Simloop simloop = simloop_make( + &(SimloopArgs){.update_fps = 10, .max_render_fps = 10, .timer = &timer}); SimloopOut simout; simloop_update(&simloop, &simout); + TEST_TRUE(simout.should_render); + TEST_TRUE(!simout.should_update); + TEST_EQUAL(simout.frame, 0); + + for (int i = 0; i < 10; i++) { + // Note that time does not advance. + simloop_update(&simloop, &simout); + TEST_TRUE(!simout.should_render); + TEST_TRUE(!simout.should_update); + TEST_EQUAL(simout.frame, 0); + } } -/// A simulation loop with no render frame cap. -TEST_CASE(simloop_test_no_render_frame_cap) { - constexpr int UPDATE_FPS = 10; - const time_delta STEP = sec_to_time_delta(1); - const time_delta SIM_TIME_SEC = sec_to_time_delta(30); +/// A simulation loop with no render frame cap: +/// 1. Updates based on the desired update frame rate. +/// 2. Renders at every loop. +TEST_CASE(simloop_no_render_frame_cap) { + constexpr int UPDATE_FPS = 10; + const time_delta EXPECT_UPDATE = sec_to_time_delta(1.0 / (double)UPDATE_FPS); + const time_delta STEP = sec_to_time_delta(1); + const time_delta SIM_TIME_SEC = sec_to_time_delta(30); Timer timer = {}; Simloop simloop = simloop_make(&(SimloopArgs){ .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); SimloopOut simout; - for (time_delta t = STEP; t < SIM_TIME_SEC; t += STEP) { + for (time_delta t = 0; t < SIM_TIME_SEC; t += STEP) { timer_advance(&timer, t); simloop_update(&simloop, &simout); TEST_TRUE(simout.should_render); - TEST_EQUAL(simout.updates_pending, UPDATE_FPS); + TEST_EQUAL((t > 0) && ((t % EXPECT_UPDATE) == 0), simout.should_update); } } -/// A simulation loop with a render frame cap. -TEST_CASE(simloop_test_with_render_frame_cap) { +/// A simulation loop with a render frame cap: +/// 1. Updates based on the desired update frame rate. +/// 2. Renders based on the desired render frame rate. +TEST_CASE(simloop_with_render_frame_cap) { constexpr int UPDATE_FPS = 10; constexpr int RENDER_FPS = 5; - const time_delta STEP = sec_to_time_delta(0.1); - const time_delta SIM_TIME_SEC = sec_to_time_delta(30); const time_delta EXPECT_UPDATE = sec_to_time_delta(1.0 / (double)UPDATE_FPS); const time_delta EXPECT_RENDER = sec_to_time_delta(1.0 / (double)RENDER_FPS); + const time_delta STEP = sec_to_time_delta(0.1); + const time_delta SIM_TIME_SEC = sec_to_time_delta(30); Timer timer = {}; Simloop simloop = simloop_make(&(SimloopArgs){ - .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); + .update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS, .timer = &timer}); SimloopOut simout; - for (time_delta t = STEP; t < SIM_TIME_SEC; t += STEP) { + for (time_delta t = 0; t < SIM_TIME_SEC; t += STEP) { timer_advance(&timer, t); simloop_update(&simloop, &simout); - TEST_TRUE(((STEP % EXPECT_RENDER) == 0) ? simout.should_render : true); - TEST_TRUE(((STEP % EXPECT_UPDATE) == 0) ? simout.updates_pending : true); + // Also expecting initial render at t=0. + TEST_EQUAL((t % EXPECT_RENDER) == 0, simout.should_render); + TEST_EQUAL((t > 0) && ((t % EXPECT_UPDATE) == 0), simout.should_update); } } -- cgit v1.2.3