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.