Skip to content

Make Beatoraja Smooth Again#168

Open
Arctice wants to merge 8 commits intoseraxis:mainfrom
Arctice:smooth-criminal
Open

Make Beatoraja Smooth Again#168
Arctice wants to merge 8 commits intoseraxis:mainfrom
Arctice:smooth-criminal

Conversation

@Arctice
Copy link
Copy Markdown
Contributor

@Arctice Arctice commented Nov 27, 2025

An effort to investigate and fix beatoraja's long-standing timing issues.

Input polling frequency

I have found that, as has been suggested by some players, input processing was indeed synchronized to the game's framerate, and not running at 1000Hz as the intent of the polling implementation would suggest. This is the case for all platforms, and both controller as well as keyboard.
The reason for this differs slightly between keyboard and controller. MainController spawns a thread that calls BMSPlayerInputProcessor.poll() at a high frequency, which in turn uses KeyBoardInputProcesseor.poll() and BMControllerInputProcessor.poll() to update the input device state.
However, the keyboard state that KeyBoardInputProcesseor checks with Gdx.input.isKeyPressed is only internally updated by GLFW.glfwPollEvents();, which is only called in the main thread, in a Lwjgl3Application main loop with the game's render calls.
Similarly, BMControllerInputProcessor.poll can only read whatever state has been set by the polling function spawned by Lwjgl3ControllerManager's constructor, which itself used Lwjgl3Application.postRunnable to synchronize its polling with the render loop.

Screenshot_2025-11-27_15-49-04

(Previously, playing a song with a BPM that aliases to the framerate would produce obvious input grouping)

Solution

Moving GLFW event and input device polling to their own thread isn't possible - according to its documentation, all of those functions may only be called from the application's main thread.
It is technically possible to configure Lwjgl3Application to use non-continuous rendering, which seemingly would allow polling more frequently in between the render calls, but this would still suffer from timing issues under VSync or with slower rendering.
Short of entirely replacing our windowing or input device backends, the only solution I've found that seemed viable is moving rendering to its own thread, allowing polling to continue at a high rate on the main thread.
This required modifying Lwjgl3Application - I've adapted it as ParallelApplication, and for ease of review I include a diff between the two classes at the end of this PR's description. It spawns the rendering thread after window creation, passing it the ownership of the OpenGL context. Analogously to postRunnable, it provides a postPollRunnable function that allows scheduling code to run in the polling loop of the main thread, which I've limited to run at 1kHz.
Lwjgl3ControllerManager now polls for controller state at high rates using postPollRunnable, while keyboard input naturally became more responsive with no further changes.

Before After
Judgement graph
image image
Grouping of inputs around a 5500μs frame
image image

In order to make window resizing work properly with this new arrangement, MainLoader calls

Configuration.GLFW_LIBRARY_NAME.set("glfw_async")

