Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cap main loop elapsed time instead of resetting it #3178

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

mstoeckl
Copy link
Contributor

This is a small tweak to the main game update loop that I've found to improve frame pacing, and increase logic steps/frame, in high lag scenarios where the game cannot maintain real time updates.

This change adjusts the way the main loop handles the game logic updates
falling significantly behind real time. There are three main ways this
can occur: a single, very slow operation (like loading a level);
consistently slow game logic updates; and consistently slow rendering.
By limiting the maximum number of logic steps per frame to 4, the main
loop couples rendering and game updates together, making it practical to
treat them being slow in the same way.

Previously, when the `elapsed_time` difference between real-ish time
and game time grew too large (> 8 steps), it would be reset to zero.
This works OK for transient slow operations, but when the game logic
or rendering runs just a bit slower than real time, the `elapsed_time`
difference would slowly grow until hitting the threshold, be reset to
zero, and then repeat, leading to a sort of sawtooth pattern. The number
of game steps run in each loop iteration is roughly proportional to
`elapsed_time` (with an upper limit), so the number of game steps per
drawn frame would also have a sawtooth pattern (like 1,2,3,3,4,4,1...),
perceivable as periodically shaky motion of the rendered scene.

By capping the `elapsed_time` to a fixed value, instead of resetting
it, this change eliminates the sawtooth pattern, making the game run
more evenly. (It also now runs faster, when logic or render is slow,
because the main loop can consistently use 3 or 4 steps per frame.)

The cost is that, on normal transient lag spikes (startup, level load,
sector switch) there may be multiple steps for the frame instead of
zero; however, such lag spikes are rare, concealed by loading screens
or safe regions, and do not occur during critical moments of game play.
@mstoeckl
Copy link
Contributor Author

mstoeckl commented Jan 15, 2025

To test this, use an old computer + high resolution screen, render inefficiently (e.g. by setting LIBGL_ALWAYS_SOFTWARE=1 on Linux), and/or run a level with a lot of lag (e.g. level heavily using magic blocks or which is very zoomed out) that runs at below 16fps.

Or modify the code to introduce lag. I tested this change using variations on following patch:

diff --git a/src/supertux/screen_manager.cpp b/src/supertux/screen_manager.cpp
index 7f5f0082f..6f8a456b3 100644
--- a/src/supertux/screen_manager.cpp
+++ b/src/supertux/screen_manager.cpp
@@ -46,6 +46,7 @@
 #include <stdio.h>
 #include <chrono>
 #include <iostream>
+#include <unistd.h>
 
 #ifdef __EMSCRIPTEN__
 #include <emscripten.h>
@@ -556,6 +557,7 @@ void ScreenManager::loop_iter()
   g_real_time += 1e-9f * static_cast<float>(nsecs);
   last_time = now;
 
+  std::cerr << "elapsed time: " << elapsed_time;
   float max_elapsed_time = 4 * seconds_per_step;
   if (elapsed_time > max_elapsed_time) {
     // when the game loads up or levels are switched the elapsed_ticks grows
@@ -570,6 +572,7 @@ void ScreenManager::loop_iter()
     // Sleep a bit because not enough time has passed since the previous
     // logical game step
     SDL_Delay(static_cast<Uint32>(1000.0f * (seconds_per_step - elapsed_time)));
+    std::cerr << std::endl;
     return;
   }
 
@@ -603,6 +606,7 @@ void ScreenManager::loop_iter()
     steps = std::min<int>(steps, max_steps_per_frame);
   }
 
+  std::cerr << " steps: " << steps << std::endl;
   for (int i = 0; i < steps; ++i) {
     // Perform a logical game step; seconds_per_step is set to a fixed value
     // so that the game is deterministic.
@@ -611,6 +615,7 @@ void ScreenManager::loop_iter()
     float dtime = seconds_per_step * m_speed * speed_multiplier;
     g_game_time += dtime;
     process_events();
+    usleep(0.8 * seconds_per_step * 1e6); // add 0.8 steps of lag per step
     update_gamelogic(dtime);
     elapsed_time -= seconds_per_step;
   }
@@ -624,6 +629,7 @@ void ScreenManager::loop_iter()
       || always_draw) {
     // Draw a frame
     Compositor compositor(m_video_system, g_config->frame_prediction ? time_offset : 0.0f);
+    usleep(1.1 * seconds_per_step * 1e6); // add 1.1 steps of lag per frame
     draw(compositor, *m_fps_statistics);
     m_fps_statistics->report_frame();
   }

@swagtoy
Copy link
Contributor

swagtoy commented Jan 17, 2025

Could you test the frame prediction feature?

EDIT: Taking context from the last PR I am confident you have probably tested it already. Code LGTM but I have not tested these.

@Brockengespenst
Copy link
Contributor

I did a quick test of the changes and did not see any problems with it.

@tobbi
Copy link
Member

tobbi commented Jan 18, 2025

I also tested this and it seemed to be working fine, though I did not test frame prediction.

@mstoeckl
Copy link
Contributor Author

Could you test the frame prediction feature?
EDIT: Taking context from the last PR I am confident you have probably tested it already. [...]

Indeed, I have been testing this mainly with frame prediction on, but also sometimes with it off.

Also: another way to test this change is to artificially limit the frame rate using gamescope: for example, with gamescope -r 10 -- supertux2 --show-fps. (Although gamescope's frame rate limiter is not too reliable / may have weird interactions; it sometimes is off by 10 fps or so.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants