GS: FidelityFX Super Resolution 1 as an output-scaling mode

Adds FSR1 (EASU upscale + RCAS sharpen, two compute passes) to the Vulkan
backend, so a game rendered below display size can be upscaled properly instead
of bilinear-stretched at present. Slots in beside the existing MetalFX branch in
GSRenderer rather than introducing a parallel abstraction: GSUpscaler and the
non-pure DoXxx virtuals already occupy that design space, and OpenGL and Metal
inherit a false return and need no change.

FSR1 is MIT (AMD, 2021) and the tree already ships ffx_a.h and ffx_cas.h under
the identical grant, so the headers are vendored verbatim with their licence
blocks intact.

★ ffx_a.h is NOT replaced. FSR1 wants the 2021 header, ours is 2019, and the
2019 one has been locally patched for Metal Shading Language (A16, A_MSL,
A_MAYBE_UNUSED) with ffx_cas.h depending on those. Swapping it would break the
Metal backend. The 2021 copy ships alongside as ffx_a_fsr1.h, used only for
GPU-side string substitution. The CPU-side FsrEasuConOffset/FsrRcasCon compile
against the existing 2019 header — verified by compiling a probe, not by
grepping, because AU1_AF1 and AU1_AH2_AF2 are functions and a grep for a #define
reports a false negative.

Two shader modules, not two specializations. FSR_EASU_F and FSR_RCAS_F are
preprocessor gates deciding which function bodies ffx_fsr1.h emits at all, and
specialization constants resolve after preprocessing, so CAS's constant_id trick
would produce a shader calling undefined functions. Confirmed distinct:
disassembly shows EASU with three OpImageGather and RCAS with none.

Both passes push the full 80-byte constant block. With all five uvec4 declared
so one layout serves both, Sample decorates to byte offset 64 — pushing the 32
bytes RCAS nominally needs would leave it undefined, and Sample gates a
gamma-squaring branch, so garbage there squares the image.

Binding 0 is a combined image sampler, unlike CAS's plain sampled image, because
EASU uses textureGather.

The EASU intermediate stays in GENERAL with explicit compute-to-compute
barriers. Layout::ShaderReadOnly targets the FRAGMENT stage and
TransitionToLayout early-outs when the layout already matches, so neither of the
usual tools makes a compute write visible to a compute read. The barrier also
covers frame N+1's EASU write against frame N's RCAS read, since the image is
parked across frames.

FSR and CAS are alternatives, not a chain: RCAS is itself a sharpener. Selecting
FSR hides the CAS rows. Pipeline compilation failure is non-fatal and leaves
Features().fsr1 false, matching the CAS path that exists because of an Adreno
650 crash.

GSUpscaler::FSR1 is appended, not inserted, since the enum is persisted as an
integer. Android clamps to the enum's own maximum rather than the count of
options its picker shows — clamping to the picker would have rewritten FSR1 back
to Off on every save, because MetalFX occupies value 1 and is never displayed.

Verified: build clean, no C++ or Kotlin errors; all three resource files
packaged into the APK; FSR code present in the core. NOT verified: anything on a
GPU. No visual check, no perf numbers, and in particular no confirmation that
textureGather in a compute shader works on the Adreno drivers this targets.
This commit is contained in:
jpolo1224
2026-08-16 12:11:33 -04:00
parent dfd92a4f31
commit eeb3affb13
15 changed files with 4352 additions and 15 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
// Based on the AMD FidelityFX Super Resolution 1.0 sample.
// Copyright(c) 2021 Advanced Micro Devices, Inc.All rights reserved.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// The #version line and FSR_PASS_EASU come from the backend, because FSR_EASU_F/FSR_RCAS_F are
// preprocessor gates deciding which function bodies ffx_fsr1.h emits at all. Selecting the pass
// with a specialization constant the way cas.glsl does would leave both calls unresolved.
#define A_GPU 1
#define A_GLSL 1
// FSR needs the 2021 revision of the portability header; ffx_a.h in this directory is the 2019 one
// that ffx_cas.h and the Metal backend are patched against, and must not be used here.
#include "ffx_a_fsr1.h"
// Both passes share this block so that one pipeline layout serves both. RCAS only reads Const0 and
// Sample, but Sample sits at offset 64 regardless, so the host has to push all five vectors.
layout(push_constant) uniform const_buffer
{
uvec4 Const0;
uvec4 Const1;
uvec4 Const2;
uvec4 Const3;
uvec4 Sample;
};
// EASU reads through textureGather, so unlike CAS this has to be a combined image sampler rather
// than a plain sampled image.
layout(set=0, binding=0) uniform sampler2D imgSrc;
layout(set=0, binding=1, rgba8) uniform writeonly image2D imgDst;
#if FSR_PASS_EASU
#define FSR_EASU_F 1
AF4 FsrEasuRF(AF2 p) { return textureGather(imgSrc, p, 0); }
AF4 FsrEasuGF(AF2 p) { return textureGather(imgSrc, p, 1); }
AF4 FsrEasuBF(AF2 p) { return textureGather(imgSrc, p, 2); }
#else
#define FSR_RCAS_F 1
AF4 FsrRcasLoadF(ASU2 p) { return texelFetch(imgSrc, ASU2(p), 0); }
// Our input is already linear and between 0 and 1, so nothing to transform. See ffx_fsr1.h
void FsrRcasInputF(inout AF1 r, inout AF1 g, inout AF1 b) {}
#endif
#include "ffx_fsr1.h"
void CurrFilter(AU2 pos)
{
AF3 c;
#if FSR_PASS_EASU
FsrEasuF(c, pos, Const0, Const1, Const2, Const3);
#else
FsrRcasF(c.r, c.g, c.b, pos, Const0);
#endif
// Sample.x selects AMD's gamma2 output path, which we never want - the host zeroes it. It is
// read unconditionally, so a short push constant write would square the whole image.
if (Sample.x == 1u)
c *= c;
imageStore(imgDst, ASU2(pos), AF4(c, 1.0));
}
layout(local_size_x=64) in;
void main()
{
// Do remapping of local xy in workgroup for a more PS-like swizzle pattern.
AU2 gxy = ARmp8x8(gl_LocalInvocationID.x)+AU2(gl_WorkGroupID.x<<4u,gl_WorkGroupID.y<<4u);
CurrFilter(gxy);
gxy.x += 8u;
CurrFilter(gxy);
gxy.y += 8u;
CurrFilter(gxy);
gxy.x -= 8u;
CurrFilter(gxy);
}
+6
View File
@@ -479,6 +479,9 @@ enum class GSUpscaler : u8
{
Off, ///< Plain bilinear present-time stretch (default).
MetalFXSpatial, ///< Apple MetalFX spatial upscaler (Metal backend, macOS 13+).
// 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).
};
enum class GSHWAutoFlushLevel : u8
@@ -1069,6 +1072,9 @@ struct Pcsx2Config
u8 LsfgFlowScale = 100;
u8 CAS_Sharpness = 50;
// FSR1's RCAS pass, 0..100. Mapped to AMD's "stops" scale in GSDevice::FSR1Upscale,
// where 0 stops is maximum sharpening - it is not the same curve as CAS_Sharpness.
u8 FSR_Sharpness = 50;
u8 ShadeBoost_Brightness = DEFAULT_SHADEBOOST_BRIGHTNESS;
u8 ShadeBoost_Contrast = DEFAULT_SHADEBOOST_CONTRAST;
u8 ShadeBoost_Saturation = DEFAULT_SHADEBOOST_SATURATION;
+97 -1
View File
@@ -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);
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);
}
GSVector2i GSDevice::GetPresentationSize() const
@@ -1228,6 +1228,8 @@ void GSDevice::ClearCurrent()
delete m_target_tmp;
delete m_cas;
delete m_mfx_output;
delete m_fsr1_easu;
delete m_fsr1_output;
m_merge = nullptr;
m_weavebob = nullptr;
@@ -1236,6 +1238,8 @@ void GSDevice::ClearCurrent()
m_target_tmp = nullptr;
m_cas = nullptr;
m_mfx_output = nullptr;
m_fsr1_easu = nullptr;
m_fsr1_output = nullptr;
}
void GSDevice::Merge(GSTexture* sTex[3], GSVector4* sRect, GSVector4* dRect, const GSVector2i& fs, const GSRegPMODE& PMODE, const GSRegEXTBUF& EXTBUF, u32 c)
@@ -1474,6 +1478,11 @@ void GSDevice::EndDSAsRT()
#define A_CPU 1
#include "bin/resources/shaders/common/ffx_a.h"
#include "bin/resources/shaders/common/ffx_cas.h"
// FSR needs the 2021 revision of ffx_a.h on the GPU side, but its CPU-side constant setup
// (FsrEasuConOffset/FsrRcasCon) is satisfied by the 2019 header above - which is the one
// PCSX2 patched locally for Metal (A16/A_MSL/A_MAYBE_UNUSED) and that ffx_cas.h depends on.
// So only the shader gets the 2021 copy; nothing here includes ffx_a_fsr1.h.
#include "bin/resources/shaders/common/ffx_fsr1.h"
#if defined(__clang__)
#pragma clang diagnostic pop
@@ -1494,6 +1503,25 @@ bool GSDevice::GetCASShaderSource(std::string* source)
return true;
}
bool GSDevice::GetFSR1ShaderSource(std::string* source, bool easu_pass)
{
std::optional<std::string> ffx_a_source = ReadShaderSource("shaders/common/ffx_a_fsr1.h");
std::optional<std::string> ffx_fsr1_source = ReadShaderSource("shaders/common/ffx_fsr1.h");
if (!ffx_a_source.has_value() || !ffx_fsr1_source.has_value())
return false;
// FSR_EASU_F/FSR_RCAS_F gate which function bodies ffx_fsr1.h emits at all, so the pass has
// to be chosen before the preprocessor runs. That is why this takes easu_pass rather than
// letting the backend pick with a specialization constant the way cas.glsl does.
source->insert(0, easu_pass ? "#version 460 core\n#define FSR_PASS_EASU 1\n"
: "#version 460 core\n#define FSR_PASS_EASU 0\n");
// Same cheeky string replace as CAS above - our shader compilers don't support includes.
StringUtil::ReplaceAll(source, "#include \"ffx_a_fsr1.h\"", ffx_a_source.value());
StringUtil::ReplaceAll(source, "#include \"ffx_fsr1.h\"", ffx_fsr1_source.value());
return true;
}
void GSDevice::CAS(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src_uv, const GSVector4& draw_rect, bool sharpen_only)
{
FlushDeferredDraws();
@@ -1565,6 +1593,74 @@ void GSDevice::MetalFXUpscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4&
src_uv = GSVector4(0.0f, 0.0f, 1.0f, 1.0f);
}
void GSDevice::FSR1Upscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src_uv, const GSVector4& draw_rect)
{
FlushDeferredDraws();
const int dst_width = static_cast<int>(std::ceil(draw_rect.z - draw_rect.x));
const int dst_height = static_cast<int>(std::ceil(draw_rect.w - draw_rect.y));
if (dst_width <= 0 || dst_height <= 0)
return;
GSTexture* src_tex = tex;
// Two targets, not one: RCAS is a separate dispatch that reads EASU's whole output, so it
// cannot write in place.
if (!m_fsr1_easu || m_fsr1_easu->GetWidth() != dst_width || m_fsr1_easu->GetHeight() != dst_height)
{
delete m_fsr1_easu;
m_fsr1_easu = CreateSurface(GSTexture::ShaderWriteTexture, dst_width, dst_height, 1, GSTexture::Format::Color);
if (!m_fsr1_easu)
{
Console.Error("Failed to allocate FSR1 EASU texture.");
return;
}
}
if (!m_fsr1_output || m_fsr1_output->GetWidth() != dst_width || m_fsr1_output->GetHeight() != dst_height)
{
delete m_fsr1_output;
m_fsr1_output = CreateSurface(GSTexture::ShaderWriteTexture, dst_width, dst_height, 1, GSTexture::Format::Color);
if (!m_fsr1_output)
{
Console.Error("Failed to allocate FSR1 RCAS texture.");
return;
}
}
// Zero-initialised, and pushed whole by both passes: the shader reads the fifth vector
// ("Sample") unconditionally to pick AMD's gamma2 output path, which we never want.
std::array<u32, NUM_FSR1_CONSTANTS> consts = {};
// EASU distinguishes the displayed region from the resource holding it: the viewport is the
// cropped src_rect, the size is the whole texture, and the offset puts the two together.
FsrEasuConOffset(&consts[0], &consts[4], &consts[8], &consts[12],
static_cast<AF1>(src_rect.width()), static_cast<AF1>(src_rect.height()),
static_cast<AF1>(src_tex->GetWidth()), static_cast<AF1>(src_tex->GetHeight()),
static_cast<AF1>(dst_width), static_cast<AF1>(dst_height),
static_cast<AF1>(src_rect.x), static_cast<AF1>(src_rect.y));
if (!DoFSR1EASU(src_tex, m_fsr1_easu, consts))
{
// leave textures intact if we failed
Console.Warning("Applying FSR1 EASU failed.");
return;
}
// RCAS takes sharpness in stops - 0 is the maximum and each stop halves it - so the 0..100
// slider runs backwards across the 2..0 range AMD's own sample exposes.
std::array<u32, NUM_FSR1_CONSTANTS> rcas_consts = {};
FsrRcasCon(&rcas_consts[0], 2.0f - (static_cast<float>(GSConfig.FSR_Sharpness) * 0.02f));
if (!DoFSR1RCAS(m_fsr1_easu, m_fsr1_output, rcas_consts))
{
Console.Warning("Applying FSR1 RCAS failed.");
return;
}
tex = m_fsr1_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)) ||
+21
View File
@@ -1416,6 +1416,7 @@ public:
bool aa1 : 1; ///< Supports the GS AA1 feature.
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 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()
@@ -1513,6 +1514,10 @@ protected:
static constexpr u32 MAX_POOLED_TEXTURES = 300;
static constexpr u32 MAX_TEXTURE_AGE = 10;
static constexpr u32 NUM_CAS_CONSTANTS = 12; // 8 plus src offset x/y, 16 byte alignment
// Five uvec4s: EASU's con0..con3 plus FSR's "Sample" vector. RCAS only reads con0 and
// 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;
static constexpr u32 EXPAND_BUFFER_SIZE = sizeof(u16) * 16383 * 6;
WindowInfo m_window_info;
@@ -1529,6 +1534,8 @@ protected:
GSTexture* m_current = nullptr;
GSTexture* m_cas = nullptr;
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_colclip_rt = nullptr; ///< Temp hw colclip texture
GSTexture* m_ds_as_rt = nullptr; ///< Depth as color
@@ -1582,6 +1589,17 @@ protected:
/// Base implementation is a no-op; only the Metal backend overrides it.
virtual bool DoMetalFXSpatial(GSTexture* sTex, GSTexture* dTex) { return false; }
/// Resolves FSR1 shader includes for the specified source, and prepends the #version line
/// plus the FSR_PASS_EASU gate. The pass cannot be a specialization constant: it decides
/// which function bodies ffx_fsr1.h emits at all, which is a preprocessor-time question.
static bool GetFSR1ShaderSource(std::string* source, bool easu_pass);
/// FSR1 pass 1 (EASU): edge-adaptive spatial upsample from sTex to dTex's size.
/// FSR1 pass 2 (RCAS): robust contrast-adaptive sharpen, same size in and out.
/// Both no-op in the base class so only the backends that gate Features().fsr1 on need them.
virtual bool DoFSR1EASU(GSTexture* sTex, GSTexture* dTex, const std::array<u32, NUM_FSR1_CONSTANTS>& constants) { return false; }
virtual bool DoFSR1RCAS(GSTexture* sTex, GSTexture* dTex, const std::array<u32, NUM_FSR1_CONSTANTS>& constants) { return false; }
/// Perform texture operations for ImGui
void UpdateImGuiTextures();
@@ -2004,6 +2022,9 @@ public:
/// tex/src_rect/src_uv to point at the upscaled result, mirroring CAS().
void MetalFXUpscale(GSTexture*& tex, GSVector4i& src_rect, GSVector4& src_uv, const GSVector4& draw_rect);
/// Same contract as MetalFXUpscale(), via FSR1's two compute passes.
void FSR1Upscale(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();
+23 -1
View File
@@ -1073,7 +1073,29 @@ void GSRenderer::VSync(u32 field, bool registers_written, bool idle_frame)
}
}
if (GSConfig.CASMode != GSCASMode::Disabled)
// FSR1 runs here for the same reason MetalFX does - its passes are compute, and the
// 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.
if (GSConfig.Upscaler == GSUpscaler::FSR1)
{
static bool fsr1_log_once = false;
if (g_gs_device->Features().fsr1)
{
const int draw_w = static_cast<int>(std::ceil(draw_rect.z - draw_rect.x));
const int draw_h = static_cast<int>(std::ceil(draw_rect.w - draw_rect.y));
if (current->GetWidth() < draw_w && current->GetHeight() < draw_h)
g_gs_device->FSR1Upscale(current, src_rect, src_uv, draw_rect);
}
else if (!fsr1_log_once)
{
Host::AddIconOSDMessage("FSR1Unsupported", ICON_FA_TRIANGLE_EXCLAMATION,
TRANSLATE_SV("GS", "FSR1 upscaling is not available, your graphics driver does not support the required functionality."),
10.0f);
fsr1_log_once = true;
}
}
else if (GSConfig.CASMode != GSCASMode::Disabled)
{
static bool cas_log_once = false;
if (g_gs_device->Features().cas_sharpening)
+170
View File
@@ -2633,6 +2633,15 @@ bool GSDeviceVK::Create(GSVSyncMode vsync_mode, bool allow_present_throttle)
m_features.cas_sharpening = false;
}
// 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 (!CompileFSR1Pipelines())
{
Console.Warning("VK: FSR1 pipeline compilation failed - disabling FSR1 upscaling.");
m_features.fsr1 = false;
}
if (!CompileImGuiPipeline())
return false;
@@ -6040,6 +6049,54 @@ bool GSDeviceVK::CompileCASPipelines()
return true;
}
bool GSDeviceVK::CompileFSR1Pipelines()
{
VkDevice dev = m_device;
Vulkan::DescriptorSetLayoutBuilder dslb;
Vulkan::PipelineLayoutBuilder plb;
if (m_use_push_descriptors)
dslb.SetPushFlag();
// Combined image sampler, not SAMPLED_IMAGE as CAS uses: EASU reads through textureGather,
// which needs 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_fsr1_ds_layout = dslb.Create(dev)) == VK_NULL_HANDLE)
return false;
Vulkan::SetObjectName(dev, m_fsr1_ds_layout, "FSR1 descriptor layout");
plb.AddPushConstants(VK_SHADER_STAGE_COMPUTE_BIT, 0, NUM_FSR1_CONSTANTS * sizeof(u32));
plb.AddDescriptorSet(m_fsr1_ds_layout);
if ((m_fsr1_pipeline_layout = plb.Create(dev)) == VK_NULL_HANDLE)
return false;
Vulkan::SetObjectName(dev, m_fsr1_pipeline_layout, "FSR1 pipeline layout");
// Two modules from two differently-#define'd copies of the same file, where CAS gets away
// with one module and a specialization constant: FSR_EASU_F/FSR_RCAS_F decide which function
// bodies ffx_fsr1.h emits, so a specialization constant would leave both calls unresolved.
for (u8 easu_pass = 0; easu_pass < NUM_FSR1_PIPELINES; easu_pass++)
{
std::optional<std::string> fsr1_source = ReadShaderSource("shaders/vulkan/fsr1.glsl");
if (!fsr1_source.has_value() || !GetFSR1ShaderSource(&fsr1_source.value(), easu_pass != 0))
return false;
VkShaderModule mod = g_vulkan_shader_cache->GetComputeShader(fsr1_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_fsr1_pipeline_layout);
cpb.SetShader(mod, "main");
m_fsr1_pipelines[easu_pass] = cpb.Create(dev, g_vulkan_shader_cache->GetPipelineCache(true), false);
if (!m_fsr1_pipelines[easu_pass])
return false;
}
m_features.fsr1 = true;
return true;
}
bool GSDeviceVK::CompileImGuiPipeline()
{
const std::optional<std::string> glsl = ReadShaderSource("shaders/vulkan/imgui.glsl");
@@ -6312,6 +6369,108 @@ bool GSDeviceVK::DoCAS(
return true;
}
bool GSDeviceVK::DoFSR1EASU(GSTexture* sTex, GSTexture* dTex, const std::array<u32, NUM_FSR1_CONSTANTS>& constants)
{
return DoFSR1Pass(sTex, dTex, true, constants);
}
bool GSDeviceVK::DoFSR1RCAS(GSTexture* sTex, GSTexture* dTex, const std::array<u32, NUM_FSR1_CONSTANTS>& constants)
{
return DoFSR1Pass(sTex, dTex, false, constants);
}
bool GSDeviceVK::DoFSR1Pass(
GSTexture* sTex, GSTexture* dTex, bool easu_pass, const std::array<u32, NUM_FSR1_CONSTANTS>& constants)
{
g_perfmon.Put(GSPerfMon::TextureCopies, 1);
EndRenderPass();
GSTextureVK* const sTexVK = static_cast<GSTextureVK*>(sTex);
GSTextureVK* const dTexVK = static_cast<GSTextureVK*>(dTex);
VkCommandBuffer cmdbuf = GetCurrentCommandBuffer();
// The EASU intermediate is handed to RCAS in compute and so never leaves GENERAL, which
// defeats both of the backend's usual tools: Layout::ShaderReadOnly's barrier targets
// VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT and would not make compute stores visible to a
// compute read, and TransitionToLayout() early-outs when the layout already matches, so
// re-requesting ComputeReadWriteImage emits nothing at all. Anything already in GENERAL
// therefore needs its compute<->compute dependency stated by hand.
const auto compute_barrier = [cmdbuf](GSTextureVK* tex, VkAccessFlags src_access, VkAccessFlags dst_access) {
const VkImageMemoryBarrier barrier = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, nullptr, src_access, dst_access,
VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, VK_QUEUE_FAMILY_IGNORED, VK_QUEUE_FAMILY_IGNORED,
tex->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);
};
if (sTexVK->GetLayout() == GSTextureVK::Layout::ComputeReadWriteImage)
{
// RCAS reading EASU's output. Without this it reads undefined data.
compute_barrier(sTexVK, VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT);
}
else
{
// EASU's input is the merged display texture, arriving from a colour-attachment write
// exactly as CAS's does.
sTexVK->TransitionToLayout(cmdbuf, GSTextureVK::Layout::ShaderReadOnly);
}
if (dTexVK->GetLayout() == GSTextureVK::Layout::ComputeReadWriteImage)
{
// Every frame after the first: the intermediate was left in GENERAL for RCAS to read, so
// this is what orders EASU's writes against the previous frame's RCAS reads of it.
compute_barrier(dTexVK, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_WRITE_BIT);
}
else
{
dTexVK->TransitionToLayout(cmdbuf, GSTextureVK::Layout::ComputeReadWriteImage);
}
// EASU gathers with normalised coordinates, so it needs the linear/clamp-to-edge sampler;
// RCAS only texelFetches and ignores the sampler entirely.
const VkSampler sampler = easu_pass ? m_linear_sampler : m_point_sampler;
// only happening once a frame, so the update isn't a huge deal.
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_fsr1_pipeline_layout, 0, false);
}
else
{
VkDescriptorSet ds = AllocateDescriptorSetFromFramePool(m_fsr1_ds_layout);
if (ds == VK_NULL_HANDLE) [[unlikely]]
return false; // two allocs per frame after EndRenderPass - exhaustion implausible; skip the pass
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_fsr1_pipeline_layout, 0, 1, &ds, 0, nullptr);
}
static const int threadGroupWorkRegionDim = 16;
const int dispatchX = (dTex->GetWidth() + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim;
const int dispatchY = (dTex->GetHeight() + (threadGroupWorkRegionDim - 1)) / threadGroupWorkRegionDim;
// Full 80 bytes for both passes. RCAS only reads Const0, but the shared block puts `Sample`
// at byte 64 either way, and it is read unconditionally - a 32-byte push leaves it undefined
// and the shader squares the image.
vkCmdPushConstants(cmdbuf, m_fsr1_pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0,
NUM_FSR1_CONSTANTS * sizeof(u32), constants.data());
vkCmdBindPipeline(cmdbuf, VK_PIPELINE_BIND_POINT_COMPUTE, m_fsr1_pipelines[static_cast<u8>(easu_pass)]);
vkCmdDispatch(cmdbuf, dispatchX, dispatchY, 1);
// The EASU target goes straight into RCAS in compute, so leave it in GENERAL and let the
// barrier at the top of the next pass order the two dispatches. Only RCAS's output is handed
// to the present pass, which samples it from the fragment stage.
if (!easu_pass)
dTexVK->TransitionToLayout(GSTextureVK::Layout::ShaderReadOnly);
return true;
}
void GSDeviceVK::DestroyResources()
{
if (m_tfx_ubo_descriptor_set != VK_NULL_HANDLE)
@@ -6375,6 +6534,17 @@ void GSDeviceVK::DestroyResources()
vkDestroyPipelineLayout(m_device, m_cas_pipeline_layout, nullptr);
if (m_cas_ds_layout != VK_NULL_HANDLE)
vkDestroyDescriptorSetLayout(m_device, m_cas_ds_layout, nullptr);
for (VkPipeline it : m_fsr1_pipelines)
{
if (it != VK_NULL_HANDLE)
vkDestroyPipeline(m_device, it, nullptr);
}
if (m_fsr1_pipeline_layout != VK_NULL_HANDLE)
vkDestroyPipelineLayout(m_device, m_fsr1_pipeline_layout, nullptr);
if (m_fsr1_ds_layout != VK_NULL_HANDLE)
vkDestroyDescriptorSetLayout(m_device, m_fsr1_ds_layout, nullptr);
if (m_imgui_pipeline != VK_NULL_HANDLE)
vkDestroyPipeline(m_device, m_imgui_pipeline, nullptr);
+12
View File
@@ -449,6 +449,7 @@ public:
CONVERT_PUSH_CONSTANTS_SIZE = 96,
NUM_CAS_PIPELINES = 2,
NUM_FSR1_PIPELINES = 2, // [0] RCAS, [1] EASU
};
enum TFX_DESCRIPTOR_SET : u32
{
@@ -541,6 +542,9 @@ private:
VkDescriptorSetLayout m_cas_ds_layout = VK_NULL_HANDLE;
VkPipelineLayout m_cas_pipeline_layout = VK_NULL_HANDLE;
std::array<VkPipeline, NUM_CAS_PIPELINES> m_cas_pipelines = {};
VkDescriptorSetLayout m_fsr1_ds_layout = VK_NULL_HANDLE;
VkPipelineLayout m_fsr1_pipeline_layout = VK_NULL_HANDLE;
std::array<VkPipeline, NUM_FSR1_PIPELINES> m_fsr1_pipelines = {};
VkPipeline m_imgui_pipeline = VK_NULL_HANDLE;
GSHWDrawConfig::VSConstantBuffer m_vs_cb_cache;
@@ -578,6 +582,13 @@ private:
bool DoCAS(
GSTexture* sTex, GSTexture* dTex, bool sharpen_only, const std::array<u32, NUM_CAS_CONSTANTS>& constants) final;
bool DoFSR1EASU(GSTexture* sTex, GSTexture* dTex, const std::array<u32, NUM_FSR1_CONSTANTS>& constants) final;
bool DoFSR1RCAS(GSTexture* sTex, GSTexture* dTex, const std::array<u32, NUM_FSR1_CONSTANTS>& constants) final;
/// Shared body of the two above: same layout, same push range, different pipeline and
/// different input-side synchronisation.
bool DoFSR1Pass(
GSTexture* sTex, GSTexture* dTex, bool easu_pass, const std::array<u32, NUM_FSR1_CONSTANTS>& constants);
VkSampler GetSampler(GSHWDrawConfig::SamplerSelector ss);
void ClearSamplerCache() final;
@@ -602,6 +613,7 @@ private:
bool CompileMergePipelines();
bool CompilePostProcessingPipelines();
bool CompileCASPipelines();
bool CompileFSR1Pipelines();
bool CompileImGuiPipeline();
void RenderImGui();
+3
View File
@@ -899,6 +899,7 @@ bool Pcsx2Config::GSOptions::OptionsAreEqual(const GSOptions& right) const
OpEqu(BackThreadMode) &&
OpEqu(CAS_Sharpness) &&
OpEqu(FSR_Sharpness) &&
OpEqu(ShadeBoost_Brightness) &&
OpEqu(ShadeBoost_Contrast) &&
OpEqu(ShadeBoost_Saturation) &&
@@ -1155,6 +1156,8 @@ void Pcsx2Config::GSOptions::LoadSave(SettingsWrapper& wrap)
SettingsWrapIntEnumEx(CASMode, "CASMode");
SettingsWrapIntEnumEx(Upscaler, "Upscaler");
SettingsWrapBitfieldEx(CAS_Sharpness, "CASSharpness");
// Bitfield, not Entry: FSR_Sharpness is a u8, same as CAS_Sharpness above.
SettingsWrapBitfieldEx(FSR_Sharpness, "FSRSharpness");
SettingsWrapBitfieldEx(Dithering, "dithering_ps2");
SettingsWrapBitfieldEx(MaxAnisotropy, "MaxAnisotropy");
SettingsWrapBitfieldEx(SWExtraThreads, "extrathreads");
@@ -602,6 +602,13 @@ 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.
* 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,
/** EmuCore/GS/FSRSharpness FSR1 RCAS strength 0..100 (%). Separate from casSharpness:
* RCAS runs on a different curve, so the two sliders are not interchangeable. */
val fsrSharpness: Int = 50,
/** EmuCore/GS/LoadTextureReplacements. */
val loadTextureReplacements: Boolean = false,
/** EmuCore/GS/LoadTextureReplacementsAsync. */
@@ -1186,6 +1193,8 @@ data class Settings(
} ?: this.shaderChainParams,
casMode = intAt("EmuCore/GS/CASMode") ?: this.casMode,
casSharpness = intAt("EmuCore/GS/CASSharpness") ?: this.casSharpness,
upscaler = intAt("EmuCore/GS/Upscaler") ?: this.upscaler,
fsrSharpness = intAt("EmuCore/GS/FSRSharpness") ?: this.fsrSharpness,
loadTextureReplacements = boolAt("EmuCore/GS/LoadTextureReplacements") ?: this.loadTextureReplacements,
loadTextureReplacementsAsync = boolAt("EmuCore/GS/LoadTextureReplacementsAsync") ?: this.loadTextureReplacementsAsync,
precacheTextureReplacements = boolAt("EmuCore/GS/PrecacheTextureReplacements") ?: this.precacheTextureReplacements,
@@ -1399,6 +1408,10 @@ 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())
put("EmuCore/GS", "FSRSharpness", "int", fsrSharpness.coerceIn(0, 100).toString())
put("EmuCore/GS", "LoadTextureReplacements", "bool", loadTextureReplacements.toString())
put("EmuCore/GS", "LoadTextureReplacementsAsync", "bool", loadTextureReplacementsAsync.toString())
put("EmuCore/GS", "PrecacheTextureReplacements", "bool", precacheTextureReplacements.toString())
@@ -1570,6 +1583,8 @@ data class Settings(
lsfgFlowScale != other.lsfgFlowScale ||
casMode != other.casMode ||
casSharpness != other.casSharpness ||
upscaler != other.upscaler ||
fsrSharpness != other.fsrSharpness ||
accurateBlendingUnit != other.accurateBlendingUnit ||
hwMipmap != other.hwMipmap ||
triFilter != other.triFilter ||
@@ -1794,6 +1809,8 @@ data class Settings(
put("lsfgFlowScale", lsfgFlowScale)
put("casMode", casMode)
put("casSharpness", casSharpness)
put("upscaler", upscaler)
put("fsrSharpness", fsrSharpness)
put("loadTextureReplacements", loadTextureReplacements)
put("loadTextureReplacementsAsync", loadTextureReplacementsAsync)
put("precacheTextureReplacements", precacheTextureReplacements)
@@ -1857,6 +1874,12 @@ data class Settings(
@JvmStatic
internal var emitSink: ((String, String, String, String) -> Unit)? = null
/** [upscaler] values, straight from the core's GSUpscaler. Named because 1 is Apple's
* MetalFX and never appears in this UI, so FSR1's value (2) does NOT line up with its
* position in any Android picker writing the picker index would select MetalFX. */
const val UPSCALER_OFF = 0
const val UPSCALER_FSR1 = 2
/** One-tap "Low-End" performance snapshot applied on top of [base].
* Only cheap, safe-for-most levers that already exist as fields:
* - accurate_blending_unit = Minimum (0) cheapest blend path
@@ -2073,6 +2096,8 @@ data class Settings(
lsfgFlowScale = json.optInt("lsfgFlowScale", def.lsfgFlowScale),
casMode = json.optInt("casMode", def.casMode),
casSharpness = json.optInt("casSharpness", def.casSharpness),
upscaler = json.optInt("upscaler", def.upscaler),
fsrSharpness = json.optInt("fsrSharpness", def.fsrSharpness),
loadTextureReplacements = json.optBoolean("loadTextureReplacements", def.loadTextureReplacements),
loadTextureReplacementsAsync = json.optBoolean("loadTextureReplacementsAsync", def.loadTextureReplacementsAsync),
precacheTextureReplacements = json.optBoolean("precacheTextureReplacements", def.precacheTextureReplacements),
@@ -2316,6 +2341,8 @@ data class Settings(
if (current.lsfgFlowScale != base.lsfgFlowScale) j.put("lsfgFlowScale", current.lsfgFlowScale)
if (current.casMode != base.casMode) j.put("casMode", current.casMode)
if (current.casSharpness != base.casSharpness) j.put("casSharpness", current.casSharpness)
if (current.upscaler != base.upscaler) j.put("upscaler", current.upscaler)
if (current.fsrSharpness != base.fsrSharpness) j.put("fsrSharpness", current.fsrSharpness)
if (current.loadTextureReplacements != base.loadTextureReplacements) j.put("loadTextureReplacements", current.loadTextureReplacements)
if (current.loadTextureReplacementsAsync != base.loadTextureReplacementsAsync) j.put("loadTextureReplacementsAsync", current.loadTextureReplacementsAsync)
if (current.precacheTextureReplacements != base.precacheTextureReplacements) j.put("precacheTextureReplacements", current.precacheTextureReplacements)
@@ -2576,6 +2603,8 @@ data class Settings(
lsfgFlowScale = if (overrides.has("lsfgFlowScale")) overrides.getInt("lsfgFlowScale") else base.lsfgFlowScale,
casMode = if (overrides.has("casMode")) overrides.getInt("casMode") else base.casMode,
casSharpness = if (overrides.has("casSharpness")) overrides.getInt("casSharpness") else base.casSharpness,
upscaler = if (overrides.has("upscaler")) overrides.getInt("upscaler") else base.upscaler,
fsrSharpness = if (overrides.has("fsrSharpness")) overrides.getInt("fsrSharpness") else base.fsrSharpness,
loadTextureReplacements = if (overrides.has("loadTextureReplacements")) overrides.getBoolean("loadTextureReplacements") else base.loadTextureReplacements,
loadTextureReplacementsAsync = if (overrides.has("loadTextureReplacementsAsync")) overrides.getBoolean("loadTextureReplacementsAsync") else base.loadTextureReplacementsAsync,
precacheTextureReplacements = if (overrides.has("precacheTextureReplacements")) overrides.getBoolean("precacheTextureReplacements") else base.precacheTextureReplacements,
@@ -1231,6 +1231,9 @@ val EN: Map<String, String> = mapOf(
"renderer.cas.sharpen" to "Sharpen",
"renderer.cas.sharpenResize" to "Sharpen + Resize",
"renderer.cas.sharpness.label" to "CAS Sharpness",
"renderer.fsr1.description" to "AMD FidelityFX Super Resolution 1 — upscales the frame to the screen with edge-adaptive sharpening instead of a plain stretch. Replaces CAS.",
"renderer.fsr1.label" to "FSR 1 Upscaling",
"renderer.fsr1.sharpness.label" to "FSR Sharpness",
"renderer.fxaa.description" to "Fast post-process anti-aliasing — smooths jagged edges with a light blur.",
"renderer.fxaa.label" to "FXAA",
"renderer.deinterlacing.description" to "Changes how interlaced video is displayed. Auto is safest.",
@@ -398,6 +398,30 @@ fun RendererTab(state: MutableState<Settings>) {
) {
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) {
SettingsDivider()
IntSliderRow(
label = str("renderer.fsr1.sharpness.label"),
value = s.fsrSharpness.coerceIn(0, 100),
min = 0,
max = 100,
valueFormatter = { "$it%" },
onChange = { apply(s.copy(fsrSharpness = it)) },
)
}
// 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.
if (!fsr1On) {
SettingsDivider()
SegmentedRow(
label = str("renderer.cas.label"),
@@ -417,6 +441,7 @@ fun RendererTab(state: MutableState<Settings>) {
onChange = { apply(s.copy(casSharpness = it)) },
)
}
}
SettingsDivider()
// RetroArch (.slangp) chains run last in the post-process order (after
// ShadeBoost/FXAA/CAS), so they close out Display Effects. Presentation-only
@@ -39,7 +39,7 @@ internal val SETTINGS_CATEGORY_FIELDS: Map<SettingsCategory, List<String>> = map
SettingsCategory.Graphics to listOf(
"accurateBlendingUnit", "adrenoFbFetch", "aspectRatio", "casMode", "casSharpness",
"customAspectRatio", "deinterlaceMode", "displayBilinear", "dumpReplaceableTextures", "fmvAspectRatio",
"forceMaliFbFetch", "fxaa", "gpuProfile", "gsBackThreadMode", "hardwareDownloadMode",
"forceMaliFbFetch", "fsrSharpness", "fxaa", "gpuProfile", "gsBackThreadMode", "hardwareDownloadMode",
"hwAa1", "hwAccurateAlphaTest", "hwMipmap", "hwRov", "loadTextureReplacements",
"loadTextureReplacementsAsync", "maxAnisotropy", "orientation",
"osdShowTextureReplacements", "portraitRenderTop", "landscapeRenderTop", "autoProgressiveScan",
@@ -47,7 +47,7 @@ internal val SETTINGS_CATEGORY_FIELDS: Map<SettingsCategory, List<String>> = map
"shadeBoost", "shadeBoostBrightness", "shadeBoostContrast", "shadeBoostGamma",
"shadeBoostSaturation", "shaderChainEnabled", "shaderChainParams", "shaderChainPreset",
"textureFiltering", "texturePreloading", "triFilter", "tvShader", "upscaleFloat",
"vsyncEnable",
"upscaler", "vsyncEnable",
),
// AudioTab.kt
SettingsCategory.Audio to listOf(
@@ -106,6 +106,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
SettingsSearchEntry("renderer.contrast.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.saturation.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.gamma.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.fsr1.sharpness.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.cas.sharpness.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.displayMode.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.fmvAspect.label", true, SettingsCategory.Graphics),
@@ -116,6 +117,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
SettingsSearchEntry("renderer.textureFiltering.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.texturePreloading.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.displayFilter.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.fsr1.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.cas.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.blendingAccuracy.label", true, SettingsCategory.Graphics),
SettingsSearchEntry("renderer.trilinear.label", true, SettingsCategory.Graphics),