Add overlay (bezel) support

Draws a user image around or behind the PSP screen, to fill the black bars
on displays whose aspect ratio doesn't match the PSP's (#20325).

- Overlays are PNGs in PSP/OVERLAYS. A plain PNG there just works: it's
  drawn in front of the game, and its transparent hole is found by walking
  from the image center to each edge until a nearly opaque pixel. Existing
  RetroArch overlay images can be dropped in as-is.
- A subfolder with an overlay.ini can define overlays with a Name, Layer
  (Foreground/Background) and an explicit Hole=x,y,w,h.
- "Load overlay image..." in the display layout screen imports any PNG
  (including Android content URIs) into the overlays folder and selects it.
- The overlay is fit to the screen (aspect preserved, centered). The hole is
  mapped with the same scale, and the game is fit into it with the normal
  stretch / integer scale / aspect ratio options. Offset and scale don't
  apply while an overlay with a hole is active. This goes through
  CalculateDisplayOutputRect, so touch mapping and post shaders agree.
- The selection is a per-orientation, per-game DisplayLayout setting.
- Disabled in VR, Cardboard, and with skip buffer effects (except software
  rendering), since the presentation path doesn't run there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGBg32EFPsTXjAFu7dnTcz
This commit is contained in:
LunaMooandClaude Opus 5.5 committed 2026-10-22 14:09:51 +02:00
1 parent ff3d184b43
commit 3ce3973781
18 files changed
+697 -6

No files matched your search

+1
View File
@@ -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),
+1
View File
@@ -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
+2
View File
@@ -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";
+1
View File
@@ -27,6 +27,7 @@ enum PSPDirectories {
DIRECTORY_EXDATA,
DIRECTORY_CUSTOM_SHADERS,
DIRECTORY_CUSTOM_THEMES,
DIRECTORY_CUSTOM_OVERLAYS,
COUNT,
};
+2
View File
@@ -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
+332
View File
@@ -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 <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <mutex>
#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<OverlayInfo> 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<const OverlayImage> 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<OverlayInfo> *out) {
IniFile ini;
if (!ini.Load(dir / "overlay.ini"))
return;
for (auto &sectionPtr : ini.Sections()) {
const Section &section = *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<OverlayInfo> *out) {
out->clear();
const Path root = GetSysDirectory(DIRECTORY_CUSTOM_OVERLAYS);
std::vector<File::FileInfo> 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<std::mutex> guard(g_overlayLock);
ScanOverlays(&g_overlayInfo);
g_overlayInfoLoaded = true;
UnloadActiveLocked();
}
std::vector<OverlayInfo> GetAllOverlayInfo() {
std::lock_guard<std::mutex> 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<OverlayImage>();
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<std::mutex> 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<const OverlayImage> GetOverlayImage(uint32_t generation) {
std::lock_guard<std::mutex> guard(g_overlayLock);
if (generation != g_generation)
return nullptr;
return g_activeImage;
}
+108
View File
@@ -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 <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#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<uint8_t> 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<OverlayInfo> 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<const OverlayImage> 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);
+123 -5
View File
@@ -19,6 +19,8 @@
#include <cmath>
#include <set>
#include <cstdint>
#include <cstring>
#include <memory>
#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<Draw::ShaderModule *> shaders, bool postShader, const UniformBufferDesc *uniformDesc) const {
Draw::Pipeline *PresentationCommon::CreatePipeline(std::vector<Draw::ShaderModule *> 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::vector<Draw::ShaderModul
InputLayout *inputLayout = draw_->CreateInputLayout(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<T *> &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<const OverlayImage> 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_);
+11 -1
View File
@@ -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<Draw::ShaderModule *> shaders, bool postShader, const UniformBufferDesc *uniformDesc) const;
Draw::Pipeline *CreatePipeline(std::vector<Draw::ShaderModule *> 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<Draw::Pipeline *> postShaderPipelines_;
std::vector<Draw::Framebuffer *> postShaderFramebuffers_;
std::vector<ShaderInfo> postShaderInfo_;
+2
View File
@@ -271,6 +271,7 @@
<ClInclude Include="Common\GPUStateUtils.h" />
<ClInclude Include="Common\IndexGenerator.h" />
<ClInclude Include="Common\PostShader.h" />
<ClInclude Include="Common\Overlay.h" />
<ClInclude Include="Common\PresentationCommon.h" />
<ClInclude Include="Common\ShaderCommon.h" />
<ClInclude Include="Common\ShaderId.h" />
@@ -378,6 +379,7 @@
<ClCompile Include="Common\GPUStateUtils.cpp" />
<ClCompile Include="Common\IndexGenerator.cpp" />
<ClCompile Include="Common\PostShader.cpp" />
<ClCompile Include="Common\Overlay.cpp" />
<ClCompile Include="Common\PresentationCommon.cpp" />
<ClCompile Include="Common\ShaderCommon.cpp" />
<ClCompile Include="Common\ShaderId.cpp" />
+6
View File
@@ -213,6 +213,9 @@
<ClInclude Include="Software\RasterizerRectangle.h">
<Filter>Software</Filter>
</ClInclude>
<ClInclude Include="Common\Overlay.h">
<Filter>Common</Filter>
</ClInclude>
<ClInclude Include="Common\PresentationCommon.h">
<Filter>Common</Filter>
</ClInclude>
@@ -473,6 +476,9 @@
<ClCompile Include="Software\RasterizerRectangle.cpp">
<Filter>Software</Filter>
</ClCompile>
<ClCompile Include="Common\Overlay.cpp">
<Filter>Common</Filter>
</ClCompile>
<ClCompile Include="Common\PresentationCommon.cpp">
<Filter>Common</Filter>
</ClCompile>
+53
View File
@@ -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<OverlayInfo> overlays = GetAllOverlayInfo();
std::vector<std::string> overlayNames{ std::string(sy->T("Off")) };
std::vector<std::string> 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();
+1
View File
@@ -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")) {
+2
View File
@@ -95,6 +95,7 @@
<ClInclude Include="..\..\GPU\Common\DrawEngineCommon.h" />
<ClInclude Include="..\..\GPU\Common\FragmentShaderGenerator.h" />
<ClInclude Include="..\..\GPU\Common\FramebufferManagerCommon.h" />
<ClInclude Include="..\..\GPU\Common\Overlay.h" />
<ClInclude Include="..\..\GPU\Common\PresentationCommon.h" />
<ClInclude Include="..\..\GPU\Common\GPUDebugInterface.h" />
<ClInclude Include="..\..\GPU\Common\GPUStateUtils.h" />
@@ -165,6 +166,7 @@
<ClCompile Include="..\..\GPU\Common\DrawEngineCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\FragmentShaderGenerator.cpp" />
<ClCompile Include="..\..\GPU\Common\FramebufferManagerCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\Overlay.cpp" />
<ClCompile Include="..\..\GPU\Common\PresentationCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\GPUDebugInterface.cpp" />
<ClCompile Include="..\..\GPU\Common\GPUStateUtils.cpp" />
+2
View File
@@ -4,6 +4,7 @@
<ClCompile Include="..\..\GPU\Common\DepalettizeShaderCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\DrawEngineCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\FramebufferManagerCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\Overlay.cpp" />
<ClCompile Include="..\..\GPU\Common\PresentationCommon.cpp" />
<ClCompile Include="..\..\GPU\Common\GPUDebugInterface.cpp" />
<ClCompile Include="..\..\GPU\Common\GPUStateUtils.cpp" />
@@ -86,6 +87,7 @@
<ClInclude Include="..\..\GPU\Common\DepalettizeShaderCommon.h" />
<ClInclude Include="..\..\GPU\Common\DrawEngineCommon.h" />
<ClInclude Include="..\..\GPU\Common\FramebufferManagerCommon.h" />
<ClInclude Include="..\..\GPU\Common\Overlay.h" />
<ClInclude Include="..\..\GPU\Common\PresentationCommon.h" />
<ClInclude Include="..\..\GPU\Common\GPUDebugInterface.h" />
<ClInclude Include="..\..\GPU\Common\GPUStateUtils.h" />
+1
View File
@@ -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 \
+1
View File
@@ -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 \
+48
View File
@@ -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<uint8_t> 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),