From f868976af2938377c1e8a3a89295727ac34d7372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 8 Oct 2026 00:55:55 +0200 Subject: [PATCH 1/3] Android GL: Add a native EGL path, behind a dev toggle First step towards removing JavaGL. The point isn't EGL itself - it's who owns the render thread. With GLSurfaceView, Java creates the thread and the context and calls us back, so a lost context is only observable after the fact as a second onSurfaceCreated. That's what forces displayInit to tear down and restart the emu thread from inside a JNI callback, and to call NotifyContextLost first because the queue is about to be drained on a thread with no context. None of that is needed when we own EGL: the render thread joins the emu thread itself, with the context still current, so the drain is real work rather than something to skip. AndroidEGLGraphicsContext subclasses OpenGLGraphicsContext (whose own InitSurface assumes someone else already made a context current) and adds display/config/surface/context plus a swap function. With it, GL reuses the Vulkan lifecycle wholesale: queryConfig("androidJavaGL") returns false, so Java builds a plain SurfaceView and starts the native render loop when a surface arrives - including all of the surface/pause ordering fixes from the previous branch. runVulkanRenderLoop/requestExitVulkanRenderLoop are now runRenderLoop/requestExitRenderLoop and serve both backends; the thread they spawn branches on NeedsSeparateEmuThread, because the two backends split the work in opposite directions (Vulkan: this thread is the main loop and the render manager spawns the render thread; GL: this thread *is* the render thread and spawns the emu thread). Deliberately a port, not an improvement: - GLES client version still follows NativeRenderer - 3 for VR, 2 otherwise. Not a cap; GLES3 is detected from GL_VERSION at runtime. - No swap-interval function. GLSurfaceView gave us no way to set one, so PresentMode::IMMEDIATE has always been ignored here, and honoring it now would be a behavior change. Worth revisiting once this path is the only one. The config ladder does take the user's word that Mali 400 and the Xperia Play are no longer a factor: it asks for RGBA8888/D24S8 and falls back twice, rather than reproducing GLSurfaceView's ask-for-nothing default that the comments in PpssppActivity.onCreate exist to explain. ANativeWindow's format is set from EGL_NATIVE_VISUAL_ID, which is the proper fix for the EGL_BAD_MATCH those comments describe. Off by default, behind Developer tools / Graphics / "Use native EGL instead of GLSurfaceView" (OpenGL backend, Android only). Needs an app restart, since the context is chosen in NativeApp.init. Builds for arm64-v8a and on Windows; NOT yet run on a device. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01152igCdjTNhT29qvphPyPC --- CMakeLists.txt | 2 + Common/GPU/OpenGL/OpenGLGraphicsContext.h | 4 +- Core/Config.cpp | 1 + Core/Config.h | 6 + UI/DeveloperToolsScreen.cpp | 11 + android/jni/Android.mk | 1 + android/jni/AndroidEGLGraphicsContext.cpp | 239 ++++++++++++++++++ android/jni/AndroidEGLGraphicsContext.h | 61 +++++ android/jni/app-android.cpp | 76 ++++-- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 12 +- 10 files changed, 384 insertions(+), 29 deletions(-) create mode 100644 android/jni/AndroidEGLGraphicsContext.cpp create mode 100644 android/jni/AndroidEGLGraphicsContext.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 291b570fe6..adfa8278a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -925,6 +925,8 @@ if(ANDROID) android/jni/app-android.cpp android/jni/AndroidAudio.cpp android/jni/AndroidAudio.h + android/jni/AndroidEGLGraphicsContext.cpp + android/jni/AndroidEGLGraphicsContext.h android/jni/OpenSLContext.cpp android/jni/OpenSLContext.h ) diff --git a/Common/GPU/OpenGL/OpenGLGraphicsContext.h b/Common/GPU/OpenGL/OpenGLGraphicsContext.h index 082ac4a819..2214830045 100644 --- a/Common/GPU/OpenGL/OpenGLGraphicsContext.h +++ b/Common/GPU/OpenGL/OpenGLGraphicsContext.h @@ -45,7 +45,9 @@ public: renderManager_->NotifyEmuThreadExit(); } -private: +protected: + // Protected rather than private so a subclass that owns the real context (see + // android/jni/AndroidEGLGraphicsContext.h) can hook up a swap function. Draw::DrawContext *draw_ = nullptr; GLRenderManager *renderManager_ = nullptr; }; diff --git a/Core/Config.cpp b/Core/Config.cpp index a82c9cc8c5..df0a205302 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -704,6 +704,7 @@ static const ConfigSetting graphicsSettings[] = { ConfigSetting("Smart2DTexFiltering", SETTING(g_Config, bSmart2DTexFiltering), false, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("InternalResolution", SETTING(g_Config, iInternalResolution), &DefaultInternalResolution, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("AndroidHwScale", SETTING(g_Config, iAndroidHwScale), 0, CfgFlag::DEFAULT), + ConfigSetting("AndroidNativeEGL", SETTING(g_Config, bAndroidNativeEGL), false, CfgFlag::DEFAULT), ConfigSetting("HighQualityDepth", SETTING(g_Config, bHighQualityDepth), true, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("FrameSkip", SETTING(g_Config, iFrameSkip), 0, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("AutoFrameSkip", SETTING(g_Config, bAutoFrameSkip), IsVREnabled(), CfgFlag::PER_GAME | CfgFlag::REPORT), diff --git a/Core/Config.h b/Core/Config.h index ca21fb0658..b369dadc5e 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -569,6 +569,12 @@ public: // Use the hardware scaler to scale up the image to save fillrate. Similar to Windows' window size, really. int iAndroidHwScale; // 0 = device resolution. 1 = 480x272 (extended to correct aspect), 2 = 960x544 etc. + // Android, OpenGL backend only: own EGL and the render thread in C++ instead of letting + // Java's GLSurfaceView do it. Temporary, while the new path is being shaken out on devices - + // the intent is for it to become the only way and for the JavaGL path to go away. + // Only read when the activity is created, so changing it needs an app restart. + bool bAndroidNativeEGL; + // Risky JIT optimizations bool bDiscardRegsOnJRRA; diff --git a/UI/DeveloperToolsScreen.cpp b/UI/DeveloperToolsScreen.cpp index c570b671ae..5a46cc1ba0 100644 --- a/UI/DeveloperToolsScreen.cpp +++ b/UI/DeveloperToolsScreen.cpp @@ -539,6 +539,17 @@ void DeveloperToolsScreen::CreateGraphicsTab(UI::LinearLayout *list) { }); } +#if PPSSPP_PLATFORM(ANDROID) + if (g_Config.iGPUBackend == (int)GPUBackend::OPENGL) { + list->Add(new ItemHeader(dev->T("OpenGL"))); + // Temporary, while the EGL path is being tested on devices - see Core/Config.h. + list->Add(new CheckBox(&g_Config.bAndroidNativeEGL, dev->T("Use native EGL instead of GLSurfaceView"), ""))->OnClick.Add([](UI::EventParams &e) { + // TODO: Not translating yet, same as the multi-threaded rendering checkbox above. + g_OSD.Show(OSDType::MESSAGE_WARNING, "Restart required"); + }); + } +#endif + if (GetGPUBackend() == GPUBackend::VULKAN && SupportsCustomDriver()) { auto driverChoice = list->Add(new Choice(gr->T("AdrenoTools driver manager"))); driverChoice->OnClick.Add([=](UI::EventParams &e) { diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 7147d4b93f..c6a3fa5fe0 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -940,6 +940,7 @@ LOCAL_MODULE := ppsspp_jni LOCAL_SRC_FILES := \ $(SRC)/android/jni/app-android.cpp \ $(SRC)/android/jni/AndroidAudio.cpp \ + $(SRC)/android/jni/AndroidEGLGraphicsContext.cpp \ $(SRC)/android/jni/OpenSLContext.cpp \ $(SRC)/UI/ImDebugger/ImDebugger.cpp \ $(SRC)/UI/ImDebugger/ImGe.cpp \ diff --git a/android/jni/AndroidEGLGraphicsContext.cpp b/android/jni/AndroidEGLGraphicsContext.cpp new file mode 100644 index 0000000000..6bc5d2d533 --- /dev/null +++ b/android/jni/AndroidEGLGraphicsContext.cpp @@ -0,0 +1,239 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include + +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/VR/PPSSPPVR.h" + +#include "android/jni/AndroidEGLGraphicsContext.h" + +// EGL_OPENGL_ES3_BIT_KHR, from EGL_KHR_create_context. Not in every platform header. +#ifndef EGL_OPENGL_ES3_BIT_KHR +#define EGL_OPENGL_ES3_BIT_KHR 0x0040 +#endif + +namespace { + +const char *EGLErrorToString(EGLint error) { + switch (error) { + case EGL_SUCCESS: return "EGL_SUCCESS"; + case EGL_NOT_INITIALIZED: return "EGL_NOT_INITIALIZED"; + case EGL_BAD_ACCESS: return "EGL_BAD_ACCESS"; + case EGL_BAD_ALLOC: return "EGL_BAD_ALLOC"; + case EGL_BAD_ATTRIBUTE: return "EGL_BAD_ATTRIBUTE"; + case EGL_BAD_CONTEXT: return "EGL_BAD_CONTEXT"; + case EGL_BAD_CONFIG: return "EGL_BAD_CONFIG"; + case EGL_BAD_CURRENT_SURFACE: return "EGL_BAD_CURRENT_SURFACE"; + case EGL_BAD_DISPLAY: return "EGL_BAD_DISPLAY"; + case EGL_BAD_SURFACE: return "EGL_BAD_SURFACE"; + case EGL_BAD_MATCH: return "EGL_BAD_MATCH"; + case EGL_BAD_PARAMETER: return "EGL_BAD_PARAMETER"; + case EGL_BAD_NATIVE_PIXMAP: return "EGL_BAD_NATIVE_PIXMAP"; + case EGL_BAD_NATIVE_WINDOW: return "EGL_BAD_NATIVE_WINDOW"; + case EGL_CONTEXT_LOST: return "EGL_CONTEXT_LOST"; + default: return "(unknown EGL error)"; + } +} + +std::string EGLFailure(const char *what) { + const EGLint error = eglGetError(); + return StringFromFormat("%s failed: %s (%04x)", what, EGLErrorToString(error), error); +} + +struct ConfigAttempt { + const char *desc; + EGLint red, green, blue, alpha, depth, stencil; +}; + +// Most to least desirable. The PSP only needs 16 bits of depth, but ask for 24 first - it is what +// essentially every driver offers anyway, and asking for less does not make a 16-bit buffer appear. +// Stencil is not optional in practice: without it, non-buffered rendering has nothing to work with. +// +// The last entry is the fallback: ask for the bare minimum and take whatever eglChooseConfig puts +// first, which is roughly what GLSurfaceView's default chooser does today. +const ConfigAttempt kConfigAttempts[] = { + { "RGBA8888 D24S8", 8, 8, 8, 8, 24, 8 }, + { "RGBA8888 D16S8", 8, 8, 8, 8, 16, 8 }, + { "RGB565 D16S8", 5, 6, 5, 0, 16, 8 }, + { "anything", 5, 6, 5, 0, 16, 0 }, +}; + +} // namespace + +bool AndroidEGLGraphicsContext::ChooseConfig(bool wantGLES3, std::string *errorMessage) { + const EGLint renderableType = wantGLES3 ? EGL_OPENGL_ES3_BIT_KHR : EGL_OPENGL_ES2_BIT; + + for (const ConfigAttempt &attempt : kConfigAttempts) { + const EGLint attribs[] = { + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, renderableType, + EGL_RED_SIZE, attempt.red, + EGL_GREEN_SIZE, attempt.green, + EGL_BLUE_SIZE, attempt.blue, + EGL_ALPHA_SIZE, attempt.alpha, + EGL_DEPTH_SIZE, attempt.depth, + EGL_STENCIL_SIZE, attempt.stencil, + EGL_NONE + }; + EGLConfig config = nullptr; + EGLint numConfigs = 0; + if (eglChooseConfig(display_, attribs, &config, 1, &numConfigs) && numConfigs > 0) { + config_ = config; + EGLint red = 0, green = 0, blue = 0, alpha = 0, depth = 0, stencil = 0, samples = 0; + eglGetConfigAttrib(display_, config_, EGL_RED_SIZE, &red); + eglGetConfigAttrib(display_, config_, EGL_GREEN_SIZE, &green); + eglGetConfigAttrib(display_, config_, EGL_BLUE_SIZE, &blue); + eglGetConfigAttrib(display_, config_, EGL_ALPHA_SIZE, &alpha); + eglGetConfigAttrib(display_, config_, EGL_DEPTH_SIZE, &depth); + eglGetConfigAttrib(display_, config_, EGL_STENCIL_SIZE, &stencil); + eglGetConfigAttrib(display_, config_, EGL_SAMPLES, &samples); + INFO_LOG(Log::G3D, "EGL config (asked for %s): R%dG%dB%dA%d depth=%d stencil=%d samples=%d", + attempt.desc, red, green, blue, alpha, depth, stencil, samples); + if (stencil == 0) { + WARN_LOG(Log::G3D, "EGL config has no stencil buffer - non-buffered rendering will misbehave."); + } + return true; + } + INFO_LOG(Log::G3D, "No EGL config for %s, trying the next one", attempt.desc); + } + + *errorMessage = "Found no usable EGL config"; + return false; +} + +bool AndroidEGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) { + _dbg_assert_(winsys == WINDOWSYSTEM_ANDROID); + wnd_ = (ANativeWindow *)data1; + if (!wnd_) { + *errorMessage = "AndroidEGLGraphicsContext::InitSurface: no native window"; + return false; + } + + display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (display_ == EGL_NO_DISPLAY) { + *errorMessage = EGLFailure("eglGetDisplay"); + return false; + } + EGLint eglMajor = 0, eglMinor = 0; + if (!eglInitialize(display_, &eglMajor, &eglMinor)) { + *errorMessage = EGLFailure("eglInitialize"); + display_ = EGL_NO_DISPLAY; + return false; + } + INFO_LOG(Log::G3D, "EGL %d.%d initialized (vendor: %s)", eglMajor, eglMinor, + eglQueryString(display_, EGL_VENDOR)); + + // Mirrors NativeRenderer's setEGLContextClientVersion(isVRDevice() ? 3 : 2). Asking for 2 is + // not a cap: GLES3 is detected from GL_VERSION at runtime (see GLFeatures.cpp), which is how + // the Java path has always ended up using GLES3 on devices that have it. + const bool wantGLES3 = IsVREnabled(); + if (!ChooseConfig(wantGLES3, errorMessage)) { + DestroyEGL(); + return false; + } + + // Line the window's pixel format up with the config we picked. Skipping this is how you get + // EGL_BAD_MATCH out of eglCreateWindowSurface on drivers that take the window format + // literally - historically the reason the Java path asked for no particular config at all. + // Width and height stay 0 so whatever size Java set (see iAndroidHwScale) is left alone. + EGLint nativeVisualID = 0; + if (eglGetConfigAttrib(display_, config_, EGL_NATIVE_VISUAL_ID, &nativeVisualID)) { + ANativeWindow_setBuffersGeometry(wnd_, 0, 0, nativeVisualID); + } else { + WARN_LOG(Log::G3D, "Couldn't get EGL_NATIVE_VISUAL_ID, leaving the window format alone"); + } + + const EGLint contextAttribs[] = { EGL_CONTEXT_CLIENT_VERSION, wantGLES3 ? 3 : 2, EGL_NONE }; + context_ = eglCreateContext(display_, config_, EGL_NO_CONTEXT, contextAttribs); + if (context_ == EGL_NO_CONTEXT) { + *errorMessage = EGLFailure("eglCreateContext"); + DestroyEGL(); + return false; + } + + surface_ = eglCreateWindowSurface(display_, config_, wnd_, nullptr); + if (surface_ == EGL_NO_SURFACE) { + *errorMessage = EGLFailure("eglCreateWindowSurface"); + DestroyEGL(); + return false; + } + + if (!eglMakeCurrent(display_, surface_, surface_, context_)) { + *errorMessage = EGLFailure("eglMakeCurrent"); + DestroyEGL(); + return false; + } + + EGLint surfaceWidth = 0, surfaceHeight = 0; + eglQuerySurface(display_, surface_, EGL_WIDTH, &surfaceWidth); + eglQuerySurface(display_, surface_, EGL_HEIGHT, &surfaceHeight); + INFO_LOG(Log::G3D, "EGL context current, surface is %dx%d", surfaceWidth, surfaceHeight); + + // GL is current on this thread now, so the shared setup (extension check, thin3d context, + // render manager) can run. + if (!OpenGLGraphicsContext::InitSurface(winsys, data1, data2, errorMessage)) { + DestroyEGL(); + return false; + } + + renderManager_->SetSwapFunction([this]() { + if (!eglSwapBuffers(display_, surface_)) { + // Most likely the surface went away under us. The render loop is about to be asked to + // exit anyway, so just complain rather than trying to recover from here. + WARN_LOG(Log::G3D, "%s", EGLFailure("eglSwapBuffers").c_str()); + } + }); + + // NOTE: Deliberately *not* calling SetSwapIntervalFunction. GLSurfaceView gave us no way to + // change the interval, so PresentMode::IMMEDIATE has always been a no-op here, and honoring it + // now would be a behavior change rather than a port. Worth revisiting once this path is the + // only one. + + return true; +} + +void AndroidEGLGraphicsContext::ShutdownSurface() { + // Order matters: the render manager's teardown still issues GL calls, so the context has to + // stay current until it's done. + OpenGLGraphicsContext::ShutdownSurface(); + DestroyEGL(); +} + +void AndroidEGLGraphicsContext::DestroyEGL() { + if (display_ == EGL_NO_DISPLAY) { + return; + } + INFO_LOG(Log::G3D, "AndroidEGLGraphicsContext::DestroyEGL"); + eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (surface_ != EGL_NO_SURFACE) { + eglDestroySurface(display_, surface_); + surface_ = EGL_NO_SURFACE; + } + if (context_ != EGL_NO_CONTEXT) { + eglDestroyContext(display_, context_); + context_ = EGL_NO_CONTEXT; + } + // eglTerminate only tears down this thread's binding of the display, which is refcounted per + // process - so this is safe even though the display is shared. + eglTerminate(display_); + eglReleaseThread(); + display_ = EGL_NO_DISPLAY; + config_ = nullptr; + wnd_ = nullptr; +} diff --git a/android/jni/AndroidEGLGraphicsContext.h b/android/jni/AndroidEGLGraphicsContext.h new file mode 100644 index 0000000000..27997ec315 --- /dev/null +++ b/android/jni/AndroidEGLGraphicsContext.h @@ -0,0 +1,61 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include + +#include "Common/GPU/OpenGL/OpenGLGraphicsContext.h" + +struct ANativeWindow; + +// Owns EGL for the OpenGL backend on Android, instead of letting Java's GLSurfaceView own it +// (NativeRenderer.java / NativeGLSurfaceView.java, the "JavaGL" path). +// +// The point is not EGL itself but who owns the render thread. With GLSurfaceView, Java creates +// the thread and the context and calls us back, so a lost context is only ever observable after +// the fact, as a second onSurfaceCreated - which is what forces app-android.cpp's displayInit to +// tear down and restart the emu thread from inside a JNI callback. Owning EGL here lets the GL +// backend use the same lifecycle as Vulkan: we start a render thread when we get a surface and +// join it when the surface goes away, which is the whole reason this exists. +// +// Deliberately behaves the same as the Java path otherwise - same GLES client version, same +// window pixel format, same separate emu thread. See Core/Config.h, bAndroidNativeEGL. +class AndroidEGLGraphicsContext : public OpenGLGraphicsContext { +public: + // Note: not just DestroyEGL(). The base destructor deletes the thin3d context, which still + // makes GL calls, so EGL has to outlive it - ShutdownSurface() gets that order right, and is + // a no-op if the render loop already called it. + ~AndroidEGLGraphicsContext() override { ShutdownSurface(); } + + // Must be called on the thread that will become the render thread - the GL context ends up + // current on the calling thread. data1 is the ANativeWindow, as on the Vulkan path. + bool InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) override; + void ShutdownSurface() override; + +private: + // Picks a config, from the most to the least desirable. Fills in config_. + bool ChooseConfig(bool wantGLES3, std::string *errorMessage); + void DestroyEGL(); + + EGLDisplay display_ = EGL_NO_DISPLAY; + EGLSurface surface_ = EGL_NO_SURFACE; + EGLContext context_ = EGL_NO_CONTEXT; + EGLConfig config_ = nullptr; + // Not owned - the caller releases it after the render loop exits. + ANativeWindow *wnd_ = nullptr; +}; diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 65522d3bd6..458dfa5506 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -89,6 +89,7 @@ struct JNIEnv {}; #include "Common/GPU/GraphicsContext.h" #include "Common/GPU/Vulkan/VulkanGraphicsContext.h" #include "Common/GPU/OpenGL/OpenGLGraphicsContext.h" +#include "android/jni/AndroidEGLGraphicsContext.h" #include "Common/StringUtils.h" #include "Common/TimeUtil.h" @@ -576,6 +577,11 @@ static std::string QueryConfig(std::string_view query) { if (GetGPUBackend() == GPUBackend::VULKAN) { return "false"; } + // GL with our own EGL wants the same treatment as Vulkan: a plain SurfaceView, and a + // render thread we start ourselves once there's a surface. + if (g_Config.bAndroidNativeEGL) { + return "false"; + } // Otherwise, some devices prefer the Java init so play it safe. return "true"; } else if (query == "audioMixWithOthers") { @@ -774,9 +780,15 @@ extern "C" void Java_org_ppsspp_ppsspp_NativeApp_init retry: switch (g_Config.iGPUBackend) { case (int)GPUBackend::OPENGL: - INFO_LOG(Log::System, "NativeApp.init() -- creating OpenGL context (JavaGL)"); - graphicsContext = new OpenGLGraphicsContext(); - INFO_LOG(Log::System, "NativeApp.init() - not yet launching emuthread, waiting to displayInit"); + if (g_Config.bAndroidNativeEGL) { + INFO_LOG(Log::System, "NativeApp.init() -- creating OpenGL context (native EGL)"); + graphicsContext = new AndroidEGLGraphicsContext(); + // Like Vulkan, we wait for a surface before doing anything - see runRenderLoop. + } else { + INFO_LOG(Log::System, "NativeApp.init() -- creating OpenGL context (JavaGL)"); + graphicsContext = new OpenGLGraphicsContext(); + INFO_LOG(Log::System, "NativeApp.init() - not yet launching emuthread, waiting to displayInit"); + } break; case (int)GPUBackend::VULKAN: { @@ -897,8 +909,10 @@ extern "C" void Java_org_ppsspp_ppsspp_NativeApp_pause(JNIEnv *, jclass) { extern "C" void Java_org_ppsspp_ppsspp_NativeApp_shutdown(JNIEnv *, jclass) { INFO_LOG(Log::System, "NativeApp.shutdown() -- begin"); - if (renderer_inited && graphicsContext && graphicsContext->NeedsSeparateEmuThread()) { - // Only used in Java EGL path. + // Only the JavaGL path gets here with a thread to join: it's the one that starts the emu thread + // from a JNI callback and leaves it in g_emuThread. On the native-EGL and Vulkan paths the + // render loop thread owns its own, and has already been joined via requestExitRenderLoop. + if (renderer_inited && graphicsContext && graphicsContext->NeedsSeparateEmuThread() && g_emuThread.joinable()) { // The join drains the render queue right here, on a thread without a GL context. graphicsContext->NotifyContextLost(); INFO_LOG(Log::System, "Joining emuthread."); @@ -1633,25 +1647,24 @@ static void ProcessFrameCommands() { std::thread g_renderLoopThread; -static void VulkanEmuThread(ANativeWindow *wnd, GraphicsContext *graphicsContext); +static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContext); -// This runs in Vulkan mode only. -// This handles the entire lifecycle of the Vulkan context, init and exit. -extern "C" jboolean JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_runVulkanRenderLoop(JNIEnv * env, jobject obj, jobject _surf) { +// Runs on the Vulkan path, and on the OpenGL path when we own EGL (bAndroidNativeEGL). Not in +// JavaGL mode - there, Java owns the thread and calls displayInit/displayRender instead. +// This handles the entire lifecycle of the graphics context's surface, init and exit. +extern "C" jboolean JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_runRenderLoop(JNIEnv * env, jobject obj, jobject _surf) { if (!graphicsContext) { - ERROR_LOG(Log::G3D, "runVulkanRenderLoop: Tried to enter without a created graphics context."); + ERROR_LOG(Log::G3D, "runRenderLoop: Tried to enter without a created graphics context."); return false; } - _assert_(!graphicsContext->NeedsSeparateEmuThread()); - if (g_renderLoopThread.joinable()) { if (renderLoopRunning) { - ERROR_LOG(Log::G3D, "runVulkanRenderLoop: Already running"); + ERROR_LOG(Log::G3D, "runRenderLoop: Already running"); return false; } // The previous thread gave up by itself (failed surface init). Reap it so we can try again. - WARN_LOG(Log::G3D, "runVulkanRenderLoop: Joining a render thread that had already exited"); + WARN_LOG(Log::G3D, "runRenderLoop: Joining a render thread that had already exited"); g_renderLoopThread.join(); g_renderLoopThread = std::thread(); } @@ -1668,12 +1681,12 @@ extern "C" jboolean JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_runVulkanRende // Set before the thread exists, so an exit request can't slip in ahead of it. renderLoopRunning = true; - g_renderLoopThread = std::thread(VulkanEmuThread, wnd, graphicsContext); + g_renderLoopThread = std::thread(RenderLoopThread, wnd, graphicsContext); return true; } -extern "C" void JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_requestExitVulkanRenderLoop(JNIEnv * env, jobject obj) { - // Like runVulkanRenderLoop, this is only called from the UI thread, so the thread object is ours. +extern "C" void JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_requestExitRenderLoop(JNIEnv * env, jobject obj) { + // Like runRenderLoop, this is only called from the UI thread, so the thread object is ours. // Don't go by renderLoopRunning - the thread might have bailed by itself, and still needs joining. if (!g_renderLoopThread.joinable()) { INFO_LOG(Log::System, "Render loop not running, nothing to join"); @@ -1687,21 +1700,26 @@ extern "C" void JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_requestExitVulkanR // TODO: Merge with the Win32 EmuThread and so on, and the Java EmuThread? // This function must release the wnd reference. -static void VulkanEmuThread(ANativeWindow *wnd, GraphicsContext *graphicsContext) { - SetCurrentThreadName("EmuThread"); +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"); AndroidJNIThreadContext ctx; JNIEnv *env = getEnv(); _assert_(graphicsContext); if (exitRenderLoop) { - WARN_LOG(Log::G3D, "runVulkanRenderLoop: ExitRenderLoop requested at start, skipping the whole thing."); + WARN_LOG(Log::G3D, "RenderLoopThread: ExitRenderLoop requested at start, skipping the whole thing."); renderLoopRunning = false; ANativeWindow_release(wnd); return; } - WARN_LOG(Log::G3D, "runVulkanRenderLoop. display_xres=%d display_yres=%d desiredBackbufferSizeX=%d desiredBackbufferSizeY=%d", + WARN_LOG(Log::G3D, "RenderLoopThread. display_xres=%d display_yres=%d desiredBackbufferSizeX=%d desiredBackbufferSizeY=%d", display_xres, display_yres, desiredBackbufferSizeX, desiredBackbufferSizeY); std::string errorMessage; @@ -1717,11 +1735,23 @@ static void VulkanEmuThread(ANativeWindow *wnd, GraphicsContext *graphicsContext } renderer_inited = true; - RunMainLoop(graphicsContext, new NativeApplication(), [](GraphicsContext *graphicsContext) { + const auto frame = [](GraphicsContext *graphicsContext) { NativeFrame(graphicsContext); 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); + // This is the GL "GPU thread" - the loop Java's displayRender used to drive one call at a + // time. ThreadFrame returns false once the emu thread is done producing. + while (!exitRenderLoop && graphicsContext->ThreadFrame()) {} + // 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/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index c23dcc30f2..86bb1b6bcc 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -641,9 +641,11 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList sizeManager.checkDisplayMeasurements(); } - public native boolean runVulkanRenderLoop(Surface surface); + // Starts the native render loop thread. Used by Vulkan, and by OpenGL when the native side + // owns EGL rather than the GLSurfaceView in the javaGL path. + public native boolean runRenderLoop(Surface surface); // Tells the render loop thread to exit, so we can restart it. - public native void requestExitVulkanRenderLoop(); + public native void requestExitRenderLoop(); @Override public void onCreate(Bundle savedInstanceState) { @@ -942,7 +944,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList Log.w(TAG, "startRenderLoopThread: Starting thread"); applyFrameRate(mSurface, 60.0f); - runVulkanRenderLoop(mSurface); + runRenderLoop(mSurface); } private synchronized void joinRenderLoopThread() { @@ -952,8 +954,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } // This will wait until the thread has exited. - Log.i(TAG, "requestExitVulkanRenderLoop"); - requestExitVulkanRenderLoop(); + Log.i(TAG, "requestExitRenderLoop"); + requestExitRenderLoop(); } void setupSystemUiCallback() { From 812d8f4c09a2791b37206cee014ffa42361abaf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 8 Oct 2026 05:27:49 +0200 Subject: [PATCH 2/3] Android GL: Delete the JavaGL path, leaving only native EGL The EGL path from the previous commit is now the only one, so GLSurfaceView and everything that existed to serve it are gone: - NativeRenderer.java and NativeGLSurfaceView.java, deleted. - The javaGL branches in PpssppActivity. GL and Vulkan now share one lifecycle: a plain SurfaceView, startRenderLoopThread() when the surface arrives, joinRenderLoopThread() when it goes away. - The "androidJavaGL" queryConfig, and the AndroidNativeEGL setting and its developer-tools checkbox. - displayInit/displayRender, and with them the "Second time, joining the emuthread and starting it up again" dance - restarting the emu thread from inside a JNI callback because a lost context was only observable as a second onSurfaceCreated. - GraphicsContext::NotifyContextLost, which existed solely because that join drained the render queue on a thread with no current context. The render thread now joins the emu thread with the context still current, so the drain is real work and nothing has to be skipped. - g_emuThread, now that nothing starts an emu thread outside the render loop. Three things displayInit/displayRender did that nothing else did, moved rather than dropped: - EnterVR and the per-frame StartVRRender/UpdateVRInput/FinishVRRender hooks now live in RenderLoopThread's GL branch. Java's GL thread could simply return early and wait for the next vsync when the VR session was inactive; this loop drives itself, so that case sleeps 16ms instead of spinning. Not tested - no headset here. - RECREATE_VIEWS after the emu thread starts, as before. Vulkan never did this, so it's a candidate for removal when the two paths converge. - The thin3d error callback, which surfaces GL errors as OSD messages. That one moved into AndroidEGLGraphicsContext::InitSurface, next to the rest of the context's own setup, matching WindowsGLContext. Tested on a nubia Z2464N (Unisoc UMS9632, Mali-G57, Android 15): cold start, UI, booting a homebrew, backgrounding and resuming both in the menu and in-game, and a density change, which destroys and recreates the activity the way a rotation does. Every cycle tears the EGL context down and builds a new one, and the renderer comes back with FBOs and textures intact. The config ladder's first choice, RGBA8888 D24S8, is what the Java chooser landed on here too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01152igCdjTNhT29qvphPyPC --- Common/GPU/GraphicsContext.h | 4 - Common/GPU/OpenGL/OpenGLGraphicsContext.h | 6 - Common/GPU/Vulkan/VulkanQueueRunner.cpp | 2 +- Core/Config.cpp | 1 - Core/Config.h | 6 - UI/DeveloperToolsScreen.cpp | 11 -- android/jni/AndroidEGLGraphicsContext.cpp | 22 ++- android/jni/AndroidEGLGraphicsContext.h | 19 +- android/jni/app-android.cpp | 182 ++++-------------- android/src/org/ppsspp/ppsspp/NativeApp.java | 2 - .../ppsspp/ppsspp/NativeGLSurfaceView.java | 22 --- .../src/org/ppsspp/ppsspp/NativeRenderer.java | 52 ----- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 137 +++---------- 13 files changed, 88 insertions(+), 378 deletions(-) delete mode 100644 android/src/org/ppsspp/ppsspp/NativeGLSurfaceView.java delete mode 100644 android/src/org/ppsspp/ppsspp/NativeRenderer.java diff --git a/Common/GPU/GraphicsContext.h b/Common/GPU/GraphicsContext.h index ca05a20a17..a4b2667a41 100644 --- a/Common/GPU/GraphicsContext.h +++ b/Common/GPU/GraphicsContext.h @@ -54,10 +54,6 @@ public: // Called from the emu thread, so be ready for that. virtual void NotifyEmuThreadExit() {} - // The API context that queued-up commands were recorded against is gone (or isn't current on the - // thread about to drain them), so they must not be executed for real. Only meaningful on Android GL. - virtual void NotifyContextLost() {} - // Useful for checks that need to be performed every frame. // Should strive to get rid of these. virtual void Poll() {} diff --git a/Common/GPU/OpenGL/OpenGLGraphicsContext.h b/Common/GPU/OpenGL/OpenGLGraphicsContext.h index 2214830045..76895759e4 100644 --- a/Common/GPU/OpenGL/OpenGLGraphicsContext.h +++ b/Common/GPU/OpenGL/OpenGLGraphicsContext.h @@ -33,12 +33,6 @@ public: renderManager_->ThreadEnd(); } - void NotifyContextLost() override { - if (renderManager_) { - renderManager_->SetSkipGLCalls(); - } - } - // Call from emu thread void NotifyEmuThreadExit() override { renderManager_->SetSkipGLCalls(); diff --git a/Common/GPU/Vulkan/VulkanQueueRunner.cpp b/Common/GPU/Vulkan/VulkanQueueRunner.cpp index 2e99af395f..7ea72f7bd4 100644 --- a/Common/GPU/Vulkan/VulkanQueueRunner.cpp +++ b/Common/GPU/Vulkan/VulkanQueueRunner.cpp @@ -8,7 +8,7 @@ using namespace PPSSPP_VK; -// Debug help: adb logcat -s DEBUG AndroidRuntime PPSSPPNativeActivity PPSSPP NativeGLView NativeRenderer NativeSurfaceView PowerSaveModeReceiver InputDeviceState PpssppActivity CameraHelper PPSSPPSizeManager +// Debug help: adb logcat -s DEBUG AndroidRuntime PPSSPPNativeActivity PPSSPP NativeSurfaceView PowerSaveModeReceiver InputDeviceState PpssppActivity CameraHelper PPSSPPSizeManager static void MergeRenderAreaRectInto(VkRect2D *dest, const VkRect2D &src) { if (dest->offset.x > src.offset.x) { diff --git a/Core/Config.cpp b/Core/Config.cpp index df0a205302..a82c9cc8c5 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -704,7 +704,6 @@ static const ConfigSetting graphicsSettings[] = { ConfigSetting("Smart2DTexFiltering", SETTING(g_Config, bSmart2DTexFiltering), false, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("InternalResolution", SETTING(g_Config, iInternalResolution), &DefaultInternalResolution, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("AndroidHwScale", SETTING(g_Config, iAndroidHwScale), 0, CfgFlag::DEFAULT), - ConfigSetting("AndroidNativeEGL", SETTING(g_Config, bAndroidNativeEGL), false, CfgFlag::DEFAULT), ConfigSetting("HighQualityDepth", SETTING(g_Config, bHighQualityDepth), true, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("FrameSkip", SETTING(g_Config, iFrameSkip), 0, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("AutoFrameSkip", SETTING(g_Config, bAutoFrameSkip), IsVREnabled(), CfgFlag::PER_GAME | CfgFlag::REPORT), diff --git a/Core/Config.h b/Core/Config.h index b369dadc5e..ca21fb0658 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -569,12 +569,6 @@ public: // Use the hardware scaler to scale up the image to save fillrate. Similar to Windows' window size, really. int iAndroidHwScale; // 0 = device resolution. 1 = 480x272 (extended to correct aspect), 2 = 960x544 etc. - // Android, OpenGL backend only: own EGL and the render thread in C++ instead of letting - // Java's GLSurfaceView do it. Temporary, while the new path is being shaken out on devices - - // the intent is for it to become the only way and for the JavaGL path to go away. - // Only read when the activity is created, so changing it needs an app restart. - bool bAndroidNativeEGL; - // Risky JIT optimizations bool bDiscardRegsOnJRRA; diff --git a/UI/DeveloperToolsScreen.cpp b/UI/DeveloperToolsScreen.cpp index 5a46cc1ba0..c570b671ae 100644 --- a/UI/DeveloperToolsScreen.cpp +++ b/UI/DeveloperToolsScreen.cpp @@ -539,17 +539,6 @@ void DeveloperToolsScreen::CreateGraphicsTab(UI::LinearLayout *list) { }); } -#if PPSSPP_PLATFORM(ANDROID) - if (g_Config.iGPUBackend == (int)GPUBackend::OPENGL) { - list->Add(new ItemHeader(dev->T("OpenGL"))); - // Temporary, while the EGL path is being tested on devices - see Core/Config.h. - list->Add(new CheckBox(&g_Config.bAndroidNativeEGL, dev->T("Use native EGL instead of GLSurfaceView"), ""))->OnClick.Add([](UI::EventParams &e) { - // TODO: Not translating yet, same as the multi-threaded rendering checkbox above. - g_OSD.Show(OSDType::MESSAGE_WARNING, "Restart required"); - }); - } -#endif - if (GetGPUBackend() == GPUBackend::VULKAN && SupportsCustomDriver()) { auto driverChoice = list->Add(new Choice(gr->T("AdrenoTools driver manager"))); driverChoice->OnClick.Add([=](UI::EventParams &e) { diff --git a/android/jni/AndroidEGLGraphicsContext.cpp b/android/jni/AndroidEGLGraphicsContext.cpp index 6bc5d2d533..962a093609 100644 --- a/android/jni/AndroidEGLGraphicsContext.cpp +++ b/android/jni/AndroidEGLGraphicsContext.cpp @@ -19,6 +19,7 @@ #include "Common/Log.h" #include "Common/StringUtils.h" +#include "Common/System/OSD.h" #include "Common/VR/PPSSPPVR.h" #include "android/jni/AndroidEGLGraphicsContext.h" @@ -66,7 +67,7 @@ struct ConfigAttempt { // Stencil is not optional in practice: without it, non-buffered rendering has nothing to work with. // // The last entry is the fallback: ask for the bare minimum and take whatever eglChooseConfig puts -// first, which is roughly what GLSurfaceView's default chooser does today. +// first, which is roughly what GLSurfaceView's default chooser used to do. const ConfigAttempt kConfigAttempts[] = { { "RGBA8888 D24S8", 8, 8, 8, 8, 24, 8 }, { "RGBA8888 D16S8", 8, 8, 8, 8, 16, 8 }, @@ -139,9 +140,9 @@ bool AndroidEGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, vo INFO_LOG(Log::G3D, "EGL %d.%d initialized (vendor: %s)", eglMajor, eglMinor, eglQueryString(display_, EGL_VENDOR)); - // Mirrors NativeRenderer's setEGLContextClientVersion(isVRDevice() ? 3 : 2). Asking for 2 is - // not a cap: GLES3 is detected from GL_VERSION at runtime (see GLFeatures.cpp), which is how - // the Java path has always ended up using GLES3 on devices that have it. + // Mirrors what NativeRenderer used to ask for: setEGLContextClientVersion(isVRDevice() ? 3 : 2). + // Asking for 2 is not a cap: GLES3 is detected from GL_VERSION at runtime (see GLFeatures.cpp), + // which is how the Java path always ended up using GLES3 on devices that have it. const bool wantGLES3 = IsVREnabled(); if (!ChooseConfig(wantGLES3, errorMessage)) { DestroyEGL(); @@ -151,6 +152,7 @@ bool AndroidEGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, vo // Line the window's pixel format up with the config we picked. Skipping this is how you get // EGL_BAD_MATCH out of eglCreateWindowSurface on drivers that take the window format // literally - historically the reason the Java path asked for no particular config at all. + // That is also why we can afford to be picky in kConfigAttempts where Java could not. // Width and height stay 0 so whatever size Java set (see iAndroidHwScale) is left alone. EGLint nativeVisualID = 0; if (eglGetConfigAttrib(display_, config_, EGL_NATIVE_VISUAL_ID, &nativeVisualID)) { @@ -192,6 +194,12 @@ bool AndroidEGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, vo return false; } + // As on Windows (see WindowsGLContext), surface GL errors to the user. The Java path did this + // from displayInit; now that the context owns its own init, it belongs here. + draw_->SetErrorCallback([](const char *shortDesc, const char *details, void *userdata) { + g_OSD.Show(OSDType::MESSAGE_ERROR, details, 5.0); + }, nullptr); + renderManager_->SetSwapFunction([this]() { if (!eglSwapBuffers(display_, surface_)) { // Most likely the surface went away under us. The render loop is about to be asked to @@ -201,9 +209,9 @@ bool AndroidEGLGraphicsContext::InitSurface(WindowSystem winsys, void *data1, vo }); // NOTE: Deliberately *not* calling SetSwapIntervalFunction. GLSurfaceView gave us no way to - // change the interval, so PresentMode::IMMEDIATE has always been a no-op here, and honoring it - // now would be a behavior change rather than a port. Worth revisiting once this path is the - // only one. + // change the interval, so PresentMode::IMMEDIATE was always a no-op on Android GL, and this + // class set out to behave identically. Now that it's the only path, eglSwapInterval(0) is + // there for the taking - but that's a behavior change, so it wants its own commit. return true; } diff --git a/android/jni/AndroidEGLGraphicsContext.h b/android/jni/AndroidEGLGraphicsContext.h index 27997ec315..dde60aad60 100644 --- a/android/jni/AndroidEGLGraphicsContext.h +++ b/android/jni/AndroidEGLGraphicsContext.h @@ -23,18 +23,15 @@ struct ANativeWindow; -// Owns EGL for the OpenGL backend on Android, instead of letting Java's GLSurfaceView own it -// (NativeRenderer.java / NativeGLSurfaceView.java, the "JavaGL" path). +// Owns EGL for the OpenGL backend on Android. Until 2026 this was Java's job, through a +// GLSurfaceView (NativeRenderer.java / NativeGLSurfaceView.java, the "JavaGL" path). // -// The point is not EGL itself but who owns the render thread. With GLSurfaceView, Java creates -// the thread and the context and calls us back, so a lost context is only ever observable after -// the fact, as a second onSurfaceCreated - which is what forces app-android.cpp's displayInit to -// tear down and restart the emu thread from inside a JNI callback. Owning EGL here lets the GL -// backend use the same lifecycle as Vulkan: we start a render thread when we get a surface and -// join it when the surface goes away, which is the whole reason this exists. -// -// Deliberately behaves the same as the Java path otherwise - same GLES client version, same -// window pixel format, same separate emu thread. See Core/Config.h, bAndroidNativeEGL. +// The point is not EGL itself but who owns the render thread. With GLSurfaceView, Java created +// the thread and the context and called us back, so a lost context was only ever observable +// after the fact, as a second onSurfaceCreated - which forced app-android.cpp to tear down and +// restart the emu thread from inside a JNI callback. Owning EGL here lets the GL backend use the +// same lifecycle as Vulkan: we start a render thread when we get a surface and join it when the +// surface goes away, which is the whole reason this exists. class AndroidEGLGraphicsContext : public OpenGLGraphicsContext { public: // Note: not just DestroyEGL(). The base destructor deletes the thin3d context, which still diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 458dfa5506..e034d6584a 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -88,7 +88,6 @@ struct JNIEnv {}; #include "Common/GPU/Vulkan/VulkanLoader.h" #include "Common/GPU/GraphicsContext.h" #include "Common/GPU/Vulkan/VulkanGraphicsContext.h" -#include "Common/GPU/OpenGL/OpenGLGraphicsContext.h" #include "android/jni/AndroidEGLGraphicsContext.h" #include "Common/StringUtils.h" #include "Common/TimeUtil.h" @@ -115,9 +114,6 @@ enum class EmuThreadState { STOPPED, }; -// OpenGL emu thread -static std::thread g_emuThread; - AndroidAudioState *g_audioState; struct FrameCommand { @@ -572,18 +568,6 @@ static std::string QueryConfig(std::string_view query) { return g_Config.GetDisplayLayoutConfig(g_display.GetDeviceOrientation()).bImmersiveMode ? "1" : "0"; } else if (query == "sustainedPerformanceMode") { return g_Config.bSustainedPerformanceMode ? "1" : "0"; - } else if (query == "androidJavaGL") { - // If we're using Vulkan, we say no... need C++ to use Vulkan. - if (GetGPUBackend() == GPUBackend::VULKAN) { - return "false"; - } - // GL with our own EGL wants the same treatment as Vulkan: a plain SurfaceView, and a - // render thread we start ourselves once there's a surface. - if (g_Config.bAndroidNativeEGL) { - return "false"; - } - // Otherwise, some devices prefer the Java init so play it safe. - return "true"; } else if (query == "audioMixWithOthers") { return g_Config.bAudioMixWithOthers ? "1" : "0"; } else { @@ -780,15 +764,9 @@ extern "C" void Java_org_ppsspp_ppsspp_NativeApp_init retry: switch (g_Config.iGPUBackend) { case (int)GPUBackend::OPENGL: - if (g_Config.bAndroidNativeEGL) { - INFO_LOG(Log::System, "NativeApp.init() -- creating OpenGL context (native EGL)"); - graphicsContext = new AndroidEGLGraphicsContext(); - // Like Vulkan, we wait for a surface before doing anything - see runRenderLoop. - } else { - INFO_LOG(Log::System, "NativeApp.init() -- creating OpenGL context (JavaGL)"); - graphicsContext = new OpenGLGraphicsContext(); - INFO_LOG(Log::System, "NativeApp.init() - not yet launching emuthread, waiting to displayInit"); - } + INFO_LOG(Log::System, "NativeApp.init() -- creating OpenGL context"); + graphicsContext = new AndroidEGLGraphicsContext(); + // Like Vulkan, we wait for a surface before doing anything - see runRenderLoop. break; case (int)GPUBackend::VULKAN: { @@ -909,20 +887,8 @@ extern "C" void Java_org_ppsspp_ppsspp_NativeApp_pause(JNIEnv *, jclass) { extern "C" void Java_org_ppsspp_ppsspp_NativeApp_shutdown(JNIEnv *, jclass) { INFO_LOG(Log::System, "NativeApp.shutdown() -- begin"); - // Only the JavaGL path gets here with a thread to join: it's the one that starts the emu thread - // from a JNI callback and leaves it in g_emuThread. On the native-EGL and Vulkan paths the - // render loop thread owns its own, and has already been joined via requestExitRenderLoop. - if (renderer_inited && graphicsContext && graphicsContext->NeedsSeparateEmuThread() && g_emuThread.joinable()) { - // The join drains the render queue right here, on a thread without a GL context. - graphicsContext->NotifyContextLost(); - INFO_LOG(Log::System, "Joining emuthread."); - EmuThread_Join(graphicsContext, g_emuThread); - - INFO_LOG(Log::System, "EmuThread joined."); - graphicsContext->ShutdownSurface(); - INFO_LOG(Log::System, "Graphics context now shut down from NativeApp_shutdown"); - } - + // Nothing to join here: the render loop thread owns both itself and the emu thread, and + // requestExitRenderLoop has already brought them down along with the surface. { if (graphicsContext) { INFO_LOG(Log::G3D, "Shutting down renderer"); @@ -945,79 +911,6 @@ extern "C" void Java_org_ppsspp_ppsspp_NativeApp_shutdown(JNIEnv *, jclass) { INFO_LOG(Log::System, "NativeApp.shutdown() -- end"); } -// JavaEGL. This doesn't get called on the Vulkan path. -// This gets called from onSurfaceCreated. -extern "C" jboolean Java_org_ppsspp_ppsspp_NativeRenderer_displayInit(JNIEnv * env, jobject obj) { - if (!graphicsContext) { - ERROR_LOG(Log::G3D, "NativeApp.displayInit() - graphicsContext is null!"); - return false; - } - - _assert_(graphicsContext->NeedsSeparateEmuThread()); - - INFO_LOG(Log::G3D, "NativeApp.displayInit()"); - bool firstStart = !renderer_inited; - - // We should be running on the render thread here. - std::string errorMessage; - if (!renderer_inited) { - INFO_LOG(Log::G3D, "NativeApp.displayInit() first time"); - if (!graphicsContext->InitSurface(WINDOWSYSTEM_ANDROID, nullptr, nullptr, &errorMessage)) { - System_Toast("Graphics initialization failed. Quitting."); - return false; - } - - graphicsContext->GetDrawContext()->SetErrorCallback([](const char *shortDesc, const char *details, void *userdata) { - g_OSD.Show(OSDType::MESSAGE_ERROR, details, 5.0); - }, nullptr); - - // This is where we start the emuthread now - after InitFromRenderThread. This eliminates a race condition. - g_emuThread = EmuThread_Start(graphicsContext, new NativeApplication(), [](GraphicsContext *graphicsContext) { - NativeFrame(graphicsContext); - ProcessFrameCommands(); - return true; - }); - renderer_inited = true; - } else { - // Would be really nice if we could get something on the GL thread immediately when shutting down, - // but the only mechanism for handling lost devices seems to be that onSurfaceCreated is called again, - // which ends up calling displayInit. - INFO_LOG(Log::G3D, "NativeApp.displayInit(): Second time, joining the emuthread and starting it up again."); - // We only get here with a fresh EGL context. What's left in the render queue refers to objects - // from the old one, so it mustn't be run against this one when the join drains it. - graphicsContext->NotifyContextLost(); - EmuThread_Join(graphicsContext, g_emuThread); - - graphicsContext->ShutdownSurface(); - - INFO_LOG(Log::G3D, "Shut down both threads. Now let's bring it up again!"); - - if (!graphicsContext->InitSurface(WINDOWSYSTEM_ANDROID, nullptr, nullptr, &errorMessage)) { - System_Toast(("Graphics initialization failed: Quitting: " + errorMessage).c_str()); - return false; - } - - graphicsContext->GetDrawContext()->SetErrorCallback([](const char *shortDesc, const char *details, void *userdata) { - g_OSD.Show(OSDType::MESSAGE_ERROR, details, 5.0); - }, nullptr); - - g_emuThread = EmuThread_Start(graphicsContext, new NativeApplication(), [](GraphicsContext *graphicsContext) { - NativeFrame(graphicsContext); - ProcessFrameCommands(); - return true; - }); - - INFO_LOG(Log::G3D, "Restored."); - } - - System_PostUIMessage(UIMessage::RECREATE_VIEWS); - - if (IsVREnabled()) { - EnterVR(firstStart); - } - return true; -} - extern "C" void JNICALL Java_org_ppsspp_ppsspp_NativeApp_backbufferResize(JNIEnv *, jclass, jint pixel_xres, jint pixel_yres, jint format) { INFO_LOG(Log::System, "NativeApp.backbufferResize(%d x %d)", pixel_xres, pixel_yres); @@ -1157,38 +1050,6 @@ extern "C" void JNICALL Java_org_ppsspp_ppsspp_NativeApp_sendRequestResult(JNIEn } } -// This doesn't get called on the Vulkan path. -// We don't need a render thread "loop" as this gets called repeatedly by the system, by a system -// render thread. -extern "C" void Java_org_ppsspp_ppsspp_NativeRenderer_displayRender(JNIEnv *env, jobject obj) { - static bool hasSetThreadName = false; - if (!hasSetThreadName) { - hasSetThreadName = true; - SetCurrentThreadName("AndroidRender"); - } - - if (IsVREnabled() && !StartVRRender()) { - return; - } - - // This is the "GPU thread". Call ThreadFrame. - if (!graphicsContext) { - return; - } - _assert_(graphicsContext->NeedsSeparateEmuThread()); - - if (!graphicsContext->ThreadFrame()) { - INFO_LOG(Log::G3D, "ThreadFrame returned false"); - // TODO: We should stop calling ThreadFrame here. - return; - } - - if (IsVREnabled()) { - UpdateVRInput(g_Config.bHapticFeedback, g_display.dpi_scale_x, g_display.dpi_scale_y); - FinishVRRender(); - } -} - void System_AskForPermission(SystemPermission permission) { switch (permission) { case SYSTEM_PERMISSION_STORAGE: @@ -1649,8 +1510,7 @@ std::thread g_renderLoopThread; static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContext); -// Runs on the Vulkan path, and on the OpenGL path when we own EGL (bAndroidNativeEGL). Not in -// JavaGL mode - there, Java owns the thread and calls displayInit/displayRender instead. +// Runs on both the Vulkan and the OpenGL path. // This handles the entire lifecycle of the graphics context's surface, init and exit. extern "C" jboolean JNICALL Java_org_ppsspp_ppsspp_PpssppActivity_runRenderLoop(JNIEnv * env, jobject obj, jobject _surf) { if (!graphicsContext) { @@ -1744,9 +1604,35 @@ static void RenderLoopThread(ANativeWindow *wnd, GraphicsContext *graphicsContex // 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); - // This is the GL "GPU thread" - the loop Java's displayRender used to drive one call at a + + // 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 && graphicsContext->ThreadFrame()) {} + 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 { diff --git a/android/src/org/ppsspp/ppsspp/NativeApp.java b/android/src/org/ppsspp/ppsspp/NativeApp.java index a2b575e193..199d80af55 100644 --- a/android/src/org/ppsspp/ppsspp/NativeApp.java +++ b/android/src/org/ppsspp/ppsspp/NativeApp.java @@ -1,7 +1,5 @@ package org.ppsspp.ppsspp; -// Note that the display* methods are in NativeRenderer.java - import android.os.Build; import android.util.Log; import android.view.InputDevice; diff --git a/android/src/org/ppsspp/ppsspp/NativeGLSurfaceView.java b/android/src/org/ppsspp/ppsspp/NativeGLSurfaceView.java deleted file mode 100644 index 503adfc381..0000000000 --- a/android/src/org/ppsspp/ppsspp/NativeGLSurfaceView.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.ppsspp.ppsspp; - -// Touch-enabled GLSurfaceView. -// Used when javaGL = true. - -import android.annotation.SuppressLint; -import android.content.Context; -import android.opengl.GLSurfaceView; -import android.view.MotionEvent; - -public class NativeGLSurfaceView extends GLSurfaceView { - public NativeGLSurfaceView(Context context) { - super(context); - } - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouchEvent(final MotionEvent ev) { - NativeApp.processTouchEvent(ev); - return true; - } -} diff --git a/android/src/org/ppsspp/ppsspp/NativeRenderer.java b/android/src/org/ppsspp/ppsspp/NativeRenderer.java deleted file mode 100644 index c7837d807e..0000000000 --- a/android/src/org/ppsspp/ppsspp/NativeRenderer.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.ppsspp.ppsspp; - -import android.opengl.GLSurfaceView; -import android.util.Log; - -import javax.microedition.khronos.egl.EGL10; -import javax.microedition.khronos.egl.EGLConfig; -import javax.microedition.khronos.egl.EGLContext; -import javax.microedition.khronos.egl.EGLDisplay; -import javax.microedition.khronos.opengles.GL10; - -// Only used for the OpenGL backend. -public class NativeRenderer implements GLSurfaceView.Renderer { - private static final String TAG = "NativeRenderer"; - - public void onDrawFrame(GL10 unused /*use GLES20*/) { - displayRender(); - } - public void onSurfaceCreated(GL10 gl, EGLConfig config) { - Log.i(TAG, "NativeRenderer (OpenGL): onSurfaceCreated"); - - EGL10 egl = (EGL10)EGLContext.getEGL(); - if (egl != null) { - EGLDisplay dpy = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); - if (dpy != null) { - int[] val = new int[1]; - egl.eglGetConfigAttrib(dpy, config, EGL10.EGL_DEPTH_SIZE, val); - int depthBits = val[0]; - egl.eglGetConfigAttrib(dpy, config, EGL10.EGL_STENCIL_SIZE, val); - int stencilBits = val[0]; - Log.i(TAG, "EGL reports " + depthBits + " bits of depth and " + stencilBits + " bits of stencil."); - } else { - Log.e(TAG, "dpy == null"); - } - } else { - Log.e(TAG, "egl == null"); - } - - if (!displayInit()) { - Log.e(TAG, "Display init failed"); - } - } - - public void onSurfaceChanged(GL10 unused, int width, int height) { - Log.i(TAG, "NativeRenderer (OpenGL): onSurfaceChanged: width=" + width + " height=" + height); - } - - // Note: This also means "device lost" and you should reload - // all buffered objects. - public native boolean displayInit(); - public native void displayRender(); -} diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 86bb1b6bcc..53d069369a 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -78,20 +78,13 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // Allows us to skip a lot of initialization on secondary calls to onCreate. private static boolean initialized = false; - // False to use Vulkan, queried from C++ after NativeApp.init. - private static boolean javaGL = true; - // Lifecycle tracker, to detect erroneous states. private final LifeCycle lifeCycle = new LifeCycle(); - // Graphics and audio interfaces for Vulkan (javaGL = false) + // The surface we hand to the native render loop thread, for both OpenGL and Vulkan. private NativeSurfaceView mSurfaceView; private Surface mSurface; - // Graphics and audio interfaces for Java EGL (javaGL = true) - private NativeGLSurfaceView mGLSurfaceView; - protected NativeRenderer nativeRenderer; - // For accelerometer sensing. private SensorManager mSensorManager; private Sensor mAccelerometer; @@ -468,9 +461,6 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList NativeApp.audioConfig(optimalFramesPerBuffer, optimalSampleRate); NativeApp.init(model, deviceType, languageRegion, apkFilePath, dataDir, extStorageDir, externalFilesDir, nativeLibDir, additionalStorageDirs, cacheDir, shortcut, installerName, Build.VERSION.SDK_INT, Build.BOARD, smallestScreenWidthDp); - // Allow C++ to tell us to use JavaGL or not. - javaGL = "true".equalsIgnoreCase(NativeApp.queryConfig("androidJavaGL")); - sendInitialGrants(); // OK, config should be initialized, we can query for screen rotation. @@ -641,8 +631,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList sizeManager.checkDisplayMeasurements(); } - // Starts the native render loop thread. Used by Vulkan, and by OpenGL when the native side - // owns EGL rather than the GLSurfaceView in the javaGL path. + // Starts the native render loop thread, which owns the graphics context and its surface. public native boolean runRenderLoop(Surface surface); // Tells the render loop thread to exit, so we can restart it. public native void requestExitRenderLoop(); @@ -713,49 +702,14 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList updateAudioFocus(this.audioManager, this.audioFocusChangeListener); NativeApp.audioInit(); - if (javaGL) { - mGLSurfaceView = new NativeGLSurfaceView(this); - nativeRenderer = new NativeRenderer(); - mGLSurfaceView.setEGLContextClientVersion(isVRDevice() ? 3 : 2); + updateSystemUiVisibility(); - sizeManager.setSurfaceView(mGLSurfaceView); - setInsetsListener(mGLSurfaceView); + mSurfaceView = new NativeSurfaceView(this); + sizeManager.setSurfaceView(mSurfaceView); + setInsetsListener(mSurfaceView); + setContentView(mSurfaceView); - // Setup the GLSurface and ask android for the correct - // Number of bits for r, g, b, a, depth and stencil components - // The PSP only has 16-bit Z so that should be enough. - // Might want to change this for other apps (24-bit might be useful). - // Actually, we might be able to do without both stencil and depth in - // the back buffer, but that would kill non-buffered rendering. - - // It appears some gingerbread devices blow up if you use a config chooser at all ???? (Xperia Play) - //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - - // On some (especially older devices), things blow up later (EGL_BAD_MATCH) if we don't set - // the format here, if we specify that we want destination alpha in the config chooser, which we do. - // http://grokbase.com/t/gg/android-developers/11bj40jm4w/fall-back - - // Tried to mess around with config choosers (NativeEGLConfigChooser) here but fail completely on Xperia Play. - - // Then I tried to require 8888/16/8 but that backfired too, does not work on Mali 450 which is - // used in popular TVs and boxes like Mi Box. So we'll just get what we get, I guess... - - // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH && !Build.MANUFACTURER.equals("Amazon")) { - // mGLSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 8); - // } - - mGLSurfaceView.setRenderer(nativeRenderer); - setContentView(mGLSurfaceView); - } else { - updateSystemUiVisibility(); - - mSurfaceView = new NativeSurfaceView(this); - sizeManager.setSurfaceView(mSurfaceView); - setInsetsListener(mSurfaceView); - setContentView(mSurfaceView); - - // render loop thread will be started once we get a surface. - } + // The render loop thread will be started once we get a surface. if (!firstRun && shortcutParam != null && !shortcutParam.isEmpty()) { // The native side is already up, so it didn't see this through NativeApp.init. @@ -903,39 +857,27 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList public void notifySurface(Surface surface) { mSurface = surface; - if (!javaGL) { - if (!initialized) { - Log.e(TAG, "notifySurface: Saving surface, but can't start/stop threads while not initialized"); - return; - } + if (!initialized) { + Log.e(TAG, "notifySurface: Saving surface, but can't start/stop threads while not initialized"); + return; + } - // If we got a surface, this starts the thread. - if (mSurface != null) { - // applyFramerate is called in here. - Log.i(TAG, "notifySurface: got surface, starting thread."); - startRenderLoopThread(); - } else { - // The surface must not be touched once surfaceDestroyed returns. Normally onPause has - // already joined the thread and this does nothing, but that order isn't guaranteed. - Log.i(TAG, "notifySurface: Surface is gone, making sure the render thread is too."); - joinRenderLoopThread(); - } - } else if (mSurface != null) { - // JavaGL path. - // TODO: This might not be the best place to do this. Seems to cause a surface recreation - // unnecessarily. - Log.i(TAG, "notifySurface: Applying framerate."); - applyFrameRate(mSurface, 60.0f); + // If we got a surface, this starts the thread. + if (mSurface != null) { + // applyFramerate is called in here. + Log.i(TAG, "notifySurface: got surface, starting thread."); + startRenderLoopThread(); + } else { + // The surface must not be touched once surfaceDestroyed returns. Normally onPause has + // already joined the thread and this does nothing, but that order isn't guaranteed. + Log.i(TAG, "notifySurface: Surface is gone, making sure the render thread is too."); + joinRenderLoopThread(); } updateSustainedPerformanceMode(); } // The render loop thread (EmuThread) is now spawned from the native side. protected synchronized void startRenderLoopThread() { - if (javaGL) { - Log.e(TAG, "JavaGL mode - should not get into startRenderLoopThread."); - return; - } if (mSurface == null) { Log.w(TAG, "startRenderLoopThread - not starting thread, needs surface"); return; @@ -948,11 +890,6 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } private synchronized void joinRenderLoopThread() { - if (javaGL) { - Log.e(TAG, "JavaGL - should not get into joinRenderLoopThread."); - return; - } - // This will wait until the thread has exited. Log.i(TAG, "requestExitRenderLoop"); requestExitRenderLoop(); @@ -973,13 +910,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList super.onDestroy(); lifeCycle.onDestroy(); - if (javaGL) { - nativeRenderer = null; - mGLSurfaceView = null; - } else { - mSurfaceView = null; - mSurface = null; - } + mSurfaceView = null; + mSurface = null; mSensorManager = null; mAccelerometer = null; @@ -1034,14 +966,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList InputManager inputManager = (InputManager)getSystemService(Context.INPUT_SERVICE); inputManager.unregisterInputDeviceListener(inputDeviceListener); - if (!javaGL) { - Log.i(TAG, "Joining render thread..."); - joinRenderLoopThread(); - Log.i(TAG, "Joined render thread"); - } else if (mGLSurfaceView != null) { - Log.i(TAG, "mGLSurfaceView.onPause..."); - mGLSurfaceView.onPause(); - } + Log.i(TAG, "Joining render thread..."); + joinRenderLoopThread(); + Log.i(TAG, "Joined render thread"); Log.i(TAG, "mSensorManager.unregisterListener..."); mSensorManager.unregisterListener(this); @@ -1081,12 +1008,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList (InputManager)getSystemService(Context.INPUT_SERVICE); inputManager.registerInputDeviceListener(inputDeviceListener, null); - if (!javaGL) { - // Restart the render loop. - startRenderLoopThread(); - } else if (mGLSurfaceView != null) { - mGLSurfaceView.onResume(); - } + // Restart the render loop. + startRenderLoopThread(); Log.i(TAG, "onResume end"); } @@ -1475,7 +1398,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } public boolean processCommand(String command, String params) { - SurfaceView surfView = javaGL ? mGLSurfaceView : mSurfaceView; + SurfaceView surfView = mSurfaceView; if (command.equals("launchBrowser")) { // Special case for twitter if (params.startsWith("https://twitter.com/#!/")) { 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 3/3] 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