diff --git a/osu.Android.props b/osu.Android.props index 03864d13adb5..6c7bb344cde4 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -88,6 +88,14 @@ + + + + + diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 7d7849b7e9f3..1f4deeb935e2 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -190,7 +190,19 @@ public void StartVulkanProbe() public string GetVulkanStatus() { if (vulkanProbe is not VulkanProbe probe) return string.Empty; - return cachedVulkanStatus ??= $"{(probe.SupportsMailboxPresentMode ? "MAILBOX" : "FIFO")}{(probe.DisablePresentId ? " [NoID]" : "")}{(probe.DisablePresentWait ? " [NoWait]" : "")}{(probe.DisableGraphicsPipelineLibrary ? " [NoGPL]" : "")}"; + + if (cachedVulkanStatus != null) + return cachedVulkanStatus; + + int ver = probe.ApiVersion; + int major = (ver >> 22) & 0x3FF; + int minor = (ver >> 12) & 0x3FF; + + cachedVulkanStatus = $"Vk{major}.{minor}" + + (probe.DisablePresentId ? " [NoID]" : "") + + (probe.DisablePresentWait ? " [NoWait]" : "") + + (probe.DisableGraphicsPipelineLibrary ? " [NoGPL]" : ""); + return cachedVulkanStatus; } public void StopVulkanProbe() @@ -211,10 +223,12 @@ private static void logVulkanInfo(VulkanProbe probe) Debug.WriteLine($"[osu!] Vulkan GPU: {probe.DeviceLocalMemoryMB}MB, " + $"API={major}.{minor}.{patch}, " - + $"mailbox={probe.SupportsMailboxPresentMode}, " + $"vk1.3={probe.MeetsVulkan13}, " + + $"vk1.4={probe.MeetsVulkan14}, " + $"gpl={probe.SupportsGraphicsPipelineLibrary}, " - + $"shaderObj={probe.SupportsShaderObject}"); + + $"shaderObj={probe.SupportsShaderObject}, " + + $"hostCopy={probe.SupportsHostImageCopy}, " + + $"pushDesc={probe.SupportsPushDescriptors}"); } [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/osu.Android/Native/CMakeLists.txt b/osu.Android/Native/CMakeLists.txt index 8c0658788d43..24f7bc4efd23 100644 --- a/osu.Android/Native/CMakeLists.txt +++ b/osu.Android/Native/CMakeLists.txt @@ -6,12 +6,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # Maximum release optimizations for lowest-latency audio callback path. # -O3: aggressive inlining & vectorisation -# -flto: link-time optimisation across translation units +# -flto=thin: link-time optimisation with thin backend (20-40% faster link than full LTO, +# equivalent binary quality for small TUs like this project) # -ffast-math: enable SIMD-friendly FP (safe — we only memset + store a double) # -ffunction/data-sections + --gc-sections: dead-code elimination # -fvisibility=hidden: only OSU_EXPORT symbols are visible -set(CMAKE_CXX_FLAGS_RELEASE "-O3 -flto -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG") -set(CMAKE_C_FLAGS_RELEASE "-O3 -flto -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG") +set(CMAKE_CXX_FLAGS_RELEASE "-O3 -flto=thin -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG") +set(CMAKE_C_FLAGS_RELEASE "-O3 -flto=thin -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG") # -Wl,-z,max-page-size=16384: align ELF LOAD segments to 16 KB for Android 15+ # devices with 16 KB page sizes. Without this, the .so will fail to load on # such devices. NDK r28+ supports this flag. @@ -23,7 +24,10 @@ set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "-Wl,--gc-sections -Wl,-z,max-page-size=16 set(OBOE_ENABLE_FLOWGRAPH OFF CACHE BOOL "Disable Oboe flowgraph to reduce binary size") # Download and build Oboe main branch from source to ensure we have the latest -# features (like ADPF performance hints) regardless of the build environment. +# features and fixes (ADPF performance hints, workload management, spatialization, +# API 36 compatibility) regardless of the build environment. +# main is preferred over pinned tags because Oboe releases infrequently (~yearly) +# and the main branch accumulates significant latency-critical improvements between tags. include(FetchContent) FetchContent_Declare(oboe GIT_REPOSITORY https://github.com/google/oboe.git @@ -41,9 +45,9 @@ endif() set(OBOE_LIB oboe) -find_library(vulkan-lib vulkan) -find_library(log-lib log) -find_library(android-lib android) +find_library(vulkan-lib vulkan REQUIRED) +find_library(log-lib log REQUIRED) +find_library(android-lib android REQUIRED) add_library(osu_native SHARED oboe_bridge.cpp diff --git a/osu.Android/Native/VulkanProbe.cs b/osu.Android/Native/VulkanProbe.cs index 0181706a46a0..aa32fcc6b5b5 100644 --- a/osu.Android/Native/VulkanProbe.cs +++ b/osu.Android/Native/VulkanProbe.cs @@ -68,6 +68,9 @@ static VulkanProbe() public bool SupportsGlobalPriority => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsGlobalPriority(nativePtr) != 0; public bool SupportsMemoryBudget => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsMemoryBudget(nativePtr) != 0; public bool SupportsSurfaceMaintenance1 => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsSurfaceMaintenance1(nativePtr) != 0; + public bool MeetsVulkan14 => !disposed && nativePtr != IntPtr.Zero && nVulkanMeetsVulkan14(nativePtr) != 0; + public bool SupportsHostImageCopy => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsHostImageCopy(nativePtr) != 0; + public bool SupportsPushDescriptors => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsPushDescriptors(nativePtr) != 0; public bool DisablePresentId => !disposed && nativePtr != IntPtr.Zero && nVulkanDisablePresentId(nativePtr) != 0; public bool DisablePresentWait => !disposed && nativePtr != IntPtr.Zero && nVulkanDisablePresentWait(nativePtr) != 0; @@ -108,5 +111,8 @@ public void Dispose() [DllImport(lib_name)] private static extern byte nVulkanDisablePresentId(IntPtr ptr); [DllImport(lib_name)] private static extern byte nVulkanDisablePresentWait(IntPtr ptr); [DllImport(lib_name)] private static extern byte nVulkanDisableGraphicsPipelineLibrary(IntPtr ptr); + [DllImport(lib_name)] private static extern byte nVulkanMeetsVulkan14(IntPtr ptr); + [DllImport(lib_name)] private static extern byte nVulkanSupportsHostImageCopy(IntPtr ptr); + [DllImport(lib_name)] private static extern byte nVulkanSupportsPushDescriptors(IntPtr ptr); } } diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 4b44e1ca2e87..d14ce1172956 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -93,7 +93,8 @@ bool OboeBridge::open(int32_t sampleRate) { oboe::OboeExtensions::setMMapEnabled(true); // Initialise StabilizedCallback to even out callback execution time. - stabilizedCallback_ = std::make_unique(this); + // shared_ptr is used to satisfy the non-deprecated setDataCallback overload. + stabilizedCallback_ = std::make_shared(this); oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output) @@ -106,11 +107,19 @@ bool OboeBridge::open(int32_t sampleRate) { ->setContentType(oboe::ContentType::Music) ->setUsage(oboe::Usage::Game) ->setAudioApi(oboe::AudioApi::AAudio) - ->setFramesPerCallback(oboe::kUnspecified) + ->setFramesPerDataCallback(oboe::kUnspecified) ->setBufferCapacityInFrames(oboe::kUnspecified) ->setChannelConversionAllowed(false) ->setFormatConversionAllowed(false) - ->setCallback(stabilizedCallback_.get()); + // Audio is pre-mixed by BASS — tell Android not to spatialize it again. + ->setIsContentSpatialized(true) + // Prevent other apps from capturing our audio stream (competitive integrity). + ->setAllowedCapturePolicy(oboe::AllowedCapturePolicy::AllowNone) + // Use shared_ptr overload (non-deprecated) for data callback. + ->setDataCallback(stabilizedCallback_) + // Non-owning shared_ptr for error callback — OboeBridge outlives the stream. + ->setErrorCallback(std::shared_ptr( + std::shared_ptr(), static_cast(this))); oboe::Result result = builder.openStream(stream_); @@ -255,6 +264,10 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( if (provider) { int32_t framesRead = provider(audioData, numFrames); + // Clamp to valid range: negative or out-of-range values from the provider + // would wrap to a huge size_t, causing a buffer overrun in the memset below. + framesRead = std::clamp(framesRead, 0, numFrames); + if (framesRead < numFrames) { size_t bytesDone = static_cast(framesRead) * stream->getChannelCount() * sizeof(float); size_t totalBytes = static_cast(numFrames) * stream->getChannelCount() * sizeof(float); diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index ec24e22dfa65..41bcb221385f 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -44,8 +44,8 @@ class OboeBridge : public oboe::AudioStreamCallback { private: std::shared_ptr stream_; + std::shared_ptr stabilizedCallback_; std::unique_ptr tuner_; - std::unique_ptr stabilizedCallback_; mutable std::mutex streamLock_; std::atomic active_{false}; diff --git a/osu.Android/Native/vulkan_bridge.cpp b/osu.Android/Native/vulkan_bridge.cpp index ffcc96a48abd..9de3969e9e10 100644 --- a/osu.Android/Native/vulkan_bridge.cpp +++ b/osu.Android/Native/vulkan_bridge.cpp @@ -7,7 +7,7 @@ #include #include -#define LOG_TAG "osu_native" +#define LOG_TAG "osu!native" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) @@ -26,7 +26,8 @@ VulkanProbe::VulkanProbe() { available_ = true; LOGI("Vulkan available: %s (Vendor: 0x%x, API %u.%u.%u, driver %u, VRAM %u MB, " - "qCount %u, mailbox %d, vk1.3 %d, sync2 %d, pWait %d, gpl %d, sObj %d, gPrio %d)", + "qCount %u, vk1.3 %d, vk1.4 %d, sync2 %d, pWait %d, gpl %d, sObj %d, gPrio %d, " + "hostCopy %d, pushDesc %d)", deviceInfo_.deviceName.c_str(), deviceInfo_.vendorId, VK_VERSION_MAJOR(deviceInfo_.apiVersion), @@ -35,13 +36,15 @@ VulkanProbe::VulkanProbe() { deviceInfo_.driverVersion, deviceInfo_.deviceLocalMemoryMB, deviceInfo_.queueFamilyCount, - deviceInfo_.supportsMailboxPresentMode ? 1 : 0, deviceInfo_.meetsVulkan13 ? 1 : 0, + deviceInfo_.meetsVulkan14 ? 1 : 0, deviceInfo_.supportsSynchronization2 ? 1 : 0, deviceInfo_.supportsPresentWait ? 1 : 0, deviceInfo_.supportsGraphicsPipelineLibrary ? 1 : 0, deviceInfo_.supportsShaderObject ? 1 : 0, - deviceInfo_.supportsGlobalPriority ? 1 : 0); + deviceInfo_.supportsGlobalPriority ? 1 : 0, + deviceInfo_.supportsHostImageCopy ? 1 : 0, + deviceInfo_.supportsPushDescriptors ? 1 : 0); } VulkanProbe::~VulkanProbe() { @@ -99,8 +102,15 @@ bool VulkanProbe::queryDevice() { queryMemory(selected); queryQueueFamilies(selected); - queryVulkan13Features(selected); + // Query extensions first (sets extension-based feature flags for pre-1.3 devices). queryModernExtensions(selected); + // Feature queries for 1.3+ override extension-based values with actual feature support. + queryVulkan13Features(selected); + + // MAILBOX present mode cannot be queried without a VkSurfaceKHR (we have none in + // this lightweight probe). The actual present mode is selected by the renderer + // (Veldrid) at swapchain creation time via vkGetPhysicalDeviceSurfacePresentModesKHR. + // We leave supportsMailboxPresentMode = false (default) to avoid false positives. return true; } @@ -137,7 +147,7 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) { std::vector exts(count); vkEnumerateDeviceExtensionProperties(device, nullptr, &count, exts.data()); for (const auto& ext : exts) { - if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) { deviceInfo_.supportsSwapchain = true; deviceInfo_.supportsMailboxPresentMode = true; } + if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) deviceInfo_.supportsSwapchain = true; if (strcmp(ext.extensionName, VK_KHR_PRESENT_ID_EXTENSION_NAME) == 0) deviceInfo_.supportsPresentId = true; if (strcmp(ext.extensionName, VK_KHR_PRESENT_WAIT_EXTENSION_NAME) == 0) deviceInfo_.supportsPresentWait = true; if (strcmp(ext.extensionName, VK_EXT_GRAPHICS_PIPELINE_LIBRARY_EXTENSION_NAME) == 0) deviceInfo_.supportsGraphicsPipelineLibrary = true; @@ -145,18 +155,32 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) { if (strcmp(ext.extensionName, VK_EXT_GLOBAL_PRIORITY_EXTENSION_NAME) == 0 || strcmp(ext.extensionName, VK_KHR_GLOBAL_PRIORITY_EXTENSION_NAME) == 0) deviceInfo_.supportsGlobalPriority = true; if (strcmp(ext.extensionName, VK_EXT_MEMORY_BUDGET_EXTENSION_NAME) == 0) deviceInfo_.supportsMemoryBudget = true; if (strcmp(ext.extensionName, "VK_EXT_surface_maintenance1") == 0) deviceInfo_.supportsSurfaceMaintenance1 = true; + + // Extension-based fallback for pre-1.3 devices (overridden by queryVulkan13Features on 1.3+). + if (strcmp(ext.extensionName, "VK_KHR_dynamic_rendering") == 0) deviceInfo_.supportsDynamicRendering = true; + if (strcmp(ext.extensionName, "VK_KHR_synchronization2") == 0) deviceInfo_.supportsSynchronization2 = true; + + // Vulkan 1.4+ / Android 16+ extensions — used by string literals since NDK r29 + // headers may not define these macros. + if (strcmp(ext.extensionName, "VK_EXT_host_image_copy") == 0) deviceInfo_.supportsHostImageCopy = true; + if (strcmp(ext.extensionName, "VK_KHR_push_descriptor") == 0) deviceInfo_.supportsPushDescriptors = true; } // ── Vendor-specific GPU quirks ────────────────────────────────────── // Qualcomm Adreno 7xx series: known flickering with PresentId/PresentWait // and broken Graphics Pipeline Library compilation on some driver versions. - if (deviceInfo_.vendorId == 0x5143 && (deviceInfo_.deviceName.find("740") != std::string::npos || - deviceInfo_.deviceName.find("750") != std::string::npos || - deviceInfo_.deviceName.find("Adreno") != std::string::npos)) { - LOGI("Adreno 7xx GPU detected: applying performance and flickering overrides"); - deviceInfo_.disablePresentId = true; - deviceInfo_.disablePresentWait = true; - deviceInfo_.disableGraphicsPipelineLibrary = true; + // Only target 7xx (730/740/750) — other Adreno generations are unaffected. + if (deviceInfo_.vendorId == 0x5143) { + const auto& name = deviceInfo_.deviceName; + bool isAdreno7xx = name.find("730") != std::string::npos || + name.find("740") != std::string::npos || + name.find("750") != std::string::npos; + if (isAdreno7xx) { + LOGI("Adreno 7xx GPU detected: applying performance and flickering overrides"); + deviceInfo_.disablePresentId = true; + deviceInfo_.disablePresentWait = true; + deviceInfo_.disableGraphicsPipelineLibrary = true; + } } // ARM Mali (Samsung Exynos, MediaTek Dimensity, Google Tensor): @@ -184,12 +208,18 @@ void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) { void VulkanProbe::queryVulkan13Features(VkPhysicalDevice device) { if (VK_VERSION_MAJOR(deviceInfo_.apiVersion) < 1 || (VK_VERSION_MAJOR(deviceInfo_.apiVersion) == 1 && VK_VERSION_MINOR(deviceInfo_.apiVersion) < 3)) return; deviceInfo_.meetsVulkan13 = true; + + // Vulkan 1.4 is just a version check — no NDK header support needed. + if (VK_VERSION_MINOR(deviceInfo_.apiVersion) >= 4) + deviceInfo_.meetsVulkan14 = true; + VkPhysicalDeviceVulkan13Features f13{}; f13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES; VkPhysicalDeviceFeatures2 f2{}; f2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2; f2.pNext = &f13; vkGetPhysicalDeviceFeatures2(device, &f2); + // Override extension-based flags with accurate feature queries for 1.3+ devices. deviceInfo_.supportsDynamicRendering = f13.dynamicRendering == VK_TRUE; deviceInfo_.supportsSynchronization2 = f13.synchronization2 == VK_TRUE; } @@ -227,4 +257,7 @@ OSU_EXPORT byte nVulkanSupportsSurfaceMaintenance1(intptr_t ptr) { return (ptr & OSU_EXPORT byte nVulkanDisablePresentId(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().disablePresentId) ? 1 : 0; } OSU_EXPORT byte nVulkanDisablePresentWait(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().disablePresentWait) ? 1 : 0; } OSU_EXPORT byte nVulkanDisableGraphicsPipelineLibrary(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().disableGraphicsPipelineLibrary) ? 1 : 0; } +OSU_EXPORT byte nVulkanMeetsVulkan14(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().meetsVulkan14) ? 1 : 0; } +OSU_EXPORT byte nVulkanSupportsHostImageCopy(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().supportsHostImageCopy) ? 1 : 0; } +OSU_EXPORT byte nVulkanSupportsPushDescriptors(intptr_t ptr) { return (ptr && reinterpret_cast(ptr)->getDeviceInfo().supportsPushDescriptors) ? 1 : 0; } } diff --git a/osu.Android/Native/vulkan_bridge.h b/osu.Android/Native/vulkan_bridge.h index ea8cb71b47f4..385c79732467 100644 --- a/osu.Android/Native/vulkan_bridge.h +++ b/osu.Android/Native/vulkan_bridge.h @@ -29,7 +29,7 @@ class VulkanProbe { bool supportsDynamicRendering = false; bool supportsSynchronization2 = false; - // API 31+ / Modern High-Performance Extensions + // Modern High-Performance Extensions bool supportsPresentId = false; bool supportsPresentWait = false; bool supportsGraphicsPipelineLibrary = false; @@ -38,6 +38,11 @@ class VulkanProbe { bool supportsMemoryBudget = false; bool supportsSurfaceMaintenance1 = false; + // Vulkan 1.4+ / Android 16+ features + bool meetsVulkan14 = false; + bool supportsHostImageCopy = false; + bool supportsPushDescriptors = false; + // Quirks / Blacklist flags bool disablePresentId = false; bool disablePresentWait = false; diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index da4fea69334a..cedb9b53dc88 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -169,6 +169,11 @@ public static class GraphicsSettingsStrings /// public static LocalisableString ShrinkGameToSafeArea => new TranslatableString(getKey(@"shrink_game_to_safe_area"), @"Shrink game to avoid cameras and notches"); + /// + /// "Low latency" + /// + public static LocalisableString LowLatency => new TranslatableString(getKey(@"low_latency"), @"Low latency"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index d7696ce2b802..07e41f4ed7ce 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -7,6 +7,7 @@ using osu.Framework.Configuration; using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering.LowLatency; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; @@ -82,6 +83,14 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDi { Keywords = new[] { @"framerate", @"counter" }, }, + new SettingsItemV2(new FormEnumDropdown + { + Caption = GraphicsSettingsStrings.LowLatency, + Current = config.GetBindable(FrameworkSetting.LatencyMode), + }) + { + Keywords = new[] { @"latency", @"reflex", @"input" }, + }, }; renderer.BindValueChanged(r => diff --git a/submodules/osu-framework b/submodules/osu-framework index 13df6c9cb05b..394ecf5d18d2 160000 --- a/submodules/osu-framework +++ b/submodules/osu-framework @@ -1 +1 @@ -Subproject commit 13df6c9cb05b5cb4099660ade055d2a6d6f417f3 +Subproject commit 394ecf5d18d2eacc21badf94511090b50d57eb09