From d14d07db08f714abef65ce74c50df5f3e6d31651 Mon Sep 17 00:00:00 2001 From: Tomislav Radanovic <8278883+TommyRadan@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:17:01 +0200 Subject: [PATCH] feat: decouple fixed-step update from variable render Split engine::tick() into an accumulator-driven fixed-step update and a variable-rate render so simulation behaviour no longer depends on frame rate. core::time gains fixed-delta accounting: accumulate() feeds elapsed wall-clock time into a clamped accumulator (the spiral-of-death guard caps it at max_steps_per_frame), next_fixed_step() drains whole steps, and interpolation_alpha() exposes the remainder for render-time smoothing. Game-logic (core::frame) now fires once per fixed step, driven by the engine; window::tick() only pumps input per rendered frame. The render_* events still fire per render inside renderer->render(), and scene-graph component propagation runs once per frame before the draw walk. Closes #168 --- core/time.cpp | 41 ++++++++++++++++- core/time.hpp | 65 ++++++++++++++++++++++++-- rendering_engine/window.cpp | 4 -- rendering_engine/window.hpp | 5 +- runtime/engine.cpp | 22 ++++++++- tests/CMakeLists.txt | 2 + tests/core/time_test.cpp | 92 +++++++++++++++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 tests/core/time_test.cpp diff --git a/core/time.cpp b/core/time.cpp index 0aeda13..d833807 100644 --- a/core/time.cpp +++ b/core/time.cpp @@ -23,7 +23,11 @@ #include #include -core::time::time() : m_frame_count{0}, m_delta_time{0} {} +core::time::time(double fixed_delta_time, int max_steps_per_frame) + : m_frame_count{0}, m_delta_time{0}, m_fixed_delta_time{fixed_delta_time}, m_accumulator{0}, + m_max_steps_per_frame{max_steps_per_frame} +{ +} void core::time::perform_tick() { @@ -57,3 +61,38 @@ const float core::time::current_fps() const return 0; return (float)(1000.0 / m_delta_time); } + +const double core::time::fixed_delta_time() const +{ + return m_fixed_delta_time; +} + +void core::time::accumulate(double frame_delta_time) +{ + m_accumulator += frame_delta_time; + + // Spiral-of-death guard: never let the accumulator hold more than + // m_max_steps_per_frame whole steps. Anything beyond is dropped, so + // the simulation runs slower than real time during a stall rather + // than trying (and failing) to catch up. + const double max_accumulated = m_fixed_delta_time * m_max_steps_per_frame; + if (m_accumulator > max_accumulated) + { + m_accumulator = max_accumulated; + } +} + +bool core::time::next_fixed_step() +{ + if (m_accumulator < m_fixed_delta_time) + { + return false; + } + m_accumulator -= m_fixed_delta_time; + return true; +} + +const double core::time::interpolation_alpha() const +{ + return m_accumulator / m_fixed_delta_time; +} diff --git a/core/time.hpp b/core/time.hpp index 9ecb48b..84ab8f7 100644 --- a/core/time.hpp +++ b/core/time.hpp @@ -35,15 +35,35 @@ namespace core * @brief Frame clock owned by @ref runtime::engine. * * Backed by SDL's high-resolution performance counter. Call - * @ref perform_tick once per frame to advance the clock; the other - * accessors report values from the most recent tick. Not thread-safe. + * @ref perform_tick once per frame to advance the wall-clock; the + * variable-rate accessors (@ref delta_time, @ref current_fps) report + * values from the most recent tick. + * + * The clock also drives a decoupled **fixed-step accumulator** so the + * main loop can run deterministic, frame-rate-independent updates + * separately from rendering. Each frame, feed the elapsed wall-clock + * time into @ref accumulate, drain whole fixed steps with + * @ref next_fixed_step (one game-logic update per step), then render + * once using @ref interpolation_alpha to smooth between the last two + * simulated states. + * + * Not thread-safe. */ struct time { - time(); + /** + * @param fixed_delta_time Length of one fixed update step, in + * milliseconds (default 1/60 s). + * @param max_steps_per_frame Upper bound on fixed steps drained in + * a single frame. Caps the accumulator so + * a long stall cannot queue an unbounded + * backlog of updates — the + * spiral-of-death guard. + */ + explicit time(double fixed_delta_time = 1000.0 / 60.0, int max_steps_per_frame = 5); /** - * @brief Advances the clock by one frame, updating the delta + * @brief Advances the wall-clock by one frame, updating the delta * time and incrementing the frame counter. Call exactly * once per main-loop iteration. */ @@ -64,8 +84,45 @@ namespace core */ const float current_fps() const; + /** @brief Length of one fixed update step, in milliseconds. */ + const double fixed_delta_time() const; + + /** + * @brief Adds elapsed real time to the fixed-step accumulator. + * + * Pass @ref delta_time once per frame, before draining steps. The + * accumulator is clamped to @c max_steps_per_frame steps so a + * single long frame (a stall, a debugger break) can never queue + * more updates than that — without the clamp a slow frame would + * schedule extra steps, which take even longer, spiralling the + * simulation further behind every frame. + * + * @param frame_delta_time Wall-clock time elapsed this frame, in ms. + */ + void accumulate(double frame_delta_time); + + /** + * @brief Drains one fixed step from the accumulator. + * @return true if a full @ref fixed_delta_time was available (and + * consumed), false once the remainder is below one step. + * Drive the fixed-update loop with + * @c while (time.next_fixed_step()) { update(); }. + */ + bool next_fixed_step(); + + /** + * @brief Fractional progress toward the next fixed step. + * @return Accumulator remainder divided by @ref fixed_delta_time, + * in [0, 1). Use to interpolate render-time state between + * the previous and current fixed update. + */ + const double interpolation_alpha() const; + private: uint32_t m_frame_count; double m_delta_time; + double m_fixed_delta_time; + double m_accumulator; + int m_max_steps_per_frame; }; } // namespace core diff --git a/rendering_engine/window.cpp b/rendering_engine/window.cpp index f00bd62..8f8b533 100644 --- a/rendering_engine/window.cpp +++ b/rendering_engine/window.cpp @@ -197,10 +197,6 @@ namespace rendering_engine auto& eng = runtime::current_engine(); auto& bus = *eng.events; - core::frame frame; - frame.m_delta_time = static_cast(eng.time->delta_time()); - bus.emit(frame); - SDL_PumpEvents(); while (SDL_PollEvent(&events)) { diff --git a/rendering_engine/window.hpp b/rendering_engine/window.hpp index 3ab3982..cf485d2 100644 --- a/rendering_engine/window.hpp +++ b/rendering_engine/window.hpp @@ -80,8 +80,9 @@ namespace rendering_engine * @brief Pumps the SDL event queue and translates OS input into * engine events broadcast through @ref core::event_bus. * - * Also broadcasts a @ref core::frame event carrying the - * current delta time. Call once per main-loop iteration. + * Variable-rate: call once per rendered frame. The fixed-step + * @ref core::frame update is driven separately by + * @ref runtime::engine::tick. */ void tick(); diff --git a/runtime/engine.cpp b/runtime/engine.cpp index 58b67f4..7dc1f94 100644 --- a/runtime/engine.cpp +++ b/runtime/engine.cpp @@ -169,14 +169,34 @@ namespace runtime void engine::tick() { + // Pump OS input once per rendered frame (variable rate). Input + // state set here is read by the fixed-step updates below. window->tick(); + + // Fixed-step update, decoupled from the render rate. Feed the time + // elapsed since the previous frame into the accumulator, then drain + // it one fixed step at a time — running game logic zero, one, or + // several times this frame so simulation behaviour is independent of + // frame rate. The accumulator's clamp bounds the step count, so this + // loop always terminates (the spiral-of-death guard lives in time). + time->accumulate(time->delta_time()); + core::frame frame; + frame.m_delta_time = static_cast(time->fixed_delta_time()); + while (time->next_fixed_step()) + { + events->emit(frame); + } + // Build the ImGui debug overlay before the passes run; its draw // data is recorded inside the swapchain-targeted debug pass (via // the render_debug event) so it composites on top of the frame on // both the OpenGL and Vulkan backends. No-op in release builds. rendering_engine::debug_ui::begin_frame(); // Propagate scene-graph component updates (light/camera poses tracking - // their nodes) after on_frame moved nodes and before the draw walk. + // their nodes) after the fixed updates moved nodes and before the draw + // walk. Runs once per rendered frame; render_* events fire per render + // inside renderer->render(). The interpolation alpha for smoothing + // between fixed states is available via time->interpolation_alpha(). scenes->update(); renderer->render(); window->swap_buffers(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 24a845e..f119ae6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,7 @@ set(ALPHAENGINE_TEST_SOURCES_UNDER_TEST ${CMAKE_SOURCE_DIR}/core/event_engine.cpp ${CMAKE_SOURCE_DIR}/core/jobs.cpp ${CMAKE_SOURCE_DIR}/core/log.cpp + ${CMAKE_SOURCE_DIR}/core/time.cpp ${CMAKE_SOURCE_DIR}/core/version.cpp # runtime::node — scene-graph hierarchy (depends only on math + the # component store; no renderer or device). @@ -54,6 +55,7 @@ set(ALPHAENGINE_TEST_SOURCES core/pool_test.cpp core/event_bus_test.cpp core/jobs_test.cpp + core/time_test.cpp runtime/node_test.cpp rendering_engine/assets/asset_cache_test.cpp ) diff --git a/tests/core/time_test.cpp b/tests/core/time_test.cpp new file mode 100644 index 0000000..50efe1f --- /dev/null +++ b/tests/core/time_test.cpp @@ -0,0 +1,92 @@ +// Unit tests for the fixed-step accounting in core::time: the accumulator +// drains whole steps, exposes a fractional interpolation alpha for the +// remainder, and clamps a long frame so it can never queue an unbounded +// backlog of updates (the spiral-of-death guard). +// +// Only the fixed-step methods are exercised here; the wall-clock side +// (perform_tick / total_time) reads SDL's performance counter and is not +// deterministic enough for a unit test. + +#include + +#include + +namespace +{ + // A 10 ms step keeps the arithmetic exact and easy to read. + constexpr double k_step = 10.0; + constexpr int k_max_steps = 5; + + int drain(core::time& clock) + { + int steps = 0; + while (clock.next_fixed_step()) + { + ++steps; + } + return steps; + } +} // namespace + +TEST(time, reports_the_configured_fixed_delta) +{ + core::time clock{k_step, k_max_steps}; + EXPECT_DOUBLE_EQ(clock.fixed_delta_time(), k_step); +} + +TEST(time, drains_no_step_below_one_full_delta) +{ + core::time clock{k_step, k_max_steps}; + clock.accumulate(k_step - 0.001); + EXPECT_EQ(drain(clock), 0); +} + +TEST(time, drains_exactly_one_step_per_full_delta) +{ + core::time clock{k_step, k_max_steps}; + clock.accumulate(k_step); + EXPECT_EQ(drain(clock), 1); +} + +TEST(time, drains_multiple_steps_from_a_long_frame) +{ + core::time clock{k_step, k_max_steps}; + clock.accumulate(3 * k_step + 1.0); + EXPECT_EQ(drain(clock), 3); +} + +TEST(time, carries_the_remainder_across_frames) +{ + core::time clock{k_step, k_max_steps}; + + // 1.6 steps' worth of time: one step now, 0.6 left over. + clock.accumulate(1.6 * k_step); + EXPECT_EQ(drain(clock), 1); + EXPECT_NEAR(clock.interpolation_alpha(), 0.6, 1e-9); + + // Another 0.6 steps tops the remainder past a full step. + clock.accumulate(0.6 * k_step); + EXPECT_EQ(drain(clock), 1); + EXPECT_NEAR(clock.interpolation_alpha(), 0.2, 1e-9); +} + +TEST(time, interpolation_alpha_stays_below_one) +{ + core::time clock{k_step, k_max_steps}; + clock.accumulate(2.5 * k_step); + drain(clock); + EXPECT_GE(clock.interpolation_alpha(), 0.0); + EXPECT_LT(clock.interpolation_alpha(), 1.0); +} + +TEST(time, clamps_a_huge_frame_to_max_steps) +{ + core::time clock{k_step, k_max_steps}; + + // A 100-step stall must not queue 100 updates — the accumulator is + // capped at k_max_steps so the loop terminates and the simulation + // falls behind real time instead of spiralling. + clock.accumulate(100 * k_step); + EXPECT_EQ(drain(clock), k_max_steps); + EXPECT_NEAR(clock.interpolation_alpha(), 0.0, 1e-9); +}