android: use gamesdk frame-pacing library

Use games-frame-pacing library (aka Swappy) for OpenGL and Vulkan.
Implements proper frame pacing for 30 FPS games and/or 120+ Hz displays.
Don't duplicate Vulkan frames.
This commit is contained in:
Flyinghead committed 2026-07-04 17:34:11 +02:00
1 parent 08c63bd39b
commit b34c4cde8d
16 files changed
+709 -18

No files matched your search

+3
View File
@@ -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
+9
View File
@@ -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 $<$<COMPILE_LANGUAGE:CXX>:-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})
+1
View File
@@ -120,6 +120,7 @@ Option<bool> NativeDepthInterpolation("rend.NativeDepthInterpolation", false);
Option<bool> EmulateFramebuffer("rend.EmulateFramebuffer", false);
Option<bool> FixUpscaleBleedingEdge("rend.FixUpscaleBleedingEdge", true);
Option<bool> CustomGpuDriver("rend.CustomGpuDriver", false);
Option<bool> FramePacing("rend.FramePacing", true);
#ifdef VIDEO_ROUTING
Option<bool, false> VideoRouting("rend.VideoRouting", false);
Option<bool, false> VideoRoutingScale("rend.VideoRoutingScale", false);
+1
View File
@@ -476,6 +476,7 @@ extern Option<bool> NativeDepthInterpolation;
extern Option<bool> EmulateFramebuffer;
extern Option<bool> FixUpscaleBleedingEdge;
extern Option<bool> CustomGpuDriver;
extern Option<bool> FramePacing;
#ifdef VIDEO_ROUTING
extern Option<bool, false> VideoRouting;
extern Option<bool, false> VideoRoutingScale;
+1
Submodule core/deps/gamesdk added at 4ac9952e6a.
+47
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
#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
}
+42 -7
View File
@@ -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<vk::DescriptorPoolSize, 11> 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<vk::Image> 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), &currentImage));
vk::Result res = swappyvk::present(presentQueue, vk::PresentInfoKHR(1, &(*renderCompleteSemaphores[currentSemaphore]), 1, &(*swapChain), &currentImage));
#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), &currentImage));
@@ -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<u8>& 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;
}
+2 -1
View File
@@ -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<VulkanContext *>(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;
+3
View File
@@ -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
+11 -9
View File
@@ -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,
@@ -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);
}
@@ -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() {}
}
@@ -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);
}
@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
#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 <swappy/swappyGL.h>
#include <swappy/swappyGL_extra.h>
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
@@ -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 <https://www.gnu.org/licenses/>.
*/
#include "rend/vulkan/swappyvk.h"
#include "jni_util.h"
#include "wsi/context.h"
#include "cfg/option.h"
#include <swappy/swappyVk.h>
#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<vk::Result>(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