diff --git a/application/application.cpp b/application/application.cpp index 9b7b8285..64693b4d 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -28,6 +28,7 @@ http://mozilla.org/MPL/2.0/. #include "utilities/translation.h" #include "vehicle/Train.h" #include "utilities/Timer.h" +#include "utilities/framepacer.h" #include "utilities/dictionary.h" #include "version_info.h" #include @@ -479,9 +480,14 @@ bool eu07_application::is_client() const int eu07_application::run() { auto frame{0}; + frame_pacer framepacer; + Timer::subsystem.mainloop_period.start(); // main application loop while (!glfwWindowShouldClose(m_windows.front()) && !m_modestack.empty()) { + // the time from one frame's start to the next, the wait for fpslimit included + Timer::subsystem.mainloop_period.stop(); + Timer::subsystem.mainloop_period.start(); Timer::subsystem.mainloop_total.start(); glfwPollEvents(); @@ -641,10 +647,11 @@ int eu07_application::run() if (m_network) m_network->update(); - auto const frametime{Timer::subsystem.mainloop_total.stop()}; - if (Global.minframetime.count() != 0.0f && (Global.minframetime - frametime).count() > 0.0f) + Timer::subsystem.mainloop_total.stop(); + // fpslimit: wait for the next frame's slot (eu07/maszyna#416) + if (auto const nextframe{framepacer.next_frame(frame_pacer::clock::now(), Global.minframetime)}) { - std::this_thread::sleep_for(Global.minframetime - frametime); + std::this_thread::sleep_until(*nextframe); } } // Handled by application.exit diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index c24f750c..aa37c83e 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -5663,12 +5663,15 @@ void opengl33_renderer::Update(double const Deltatime) } m_updateaccumulator = 0.0; - m_framerate = 1000.f / ( Timer::subsystem.mainloop_total.average() ); + // the frames actually shown, the wait for fpslimit included (eu07/maszyna#416) + m_framerate = 1000.f / ( Timer::subsystem.mainloop_period.average() ); + // the frames the work alone would allow, so a frame rate held down by fpslimit doesn't cut the draw ranges + auto const workframerate{1000.f / Timer::subsystem.mainloop_total.average()}; // adjust draw ranges etc, based on recent performance if (Global.targetfps != 0.0f) { - float fps_diff = Global.targetfps - m_framerate; + float fps_diff = Global.targetfps - workframerate; if (fps_diff > 0.0f) Global.fDistanceFactor = std::max(0.5f, Global.fDistanceFactor - 0.05f); else @@ -5676,7 +5679,7 @@ void opengl33_renderer::Update(double const Deltatime) } // legacy framerate parameters else if (Global.fFpsAverage != 0.f) { - auto const fps_diff = m_framerate - Global.fFpsAverage; + auto const fps_diff = workframerate - Global.fFpsAverage; if (fps_diff < -Global.fFpsDeviation) { Global.fDistanceFactor = std::min(std::max(1.0f, Global.fDistanceFactor - 0.05f), Global.gfx_distance_factor_max); } diff --git a/rendering/openglrenderer.cpp b/rendering/openglrenderer.cpp index 180dba77..e09aad91 100644 --- a/rendering/openglrenderer.cpp +++ b/rendering/openglrenderer.cpp @@ -4327,12 +4327,15 @@ opengl_renderer::Update( double const Deltatime ) { } m_updateaccumulator = 0.0; - m_framerate = 1000.f / ( Timer::subsystem.gfx_total.average() ); + // the frames actually shown, the wait for fpslimit included (eu07/maszyna#416) + m_framerate = 1000.f / ( Timer::subsystem.mainloop_period.average() ); + // the draw ranges follow the time spent on drawing alone, as before + auto const drawframerate { 1000.f / ( Timer::subsystem.gfx_total.average() ) }; // adjust draw ranges etc, based on recent performance // TODO: it doesn't make much sense with vsync if( Global.targetfps != 0.f ) { - auto const fps_diff = m_framerate - Global.targetfps; + auto const fps_diff = drawframerate - Global.targetfps; if( fps_diff < -0.5f ) { Global.fDistanceFactor = std::min( std::max( 1.0f, Global.fDistanceFactor - 0.05f ), Global.gfx_distance_factor_max ); } @@ -4342,7 +4345,7 @@ opengl_renderer::Update( double const Deltatime ) { } // legacy framerate parameters else if( Global.fFpsAverage != 0.f ) { - auto const fps_diff = m_framerate - Global.fFpsAverage; + auto const fps_diff = drawframerate - Global.fFpsAverage; if( fps_diff < -Global.fFpsDeviation ) { Global.fDistanceFactor = std::min( std::max( 1.0f, Global.fDistanceFactor - 0.05f ), Global.gfx_distance_factor_max ); } diff --git a/tests/unit/test_framepacer.cpp b/tests/unit/test_framepacer.cpp new file mode 100644 index 00000000..bb9acb02 --- /dev/null +++ b/tests/unit/test_framepacer.cpp @@ -0,0 +1,110 @@ +#include "stdafx.h" +#include "doctest.h" +#include "utilities/framepacer.h" + +// the main loop's frame limiter (fpslimit in eu07.ini, eu07/maszyna#416), driven with made-up clock readings +namespace +{ +using namespace std::chrono_literals; +using clock_type = frame_pacer::clock; + +clock_type::time_point const kStart{clock_type::time_point{} + 100s}; +constexpr clock_type::duration kFrame{std::chrono::duration_cast(std::chrono::duration(1.f / 30.f))}; + +// runs frames that take Work each, with every wait overshooting by Oversleep; returns when the last of Frames ends +clock_type::time_point run_frames(frame_pacer &Pacer, int const Frames, clock_type::duration const Work, clock_type::duration const Oversleep) +{ + auto now{kStart}; + for (auto frame = 0; frame < Frames; ++frame) + { + now += Work; + if (auto const next{Pacer.next_frame(now, kFrame)}) + { + CHECK(*next > now); + now = *next + Oversleep; + } + } + return now; +} +} // namespace + +TEST_CASE("frame pacer: without a limit nothing waits") +{ + frame_pacer pacer; + CHECK_FALSE(pacer.next_frame(kStart, clock_type::duration::zero()).has_value()); + CHECK_FALSE(pacer.next_frame(kStart + 1ms, clock_type::duration::zero()).has_value()); +} + +TEST_CASE("frame pacer: a quick frame waits out the rest of the frame time") +{ + frame_pacer pacer; + // the first frame only sets the cadence + CHECK_FALSE(pacer.next_frame(kStart, kFrame).has_value()); + auto const next{pacer.next_frame(kStart + 5ms, kFrame)}; + REQUIRE(next.has_value()); + CHECK(*next == kStart + kFrame); +} + +TEST_CASE("frame pacer: a slow frame doesn't wait") +{ + frame_pacer pacer; + CHECK_FALSE(pacer.next_frame(kStart, kFrame).has_value()); + CHECK_FALSE(pacer.next_frame(kStart + kFrame + 5ms, kFrame).has_value()); +} + +TEST_CASE("frame pacer: the frame rate holds the limit with fast frames") +{ + frame_pacer pacer; + auto const end{run_frames(pacer, 301, 2ms, clock_type::duration::zero())}; + // 300 paced frames after the first + CHECK(end == kStart + 2ms + 300 * kFrame); +} + +TEST_CASE("frame pacer: a wait that overshoots is made up in the next frame") +{ + // the windows timer wakes a sleeping thread up to ~15.6 ms late; sleeping off what is left of each frame + // then drops 30 fps to about 22. on a fixed cadence the frame rate still averages to the limit + frame_pacer pacer; + auto const end{run_frames(pacer, 301, 2ms, 12ms)}; + auto const elapsed{std::chrono::duration(end - kStart).count()}; + CHECK(300.0 / elapsed == doctest::Approx(30.0).epsilon(0.01)); +} + +TEST_CASE("frame pacer: after a long frame the cadence starts anew instead of catching up") +{ + frame_pacer pacer; + CHECK_FALSE(pacer.next_frame(kStart, kFrame).has_value()); + // a hitch of half a second + auto const late{kStart + 500ms}; + CHECK_FALSE(pacer.next_frame(late, kFrame).has_value()); + // the next quick frame waits a whole frame time from there rather than running at once + auto const next{pacer.next_frame(late + 1ms, kFrame)}; + REQUIRE(next.has_value()); + CHECK(*next == late + kFrame); +} + +TEST_CASE("frame pacer: turning the limit off and on again starts a new cadence") +{ + frame_pacer pacer; + CHECK_FALSE(pacer.next_frame(kStart, kFrame).has_value()); + CHECK_FALSE(pacer.next_frame(kStart + 1ms, clock_type::duration::zero()).has_value()); + CHECK_FALSE(pacer.next_frame(kStart + 2s, kFrame).has_value()); + auto const next{pacer.next_frame(kStart + 2s + 1ms, kFrame)}; + REQUIRE(next.has_value()); + CHECK(*next == kStart + 2s + kFrame); +} + +TEST_CASE("frame pacer: the wait stays exact after days of system uptime") +{ + // fpslimit 60 gives the frame time as float seconds; the old limiter handed the float rest of the frame + // to sleep_for, which adds it to steady_clock::now() in float nanoseconds. after 95 hours of uptime that + // rounds the wake-up time to steps of ~33 ms, and a 14 ms wait came out as no wait at all + frame_pacer pacer; + std::chrono::duration const frametime{1.f / 60.f}; + auto const uptime{clock_type::time_point{} + 95h}; + CHECK_FALSE(pacer.next_frame(uptime, frametime).has_value()); + auto const next{pacer.next_frame(uptime + 2ms, frametime)}; + REQUIRE(next.has_value()); + auto const wait{std::chrono::duration(*next - (uptime + 2ms)).count()}; + CHECK(wait == doctest::Approx(1000.0 / 60.0 - 2.0).epsilon(0.001)); +} diff --git a/utilities/Timer.h b/utilities/Timer.h index 32e170a4..c63ef14a 100644 --- a/utilities/Timer.h +++ b/utilities/Timer.h @@ -64,6 +64,7 @@ struct subsystem_stopwatches { stopwatch sim_events; stopwatch sim_ai; stopwatch mainloop_total; + stopwatch mainloop_period; // start to start, the wait for fpslimit included }; extern subsystem_stopwatches subsystem; diff --git a/utilities/framepacer.h b/utilities/framepacer.h new file mode 100644 index 00000000..a0de0144 --- /dev/null +++ b/utilities/framepacer.h @@ -0,0 +1,59 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include + +// paces the main loop to the frame time set by fpslimit in eu07.ini. +// the frames are scheduled on a fixed cadence rather than by sleeping off what is left of each frame, +// so a sleep that overshoots (the windows timer wakes up in steps of up to ~15.6 ms) is made up by +// a shorter wait in the next frame, and the frame rate averages to the limit. +// all the time arithmetic is done in the clock's integer ticks: a sleep_for() given a float duration +// adds it to now() as float nanoseconds, which after a few hours of system uptime rounds the wake-up +// time to tens of milliseconds and lets the limit fall through (eu07/maszyna#416) +class frame_pacer { + +public: + using clock = std::chrono::steady_clock; + + // called at the end of each frame. returns the point in time the next frame may start at, + // or nothing when it may start right away + [[nodiscard]] std::optional next_frame(clock::time_point const Now, std::chrono::duration const Frametime) { + return next_frame(Now, std::chrono::duration_cast(Frametime)); + } + [[nodiscard]] std::optional next_frame(clock::time_point const Now, clock::duration const Minframetime) { + if (Minframetime <= clock::duration::zero()) { + // no limit + m_framestart.reset(); + return std::nullopt; + } + if (!m_framestart) { + // first paced frame + m_framestart = Now; + return std::nullopt; + } + auto const nextframe{*m_framestart + Minframetime}; + if (Now >= nextframe + Minframetime) { + // a whole frame or more behind: start the cadence anew rather than run frames back to back to catch up + m_framestart = Now; + return std::nullopt; + } + m_framestart = nextframe; + if (Now >= nextframe) { + return std::nullopt; + } + return nextframe; + } + +private: + // the scheduled start of the current frame + std::optional m_framestart; +};