diff --git a/.gitmodules b/.gitmodules index ef4ae2ea2..c7f2d0bd7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -62,3 +62,6 @@ [submodule "core/deps/freetype"] path = core/deps/freetype url = https://gitlab.freedesktop.org/freetype/freetype.git +[submodule "core/deps/gamesdk"] + path = core/deps/gamesdk + url = https://github.com/flyinghead/gamesdk.git diff --git a/CMakeLists.txt b/CMakeLists.txt index ff86145f3..9c34ba6a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1253,6 +1253,15 @@ if(NOT LIBRETRO) add_subdirectory(shell/android-studio/flycast/src/main/jni/src) target_link_libraries(${PROJECT_NAME} PRIVATE android log) + set(GAMESDK_THREAD_CHECKS 0) + add_subdirectory(core/deps/gamesdk/games-frame-pacing EXCLUDE_FROM_ALL) + target_compile_options(swappy_static PRIVATE $<$:-Wno-vla-cxx-extension>) + target_compile_definitions(swappy_static PRIVATE ANDROIDGAMESDK_NO_BINARY_DEX_LINKAGE) + + target_include_directories(${PROJECT_NAME} PRIVATE core/deps/gamesdk/games-frame-pacing/include) + target_link_libraries(${PROJECT_NAME} PRIVATE swappy_static) + target_compile_definitions(${PROJECT_NAME} PRIVATE SWAPPY) + elseif(APPLE) string(TIMESTAMP YEAR "%Y") string(REGEX MATCH "^[^-]+" TAG_VERSION ${GIT_VERSION}) diff --git a/core/cfg/option.cpp b/core/cfg/option.cpp index ecd4a3be1..82f3994f6 100644 --- a/core/cfg/option.cpp +++ b/core/cfg/option.cpp @@ -120,6 +120,7 @@ Option NativeDepthInterpolation("rend.NativeDepthInterpolation", false); Option EmulateFramebuffer("rend.EmulateFramebuffer", false); Option FixUpscaleBleedingEdge("rend.FixUpscaleBleedingEdge", true); Option CustomGpuDriver("rend.CustomGpuDriver", false); +Option FramePacing("rend.FramePacing", true); #ifdef VIDEO_ROUTING Option VideoRouting("rend.VideoRouting", false); Option VideoRoutingScale("rend.VideoRoutingScale", false); diff --git a/core/cfg/option.h b/core/cfg/option.h index 874a6dc04..267c90b00 100644 --- a/core/cfg/option.h +++ b/core/cfg/option.h @@ -476,6 +476,7 @@ extern Option NativeDepthInterpolation; extern Option EmulateFramebuffer; extern Option FixUpscaleBleedingEdge; extern Option CustomGpuDriver; +extern Option FramePacing; #ifdef VIDEO_ROUTING extern Option VideoRouting; extern Option VideoRoutingScale; diff --git a/core/deps/gamesdk b/core/deps/gamesdk new file mode 160000 index 000000000..4ac9952e6 --- /dev/null +++ b/core/deps/gamesdk @@ -0,0 +1 @@ +Subproject commit 4ac9952e6a5b3e0846003cb156b4a664178b7549 diff --git a/core/rend/vulkan/swappyvk.h b/core/rend/vulkan/swappyvk.h new file mode 100644 index 000000000..58d102892 --- /dev/null +++ b/core/rend/vulkan/swappyvk.h @@ -0,0 +1,47 @@ +/* + Copyright 2026 flyinghead + + This file is part of Flycast. + + Flycast 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, either version 2 of the License, or + (at your option) any later version. + + Flycast 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 for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +#pragma once +#include "vulkan.h" + +namespace swappyvk +{ +#ifdef SWAPPY + +void setQueueFamilyIndex(bool googleTimingSupported, vk::Device device, vk::Queue queue, u32 queueFamilyIndex); +void init(vk::PhysicalDevice phyDevice, vk::Device device, vk::SwapchainKHR swapchain); +void setSwapInterval(int i); +vk::Result present(vk::Queue queue, const vk::PresentInfoKHR& presentInfo); +void detachSwapchain(); +void detachDevice(); + +#else + +static inline void setQueueFamilyIndex(bool googleTimingSupported, vk::Device device, vk::Queue queue, u32 queueFamilyIndex) {} +static inline void init(vk::PhysicalDevice phyDevice, vk::Device device, vk::SwapchainKHR swapchain) {} +static inline void setSwapInterval(int i) {} + +static inline vk::Result present(vk::Queue queue, const vk::PresentInfoKHR& presentInfo) { + return queue.presentKHR(presentInfo); +} + +static inline void detachSwapchain() {} +static inline void detachDevice() {} + +#endif +} diff --git a/core/rend/vulkan/vulkan_context.cpp b/core/rend/vulkan/vulkan_context.cpp index f65de9c45..23d65f9cf 100644 --- a/core/rend/vulkan/vulkan_context.cpp +++ b/core/rend/vulkan/vulkan_context.cpp @@ -34,6 +34,7 @@ #include "oslib/directory.h" #include "vulkan_driver.h" #include "rend/transform_matrix.h" +#include "swappyvk.h" #if defined(__ANDROID__) && HOST_CPU == CPU_ARM64 #include "adreno.h" #endif @@ -497,7 +498,13 @@ bool VulkanContext::InitDevice() // Enable VK_EXT_provoking_vertex if available provokingVertexSupported = tryAddDeviceExtension(VK_EXT_PROVOKING_VERTEX_EXTENSION_NAME); } - + bool googleTimingSupported = false; +#if defined(SWAPPY) + googleTimingSupported = tryAddDeviceExtension(vk::GOOGLEDisplayTimingExtensionName); + if (googleTimingSupported) + NOTICE_LOG(RENDERER, "VK_GOOGLE_display_timing supported"); +#endif + // Get device features vk::PhysicalDeviceFeatures2 featuresChain{}; @@ -553,6 +560,7 @@ bool VulkanContext::InitDevice() // Queues graphicsQueue = device->getQueue(graphicsQueueIndex, 0); presentQueue = device->getQueue(presentQueueIndex, 0); + swappyvk::setQueueFamilyIndex(googleTimingSupported, device.get(), presentQueue, presentQueueIndex); // Descriptor pool std::array pool_sizes = @@ -751,14 +759,22 @@ void VulkanContext::CreateSwapChain() INFO_LOG(RENDERER, "Using mailbox present mode"); swapchainPresentMode = vk::PresentModeKHR::eMailbox; } +#ifndef SWAPPY if (swapOnVSync && config::DupeFrames && settings.display.refreshRate > 60.f) swapInterval = settings.display.refreshRate / 60.f; else +#endif swapInterval = 1; vk::SurfaceTransformFlagBitsKHR preTransform = (surfaceCapabilities.supportedTransforms & vk::SurfaceTransformFlagBitsKHR::eIdentity) ? vk::SurfaceTransformFlagBitsKHR::eIdentity : surfaceCapabilities.currentTransform; - - u32 imageCount = std::max(3u * swapInterval, surfaceCapabilities.minImageCount); + INFO_LOG(RENDERER, "Surface capabilities: minImageCount %d maxImageCount %d swapInterval %d", surfaceCapabilities.minImageCount, surfaceCapabilities.maxImageCount, swapInterval); +#ifdef SWAPPY + // No need to dupe frames + u32 imageCount = 3; +#else + u32 imageCount = 4; +#endif + imageCount = std::max(imageCount * swapInterval, surfaceCapabilities.minImageCount); if (surfaceCapabilities.maxImageCount != 0) imageCount = std::min(imageCount, surfaceCapabilities.maxImageCount); vk::ImageUsageFlags usage = vk::ImageUsageFlagBits::eColorAttachment; @@ -779,6 +795,7 @@ void VulkanContext::CreateSwapChain() swapChainCreateInfo.pQueueFamilyIndices = queueFamilyIndices; } + swappyvk::detachSwapchain(); swapChain.reset(); try { swapChain = device->createSwapchainKHRUnique(swapChainCreateInfo); @@ -792,6 +809,8 @@ void VulkanContext::CreateSwapChain() } while (!swapChain); + swappyvk::init(physicalDevice, *device, *swapChain); + std::vector swapChainImages = device->getSwapchainImagesKHR(*swapChain); imageViews.resize(swapChainImages.size()); @@ -1012,7 +1031,7 @@ void VulkanContext::Present() noexcept { try { DoSwapAutomation(); - vk::Result res = presentQueue.presentKHR(vk::PresentInfoKHR(1, &(*renderCompleteSemaphores[currentSemaphore]), 1, &(*swapChain), ¤tImage)); + vk::Result res = swappyvk::present(presentQueue, vk::PresentInfoKHR(1, &(*renderCompleteSemaphores[currentSemaphore]), 1, &(*swapChain), ¤tImage)); #if defined(VK_USE_PLATFORM_METAL_EXT) || defined(__APPLE__) // presentKHR lists eSuboptimalKHR as a success code so it is // returned rather than thrown. Treat it as a request to @@ -1026,9 +1045,12 @@ void VulkanContext::Present() noexcept resized = true; #endif currentSemaphore = (currentSemaphore + 1) % renderCompleteSemaphores.size(); - - if (lastFrameView && IsValid() && !gui_is_open()) - for (int i = 1; i < swapInterval; i++) +#ifndef SWAPPY + if (lastFrameView && IsValid() && !gui_is_open() + && swapOnVSync && config::DupeFrames) + { + const int interval = swapInterval * gameSwapInterval; + for (int i = 1; i < interval; i++) { PresentFrame(vk::Image(), lastFrameView, lastFrameExtent, lastFrameAR); res = presentQueue.presentKHR(vk::PresentInfoKHR(1, &(*renderCompleteSemaphores[currentSemaphore]), 1, &(*swapChain), ¤tImage)); @@ -1042,6 +1064,8 @@ void VulkanContext::Present() noexcept break; } } + } +#endif } catch (const vk::SystemError& e) { // Happens when resizing the window INFO_LOG(RENDERER, "vk::SystemError %s", e.what()); @@ -1234,6 +1258,7 @@ void VulkanContext::term() instance->destroySurfaceKHR(surface.release()); #endif pipelineCache.reset(); + swappyvk::detachDevice(); device.reset(); #ifdef VK_DEBUG #ifndef __ANDROID__ @@ -1574,3 +1599,13 @@ bool VulkanContext::GetLastFrame(std::vector& data, int& width, int& height) return true; } + +void VulkanContext::setSwapInterval(int interval) +{ + interval = std::min(interval, 2); + if (gameSwapInterval != interval) { + NOTICE_LOG(RENDERER, "Swap interval changed to %d", swapOnVSync ? swapInterval * interval : 0); + swappyvk::setSwapInterval(interval); + } + gameSwapInterval = interval; +} diff --git a/core/rend/vulkan/vulkan_context.h b/core/rend/vulkan/vulkan_context.h index b6fb59561..c938fcf28 100644 --- a/core/rend/vulkan/vulkan_context.h +++ b/core/rend/vulkan/vulkan_context.h @@ -149,6 +149,7 @@ public: bool isAMD() override { return vendorID == VENDOR_ATI || vendorID == VENDOR_AMD; } + void setSwapInterval(int interval) override; vk::Format GetDepthFormat() const { return depthFormat; } static VulkanContext *Instance() { return static_cast(GraphicsContext::Instance()); } bool SupportsSamplerAnisotropy() const { return samplerAnisotropy; } @@ -278,7 +279,7 @@ private: std::string driverName; std::string driverVersion; - + int gameSwapInterval = 1; #ifdef VK_DEBUG #ifndef __ANDROID__ vk::UniqueDebugUtilsMessengerEXT debugUtilsMessenger; diff --git a/core/rend/vulkan/vulkan_renderer.cpp b/core/rend/vulkan/vulkan_renderer.cpp index e43ee81e6..2af8fdd87 100644 --- a/core/rend/vulkan/vulkan_renderer.cpp +++ b/core/rend/vulkan/vulkan_renderer.cpp @@ -104,6 +104,9 @@ void BaseVulkanRenderer::Process(TA_context* ctx) texCommandBuffer = texCommandPool.Allocate(); texCommandBuffer.begin(vk::CommandBufferBeginInfo(vk::CommandBufferUsageFlagBits::eOneTimeSubmit)); + if (!ctx->rend.isRTT && ctx->rend.swapInterval > 0) + GetContext()->setSwapInterval(ctx->rend.swapInterval); + ta_parse(ctx, true); // TODO can't update fog or palette twice in multi render diff --git a/core/ui/settings_video.cpp b/core/ui/settings_video.cpp index 976999de6..2b6a79e5b 100644 --- a/core/ui/settings_video.cpp +++ b/core/ui/settings_video.cpp @@ -205,16 +205,18 @@ void gui_settings_video() OptionCheckbox(T("Linear Interpolation"), config::LinearInterpolation, T("Scales the output with linear interpolation. Will use nearest neighbor interpolation otherwise. Disable with integer scaling.")); #ifndef TARGET_IPHONE OptionCheckbox(T("VSync"), config::VSync, T("Synchronizes the frame rate with the screen refresh rate. Recommended")); - if (isVulkan(config::RendererType)) - { - ImGui::Indent(); - { - DisabledScope scope(!config::VSync); - - OptionCheckbox(T("Duplicate frames"), config::DupeFrames, T("Duplicate frames on high refresh rate monitors (120 Hz and higher)")); - } - ImGui::Unindent(); + ImGui::Indent(); + { + DisabledScope scope(!config::VSync); +#ifdef __ANDROID__ + OptionCheckbox(T("Frame Pacing (Experimental)"), config::FramePacing, + T("Provide the GPU with presentation timing for each frame to replicate original frame pacing.")); +#else + if (isVulkan(config::RendererType)) + OptionCheckbox(T("Duplicate frames"), config::DupeFrames, T("Duplicate frames on high refresh rate monitors (120 Hz and higher)")); +#endif } + ImGui::Unindent(); #endif OptionCheckbox(T("Show VMU In-game"), config::FloatVMUs, T("Show the VMU LCD screens while in-game")); OptionCheckbox(T("Full Framebuffer Emulation"), config::EmulateFramebuffer, diff --git a/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java b/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java new file mode 100644 index 000000000..64c8db682 --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java @@ -0,0 +1,56 @@ +package com.google.androidgamesdk; + +import android.os.Handler; +import android.os.Looper; +import android.view.Choreographer; +import android.util.Log; + + +public class ChoreographerCallback implements Choreographer.FrameCallback { + private static final String LOG_TAG = "ChoreographerCallback"; + private long mCookie; + private LooperThread mLooper; + + private class LooperThread extends Thread { + public Handler mHandler; + + public void run() { + Log.i(LOG_TAG, "Starting looper thread"); + Looper.prepare(); + mHandler = new Handler(); + Looper.loop(); + Log.i(LOG_TAG, "Terminating looper thread"); + } + } + + public ChoreographerCallback(long cookie) { + mCookie = cookie; + mLooper = new LooperThread(); + mLooper.start(); + } + + public void postFrameCallback() { + mLooper.mHandler.post(new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback(ChoreographerCallback.this); + } + }); + } + + public void postFrameCallbackDelayed(long delayMillis) { + Choreographer.getInstance().postFrameCallbackDelayed(this, delayMillis); + } + + public void terminate() { + mLooper.mHandler.getLooper().quit(); + } + + @Override + public void doFrame(long frameTimeNanos) { + nOnChoreographer(mCookie, frameTimeNanos); + } + + public native void nOnChoreographer(long cookie, long frameTimeNanos); + +} diff --git a/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/GameSdkDeviceInfoJni.java b/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/GameSdkDeviceInfoJni.java new file mode 100644 index 000000000..5ba1b5121 --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/GameSdkDeviceInfoJni.java @@ -0,0 +1,55 @@ +package com.google.androidgamesdk; + +/** JNI api for getting device information */ +public class GameSdkDeviceInfoJni { + private static Throwable initializationExceptionOrError; + + static { + try { + System.loadLibrary("game_sdk_device_info_jni"); + } catch(Exception exception) { + // Catch SecurityException, NullPointerException (or any potential unchecked exception) + // as we don't want to crash the app if the library failed to load. + initializationExceptionOrError = exception; + } catch(Error error) { + // Catch UnsatisfiedLinkError (or any potential unchecked error) + // as we don't want to crash the app if the library failed to load. + initializationExceptionOrError = error; + } + } + + /** + * Returns a byte array, which is a serialized proto containing device information, or + * null if the native library "game_sdk_device_info_jni" could not be loaded. + * + * @return Optional with the serialized byte array, representing game sdk device info with errors, + * or null. + */ + public static byte[] tryGetProtoSerialized() { + if (initializationExceptionOrError != null) { + return null; + } + + return getProtoSerialized(); + } + + + /** + * Returns the exception or error that was caught when trying to load the library, if any. + * Otherwise, returns null. + * + * @return The caught Throwable or null. + */ + public static Throwable getInitializationExceptionOrError() { + return initializationExceptionOrError; + } + + /** + * Returns a byte array, which is a serialized proto. + * + * @return serialized byte array, representing game sdk device info with errors. + */ + private static native byte[] getProtoSerialized(); + + private GameSdkDeviceInfoJni() {} +} diff --git a/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java b/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java new file mode 100644 index 000000000..fbf1685b3 --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java @@ -0,0 +1,218 @@ +package com.google.androidgamesdk; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ComponentName; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import static android.app.NativeActivity.META_DATA_LIB_NAME; + +public class SwappyDisplayManager implements DisplayManager.DisplayListener { + final private String LOG_TAG = "SwappyDisplayManager"; + final private boolean DEBUG = false; + final private long ONE_MS_IN_NS = 1000000; + final private long ONE_S_IN_NS = ONE_MS_IN_NS * 1000; + + private long mCookie; + private Activity mActivity; + private DisplayManager mDisplayManager; + private WindowManager mWindowManager; + private Display.Mode mCurrentMode; + + private LooperThread mLooper; + + private class LooperThread extends Thread { + public Handler mHandler; + private Lock mLock = new ReentrantLock(); + private Condition mCondition = mLock.newCondition(); + + @Override + public void start() { + mLock.lock(); + super.start(); + try { + mCondition.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + mLock.unlock(); + + } + + public void run() { + Log.i(LOG_TAG, "Starting looper thread"); + + mLock.lock(); + Looper.prepare(); + mHandler = new Handler(); + mCondition.signal(); + mLock.unlock(); + + Looper.loop(); + + Log.i(LOG_TAG, "Terminating looper thread"); + } + } + + @TargetApi(Build.VERSION_CODES.M) + private boolean modeMatchesCurrentResolution(Display.Mode mode) { + return mode.getPhysicalHeight() == mCurrentMode.getPhysicalHeight() && + mode.getPhysicalWidth() == mCurrentMode.getPhysicalWidth(); + + } + + // Called from native SwappyDisplayManager.cpp + public SwappyDisplayManager(long cookie, Activity activity) { + // Load the native library for cases where an NDK application is running + // without a java componenet + try { + ActivityInfo ai = activity.getPackageManager().getActivityInfo( + activity.getIntent().getComponent(), PackageManager.GET_META_DATA); + if (ai.metaData != null) { + String nativeLibName = ai.metaData.getString(META_DATA_LIB_NAME); + if (nativeLibName != null) { + System.loadLibrary(nativeLibName); + } + } + } catch (java.lang.Throwable e) { + Log.e(LOG_TAG, e.getMessage()); + } + + mCookie = cookie; + mActivity = activity; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mDisplayManager = mActivity.getSystemService(DisplayManager.class); + mWindowManager = mActivity.getSystemService(WindowManager.class); + Display display = mWindowManager.getDefaultDisplay(); + mCurrentMode = display.getMode(); + updateSupportedRefreshRates(display); + } + + // Register display listener callbacks + synchronized(this) { + mLooper = new LooperThread(); + mLooper.start(); + mDisplayManager.registerDisplayListener(this, mLooper.mHandler); + } + } + + @TargetApi(Build.VERSION_CODES.M) + private void updateSupportedRefreshRates(Display display) { + Display.Mode[] supportedModes = display.getSupportedModes(); + int totalModes = 0; + for (int i = 0; i < supportedModes.length; i++) { + if (!modeMatchesCurrentResolution(supportedModes[i])) { + continue; + } + totalModes++; + } + + long[] supportedRefreshPeriods = new long[totalModes]; + int[] supportedDisplayModeIds = new int[totalModes]; + totalModes = 0; + for (int i = 0; i < supportedModes.length; i++) { + if (!modeMatchesCurrentResolution(supportedModes[i])) { + continue; + } + supportedRefreshPeriods[totalModes] = + (long) (ONE_S_IN_NS / supportedModes[i].getRefreshRate()); + supportedDisplayModeIds[totalModes] = supportedModes[i].getModeId(); + totalModes++; + + } + // Call down to native to set the supported refresh rates + nSetSupportedRefreshPeriods(mCookie, supportedRefreshPeriods, supportedDisplayModeIds); + } + + // Called from native SwappyDisplayManager.cpp + public void setPreferredDisplayModeId(final int modeId) { + mActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + Window w = mActivity.getWindow(); + WindowManager.LayoutParams l = w.getAttributes(); + if (DEBUG) { + Log.v(LOG_TAG, "set preferredDisplayModeId to " + modeId); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + l.preferredDisplayModeId = modeId; + } + + + w.setAttributes(l); + } + }); + } + + // Called from native SwappyDisplayManager.cpp + public void terminate() { + mDisplayManager.unregisterDisplayListener(this); + mLooper.mHandler.getLooper().quit(); + } + + @Override + public void onDisplayAdded(int displayId) { + + } + + @Override + public void onDisplayRemoved(int displayId) { + + } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onDisplayChanged(int displayId) { + synchronized(this) { + Display display = mWindowManager.getDefaultDisplay(); + float newRefreshRate = display.getRefreshRate(); + Display.Mode newMode = display.getMode(); + boolean resolutionChanged = + (newMode.getPhysicalWidth() != mCurrentMode.getPhysicalWidth()) | + (newMode.getPhysicalHeight() != mCurrentMode.getPhysicalHeight()); + boolean refreshRateChanged = (newRefreshRate != mCurrentMode.getRefreshRate()); + mCurrentMode = newMode; + + if (resolutionChanged) { + updateSupportedRefreshRates(display); + } + + if (refreshRateChanged) { + final long appVsyncOffsetNanos = display.getAppVsyncOffsetNanos(); + final long vsyncPresentationDeadlineNanos = + mWindowManager.getDefaultDisplay().getPresentationDeadlineNanos(); + + final long vsyncPeriodNanos = (long)(ONE_S_IN_NS / newRefreshRate); + final long sfVsyncOffsetNanos = + vsyncPeriodNanos - (vsyncPresentationDeadlineNanos - ONE_MS_IN_NS); + + nOnRefreshPeriodChanged(mCookie, + vsyncPeriodNanos, + appVsyncOffsetNanos, + sfVsyncOffsetNanos); + } + } + } + + private native void nSetSupportedRefreshPeriods(long cookie, + long[] refreshPeriods, + int[] modeIds); + private native void nOnRefreshPeriodChanged(long cookie, + long refreshPeriod, + long appOffset, + long sfOffset); +} diff --git a/shell/android-studio/flycast/src/main/jni/src/CMakeLists.txt b/shell/android-studio/flycast/src/main/jni/src/CMakeLists.txt index d1cd93978..7cdf0a253 100644 --- a/shell/android-studio/flycast/src/main/jni/src/CMakeLists.txt +++ b/shell/android-studio/flycast/src/main/jni/src/CMakeLists.txt @@ -8,4 +8,6 @@ target_sources(${PROJECT_NAME} PRIVATE Android.cpp http_client.h i18n.cpp - jni_util.h) + jni_util.h + swappygl.cpp + swappyvk.cpp) diff --git a/shell/android-studio/flycast/src/main/jni/src/swappygl.cpp b/shell/android-studio/flycast/src/main/jni/src/swappygl.cpp new file mode 100644 index 000000000..61fa1c198 --- /dev/null +++ b/shell/android-studio/flycast/src/main/jni/src/swappygl.cpp @@ -0,0 +1,123 @@ +/* + Copyright 2026 flyinghead + + This file is part of Flycast. + + Flycast 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, either version 2 of the License, or + (at your option) any later version. + + Flycast 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 for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +#include "types.h" +#include "wsi/egl.h" +#include "cfg/option.h" +#include "jni_util.h" +#ifdef SWAPPY + +#define __eglext_h_ 1 // avoid multiply defined errors +#include +#include + +class SwappyGLGC : public EGLGraphicsContext +{ +public: + SwappyGLGC(void *window, void *display); + ~SwappyGLGC(); + void swap() override; + +private: + void changeSwapInterval(); + void activate(bool enable); + + bool swappyAvailable = false; + bool enabled = false; +}; + +extern jobject g_activity; + +void EGLGraphicsContext::Create(void *window, void *display) { + new SwappyGLGC(window, display); +} + +SwappyGLGC::SwappyGLGC(void *window, void *display) + : EGLGraphicsContext(window, display) +{ + activate(config::FramePacing); +} + +SwappyGLGC::~SwappyGLGC() { + activate(false); +} + +void SwappyGLGC::activate(bool enable) +{ + if (this->enabled == enable) + return; + this->enabled = enable; + if (enable) + { + SwappyGL_init(jni::env(), g_activity); + swappyAvailable = SwappyGL_isEnabled(); + if (swappyAvailable) + { + SwappyGL_setWindow((ANativeWindow *)window); + SwappyGL_setSwapIntervalNS(SWAPPY_SWAP_60FPS); + SwappyGL_setAutoSwapInterval(false); + SwappyGL_setAutoPipelineMode(false); + } + } + else { + SwappyGL_destroy(); + swappyAvailable = false; + } +} + +void SwappyGLGC::swap() +{ + activate(config::FramePacing); + do_swap_automation(); + changeSwapInterval(); + if (swapOnVSync && swappyAvailable) + SwappyGL_swap(display, surface); + else + eglSwapBuffers(display, surface); +} + +void SwappyGLGC::changeSwapInterval() +{ + if (!swappyAvailable) { + EGLGraphicsContext::changeSwapInterval(); + return; + } + if (!gameSwapIntervalChanged + && swapOnVSync == (!settings.input.fastForwardMode && config::VSync)) + return; + gameSwapIntervalChanged = false; + const bool oldSwapOnVSync = swapOnVSync; + swapOnVSync = (!settings.input.fastForwardMode && config::VSync); + if (swapOnVSync) + { + if (!oldSwapOnVSync) + // put swappy back in control + eglSwapInterval(display, 1); + currentSwapInterval = gameSwapInterval; + if (swappyAvailable) { + SwappyGL_setSwapIntervalNS(SWAPPY_SWAP_60FPS * currentSwapInterval); + NOTICE_LOG(RENDERER, "Swap interval changed to %d (EGL max %d)", currentSwapInterval, maxSwapInterval); + } + } + else { + // No way to disable swappy so use EGL to disable sync wait + eglSwapInterval(display, 0); + } +} + +#endif // SWAPPY diff --git a/shell/android-studio/flycast/src/main/jni/src/swappyvk.cpp b/shell/android-studio/flycast/src/main/jni/src/swappyvk.cpp new file mode 100644 index 000000000..97a0f750b --- /dev/null +++ b/shell/android-studio/flycast/src/main/jni/src/swappyvk.cpp @@ -0,0 +1,134 @@ +/* + Copyright 2026 flyinghead + + This file is part of Flycast. + + Flycast 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, either version 2 of the License, or + (at your option) any later version. + + Flycast 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 for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +#include "rend/vulkan/swappyvk.h" +#include "jni_util.h" +#include "wsi/context.h" +#include "cfg/option.h" +#include + +#ifdef SWAPPY + +extern jobject g_activity; + +namespace swappyvk +{ +static bool googleTimingSupported; +static vk::PhysicalDevice phyDevice; +static vk::Device device; +static vk::SwapchainKHR swapchain; +static bool swappyAvailable; +static bool enabled; + +void setQueueFamilyIndex(bool googleTimingSupported, vk::Device device, vk::Queue queue, u32 queueFamilyIndex) +{ + swappyvk::googleTimingSupported = googleTimingSupported; + if (googleTimingSupported) + SwappyVk_setQueueFamilyIndex((VkDevice)device, (VkQueue)queue, queueFamilyIndex); +} + +static void activate(bool enable) +{ + if (enabled == enable) + return; + enabled = enable; + swappyAvailable = false; + if (enable) + { + if (settings.input.fastForwardMode || !config::VSync || !googleTimingSupported) + return; + + // Fake call to make swappy use google timing for this device + VkExtensionProperties prop { { VK_GOOGLE_DISPLAY_TIMING_EXTENSION_NAME } }; + u32 requiredExtensionCount = 1; + char s[VK_MAX_EXTENSION_NAME_SIZE]; + char *requiredExtension = s; + SwappyVk_determineDeviceExtensions((VkPhysicalDevice)phyDevice, 1, &prop, &requiredExtensionCount, &requiredExtension); + + u64 refreshDuration; + SwappyVk_initAndGetRefreshCycleDuration(jni::env(), g_activity, (VkPhysicalDevice)phyDevice, (VkDevice)device, + (VkSwapchainKHR)swapchain, &refreshDuration); + SwappyVk_isEnabled((VkSwapchainKHR)swapchain, &swappyAvailable); + NOTICE_LOG(RENDERER, "SwappyVk status: %s", swappyAvailable ? "enabled" : "disabled"); + if (!swappyAvailable) { + SwappyVk_destroySwapchain((VkDevice)device, (VkSwapchainKHR)swapchain); + return; + } + GraphicsContext *gc = GraphicsContext::Instance(); + if (gc != nullptr) + { + void *window, *display; + gc->getWindow(&window, &display); + SwappyVk_setWindow((VkDevice)device, (VkSwapchainKHR)swapchain, (ANativeWindow *)window); + } + else { + ERROR_LOG(RENDERER, "SwappyVk: Graphics context is null"); + } + SwappyVk_setAutoSwapInterval(false); + SwappyVk_setAutoPipelineMode(false); + SwappyVk_setSwapIntervalNS((VkDevice)device, (VkSwapchainKHR)swapchain, SWAPPY_SWAP_60FPS); + } + else { + if (swapchain) + SwappyVk_destroySwapchain((VkDevice)device, (VkSwapchainKHR)swapchain); + } +} + +void init(vk::PhysicalDevice phyDevice, vk::Device device, vk::SwapchainKHR swapchain) +{ + swappyvk::phyDevice = phyDevice; + swappyvk::device = device; + swappyvk::swapchain = swapchain; + activate(config::FramePacing); +} + +void setSwapInterval(int i) { + if (swappyAvailable) + SwappyVk_setSwapIntervalNS((VkDevice)device, (VkSwapchainKHR)swapchain, SWAPPY_SWAP_60FPS * i); +} + +vk::Result present(vk::Queue queue, const vk::PresentInfoKHR& presentInfo) +{ + activate(config::FramePacing); + + if (!swappyAvailable) + return queue.presentKHR(presentInfo); + + bool swapOnVSync = !settings.input.fastForwardMode && config::VSync; + SwappyVk_enableFramePacing((VkSwapchainKHR)swapchain, swapOnVSync); + SwappyVk_enableBlockingWait((VkSwapchainKHR)swapchain, swapOnVSync); + return static_cast(SwappyVk_queuePresent((VkQueue)queue, (const VkPresentInfoKHR *)presentInfo)); +} + +void detachSwapchain() { + activate(false); +} + +void detachDevice() +{ + if (device) + { + SwappyVk_destroyDevice((VkDevice)device); + swapchain = nullptr; + device = nullptr; + swappyAvailable = false; + } +} + +} // namespace swappyvk +#endif // SWAPPY