which configures our Lwjgl3Graphics backend to synchronize the window resize code with render calls (doing it on the main thread would cause race conditions and wouldn't work without the GL context, which is exclusive to the rendering thread).
Unfortunately, despite several attempts, I was unable to get any of this to work on MacOS. Instead, I've specialized ParallelApplication's behaviour on MacOS to run rendering and polling serially, as it originally did. Until we find a solution to this, Mac players will simply need to run the game at high framerates to reach an appropriate input polling frequency.

VSync frame instability

My testing of beatoraja's VSync showed that the frame timings produced by it an exhibit an alternating pattern - for example, synchronization to 60Hz could result in alternating ~14ms and ~19ms frames, which always correctly adds up to the expected average of 16.66ms, but ends up producing visible stuttering in the movement of notes during play.
After trying out several approaches, as the solution I have decided to adopt - with its author's permission - the upstream PR #835, which augments the time delta values used by skin rendering to significantly improve the apparent smoothness of note motion. This change only takes effect with VSync on, and is only a visual improvement, with no changes to the game's logic.

video

Other changes

I've experimentally included a change to how the main TimerManager object's update() is called, moving it to the polling thread, which should reduce judge processing latency and allow keysounds to no longer by synchronized to rendering. Requires further testing to make sure that this doesn't cause any issues.

I've slightly refactored ImGui, making it so that its rendering functions are only called when there are notifications to be displayed or the mod menu is open. Saves about ~100μs during rendering, which is minor but I thought we have no reason not to do this.

Garbage Collector

I've briefly tested the various GC algorithms provided by the JVM, and found that the default G1 garbage collector nearly always causes untolerably long pauses during play - frequently as high as 20 or 30 milliseconds, and in some rare cases even higher. These are easily long enough to be very noticeable during play.
In contrast, the latency-focused ZGC and Shenandoah garbage collectors both provided much more stable performance. My testing was not rigorous enough to be able to tell much of a difference between the performance of the two, but both are clearly far better than G1.
This is unfortunate in that currently, Endless Dream has no control over the JVM's launch settings, and the widely used launch scripts do not change the default garbage collector, which is nearly certain to cause stuttering during play. For now, the best we can do is actively inform users to update their scripts to include either -XX:+UseZGC or -XX:+UseShenandoahGC.
Future work might allow us to have more control over how the game is launched, and we could also try to minimize how much memory is allocated during play in the first place to allow the GC to do less work.
For now, I've included a call to System.gc() (which does induce a long pause, up to 100ms) at play scene startup in hopes that it will minimize some of the stutter that seems to be frequent early in a song.

Needs testing

I've done all of the testing exclusively on my personal Linux machines, and the proposed changes are major. We'll need lots of testing to find any possible issues and to verify that the changes work on other platforms.

--- /tmp/scorSfW58n	2025-11-27 15:21:44.786737555 +0100
+++ /tmp/scorSvjCV1	2025-11-27 15:21:44.786737555 +0100
@@ -14,6 +14,8 @@
  * limitations under the License.
  ******************************************************************************/
 
+// Adapted from Lwjgl3Application for beatoraja to improve event polling frequency
+
 package com.badlogic.gdx.backends.lwjgl3;
 
 import java.io.File;
@@ -58,7 +60,7 @@
 import com.badlogic.gdx.utils.ObjectMap;
 import com.badlogic.gdx.utils.SharedLibraryLoader;
 
-public class Lwjgl3Application implements Lwjgl3ApplicationBase {
+public class ParallelApplication implements Lwjgl3ApplicationBase {
 	private final Lwjgl3ApplicationConfiguration config;
 	final Array<Lwjgl3Window> windows = new Array<Lwjgl3Window>();
 	private volatile Lwjgl3Window currentWindow;
@@ -72,11 +74,14 @@
 	private volatile boolean running = true;
 	private final Array<Runnable> runnables = new Array<Runnable>();
 	private final Array<Runnable> executedRunnables = new Array<Runnable>();
+	private final Array<Runnable> pollRunnables = new Array<Runnable>();
+	private final Array<Runnable> executedPollRunnables = new Array<Runnable>();
 	private final Array<LifecycleListener> lifecycleListeners = new Array<LifecycleListener>();
 	private static GLFWErrorCallback errorCallback;
 	private static GLVersion glVersion;
 	private static Callback glDebugCallback;
 	private final Sync sync;
+	private final Sync pollSync;
 
 	static void initializeGlfw () {
 		if (errorCallback == null) {
@@ -116,11 +121,11 @@
 		}
 	}
 
-	public Lwjgl3Application (ApplicationListener listener) {
+	public ParallelApplication (ApplicationListener listener) {
 		this(listener, new Lwjgl3ApplicationConfiguration());
 	}
 
-	public Lwjgl3Application (ApplicationListener listener, Lwjgl3ApplicationConfiguration config) {
+	public ParallelApplication (ApplicationListener listener, Lwjgl3ApplicationConfiguration config) {
 		if (config.glEmulation == Lwjgl3ApplicationConfiguration.GLEmulation.ANGLE_GLES20) loadANGLE();
 		initializeGlfw();
 		setApplicationLogger(new Lwjgl3ApplicationLogger());
@@ -145,13 +150,20 @@
 		this.clipboard = new Lwjgl3Clipboard();
 
 		this.sync = new Sync();
+		this.pollSync = new Sync();
 
 		Lwjgl3Window window = createWindow(config, listener, 0);
 		if (config.glEmulation == Lwjgl3ApplicationConfiguration.GLEmulation.ANGLE_GLES20) postLoadANGLE();
 		windows.add(window);
+        boolean isMacOS = System.getProperty("os.name").toLowerCase().contains("mac");
 		try {
+            if (isMacOS) { serialLoop(); }
+            else {
+                startRendering();
 			loop();
-			cleanupWindows();
+                stopRendering();
+            }
+            // cleanupWindows();
 		} catch (Throwable t) {
 			if (t instanceof RuntimeException)
 				throw (RuntimeException)t;
@@ -162,92 +174,129 @@
 		}
 	}
 
-	protected void loop () {
-		Array<Lwjgl3Window> closedWindows = new Array<Lwjgl3Window>();
-		while (running && windows.size > 0) {
-			// FIXME put it on a separate thread
-			audio.update();
+    private Thread renderThread = null;
 
-			boolean haveWindowsRendered = false;
-			closedWindows.clear();
-			int targetFramerate = -2;
-			for (Lwjgl3Window window : windows) {
-				if (currentWindow != window) {
+    private void startRendering() {
+        Lwjgl3Window window = windows.get(0);
+        int targetFramerate = window.getConfig().foregroundFPS;
+
+        renderThread = new Thread(() -> {
 					window.makeCurrent();
-					currentWindow = window;
-				}
-				if (targetFramerate == -2) targetFramerate = window.getConfig().foregroundFPS;
-				synchronized (lifecycleListeners) {
-					haveWindowsRendered |= window.update();
+            if (config.glEmulation == Lwjgl3ApplicationConfiguration.GLEmulation.ANGLE_GLES20) {
+                try {
+                    Class gles = Class.forName("org.lwjgl.opengles.GLES");
+                    gles.getMethod("createCapabilities").invoke(gles);
 				}
-				if (window.shouldClose()) {
-					closedWindows.add(window);
+                catch (Throwable e) {
+                    throw new GdxRuntimeException("Couldn't initialize GLES", e);
 				}
 			}
-			GLFW.glfwPollEvents();
+            else { GL.createCapabilities(); }
 
-			boolean shouldRequestRendering;
+            while (running) {
+                synchronized (lifecycleListeners) { window.update(); }
 			synchronized (runnables) {
-				shouldRequestRendering = runnables.size > 0;
 				executedRunnables.clear();
 				executedRunnables.addAll(runnables);
 				runnables.clear();
 			}
-			for (Runnable runnable : executedRunnables) {
-				runnable.run();
-			}
-			if (shouldRequestRendering) {
-				// Must follow Runnables execution so changes done by Runnables are reflected
-				// in the following render.
-				for (Lwjgl3Window window : windows) {
-					if (!window.getGraphics().isContinuousRendering()) window.requestRendering();
-				}
+                for (Runnable runnable : executedRunnables) { runnable.run(); }
+                if (window.shouldClose()) { break; }
+                if (targetFramerate > 0) { sync.sync(targetFramerate); }
 			}
 
-			for (Lwjgl3Window closedWindow : closedWindows) {
-				if (windows.size == 1) {
-					// Lifecycle listener methods have to be called before ApplicationListener methods. The
-					// application will be disposed when _all_ windows have been disposed, which is the case,
-					// when there is only 1 window left, which is in the process of being disposed.
+            // Lifecycle listener methods have to be called before ApplicationListener methods
 					for (int i = lifecycleListeners.size - 1; i >= 0; i--) {
 						LifecycleListener l = lifecycleListeners.get(i);
 						l.pause();
 						l.dispose();
 					}
 					lifecycleListeners.clear();
+
+            window.dispose();
+            running = false;
+        });
+
+        GLFW.glfwMakeContextCurrent(0);
+        renderThread.start();
 				}
-				closedWindow.dispose();
 
-				windows.removeValue(closedWindow, false);
+    private void stopRendering() {
+        if (renderThread == null) return;
+        try { renderThread.join(); }
+        catch (Exception e) { e.printStackTrace(); }
 			}
 
-			if (!haveWindowsRendered) {
-				// Sleep a few milliseconds in case no rendering was requested
-				// with continuous rendering disabled.
-				try {
-					Thread.sleep(1000 / config.idleFPS);
-				} catch (InterruptedException e) {
-					// ignore
+    protected void loop () {
+		while (running) {
+			audio.update();
+            GLFW.glfwPollEvents();
+
+            synchronized (pollRunnables) {
+                executedPollRunnables.clear();
+                executedPollRunnables.addAll(pollRunnables);
+                pollRunnables.clear();
 				}
-			} else if (targetFramerate > 0) {
-				sync.sync(targetFramerate); // sleep as needed to meet the target framerate
+            for (Runnable runnable : executedPollRunnables) { runnable.run(); }
+
+            pollSync.sync(1000);
 			}
 		}
+
+    protected void serialLoop () {
+        Lwjgl3Window window = windows.get(0);
+        int targetFramerate = window.getConfig().foregroundFPS;
+        window.makeCurrent();
+
+		while (running) {
+			audio.update();
+            GLFW.glfwPollEvents();
+
+            synchronized (lifecycleListeners) { window.update(); }
+
+            synchronized (runnables) {
+                executedRunnables.clear();
+                executedRunnables.addAll(runnables);
+                runnables.clear();
 	}
+            for (Runnable runnable : executedRunnables) { runnable.run(); }
 
-	protected void cleanupWindows () {
-		synchronized (lifecycleListeners) {
-			for (LifecycleListener lifecycleListener : lifecycleListeners) {
-				lifecycleListener.pause();
-				lifecycleListener.dispose();
+            synchronized (pollRunnables) {
+                executedPollRunnables.clear();
+                executedPollRunnables.addAll(pollRunnables);
+                pollRunnables.clear();
 			}
+            for (Runnable runnable : executedPollRunnables) { runnable.run(); }
+
+            if (window.shouldClose()) { break; }
+            if (targetFramerate > 0) { sync.sync(targetFramerate); }
+            else { pollSync.sync(1000); }
 		}
-		for (Lwjgl3Window window : windows) {
-			window.dispose();
+
+        // Lifecycle listener methods have to be called before ApplicationListener methods
+        for (int i = lifecycleListeners.size - 1; i >= 0; i--) {
+            LifecycleListener l = lifecycleListeners.get(i);
+            l.pause();
+            l.dispose();
 		}
-		windows.clear();
+        lifecycleListeners.clear();
+
+        window.dispose();
 	}
 
+	// protected void cleanupWindows () {
+	// 	synchronized (lifecycleListeners) {
+	// 		for (LifecycleListener lifecycleListener : lifecycleListeners) {
+	// 			lifecycleListener.pause();
+	// 			lifecycleListener.dispose();
+	// 		}
+	// 	}
+	// 	for (Lwjgl3Window window : windows) {
+	// 		window.dispose();
+	// 	}
+	// 	windows.clear();
+	// }
+
 	protected void cleanup () {
 		Lwjgl3Cursor.disposeSystemCursors();
 		audio.dispose();
@@ -377,6 +426,12 @@
 		return clipboard;
 	}
 
+	public void postPollRunnable (Runnable runnable) {
+		synchronized (runnables) {
+			pollRunnables.add(runnable);
+		}
+	}
+
 	@Override
 	public void postRunnable (Runnable runnable) {
 		synchronized (runnables) {

@RaceDriverMIKU
Copy link
Copy Markdown

Can you provide jar file? I really want to test this.

@Arctice
Copy link
Copy Markdown
Contributor Author

Arctice commented Dec 11, 2025

Join ED discord and ping me (or DM at arc.outerheaven).

@Arctice
Copy link
Copy Markdown
Contributor Author

Arctice commented Dec 11, 2025

So far in testing it's been noted that these changes do also significantly reduce input latency.
There is also one known rare crash:
image

Copy link
Copy Markdown
Owner

@seraxis seraxis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good. Waiting on tests and bugfixes as discussed internally.

phu54321 and others added 8 commits December 12, 2025 02:32
Vsync doesn't really ensure evenly timed `MainController::render()` calls.
They jitter, and they jitter a lot. More than you'd expect. (You can
measure it yourself) This commit adds `TimeSmoother` that *smooths*
jittered time when vsync is enabled.

If we don't have vsync, It is expected to have frame drops or changes in
render() call interval, so `TimeSmoother` is not enabled when vsync is off.

feat(VsyncSmoother): utilize current display Hz

feat(VsyncSmoother): prevent double smoothing of time
Copy link
Copy Markdown
Collaborator

@Catizard Catizard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic work

@zkldi
Copy link
Copy Markdown

zkldi commented Dec 20, 2025

Brilliant stuff. I can vouch for the accuracy of this implementation as it's basically identical to what Zenith does.

@BKVad1m
Copy link
Copy Markdown

BKVad1m commented Jan 24, 2026

the problem continues due input dropped on MIDI keyboard

@seraxis
Copy link
Copy Markdown
Owner

seraxis commented Feb 23, 2026

What's the status of this?

@Arctice
Copy link
Copy Markdown
Contributor Author

Arctice commented Feb 23, 2026

I need to fix bugs that were discovered in testing, and I intend to add config options to let the player select which adjustment options they prefer, since it turned out on some systems these changes do cause issues. So instead of testing all possible configurations to try and find something that works for everyone, we'll instead go with reasonable defaults and let the player disable these features.

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.

7 participants