From 63b3d35259f9126a1dbd2e32a902c66827b73b94 Mon Sep 17 00:00:00 2001 From: Andreas Leroux Date: Fri, 22 Nov 2024 15:55:46 +0100 Subject: [PATCH] Introduce system/phase timers Close: #181 --- doc/Tutorial.md | 65 +++++++++ src/ecstasy/registry/Registry.cpp | 5 +- src/ecstasy/system/CMakeLists.txt | 2 + src/ecstasy/system/ISystem.hpp | 38 +++++ src/ecstasy/system/Pipeline.cpp | 8 +- src/ecstasy/system/Pipeline.hpp | 49 ++++++- src/ecstasy/system/Timer.cpp | 88 ++++++++++++ src/ecstasy/system/Timer.hpp | 228 ++++++++++++++++++++++++++++++ tests/registry/tests_Registry.cpp | 92 ++++++++++++ 9 files changed, 567 insertions(+), 8 deletions(-) create mode 100644 src/ecstasy/system/Timer.cpp create mode 100644 src/ecstasy/system/Timer.hpp diff --git a/doc/Tutorial.md b/doc/Tutorial.md index 1c6efc77a..ae04eaf2c 100644 --- a/doc/Tutorial.md +++ b/doc/Tutorial.md @@ -472,6 +472,71 @@ I strongly recommend you to use enum types, or const values/macros. registry.runSystemsPhase(); ``` +### Timers + +By default, phases and systems are runned at each frame (ie each `registry.run()` call). +However you may want to limit them to run every two frames, or every 5 seconds. +This is possible through the @ref ecstasy::Timer "Timer" class. + +Every @ref ecstasy::ISystem "ISystem" and @ref ecstasy::Pipeline::Phase "Phase" have a timer instance accessible through `getTimer()` getter. + +#### Interval + +Want to limit your system to run at a specific time ? Use @ref ecstasy::Timer::setInterval "setInterval()" function: + +@warning +Setting an interval means it will always wait **at least** the required interval between two systems calls but it can be longer. + +```cpp +ecstasy::Registry registry; + +registry.addSystem().getTimer().setInterval(std::chrono::milliseconds(500)); +// Thanks to std::chrono, you can easily use other time units +registry.addSystem().getTimer().setInterval(std::chrono::seconds(5)); + +// Set render to every 16ms -> ~60Hz +registry.getPipeline().getPhase(Pipeline::PredefinedPhases::OnStore).getTimer().setInterval(std::chrono::milliseconds(16)); +``` + +#### Rate + +Want to limit your system to run at a frame frequency instead ? Use @ref ecstasy::Timer::setRate "setRate()" function: + +```cpp +ecstasy::Registry registry; + +// Will run every two frames (ie run one, skip one) +registry.addSystem().getTimer().setRate(2); + +// Will render every 5 frames +registry.getPipeline().getPhase(Pipeline::PredefinedPhases::OnStore).getTimer().setRate(5); +``` + +#### Tips when using timers on Systems and Phases + +The one sentence to remember about combining system and phases timers is this one: +**The system timer is only evaluated if the phase timer succeed.** + +Below are some resulting behaviors. + +1. System and Phase rates + + Combined rates are multiplied. Inverting the values will have the same behaviors. + Ex: Rate limit of 5 on the system and 2 on the phase will result in a system rate of 10 frames. + +2. Phase interval and system rate + + The final interval is the phase interval multiplied by the system rate. + Ex: Interval of 5s with rate of 3 will result in a system interval of 15s. + +3. Phase rate (R) and system interval (I) + + The system will be called at frames when the frame id is a multiple of the `R` **and** at least `I` seconds elapsed since last system call. + +4. Phase and system intervals + + The longest interval need to be satisfied. They are not added. + ## Using resources Creating a resource is even simpler than creating a system: you only have to inherit @ref ecstasy::IResource "IResource". diff --git a/src/ecstasy/registry/Registry.cpp b/src/ecstasy/registry/Registry.cpp index 072f5a8c6..db79d18eb 100644 --- a/src/ecstasy/registry/Registry.cpp +++ b/src/ecstasy/registry/Registry.cpp @@ -83,7 +83,10 @@ namespace ecstasy void Registry::runSystem(const std::type_index &systemId) { - _systems.get(systemId).run(*this); + ISystem &system = _systems.get(systemId); + + if (system.getTimer().trigger()) + system.run(*this); } void Registry::runSystems() diff --git a/src/ecstasy/system/CMakeLists.txt b/src/ecstasy/system/CMakeLists.txt index bf1f662dc..e43710d5d 100644 --- a/src/ecstasy/system/CMakeLists.txt +++ b/src/ecstasy/system/CMakeLists.txt @@ -6,5 +6,7 @@ set(SRC ${INCROOT}/Pipeline.hpp ${SRCROOT}/Pipeline.cpp ${INCROOT}/ISystem.hpp + ${INCROOT}/Timer.hpp + ${SRCROOT}/Timer.cpp PARENT_SCOPE ) diff --git a/src/ecstasy/system/ISystem.hpp b/src/ecstasy/system/ISystem.hpp index d6140a8b5..e2f11eeaa 100644 --- a/src/ecstasy/system/ISystem.hpp +++ b/src/ecstasy/system/ISystem.hpp @@ -12,6 +12,8 @@ #ifndef ECSTASY_SYSTEM_ISYSTEM_HPP_ #define ECSTASY_SYSTEM_ISYSTEM_HPP_ +#include "ecstasy/system/Timer.hpp" + namespace ecstasy { /// @brief Forward declaration of Registry class. @@ -37,6 +39,42 @@ namespace ecstasy /// @since 1.0.0 (2022-10-17) /// virtual void run(Registry ®istry) = 0; + + /// + /// @brief Get the system timer. + /// + /// @note The timer is used to control the execution of the system. You can also use + /// @ref ecstasy::Pipeline::Phase "Phase" scale timers. + /// + /// @return Timer& Reference to the system timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] constexpr Timer &getTimer() noexcept + { + return _timer; + } + + /// + /// @brief Get the system timer. + /// + /// @note The timer is used to control the execution of the system. You can also use + /// @ref ecstasy::Pipeline::Phase "Phase" scale timers. + /// + /// @return const Timer& Reference to the system timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] constexpr const Timer &getTimer() const noexcept + { + return _timer; + } + + private: + /// @brief Timer to control the execution of the system. + Timer _timer; }; } // namespace ecstasy diff --git a/src/ecstasy/system/Pipeline.cpp b/src/ecstasy/system/Pipeline.cpp index 70180f474..7f52ce3e8 100644 --- a/src/ecstasy/system/Pipeline.cpp +++ b/src/ecstasy/system/Pipeline.cpp @@ -25,8 +25,10 @@ namespace ecstasy return _pipeline._systemsIds.begin() + static_cast(end_idx()); } - void Pipeline::Phase::run() const + void Pipeline::Phase::run() { + if (!_timer.trigger()) + return; for (auto it = begin(); it < end(); ++it) { _pipeline._registry.runSystem(*it); } @@ -54,14 +56,14 @@ namespace ecstasy } } - void Pipeline::run() const + void Pipeline::run() { for (auto &phase : _phases) { phase.second.run(); } } - void Pipeline::run(Pipeline::PhaseId phase) const + void Pipeline::run(Pipeline::PhaseId phase) { auto phaseIt = _phases.find(phase); diff --git a/src/ecstasy/system/Pipeline.hpp b/src/ecstasy/system/Pipeline.hpp index f1bbb5010..06b176451 100644 --- a/src/ecstasy/system/Pipeline.hpp +++ b/src/ecstasy/system/Pipeline.hpp @@ -16,6 +16,7 @@ #include #include #include "ecstasy/system/ISystem.hpp" +#include "ecstasy/system/Timer.hpp" namespace ecstasy { @@ -72,7 +73,7 @@ namespace ecstasy /// @author Andréas Leroux (andreas.leroux@epitech.eu) /// @since 1.0.0 (2024-11-21) /// - constexpr Phase(Pipeline &pipeline, PhaseId id) : _pipeline(pipeline), _begin(), _size(0), _id(id) + Phase(Pipeline &pipeline, PhaseId id) : _pipeline(pipeline), _begin(), _size(0), _id(id) { } @@ -83,7 +84,7 @@ namespace ecstasy /// @author Andréas Leroux (andreas.leroux@epitech.eu) /// @since 1.0.0 (2024-11-21) /// - void run() const; + void run(); /// /// @brief Get the number of systems in this phase. @@ -132,6 +133,44 @@ namespace ecstasy /// [[nodiscard]] SystemIterator end() const noexcept; + /// + /// @brief Get the phase timer. + /// + /// @note The timer is used to control the execution of the phase. You can also use + /// @ref ecstasy::ISystem "ISystem" scale timers. + /// + /// @warning Rate limiting both the phase and the systems will result in multiplied timers (not added). + /// However interval timers will be cumulative. + /// + /// @return Timer& Reference to the system timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] constexpr Timer &getTimer() noexcept + { + return _timer; + } + + /// + /// @brief Get the phase timer. + /// + /// @note The timer is used to control the execution of the phase. You can also use + /// @ref ecstasy::ISystem "ISystem" scale timers. + /// + /// @warning Rate limiting both the phase and the systems will result in multiplied timers (not added). + /// However interval timers will be cumulative. + /// + /// @return const Timer& Reference to the system timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] constexpr const Timer &getTimer() const noexcept + { + return _timer; + } + private: /// @brief Owning pipeline. Pipeline &_pipeline; @@ -141,6 +180,8 @@ namespace ecstasy std::size_t _size; /// @brief Identifier of the phase. PhaseId _id; + /// @brief Timer to control the execution of the phase. + Timer _timer; /// /// @brief Get the index of the first system in this phase. @@ -266,7 +307,7 @@ namespace ecstasy /// @author Andréas Leroux (andreas.leroux@epitech.eu) /// @since 1.0.0 (2024-11-21) /// - void run() const; + void run(); /// /// @brief Run a specific phase of the pipeline. @@ -276,7 +317,7 @@ namespace ecstasy /// @author Andréas Leroux (andreas.leroux@epitech.eu) /// @since 1.0.0 (2024-11-21) /// - void run(PhaseId phase) const; + void run(PhaseId phase); private: /// @brief Ordered list of systems. diff --git a/src/ecstasy/system/Timer.cpp b/src/ecstasy/system/Timer.cpp new file mode 100644 index 000000000..bb0f22a42 --- /dev/null +++ b/src/ecstasy/system/Timer.cpp @@ -0,0 +1,88 @@ +/// +/// @file Timer.cpp +/// @author Andréas Leroux (andreas.leroux@epitech.eu) +/// @brief +/// @version 1.0.0 +/// @date 2024-11-21 +/// +/// @copyright Copyright (c) ECSTASY 2022 - 2024 +/// +/// + +#include "Timer.hpp" +#include + +namespace ecstasy +{ + Timer::Timer() + { + _lastTrigger = TimePoint::min(); + setRate(0); + } + + Timer::Timer(Timer::Interval interval) : Timer() + { + setInterval(interval); + } + + Timer::Timer(std::uint32_t rate) : Timer() + { + setRate(rate); + } + + void Timer::setRate(std::uint32_t rate) noexcept + { + _type = Type::Rate; + if (rate == 0) { + rate = 1; + } + _rate.rate = rate; + // Reset the countdown to trigger to run the first time + _rate.triggerCountdown = 0; + } + + std::uint32_t Timer::getRate() const + { + if (_type != Type::Rate) + throw std::runtime_error("Timer is not of type Rate"); + return _rate.rate; + } + + void Timer::setInterval(Timer::Interval interval) noexcept + { + _type = Type::TimeInterval; + _timer.interval = interval; + } + + Timer::Interval Timer::getInterval() const + { + if (_type != Type::TimeInterval) + throw std::runtime_error("Timer is not of type Rate"); + return _timer.interval; + } + + bool Timer::trigger() noexcept + { + switch (_type) { + case Type::TimeInterval: { + auto tp = std::chrono::system_clock::now(); + + if (tp - _timer.interval >= _lastTrigger) { + _lastTrigger = tp; + return true; + } + return false; + } + case Type::Rate: { + if (_rate.triggerCountdown == 0) { + _rate.triggerCountdown = _rate.rate - 1; + _lastTrigger = std::chrono::system_clock::now(); + return true; + } + --_rate.triggerCountdown; + return false; + } + } + return false; + } +} // namespace ecstasy diff --git a/src/ecstasy/system/Timer.hpp b/src/ecstasy/system/Timer.hpp new file mode 100644 index 000000000..f874f296f --- /dev/null +++ b/src/ecstasy/system/Timer.hpp @@ -0,0 +1,228 @@ +/// +/// @file Timer.hpp +/// @author Andréas Leroux (andreas.leroux@epitech.eu) +/// @brief +/// @version 1.0.0 +/// @date 2024-11-22 +/// +/// @copyright Copyright (c) ECSTASY 2022 - 2024 +/// +/// + +#ifndef ECSTASY_SYSTEM_TIMER_HPP_ +#define ECSTASY_SYSTEM_TIMER_HPP_ + +#include +#include +#include + +namespace ecstasy +{ + /// @brief Forward declaration of Registry class. + class Registry; + + /// + /// @brief Timer class to control the execution of systems. + /// + /// Timers can be assigned to systems or phases to control their execution. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + class Timer { + public: + /// @brief Type of time points. + using TimePoint = std::chrono::time_point; + /// @brief Type of time intervals. + using Interval = std::chrono::milliseconds; + + private: + struct TimeInterval { + Interval interval; + }; + + struct Rate { + std::uint32_t rate; + std::uint32_t triggerCountdown; + }; + + public: + /// + /// @brief Possible types of timers. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + enum class Type { + TimeInterval, ///< Timer that triggers every time interval. Ex: every 5 seconds. + Rate, ///< Timer that triggers at a fixed rate. Ex: every 5 frames. + }; + + /// + /// @brief Construct a new Timer. + /// + /// @note The default timer is a Rate with a 0 interval, ie run every frame. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer(); + + /// + /// @brief Construct a new Timer with the given interval. + /// + /// @param[in] interval Interval of the timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer(Interval interval); + + /// + /// @brief Construct a new Timer with the given rate. + /// + /// @param[in] rate Rate of the timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer(std::uint32_t rate); + + /// + /// @brief Copy constructor. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer(const Timer &) = default; + + /// + /// @brief Move constructor. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer(Timer &&) = default; + + /// + /// @brief Destroy the Timer + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + ~Timer() = default; + + /// + /// @brief Copy assignment operator. + /// + /// @return Timer& + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer &operator=(const Timer &) = default; + + /// + /// @brief Move assignment operator. + /// + /// @return Timer& + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + Timer &operator=(Timer &&) = default; + + /// + /// @brief Get the Type of the timer. + /// + /// @return Type Type of the timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] constexpr Type getType() const noexcept + { + return _type; + } + + /// + /// @brief Get the last time the timer was triggered. + /// + /// @return TimePoint Last time the timer was triggered. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] constexpr TimePoint getLastTrigger() const noexcept + { + return _lastTrigger; + } + + /// + /// @brief Set the Rate of the timer. This will convert the timer to a Rate timer. + /// + /// @param[in] rate Rate of the timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + void setRate(std::uint32_t rate) noexcept; + + /// + /// @brief Get the Rate of the timer. + /// + /// @return std::uint32_t Rate of the timer. + /// + /// @throw std::runtime_error If the timer is not a Rate timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] std::uint32_t getRate() const; + + /// + /// @brief Set the Interval of the timer. This will convert the timer to an Interval timer. + /// + /// @param[in] interval Interval of the timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + void setInterval(Interval interval) noexcept; + + /// + /// @brief Get the Interval of the timer. + /// + /// @return Interval Interval of the timer. + /// + /// @throw std::runtime_error If the timer is not an Interval timer. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] Interval getInterval() const; + + /// + /// @brief Trigger the timer if it is time to do so. + /// + /// @warning This does not call the system or phase, it only checks if it is allowed to run now. + /// + /// @return bool @c true if the timer was triggered, @c false otherwise. + /// + /// @author Andréas Leroux (andreas.leroux@epitech.eu) + /// @since 1.0.0 (2024-11-22) + /// + [[nodiscard]] bool trigger() noexcept; + + private: + /// @brief Type of the timer. + Type _type; + /// @brief Last time the timer was triggered. + TimePoint _lastTrigger; + /// @brief Interval or rate of the timer. + union { + TimeInterval _timer; + Rate _rate; + }; + }; +} // namespace ecstasy + +#endif /* !ECSTASY_SYSTEM_TIMER_HPP_ */ diff --git a/tests/registry/tests_Registry.cpp b/tests/registry/tests_Registry.cpp index 2f8fb9a6e..f97f09f87 100644 --- a/tests/registry/tests_Registry.cpp +++ b/tests/registry/tests_Registry.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "ecstasy/query/conditions/include.hpp" #include "ecstasy/registry/Registry.hpp" #include "ecstasy/registry/modifiers/And.hpp" @@ -924,6 +925,97 @@ TEST(Registry, Pipeline_Implicit_Order) GTEST_ASSERT_EQ(testing::internal::GetCapturedStdout(), "ABCDEF"); } +TEST(Timer, Pipeline_system_timers_rates) +{ + ecstasy::Registry registry; + testing::internal::CaptureStdout(); + + // No timer, run every frame + registry.addSystem(); + // Run every 2 frames + ecstasy::ISystem &b = registry.addSystem(); + GTEST_ASSERT_EQ(b.getTimer().getType(), ecstasy::Timer::Type::Rate); + EXPECT_EQ(b.getTimer().getRate(), 1); + EXPECT_THROW(static_cast(b.getTimer().getInterval()), std::runtime_error); + b.getTimer().setRate(2); + EXPECT_EQ(b.getTimer().getRate(), 2); + EXPECT_THROW(static_cast(b.getTimer().getInterval()), std::runtime_error); + // Run every 4 frames + registry.addSystem().getTimer().setRate(4); + // Run every 8 frames + registry.addSystem().getTimer().setRate(8); + // Run every 16 frames + registry.addSystem().getTimer().setRate(16); + + for (int i = 0; i < 32; i++) { + registry.runSystems(); + } + // Every systems are runned at first frame, then their rate applies + GTEST_ASSERT_EQ( + testing::internal::GetCapturedStdout(), "ABCDEAABAABCAABAABCDAABAABCAABAABCDEAABAABCAABAABCDAABAABCAABA"); +} + +TEST(Timer, Pipeline_system_timers_intervals) +{ + ecstasy::Registry registry; + testing::internal::CaptureStdout(); + + // No timer, run every frame + registry.addSystem(); + + // Run every 2 frames + ecstasy::ISystem &b = registry.addSystem(); + b.getTimer().setInterval(std::chrono::milliseconds(200)); + GTEST_ASSERT_EQ(b.getTimer().getType(), ecstasy::Timer::Type::TimeInterval); + EXPECT_EQ(b.getTimer().getInterval(), std::chrono::milliseconds(200)); + EXPECT_THROW(static_cast(b.getTimer().getRate()), std::runtime_error); + + // Run every 4 frames + registry.addSystem().getTimer().setInterval(std::chrono::milliseconds(350)); + // Run every 8 frames + registry.addSystem().getTimer().setInterval(std::chrono::milliseconds(800)); + // Run every 16 frames + registry.addSystem().getTimer().setInterval(std::chrono::seconds(1)); + + auto start = std::chrono::system_clock::now(); + auto end = std::chrono::system_clock::now(); + long loops = 0; + while (start + std::chrono::seconds(3) > end) { + ++loops; + registry.runSystems(); + if (loops == 1) + start = std::chrono::system_clock::now(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + end = std::chrono::system_clock::now(); + } + + std::string captured = testing::internal::GetCapturedStdout(); + + auto nb_a = std::count_if(captured.begin(), captured.end(), [](char c) { + return c == 'A'; + }); + auto nb_b = std::count_if(captured.begin(), captured.end(), [](char c) { + return c == 'B'; + }); + auto nb_c = std::count_if(captured.begin(), captured.end(), [](char c) { + return c == 'C'; + }); + auto nb_d = std::count_if(captured.begin(), captured.end(), [](char c) { + return c == 'D'; + }); + auto nb_e = std::count_if(captured.begin(), captured.end(), [](char c) { + return c == 'E'; + }); + auto nb_ms = std::chrono::duration_cast(end - start).count(); + + // Not sure about these values, but they should be close + GTEST_ASSERT_EQ(nb_a, loops); + ASSERT_NEAR(nb_b, nb_ms / 200, 1); + ASSERT_NEAR(nb_c, nb_ms / 350, 1); + ASSERT_NEAR(nb_d, nb_ms / 800, 1); + ASSERT_NEAR(nb_e, nb_ms / 1000, 1); +} + TEST(Registry, Pipeline_Explicit_Order) { ecstasy::Registry registry;