From bd94e8141f17b902c39f6300948bb4bc2a4fc747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 8 Oct 2026 06:47:42 +0200 Subject: [PATCH] Android: Converge the GL and Vulkan lifecycles on a shared RunGraphicsLoop Stage 5 of the JavaGL removal. Now that the GL path owns its own EGL context and render thread, Android's two lifecycle branches and the Win32 one in MainThreadFunc were the same code three times over, so this moves the branch into one place - RunGraphicsLoop() in Core/EmuThread.cpp. It picks the thread arrangement the context asks for: either this thread becomes the render thread and spawns the emu thread (OpenGL), or it just runs the main loop because the backend spawns its own render thread (Vulkan, D3D11). Android, Windows and headless all go through it now, which is what the "TODO: Merge with the Win32 EmuThread and so on" in app-android.cpp was asking for. Falls out of having one implementation instead of three: - MainThreadFunc called graphicsContext->ThreadStart() right after EmuThread_Start(), which already calls it, and ThreadEnd() right after EmuThread_Join(), which already calls that too. So Windows and headless GL ran CreateDeviceObjects()/DestroyDeviceObjects() twice per context, leaking a VAO on the way in and double-deleting it on the way out. Dates back to 16206d18ab, which moved those calls into EmuThread_Start/Join without removing the originals. - RECREATE_VIEWS is gone. Only the GL path ever posted it; Vulkan goes through the same destroy/recreate without it and always has. Verified on device: views survive pause/resume, and both a real rotation and a density change - each one an activity destroy and recreate - lay them out again by themselves. - The non-separate path is now named EmuThread rather than MainThread on Windows. It's the thread that runs NativeFrame(), which is what DebuggerThreading.md calls the emu thread, and the real main thread there is the GUI message pump. The loop keeps an explicit exit flag, as a shouldExit callback, rather than relying on ThreadFrame() returning false. In VR we skip ThreadFrame() entirely while the XR session is idle, and the session going idle is exactly what backgrounding the app does - so the exit would never be observed, the render thread would sit in that sleep forever, and the UI thread joining it would hang. That is not hypothetical: it deadlocked a Quest 2 on the first try. Also drops a dead EmuThreadState enum and an unused JNIEnv local in app-android.cpp, both orphaned when the JavaGL JNI entry points went away. Tested on a nubia Z2464N (Mali-G57, Android 15) on both backends: cold start, booting a homebrew, pause/resume in the menu, in a settings screen and in-game, rotation back and forth, a density-change activity recreate with the game live, the pause menu, and exit to menu. ThreadStart/ThreadEnd log exactly once per context, and every one of those cycles tears the EGL context down and builds a new one. Also tested on a Quest 2 with the vr flavor, which is what shook out the deadlock above: cold start into a focused XR session at 72fps, backgrounding (render thread joins in 27ms where it used to never return), resuming back into VR with a fresh EGL context, and exiting from in-app. xrCreateSession runs on the render loop thread, which is where the EGL context is current - VRBase.cpp builds its graphics binding from eglGetCurrentDisplay/eglGetCurrentContext, so that ordering matters. Windows x64 debug and the Android gradle builds are clean. Not converged, and not a small change: NeedsSeparateEmuThread() itself. GL needs its API calls on the thread its context is current on, so somebody has to pump ThreadFrame() there, whereas VulkanRenderManager owns its thread outright. Getting rid of the virtual means teaching GLRenderManager to spawn and own its own render thread, for every GL platform at once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01152igCdjTNhT29qvphPyPC --- Core/EmuThread.cpp | 79 +++++++++++++++++++++++++++---------- Core/EmuThread.h | 12 ++++++ android/jni/app-android.cpp | 62 +++++------------------------ docs/DebuggerThreading.md | 11 +++--- 4 files changed, 85 insertions(+), 79 deletions(-) diff --git a/Core/EmuThread.cpp b/Core/EmuThread.cpp index aed82b24f4..10aababd5f 100644 --- a/Core/EmuThread.cpp +++ b/Core/EmuThread.cpp @@ -13,7 +13,10 @@ #include "Common/Log.h" #include "Common/StringUtils.h" #include "Common/GPU/GraphicsContext.h" +#include "Common/System/Display.h" #include "Common/Thread/ThreadUtil.h" +#include "Common/TimeUtil.h" +#include "Common/VR/PPSSPPVR.h" #include "Core/EmuThread.h" #include "Core/Core.h" @@ -125,34 +128,68 @@ bool RunMainLoop(GraphicsContext *graphicsContext, Application *application, std return true; } +void RunGraphicsLoop(GraphicsContext *graphicsContext, Application *application, std::function frame, std::function shouldExit) { + if (!graphicsContext->NeedsSeparateEmuThread()) { + // The backend spawns its own render thread, so this thread is simply where emulation runs. + SetCurrentThreadName("EmuThread"); + RunMainLoop(graphicsContext, application, frame); + return; + } + + // OpenGL wants its API calls on the thread its context is current on, which is this one. So + // this thread becomes the render thread - all it does is execute the GPU commands the emu + // thread queues up - and emulation moves to a thread we spawn here. + SetCurrentThreadName("RenderThread"); + + g_inLoop = true; + + // EmuThread_Start calls ThreadStart() for us, and EmuThread_Join calls ThreadEnd(), so neither + // is called directly here. + std::thread emuThread = EmuThread_Start(graphicsContext, application, frame); + + if (IsVREnabled()) { + static bool vrFirstStart = true; + EnterVR(vrFirstStart); + vrFirstStart = false; + } + + // Normally ThreadFrame() returning false is what gets us out of here - the frame callback + // decides emulation is done, and the emu thread's NotifyEmuThreadExit then queues the exit. + // That isn't enough in VR: while the session is idle we skip ThreadFrame() entirely, and going + // to the background is precisely when the session goes idle, so the exit would never be seen + // and whoever is waiting to join this thread would wait forever. Hence shouldExit. + while (!(shouldExit && shouldExit())) { + if (IsVREnabled() && !StartVRRender()) { + // The session isn't active, so there's no frame to render into. Nothing to do until it + // comes back - wait out a frame rather than spinning on it. + sleep_ms(16, "vr-session-idle"); + continue; + } + if (!graphicsContext->ThreadFrame()) { + break; + } + if (IsVREnabled()) { + UpdateVRInput(g_Config.bHapticFeedback, g_display.dpi_scale_x, g_display.dpi_scale_y); + FinishVRRender(); + } + } + + // Also drains whatever the emu thread still had queued, so it can't get stuck waiting on us. + EmuThread_Join(graphicsContext, emuThread); + + g_inLoop = false; + + INFO_LOG(Log::System, "RenderThread - joined"); +} + // Call InitAPI and ShutdownAPI outside this! bool MainThreadFunc(GraphicsContext *graphicsContext, Application *application, const WindowDesc &windowDesc, std::function frame, std::string *errorMessage) { - // This is now the render thread, and will spawn the emu thread below. if (!graphicsContext->InitSurface(windowDesc.winsys, windowDesc.data1, windowDesc.data2, errorMessage)) { ERROR_LOG(Log::G3D, "MainThreadFunc: InitSurface failed: %s", errorMessage->c_str()); delete application; return false; } - if (graphicsContext->NeedsSeparateEmuThread()) { - SetCurrentThreadName("RenderThread"); - - g_inLoop = true; - std::thread emuThread = EmuThread_Start(graphicsContext, application, frame); - graphicsContext->ThreadStart(); - // This thread becomes the render thread. EmuThread will tell it when to quit by sending a message. - while (graphicsContext->ThreadFrame()) {} - EmuThread_Join(graphicsContext, emuThread); - g_inLoop = false; - - graphicsContext->ThreadEnd(); - - INFO_LOG(Log::System, "RenderThread - joined"); - - } else { - SetCurrentThreadName("MainThread"); - - RunMainLoop(graphicsContext, application, frame); - } + RunGraphicsLoop(graphicsContext, application, frame); graphicsContext->ShutdownSurface(); return true; } diff --git a/Core/EmuThread.h b/Core/EmuThread.h index b220b0c5fe..705421a774 100644 --- a/Core/EmuThread.h +++ b/Core/EmuThread.h @@ -50,3 +50,15 @@ void EmuThread_Join(GraphicsContext *graphicsContext, std::thread &emuThread); // Call from the main thread. // NOTE: Does take ownership over Application (which is just a wrapper for NativeInitGraphics/NativeShutdownGraphics/NativeFrame). bool RunMainLoop(GraphicsContext *graphicsContext, Application *application, std::function frame); + +// Runs whichever thread arrangement graphicsContext asks for, on the calling thread, and doesn't +// return until the emu thread is done. Either this thread becomes the render thread and the emu +// thread is spawned alongside it (OpenGL, which needs its API calls on the thread its context is +// current on), or this thread just runs the main loop because the backend spawns its own render +// thread (Vulkan, D3D11). +// The surface has to be initialized already, and is left alone - use MainThreadFunc if you want +// that taken care of too. +// shouldExit is optional, but required if you might run this in VR: see the comment on the render +// loop in EmuThread.cpp for why ThreadFrame() alone isn't enough to get out of there. +// NOTE: Does take ownership over Application (which is just a wrapper for NativeInitGraphics/NativeShutdownGraphics/NativeFrame). +void RunGraphicsLoop(GraphicsContext *graphicsContext, Application *application, std::function frame, std::function shouldExit = nullptr); diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index e034d6584a..492252290a 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -106,14 +106,6 @@ struct JNIEnv {}; #include "app-android.h" -enum class EmuThreadState { - DISABLED, - START_REQUESTED, - RUNNING, - QUIT_REQUESTED, - STOPPED, -}; - AndroidAudioState *g_audioState; struct FrameCommand { @@ -1558,18 +1550,13 @@ extern "C" void JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_requestExitRenderL exitRenderLoop = false; } -// TODO: Merge with the Win32 EmuThread and so on, and the Java EmuThread? // This function must release the wnd reference. static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContext) { - // The two backends split the work in opposite directions, which is why this thread has two - // names. Vulkan handles its own threading: this thread runs the main loop and the render - // manager spawns the actual render thread. OpenGL doesn't: this thread becomes the render - // thread and spawns the emu thread, which is what Java's GL thread used to do for us. - const bool separateEmuThread = graphicsContext->NeedsSeparateEmuThread(); - SetCurrentThreadName(separateEmuThread ? "RenderThread" : "EmuThread"); + // Only a provisional name, so the surface init below has something to log under. RunGraphicsLoop + // renames this thread once it knows which of the two roles it's going to take. + SetCurrentThreadName("RenderLoop"); AndroidJNIThreadContext ctx; - JNIEnv *env = getEnv(); _assert_(graphicsContext); if (exitRenderLoop) { @@ -1600,44 +1587,13 @@ static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContex ProcessFrameCommands(); return !exitRenderLoop; }; - if (separateEmuThread) { - // EmuThread_Start calls ThreadStart() for us, and EmuThread_Join calls ThreadEnd(), so - // neither is called directly here. - std::thread emuThread = EmuThread_Start(graphicsContext, new NativeApplication(), frame); + // Both backends share one lifecycle now, which is the point of all this: RunGraphicsLoop picks + // the thread arrangement the context needs and doesn't return until emulation is done. Windows + // and headless go through the same function, via MainThreadFunc. + // We have to hand it exitRenderLoop as well as the frame callback - in VR the loop can be + // parked waiting for the session to come back, where the callback is never reached. + RunGraphicsLoop(graphicsContext, new NativeApplication(), frame, []() { return exitRenderLoop.load(); }); - // The views were created against the previous surface, if there was one. - System_PostUIMessage(UIMessage::RECREATE_VIEWS); - - if (IsVREnabled()) { - // Only ever a GL thing, which is why it lives in this branch. - static bool vrFirstStart = true; - EnterVR(vrFirstStart); - vrFirstStart = false; - } - - // This is the GL "GPU thread" - the loop Java's GLSurfaceView used to drive one call at a - // time. ThreadFrame returns false once the emu thread is done producing. - while (!exitRenderLoop) { - if (IsVREnabled() && !StartVRRender()) { - // The session isn't active, so there's no frame to render into. Java's GL thread - // just missed a vsync here; we drive the loop ourselves, so pace it by hand - // rather than spinning. - sleep_ms(16, "vr-session-idle"); - continue; - } - if (!graphicsContext->ThreadFrame()) { - break; - } - if (IsVREnabled()) { - UpdateVRInput(g_Config.bHapticFeedback, g_display.dpi_scale_x, g_display.dpi_scale_y); - FinishVRRender(); - } - } - // Also drains whatever the emu thread still has queued, so it can't block on us. - EmuThread_Join(graphicsContext, emuThread); - } else { - RunMainLoop(graphicsContext, new NativeApplication(), frame); - } renderer_inited = false; // Shut the graphics context down to the same state it was in when we entered the render thread. diff --git a/docs/DebuggerThreading.md b/docs/DebuggerThreading.md index 2975476c35..23d7750641 100644 --- a/docs/DebuggerThreading.md +++ b/docs/DebuggerThreading.md @@ -34,11 +34,12 @@ mechanisms now exist for doing this safely - pick based on whether you're mutati Architecture fact that makes `g_frameMutex` correct: regardless of graphics backend, `NativeFrame()` (and thus CPU emulation via `Core_RunLoopUntil()`, and the Dear ImGui debugger) always runs on the -*same* thread - see `Core/EmuThread.cpp`. When a backend needs its own thread for actual graphics -API calls (`GraphicsContext::NeedsSeparateEmuThread()` - true for OpenGL, SDL, headless, libretro, -Qt; false for D3D11/Vulkan on Windows), the *original* thread stays behind purely to pump -`graphicsContext->ThreadFrame()` (i.e. just executes queued graphics API calls), and a *newly -spawned* thread takes over `NativeFrame()`/game logic/CPU duty. So `UI/ImDebugger/*.cpp` is always +*same* thread - see `RunGraphicsLoop()` in `Core/EmuThread.cpp`, which every platform goes through +(Windows and headless via `MainThreadFunc()`, Android from its render loop thread). When a backend +needs its own thread for actual graphics API calls (`GraphicsContext::NeedsSeparateEmuThread()` - +true for OpenGL, SDL, headless, libretro, Qt; false for D3D11/Vulkan on Windows), the *original* +thread stays behind purely to pump `graphicsContext->ThreadFrame()` (i.e. just executes queued +graphics API calls), and a *newly spawned* thread takes over `NativeFrame()`/game logic/CPU duty. So `UI/ImDebugger/*.cpp` is always safe to read/write this state directly, without either mechanism - it's always on the same thread as `Core_RunLoopUntil()`. The legacy Win32 debugger is different: its dialogs are pumped by the *original* `WinMain` message-loop thread, which is a genuinely separate OS thread from whichever