feat(renderer): Add ScaleFX pixel art upscaling filter

Implements ScaleFX algorithm for pixel art upscaling with edge
preservation. Supports both OpenGL and Vulkan with FP16/FP32
variants for hardware optimization.

ScaleFX is designed to reduce pixelation while preserving sharp
edges, ideal for low-resolution and pixel art games.

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2025-10-11 13:35:19 +10:00
parent 0c5c1bbf7f
commit 568ab699f6
12 changed files with 201 additions and 0 deletions

View File

@@ -406,6 +406,7 @@ std::unique_ptr<ComboboxTranslationMap> ComboboxEnumeration(QWidget* parent) {
PAIR(ScalingFilter, Lanczos, tr("Lanczos")),
PAIR(ScalingFilter, Gaussian, tr("Gaussian")),
PAIR(ScalingFilter, ScaleForce, tr("ScaleForce")),
PAIR(ScalingFilter, ScaleFx, tr("ScaleFX")),
PAIR(ScalingFilter, Fsr, tr("AMD FidelityFX™ Super Resolution")),
PAIR(ScalingFilter, Fsr2, tr("AMD FidelityFX™ Super Resolution 2.0")),
}});

View File

@@ -42,6 +42,8 @@ static const std::map<Settings::ScalingFilter, QString> scaling_filter_texts_map
{Settings::ScalingFilter::Lanczos, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "Lanczos"))},
{Settings::ScalingFilter::ScaleForce,
QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "ScaleForce"))},
{Settings::ScalingFilter::ScaleFx,
QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "ScaleFX"))},
{Settings::ScalingFilter::Fsr, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "FSR"))},
{Settings::ScalingFilter::Fsr2, QStringLiteral(QT_TRANSLATE_NOOP("GMainWindow", "FSR 2.0"))},
};

View File

