Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion core/time.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
#include <core/time.hpp>
#include <SDL3/SDL.h>

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()
{
Expand Down Expand Up @@ -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;
}
65 changes: 61 additions & 4 deletions core/time.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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
4 changes: 0 additions & 4 deletions rendering_engine/window.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<float>(eng.time->delta_time());
bus.emit<core::frame>(frame);

SDL_PumpEvents();
while (SDL_PollEvent(&events))
{
Expand Down
5 changes: 3 additions & 2 deletions rendering_engine/window.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
22 changes: 21 additions & 1 deletion runtime/engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<float>(time->fixed_delta_time());
while (time->next_fixed_step())
{
events->emit<core::frame>(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();
Expand Down
2 changes: 2 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
)
Expand Down
92 changes: 92 additions & 0 deletions tests/core/time_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <gtest/gtest.h>

#include <core/time.hpp>

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);
}
Loading