diff --git a/Core/Config.cpp b/Core/Config.cpp index ab7a631017..8818631e60 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -685,6 +685,7 @@ static const ConfigSetting displayLayoutSettings[] = { ConfigSetting("DisplayIntegerScale", SETTING(g_Config.displayLayoutLandscape, bDisplayIntegerScale), CfgFlag::PER_GAME), ConfigSetting("DisplayAspectRatio", SETTING(g_Config.displayLayoutLandscape, fDisplayAspectRatio), CfgFlag::PER_GAME), ConfigSetting("IgnoreScreenInsets", SETTING(g_Config.displayLayoutLandscape, bIgnoreScreenInsets), CfgFlag::PER_GAME), + ConfigSetting("Overlay", SETTING(g_Config.displayLayoutLandscape, sOverlay), "", CfgFlag::PER_GAME), ConfigSetting("InternalScreenRotation", SETTING(g_Config.displayLayoutLandscape, iInternalScreenRotation), CfgFlag::PER_GAME), ConfigSetting("RotateControlsWithScreen", SETTING(g_Config.displayLayoutLandscape, bRotateControlsWithScreen), CfgFlag::PER_GAME), ConfigSetting("EnableCardboardVR", SETTING(g_Config.displayLayoutLandscape, bEnableCardboardVR), CfgFlag::PER_GAME), diff --git a/Core/Config.h b/Core/Config.h index fcc38aef28..0e3f7b35fa 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -85,6 +85,7 @@ struct DisplayLayoutConfig : public ConfigBlock { int iInternalScreenRotation = ROTATION_LOCKED_HORIZONTAL; // The internal screen rotation angle. Useful for vertical SHMUPs and similar. bool bRotateControlsWithScreen = true; // Rotate gamepad controls along with the internal screen rotation. bool bIgnoreScreenInsets = true; // Android: Center screen disregarding insets if this is enabled. + std::string sOverlay; // Overlay (bezel) image id, see GPU/Common/Overlay.h. Empty means off. // Deprecated bool bEnableCardboardVR = false; // Cardboard Master Switch diff --git a/Core/Util/PathUtil.cpp b/Core/Util/PathUtil.cpp index ee9ab53ae6..47d4818596 100644 --- a/Core/Util/PathUtil.cpp +++ b/Core/Util/PathUtil.cpp @@ -141,6 +141,8 @@ Path GetSysDirectory(PSPDirectories directoryType) { return PreferUpperCaseDir(pspDirectory, "SHADERS", "shaders"); case DIRECTORY_CUSTOM_THEMES: return PreferUpperCaseDir(pspDirectory, "THEMES", "themes"); + case DIRECTORY_CUSTOM_OVERLAYS: + return PreferUpperCaseDir(pspDirectory, "OVERLAYS", "overlays"); case DIRECTORY_NAND: return pspDirectory / "NAND"; diff --git a/Core/Util/PathUtil.h b/Core/Util/PathUtil.h index 2d5cf12ad6..0c969b8d4e 100644 --- a/Core/Util/PathUtil.h +++ b/Core/Util/PathUtil.h @@ -27,6 +27,7 @@ enum PSPDirectories { DIRECTORY_EXDATA, DIRECTORY_CUSTOM_SHADERS, DIRECTORY_CUSTOM_THEMES, + DIRECTORY_CUSTOM_OVERLAYS, COUNT, }; diff --git a/GPU/CMakeLists.txt b/GPU/CMakeLists.txt index 111b4f2503..7a23a6dc9a 100644 --- a/GPU/CMakeLists.txt +++ b/GPU/CMakeLists.txt @@ -114,6 +114,8 @@ set(GPU_SOURCES Common/GPUStateUtils.h Common/DrawEngineCommon.cpp Common/DrawEngineCommon.h + Common/Overlay.cpp + Common/Overlay.h Common/PresentationCommon.cpp Common/PresentationCommon.h Common/ReinterpretFramebuffer.cpp diff --git a/GPU/Common/Overlay.cpp b/GPU/Common/Overlay.cpp new file mode 100644 index 0000000000..734c594b96 --- /dev/null +++ b/GPU/Common/Overlay.cpp @@ -0,0 +1,332 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program 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, version 2.0 or later versions. + +// This program 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 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#include +#include +#include +#include +#include + +#include "Common/Data/Format/IniFile.h" +#include "Common/Data/Format/PNGLoad.h" +#include "Common/File/DirListing.h" +#include "Common/File/FileUtil.h" +#include "Common/Log.h" +#include "Common/StringUtils.h" +#include "Common/VR/PPSSPPVR.h" +#include "Core/Config.h" +#include "Core/Util/PathUtil.h" +#include "GPU/Common/Overlay.h" + +// Overlays are full screen images, no need to go beyond what a 4K display needs. +// Also keeps us within the max texture size of most mobile GPUs. +static constexpr int MAX_OVERLAY_DIMENSION = 4096; +// Pixels with alpha above this count as the overlay's border when searching for the hole. +// Not 0, since many bezels have soft edges or a light glare over the screen area. +static constexpr uint8_t HOLE_ALPHA_THRESHOLD = 250; +static constexpr int MIN_HOLE_SIZE = 16; + +static std::mutex g_overlayLock; +static std::vector g_overlayInfo; +static bool g_overlayInfoLoaded = false; + +// The currently loaded overlay. +static std::string g_activeId; +static bool g_activeFailed = false; +static OverlayInfo g_activeInfo; +static std::shared_ptr g_activeImage; +static bool g_activeHasHole = false; +static int g_activeHole[4]{}; +static uint32_t g_generation = 0; + +bool DetectOverlayHole(const uint8_t *rgba, int width, int height, int *x, int *y, int *w, int *h) { + if (!rgba || width <= 0 || height <= 0) + return false; + + auto isBorder = [&](int px, int py) { + return rgba[((size_t)py * width + px) * 4 + 3] > HOLE_ALPHA_THRESHOLD; + }; + + const int cx = width / 2; + const int cy = height / 2; + if (isBorder(cx, cy)) + return false; + + int left = cx; + while (left > 0 && !isBorder(left - 1, cy)) + left--; + int right = cx; + while (right < width - 1 && !isBorder(right + 1, cy)) + right++; + int top = cy; + while (top > 0 && !isBorder(cx, top - 1)) + top--; + int bottom = cy; + while (bottom < height - 1 && !isBorder(cx, bottom + 1)) + bottom++; + + const int hw = right - left + 1; + const int hh = bottom - top + 1; + if (hw < MIN_HOLE_SIZE || hh < MIN_HOLE_SIZE) + return false; + + *x = left; + *y = top; + *w = hw; + *h = hh; + return true; +} + +static bool ParseHole(const std::string &str, OverlayInfo *info) { + int x, y, w, h; + if (sscanf(str.c_str(), "%d , %d , %d , %d", &x, &y, &w, &h) != 4 || w <= 0 || h <= 0 || x < 0 || y < 0) + return false; + info->holeFromIni = true; + info->holeX = x; + info->holeY = y; + info->holeW = w; + info->holeH = h; + return true; +} + +static void LoadOverlayIni(const Path &dir, const std::string &dirName, std::vector *out) { + IniFile ini; + if (!ini.Load(dir / "overlay.ini")) + return; + + for (auto §ionPtr : ini.Sections()) { + const Section §ion = *sectionPtr; + std::string image; + if (section.name().empty() || !section.Get("Image", &image) || image.empty()) + continue; + if (HasPathTraversal(image)) { + WARN_LOG(Log::G3D, "Overlay '%s/%s': ignoring image name '%s'", dirName.c_str(), std::string(section.name()).c_str(), image.c_str()); + continue; + } + + OverlayInfo info; + info.id = dirName + "/" + std::string(section.name()); + info.name = std::string(section.name()); + section.Get("Name", &info.name); + info.imagePath = dir / image; + + std::string layer; + if (section.Get("Layer", &layer) && equalsNoCase(layer, "Background")) + info.layer = OverlayLayer::Background; + + std::string hole; + if (section.Get("Hole", &hole) && !hole.empty() && !ParseHole(hole, &info)) { + WARN_LOG(Log::G3D, "Overlay '%s': bad Hole '%s', expected x,y,w,h. Will auto-detect.", info.id.c_str(), hole.c_str()); + } + out->push_back(info); + } +} + +static void ScanOverlays(std::vector *out) { + out->clear(); + const Path root = GetSysDirectory(DIRECTORY_CUSTOM_OVERLAYS); + std::vector files; + File::GetFilesInDir(root, &files); + + for (const auto &file : files) { + if (file.isDirectory) { + LoadOverlayIni(file.fullName, file.name, out); + } else if (endsWithNoCase(file.name, ".png")) { + OverlayInfo info; + info.id = file.name; + info.name = file.name.substr(0, file.name.size() - 4); + info.imagePath = file.fullName; + out->push_back(info); + } + } + + std::sort(out->begin(), out->end(), [](const OverlayInfo &a, const OverlayInfo &b) { + return a.name < b.name; + }); + INFO_LOG(Log::G3D, "Found %d overlays in %s", (int)out->size(), root.c_str()); +} + +static void UnloadActiveLocked() { + g_activeId.clear(); + g_activeFailed = false; + g_activeImage.reset(); + g_activeHasHole = false; + g_generation++; +} + +void ReloadAllOverlayInfo() { + std::lock_guard guard(g_overlayLock); + ScanOverlays(&g_overlayInfo); + g_overlayInfoLoaded = true; + UnloadActiveLocked(); +} + +std::vector GetAllOverlayInfo() { + std::lock_guard guard(g_overlayLock); + if (!g_overlayInfoLoaded) { + ScanOverlays(&g_overlayInfo); + g_overlayInfoLoaded = true; + } + return g_overlayInfo; +} + +bool ImportOverlayImage(const Path &source, std::string *id) { + // Check the format by content, since content URIs may not have a usable extension. + FILE *f = File::OpenCFile(source, "rb"); + uint8_t magic[8]{}; + const bool readOk = f && fread(magic, 1, sizeof(magic), f) == sizeof(magic); + if (f) + fclose(f); + static const uint8_t pngMagic[8] = { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n' }; + if (!readOk || memcmp(magic, pngMagic, sizeof(magic)) != 0) { + WARN_LOG(Log::G3D, "Overlay import: %s is not a PNG", source.c_str()); + return false; + } + + const Path root = GetSysDirectory(DIRECTORY_CUSTOM_OVERLAYS); + File::CreateFullPath(root); + + std::string base = source.GetFilename(); + if (endsWithNoCase(base, ".png")) + base = base.substr(0, base.size() - 4); + if (!IsSafePathComponent(base) || base.empty()) + base = "overlay"; + + std::string filename = base + ".png"; + for (int i = 2; File::Exists(root / filename); i++) { + filename = StringFromFormat("%s_%d.png", base.c_str(), i); + } + + if (!File::Copy(source, root / filename)) { + ERROR_LOG(Log::G3D, "Overlay import: failed to copy %s to %s", source.c_str(), (root / filename).c_str()); + return false; + } + INFO_LOG(Log::G3D, "Imported overlay %s as %s", source.c_str(), filename.c_str()); + ReloadAllOverlayInfo(); + *id = filename; + return true; +} + +// Called with the lock held. Sets g_activeFailed on failure. +static void LoadActiveLocked(const std::string &id) { + UnloadActiveLocked(); + g_activeId = id; + + if (!g_overlayInfoLoaded) { + ScanOverlays(&g_overlayInfo); + g_overlayInfoLoaded = true; + } + + auto iter = std::find_if(g_overlayInfo.begin(), g_overlayInfo.end(), [&](const OverlayInfo &info) { return info.id == id; }); + if (iter == g_overlayInfo.end()) { + WARN_LOG(Log::G3D, "Overlay '%s' not found", id.c_str()); + g_activeFailed = true; + return; + } + g_activeInfo = *iter; + + std::string data; + if (!File::ReadBinaryFileToString(g_activeInfo.imagePath, &data)) { + ERROR_LOG(Log::G3D, "Overlay '%s': failed to read %s", id.c_str(), g_activeInfo.imagePath.c_str()); + g_activeFailed = true; + return; + } + + int width = 0, height = 0; + unsigned char *pixels = nullptr; + if (pngLoadPtr((const unsigned char *)data.data(), data.size(), &width, &height, &pixels, MAX_OVERLAY_DIMENSION, MAX_OVERLAY_DIMENSION) != 1 || !pixels) { + ERROR_LOG(Log::G3D, "Overlay '%s': failed to decode %s (PNG, max %dx%d)", id.c_str(), g_activeInfo.imagePath.c_str(), MAX_OVERLAY_DIMENSION, MAX_OVERLAY_DIMENSION); + free(pixels); + g_activeFailed = true; + return; + } + + auto image = std::make_shared(); + image->width = width; + image->height = height; + image->rgba.assign(pixels, pixels + (size_t)width * height * 4); + free(pixels); + + if (g_activeInfo.holeFromIni) { + // Clamp to the image. + int x0 = std::clamp(g_activeInfo.holeX, 0, width); + int y0 = std::clamp(g_activeInfo.holeY, 0, height); + int x1 = std::clamp(g_activeInfo.holeX + g_activeInfo.holeW, 0, width); + int y1 = std::clamp(g_activeInfo.holeY + g_activeInfo.holeH, 0, height); + g_activeHasHole = x1 - x0 >= MIN_HOLE_SIZE && y1 - y0 >= MIN_HOLE_SIZE; + g_activeHole[0] = x0; + g_activeHole[1] = y0; + g_activeHole[2] = x1 - x0; + g_activeHole[3] = y1 - y0; + } else { + g_activeHasHole = DetectOverlayHole(image->rgba.data(), width, height, &g_activeHole[0], &g_activeHole[1], &g_activeHole[2], &g_activeHole[3]); + } + + if (g_activeHasHole) { + INFO_LOG(Log::G3D, "Overlay '%s': %dx%d, hole at %d,%d size %dx%d%s", id.c_str(), width, height, + g_activeHole[0], g_activeHole[1], g_activeHole[2], g_activeHole[3], g_activeInfo.holeFromIni ? "" : " (detected)"); + } else { + WARN_LOG(Log::G3D, "Overlay '%s': %dx%d, no hole found, the game keeps its normal position. Set Hole=x,y,w,h in overlay.ini to place it.", id.c_str(), width, height); + } + + g_activeImage = image; +} + +bool GetOverlayLayout(const DisplayLayoutConfig &config, float frameX, float frameY, float frameW, float frameH, OverlayLayout *layout) { + if (config.sOverlay.empty() || config.bEnableCardboardVR || IsVREnabled() || frameW <= 0.0f || frameH <= 0.0f) + return false; + // With skip buffer effects, the game draws straight to the backbuffer and the presentation + // code that draws the overlay doesn't run. Keep the normal layout then, rather than a game in a + // hole with nothing around it. The software renderer always presents, so it's fine there. + if (g_Config.bSkipBufferEffects && !g_Config.bSoftwareRendering) + return false; + + std::lock_guard guard(g_overlayLock); + if (config.sOverlay != g_activeId) { + LoadActiveLocked(config.sOverlay); + } + if (g_activeFailed || !g_activeImage) + return false; + + const float imgW = (float)g_activeImage->width; + const float imgH = (float)g_activeImage->height; + + // Fit the overlay to the frame, keeping the aspect ratio, centered. + const float scale = std::min(frameW / imgW, frameH / imgH); + layout->overlayW = imgW * scale; + layout->overlayH = imgH * scale; + layout->overlayX = frameX + (frameW - layout->overlayW) * 0.5f; + layout->overlayY = frameY + (frameH - layout->overlayH) * 0.5f; + + layout->layer = g_activeInfo.layer; + layout->hasHole = g_activeHasHole; + if (g_activeHasHole) { + layout->holeX = layout->overlayX + g_activeHole[0] * scale; + layout->holeY = layout->overlayY + g_activeHole[1] * scale; + layout->holeW = g_activeHole[2] * scale; + layout->holeH = g_activeHole[3] * scale; + } + layout->generation = g_generation; + return true; +} + +std::shared_ptr GetOverlayImage(uint32_t generation) { + std::lock_guard guard(g_overlayLock); + if (generation != g_generation) + return nullptr; + return g_activeImage; +} diff --git a/GPU/Common/Overlay.h b/GPU/Common/Overlay.h new file mode 100644 index 0000000000..ed44d033c6 --- /dev/null +++ b/GPU/Common/Overlay.h @@ -0,0 +1,108 @@ +// Copyright (c) 2026- PPSSPP Project. + +// This program 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, version 2.0 or later versions. + +// This program 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 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +// Overlays (bezels): an image drawn around (or behind) the emulated screen, filling the +// black bars on displays whose aspect ratio doesn't match the PSP's. +// +// Overlays live in memstick/PSP/OVERLAYS (or overlays): +// +// - Any PNG directly in that folder is an overlay on its own. It's drawn in front of the game, +// and the game is placed in its transparent hole, which is found automatically. +// Existing RetroArch overlay PNGs work as-is this way. "Load overlay image..." in the display +// layout screen copies any picked PNG into this folder, so no file management is needed. +// +// - A subfolder with an overlay.ini can define one or more overlays, one per section: +// +// [RP2S_Digimon] +// Name=Retroid Pocket 2S - Digimon +// Image=rp2s_640x480.png +// Layer=Foreground ; or Background (drawn behind the game) +// Hole=0,0,640,362 ; optional, in image pixels. Auto-detected if missing. +// +// Placement: the overlay is fit to the screen (aspect preserved, centered). The hole is mapped +// to the screen with the same scale and offset, and the PSP screen is then fit into the hole +// using the normal display layout options (stretch, integer scale, aspect ratio). +// The display offset and scale options are ignored while an overlay with a hole is active. + +#pragma once + +#include +#include +#include +#include +#include + +#include "Common/File/Path.h" + +struct DisplayLayoutConfig; +struct FRect; + +enum class OverlayLayer { + Foreground, + Background, +}; + +struct OverlayInfo { + std::string id; // What gets stored in the config. "file.png" or "folder/section". + std::string name; // Display name. + Path imagePath; + OverlayLayer layer = OverlayLayer::Foreground; + // Hole from the ini, in image pixels. If not set, it's auto-detected. + bool holeFromIni = false; + int holeX = 0; + int holeY = 0; + int holeW = 0; + int holeH = 0; +}; + +// The decoded image of the active overlay, for uploading to the GPU. +struct OverlayImage { + int width = 0; + int height = 0; + std::vector rgba; // width * height * 4 bytes. +}; + +// Where things go on screen, for the active overlay. +struct OverlayLayout { + OverlayLayer layer = OverlayLayer::Foreground; + float overlayX = 0.0f, overlayY = 0.0f, overlayW = 0.0f, overlayH = 0.0f; // Overlay image, in output pixels. + bool hasHole = false; + float holeX = 0.0f, holeY = 0.0f, holeW = 0.0f, holeH = 0.0f; // The hole, in output pixels. + // Changes whenever a different image gets loaded, so the presentation code knows to re-upload. + uint32_t generation = 0; +}; + +// Rescans the overlays folder. Also unloads the active overlay, so it gets reloaded from disk. +void ReloadAllOverlayInfo(); +std::vector GetAllOverlayInfo(); + +// The "just pick an image" flow: copies a PNG from anywhere (including Android content URIs) into the +// overlays folder and rescans. On success, *id is what to put in DisplayLayoutConfig::sOverlay. +// Such overlays are always foreground with an auto-detected hole, no ini needed. +bool ImportOverlayImage(const Path &source, std::string *id); + +// Returns false if no overlay is selected in this config, it failed to load, or overlays can't +// be used right now (VR, Cardboard). Cheap to call every frame; loads the image on first use. +bool GetOverlayLayout(const DisplayLayoutConfig &config, float frameX, float frameY, float frameW, float frameH, OverlayLayout *layout); + +// The decoded image for the given generation, or null if it's no longer the active one. +std::shared_ptr GetOverlayImage(uint32_t generation); + +// Finds the transparent hole by walking from the image center towards each edge, stopping at +// the first nearly-opaque pixel. Returns false if the center itself is opaque, or the hole is tiny. +// Exposed for unit tests. +bool DetectOverlayHole(const uint8_t *rgba, int width, int height, int *x, int *y, int *w, int *h); diff --git a/GPU/Common/PresentationCommon.cpp b/GPU/Common/PresentationCommon.cpp index d541b43c01..5ef649757d 100644 --- a/GPU/Common/PresentationCommon.cpp +++ b/GPU/Common/PresentationCommon.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #include "Common/GPU/thin3d.h" #include "Common/System/Display.h" @@ -33,6 +35,7 @@ #include "Core/ConfigValues.h" #include "Core/System.h" #include "Core/HW/Display.h" +#include "GPU/Common/Overlay.h" #include "GPU/Common/PostShader.h" #include "GPU/Common/PresentationCommon.h" #include "GPU/GPUState.h" @@ -92,7 +95,7 @@ FRect GetScreenFrame(bool ignoreScreenInsets, float pixelWidth, float pixelHeigh return rc; } -void CalculateDisplayOutputRect(const DisplayLayoutConfig &config, FRect *rc, float origW, float origH, const FRect &frame, int rotation) { +void CalculateDisplayOutputRect(const DisplayLayoutConfig &config, FRect *rc, float origW, float origH, const FRect &screenFrame, int rotation) { float outW; float outH; @@ -104,6 +107,18 @@ void CalculateDisplayOutputRect(const DisplayLayoutConfig &config, FRect *rc, fl float offsetY = config.fDisplayOffsetY; float scale = config.fDisplayScale; + + // With an overlay that has a hole, the overlay is fit to the screen and the game is fit into + // the hole instead. The offset and scale options don't apply then, the overlay decides those. + FRect frame = screenFrame; + OverlayLayout overlay; + const bool inOverlayHole = GetOverlayLayout(config, screenFrame.x, screenFrame.y, screenFrame.w, screenFrame.h, &overlay) && overlay.hasHole; + if (inOverlayHole) { + frame = FRect{ overlay.holeX, overlay.holeY, overlay.holeW, overlay.holeH }; + offsetX = 0.5f; + offsetY = 0.5f; + scale = 1.0f; + } float aspectRatioAdjust = config.fDisplayAspectRatio; float origRatio = !rotated ? origW / origH : origH / origW; @@ -136,7 +151,7 @@ void CalculateDisplayOutputRect(const DisplayLayoutConfig &config, FRect *rc, fl // Ye olde 1080p hack: If everything is setup to exactly cover the screen (defaults), and the screen display aspect ratio is 16:9, // cut off one line from the top and bottom. - if (scale == 1.0f && aspectRatioAdjust == 1.0f && offsetX == 0.5f && offsetY == 0.5f && g_Config.bDisplayCropTo16x9) { + if (!inOverlayHole && scale == 1.0f && aspectRatioAdjust == 1.0f && offsetX == 0.5f && offsetY == 0.5f && g_Config.bDisplayCropTo16x9) { if (fabsf(frame.w / frame.h - 16.0f / 9.0f) < 0.0001f) { outW *= 272.0f / 270.0f; outH *= 272.0f / 270.0f; @@ -173,6 +188,14 @@ void CalculateDisplayOutputRect(const DisplayLayoutConfig &config, FRect *rc, fl rc->h = floorf(frame.h); outW = frame.w; outH = frame.h; + } else if (inOverlayHole && overlay.layer == OverlayLayer::Foreground) { + // Round outwards, so no black seam shows between the game and the overlay's border. + const float x0 = frame.x + frame.w * offsetX - outW * 0.5f; + const float y0 = frame.y + frame.h * offsetY - outH * 0.5f; + rc->x = floorf(x0); + rc->y = floorf(y0); + rc->w = ceilf(x0 + outW) - rc->x; + rc->h = ceilf(y0 + outH) - rc->y; } else { rc->x = floorf(frame.x + frame.w * offsetX - outW * 0.5f); rc->y = floorf(frame.y + frame.h * offsetY - outH * 0.5f); @@ -491,7 +514,7 @@ void PresentationCommon::DeviceRestore(Draw::DrawContext *draw) { CreateDeviceObjects(); } -Draw::Pipeline *PresentationCommon::CreatePipeline(std::vector shaders, bool postShader, const UniformBufferDesc *uniformDesc) const { +Draw::Pipeline *PresentationCommon::CreatePipeline(std::vector shaders, bool postShader, const UniformBufferDesc *uniformDesc, bool alphaBlend) const { using namespace Draw; Semantic pos = SEM_POSITION; @@ -514,7 +537,9 @@ Draw::Pipeline *PresentationCommon::CreatePipeline(std::vectorCreateInputLayout(inputDesc); DepthStencilState *depth = draw_->CreateDepthStencilState({ false, false, Comparison::LESS }); - BlendState *blendstateOff = draw_->CreateBlendState({ false, 0xF }); + BlendState *blendstateOff = alphaBlend ? + draw_->CreateBlendState({ true, 0xF, BlendFactor::SRC_ALPHA, BlendFactor::ONE_MINUS_SRC_ALPHA, BlendOp::ADD, BlendFactor::ONE, BlendFactor::ONE_MINUS_SRC_ALPHA, BlendOp::ADD }) : + draw_->CreateBlendState({ false, 0xF }); RasterState *rasterNoCull = draw_->CreateRasterState({}); PipelineDesc pipelineDesc{ Primitive::TRIANGLE_STRIP, shaders, inputLayout, depth, blendstateOff, rasterNoCull, uniformDesc }; @@ -542,6 +567,8 @@ void PresentationCommon::CreateDeviceObjects() { texColor_ = CreatePipeline({ draw_->GetVshaderPreset(VS_TEXTURE_COLOR_2D), draw_->GetFshaderPreset(FS_TEXTURE_COLOR_2D) }, false, &vsTexColBufDesc); texColorRBSwizzle_ = CreatePipeline({ draw_->GetVshaderPreset(VS_TEXTURE_COLOR_2D), draw_->GetFshaderPreset(FS_TEXTURE_COLOR_2D_RB_SWIZZLE) }, false, &vsTexColBufDesc); + texColorBlend_ = CreatePipeline({ draw_->GetVshaderPreset(VS_TEXTURE_COLOR_2D), draw_->GetFshaderPreset(FS_TEXTURE_COLOR_2D) }, false, &vsTexColBufDesc, true); + overlayVdata_ = draw_->CreateBuffer(sizeof(Vertex) * 4, BufferUsageFlag::DYNAMIC | BufferUsageFlag::VERTEXDATA); restorePostShader_ = false; } @@ -567,6 +594,10 @@ static void DoReleaseVector(std::vector &list) { void PresentationCommon::DestroyDeviceObjects() { DoRelease(texColor_); DoRelease(texColorRBSwizzle_); + DoRelease(texColorBlend_); + DoRelease(overlayVdata_); + DoRelease(overlayTexture_); + overlayGeneration_ = 0xFFFFFFFF; DoRelease(samplerNearest_); DoRelease(samplerLinear_); DoRelease(vdata_); @@ -900,6 +931,74 @@ void PresentationCommon::RunPostshaderPasses(const DisplayLayoutConfig &config, } } +// Uploads the active overlay image if it changed. Returns false if there's nothing to draw. +bool PresentationCommon::PrepareOverlayTexture(const OverlayLayout &layout) { + if (overlayTexture_ && overlayGeneration_ == layout.generation) + return true; + + DoRelease(overlayTexture_); + overlayGeneration_ = layout.generation; + + std::shared_ptr image = GetOverlayImage(layout.generation); + if (!image || image->width <= 0 || image->height <= 0) { + // Changed under us, we'll get it next frame. Retry then. + overlayGeneration_ = 0xFFFFFFFF; + return false; + } + + Draw::TextureDesc desc{}; + desc.type = Draw::TextureType::LINEAR2D; + desc.format = Draw::DataFormat::R8G8B8A8_UNORM; + desc.width = image->width; + desc.height = image->height; + desc.depth = 1; + desc.mipLevels = 1; + desc.generateMips = false; + desc.tag = "overlay"; + desc.initData.push_back(image->rgba.data()); + overlayTexture_ = draw_->CreateTexture(desc); + if (!overlayTexture_) { + ERROR_LOG(Log::G3D, "Failed to create overlay texture (%dx%d)", image->width, image->height); + return false; + } + return true; +} + +void PresentationCommon::DrawOverlay(const OverlayLayout &layout) { + const float x0 = layout.overlayX, y0 = layout.overlayY; + const float x1 = x0 + layout.overlayW, y1 = y0 + layout.overlayH; + Vertex verts[4] = { + { x0, y0, 0, 0.0f, 0.0f, 0xFFFFFFFF }, // TL + { x1, y0, 0, 1.0f, 0.0f, 0xFFFFFFFF }, // TR + { x0, y1, 0, 0.0f, 1.0f, 0xFFFFFFFF }, // BL + { x1, y1, 0, 1.0f, 1.0f, 0xFFFFFFFF }, // BR + }; + // Same transform as the game quad, see RunPostshaderPasses. + const bool flipped = draw_->GetDeviceCaps().coordConvention == CoordConvention::OpenGL; + const float invDestW = 2.0f / pixelWidth_; + const float invDestH = 2.0f / pixelHeight_; + for (auto &v : verts) { + v.x = v.x * invDestW - 1.0f; + v.y = v.y * invDestH - 1.0f; + if (flipped) + v.y = -v.y; + } + draw_->UpdateBuffer(overlayVdata_, (const uint8_t *)verts, 0, sizeof(verts), Draw::UPDATE_DISCARD); + + draw_->BindPipeline(texColorBlend_); + Draw::VsTexColUB ub{}; + memcpy(ub.WorldViewProj, g_display.rot_matrix.m, sizeof(float) * 16); + draw_->UpdateDynamicUniformBuffer(&ub, sizeof(ub)); + draw_->BindTexture(0, overlayTexture_); + draw_->BindSamplerStates(0, 1, &samplerLinear_); + draw_->BindVertexBuffer(overlayVdata_, 0); + + Draw::Viewport viewport{ 0.0f, 0.0f, (float)pixelWidth_, (float)pixelHeight_, 0.0f, 1.0f }; + draw_->SetViewport(viewport); + draw_->SetScissorRect(0, 0, pixelWidth_, pixelHeight_); + draw_->Draw(4, 0); +} + void PresentationCommon::CopyToOutput(const DisplayLayoutConfig &config) { bool useNearest = outputFlags_ & OutputFlags::NEAREST; bool useStereo = gstate_c.Use(GPU_USE_SIMPLE_STEREO_PERSPECTIVE) && stereoPipeline_ != nullptr; // TODO: Also check that the backend has support for it. @@ -911,12 +1010,27 @@ void PresentationCommon::CopyToOutput(const DisplayLayoutConfig &config) { draw_->SetScissorRect(0, 0, pixelWidth_, pixelHeight_); + OverlayLayout overlay; + bool drawOverlay = false; + if (!(outputFlags_ & OutputFlags::NATIVE_SIZE)) { + const FRect frame = GetScreenFrame(config.bIgnoreScreenInsets, (float)pixelWidth_, (float)pixelHeight_); + drawOverlay = GetOverlayLayout(config, frame.x, frame.y, frame.w, frame.h, &overlay) && PrepareOverlayTexture(overlay); + } + if (!srcFramebuffer_ && !srcTexture_) { - // Bound blank. We're done (although we could draw a black rectangle here). + // Bound blank. Keep the overlay up though, so it doesn't flicker during loading and such. + if (drawOverlay) { + DrawOverlay(overlay); + draw_->Invalidate(InvalidationFlags::CACHED_RENDER_STATE); + } presentedThisFrame_ = true; return; } + if (drawOverlay && overlay.layer == OverlayLayer::Background) { + DrawOverlay(overlay); + } + Draw::Pipeline *pipeline = (outputFlags_ & OutputFlags::RB_SWIZZLE) ? texColorRBSwizzle_ : texColor_; if (useStereo) { @@ -983,6 +1097,10 @@ void PresentationCommon::CopyToOutput(const DisplayLayoutConfig &config) { draw_->Draw(4, 0); } + if (drawOverlay && overlay.layer == OverlayLayer::Foreground) { + DrawOverlay(overlay); + } + DoRelease(srcFramebuffer_); DoRelease(srcTexture_); diff --git a/GPU/Common/PresentationCommon.h b/GPU/Common/PresentationCommon.h index 866ca2a787..e6f889b356 100644 --- a/GPU/Common/PresentationCommon.h +++ b/GPU/Common/PresentationCommon.h @@ -66,6 +66,7 @@ class Texture; } struct ShaderInfo; +struct OverlayLayout; class TextureCacheCommon; enum class OutputFlags { @@ -148,13 +149,16 @@ protected: static void ShowPostShaderError(const std::string &errorString); Draw::ShaderModule *CompileShaderModule(ShaderStage stage, ShaderLanguage lang, const std::string &src, std::string *errorString) const; - Draw::Pipeline *CreatePipeline(std::vector shaders, bool postShader, const UniformBufferDesc *uniformDesc) const; + Draw::Pipeline *CreatePipeline(std::vector shaders, bool postShader, const UniformBufferDesc *uniformDesc, bool alphaBlend = false) const; bool CompilePostShader(const ShaderInfo *shaderInfo, Draw::Pipeline **outPipeline) const; bool BuildPostShader(const DisplayLayoutConfig &config, const ShaderInfo *shaderInfo, const ShaderInfo *next, Draw::Pipeline **outPipeline); bool AllocateFramebuffer(int w, int h); bool BindSource(int binding, bool bindStereo); + bool PrepareOverlayTexture(const OverlayLayout &layout); + void DrawOverlay(const OverlayLayout &layout); + void GetCardboardSettings(const DisplayLayoutConfig &config, CardboardSettings *cardboardSettings) const; void CalculatePostShaderUniforms(int bufferWidth, int bufferHeight, int targetWidth, int targetHeight, const ShaderInfo *shaderInfo, PostShaderUniforms *uniforms) const; @@ -165,6 +169,12 @@ protected: Draw::SamplerState *samplerLinear_ = nullptr; Draw::Buffer *vdata_ = nullptr; + // Overlay (bezel) drawn around or behind the game, see Overlay.h. + Draw::Pipeline *texColorBlend_ = nullptr; + Draw::Buffer *overlayVdata_ = nullptr; + Draw::Texture *overlayTexture_ = nullptr; + uint32_t overlayGeneration_ = 0xFFFFFFFF; + std::vector postShaderPipelines_; std::vector postShaderFramebuffers_; std::vector postShaderInfo_; diff --git a/GPU/GPU.vcxproj b/GPU/GPU.vcxproj index 760262b184..1893cc240b 100644 --- a/GPU/GPU.vcxproj +++ b/GPU/GPU.vcxproj @@ -271,6 +271,7 @@ + @@ -378,6 +379,7 @@ + diff --git a/GPU/GPU.vcxproj.filters b/GPU/GPU.vcxproj.filters index 50c96af50e..0bd8fff958 100644 --- a/GPU/GPU.vcxproj.filters +++ b/GPU/GPU.vcxproj.filters @@ -213,6 +213,9 @@ Software + + Common + Common @@ -473,6 +476,9 @@ Software + + Common + Common diff --git a/UI/DisplayLayoutScreen.cpp b/UI/DisplayLayoutScreen.cpp index 208b1e4af9..63c99b4105 100644 --- a/UI/DisplayLayoutScreen.cpp +++ b/UI/DisplayLayoutScreen.cpp @@ -30,6 +30,10 @@ #include "Common/System/NativeApp.h" #include "Common/VR/PPSSPPVR.h" #include "Common/StringUtils.h" +#include "Common/File/FileUtil.h" +#include "Common/System/OSD.h" +#include "Common/System/Request.h" +#include "Core/Util/PathUtil.h" #include "Common/Data/Color/RGBAUtil.h" #include "Common/Data/Text/I18n.h" @@ -41,6 +45,7 @@ #include "Core/System.h" #include "GPU/GPUCommon.h" #include "GPU/Common/FramebufferManagerCommon.h" +#include "GPU/Common/Overlay.h" #include "GPU/Common/PresentationCommon.h" static const int leftColumnWidth = 200; @@ -345,6 +350,54 @@ void DisplayLayoutScreen::CreateViews() { if (!IsVREnabled()) { static const char *bufFilters[] = { "Linear", "Nearest", }; leftColumn->Add(new PopupMultiChoice(&config.iDisplayFilter, gr->T("Screen Scaling Filter"), bufFilters, 1, ARRAY_SIZE(bufFilters), I18NCat::GRAPHICS, screenManager())); + + // Overlays (bezels), from PSP/OVERLAYS. Rescanned each time, so new files show up without a restart. + ReloadAllOverlayInfo(); + const std::vector overlays = GetAllOverlayInfo(); + std::vector overlayNames{ std::string(sy->T("Off")) }; + std::vector overlayIds{ "" }; + bool selectedFound = config.sOverlay.empty(); + for (const auto &overlay : overlays) { + overlayNames.push_back(overlay.name); + overlayIds.push_back(overlay.id); + if (overlay.id == config.sOverlay) + selectedFound = true; + } + if (!selectedFound) { + // Keep showing a selected overlay whose files went missing, so it can be turned off. + overlayNames.push_back(config.sOverlay); + overlayIds.push_back(config.sOverlay); + } + if (overlayIds.size() > 1) { + auto overlayChoice = leftColumn->Add(new PopupMultiChoiceDynamic(&config.sOverlay, gr->T("Overlay"), overlayNames, I18NCat::NONE, screenManager(), &overlayIds)); + overlayChoice->OnChoice.Add([](UI::EventParams &) { + // The game rect may have moved, which matters for post shaders that run at output resolution. + NotifyPostChanges(); + }); + } + + // Just pick any PNG: it's copied into the overlays folder and everything is auto-detected. + Choice *browseOverlay = leftColumn->Add(new Choice(gr->T("Load overlay image..."))); + const DeviceOrientation orientation = GetDeviceOrientation(); + browseOverlay->OnClick.Add([this, orientation](UI::EventParams &) { + auto gr = GetI18NCategory(I18NCat::GRAPHICS); + const Path savePath = GetSysDirectory(DIRECTORY_CUSTOM_OVERLAYS) / "imported.png"; + System_BrowseForImage(GetRequesterToken(), gr->T("Load overlay image..."), savePath, [this, orientation, savePath](std::string_view value, int converted) { + // If the platform converted the image, it's been saved to savePath already. + const Path source = converted == 1 ? savePath : Path(value); + std::string id; + if (!source.empty() && ImportOverlayImage(source, &id)) { + if (converted == 1) + File::Delete(savePath); + g_Config.GetDisplayLayoutConfig(orientation).sOverlay = id; + NotifyPostChanges(); + RecreateViews(); + } else if (!value.empty() || converted == 1) { + auto gr = GetI18NCategory(I18NCat::GRAPHICS); + g_OSD.Show(OSDType::MESSAGE_ERROR, gr->T("Overlay images must be PNG files")); + } + }); + }); } Draw::DrawContext *draw = screenManager()->getDrawContext(); diff --git a/UI/ImDebugger/ImDebugger.cpp b/UI/ImDebugger/ImDebugger.cpp index b0b4a46ec6..435915ff62 100644 --- a/UI/ImDebugger/ImDebugger.cpp +++ b/UI/ImDebugger/ImDebugger.cpp @@ -911,6 +911,7 @@ static void DrawInternals(ImConfig &cfg) { {DIRECTORY_EXDATA, "EXDATA"}, {DIRECTORY_CUSTOM_SHADERS, "CUSTOM_SHADERS"}, {DIRECTORY_CUSTOM_THEMES, "CUSTOM_THEMES"}, + {DIRECTORY_CUSTOM_OVERLAYS, "CUSTOM_OVERLAYS"}, }; if (ImGui::CollapsingHeader("GetSysDirectory")) { diff --git a/UWP/GPU_UWP/GPU_UWP.vcxproj b/UWP/GPU_UWP/GPU_UWP.vcxproj index 229411b40b..b26a4965eb 100644 --- a/UWP/GPU_UWP/GPU_UWP.vcxproj +++ b/UWP/GPU_UWP/GPU_UWP.vcxproj @@ -95,6 +95,7 @@ + @@ -165,6 +166,7 @@ + diff --git a/UWP/GPU_UWP/GPU_UWP.vcxproj.filters b/UWP/GPU_UWP/GPU_UWP.vcxproj.filters index 44d72b8ee2..7053564988 100644 --- a/UWP/GPU_UWP/GPU_UWP.vcxproj.filters +++ b/UWP/GPU_UWP/GPU_UWP.vcxproj.filters @@ -4,6 +4,7 @@ + @@ -86,6 +87,7 @@ + diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 6b289da016..2089a1032d 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -594,6 +594,7 @@ EXEC_AND_LIB_FILES := \ $(SRC)/GPU/Common/DepalettizeShaderCommon.cpp \ $(SRC)/GPU/Common/FragmentShaderGenerator.cpp \ $(SRC)/GPU/Common/FramebufferManagerCommon.cpp \ + $(SRC)/GPU/Common/Overlay.cpp \ $(SRC)/GPU/Common/PresentationCommon.cpp \ $(SRC)/GPU/Common/GPUDebugInterface.cpp \ $(SRC)/GPU/Common/IndexGenerator.cpp.arm \ diff --git a/libretro/Makefile.common b/libretro/Makefile.common index b5f1c08213..5fe85b74f1 100644 --- a/libretro/Makefile.common +++ b/libretro/Makefile.common @@ -557,6 +557,7 @@ SOURCES_CXX += \ $(GPUCOMMONDIR)/DrawEngineCommon.cpp \ $(GPUCOMMONDIR)/SplineCommon.cpp \ $(GPUCOMMONDIR)/FramebufferManagerCommon.cpp \ + $(GPUCOMMONDIR)/Overlay.cpp \ $(GPUCOMMONDIR)/PresentationCommon.cpp \ $(GPUCOMMONDIR)/ReinterpretFramebuffer.cpp \ $(GPUCOMMONDIR)/ShaderId.cpp \ diff --git a/unittest/UnitTest.cpp b/unittest/UnitTest.cpp index 1d2a6484fe..da1860fb52 100644 --- a/unittest/UnitTest.cpp +++ b/unittest/UnitTest.cpp @@ -123,6 +123,7 @@ #include "GPU/Common/DepthSwizzle.h" #include "GPU/Common/TextureDecoder.h" #include "GPU/Common/GPUStateUtils.h" +#include "GPU/Common/Overlay.h" #include "GPU/Math3D.h" #include "Common/File/AndroidContentURI.h" @@ -3415,6 +3416,52 @@ bool TestLang() { return true; } +static bool TestOverlayHole() { + const int W = 64, H = 48; + std::vector img(W * H * 4); + auto fill = [&](int x0, int y0, int x1, int y1, uint8_t a) { + for (int y = y0; y < y1; y++) + for (int x = x0; x < x1; x++) + img[(y * W + x) * 4 + 3] = a; + }; + int x, y, w, h; + + // Opaque frame with a transparent hole at 8,4 size 40x30 (contains the center 32,24). + fill(0, 0, W, H, 255); + fill(8, 4, 48, 34, 0); + EXPECT_TRUE(DetectOverlayHole(img.data(), W, H, &x, &y, &w, &h)); + EXPECT_EQ_INT(x, 8); + EXPECT_EQ_INT(y, 4); + EXPECT_EQ_INT(w, 40); + EXPECT_EQ_INT(h, 30); + + // A semi-transparent glare over the screen area and soft edges still count as the hole. + fill(20, 10, 40, 30, 200); + fill(7, 4, 8, 34, 128); + EXPECT_TRUE(DetectOverlayHole(img.data(), W, H, &x, &y, &w, &h)); + EXPECT_EQ_INT(x, 7); + EXPECT_EQ_INT(w, 41); + + // Fully transparent image: the hole is the whole image. + fill(0, 0, W, H, 0); + EXPECT_TRUE(DetectOverlayHole(img.data(), W, H, &x, &y, &w, &h)); + EXPECT_EQ_INT(x, 0); + EXPECT_EQ_INT(y, 0); + EXPECT_EQ_INT(w, W); + EXPECT_EQ_INT(h, H); + + // Hole not covering the center: no valid start point. + fill(0, 0, W, H, 255); + fill(0, 0, 30, 20, 0); + EXPECT_FALSE(DetectOverlayHole(img.data(), W, H, &x, &y, &w, &h)); + + // Tiny hole is rejected. + fill(0, 0, W, H, 255); + fill(30, 22, 36, 28, 0); + EXPECT_FALSE(DetectOverlayHole(img.data(), W, H, &x, &y, &w, &h)); + return true; +} + typedef bool (*TestFunc)(); struct TestItem { const char *name; @@ -3877,6 +3924,7 @@ TestItem availableTests[] = { TEST_ITEM(BlockShadow), TEST_ITEM(ShaderGenerators), TEST_ITEM(Path), + TEST_ITEM(OverlayHole), TEST_ITEM(AndroidContentURI), TEST_ITEM(ThreadManager), TEST_ITEM(WrapText),