@@ -40,6 +40,7 @@ set(SHADER_FILES
opengl_present.frag
opengl_present.vert
opengl_present_scaleforce.frag
opengl_present_scalefx.frag
opengl_smaa.glsl
pitch_unswizzle.comp
present_bicubic.frag
@@ -70,6 +71,8 @@ set(SHADER_FILES
vulkan_present.vert
vulkan_present_scaleforce_fp16.frag
vulkan_present_scaleforce_fp32.frag
vulkan_present_scalefx_fp16.frag
vulkan_present_scalefx_fp32.frag
vulkan_quad_indexed.comp
vulkan_taa.frag
vulkan_taa.vert

View File

@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// ScaleFX shader adapted from libretro's ScaleFX implementation
// Original: https://github.com/libretro/slang-shaders/tree/master/scalefx
// ScaleFX is a pixel art scaling algorithm designed to preserve sharp edges
#ifndef VERSION
#version 460
#endif
#extension GL_ARB_separate_shader_objects : enable
#ifdef CITRON_USE_FP16
#extension GL_AMD_gpu_shader_half_float : enable
#extension GL_NV_gpu_shader5 : enable
#define lfloat float16_t
#define lvec2 f16vec2
#define lvec3 f16vec3
#define lvec4 f16vec4
#else
#define lfloat float
#define lvec2 vec2
#define lvec3 vec3
#define lvec4 vec4
#endif
layout (location = 0) in vec2 tex_coord;
layout (location = 0) out vec4 frag_color;
layout (binding = 0) uniform sampler2D input_texture;
// ScaleFX configuration
const lfloat SFX_CLR = lfloat(0.35); // Color threshold - lower = more edge detection
const lfloat SFX_SAA = lfloat(1.0); // Sub-pixel anti-aliasing strength
// Helper function to calculate color difference using weighted RGB
lfloat df(lvec3 A, lvec3 B) {
lvec3 diff = A - B;
return dot(abs(diff), lvec3(1.0, 1.0, 1.0));
}
// Check if two colors are similar within threshold
bool eq(lvec3 A, lvec3 B) {
return df(A, B) < SFX_CLR;
}
// ScaleFX main algorithm
lvec4 scalefx(sampler2D tex, vec2 tex_coord) {
vec2 ps = vec2(1.0) / vec2(textureSize(tex, 0));
vec2 pos = fract(tex_coord * vec2(textureSize(tex, 0)) - vec2(0.5));
// Sample 3x3 grid around current pixel
// A B C
// D E F
// G H I
lvec3 A = lvec3(texture(tex, tex_coord + vec2(-1, -1) * ps).rgb);
lvec3 B = lvec3(texture(tex, tex_coord + vec2( 0, -1) * ps).rgb);
lvec3 C = lvec3(texture(tex, tex_coord + vec2( 1, -1) * ps).rgb);
lvec3 D = lvec3(texture(tex, tex_coord + vec2(-1, 0) * ps).rgb);
lvec3 E = lvec3(texture(tex, tex_coord).rgb);
lvec3 F = lvec3(texture(tex, tex_coord + vec2( 1, 0) * ps).rgb);
lvec3 G = lvec3(texture(tex, tex_coord + vec2(-1, 1) * ps).rgb);
lvec3 H = lvec3(texture(tex, tex_coord + vec2( 0, 1) * ps).rgb);
lvec3 I = lvec3(texture(tex, tex_coord + vec2( 1, 1) * ps).rgb);
// Edge detection patterns - ScaleFX algorithm
// Pattern 1: Horizontal edge D-E-F
bool P1 = (eq(D, E) && !eq(D, F) && !eq(D, B) && !eq(D, H));
// Pattern 2: Horizontal edge E-F-?
bool P2 = (eq(E, F) && !eq(E, D) && !eq(E, B) && !eq(E, H));
// Pattern 3: Vertical edge B-E-H
bool P3 = (eq(B, E) && !eq(B, H) && !eq(B, D) && !eq(B, F));
// Pattern 4: Vertical edge E-H-?
bool P4 = (eq(E, H) && !eq(E, B) && !eq(E, D) && !eq(E, F));
// Pattern 5: Diagonal edge A-E-I
bool P5 = (eq(A, E) && !eq(A, I) && !eq(A, C) && !eq(A, G));
// Pattern 6: Diagonal edge C-E-G
bool P6 = (eq(C, E) && !eq(C, G) && !eq(C, A) && !eq(C, I));
lvec3 res = E;
lfloat fx = lfloat(pos.x);
lfloat fy = lfloat(pos.y);
// Apply ScaleFX blending based on detected patterns
// Horizontal patterns
if (P1) {
// Blend towards D
lfloat dist_x = lfloat(1.0) - fx;
res = mix(res, D, dist_x * SFX_SAA * lfloat(0.5));
}
if (P2) {
// Blend towards F
res = mix(res, F, fx * SFX_SAA * lfloat(0.5));
}
// Vertical patterns
if (P3) {
// Blend towards B
lfloat dist_y = lfloat(1.0) - fy;
res = mix(res, B, dist_y * SFX_SAA * lfloat(0.5));
}
if (P4) {
// Blend towards H
res = mix(res, H, fy * SFX_SAA * lfloat(0.5));
}
// Diagonal patterns (more subtle)
if (P5) {
// Blend towards A (top-left)
lfloat dist = lfloat(1.0) - (fx + fy) * lfloat(0.5);
if (dist > lfloat(0.0)) {
res = mix(res, A, dist * SFX_SAA * lfloat(0.3));
}
}
if (P6) {
// Blend towards C (top-right)
lfloat dist = lfloat(1.0) - (lfloat(1.0) - fx + fy) * lfloat(0.5);
if (dist > lfloat(0.0)) {
res = mix(res, C, dist * SFX_SAA * lfloat(0.3));
}
}
// Additional corner handling for better quality
// Bottom-right corner blending
if (eq(E, I) && !eq(E, G) && fx > lfloat(0.5) && fy > lfloat(0.5)) {
lfloat dist = (fx - lfloat(0.5)) * (fy - lfloat(0.5)) * lfloat(4.0);
res = mix(res, I, dist * SFX_SAA * lfloat(0.3));
}
// Bottom-left corner blending
if (eq(E, G) && !eq(E, I) && fx < lfloat(0.5) && fy > lfloat(0.5)) {
lfloat dist = (lfloat(0.5) - fx) * (fy - lfloat(0.5)) * lfloat(4.0);
res = mix(res, G, dist * SFX_SAA * lfloat(0.3));
}
return lvec4(res, lfloat(1.0));
}
void main() {
frag_color = vec4(scalefx(input_texture, tex_coord));
}

View File

@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#version 460
#extension GL_GOOGLE_include_directive : enable
#extension GL_AMD_gpu_shader_half_float : enable
#extension GL_NV_gpu_shader5 : enable
#define VERSION 2
#include "opengl_present_scalefx.frag"

View File

@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#version 460
#extension GL_GOOGLE_include_directive : enable
#define VERSION 2
#include "opengl_present_scalefx.frag"

View File

@@ -90,6 +90,9 @@ void BlitScreen::CreateWindowAdapt() {
case Settings::ScalingFilter::ScaleForce:
window_adapt = MakeScaleForce(device);
break;
case Settings::ScalingFilter::ScaleFx:
window_adapt = MakeScaleFx(device);
break;
case Settings::ScalingFilter::Fsr:
case Settings::ScalingFilter::Fsr2:
case Settings::ScalingFilter::Bilinear:

View File

@@ -4,6 +4,7 @@
#include "video_core/host_shaders/opengl_present_frag.h"
#include "video_core/host_shaders/opengl_present_scaleforce_frag.h"
#include "video_core/host_shaders/opengl_present_scalefx_frag.h"
#include "video_core/host_shaders/present_bicubic_frag.h"
#include "video_core/host_shaders/present_lanczos_frag.h"
#include "video_core/host_shaders/present_gaussian_frag.h"
@@ -38,6 +39,12 @@ std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device) {
fmt::format("#version 460\n{}", HostShaders::OPENGL_PRESENT_SCALEFORCE_FRAG));
}
std::unique_ptr<WindowAdaptPass> MakeScaleFx(const Device& device) {
return std::make_unique<WindowAdaptPass>(
device, CreateBilinearSampler(),
fmt::format("#version 460\n{}", HostShaders::OPENGL_PRESENT_SCALEFX_FRAG));
}
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device) {
return std::make_unique<WindowAdaptPass>(device, CreateNearestNeighborSampler(),
HostShaders::PRESENT_LANCZOS_FRAG);

View File

@@ -15,5 +15,6 @@ std::unique_ptr<WindowAdaptPass> MakeBicubic(const Device& device);
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device);
std::unique_ptr<WindowAdaptPass> MakeGaussian(const Device& device);
std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device);
std::unique_ptr<WindowAdaptPass> MakeScaleFx(const Device& device);
} // namespace OpenGL

