diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
index be3b2f4a48..c8985cd45d 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
@@ -29,6 +29,7 @@ enum class IntSetting(override val key: String) : AbstractIntSetting {
RENDERER_DYNA_STATE("dyna_state"),
DMA_ACCURACY("dma_accuracy"),
+ FRAME_PACING_MODE("frame_pacing_mode"),
AUDIO_OUTPUT_ENGINE("output_engine"),
MAX_ANISOTROPY("max_anisotropy"),
THEME("theme"),
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
index acbfbac337..762f0da262 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
@@ -643,6 +643,15 @@ abstract class SettingsItem(
valuesId = R.array.dmaAccuracyValues
)
)
+ put(
+ SingleChoiceSetting(
+ IntSetting.FRAME_PACING_MODE,
+ titleId = R.string.frame_pacing_mode,
+ descriptionId = R.string.frame_pacing_mode_description,
+ choicesId = R.array.framePacingModeNames,
+ valuesId = R.array.framePacingModeValues
+ )
+ )
put(
SwitchSetting(
BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS,
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
index 5ea94d3ac5..06d4d74176 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -265,6 +265,7 @@ class SettingsFragmentPresenter(
add(IntSetting.RENDERER_ACCURACY.key)
add(IntSetting.DMA_ACCURACY.key)
+ add(IntSetting.FRAME_PACING_MODE.key)
add(IntSetting.MAX_ANISOTROPY.key)
add(IntSetting.RENDERER_VRAM_USAGE_MODE.key)
add(IntSetting.RENDERER_ASTC_DECODE_METHOD.key)
diff --git a/src/android/app/src/main/res/values/arrays.xml b/src/android/app/src/main/res/values/arrays.xml
index 69f1590844..e85bc3592a 100644
--- a/src/android/app/src/main/res/values/arrays.xml
+++ b/src/android/app/src/main/res/values/arrays.xml
@@ -530,6 +530,21 @@
- 2
+
+ - @string/frame_pacing_mode_target_Auto
+ - @string/frame_pacing_mode_target_30
+ - @string/frame_pacing_mode_target_60
+ - @string/frame_pacing_mode_target_120
+ - @string/frame_pacing_mode_target_240
+
+
+ - 0
+ - 1
+ - 2
+ - 3
+ - 4
+
+
- @string/applet_hle
- @string/applet_lle
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 531b14789c..3d6a27bb3c 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -473,6 +473,8 @@
Controls the GPU emulation mode. Most games render fine with Fast or Balanced modes, but Accurate is still required for some. Particles tend to only render correctly with Accurate mode.
DMA Accuracy
Controls the DMA precision accuracy. Safe precision can fix issues in some games, but it can also impact performance in some cases. If unsure, leave this on Default.
+ Frame Pacing Mode
+ Controls how the emulator manages frame pacing to reduce stuttering and make the frame rate smoother and more consistent.
Anisotropic filtering
Improves the quality of textures when viewed at oblique angles
VRAM Usage Mode
@@ -1028,6 +1030,13 @@
Unsafe
Safe
+
+ Auto
+ 30 FPS
+ 60 FPS
+ 120 FPS
+ 240 FPS
+
CPU
GPU
diff --git a/src/common/settings.h b/src/common/settings.h
index 770acdbb69..385ae7e1c9 100644
--- a/src/common/settings.h
+++ b/src/common/settings.h
@@ -434,6 +434,13 @@ struct Values {
"accelerate_astc",
Category::RendererAdvanced};
+ SwitchableSetting frame_pacing_mode{linkage,
+ FramePacingMode::Target_Auto,
+ FramePacingMode::Target_Auto,
+ FramePacingMode::Target_240,
+ "frame_pacing_mode",
+ Category::RendererAdvanced};
+
SwitchableSetting astc_recompression{linkage,
AstcRecompression::Uncompressed,
"astc_recompression",
diff --git a/src/common/settings_enums.h b/src/common/settings_enums.h
index 33c553dc3c..7e3bef9bea 100644
--- a/src/common/settings_enums.h
+++ b/src/common/settings_enums.h
@@ -129,6 +129,7 @@ ENUM(TimeZone, Auto, Default, Cet, Cst6Cdt, Cuba, Eet, Egypt, Eire, Est, Est5Edt
ENUM(AnisotropyMode, Automatic, Default, X2, X4, X8, X16, X32, X64, None);
ENUM(AstcDecodeMode, Cpu, Gpu, CpuAsynchronous);
ENUM(AstcRecompression, Uncompressed, Bc1, Bc3);
+ENUM(FramePacingMode, Target_Auto, Target_30, Target_60, Target_120, Target_240);
ENUM(VSyncMode, Immediate, Mailbox, Fifo, FifoRelaxed);
ENUM(VramUsageMode, Conservative, Aggressive);
ENUM(RendererBackend, OpenGL_GLSL, Vulkan, Null, OpenGL_GLASM, OpenGL_SPIRV);
diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp
index 404685b36c..225cdcd10e 100644
--- a/src/qt_common/config/shared_translation.cpp
+++ b/src/qt_common/config/shared_translation.cpp
@@ -225,6 +225,8 @@ std::unique_ptr InitializeTranslations(QObject* parent)
"intermediate format: RGBA8.\n"
"BC1/BC3: The intermediate format will be recompressed to BC1 or BC3 format,\n"
" saving VRAM but degrading image quality."));
+ INSERT(Settings, frame_pacing_mode, tr("Frame Pacing Mode (Vulkan only)"),
+ tr("Controls how the emulator manages frame pacing to reduce stuttering and make the frame rate smoother and more consistent."));
INSERT(Settings,
vram_usage_mode,
tr("VRAM Usage Mode:"),
@@ -502,6 +504,14 @@ std::unique_ptr ComboboxEnumeration(QObject* parent)
PAIR(AstcRecompression, Bc1, tr("BC1 (Low quality)")),
PAIR(AstcRecompression, Bc3, tr("BC3 (Medium quality)")),
}});
+ translations->insert({Settings::EnumMetadata::Index(),
+ {
+ PAIR(FramePacingMode, Target_Auto, tr("Auto")),
+ PAIR(FramePacingMode, Target_30, tr("30 FPS")),
+ PAIR(FramePacingMode, Target_60, tr("60 FPS")),
+ PAIR(FramePacingMode, Target_120, tr("120 FPS")),
+ PAIR(FramePacingMode, Target_240, tr("240 FPS")),
+ }});
translations->insert({Settings::EnumMetadata::Index(),
{
PAIR(VramUsageMode, Conservative, tr("Conservative")),
diff --git a/src/video_core/renderer_vulkan/vk_scheduler.cpp b/src/video_core/renderer_vulkan/vk_scheduler.cpp
index aafcfdf65b..2a69d6d244 100644
--- a/src/video_core/renderer_vulkan/vk_scheduler.cpp
+++ b/src/video_core/renderer_vulkan/vk_scheduler.cpp
@@ -347,7 +347,7 @@ void Scheduler::EndRenderPass()
Record([num_images = num_renderpass_images,
images = renderpass_images,
ranges = renderpass_image_ranges](vk::CommandBuffer cmdbuf) {
- std::array barriers;
+ std::vector barriers(num_images);
VkPipelineStageFlags src_stages = 0;
for (size_t i = 0; i < num_images; ++i) {
const VkImageSubresourceRange& range = ranges[i];
diff --git a/src/video_core/renderer_vulkan/vk_scheduler.h b/src/video_core/renderer_vulkan/vk_scheduler.h
index 5216a436c8..fb0ac9b008 100644
--- a/src/video_core/renderer_vulkan/vk_scheduler.h
+++ b/src/video_core/renderer_vulkan/vk_scheduler.h
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
@@ -16,6 +16,7 @@
#include "common/alignment.h"
#include "common/common_types.h"
+#include "common/settings.h"
#include "common/polyfill_thread.h"
#include "video_core/renderer_vulkan/vk_master_semaphore.h"
#include "video_core/vulkan_common/vulkan_wrapper.h"
@@ -111,15 +112,34 @@ public:
return master_semaphore->IsFree(tick);
}
- /// Waits for the given tick to trigger on the GPU.
- void Wait(u64 tick) {
- if (tick >= master_semaphore->CurrentTick()) {
- // Make sure we are not waiting for the current tick without signalling
+ /// Waits for the given GPU tick, optionally pacing frames.
+ void Wait(u64 tick, double target_fps = 0.0) {
+ if (Settings::values.use_speed_limit.GetValue() && target_fps > 0.0) {
+ auto frame_duration = std::chrono::duration_cast(std::chrono::duration(1.0 / target_fps));
+ auto now = std::chrono::steady_clock::now();
+ if (now < next_frame_time) {
+ std::this_thread::sleep_until(next_frame_time);
+ next_frame_time += frame_duration;
+ } else {
+ next_frame_time = now + frame_duration;
+ }
+ }
+ if (tick > master_semaphore->CurrentTick() && !chunk->Empty()) {
Flush();
}
master_semaphore->Wait(tick);
}
+ /// Resets the frame pacing state by setting the next frame time.
+ void ResetFramePacing(double target_fps = 0.0) {
+ if (target_fps > 0.0) {
+ auto frame_duration = std::chrono::duration_cast(std::chrono::duration(1.0 / target_fps));
+ next_frame_time = std::chrono::steady_clock::now() + frame_duration;
+ } else {
+ next_frame_time = std::chrono::steady_clock::time_point{};
+ }
+ }
+
/// Returns the master timeline semaphore.
[[nodiscard]] MasterSemaphore& GetMasterSemaphore() const noexcept {
return *master_semaphore;
@@ -261,6 +281,8 @@ private:
std::mutex queue_mutex;
std::condition_variable_any event_cv;
std::jthread worker_thread;
+
+ std::chrono::steady_clock::time_point next_frame_time{};
};
} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/vk_swapchain.cpp b/src/video_core/renderer_vulkan/vk_swapchain.cpp
index 7418ad934e..7e7e67639d 100644
--- a/src/video_core/renderer_vulkan/vk_swapchain.cpp
+++ b/src/video_core/renderer_vulkan/vk_swapchain.cpp
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project
@@ -146,6 +146,25 @@ void Swapchain::Create(
{
is_outdated = false;
is_suboptimal = false;
+
+ switch (Settings::values.frame_pacing_mode.GetValue()) {
+ case Settings::FramePacingMode::Target_Auto:
+ scheduler.ResetFramePacing();
+ break;
+ case Settings::FramePacingMode::Target_30:
+ scheduler.ResetFramePacing(30.0);
+ break;
+ case Settings::FramePacingMode::Target_60:
+ scheduler.ResetFramePacing(60.0);
+ break;
+ case Settings::FramePacingMode::Target_120:
+ scheduler.ResetFramePacing(120.0);
+ break;
+ case Settings::FramePacingMode::Target_240:
+ scheduler.ResetFramePacing(240.0);
+ break;
+ }
+
width = width_;
height = height_;
#ifdef ANDROID
@@ -194,7 +213,26 @@ bool Swapchain::AcquireNextImage() {
break;
}
- scheduler.Wait(resource_ticks[image_index]);
+ if (resource_ticks[image_index] != 0 && !scheduler.IsFree(resource_ticks[image_index])) {
+ switch (Settings::values.frame_pacing_mode.GetValue()) {
+ case Settings::FramePacingMode::Target_Auto:
+ scheduler.Wait(resource_ticks[image_index]);
+ break;
+ case Settings::FramePacingMode::Target_30:
+ scheduler.Wait(resource_ticks[image_index], 30.0);
+ break;
+ case Settings::FramePacingMode::Target_60:
+ scheduler.Wait(resource_ticks[image_index], 60.0);
+ break;
+ case Settings::FramePacingMode::Target_120:
+ scheduler.Wait(resource_ticks[image_index], 120.0);
+ break;
+ case Settings::FramePacingMode::Target_240:
+ scheduler.Wait(resource_ticks[image_index], 240.0);
+ break;
+ }
+ }
+
resource_ticks[image_index] = scheduler.CurrentTick();
return is_suboptimal || is_outdated;