From 6482f3995baf9515158d999db925ef35158cfba5 Mon Sep 17 00:00:00 2001 From: 3gg <3gg@shellblade.net> Date: Sat, 2 May 2026 14:35:43 -0700 Subject: Throttle rendering to reduce CPU usage --- simloop/include/simloop.h | 21 +++++------- simloop/src/simloop.c | 84 +++++++++++++-------------------------------- simloop/test/simloop_test.c | 69 +++++++++++-------------------------- 3 files changed, 53 insertions(+), 121 deletions(-) diff --git a/simloop/include/simloop.h b/simloop/include/simloop.h index 4e3ed20..6a83b23 100644 --- a/simloop/include/simloop.h +++ b/simloop/include/simloop.h @@ -2,7 +2,7 @@ #include -typedef uint64_t simloop_time_t; +typedef uint64_t simloop_time_t; ///< Time delta in nanoseconds. typedef struct SimloopArgs { int update_fps; ///< Update frame rate. Must be >0. @@ -11,13 +11,12 @@ typedef struct SimloopArgs { typedef struct SimloopOut { uint64_t frame; ///< Frame counter. - simloop_time_t render_elapsed; ///< Amount of time elapsed in the rendering. simloop_time_t update_elapsed; ///< Amount of time elapsed in the simulation. simloop_time_t update_dt; ///< Delta time for simulation updates. - double percent_frame; ///< Percent progress between this frame and - ///< the next. Used for smooth animation. - bool should_update; ///< Whether the simulation should update. - bool should_render; ///< Whether the simulation should be rendered. + simloop_time_t throttle; ///< Render throttle if max render fps is given. + double percent_frame; ///< Percent progress between this frame and + ///< the next. Used for smooth animation. + bool should_update; ///< Whether the simulation should update. } SimloopOut; typedef struct SimloopTimeline { @@ -26,12 +25,10 @@ typedef struct SimloopTimeline { } SimloopTimeline; typedef struct Simloop { - simloop_time_t clock; ///< Tracks simulation time. - uint64_t frame; ///< Frame counter, number of updates done. - SimloopTimeline update; ///< Update timeline. - SimloopTimeline render; ///< Render timeline. - double percent_frame; - bool first_iter; + simloop_time_t clock; ///< Tracks simulation time. + uint64_t frame; ///< Frame counter, number of updates done. + SimloopTimeline update; ///< Update timeline. + simloop_time_t render_ddt; ///< Desired render delta time. } Simloop; /// Create a simulation loop. diff --git a/simloop/src/simloop.c b/simloop/src/simloop.c index b8547fd..d231ab3 100644 --- a/simloop/src/simloop.c +++ b/simloop/src/simloop.c @@ -20,87 +20,51 @@ Simloop simloop_make(const SimloopArgs* args) { .ddt = ddt_from_fps(args->update_fps), .time = 0, }, - .render = - (SimloopTimeline){ - .ddt = ddt_from_fps(args->max_render_fps), - .time = 0, - }, - .percent_frame = 0., - .first_iter = true, + .render_ddt = ddt_from_fps(args->max_render_fps), }; } -static bool step_update(const Simloop* sim, SimloopTimeline* timeline) { +void simloop_update(Simloop* sim, simloop_time_t dt, SimloopOut* out) { assert(sim); - assert(timeline); - assert(timeline->ddt > 0); + assert(out); + + sim->clock += dt; + // Simulation update. // If the update falls behind the clock, we advance by a single ddt increment // per loop iteration here and give it a chance to catch up over subsequent // iterations. // This has the implication that percent_frame can fall out of range (>1) if // we are not careful with how it is defined. See the general update function // below. - const simloop_time_t dt = sim->clock - timeline->time; - const bool should_step = dt >= timeline->ddt; - timeline->time += should_step ? timeline->ddt : 0; - return should_step; -} - -static bool step_render(const Simloop* sim, SimloopTimeline* timeline) { - assert(sim); - assert(timeline); - - bool render = false; - if (timeline->ddt > 0) { - render = step_update(sim, timeline); - } else { - render = timeline->time < sim->clock; - timeline->time = sim->clock; - } - return render; -} - -void simloop_update(Simloop* sim, simloop_time_t dt, SimloopOut* out) { - assert(sim); - assert(out); - - sim->clock += dt; - - // Simulation update. - const bool update_this_tick = step_update(sim, &sim->update); + const simloop_time_t delta = sim->clock - sim->update.time; + const bool update_this_tick = delta >= sim->update.ddt; + sim->update.time += update_this_tick ? sim->update.ddt : 0; - // Simulation render. - const bool render_this_tick = - step_render(sim, &sim->render) || - sim->first_iter; // Trigger an initial render on the first frame. + // Loop-state update. + sim->frame += (update_this_tick ? 1 : 0); // Interpolator for smooth animation. - // If rendering is not frame-rate capped, then its timeline should always be - // at least as recent as the update's. Otherwise, it is possible for the - // rendering timeline to be behind. // If the update falls behind the clock, then percent_frame can fall out of // range (>1) if we are not careful. We impose that it is strictly never >1 // to account for this case. assert(sim->update.ddt > 0); - assert( - (sim->render.ddt == 0) ? (sim->update.time <= sim->render.time) : true); - sim->percent_frame = - (sim->render.time >= sim->update.time) - ? min(1., ((double)(sim->render.time - sim->update.time) / - (double)sim->update.ddt)) - : sim->percent_frame; - assert((0. <= sim->percent_frame) && (sim->percent_frame <= 1.)); - - // Loop state update. - sim->frame += (update_this_tick ? 1 : 0); - sim->first_iter = false; + assert(sim->update.time <= sim->clock); + out->percent_frame = min( + 1., (double)(sim->clock - sim->update.time) / (double)sim->update.ddt); + assert((0. <= out->percent_frame) && (out->percent_frame <= 1.)); + + // Render frame rate throttle. + // Note that if no max render fps is given, then render_ddt is 0. The logic + // works for both render_ddt>0 and =0. + // Need to be careful with subtraction since the quantities are unsigned. + // Subtract an epsilon to account for delays in thread scheduling. + static const simloop_time_t eps = 50'000; // 50us + out->throttle = + (sim->render_ddt > (dt - eps)) ? (sim->render_ddt - eps - dt) : 0; out->frame = sim->frame; - out->render_elapsed = sim->render.time; out->update_elapsed = sim->update.time; out->update_dt = sim->update.ddt; - out->percent_frame = sim->percent_frame; out->should_update = update_this_tick; - out->should_render = render_this_tick; } diff --git a/simloop/test/simloop_test.c b/simloop/test/simloop_test.c index 790ad73..61e7dff 100644 --- a/simloop/test/simloop_test.c +++ b/simloop/test/simloop_test.c @@ -29,65 +29,38 @@ static uint64_t xorshift64(XorShift64State* state) { // ----------------------------------------------------------------------------- // Tests. -/// At time/frame 0: -/// 1. An initial render is always triggered. -/// 2. No update is triggered (not enough time passed). +/// At time/frame 0, no update is triggered (not enough time passed). TEST_CASE(simloop_initial_render) { Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = 10}); SimloopOut simout; simloop_update(&simloop, 0, &simout); - TEST_TRUE(simout.should_render); TEST_TRUE(!simout.should_update); TEST_EQUAL(simout.frame, 0); } -/// A frame is not re-rendered if time does not advance. -/// This applies whether rendering is frame-rate capped or unlimited, and -/// whether we are in the initial frame or a subsequent one. -void simloop_render_not_retriggered( - struct test_case_metadata* metadata, int max_render_fps, - bool initial_frame) { - Simloop simloop = simloop_make( - &(SimloopArgs){.update_fps = 10, .max_render_fps = max_render_fps}); +/// The simulation is not updated if time does not advance. +/// This applies generally to any time > 0. +TEST_CASE(simloop_render_not_retriggered) { + Simloop simloop = simloop_make(&(SimloopArgs){.update_fps = 10}); SimloopOut simout; + // Advance time by some amount to get past t=0. + simloop_update(&simloop, 1, &simout); + + // Now "advance" by 0. + const uint64_t frame_before = simout.frame; simloop_update(&simloop, 0, &simout); + const uint64_t frame_after = simout.frame; - TEST_TRUE(simout.should_render); TEST_TRUE(!simout.should_update); - TEST_EQUAL(simout.frame, 0); - - if (!initial_frame) { - // Advance time beyond the initial frame. - simloop_update(&simloop, 1, &simout); - } - - for (int i = 0; i < 10; i++) { - // Note that time does not advance here. - simloop_update(&simloop, 0, &simout); - TEST_TRUE(!simout.should_render); - TEST_TRUE(!simout.should_update); - TEST_EQUAL(simout.frame, 0); - } -} -TEST_CASE(simloop_render_not_retriggered_capped_initial_frame) { - simloop_render_not_retriggered(metadata, 10, true); -} -TEST_CASE(simloop_render_not_retriggered_unlimited_initial_frame) { - simloop_render_not_retriggered(metadata, 0, true); -} -TEST_CASE(simloop_render_not_retriggered_capped_subsequent_frame) { - simloop_render_not_retriggered(metadata, 10, false); -} -TEST_CASE(simloop_render_not_retriggered_unlimited_subsequent_frame) { - simloop_render_not_retriggered(metadata, 0, false); + TEST_EQUAL(frame_before, frame_after); } /// A simulation loop with no render frame cap: /// 1. Updates based on the desired update frame rate. -/// 2. Renders at every step. +/// 2. Does not throttle rendering. TEST_CASE(simloop_no_render_frame_cap) { constexpr int UPDATE_FPS = 10; // 100ms delta const simloop_time_t UPDATE_DDT = @@ -104,19 +77,19 @@ TEST_CASE(simloop_no_render_frame_cap) { simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // Time has not advanced. - TEST_TRUE(simout.should_render); // Initial render. + TEST_EQUAL(simout.throttle, 0); // No throttling with no render frame cap. for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { simloop_update(&simloop, STEP, &simout); const bool expect_update = (t % UPDATE_DDT) == 0; TEST_EQUAL(simout.should_update, expect_update); - TEST_TRUE(simout.should_render); // Always renders. + TEST_EQUAL(simout.throttle, 0); } } /// 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. +/// 2. Throttles rendering based on the desired render frame rate. TEST_CASE(simloop_with_render_frame_cap) { constexpr int UPDATE_FPS = 10; // 100ms delta constexpr int RENDER_FPS = 5; // 200ms delta @@ -130,7 +103,6 @@ TEST_CASE(simloop_with_render_frame_cap) { // We need simulation time to be an exact multiple of the desired deltas for // the modulo comparisons below. TEST_TRUE((UPDATE_DDT % STEP) == 0); - TEST_TRUE((RENDER_DDT % STEP) == 0); Simloop simloop = simloop_make( &(SimloopArgs){.update_fps = UPDATE_FPS, .max_render_fps = RENDER_FPS}); @@ -138,13 +110,12 @@ TEST_CASE(simloop_with_render_frame_cap) { simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // Time has not advanced. - TEST_TRUE(simout.should_render); // Initial render. + TEST_EQUAL(simout.throttle, 0); // No throttle since time has not advanced. for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { simloop_update(&simloop, STEP, &simout); - // A render is still expected at time 0. - TEST_EQUAL(simout.should_render, (t % RENDER_DDT) == 0); TEST_EQUAL(simout.should_update, (t % UPDATE_DDT) == 0); + TEST_NOTEQUAL(simout.throttle, 0); } } @@ -166,12 +137,12 @@ TEST_CASE(simloop_percent_frame_01_large_jump) { simloop_update(&simloop, 0, &simout); TEST_TRUE(!simout.should_update); // Time has not advanced. - TEST_TRUE(simout.should_render); // Initial render. for (simloop_time_t t = STEP; t <= SIM_DURATION_SEC; t += STEP) { simloop_update(&simloop, STEP, &simout); TEST_TRUE(simout.should_update); // Tries to catch up to clock. - TEST_TRUE(simout.should_render); + TEST_TRUE(0. <= simout.percent_frame); + TEST_TRUE(simout.percent_frame <= 1.); } } -- cgit v1.2.3