View File

@@ -10,6 +10,8 @@
#include "video_core/host_shaders/vulkan_present_frag_spv.h"
#include "video_core/host_shaders/vulkan_present_scaleforce_fp16_frag_spv.h"
#include "video_core/host_shaders/vulkan_present_scaleforce_fp32_frag_spv.h"
#include "video_core/host_shaders/vulkan_present_scalefx_fp16_frag_spv.h"
#include "video_core/host_shaders/vulkan_present_scalefx_fp32_frag_spv.h"
#include "video_core/renderer_vulkan/present/filters.h"
#include "video_core/renderer_vulkan/present/util.h"
#include "video_core/renderer_vulkan/vk_shader_util.h"
@@ -27,6 +29,14 @@ vk::ShaderModule SelectScaleForceShader(const Device& device) {
}
}
vk::ShaderModule SelectScaleFxShader(const Device& device) {
if (device.IsFloat16Supported()) {
return BuildShader(device, VULKAN_PRESENT_SCALEFX_FP16_FRAG_SPV);
} else {
return BuildShader(device, VULKAN_PRESENT_SCALEFX_FP32_FRAG_SPV);
}
}
} // Anonymous namespace
std::unique_ptr<WindowAdaptPass> MakeNearestNeighbor(const Device& device, VkFormat frame_format) {
@@ -55,6 +65,11 @@ std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device, VkFormat f
SelectScaleForceShader(device));
}
std::unique_ptr<WindowAdaptPass> MakeScaleFx(const Device& device, VkFormat frame_format) {
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateBilinearSampler(device),
SelectScaleFxShader(device));
}
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device, VkFormat frame_format) {
return std::make_unique<WindowAdaptPass>(device, frame_format, CreateNearestNeighborSampler(device),
BuildShader(device, PRESENT_LANCZOS_FRAG_SPV));

View File

@@ -16,5 +16,6 @@ std::unique_ptr<WindowAdaptPass> MakeBicubic(const Device& device, VkFormat fram
std::unique_ptr<WindowAdaptPass> MakeLanczos(const Device& device, VkFormat frame_format);
std::unique_ptr<WindowAdaptPass> MakeGaussian(const Device& device, VkFormat frame_format);
std::unique_ptr<WindowAdaptPass> MakeScaleForce(const Device& device, VkFormat frame_format);
std::unique_ptr<WindowAdaptPass> MakeScaleFx(const Device& device, VkFormat frame_format);
} // namespace Vulkan

View File

@@ -47,6 +47,9 @@ void BlitScreen::SetWindowAdaptPass() {
case Settings::ScalingFilter::ScaleForce:
window_adapt = MakeScaleForce(device, swapchain_view_format);
break;
case Settings::ScalingFilter::ScaleFx:
window_adapt = MakeScaleFx(device, swapchain_view_format);
break;
case Settings::ScalingFilter::Fsr:
case Settings::ScalingFilter::Fsr2:
case Settings::ScalingFilter::Bilinear: