From 5458f3db7b08bf06c1f5c99a5851f6b270126b92 Mon Sep 17 00:00:00 2001 From: 3gg <3gg@shellblade.net> Date: Sun, 5 Apr 2026 14:48:01 -0700 Subject: Initial simloop module --- simloop/CMakeLists.txt | 30 ++++++++++++++++++++ simloop/include/simloop.h | 44 +++++++++++++++++++++++++++++ simloop/src/simloop.c | 68 +++++++++++++++++++++++++++++++++++++++++++++ simloop/test/simloop_test.c | 60 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 simloop/CMakeLists.txt create mode 100644 simloop/include/simloop.h create mode 100644 simloop/src/simloop.c create mode 100644 simloop/test/simloop_test.c diff --git a/simloop/CMakeLists.txt b/simloop/CMakeLists.txt new file mode 100644 index 0000000..1997e2f --- /dev/null +++ b/simloop/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.5) + +project(simloop) + +set(CMAKE_C_STANDARD 23) +set(CMAKE_C_STANDARD_REQUIRED On) +set(CMAKE_C_EXTENSIONS Off) + +add_library(simloop + include/simloop.h + src/simloop.c) + +target_include_directories(simloop PUBLIC + include) + +target_link_libraries(simloop PUBLIC + timer) + +target_compile_options(simloop PRIVATE -Wall -Wextra -Wpedantic) + +# Test + +add_executable(simloop_test + test/simloop_test.c) + +target_link_libraries(simloop_test + simloop + test) + +target_compile_options(simloop_test PRIVATE -DUNIT_TEST -DNDEBUG -Wall -Wextra -Wpedantic) diff --git a/simloop/include/simloop.h b/simloop/include/simloop.h new file mode 100644 index 0000000..f267d40 --- /dev/null +++ b/simloop/include/simloop.h @@ -0,0 +1,44 @@ +/* Simulation loop module. + * + * 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. + */ +#pragma once + +#include + +#include + +typedef struct SimloopArgs { + int update_fps; ///< Update frame rate. Must be >0. + int max_render_fps; ///< Render frame rate cap. 0 to disable. + Timer* timer; ///< Timer that drives the simulation. +} SimloopArgs; + +typedef struct SimloopOut { + uint64_t frame; ///< Frame counter. + 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. +} SimloopOut; + +typedef struct SimloopTimeline { + time_delta ddt; ///< Desired delta time. + time_point last_step; ///< Time of the last simulation step. +} SimloopTimeline; + +typedef struct Simloop { + SimloopTimeline update; ///< Update timeline. + SimloopTimeline render; ///< Render timeline. + uint64_t frame; ///< Frame counter. + Timer* timer; +} Simloop; + +/// Create a simulation loop. +Simloop simloop_make(const SimloopArgs*); + +/// Step the simulation loop. +void simloop_update(Simloop*, SimloopOut*); diff --git a/simloop/src/simloop.c b/simloop/src/simloop.c new file mode 100644 index 0000000..11f4d6d --- /dev/null +++ b/simloop/src/simloop.c @@ -0,0 +1,68 @@ +#include + +#include + +static uint64_t ddt_from_fps(int fps) { + static constexpr double NANOSECONDS = 1e9; + return (fps == 0) ? 0 : (uint64_t)(NANOSECONDS / (double)fps); +} + +Simloop simloop_make(const SimloopArgs* args) { + assert(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, + }; +} + +static time_delta time_elapsed(const Simloop* sim, time_point t) { + assert(sim); + return time_diff(sim->timer->start_time, t); +} + +static int 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; +} + +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) > 0; + } else { + timeline->last_step = sim->timer->last_tick; + render = true; + } + return render; +} + +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_render = + step_render(sim, &sim->render) || + (sim->frame == 0); // Trigger an initial render on the first frame. + sim->frame += new_frames; + out->frame = sim->frame; + out->render_elapsed = time_elapsed(sim, sim->render.last_step); + out->update_elapsed = time_elapsed(sim, sim->update.last_step); + out->update_dt = sim->update.ddt; +} diff --git a/simloop/test/simloop_test.c b/simloop/test/simloop_test.c new file mode 100644 index 0000000..3f2aa46 --- /dev/null +++ b/simloop/test/simloop_test.c @@ -0,0 +1,60 @@ +#include + +#include +#include + +/// An initial render should always trigger on frame 0. +TEST_CASE(simloop_initial_render) { + constexpr int UPDATE_FPS = 10; + + Timer timer = {}; + Simloop simloop = simloop_make(&(SimloopArgs){ + .update_fps = UPDATE_FPS, .max_render_fps = 0, .timer = &timer}); + SimloopOut simout; + + simloop_update(&simloop, &simout); + TEST_TRUE(simout.should_render); +} + +/// 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); + + 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) { + timer_advance(&timer, t); + simloop_update(&simloop, &simout); + TEST_TRUE(simout.should_render); + TEST_EQUAL(simout.updates_pending, UPDATE_FPS); + } +} + +/// A simulation loop with a render frame cap. +TEST_CASE(simloop_test_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); + + 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) { + 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); + } +} + +int main() { return 0; } -- cgit v1.2.3