From 001ca40803074ee5d28e9dcf2049a8d82db8daab Mon Sep 17 00:00:00 2001 From: jpolo1224 Date: Mon, 24 Aug 2026 15:44:20 -0400 Subject: [PATCH] GS: add SGSR upscaling (Qualcomm Snapdragon Game Super Resolution 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third display upscaler beside FSR1, and the first one written for the hardware this app actually runs on: FSR1's two compute passes are the expensive outlier on mobile, while SGSR is a single pass Qualcomm designed for Adreno. The filter is Qualcomm's, BSD-3-Clause, unchanged in substance. What differs is the shape around it -- theirs is a fragment shader over a fullscreen triangle, this is a compute pass, because that is what GSDevice already knows how to schedule. So the interpolated texcoord becomes a UV computed from the invocation id and the fragment output becomes an imageStore. The crop handling and the widened 0..2 sharpness range come from the Eden Emulator Project's port (GPL-3.0-or-later, compatible with PCSX2's GPL-3.0+); PCSX2 hands the pass a display rectangle inside a larger target, which is the same problem FsrEasuConOffset solves for FSR1. Deliberately a strict subset of what FSR1 already requires -- same descriptor types, same rgba8 storage image, textureGather with a constant component and no offset, which is core Vulkan 1.0 and needs no optional feature. So any driver already running FSR1 can run this, Turnip included, and there is no vendor gate on either. A driver that cannot compile the pipeline clears Features().sgsr and the renderer falls back to plain bilinear with an OSD notice, rather than failing. The Android upscaler control becomes a picker rather than an on/off toggle: three mutually exclusive upscalers expressed as two toggles that silently switch each other off is a worse way to say it than one list. FSR1 and SGSR share the existing sharpness slider -- the number means different things to each, but it is the same intent, and a second slider would only invite the two to disagree. ★ The Settings.kt clamp on the persisted enum was still bounded at UPSCALER_FSR1, and would have silently rewritten any SGSR selection back to Off. That clamp's own comment warns about exactly this failure; it still had to be updated by hand. Worth remembering the next time the enum grows. Suggested by CamilleLaVey, who authored the upstream changes (eden-emu #4293). --- bin/resources/shaders/vulkan/sgsr.glsl | 114 ++++++++++++++++ pcsx2/Config.h | 1 + pcsx2/GS/Renderers/Common/GSDevice.cpp | 76 ++++++++++- pcsx2/GS/Renderers/Common/GSDevice.h | 13 ++ pcsx2/GS/Renderers/Common/GSRenderer.cpp | 23 ++++ pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp | 127 ++++++++++++++++++ pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h | 5 + .../main/java/com/armsx2/config/Settings.kt | 11 +- .../app/src/main/java/com/armsx2/i18n/I18n.kt | 1 + .../com/armsx2/ui/settings/RendererTab.kt | 26 ++-- 10 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 bin/resources/shaders/vulkan/sgsr.glsl diff --git a/bin/resources/shaders/vulkan/sgsr.glsl b/bin/resources/shaders/vulkan/sgsr.glsl new file mode 100644 index 0000000000..9801cac60d --- /dev/null +++ b/bin/resources/shaders/vulkan/sgsr.glsl @@ -0,0 +1,114 @@ +// Snapdragon Game Super Resolution 1.0, "mobile" variant. +// +// SPDX-FileCopyrightText: Copyright (c) 2025, Qualcomm Innovation Center, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause +// +// The filter body below is Qualcomm's, unchanged in substance. What differs from their sample is +// the shape around it: theirs is a fragment shader over a fullscreen triangle, and this is a +// compute pass, because that is what GSDevice already knows how to schedule (see fsr1.glsl and +// DoFSR1Pass). So the interpolated texcoord becomes an explicit UV computed from the invocation +// id, and the fragment output becomes an imageStore. +// +// The crop handling comes from the Eden Emulator Project's port (GPL-3.0-or-later), which found +// that the source rect has to be mapped explicitly rather than assumed to be the whole texture -- +// PCSX2 hands us a display rectangle inside a larger target for exactly the same reason FSR1's +// FsrEasuConOffset takes an offset. The sharpness range being 0..2 rather than 0..1 comes from +// there too; the original was too tight to be useful at the top end. +// +// Brought to ARMSX2 at the suggestion of CamilleLaVey, who authored the Eden changes this is +// based on (eden-emu/eden PR #4293). + +#define EDGE_THRESHOLD (8.0 / 255.0) + +layout(push_constant) uniform const_buffer +{ + // Output extent, for the bounds check. A dispatch is rounded up to whole workgroups, so the + // last one runs partly outside the image. + uvec2 dstSize; + // The displayed region inside the source texture, normalised. PCSX2's merge target is bigger + // than the picture in it; without this the filter would upscale the padding too. + vec2 uvOffset; + vec2 uvScale; + // Source texture dimensions and their reciprocal. Qualcomm's "size" and "scale". + vec2 srcSize; + vec2 invSrcSize; + // 0..2. 1.0 is Qualcomm's own default; above that is oversharpened and is offered because + // the range was too tight to be useful at the top end. + float edgeSharpness; +}; + +layout(set = 0, binding = 0) uniform sampler2D imgSrc; +layout(set = 0, binding = 1, rgba8) uniform writeonly image2D imgDst; + +vec4 weightY(vec4 dx, vec4 dy, vec4 std) +{ + vec4 x = ((dx * dx) + (dy * dy)) * 0.55f + std; + return (x - 1.f) * (x - 4.f) * 3.8125f; // approx. of (x - 1) * (x - 4)^3 +} + +layout(local_size_x = 8, local_size_y = 8) in; +void main() +{ + const uvec2 pos = gl_GlobalInvocationID.xy; + if (pos.x >= dstSize.x || pos.y >= dstSize.y) + return; + + // Centre of this output pixel, mapped into the displayed region of the source. + const vec2 texcoord = uvOffset + ((vec2(pos) + vec2(0.5f)) / vec2(dstSize)) * uvScale; + + vec4 color = textureLod(imgSrc, texcoord, 0.0f); + + // image coord + vec2 icoord = (texcoord * srcSize + vec2(-0.5f, 0.5f)); + vec2 icoord_pixel = floor(icoord); + vec2 coord = icoord_pixel * invSrcSize; + vec2 pl = icoord - icoord_pixel; + // left: 0, right: 1, upDown: 2 + mat3x4 dg = mat3x4( + textureGather(imgSrc, coord, 1), + textureGather(imgSrc, coord + vec2(2.f * invSrcSize.x, 0.0f), 1), + vec4( + textureGather(imgSrc, coord + vec2(invSrcSize.x, -invSrcSize.y), 1).wz, + textureGather(imgSrc, coord + vec2(invSrcSize.x, +invSrcSize.y), 1).yx + ) + ); + float edgeVote = abs(dg[0].z - dg[0].y) + abs(color.y - dg[0].y) + abs(color.y - dg[0].z); + if (edgeVote > EDGE_THRESHOLD) + { + float mean = (dg[0].y + dg[0].z + dg[1].x + dg[1].w) * 0.25f; + dg = dg - mean; + vec4 sum = abs(dg[0]) + abs(dg[1]) + abs(dg[2]); + float std = 2.181818f / (sum.x + sum.y + sum.z + sum.w); + mat2x4 w = mat2x4( + weightY( + pl.xxxx + vec4(+1.0f, +0.0f, +0.0f, +1.0f), + pl.yyyy + vec4(-1.0f, -1.0f, +0.0f, +0.0f), + clamp(abs(dg[0]) * std, 0.0f, 1.0f) + ) + weightY( + pl.xxxx + vec4(-1.0f, -2.0f, -2.0f, -1.0f), + pl.yyyy + vec4(-1.0f, -1.0f, +0.0f, +0.0f), + clamp(abs(dg[1]) * std, 0.0f, 1.0f) + ) + weightY( + pl.xxxx + vec4(+0.0f, -1.0f, -1.0f, +0.0f), + pl.yyyy + vec4(+1.0f, +1.0f, -2.0f, -2.0f), + clamp(abs(dg[2]) * std, 0.0f, 1.0f) + ), + dg[0] + dg[1] + dg[2] + ); + // compute final y with bounds + vec2 yb = vec2( + min(min(dg[0].y, dg[0].z), min(dg[1].x, dg[1].w)), // min + max(max(dg[0].y, dg[0].z), max(dg[1].x, dg[1].w)) // max + ); + vec2 fvy = vec2( + w[0].x + w[0].y + w[0].z + w[0].w, + w[1].x + w[1].y + w[1].z + w[1].w + ); + float fy = clamp((fvy.y / fvy.x) * edgeSharpness, yb[0], yb[1]); + // Smooth high contrast input + float dy = clamp(fy - color.y + mean, -23.0f / 255.0f, 23.0f / 255.0f); + color = clamp(color + dy, 0.0f, 1.0f); + } + color.w = 1.0f; // assume alpha channel is not used + imageStore(imgDst, ivec2(pos), color); +} diff --git a/pcsx2/Config.h b/pcsx2/Config.h index 5a7532ba2a..96fc9648a6 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -482,6 +482,7 @@ enum class GSUpscaler : u8 // Appended rather than inserted: this enum is persisted as an integer, so renumbering // MetalFXSpatial would silently re-point every existing config at a different upscaler. FSR1, ///< AMD FidelityFX Super Resolution 1 (EASU + RCAS compute passes, Vulkan). + SGSR, ///< Qualcomm Snapdragon Game Super Resolution 1 (single compute pass, Vulkan). }; enum class GSHWAutoFlushLevel : u8 diff --git a/pcsx2/GS/Renderers/Common/GSDevice.cpp b/pcsx2/GS/Renderers/Common/GSDevice.cpp index 9dd010733b..b9bdd945ba 100644 --- a/pcsx2/GS/Renderers/Common/GSDevice.cpp +++ b/pcsx2/GS/Renderers/Common/GSDevice.cpp @@ -293,7 +293,7 @@ GSDevice::GSDevice() GSDevice::~GSDevice() { // should've been cleaned up in Destroy() - pxAssert(m_pool[0].empty() && m_pool[1].empty() && !m_merge && !m_weavebob && !m_blend && !m_mad && !m_target_tmp && !m_cas && !m_mfx_output && !m_fsr1_easu && !m_fsr1_output); + pxAssert(m_pool[0].empty() && m_pool[1].empty() && !m_merge && !m_weavebob && !m_blend && !m_mad && !m_target_tmp && !m_cas && !m_mfx_output && !m_fsr1_easu && !m_fsr1_output && !m_sgsr_output); } GSVector2i GSDevice::GetPresentationSize() const @@ -1230,6 +1230,7 @@ void GSDevice::ClearCurrent() delete m_mfx_output; delete m_fsr1_easu; delete m_fsr1_output; + delete m_sgsr_output; m_merge = nullptr; m_weavebob = nullptr; @@ -1240,6 +1241,7 @@ void GSDevice::ClearCurrent() m_mfx_output = nullptr; m_fsr1_easu = nullptr; m_fsr1_output = nullptr; + m_sgsr_output = nullptr; } void GSDevice::Merge(GSTexture* sTex[3], GSVector4* sRect, GSVector4* dRect, const GSVector2i& fs, const GSRegPMODE& PMODE, const GSRegEXTBUF& EXTBUF, u32 c) @@ -1682,6 +1684,78 @@ void GSDevice::FSR1Upscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src src_uv = GSVector4(0.0f, 0.0f, 1.0f, 1.0f); } +void GSDevice::SGSRUpscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src_uv, const GSVector4& draw_rect) +{ + FlushDeferredDraws(); + const int dst_width = static_cast(std::ceil(draw_rect.z - draw_rect.x)); + const int dst_height = static_cast(std::ceil(draw_rect.w - draw_rect.y)); + if (dst_width <= 0 || dst_height <= 0) + return; + + GSTexture* src_tex = tex; + + static int s_logged_w = 0, s_logged_h = 0; + if (s_logged_w != dst_width || s_logged_h != dst_height) + { + s_logged_w = dst_width; + s_logged_h = dst_height; + Console.WriteLnFmt("@@ANDROID_SGSR@@ upscaling {}x{} -> {}x{} (sharpness {})", + src_rect.width(), src_rect.height(), dst_width, dst_height, GSConfig.FSR_Sharpness); + } + + // One target, unlike FSR1: SGSR is a single pass, so there is no intermediate to hand on. + if (!m_sgsr_output || m_sgsr_output->GetWidth() != dst_width || m_sgsr_output->GetHeight() != dst_height) + { + delete m_sgsr_output; + m_sgsr_output = CreateSurface(GSTexture::ShaderWriteTexture, dst_width, dst_height, 1, GSTexture::Format::Color); + if (!m_sgsr_output) + { + Console.Error("Failed to allocate SGSR output texture."); + return; + } + } + + // The picture occupies src_rect inside a larger merge target, so the shader is told where it + // is rather than assuming the whole texture — the same problem FsrEasuConOffset solves for + // FSR1 by taking an offset. + const float src_tex_w = static_cast(src_tex->GetWidth()); + const float src_tex_h = static_cast(src_tex->GetHeight()); + const float uv_off_x = static_cast(src_rect.x) / src_tex_w; + const float uv_off_y = static_cast(src_rect.y) / src_tex_h; + const float uv_scale_x = static_cast(src_rect.width()) / src_tex_w; + const float uv_scale_y = static_cast(src_rect.height()) / src_tex_h; + + // Qualcomm's edge sharpness runs 0..2 with 1.0 as its own default. Our slider is the 0..100 + // one FSR1 already uses, so the two features share a control rather than growing a second + // one that means almost the same thing. + const float sharpness = std::clamp(static_cast(GSConfig.FSR_Sharpness) * 0.02f, 0.0f, 2.0f); + + std::array consts = {}; + const auto put_f = [&consts](u32 i, float v) { std::memcpy(&consts[i], &v, sizeof(float)); }; + consts[0] = static_cast(dst_width); + consts[1] = static_cast(dst_height); + put_f(2, uv_off_x); + put_f(3, uv_off_y); + put_f(4, uv_scale_x); + put_f(5, uv_scale_y); + put_f(6, src_tex_w); + put_f(7, src_tex_h); + put_f(8, 1.0f / src_tex_w); + put_f(9, 1.0f / src_tex_h); + put_f(10, sharpness); + + if (!DoSGSR(src_tex, m_sgsr_output, consts)) + { + // leave textures intact if we failed + Console.Warning("Applying SGSR failed."); + return; + } + + tex = m_sgsr_output; + src_rect = GSVector4i(0, 0, dst_width, dst_height); + src_uv = GSVector4(0.0f, 0.0f, 1.0f, 1.0f); +} + bool GSHWDrawConfig::BlendState::IsEffective(ColorMaskSelector colormask) const { return enable && (((colormask.key & 7u) && (src_factor != GSDevice::CONST_ONE || dst_factor != GSDevice::CONST_ZERO)) || diff --git a/pcsx2/GS/Renderers/Common/GSDevice.h b/pcsx2/GS/Renderers/Common/GSDevice.h index 35f5c3a845..f611e28927 100644 --- a/pcsx2/GS/Renderers/Common/GSDevice.h +++ b/pcsx2/GS/Renderers/Common/GSDevice.h @@ -1417,6 +1417,7 @@ public: bool rov : 1; ///< Supports rasterizer ordered views for both depth and color. bool metalfx_spatial : 1; ///< Supports Apple MetalFX spatial upscaling (Metal backend, macOS 13+). bool fsr1 : 1; ///< Supports AMD FidelityFX Super Resolution 1 (two compute passes). + bool sgsr : 1; ///< Supports Qualcomm Snapdragon Game Super Resolution 1 (one compute pass). bool dual_source_blend : 1; ///< Supports a second fragment output (SRC1) as a hardware blend factor. bool broken_mad_deinterlace : 1; ///< Driver can't reliably preserve/read the two-bank FastMAD history target. FeatureSupport() @@ -1518,6 +1519,9 @@ protected: // Sample, but Sample still decorates to byte offset 64, so both passes push all 80 bytes // - a short push leaves Sample undefined and the shader squares the whole image. static constexpr u32 NUM_FSR1_CONSTANTS = 20; + /// dstSize(2) + uvOffset(2) + uvScale(2) + srcSize(2) + invSrcSize(2) + edgeSharpness(1), + /// as u32 words. Mixed uint/float, so the host packs it rather than the type saying so. + static constexpr u32 NUM_SGSR_CONSTANTS = 11; static constexpr u32 EXPAND_BUFFER_SIZE = sizeof(u16) * 16383 * 6; WindowInfo m_window_info; @@ -1539,6 +1543,7 @@ protected: GSTexture* m_mfx_output = nullptr; ///< MetalFX spatial upscale destination (Metal backend). GSTexture* m_fsr1_easu = nullptr; ///< FSR1 EASU output, at display size; RCAS reads it back. GSTexture* m_fsr1_output = nullptr; ///< FSR1 RCAS output, the texture actually presented. + GSTexture* m_sgsr_output = nullptr; ///< SGSR output. One pass, so one target, unlike FSR1. GSTexture* m_colclip_rt = nullptr; ///< Temp hw colclip texture GSTexture* m_ds_as_rt = nullptr; ///< Depth as color @@ -1609,6 +1614,10 @@ protected: virtual bool DoFSR1EASU(GSTexture* sTex, GSTexture* dTex, const std::array& constants) { return false; } virtual bool DoFSR1RCAS(GSTexture* sTex, GSTexture* dTex, const std::array& constants) { return false; } + /// SGSR: edge-directed spatial upsample from sTex to dTex's size, in a single dispatch. + /// No-op in the base class, like the FSR1 pair above. + virtual bool DoSGSR(GSTexture* sTex, GSTexture* dTex, const std::array& constants) { return false; } + /// Perform texture operations for ImGui void UpdateImGuiTextures(); @@ -2034,6 +2043,10 @@ public: /// Same contract as MetalFXUpscale(), via FSR1's two compute passes. void FSR1Upscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src_uv, const GSVector4& draw_rect); + /// Same contract again, via SGSR's single compute pass. Cheaper than FSR1 on mobile, which is + /// the point of having it: SGSR was designed for Adreno, FSR1's two passes were not. + void SGSRUpscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src_uv, const GSVector4& draw_rect); + bool ResizeRenderTarget(GSTexture** t, int w, int h, bool preserve_contents, bool recycle); void AgePool(); diff --git a/pcsx2/GS/Renderers/Common/GSRenderer.cpp b/pcsx2/GS/Renderers/Common/GSRenderer.cpp index 82f6805c84..c8e09081d3 100644 --- a/pcsx2/GS/Renderers/Common/GSRenderer.cpp +++ b/pcsx2/GS/Renderers/Common/GSRenderer.cpp @@ -1077,6 +1077,29 @@ void GSRenderer::VSync(u32 field, bool registers_written, bool idle_frame) // present render pass is already open by the time DoBeginPresent runs. // It is CAS's `if`, not a second branch beside it: FSR's second pass *is* RCAS, a // contrast-adaptive sharpener, so letting CAS run afterward sharpens twice. + // SGSR sits in the same place and under the same rule as FSR1 below: a single + // compute pass before the present render pass opens, and inside CAS's `if` rather + // than beside it, because SGSR sharpens as part of upscaling and letting CAS run + // afterwards would sharpen twice. + if (GSConfig.Upscaler == GSUpscaler::SGSR) + { + static bool sgsr_log_once = false; + if (g_gs_device->Features().sgsr) + { + const int draw_w = static_cast(std::ceil(draw_rect.z - draw_rect.x)); + const int draw_h = static_cast(std::ceil(draw_rect.w - draw_rect.y)); + if (current->GetWidth() < draw_w && current->GetHeight() < draw_h) + g_gs_device->SGSRUpscale(current, src_rect, src_uv, draw_rect); + } + else if (!sgsr_log_once) + { + Host::AddIconOSDMessage("SGSRUnsupported", ICON_FA_TRIANGLE_EXCLAMATION, + TRANSLATE_SV("GS", "SGSR upscaling is not available, your graphics driver does not support the required functionality."), + 10.0f); + sgsr_log_once = true; + } + } + if (GSConfig.Upscaler == GSUpscaler::FSR1) { static bool fsr1_log_once = false; diff --git a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp index 1925ce5907..e2e1c9f91f 100644 --- a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp +++ b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp @@ -2670,6 +2670,12 @@ bool GSDeviceVK::Create(GSVSyncMode vsync_mode, bool allow_present_throttle) // Same non-fatal treatment as CAS above, and for the same reason: FSR1 is two more compute // pipelines, so a driver that chokes on CAS's will likely choke on these too. Leaving // Features().fsr1 false makes GSRenderer fall back to the plain bilinear present. + if (!CompileSGSRPipeline()) + { + Console.Warning("VK: SGSR pipeline compilation failed - disabling SGSR upscaling."); + m_features.sgsr = false; + } + if (!CompileFSR1Pipelines()) { Console.Warning("VK: FSR1 pipeline compilation failed - disabling FSR1 upscaling."); @@ -6131,6 +6137,51 @@ bool GSDeviceVK::CompileFSR1Pipelines() return true; } +bool GSDeviceVK::CompileSGSRPipeline() +{ + VkDevice dev = m_device; + Vulkan::DescriptorSetLayoutBuilder dslb; + Vulkan::PipelineLayoutBuilder plb; + + if (m_use_push_descriptors) + dslb.SetPushFlag(); + // Combined image sampler for the same reason FSR1 needs one: SGSR reads through + // textureGather, which has to have a sampler bound to the image. + dslb.AddBinding(0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_COMPUTE_BIT); + dslb.AddBinding(1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1, VK_SHADER_STAGE_COMPUTE_BIT); + if ((m_sgsr_ds_layout = dslb.Create(dev)) == VK_NULL_HANDLE) + return false; + Vulkan::SetObjectName(dev, m_sgsr_ds_layout, "SGSR descriptor layout"); + + plb.AddPushConstants(VK_SHADER_STAGE_COMPUTE_BIT, 0, NUM_SGSR_CONSTANTS * sizeof(u32)); + plb.AddDescriptorSet(m_sgsr_ds_layout); + if ((m_sgsr_pipeline_layout = plb.Create(dev)) == VK_NULL_HANDLE) + return false; + Vulkan::SetObjectName(dev, m_sgsr_pipeline_layout, "SGSR pipeline layout"); + + // One module, no preprocessor gating: the whole filter is one function, so unlike FSR1 there + // is nothing to select before the preprocessor runs. + std::optional sgsr_source = ReadShaderSource("shaders/vulkan/sgsr.glsl"); + if (!sgsr_source.has_value()) + return false; + sgsr_source->insert(0, "#version 460 core\n"); + + VkShaderModule mod = g_vulkan_shader_cache->GetComputeShader(sgsr_source->c_str()); + if (mod == VK_NULL_HANDLE) + return false; + ScopedGuard mod_guard = [this, &mod]() { vkDestroyShaderModule(m_device, mod, nullptr); }; + + Vulkan::ComputePipelineBuilder cpb; + cpb.SetPipelineLayout(m_sgsr_pipeline_layout); + cpb.SetShader(mod, "main"); + m_sgsr_pipeline = cpb.Create(dev, g_vulkan_shader_cache->GetPipelineCache(true), false); + if (!m_sgsr_pipeline) + return false; + + m_features.sgsr = true; + return true; +} + bool GSDeviceVK::CompileImGuiPipeline() { const std::optional glsl = ReadShaderSource("shaders/vulkan/imgui.glsl"); @@ -6505,6 +6556,75 @@ bool GSDeviceVK::DoFSR1Pass( return true; } +bool GSDeviceVK::DoSGSR(GSTexture* sTex, GSTexture* dTex, const std::array& constants) +{ + g_perfmon.Put(GSPerfMon::TextureCopies, 1); + + EndRenderPass(); + + GSTextureVK* const sTexVK = static_cast(sTex); + GSTextureVK* const dTexVK = static_cast(dTex); + VkCommandBuffer cmdbuf = GetCurrentCommandBuffer(); + + // Input arrives from a colour-attachment write, exactly as FSR1's EASU input does. There is + // no compute->compute case here because SGSR has no intermediate. + sTexVK->TransitionToLayout(cmdbuf, GSTextureVK::Layout::ShaderReadOnly); + + if (dTexVK->GetLayout() == GSTextureVK::Layout::ComputeReadWriteImage) + { + // Every frame after the first: order this dispatch's writes against the previous frame's + // reads of the same texture by the present pass. TransitionToLayout early-outs when the + // layout already matches, so a same-layout dependency has to be stated by hand. + const VkImageMemoryBarrier barrier = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, nullptr, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, VK_QUEUE_FAMILY_IGNORED, VK_QUEUE_FAMILY_IGNORED, + dTexVK->GetImage(), {VK_IMAGE_ASPECT_COLOR_BIT, 0u, 1u, 0u, 1u}}; + vkCmdPipelineBarrier(cmdbuf, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + } + else + { + dTexVK->TransitionToLayout(cmdbuf, GSTextureVK::Layout::ComputeReadWriteImage); + } + + // Normalised coordinates and gathers, so linear/clamp-to-edge, like EASU. + const VkSampler sampler = m_linear_sampler; + + Vulkan::DescriptorSetUpdateBuilder dsub; + if (m_use_push_descriptors) + { + dsub.AddCombinedImageSamplerDescriptorWrite(VK_NULL_HANDLE, 0, sTexVK->GetView(), sampler, sTexVK->GetVkLayout()); + dsub.AddStorageImageDescriptorWrite(VK_NULL_HANDLE, 1, dTexVK->GetView(), dTexVK->GetVkLayout()); + dsub.PushUpdate(cmdbuf, VK_PIPELINE_BIND_POINT_COMPUTE, m_sgsr_pipeline_layout, 0, false); + } + else + { + VkDescriptorSet ds = AllocateDescriptorSetFromFramePool(m_sgsr_ds_layout); + if (ds == VK_NULL_HANDLE) [[unlikely]] + return false; + dsub.AddCombinedImageSamplerDescriptorWrite(ds, 0, sTexVK->GetView(), sampler, sTexVK->GetVkLayout()); + dsub.AddStorageImageDescriptorWrite(ds, 1, dTexVK->GetView(), dTexVK->GetVkLayout()); + dsub.Update(m_device); + vkCmdBindDescriptorSets(cmdbuf, VK_PIPELINE_BIND_POINT_COMPUTE, m_sgsr_pipeline_layout, 0, 1, &ds, 0, nullptr); + } + + // 8x8 local size, one pixel per invocation — not FSR1's 16, which comes from its 64 threads + // each writing four pixels. + static const int threadGroupWorkRegionDim = 8; + const int dispatchX = (dTex->GetWidth() + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim; + const int dispatchY = (dTex->GetHeight() + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim; + + vkCmdPushConstants(cmdbuf, m_sgsr_pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, + NUM_SGSR_CONSTANTS * sizeof(u32), constants.data()); + vkCmdBindPipeline(cmdbuf, VK_PIPELINE_BIND_POINT_COMPUTE, m_sgsr_pipeline); + vkCmdDispatch(cmdbuf, dispatchX, dispatchY, 1); + + // Handed straight to the present pass, which samples it from the fragment stage. + dTexVK->TransitionToLayout(GSTextureVK::Layout::ShaderReadOnly); + + return true; +} + void GSDeviceVK::DestroyResources() { if (m_tfx_ubo_descriptor_set != VK_NULL_HANDLE) @@ -6579,6 +6699,13 @@ void GSDeviceVK::DestroyResources() if (m_fsr1_ds_layout != VK_NULL_HANDLE) vkDestroyDescriptorSetLayout(m_device, m_fsr1_ds_layout, nullptr); + if (m_sgsr_pipeline != VK_NULL_HANDLE) + vkDestroyPipeline(m_device, m_sgsr_pipeline, nullptr); + if (m_sgsr_pipeline_layout != VK_NULL_HANDLE) + vkDestroyPipelineLayout(m_device, m_sgsr_pipeline_layout, nullptr); + if (m_sgsr_ds_layout != VK_NULL_HANDLE) + vkDestroyDescriptorSetLayout(m_device, m_sgsr_ds_layout, nullptr); + if (m_imgui_pipeline != VK_NULL_HANDLE) vkDestroyPipeline(m_device, m_imgui_pipeline, nullptr); diff --git a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h index 8786d5ea56..ccb13a7936 100644 --- a/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h +++ b/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.h @@ -550,6 +550,9 @@ private: VkDescriptorSetLayout m_fsr1_ds_layout = VK_NULL_HANDLE; VkPipelineLayout m_fsr1_pipeline_layout = VK_NULL_HANDLE; std::array m_fsr1_pipelines = {}; + VkDescriptorSetLayout m_sgsr_ds_layout = VK_NULL_HANDLE; + VkPipelineLayout m_sgsr_pipeline_layout = VK_NULL_HANDLE; + VkPipeline m_sgsr_pipeline = VK_NULL_HANDLE; ///< One pass, so one pipeline, unlike FSR1. VkPipeline m_imgui_pipeline = VK_NULL_HANDLE; GSHWDrawConfig::VSConstantBuffer m_vs_cb_cache; @@ -590,6 +593,7 @@ private: bool DoFSR1EASU(GSTexture* sTex, GSTexture* dTex, const std::array& constants) final; bool DoFSR1RCAS(GSTexture* sTex, GSTexture* dTex, const std::array& constants) final; + bool DoSGSR(GSTexture* sTex, GSTexture* dTex, const std::array& constants) final; /// Shared body of the two above: same layout, same push range, different pipeline and /// different input-side synchronisation. bool DoFSR1Pass( @@ -620,6 +624,7 @@ private: bool CompilePostProcessingPipelines(); bool CompileCASPipelines(); bool CompileFSR1Pipelines(); + bool CompileSGSRPipeline(); bool CompileImGuiPipeline(); void RenderImGui(); diff --git a/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt b/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt index 17b1ee0679..1bbe5eda51 100644 --- a/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt +++ b/platforms/android/app/src/main/java/com/armsx2/config/Settings.kt @@ -619,7 +619,7 @@ data class Settings( val casMode: Int = 0, /** EmuCore/GS/CASSharpness — sharpening strength 0..100 (%). */ val casSharpness: Int = 50, - /** EmuCore/GS/Upscaler — GSUpscaler: 0 Off / 1 MetalFX (Apple only) / 2 FSR1. + /** EmuCore/GS/Upscaler — GSUpscaler: 0 Off / 1 MetalFX (Apple only) / 2 FSR1 / 3 SGSR. * 1 is unreachable from this UI; the values are the core enum's, and it is persisted * as an integer, so they must not be renumbered to close the gap. */ val upscaler: Int = 0, @@ -1448,9 +1448,11 @@ data class Settings( ShaderParams.push(shaderChainPreset, shaderChainParams[shaderChainPreset].orEmpty()) put("EmuCore/GS", "CASMode", "int", casMode.coerceIn(0, 2).toString()) put("EmuCore/GS", "CASSharpness", "int", casSharpness.coerceIn(0, 100).toString()) - // Upper bound is UPSCALER_FSR1, not the count of options this UI shows — clamping to - // the visible choices would silently rewrite FSR1 back to Off. - put("EmuCore/GS", "Upscaler", "int", upscaler.coerceIn(UPSCALER_OFF, UPSCALER_FSR1).toString()) + // Upper bound is the HIGHEST enum value, not the count of options this UI shows — + // clamping to the visible choices would silently rewrite the top one back to Off. This + // bound has to move every time the core enum grows, which is exactly the trap it was + // written to warn about: it was still UPSCALER_FSR1 when SGSR was added. + put("EmuCore/GS", "Upscaler", "int", upscaler.coerceIn(UPSCALER_OFF, UPSCALER_SGSR).toString()) put("EmuCore/GS", "FSRSharpness", "int", fsrSharpness.coerceIn(0, 100).toString()) put("EmuCore/GS", "LoadTextureReplacements", "bool", loadTextureReplacements.toString()) put("EmuCore/GS", "LoadTextureReplacementsAsync", "bool", loadTextureReplacementsAsync.toString()) @@ -1923,6 +1925,7 @@ data class Settings( * position in any Android picker — writing the picker index would select MetalFX. */ const val UPSCALER_OFF = 0 const val UPSCALER_FSR1 = 2 + const val UPSCALER_SGSR = 3 /** One-tap "Low-End" performance snapshot applied on top of [base]. * Only cheap, safe-for-most levers that already exist as fields: diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index ce8abacd63..209cd1f86a 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -1703,6 +1703,7 @@ private val BASE_EN: Map = mapOf( "touch.editor.scopeGlobal" to "Editing Global Default touch layout", "touch.editor.show" to "Show", "touch.editor.tapHoldOff" to "Tap-Hold Off", + "renderer.upscaler.label" to "Display upscaler", "secondScreen.ra.hardcore" to "Hardcore", "secondScreen.ra.casual" to "Casual", "secondScreen.tile.cover" to "Cover", diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt index f243461abc..a453c22376 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/RendererTab.kt @@ -399,16 +399,23 @@ fun RendererTab(state: MutableState) { apply(s.copy(fxaa = it)) } SettingsDivider() - val fsr1On = s.upscaler == Settings.UPSCALER_FSR1 - ToggleRow( - str("renderer.fsr1.label"), - fsr1On, - description = str("renderer.fsr1.description"), - ) { - apply(s.copy(upscaler = if (it) Settings.UPSCALER_FSR1 else Settings.UPSCALER_OFF)) - } - if (fsr1On) { + // A picker rather than the on/off toggle this was: with SGSR there are three + // mutually exclusive upscalers, and two toggles that silently turn each other off + // is a worse way to say that than one list. The UI index is NOT the enum value -- + // MetalFX is 1 and is Apple-only, so it has no row here. + val upscalerValues = listOf(Settings.UPSCALER_OFF, Settings.UPSCALER_FSR1, Settings.UPSCALER_SGSR) + val upscalerOn = s.upscaler == Settings.UPSCALER_FSR1 || s.upscaler == Settings.UPSCALER_SGSR + SegmentedRow( + label = str("renderer.upscaler.label"), + options = listOf(str("common.off"), "FSR 1", "SGSR"), + selectedIndex = upscalerValues.indexOf(s.upscaler).coerceAtLeast(0), + onChange = { apply(s.copy(upscaler = upscalerValues[it])) }, + ) + if (upscalerOn) { SettingsDivider() + // Shared slider. FSR1 maps it onto RCAS stops and SGSR onto its 0..2 edge + // sharpness, so the number means different things, but it is the same intent and + // a second slider would only invite disagreement between them. IntSliderRow( label = str("renderer.fsr1.sharpness.label"), value = s.fsrSharpness.coerceIn(0, 100), @@ -418,6 +425,7 @@ fun RendererTab(state: MutableState) { onChange = { apply(s.copy(fsrSharpness = it)) }, ) } + val fsr1On = upscalerOn // FSR's second pass IS RCAS, a contrast-adaptive sharpener, so the core runs one or // the other and never both. Showing CAS while FSR is on would offer a slider that // does nothing.