From ae82527c6216aa7de159ffe5f4a79554b9998dd9 Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Tue, 21 Apr 2026 06:37:33 +0000 Subject: [PATCH 01/10] Add support for game-specific buttons.svg --- .github/workflows/manual_generate_apk.yml | 5 ----- UI/EmuScreen.cpp | 4 ++++ UI/UIAtlas.cpp | 12 ++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/manual_generate_apk.yml b/.github/workflows/manual_generate_apk.yml index 4c74ef945e8f..f07fdd951cab 100644 --- a/.github/workflows/manual_generate_apk.yml +++ b/.github/workflows/manual_generate_apk.yml @@ -54,11 +54,6 @@ jobs: # uses: nttld/setup-ndk@v1 # with: # ndk-version: r21e - - - name: Test androidGitVersion - run: | - echo "count=${{steps.valid-tags.outputs.count}}" - gradle --quiet androidGitVersion - name: Assemble APK run: ./gradlew assemble${{ github.event.inputs.buildVariant }} --stacktrace diff --git a/UI/EmuScreen.cpp b/UI/EmuScreen.cpp index 4e173331223e..386851fa0875 100644 --- a/UI/EmuScreen.cpp +++ b/UI/EmuScreen.cpp @@ -384,6 +384,10 @@ void EmuScreen::bootComplete() { g_Discord.SetPresenceGame(sc->T("Untitled PSP game")); } + if (UIContext *ctx = screenManager()->getUIContext()) { + ctx->InvalidateAtlas(); + } + UpdateUIState(UISTATE_INGAME); System_Notify(SystemNotification::BOOT_DONE); System_Notify(SystemNotification::DISASSEMBLY); diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index 34f4f483b512..a3d500613025 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -14,7 +14,9 @@ #include "Common/Thread/ParallelLoop.h" #include "Common/Log.h" #include "Common/Data/Convert/ColorConv.h" +#include "Core/ELF/ParamSFO.h" #include "Core/Util/PathUtil.h" +#include "Core/System.h" #include "UI/UIAtlas.h" @@ -377,6 +379,16 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int return false; } Path customButtons = GetSysDirectory(DIRECTORY_SYSTEM) / "buttons.svg"; + if (g_paramSFO.IsValid()) { + std::string gameID = g_paramSFO.GetDiscID(); + if (!gameID.empty()) { + Path gameButtons = GetSysDirectory(DIRECTORY_SYSTEM) / (gameID + "_buttons.svg"); + if (File::Exists(gameButtons)) { + INFO_LOG(Log::G3D, "Using game-specific buttons SVG: %s", gameButtons.c_str()); + customButtons = gameButtons; + } + } + } if (File::Exists(customButtons)) { if (!RasterizeSVG(customButtons.c_str(), dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { return false; From a83754da252d544b01f009b6626d17d8776a381f Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Tue, 21 Apr 2026 08:47:07 +0000 Subject: [PATCH 02/10] Add support for loading PNG for buttons assets --- UI/UIAtlas.cpp | 53 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index a3d500613025..a350795286d0 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -361,6 +361,40 @@ static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextu return true; } +static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameID, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { + int loaded = 0; + for (int i = 0; i < (int)imageCount; i++) { + std::string pngName = PNGNameFromID(imageIDs[i].id); + + Path gameSpecificPath; + if (!gameID.empty()) { + gameSpecificPath = systemDir / (std::string(gameID) + "_buttons_" + pngName); + } + Path globalPath = systemDir / ("buttons_" + pngName); + + Path chosenPath; + if (!gameID.empty() && File::Exists(gameSpecificPath)) { + chosenPath = gameSpecificPath; + } else if (File::Exists(globalPath)) { + chosenPath = globalPath; + } else { + continue; + } + + Image loadedImage; + if (!loadedImage.LoadPNG(chosenPath.c_str())) { + ERROR_LOG(Log::G3D, "Failed to load custom buttons PNG: %s", chosenPath.c_str()); + continue; + } + + loadedImage.ConvertToPremultipliedAlpha(); + (*images)[i] = std::move(loadedImage); + loaded++; + } + + return loaded; +} + static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int maxTextureSize, const ImageMeta *imageIDs, size_t imageCount) { Bucket bucket; @@ -378,16 +412,18 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int if (!RasterizeSVG("ui_images/images.svg", dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { return false; } - Path customButtons = GetSysDirectory(DIRECTORY_SYSTEM) / "buttons.svg"; + Path systemDir = GetSysDirectory(DIRECTORY_SYSTEM); + std::string gameID; if (g_paramSFO.IsValid()) { - std::string gameID = g_paramSFO.GetDiscID(); - if (!gameID.empty()) { - Path gameButtons = GetSysDirectory(DIRECTORY_SYSTEM) / (gameID + "_buttons.svg"); + gameID = g_paramSFO.GetDiscID(); + } + Path customButtons = systemDir / "buttons.svg"; + if (!gameID.empty()) { + Path gameButtons = systemDir / (gameID + "_buttons.svg"); if (File::Exists(gameButtons)) { INFO_LOG(Log::G3D, "Using game-specific buttons SVG: %s", gameButtons.c_str()); customButtons = gameButtons; } - } } if (File::Exists(customButtons)) { if (!RasterizeSVG(customButtons.c_str(), dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { @@ -398,6 +434,13 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int return false; } } + + // Optional PNG override only for buttons assets. + int buttonsPngOverridden = LoadButtonsPNGOverrides(systemDir, gameID, imageIDs, imageCount, &images); + if (buttonsPngOverridden > 0) { + INFO_LOG(Log::G3D, "Loaded %d custom buttons PNG overrides", buttonsPngOverridden); + } + Instant shadowStart = Instant::Now(); // We can trivially parallelize shadowing/extension of the images. From 13ada2cdb324b8c6cb72c8dbfc60d4612ed1fab8 Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Tue, 21 Apr 2026 09:08:08 +0000 Subject: [PATCH 03/10] Add functionality to dump buttons SVG as PNG files --- UI/DeveloperToolsScreen.cpp | 12 +++++++++ UI/DeveloperToolsScreen.h | 1 + UI/UIAtlas.cpp | 51 +++++++++++++++++++++++++++++++++++-- UI/UIAtlas.h | 1 + 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/UI/DeveloperToolsScreen.cpp b/UI/DeveloperToolsScreen.cpp index 7bb25526b98b..7aece2265f1c 100644 --- a/UI/DeveloperToolsScreen.cpp +++ b/UI/DeveloperToolsScreen.cpp @@ -46,6 +46,7 @@ #include "UI/OnScreenDisplay.h" #include "UI/IconCache.h" #include "UI/MiscViews.h" +#include "UI/UIAtlas.h" #if PPSSPP_PLATFORM(ANDROID) @@ -236,6 +237,8 @@ void DeveloperToolsScreen::CreateGeneralTab(UI::LinearLayout *list) { g_OSD.Show(OSDType::MESSAGE_INFO, ApplySafeSubstitutions(di->T("Copied to clipboard: %1"), "ppsspp.ini"), 0.0f, "copyToClip"); } }); + + list->Add(new Choice(dev->T("Dump buttons SVG to PNG files")))->OnClick.Handle(this, &DeveloperToolsScreen::OnDumpButtonsPNGs); } void DeveloperToolsScreen::CreateTestsTab(UI::LinearLayout *list) { @@ -738,6 +741,15 @@ void DeveloperToolsScreen::OnRemoteDebugger(UI::EventParams &e) { g_Config.bRemoteDebuggerOnStartup = allowDebugger_; } +void DeveloperToolsScreen::OnDumpButtonsPNGs(UI::EventParams &e) { + int dumped = DumpButtonsPNGsToSystem(); + if (dumped > 0) { + g_OSD.Show(OSDType::MESSAGE_SUCCESS, "Buttons PNG dump complete", StringFromFormat("Dumped %d files to PSP/SYSTEM", dumped), 4.0f, "buttons_png_dump"); + } else { + g_OSD.Show(OSDType::MESSAGE_ERROR, "Buttons PNG dump failed", "Could not dump PNG files from buttons.svg", 4.0f, "buttons_png_dump"); + } +} + void DeveloperToolsScreen::OnMIPSTracerEnabled(UI::EventParams &e) { if (MIPSTracerEnabled_) { u32 capacity = mipsTracer.in_storage_capacity; diff --git a/UI/DeveloperToolsScreen.h b/UI/DeveloperToolsScreen.h index 8c597c317db3..47723b5ddf7c 100644 --- a/UI/DeveloperToolsScreen.h +++ b/UI/DeveloperToolsScreen.h @@ -41,6 +41,7 @@ class DeveloperToolsScreen : public UITabbedBaseDialogScreen { void OnMemstickTest(UI::EventParams &e); void OnTouchscreenTest(UI::EventParams &e); void OnCopyStatesToRoot(UI::EventParams &e); + void OnDumpButtonsPNGs(UI::EventParams &e); void MemoryMapTest(); diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index a350795286d0..7022c12b9628 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -220,7 +220,7 @@ static bool IsImageID(const ImageMeta *imageIDs, size_t imageCount, std::string_ return GetImageIndex(imageIDs, imageCount, id) != -1; } -static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextureSize, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { +static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextureSize, const ImageMeta *imageIDs, size_t imageCount, std::vector *images, bool premultiplyAlpha = true) { Instant svgStart = Instant::Now(); // Load SVGs here, trying to fill in the images. The remaining images we fill from PNGs. @@ -342,7 +342,9 @@ static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextu pngSave(Path(name), img.data(), img.width(), img.height(), 4); } - img.ConvertToPremultipliedAlpha(); + if (premultiplyAlpha) { + img.ConvertToPremultipliedAlpha(); + } } shapeCount = (int)usedShapes.size(); @@ -361,6 +363,51 @@ static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextu return true; } +int DumpButtonsPNGsToSystem() { + Path systemDir = GetSysDirectory(DIRECTORY_SYSTEM); + std::string gameID; + if (g_paramSFO.IsValid()) { + gameID = g_paramSFO.GetDiscID(); + } + + Path customButtons = systemDir / "buttons.svg"; + if (!gameID.empty()) { + Path gameButtons = systemDir / (gameID + "_buttons.svg"); + if (File::Exists(gameButtons)) { + customButtons = gameButtons; + } + } + + Path sourceButtons = customButtons; + if (!File::Exists(sourceButtons)) { + sourceButtons = Path("ui_images/buttons.svg"); + } + + std::vector images(ARRAY_SIZE(g_uiImageIDs)); + if (!RasterizeSVG(sourceButtons.c_str(), 1.0f, 8192, g_uiImageIDs, ARRAY_SIZE(g_uiImageIDs), &images, false)) { + ERROR_LOG(Log::G3D, "Failed to rasterize buttons SVG for PNG dump: %s", sourceButtons.c_str()); + return 0; + } + + int dumped = 0; + for (int i = 0; i < (int)images.size(); i++) { + if (images[i].IsEmpty()) { + continue; + } + + std::string fileNamePrefix; + if (!gameID.empty()) { + fileNamePrefix = gameID + "_"; + } + Path outPath = systemDir / (fileNamePrefix + "buttons_" + PNGNameFromID(g_uiImageIDs[i].id)); + pngSave(outPath, images[i].data(), images[i].width(), images[i].height(), 4); + dumped++; + } + + INFO_LOG(Log::G3D, "Dumped %d buttons PNG files to %s", dumped, systemDir.c_str()); + return dumped; +} + static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameID, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { int loaded = 0; for (int i = 0; i < (int)imageCount; i++) { diff --git a/UI/UIAtlas.h b/UI/UIAtlas.h index e84257ba37ec..69e0f9995b44 100644 --- a/UI/UIAtlas.h +++ b/UI/UIAtlas.h @@ -6,3 +6,4 @@ const Atlas *GetFontAtlas(); Atlas *GetUIAtlas(); AtlasData AtlasProvider(Draw::DrawContext *draw, AtlasChoice atlas, float dpiScale, bool invalidate); +int DumpButtonsPNGsToSystem(); From de0f385176faf02beb2e802edcc22033cd2e6b40 Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Tue, 21 Apr 2026 09:55:41 +0000 Subject: [PATCH 04/10] Update button PNG dump paths to use ICON directory --- UI/DeveloperToolsScreen.cpp | 2 +- UI/UIAtlas.cpp | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/UI/DeveloperToolsScreen.cpp b/UI/DeveloperToolsScreen.cpp index 7aece2265f1c..3d9bcfe1cc2a 100644 --- a/UI/DeveloperToolsScreen.cpp +++ b/UI/DeveloperToolsScreen.cpp @@ -744,7 +744,7 @@ void DeveloperToolsScreen::OnRemoteDebugger(UI::EventParams &e) { void DeveloperToolsScreen::OnDumpButtonsPNGs(UI::EventParams &e) { int dumped = DumpButtonsPNGsToSystem(); if (dumped > 0) { - g_OSD.Show(OSDType::MESSAGE_SUCCESS, "Buttons PNG dump complete", StringFromFormat("Dumped %d files to PSP/SYSTEM", dumped), 4.0f, "buttons_png_dump"); + g_OSD.Show(OSDType::MESSAGE_SUCCESS, "Buttons PNG dump complete", StringFromFormat("Dumped %d files to PSP/SYSTEM/ICON/DUMP", dumped), 4.0f, "buttons_png_dump"); } else { g_OSD.Show(OSDType::MESSAGE_ERROR, "Buttons PNG dump failed", "Could not dump PNG files from buttons.svg", 4.0f, "buttons_png_dump"); } diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index 7022c12b9628..696308f8d43e 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -365,14 +365,15 @@ static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextu int DumpButtonsPNGsToSystem() { Path systemDir = GetSysDirectory(DIRECTORY_SYSTEM); + Path iconDir = systemDir / "ICON"; std::string gameID; if (g_paramSFO.IsValid()) { gameID = g_paramSFO.GetDiscID(); } - Path customButtons = systemDir / "buttons.svg"; + Path customButtons = iconDir / "buttons.svg"; if (!gameID.empty()) { - Path gameButtons = systemDir / (gameID + "_buttons.svg"); + Path gameButtons = iconDir / gameID / "buttons.svg"; if (File::Exists(gameButtons)) { customButtons = gameButtons; } @@ -389,35 +390,38 @@ int DumpButtonsPNGsToSystem() { return 0; } + Path dumpDir = iconDir / "DUMP"; + if (!gameID.empty()) { + dumpDir = dumpDir / gameID; + } + File::CreateFullPath(dumpDir); + int dumped = 0; for (int i = 0; i < (int)images.size(); i++) { if (images[i].IsEmpty()) { continue; } - std::string fileNamePrefix; - if (!gameID.empty()) { - fileNamePrefix = gameID + "_"; - } - Path outPath = systemDir / (fileNamePrefix + "buttons_" + PNGNameFromID(g_uiImageIDs[i].id)); + Path outPath = dumpDir / ("buttons_" + PNGNameFromID(g_uiImageIDs[i].id)); pngSave(outPath, images[i].data(), images[i].width(), images[i].height(), 4); dumped++; } - INFO_LOG(Log::G3D, "Dumped %d buttons PNG files to %s", dumped, systemDir.c_str()); + INFO_LOG(Log::G3D, "Dumped %d buttons PNG files to %s", dumped, dumpDir.c_str()); return dumped; } static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameID, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { + Path iconDir = systemDir / "ICON"; int loaded = 0; for (int i = 0; i < (int)imageCount; i++) { std::string pngName = PNGNameFromID(imageIDs[i].id); Path gameSpecificPath; if (!gameID.empty()) { - gameSpecificPath = systemDir / (std::string(gameID) + "_buttons_" + pngName); + gameSpecificPath = iconDir / std::string(gameID) / ("buttons_" + pngName); } - Path globalPath = systemDir / ("buttons_" + pngName); + Path globalPath = iconDir / ("buttons_" + pngName); Path chosenPath; if (!gameID.empty() && File::Exists(gameSpecificPath)) { @@ -464,9 +468,10 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int if (g_paramSFO.IsValid()) { gameID = g_paramSFO.GetDiscID(); } - Path customButtons = systemDir / "buttons.svg"; + Path iconDir = systemDir / "ICON"; + Path customButtons = iconDir / "buttons.svg"; if (!gameID.empty()) { - Path gameButtons = systemDir / (gameID + "_buttons.svg"); + Path gameButtons = iconDir / gameID / "buttons.svg"; if (File::Exists(gameButtons)) { INFO_LOG(Log::G3D, "Using game-specific buttons SVG: %s", gameButtons.c_str()); customButtons = gameButtons; From 27d2b09eac4f52b741180657085f8d53ceb32149 Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Tue, 21 Apr 2026 11:36:09 +0000 Subject: [PATCH 05/10] fix: prevent oversized custom button PNGs from enlarging button size --- UI/UIAtlas.cpp | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index 696308f8d43e..12544eb96f1b 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -1,3 +1,4 @@ +#include #include #include "Common/File/Path.h" @@ -412,6 +413,43 @@ int DumpButtonsPNGsToSystem() { } static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameID, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { + auto bilinearSample = [](const Image &img, float x, float y) { + const int w = img.width(); + const int h = img.height(); + + x = std::clamp(x, 0.0f, (float)(w - 1)); + y = std::clamp(y, 0.0f, (float)(h - 1)); + + const int x0 = (int)std::floor(x); + const int y0 = (int)std::floor(y); + const int x1 = std::min(x0 + 1, w - 1); + const int y1 = std::min(y0 + 1, h - 1); + + const float tx = x - (float)x0; + const float ty = y - (float)y0; + + const u32 c00 = img.get1(x0, y0); + const u32 c10 = img.get1(x1, y0); + const u32 c01 = img.get1(x0, y1); + const u32 c11 = img.get1(x1, y1); + + auto interpChannel = [&](int shift) { + const float v00 = (float)((c00 >> shift) & 0xFF); + const float v10 = (float)((c10 >> shift) & 0xFF); + const float v01 = (float)((c01 >> shift) & 0xFF); + const float v11 = (float)((c11 >> shift) & 0xFF); + const float a = v00 + (v10 - v00) * tx; + const float b = v01 + (v11 - v01) * tx; + return (u32)std::lround(a + (b - a) * ty); + }; + + const u32 r = interpChannel(0); + const u32 g = interpChannel(8); + const u32 b = interpChannel(16); + const u32 a = interpChannel(24); + return r | (g << 8) | (b << 16) | (a << 24); + }; + Path iconDir = systemDir / "ICON"; int loaded = 0; for (int i = 0; i < (int)imageCount; i++) { @@ -438,6 +476,37 @@ static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameI continue; } + const Image &targetImage = (*images)[i]; + if (!targetImage.IsEmpty() && targetImage.width() > 0 && targetImage.height() > 0) { + const int targetW = targetImage.width(); + const int targetH = targetImage.height(); + const int srcW = loadedImage.width(); + const int srcH = loadedImage.height(); + + float fitScale = std::min((float)targetW / (float)srcW, (float)targetH / (float)srcH); + fitScale = std::min(fitScale, 1.0f); + + const int fitW = std::max(1, (int)std::lround(srcW * fitScale)); + const int fitH = std::max(1, (int)std::lround(srcH * fitScale)); + const int offsetX = (targetW - fitW) / 2; + const int offsetY = (targetH - fitH) / 2; + + Image fitted; + fitted.resize(targetW, targetH); + fitted.fill(0); + fitted.scale = targetImage.scale; + + for (int y = 0; y < fitH; y++) { + const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; + for (int x = 0; x < fitW; x++) { + const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; + fitted.set1(offsetX + x, offsetY + y, bilinearSample(loadedImage, srcX, srcY)); + } + } + + loadedImage = std::move(fitted); + } + loadedImage.ConvertToPremultipliedAlpha(); (*images)[i] = std::move(loadedImage); loaded++; From c23318e1eb9b6314779f8bf5374dd7b0b385e90b Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Tue, 21 Apr 2026 18:13:00 +0000 Subject: [PATCH 06/10] refactor: migrate button icon replacement to texture replacement flow - move dump action to Texture Replacement tab as Save buttons texture --- GPU/Common/TextureReplacer.cpp | 8 ++ UI/DeveloperToolsScreen.cpp | 9 ++- UI/DeveloperToolsScreen.h | 2 +- UI/UIAtlas.cpp | 134 +++++++++++++++++++++------------ 4 files changed, 100 insertions(+), 53 deletions(-) diff --git a/GPU/Common/TextureReplacer.cpp b/GPU/Common/TextureReplacer.cpp index 660f00678b07..ff5dca620529 100644 --- a/GPU/Common/TextureReplacer.cpp +++ b/GPU/Common/TextureReplacer.cpp @@ -1052,6 +1052,14 @@ ignoreAddress = false # Reduces duplicates at the cost of making hash less re # Use / for folders not \\, avoid special characters, and stick to lowercase. # See wiki for more info. +[buttons] +# Optional mapping for UI button textures used by the on-screen controls. +# Keys can be either the button id (I_CROSS) or short name (cross). +# Values are paths relative to this TEXTURES/ folder. +# Example: +# cross = ui/buttons/cross.png +# I_CIRCLE = ui/buttons/circle.png + [hashranges] # This is useful for images that very clearly have smaller dimensions, like 480x272 image. They'll need to be redumped, since the hash will change. See the documentation. # Example: 08b31020,512,512 = 480,272 diff --git a/UI/DeveloperToolsScreen.cpp b/UI/DeveloperToolsScreen.cpp index 3d9bcfe1cc2a..160882b26f3c 100644 --- a/UI/DeveloperToolsScreen.cpp +++ b/UI/DeveloperToolsScreen.cpp @@ -127,6 +127,8 @@ void DeveloperToolsScreen::CreateTextureReplacementTab(UI::LinearLayout *list) { static const char *texLoadSpeeds[] = { "Slow (smooth)", "Medium", "Fast", "Instant (may stutter)" }; PopupMultiChoice *texLoadSpeed = list->Add(new PopupMultiChoice(&g_Config.iReplacementTextureLoadSpeed, dev->T("Replacement texture load speed"), texLoadSpeeds, 0, ARRAY_SIZE(texLoadSpeeds), I18NCat::DEVELOPER, screenManager())); texLoadSpeed->SetChoiceIcon(3, ImageID("I_WARNING")); + + list->Add(new Choice(dev->T("Save buttons texture")))->OnClick.Handle(this, &DeveloperToolsScreen::OnSaveButtonsTexture); } void DeveloperToolsScreen::CreateGeneralTab(UI::LinearLayout *list) { @@ -238,7 +240,6 @@ void DeveloperToolsScreen::CreateGeneralTab(UI::LinearLayout *list) { } }); - list->Add(new Choice(dev->T("Dump buttons SVG to PNG files")))->OnClick.Handle(this, &DeveloperToolsScreen::OnDumpButtonsPNGs); } void DeveloperToolsScreen::CreateTestsTab(UI::LinearLayout *list) { @@ -741,12 +742,12 @@ void DeveloperToolsScreen::OnRemoteDebugger(UI::EventParams &e) { g_Config.bRemoteDebuggerOnStartup = allowDebugger_; } -void DeveloperToolsScreen::OnDumpButtonsPNGs(UI::EventParams &e) { +void DeveloperToolsScreen::OnSaveButtonsTexture(UI::EventParams &e) { int dumped = DumpButtonsPNGsToSystem(); if (dumped > 0) { - g_OSD.Show(OSDType::MESSAGE_SUCCESS, "Buttons PNG dump complete", StringFromFormat("Dumped %d files to PSP/SYSTEM/ICON/DUMP", dumped), 4.0f, "buttons_png_dump"); + g_OSD.Show(OSDType::MESSAGE_SUCCESS, "Buttons texture saved", StringFromFormat("Saved %d files to PSP/TEXTURES//new", dumped), 4.0f, "buttons_png_dump"); } else { - g_OSD.Show(OSDType::MESSAGE_ERROR, "Buttons PNG dump failed", "Could not dump PNG files from buttons.svg", 4.0f, "buttons_png_dump"); + g_OSD.Show(OSDType::MESSAGE_ERROR, "Save buttons texture failed", "Could not save PNG files from buttons.svg", 4.0f, "buttons_png_dump"); } } diff --git a/UI/DeveloperToolsScreen.h b/UI/DeveloperToolsScreen.h index 47723b5ddf7c..8d0e5704ef6a 100644 --- a/UI/DeveloperToolsScreen.h +++ b/UI/DeveloperToolsScreen.h @@ -41,7 +41,7 @@ class DeveloperToolsScreen : public UITabbedBaseDialogScreen { void OnMemstickTest(UI::EventParams &e); void OnTouchscreenTest(UI::EventParams &e); void OnCopyStatesToRoot(UI::EventParams &e); - void OnDumpButtonsPNGs(UI::EventParams &e); + void OnSaveButtonsTexture(UI::EventParams &e); void MemoryMapTest(); diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index 12544eb96f1b..8d394a3cce7f 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include "Common/Data/Format/IniFile.h" #include "Common/File/Path.h" #include "Common/File/FileUtil.h" #include "Common/File/VFS/VFS.h" @@ -15,6 +17,7 @@ #include "Common/Thread/ParallelLoop.h" #include "Common/Log.h" #include "Common/Data/Convert/ColorConv.h" +#include "Core/Config.h" #include "Core/ELF/ParamSFO.h" #include "Core/Util/PathUtil.h" #include "Core/System.h" @@ -365,22 +368,16 @@ static bool RasterizeSVG(std::string_view filename, float dpiScale, int maxTextu } int DumpButtonsPNGsToSystem() { - Path systemDir = GetSysDirectory(DIRECTORY_SYSTEM); - Path iconDir = systemDir / "ICON"; + Path textureDir = GetSysDirectory(DIRECTORY_TEXTURES); std::string gameID; if (g_paramSFO.IsValid()) { gameID = g_paramSFO.GetDiscID(); - } - - Path customButtons = iconDir / "buttons.svg"; - if (!gameID.empty()) { - Path gameButtons = iconDir / gameID / "buttons.svg"; - if (File::Exists(gameButtons)) { - customButtons = gameButtons; + if (!gameID.empty()) { + textureDir = textureDir / gameID; } } - Path sourceButtons = customButtons; + Path sourceButtons = textureDir / "buttons.svg"; if (!File::Exists(sourceButtons)) { sourceButtons = Path("ui_images/buttons.svg"); } @@ -391,12 +388,20 @@ int DumpButtonsPNGsToSystem() { return 0; } - Path dumpDir = iconDir / "DUMP"; - if (!gameID.empty()) { - dumpDir = dumpDir / gameID; - } + Path dumpDir = textureDir / "new"; File::CreateFullPath(dumpDir); + // Also dump the source SVG to keep PNG dumps and vector source together. + size_t svgSize = 0; + const uint8_t *svgData = g_VFS.ReadFile(sourceButtons, &svgSize); + if (svgData && svgSize > 0) { + Path svgOutPath = dumpDir / "buttons.svg"; + if (!File::WriteDataToFile(false, svgData, svgSize, svgOutPath)) { + WARN_LOG(Log::G3D, "Failed to write buttons SVG dump: %s", svgOutPath.c_str()); + } + delete[] svgData; + } + int dumped = 0; for (int i = 0; i < (int)images.size(); i++) { if (images[i].IsEmpty()) { @@ -412,7 +417,40 @@ int DumpButtonsPNGsToSystem() { return dumped; } -static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameID, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { +static std::string NormalizeButtonAliasKey(std::string_view key) { + std::string out; + out.reserve(key.size()); + if (key.size() > 2 && key[0] == 'I' && key[1] == '_') { + key = key.substr(2); + } + for (char c : key) { + out.push_back((char)tolower((unsigned char)c)); + } + return out; +} + +static std::unordered_map LoadButtonAliases(const Path &textureDir) { + std::unordered_map aliases; + IniFile ini; + if (!ini.Load(textureDir / "textures.ini")) { + return aliases; + } + + const Section *buttons = ini.GetSection("buttons"); + if (!buttons) { + return aliases; + } + + for (const ParsedIniLine &line : buttons->Lines()) { + if (line.Key().empty() || line.Value().empty()) { + continue; + } + aliases[NormalizeButtonAliasKey(line.Key())] = std::string(line.Value()); + } + return aliases; +} + +static int LoadButtonsPNGOverrides(const Path &textureDir, const std::unordered_map &aliases, const ImageMeta *imageIDs, size_t imageCount, std::vector *images) { auto bilinearSample = [](const Image &img, float x, float y) { const int w = img.width(); const int h = img.height(); @@ -450,23 +488,20 @@ static int LoadButtonsPNGOverrides(const Path &systemDir, std::string_view gameI return r | (g << 8) | (b << 16) | (a << 24); }; - Path iconDir = systemDir / "ICON"; int loaded = 0; for (int i = 0; i < (int)imageCount; i++) { std::string pngName = PNGNameFromID(imageIDs[i].id); - - Path gameSpecificPath; - if (!gameID.empty()) { - gameSpecificPath = iconDir / std::string(gameID) / ("buttons_" + pngName); - } - Path globalPath = iconDir / ("buttons_" + pngName); + std::string key = NormalizeButtonAliasKey(imageIDs[i].id); Path chosenPath; - if (!gameID.empty() && File::Exists(gameSpecificPath)) { - chosenPath = gameSpecificPath; - } else if (File::Exists(globalPath)) { - chosenPath = globalPath; + auto alias = aliases.find(key); + if (alias != aliases.end()) { + chosenPath = textureDir / alias->second; } else { + chosenPath = textureDir / ("buttons_" + pngName); + } + + if (!File::Exists(chosenPath)) { continue; } @@ -532,23 +567,32 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int if (!RasterizeSVG("ui_images/images.svg", dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { return false; } - Path systemDir = GetSysDirectory(DIRECTORY_SYSTEM); - std::string gameID; - if (g_paramSFO.IsValid()) { - gameID = g_paramSFO.GetDiscID(); - } - Path iconDir = systemDir / "ICON"; - Path customButtons = iconDir / "buttons.svg"; - if (!gameID.empty()) { - Path gameButtons = iconDir / gameID / "buttons.svg"; - if (File::Exists(gameButtons)) { - INFO_LOG(Log::G3D, "Using game-specific buttons SVG: %s", gameButtons.c_str()); - customButtons = gameButtons; + if (g_Config.bReplaceTextures) { + Path textureDir = GetSysDirectory(DIRECTORY_TEXTURES); + std::string gameID; + if (g_paramSFO.IsValid()) { + gameID = g_paramSFO.GetDiscID(); + if (!gameID.empty()) { + textureDir = textureDir / gameID; } - } - if (File::Exists(customButtons)) { - if (!RasterizeSVG(customButtons.c_str(), dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { - return false; + } + + Path customButtons = textureDir / "buttons.svg"; + if (File::Exists(customButtons)) { + INFO_LOG(Log::G3D, "Using texture-replacement buttons SVG: %s", customButtons.c_str()); + if (!RasterizeSVG(customButtons.c_str(), dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { + return false; + } + } else { + if (!RasterizeSVG("ui_images/buttons.svg", dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { + return false; + } + } + + std::unordered_map aliases = LoadButtonAliases(textureDir); + int buttonsPngOverridden = LoadButtonsPNGOverrides(textureDir, aliases, imageIDs, imageCount, &images); + if (buttonsPngOverridden > 0) { + INFO_LOG(Log::G3D, "Loaded %d custom buttons PNG overrides", buttonsPngOverridden); } } else { if (!RasterizeSVG("ui_images/buttons.svg", dpiScale, maxTextureSize, imageIDs, imageCount, &images)) { @@ -556,12 +600,6 @@ static bool GenerateUIAtlasImage(Atlas *atlas, float dpiScale, Image *dest, int } } - // Optional PNG override only for buttons assets. - int buttonsPngOverridden = LoadButtonsPNGOverrides(systemDir, gameID, imageIDs, imageCount, &images); - if (buttonsPngOverridden > 0) { - INFO_LOG(Log::G3D, "Loaded %d custom buttons PNG overrides", buttonsPngOverridden); - } - Instant shadowStart = Instant::Now(); // We can trivially parallelize shadowing/extension of the images. From c9ebaa423369715f94c147e628c1321b83870c69 Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Wed, 22 Apr 2026 01:48:02 +0000 Subject: [PATCH 07/10] fix: convert Path to string for VFS ReadFile in UIAtlas --- UI/UIAtlas.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index 8d394a3cce7f..c9e9880eeb4b 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -393,7 +393,8 @@ int DumpButtonsPNGsToSystem() { // Also dump the source SVG to keep PNG dumps and vector source together. size_t svgSize = 0; - const uint8_t *svgData = g_VFS.ReadFile(sourceButtons, &svgSize); + std::string sourceButtonsPath = sourceButtons.ToString(); + const uint8_t *svgData = g_VFS.ReadFile(sourceButtonsPath, &svgSize); if (svgData && svgSize > 0) { Path svgOutPath = dumpDir / "buttons.svg"; if (!File::WriteDataToFile(false, svgData, svgSize, svgOutPath)) { From 30047fc56751a23708e6308cc1c260b1d0b847b0 Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Wed, 22 Apr 2026 02:28:34 +0000 Subject: [PATCH 08/10] fix: reinitialize button atlas when replace textures is toggled --- UI/DeveloperToolsScreen.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/UI/DeveloperToolsScreen.cpp b/UI/DeveloperToolsScreen.cpp index 160882b26f3c..07828aa3539f 100644 --- a/UI/DeveloperToolsScreen.cpp +++ b/UI/DeveloperToolsScreen.cpp @@ -92,7 +92,13 @@ void DeveloperToolsScreen::CreateTextureReplacementTab(UI::LinearLayout *list) { list->Add(new ItemHeader(dev->T("Texture Replacement"))); list->Add(new CheckBox(&g_Config.bSaveNewTextures, dev->T("Save new textures"))); - list->Add(new CheckBox(&g_Config.bReplaceTextures, dev->T("Replace textures"))); + CheckBox *replaceTextures = list->Add(new CheckBox(&g_Config.bReplaceTextures, dev->T("Replace textures"))); + replaceTextures->OnClick.Add([this](UI::EventParams &) { + if (UIContext *ctx = screenManager()->getUIContext()) { + ctx->InvalidateAtlas(); + } + System_PostUIMessage(UIMessage::GPU_CONFIG_CHANGED); + }); Choice *createTextureIni = list->Add(new Choice(dev->T("Create/Open textures.ini file for current game"))); createTextureIni->OnClick.Handle(this, &DeveloperToolsScreen::OnOpenTexturesIniFile); From d61720824e3e5345465c983ba9e3cc899c32a65b Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Thu, 28 May 2026 14:04:25 +0000 Subject: [PATCH 09/10] fix: improve button image sampling for better quality in PNG overrides --- UI/UIAtlas.cpp | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index c9e9880eeb4b..d025a84b5c4d 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -488,6 +488,28 @@ static int LoadButtonsPNGOverrides(const Path &textureDir, const std::unordered_ const u32 a = interpChannel(24); return r | (g << 8) | (b << 16) | (a << 24); }; + auto sampleArea = [&](const Image &img, float x0, float y0, float x1, float y1) { + constexpr int kSamplesPerAxis = 4; + uint64_t sums[4] = {}; + for (int sy = 0; sy < kSamplesPerAxis; sy++) { + const float py = y0 + ((float)sy + 0.5f) * (y1 - y0) / (float)kSamplesPerAxis; + for (int sx = 0; sx < kSamplesPerAxis; sx++) { + const float px = x0 + ((float)sx + 0.5f) * (x1 - x0) / (float)kSamplesPerAxis; + const u32 col = bilinearSample(img, px, py); + sums[0] += col & 0xFF; + sums[1] += (col >> 8) & 0xFF; + sums[2] += (col >> 16) & 0xFF; + sums[3] += (col >> 24) & 0xFF; + } + } + + const uint32_t sampleCount = kSamplesPerAxis * kSamplesPerAxis; + const u32 r = (u32)((sums[0] + sampleCount / 2) / sampleCount); + const u32 g = (u32)((sums[1] + sampleCount / 2) / sampleCount); + const u32 b = (u32)((sums[2] + sampleCount / 2) / sampleCount); + const u32 a = (u32)((sums[3] + sampleCount / 2) / sampleCount); + return r | (g << 8) | (b << 16) | (a << 24); + }; int loaded = 0; for (int i = 0; i < (int)imageCount; i++) { @@ -533,10 +555,18 @@ static int LoadButtonsPNGOverrides(const Path &textureDir, const std::unordered_ fitted.scale = targetImage.scale; for (int y = 0; y < fitH; y++) { - const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; + const float srcY0 = (float)y * (float)srcH / (float)fitH; + const float srcY1 = (float)(y + 1) * (float)srcH / (float)fitH; for (int x = 0; x < fitW; x++) { - const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; - fitted.set1(offsetX + x, offsetY + y, bilinearSample(loadedImage, srcX, srcY)); + const float srcX0 = (float)x * (float)srcW / (float)fitW; + const float srcX1 = (float)(x + 1) * (float)srcW / (float)fitW; + if (srcW > fitW || srcH > fitH) { + fitted.set1(offsetX + x, offsetY + y, sampleArea(loadedImage, srcX0, srcY0, srcX1, srcY1)); + } else { + const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; + const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; + fitted.set1(offsetX + x, offsetY + y, bilinearSample(loadedImage, srcX, srcY)); + } } } From 81749196993d9e154e73ef418476cc16bd7ed0ed Mon Sep 17 00:00:00 2001 From: Yoga Dzaki Date: Thu, 28 May 2026 19:01:04 +0000 Subject: [PATCH 10/10] feat: add UI button image sharpening and downscale filter options --- Core/Config.cpp | 2 + Core/Config.h | 4 ++ UI/GameSettingsScreen.cpp | 8 +++ UI/UIAtlas.cpp | 136 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/Core/Config.cpp b/Core/Config.cpp index fe143fa11937..e0c8c5404b07 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -767,6 +767,8 @@ static const ConfigSetting graphicsSettings[] = { ConfigSetting("TexScalingLevel", SETTING(g_Config, iTexScalingLevel), 1, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("TexScalingType", SETTING(g_Config, iTexScalingType), 0, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("TexDeposterize", SETTING(g_Config, bTexDeposterize), false, CfgFlag::PER_GAME | CfgFlag::REPORT), + ConfigSetting("UIButtonSharpen", SETTING(g_Config, bUIButtonSharpen), true, CfgFlag::PER_GAME | CfgFlag::REPORT), + ConfigSetting("UIButtonDownscaleFilter", SETTING(g_Config, iUIButtonDownscaleFilter), 2, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("TexHardwareScaling", SETTING(g_Config, bTexHardwareScaling), false, CfgFlag::PER_GAME | CfgFlag::REPORT), ConfigSetting("VerticalSync", SETTING(g_Config, bVSync), true, CfgFlag::PER_GAME), ConfigSetting("LowLatencyPresent", SETTING(g_Config, bLowLatencyPresent), true, CfgFlag::PER_GAME), diff --git a/Core/Config.h b/Core/Config.h index 1f6ad2209b97..e49eb55ad043 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -337,6 +337,10 @@ struct Config : public ConfigBlock { int iTexScalingLevel; // 0 = auto, 1 = off, 2 = 2x, ..., 5 = 5x int iTexScalingType; // 0 = xBRZ, 1 = Hybrid bool bTexDeposterize; + // Sharpen downscaled UI button PNG overrides when packing into atlas. + bool bUIButtonSharpen = true; + // 0 = Linear, 1 = Bicubic, 2 = Hybrid (area sampling + optional sharpen) + int iUIButtonDownscaleFilter = 2; bool bTexHardwareScaling; int iFpsLimit1; int iFpsLimit2; diff --git a/UI/GameSettingsScreen.cpp b/UI/GameSettingsScreen.cpp index 408d60e7245d..5b6ba8a2f608 100644 --- a/UI/GameSettingsScreen.cpp +++ b/UI/GameSettingsScreen.cpp @@ -625,6 +625,14 @@ void GameSettingsScreen::CreateGraphicsSettings(UI::ViewGroup *graphicsSettings) CheckBox *smartFiltering = graphicsSettings->Add(new CheckBox(&g_Config.bSmart2DTexFiltering, gr->T("Smart 2D texture filtering"))); smartFiltering->SetDisabledPtr(&g_Config.bSoftwareRendering); + // UI button image sharpening for downscaled PNG overrides. + CheckBox *uiButtonSharpen = graphicsSettings->Add(new CheckBox(&g_Config.bUIButtonSharpen, gr->T("Sharpen downscaled UI button images"))); + uiButtonSharpen->SetDisabledPtr(&g_Config.bSoftwareRendering); + + static const char *uiDownscaleFilters[] = { "Linear", "Bicubic", "Hybrid (area+sharpen)" }; + PopupMultiChoice *uiDownscaleFilterChoice = graphicsSettings->Add(new PopupMultiChoice(&g_Config.iUIButtonDownscaleFilter, gr->T("UI button downscale filter"), uiDownscaleFilters, 2, ARRAY_SIZE(uiDownscaleFilters), I18NCat::GRAPHICS, screenManager())); + uiDownscaleFilterChoice->SetDisabledPtr(&g_Config.bSoftwareRendering); + #if PPSSPP_PLATFORM(ANDROID) || PPSSPP_PLATFORM(IOS) bool showCardboardSettings = deviceType != DEVICE_TYPE_VR; #else diff --git a/UI/UIAtlas.cpp b/UI/UIAtlas.cpp index d025a84b5c4d..097421152038 100644 --- a/UI/UIAtlas.cpp +++ b/UI/UIAtlas.cpp @@ -511,6 +511,103 @@ static int LoadButtonsPNGOverrides(const Path &textureDir, const std::unordered_ return r | (g << 8) | (b << 16) | (a << 24); }; + // Bicubic sample at fractional coordinates (Catmull-Rom style) + auto bicubicSample = [&](const Image &img, float x, float y) { + auto cubic = [](float v0, float v1, float v2, float v3, float t) { + float a0 = -0.5f*v0 + 1.5f*v1 - 1.5f*v2 + 0.5f*v3; + float a1 = v0 - 2.5f*v1 + 2.0f*v2 - 0.5f*v3; + float a2 = -0.5f*v0 + 0.5f*v2; + float a3 = v1; + return ((a0 * t + a1) * t + a2) * t + a3; + }; + const int w = img.width(); + const int h = img.height(); + float fx = std::clamp(x, 0.0f, (float)(w - 1)); + float fy = std::clamp(y, 0.0f, (float)(h - 1)); + int ix = (int)std::floor(fx); + int iy = (int)std::floor(fy); + float tx = fx - ix; + float ty = fy - iy; + int vals[4]; + int chvals[4]; + int outc[4] = {0,0,0,0}; + for (int c = 0; c < 4; c++) { + float col_y[4]; + for (int m = -1; m <= 2; m++) { + for (int n = -1; n <= 2; n++) { + int sx = std::clamp(ix + n, 0, w - 1); + int sy = std::clamp(iy + m, 0, h - 1); + u32 cc = img.get1(sx, sy); + chvals[n + 1] = (cc >> (c * 8)) & 0xFF; + } + col_y[m + 1] = cubic(chvals[0], chvals[1], chvals[2], chvals[3], tx); + } + float finalc = cubic(col_y[0], col_y[1], col_y[2], col_y[3], ty); + int ic = (int)std::lround(finalc); + outc[c] = std::clamp(ic, 0, 255); + } + return (u32)outc[0] | ((u32)outc[1] << 8) | ((u32)outc[2] << 16) | ((u32)outc[3] << 24); + }; + + // Simple unsharp mask to restore apparent sharpness after downsampling. + // amount: 0..1 roughly how strong the effect is. radius currently fixed to 1 (3x3 gaussian). + auto unsharpMask = [&](Image &img, float amount) { + if (amount <= 0.0f) return; + const int w = img.width(); + const int h = img.height(); + if (w <= 1 || h <= 1) return; + std::vector blurred((size_t)w * h); + // 3x3 gaussian kernel: 1 2 1 / 2 4 2 / 1 2 1 -> sum = 16 + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int sum[4] = {0,0,0,0}; + int ksum = 0; + for (int oy = -1; oy <= 1; oy++) { + int yy = std::clamp(y + oy, 0, h - 1); + for (int ox = -1; ox <= 1; ox++) { + int xx = std::clamp(x + ox, 0, w - 1); + int weight = 1; + if (oy == 0 && ox == 0) weight = 4; else if (oy == 0 || ox == 0) weight = 2; + const u32 c = img.get1(xx, yy); + sum[0] += (c & 0xFF) * weight; + sum[1] += ((c >> 8) & 0xFF) * weight; + sum[2] += ((c >> 16) & 0xFF) * weight; + sum[3] += ((c >> 24) & 0xFF) * weight; + ksum += weight; + } + } + const u32 r = (u32)((sum[0] + ksum/2) / ksum); + const u32 g = (u32)((sum[1] + ksum/2) / ksum); + const u32 b = (u32)((sum[2] + ksum/2) / ksum); + const u32 a = (u32)((sum[3] + ksum/2) / ksum); + blurred[y * w + x] = r | (g << 8) | (b << 16) | (a << 24); + } + } + // Apply unsharp: out = orig + amount*(orig - blurred) + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + const u32 orig = img.get1(x, y); + const u32 blur = blurred[y * w + x]; + int orc = (orig & 0xFF); + int ogc = ((orig >> 8) & 0xFF); + int obc = ((orig >> 16) & 0xFF); + int oac = ((orig >> 24) & 0xFF); + int brc = (blur & 0xFF); + int bgc = ((blur >> 8) & 0xFF); + int bbc = ((blur >> 16) & 0xFF); + int bac = ((blur >> 24) & 0xFF); + int nrc = orc + (int)std::lround((orc - brc) * amount); + int ngc = ogc + (int)std::lround((ogc - bgc) * amount); + int nbc = obc + (int)std::lround((obc - bbc) * amount); + int nac = oac; // keep alpha as original + nrc = std::clamp(nrc, 0, 255); + ngc = std::clamp(ngc, 0, 255); + nbc = std::clamp(nbc, 0, 255); + img.set1(x, y, (u32)nrc | ((u32)ngc << 8) | ((u32)nbc << 16) | ((u32)nac << 24)); + } + } + }; + int loaded = 0; for (int i = 0; i < (int)imageCount; i++) { std::string pngName = PNGNameFromID(imageIDs[i].id); @@ -560,17 +657,40 @@ static int LoadButtonsPNGOverrides(const Path &textureDir, const std::unordered_ for (int x = 0; x < fitW; x++) { const float srcX0 = (float)x * (float)srcW / (float)fitW; const float srcX1 = (float)(x + 1) * (float)srcW / (float)fitW; - if (srcW > fitW || srcH > fitH) { - fitted.set1(offsetX + x, offsetY + y, sampleArea(loadedImage, srcX0, srcY0, srcX1, srcY1)); - } else { - const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; - const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; - fitted.set1(offsetX + x, offsetY + y, bilinearSample(loadedImage, srcX, srcY)); - } + if (srcW > fitW || srcH > fitH) { + // Downscaling: choose algorithm from config + switch (g_Config.iUIButtonDownscaleFilter) { + case 0: { // Linear (bilinear sample at pixel center) + const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; + const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; + fitted.set1(offsetX + x, offsetY + y, bilinearSample(loadedImage, srcX, srcY)); + break; + } + case 1: { // Bicubic + const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; + const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; + fitted.set1(offsetX + x, offsetY + y, bicubicSample(loadedImage, srcX, srcY)); + break; + } + case 2: // Hybrid: area sampling + default: + fitted.set1(offsetX + x, offsetY + y, sampleArea(loadedImage, srcX0, srcY0, srcX1, srcY1)); + break; + } + } else { + const float srcX = ((float)x + 0.5f) * (float)srcW / (float)fitW - 0.5f; + const float srcY = ((float)y + 0.5f) * (float)srcH / (float)fitH - 0.5f; + fitted.set1(offsetX + x, offsetY + y, bilinearSample(loadedImage, srcX, srcY)); + } } } + bool wasDownscaled = (srcW > fitW || srcH > fitH); loadedImage = std::move(fitted); + if (wasDownscaled && g_Config.bUIButtonSharpen) { + // Subtle sharpening to counteract downsample blur. + unsharpMask(loadedImage, 0.6f); + } } loadedImage.ConvertToPremultipliedAlpha(); @@ -753,7 +873,9 @@ Draw::Texture *GenerateUIAtlas(Draw::DrawContext *draw, Atlas *atlas, float dpiS desc.width = g_cachedUIAtlasImage.width(); desc.height = g_cachedUIAtlasImage.height(); desc.depth = 1; + // Let the backend generate mipmaps for better sampling at different scales. desc.mipLevels = 1; + desc.generateMips = true; desc.format = Draw::DataFormat::R8G8B8A8_UNORM; desc.type = Draw::TextureType::LINEAR2D; desc.initData.push_back((const u8 *)g_cachedUIAtlasImage.data());