From 6f250a4ff7ffc06c68ed9d48ee7b97f925d354d8 Mon Sep 17 00:00:00 2001
From: Connor McLaughlin <stenzek@gmail.com>
Date: Fri, 11 Sep 2020 22:20:19 +1000
Subject: [PATCH] GPU/HW: Add JINC2 and xBRZ texture filtering options

Shaders ported from beetle-psx.
---
 README.md                                     |   1 +
 src/core/gpu_hw.cpp                           |  14 +-
 src/core/gpu_hw.h                             |   2 +-
 src/core/gpu_hw_d3d11.cpp                     |   2 +-
 src/core/gpu_hw_shadergen.cpp                 | 521 ++++++++++++++++--
 src/core/gpu_hw_shadergen.h                   |   5 +-
 src/core/gpu_hw_vulkan.cpp                    |   2 +-
 src/core/host_interface.cpp                   |   4 +-
 src/core/settings.cpp                         |  36 +-
 src/core/settings.h                           |   7 +-
 src/core/types.h                              |   9 +
 .../libretro_host_interface.cpp               |   8 +-
 .../enhancementsettingswidget.cpp             |  16 +-
 .../enhancementsettingswidget.ui              |  27 +-
 src/duckstation-qt/gamepropertiesdialog.cpp   |  21 +-
 src/duckstation-qt/gamepropertiesdialog.ui    |  26 +-
 src/duckstation-sdl/sdl_host_interface.cpp    |  34 +-
 src/frontend-common/game_settings.cpp         |  17 +-
 src/frontend-common/game_settings.h           |   2 +-
 19 files changed, 651 insertions(+), 103 deletions(-)

diff --git a/README.md b/README.md
index 7f8a71277..16c23a2ac 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ A "BIOS" ROM image is required to to start the emulator and to play games. You c
 
 ## Latest News
 
+- 2020/09/12: Additional texture filtering options added.
 - 2020/09/09: Basic cheat support added. Not all instructions/commands are supported yet.
 - 2020/09/01: Many additional user settings available, including memory cards and enhancements. Now you can set these per-game.
 - 2020/08/25: Automated builds for macOS now available.
diff --git a/src/core/gpu_hw.cpp b/src/core/gpu_hw.cpp
index 4741a9263..e1c0c60f9 100644
--- a/src/core/gpu_hw.cpp
+++ b/src/core/gpu_hw.cpp
@@ -26,7 +26,7 @@ ALWAYS_INLINE static constexpr std::tuple<T, T> MinMax(T v1, T v2)
 ALWAYS_INLINE static bool ShouldUseUVLimits()
 {
   // We only need UV limits if PGXP is enabled, or texture filtering is enabled.
-  return g_settings.gpu_pgxp_enable || g_settings.gpu_texture_filtering;
+  return g_settings.gpu_pgxp_enable || g_settings.gpu_texture_filter != GPUTextureFilter::Nearest;
 }
 
 GPU_HW::GPU_HW() : GPU()
@@ -50,7 +50,7 @@ bool GPU_HW::Initialize(HostDisplay* host_display)
   m_render_api = host_display->GetRenderAPI();
   m_true_color = g_settings.gpu_true_color;
   m_scaled_dithering = g_settings.gpu_scaled_dithering;
-  m_texture_filtering = g_settings.gpu_texture_filtering;
+  m_texture_filtering = g_settings.gpu_texture_filter;
   m_using_uv_limits = ShouldUseUVLimits();
   PrintSettingsToLog();
   return true;
@@ -96,7 +96,7 @@ void GPU_HW::UpdateHWSettings(bool* framebuffer_changed, bool* shaders_changed)
   *framebuffer_changed = (m_resolution_scale != resolution_scale);
   *shaders_changed = (m_resolution_scale != resolution_scale || m_true_color != g_settings.gpu_true_color ||
                       m_scaled_dithering != g_settings.gpu_scaled_dithering ||
-                      m_texture_filtering != g_settings.gpu_texture_filtering || m_using_uv_limits != use_uv_limits);
+                      m_texture_filtering != g_settings.gpu_texture_filter || m_using_uv_limits != use_uv_limits);
 
   if (m_resolution_scale != resolution_scale)
   {
@@ -109,7 +109,7 @@ void GPU_HW::UpdateHWSettings(bool* framebuffer_changed, bool* shaders_changed)
   m_resolution_scale = resolution_scale;
   m_true_color = g_settings.gpu_true_color;
   m_scaled_dithering = g_settings.gpu_scaled_dithering;
-  m_texture_filtering = g_settings.gpu_texture_filtering;
+  m_texture_filtering = g_settings.gpu_texture_filter;
   m_using_uv_limits = use_uv_limits;
   PrintSettingsToLog();
 }
@@ -148,7 +148,7 @@ void GPU_HW::PrintSettingsToLog()
                  VRAM_HEIGHT * m_resolution_scale, m_max_resolution_scale);
   Log_InfoPrintf("Dithering: %s%s", m_true_color ? "Disabled" : "Enabled",
                  (!m_true_color && m_scaled_dithering) ? " (Scaled)" : "");
-  Log_InfoPrintf("Texture Filtering: %s", m_texture_filtering ? "Enabled" : "Disabled");
+  Log_InfoPrintf("Texture Filtering: %s", Settings::GetTextureFilterDisplayName(m_texture_filtering));
   Log_InfoPrintf("Dual-source blending: %s", m_supports_dual_source_blend ? "Supported" : "Not supported");
   Log_InfoPrintf("Using UV limits: %s", m_using_uv_limits ? "YES" : "NO");
 }
@@ -1036,8 +1036,8 @@ void GPU_HW::DrawRendererStats(bool is_idle_frame)
 
     ImGui::TextUnformatted("Texture Filtering:");
     ImGui::NextColumn();
-    ImGui::TextColored(m_texture_filtering ? active_color : inactive_color,
-                       m_texture_filtering ? "Enabled" : "Disabled");
+    ImGui::TextColored((m_texture_filtering != GPUTextureFilter::Nearest) ? active_color : inactive_color,
+                       Settings::GetTextureFilterDisplayName(m_texture_filtering));
     ImGui::NextColumn();
 
     ImGui::TextUnformatted("PGXP:");
diff --git a/src/core/gpu_hw.h b/src/core/gpu_hw.h
index 2b6141db3..1d64de71d 100644
--- a/src/core/gpu_hw.h
+++ b/src/core/gpu_hw.h
@@ -270,7 +270,7 @@ protected:
   HostDisplay::RenderAPI m_render_api = HostDisplay::RenderAPI::None;
   bool m_true_color = true;
   bool m_scaled_dithering = false;
-  bool m_texture_filtering = false;
+  GPUTextureFilter m_texture_filtering = GPUTextureFilter::Nearest;
   bool m_supports_dual_source_blend = false;
   bool m_using_uv_limits = false;
 
diff --git a/src/core/gpu_hw_d3d11.cpp b/src/core/gpu_hw_d3d11.cpp
index 6a669a028..87c44e8df 100644
--- a/src/core/gpu_hw_d3d11.cpp
+++ b/src/core/gpu_hw_d3d11.cpp
@@ -332,7 +332,7 @@ bool GPU_HW_D3D11::CreateStateObjects()
   for (u8 transparency_mode = 0; transparency_mode < 5; transparency_mode++)
   {
     bl_desc = CD3D11_BLEND_DESC(CD3D11_DEFAULT());
-    if (transparency_mode != static_cast<u8>(TransparencyMode::Disabled) || m_texture_filtering)
+    if (transparency_mode != static_cast<u8>(TransparencyMode::Disabled) || m_texture_filtering != GPUTextureFilter::Nearest)
     {
       bl_desc.RenderTarget[0].BlendEnable = TRUE;
       bl_desc.RenderTarget[0].SrcBlend = D3D11_BLEND_ONE;
diff --git a/src/core/gpu_hw_shadergen.cpp b/src/core/gpu_hw_shadergen.cpp
index ee1817367..893efc60e 100644
--- a/src/core/gpu_hw_shadergen.cpp
+++ b/src/core/gpu_hw_shadergen.cpp
@@ -6,10 +6,10 @@
 Log_SetChannel(GPU_HW_ShaderGen);
 
 GPU_HW_ShaderGen::GPU_HW_ShaderGen(HostDisplay::RenderAPI render_api, u32 resolution_scale, bool true_color,
-                                   bool scaled_dithering, bool texture_filtering, bool uv_limits,
+                                   bool scaled_dithering, GPUTextureFilter texture_filtering, bool uv_limits,
                                    bool supports_dual_source_blend)
   : m_render_api(render_api), m_resolution_scale(resolution_scale), m_true_color(true_color),
-    m_scaled_dithering(scaled_dithering), m_texture_filering(texture_filtering), m_uv_limits(uv_limits),
+    m_scaled_dithering(scaled_dithering), m_texture_filter(texture_filtering), m_uv_limits(uv_limits),
     m_glsl(render_api != HostDisplay::RenderAPI::D3D11), m_supports_dual_source_blend(supports_dual_source_blend),
     m_use_glsl_interface_blocks(false)
 {
@@ -148,6 +148,8 @@ void GPU_HW_ShaderGen::WriteHeader(std::stringstream& ss)
     ss << "#define CONSTANT const\n";
     ss << "#define VECTOR_EQ(a, b) ((a) == (b))\n";
     ss << "#define VECTOR_NEQ(a, b) ((a) != (b))\n";
+    ss << "#define VECTOR_COMP_EQ(a, b) equal((a), (b))\n";
+    ss << "#define VECTOR_COMP_NEQ(a, b) notEqual((a), (b))\n";
     ss << "#define SAMPLE_TEXTURE(name, coords) texture(name, coords)\n";
     ss << "#define LOAD_TEXTURE(name, coords, mip) texelFetch(name, coords, mip)\n";
     ss << "#define LOAD_TEXTURE_OFFSET(name, coords, mip, offset) texelFetchOffset(name, coords, mip, offset)\n";
@@ -160,6 +162,8 @@ void GPU_HW_ShaderGen::WriteHeader(std::stringstream& ss)
     ss << "#define CONSTANT static const\n";
     ss << "#define VECTOR_EQ(a, b) (all((a) == (b)))\n";
     ss << "#define VECTOR_NEQ(a, b) (any((a) != (b)))\n";
+    ss << "#define VECTOR_COMP_EQ(a, b) ((a) == (b))\n";
+    ss << "#define VECTOR_COMP_NEQ(a, b) ((a) != (b))\n";
     ss << "#define SAMPLE_TEXTURE(name, coords) name.Sample(name##_ss, coords)\n";
     ss << "#define LOAD_TEXTURE(name, coords, mip) name.Load(int3(coords, mip))\n";
     ss << "#define LOAD_TEXTURE_OFFSET(name, coords, mip, offset) name.Load(int3(coords, mip), offset)\n";
@@ -578,6 +582,476 @@ std::string GPU_HW_ShaderGen::GenerateBatchVertexShader(bool textured)
   return ss.str();
 }
 
+void GPU_HW_ShaderGen::WriteBatchTextureFilter(std::stringstream& ss, GPUTextureFilter texture_filter)
+{
+  // JINC2 and xBRZ shaders originally from beetle-psx, modified to support filtering mask channel.
+  if (texture_filter == GPUTextureFilter::Bilinear)
+  {
+    ss << R"(
+void FilteredSampleFromVRAM(uint4 texpage, float2 coords, float4 uv_limits,
+                            out float4 texcol, out float ialpha)
+{
+  // Compute the coordinates of the four texels we will be interpolating between.
+  // Clamp this to the triangle texture coordinates.
+  float2 texel_top_left = frac(coords) - float2(0.5, 0.5);
+  float2 texel_offset = sign(texel_top_left);
+  float4 fcoords = max(coords.xyxy + float4(0.0, 0.0, texel_offset.x, texel_offset.y),
+                        float4(0.0, 0.0, 0.0, 0.0));
+
+  // Load four texels.
+  float4 s00 = SampleFromVRAM(texpage, clamp(fcoords.xy, uv_limits.xy, uv_limits.zw));
+  float4 s10 = SampleFromVRAM(texpage, clamp(fcoords.zy, uv_limits.xy, uv_limits.zw));
+  float4 s01 = SampleFromVRAM(texpage, clamp(fcoords.xw, uv_limits.xy, uv_limits.zw));
+  float4 s11 = SampleFromVRAM(texpage, clamp(fcoords.zw, uv_limits.xy, uv_limits.zw));
+
+  // Compute alpha from how many texels aren't pixel color 0000h.
+  float a00 = float(VECTOR_NEQ(s00, TRANSPARENT_PIXEL_COLOR));
+  float a10 = float(VECTOR_NEQ(s10, TRANSPARENT_PIXEL_COLOR));
+  float a01 = float(VECTOR_NEQ(s01, TRANSPARENT_PIXEL_COLOR));
+  float a11 = float(VECTOR_NEQ(s11, TRANSPARENT_PIXEL_COLOR));
+
+  // Bilinearly interpolate.
+  float2 weights = abs(texel_top_left);
+  texcol = lerp(lerp(s00, s10, weights.x), lerp(s01, s11, weights.x), weights.y);
+  ialpha = lerp(lerp(a00, a10, weights.x), lerp(a01, a11, weights.x), weights.y);
+
+  // Compensate for partially transparent sampling.
+  if (ialpha > 0.0)
+    texcol.rgb /= float3(ialpha, ialpha, ialpha);
+}
+)";
+  }
+  else if (texture_filter == GPUTextureFilter::JINC2)
+  {
+    ss << R"(
+CONSTANT float JINC2_WINDOW_SINC = 0.44;
+CONSTANT float JINC2_SINC = 0.82;
+CONSTANT float JINC2_AR_STRENGTH = 0.8;
+
+CONSTANT   float halfpi            = 1.5707963267948966192313216916398;
+CONSTANT   float pi                = 3.1415926535897932384626433832795;
+CONSTANT   float wa                = 1.382300768;
+CONSTANT   float wb                = 2.576105976;
+
+// Calculates the distance between two points
+float d(float2 pt1, float2 pt2)
+{
+  float2 v = pt2 - pt1;
+  return sqrt(dot(v,v));
+}
+
+float min4(float a, float b, float c, float d)
+{
+    return min(a, min(b, min(c, d)));
+}
+
+float4 min4(float4 a, float4 b, float4 c, float4 d)
+{
+    return min(a, min(b, min(c, d)));
+}
+
+float max4(float a, float b, float c, float d)
+{
+  return max(a, max(b, max(c, d)));
+}
+
+float4 max4(float4 a, float4 b, float4 c, float4 d)
+{
+    return max(a, max(b, max(c, d)));
+}
+
+float4 resampler(float4 x)
+{
+   float4 res;
+
+   // res = (x==float4(0.0, 0.0, 0.0, 0.0)) ?  float4(wa*wb)  :  sin(x*wa)*sin(x*wb)/(x*x);
+   // Need to use mix(.., equal(..)) since we want zero check to be component wise
+   res = lerp(sin(x*wa)*sin(x*wb)/(x*x), float4(wa*wb, wa*wb, wa*wb, wa*wb), VECTOR_COMP_EQ(x,float4(0.0, 0.0, 0.0, 0.0)));
+
+   return res;
+}
+
+void FilteredSampleFromVRAM(uint4 texpage, float2 coords, float4 uv_limits,
+                            out float4 texcol, out float ialpha)
+{
+    float4 weights[4];
+
+    float2 dx = float2(1.0, 0.0);
+    float2 dy = float2(0.0, 1.0);
+
+    float2 pc = coords.xy;
+
+    float2 tc = (floor(pc-float2(0.5,0.5))+float2(0.5,0.5));
+
+    weights[0] = resampler(float4(d(pc, tc    -dx    -dy), d(pc, tc           -dy), d(pc, tc    +dx    -dy), d(pc, tc+2.0*dx    -dy)));
+    weights[1] = resampler(float4(d(pc, tc    -dx       ), d(pc, tc              ), d(pc, tc    +dx       ), d(pc, tc+2.0*dx       )));
+    weights[2] = resampler(float4(d(pc, tc    -dx    +dy), d(pc, tc           +dy), d(pc, tc    +dx    +dy), d(pc, tc+2.0*dx    +dy)));
+    weights[3] = resampler(float4(d(pc, tc    -dx+2.0*dy), d(pc, tc       +2.0*dy), d(pc, tc    +dx+2.0*dy), d(pc, tc+2.0*dx+2.0*dy)));
+
+    dx = dx;
+    dy = dy;
+    tc = tc;
+
+#define sample_texel(coords) SampleFromVRAM(texpage, clamp((coords), uv_limits.xy, uv_limits.zw))
+
+    float4 c00 = sample_texel(tc    -dx    -dy);
+    float a00 = float(VECTOR_NEQ(c00, TRANSPARENT_PIXEL_COLOR));
+    float4 c10 = sample_texel(tc           -dy);
+    float a10 = float(VECTOR_NEQ(c10, TRANSPARENT_PIXEL_COLOR));
+    float4 c20 = sample_texel(tc    +dx    -dy);
+    float a20 = float(VECTOR_NEQ(c20, TRANSPARENT_PIXEL_COLOR));
+    float4 c30 = sample_texel(tc+2.0*dx    -dy);
+    float a30 = float(VECTOR_NEQ(c30, TRANSPARENT_PIXEL_COLOR));
+    float4 c01 = sample_texel(tc    -dx       );
+    float a01 = float(VECTOR_NEQ(c01, TRANSPARENT_PIXEL_COLOR));
+    float4 c11 = sample_texel(tc              );
+    float a11 = float(VECTOR_NEQ(c11, TRANSPARENT_PIXEL_COLOR));
+    float4 c21 = sample_texel(tc    +dx       );
+    float a21 = float(VECTOR_NEQ(c21, TRANSPARENT_PIXEL_COLOR));
+    float4 c31 = sample_texel(tc+2.0*dx       );
+    float a31 = float(VECTOR_NEQ(c31, TRANSPARENT_PIXEL_COLOR));
+    float4 c02 = sample_texel(tc    -dx    +dy);
+    float a02 = float(VECTOR_NEQ(c02, TRANSPARENT_PIXEL_COLOR));
+    float4 c12 = sample_texel(tc           +dy);
+    float a12 = float(VECTOR_NEQ(c12, TRANSPARENT_PIXEL_COLOR));
+    float4 c22 = sample_texel(tc    +dx    +dy);
+    float a22 = float(VECTOR_NEQ(c22, TRANSPARENT_PIXEL_COLOR));
+    float4 c32 = sample_texel(tc+2.0*dx    +dy);
+    float a32 = float(VECTOR_NEQ(c32, TRANSPARENT_PIXEL_COLOR));
+    float4 c03 = sample_texel(tc    -dx+2.0*dy);
+    float a03 = float(VECTOR_NEQ(c03, TRANSPARENT_PIXEL_COLOR));
+    float4 c13 = sample_texel(tc       +2.0*dy);
+    float a13 = float(VECTOR_NEQ(c13, TRANSPARENT_PIXEL_COLOR));
+    float4 c23 = sample_texel(tc    +dx+2.0*dy);
+    float a23 = float(VECTOR_NEQ(c23, TRANSPARENT_PIXEL_COLOR));
+    float4 c33 = sample_texel(tc+2.0*dx+2.0*dy);
+    float a33 = float(VECTOR_NEQ(c33, TRANSPARENT_PIXEL_COLOR));
+
+#undef sample_texel
+
+    //  Get min/max samples
+    float4 min_sample = min4(c11, c21, c12, c22);
+    float min_sample_alpha = min4(a11, a21, a12, a22);
+    float4 max_sample = max4(c11, c21, c12, c22);
+    float max_sample_alpha = max4(a11, a21, a12, a22);
+
+    float4 color;
+    color = float4(dot(weights[0], float4(c00.x, c10.x, c20.x, c30.x)), dot(weights[0], float4(c00.y, c10.y, c20.y, c30.y)), dot(weights[0], float4(c00.z, c10.z, c20.z, c30.z)), dot(weights[0], float4(c00.w, c10.w, c20.w, c30.w)));
+    color+= float4(dot(weights[1], float4(c01.x, c11.x, c21.x, c31.x)), dot(weights[1], float4(c01.y, c11.y, c21.y, c31.y)), dot(weights[1], float4(c01.z, c11.z, c21.z, c31.z)), dot(weights[1], float4(c01.w, c11.w, c21.w, c31.w)));
+    color+= float4(dot(weights[2], float4(c02.x, c12.x, c22.x, c32.x)), dot(weights[2], float4(c02.y, c12.y, c22.y, c32.y)), dot(weights[2], float4(c02.z, c12.z, c22.z, c32.z)), dot(weights[2], float4(c02.w, c12.w, c22.w, c32.w)));
+    color+= float4(dot(weights[3], float4(c03.x, c13.x, c23.x, c33.x)), dot(weights[3], float4(c03.y, c13.y, c23.y, c33.y)), dot(weights[3], float4(c03.z, c13.z, c23.z, c33.z)), dot(weights[3], float4(c03.w, c13.w, c23.w, c33.w)));
+    color = color/(dot(weights[0], float4(1,1,1,1)) + dot(weights[1], float4(1,1,1,1)) + dot(weights[2], float4(1,1,1,1)) + dot(weights[3], float4(1,1,1,1)));
+
+    float alpha;
+    alpha = dot(weights[0], float4(a00, a10, a20, a30));
+    alpha+= dot(weights[1], float4(a01, a11, a21, a31));
+    alpha+= dot(weights[2], float4(a02, a12, a22, a32));
+    alpha+= dot(weights[3], float4(a03, a13, a23, a33));
+    //alpha = alpha/(weights[0].w + weights[1].w + weights[2].w + weights[3].w);
+    alpha = alpha/(dot(weights[0], float4(1,1,1,1)) + dot(weights[1], float4(1,1,1,1)) + dot(weights[2], float4(1,1,1,1)) + dot(weights[3], float4(1,1,1,1)));
+
+    // Anti-ringing
+    float4 aux = color;
+    float aux_alpha = alpha;
+    color = clamp(color, min_sample, max_sample);
+    alpha = clamp(alpha, min_sample_alpha, max_sample_alpha);
+    color = lerp(aux, color, JINC2_AR_STRENGTH);
+    alpha = lerp(aux_alpha, alpha, JINC2_AR_STRENGTH);
+
+    // final sum and weight normalization
+    ialpha = alpha;
+    texcol = color;
+
+    // Compensate for partially transparent sampling.
+    if (ialpha > 0.0)
+      texcol.rgb /= float3(ialpha, ialpha, ialpha);
+}
+)";
+  }
+  else if (texture_filter == GPUTextureFilter::xBRZ)
+  {
+    ss << R"(
+CONSTANT int BLEND_NONE = 0;
+CONSTANT int BLEND_NORMAL = 1;
+CONSTANT int BLEND_DOMINANT = 2;
+CONSTANT float LUMINANCE_WEIGHT = 1.0;
+CONSTANT float EQUAL_COLOR_TOLERANCE = 0.1176470588235294;
+CONSTANT float STEEP_DIRECTION_THRESHOLD = 2.2;
+CONSTANT float DOMINANT_DIRECTION_THRESHOLD = 3.6;
+CONSTANT float4 w = float4(0.2627, 0.6780, 0.0593, 0.5);
+
+float DistYCbCr(float4 pixA, float4 pixB)
+{
+  const float scaleB = 0.5 / (1.0 - w.b);
+  const float scaleR = 0.5 / (1.0 - w.r);
+  float4 diff = pixA - pixB;
+  float Y = dot(diff, w);
+  float Cb = scaleB * (diff.b - Y);
+  float Cr = scaleR * (diff.r - Y);
+
+  return sqrt(((LUMINANCE_WEIGHT * Y) * (LUMINANCE_WEIGHT * Y)) + (Cb * Cb) + (Cr * Cr));
+}
+
+bool IsPixEqual(const float4 pixA, const float4 pixB)
+{
+  return (DistYCbCr(pixA, pixB) < EQUAL_COLOR_TOLERANCE);
+}
+
+float get_left_ratio(float2 center, float2 origin, float2 direction, float2 scale)
+{
+  float2 P0 = center - origin;
+  float2 proj = direction * (dot(P0, direction) / dot(direction, direction));
+  float2 distv = P0 - proj;
+  float2 orth = float2(-direction.y, direction.x);
+  float side = sign(dot(P0, orth));
+  float v = side * length(distv * scale);
+
+//  return step(0, v);
+  return smoothstep(-sqrt(2.0)/2.0, sqrt(2.0)/2.0, v);
+}
+
+#define P(coord, xoffs, yoffs) SampleFromVRAM(texpage, clamp(coords + float2((xoffs), (yoffs)), uv_limits.xy, uv_limits.zw))
+
+void FilteredSampleFromVRAM(uint4 texpage, float2 coords, float4 uv_limits,
+                            out float4 texcol, out float ialpha)
+{
+  //---------------------------------------
+  // Input Pixel Mapping:  -|x|x|x|-
+  //                       x|A|B|C|x
+  //                       x|D|E|F|x
+  //                       x|G|H|I|x
+  //                       -|x|x|x|-
+
+  float2 scale = float2(8.0, 8.0);
+  float2 pos = frac(coords.xy) - float2(0.5, 0.5);
+  float2 coord = coords.xy - pos;
+
+  float4 A = P(coord, -1,-1);
+  float Aw = A.w;
+  A.w = float(VECTOR_NEQ(A, TRANSPARENT_PIXEL_COLOR));
+  float4 B = P(coord,  0,-1);
+  float Bw = B.w;
+  B.w = float(VECTOR_NEQ(B, TRANSPARENT_PIXEL_COLOR));
+  float4 C = P(coord,  1,-1);
+  float Cw = C.w;
+  C.w = float(VECTOR_NEQ(C, TRANSPARENT_PIXEL_COLOR));
+  float4 D = P(coord, -1, 0);
+  float Dw = D.w;
+  D.w = float(VECTOR_NEQ(D, TRANSPARENT_PIXEL_COLOR));
+  float4 E = P(coord, 0, 0);
+  float Ew = E.w;
+  E.w = float(VECTOR_NEQ(E, TRANSPARENT_PIXEL_COLOR));
+  float4 F = P(coord,  1, 0);
+  float Fw = F.w;
+  F.w = float(VECTOR_NEQ(F, TRANSPARENT_PIXEL_COLOR));
+  float4 G = P(coord, -1, 1);
+  float Gw = G.w;
+  G.w = float(VECTOR_NEQ(G, TRANSPARENT_PIXEL_COLOR));
+  float4 H = P(coord,  0, 1);
+  float Hw = H.w;
+  H.w = float(VECTOR_NEQ(H, TRANSPARENT_PIXEL_COLOR));
+  float4 I = P(coord,  1, 1);
+  float Iw = I.w;
+  I.w = float(VECTOR_NEQ(H, TRANSPARENT_PIXEL_COLOR));
+
+  // blendResult Mapping: x|y|
+  //                      w|z|
+  int4 blendResult = int4(BLEND_NONE,BLEND_NONE,BLEND_NONE,BLEND_NONE);
+
+  // Preprocess corners
+  // Pixel Tap Mapping: -|-|-|-|-
+  //                    -|-|B|C|-
+  //                    -|D|E|F|x
+  //                    -|G|H|I|x
+  //                    -|-|x|x|-
+  if (!((VECTOR_EQ(E,F) && VECTOR_EQ(H,I)) || (VECTOR_EQ(E,H) && VECTOR_EQ(F,I))))
+  {
+    float dist_H_F = DistYCbCr(G, E) + DistYCbCr(E, C) + DistYCbCr(P(coord, 0,2), I) + DistYCbCr(I, P(coord, 2,0)) + (4.0 * DistYCbCr(H, F));
+    float dist_E_I = DistYCbCr(D, H) + DistYCbCr(H, P(coord, 1,2)) + DistYCbCr(B, F) + DistYCbCr(F, P(coord, 2,1)) + (4.0 * DistYCbCr(E, I));
+    bool dominantGradient = (DOMINANT_DIRECTION_THRESHOLD * dist_H_F) < dist_E_I;
+    blendResult.z = ((dist_H_F < dist_E_I) && VECTOR_NEQ(E,F) && VECTOR_NEQ(E,H)) ? ((dominantGradient) ? BLEND_DOMINANT : BLEND_NORMAL) : BLEND_NONE;
+  }
+
+
+  // Pixel Tap Mapping: -|-|-|-|-
+  //                    -|A|B|-|-
+  //                    x|D|E|F|-
+  //                    x|G|H|I|-
+  //                    -|x|x|-|-
+  if (!((VECTOR_EQ(D,E) && VECTOR_EQ(G,H)) || (VECTOR_EQ(D,G) && VECTOR_EQ(E,H))))
+  {
+    float dist_G_E = DistYCbCr(P(coord, -2,1)  , D) + DistYCbCr(D, B) + DistYCbCr(P(coord, -1,2), H) + DistYCbCr(H, F) + (4.0 * DistYCbCr(G, E));
+    float dist_D_H = DistYCbCr(P(coord, -2,0)  , G) + DistYCbCr(G, P(coord, 0,2)) + DistYCbCr(A, E) + DistYCbCr(E, I) + (4.0 * DistYCbCr(D, H));
+    bool dominantGradient = (DOMINANT_DIRECTION_THRESHOLD * dist_D_H) < dist_G_E;
+    blendResult.w = ((dist_G_E > dist_D_H) && VECTOR_NEQ(E,D) && VECTOR_NEQ(E,H)) ? ((dominantGradient) ? BLEND_DOMINANT : BLEND_NORMAL) : BLEND_NONE;
+  }
+
+  // Pixel Tap Mapping: -|-|x|x|-
+  //                    -|A|B|C|x
+  //                    -|D|E|F|x
+  //                    -|-|H|I|-
+  //                    -|-|-|-|-
+  if (!((VECTOR_EQ(B,C) && VECTOR_EQ(E,F)) || (VECTOR_EQ(B,E) && VECTOR_EQ(C,F))))
+  {
+    float dist_E_C = DistYCbCr(D, B) + DistYCbCr(B, P(coord, 1,-2)) + DistYCbCr(H, F) + DistYCbCr(F, P(coord, 2,-1)) + (4.0 * DistYCbCr(E, C));
+    float dist_B_F = DistYCbCr(A, E) + DistYCbCr(E, I) + DistYCbCr(P(coord, 0,-2), C) + DistYCbCr(C, P(coord, 2,0)) + (4.0 * DistYCbCr(B, F));
+    bool dominantGradient = (DOMINANT_DIRECTION_THRESHOLD * dist_B_F) < dist_E_C;
+    blendResult.y = ((dist_E_C > dist_B_F) && VECTOR_NEQ(E,B) && VECTOR_NEQ(E,F)) ? ((dominantGradient) ? BLEND_DOMINANT : BLEND_NORMAL) : BLEND_NONE;
+  }
+
+  // Pixel Tap Mapping: -|x|x|-|-
+  //                    x|A|B|C|-
+  //                    x|D|E|F|-
+  //                    -|G|H|-|-
+  //                    -|-|-|-|-
+  if (!((VECTOR_EQ(A,B) && VECTOR_EQ(D,E)) || (VECTOR_EQ(A,D) && VECTOR_EQ(B,E))))
+  {
+    float dist_D_B = DistYCbCr(P(coord, -2,0), A) + DistYCbCr(A, P(coord, 0,-2)) + DistYCbCr(G, E) + DistYCbCr(E, C) + (4.0 * DistYCbCr(D, B));
+    float dist_A_E = DistYCbCr(P(coord, -2,-1), D) + DistYCbCr(D, H) + DistYCbCr(P(coord, -1,-2), B) + DistYCbCr(B, F) + (4.0 * DistYCbCr(A, E));
+    bool dominantGradient = (DOMINANT_DIRECTION_THRESHOLD * dist_D_B) < dist_A_E;
+    blendResult.x = ((dist_D_B < dist_A_E) && VECTOR_NEQ(E,D) && VECTOR_NEQ(E,B)) ? ((dominantGradient) ? BLEND_DOMINANT : BLEND_NORMAL) : BLEND_NONE;
+  }
+
+  float4 res = E;
+  float resW = Ew;
+
+  // Pixel Tap Mapping: -|-|-|-|-
+  //                    -|-|B|C|-
+  //                    -|D|E|F|x
+  //                    -|G|H|I|x
+  //                    -|-|x|x|-
+  if(blendResult.z != BLEND_NONE)
+  {
+    float dist_F_G = DistYCbCr(F, G);
+    float dist_H_C = DistYCbCr(H, C);
+    bool doLineBlend = (blendResult.z == BLEND_DOMINANT ||
+                !((blendResult.y != BLEND_NONE && !IsPixEqual(E, G)) || (blendResult.w != BLEND_NONE && !IsPixEqual(E, C)) ||
+                  (IsPixEqual(G, H) && IsPixEqual(H, I) && IsPixEqual(I, F) && IsPixEqual(F, C) && !IsPixEqual(E, I))));
+
+    float2 origin = float2(0.0, 1.0 / sqrt(2.0));
+    float2 direction = float2(1.0, -1.0);
+    if(doLineBlend)
+    {
+      bool haveShallowLine = (STEEP_DIRECTION_THRESHOLD * dist_F_G <= dist_H_C) && VECTOR_NEQ(E,G) && VECTOR_NEQ(D,G);
+      bool haveSteepLine = (STEEP_DIRECTION_THRESHOLD * dist_H_C <= dist_F_G) && VECTOR_NEQ(E,C) && VECTOR_NEQ(B,C);
+      origin = haveShallowLine? float2(0.0, 0.25) : float2(0.0, 0.5);
+      direction.x += haveShallowLine? 1.0: 0.0;
+      direction.y -= haveSteepLine? 1.0: 0.0;
+    }
+
+    float4 blendPix = lerp(H,F, step(DistYCbCr(E, F), DistYCbCr(E, H)));
+    float blendW = lerp(Hw,Fw, step(DistYCbCr(E, F), DistYCbCr(E, H)));
+    res = lerp(res, blendPix, get_left_ratio(pos, origin, direction, scale));
+    resW = lerp(resW, blendW, get_left_ratio(pos, origin, direction, scale));
+  }
+
+  // Pixel Tap Mapping: -|-|-|-|-
+  //                    -|A|B|-|-
+  //                    x|D|E|F|-
+  //                    x|G|H|I|-
+  //                    -|x|x|-|-
+  if(blendResult.w != BLEND_NONE)
+  {
+    float dist_H_A = DistYCbCr(H, A);
+    float dist_D_I = DistYCbCr(D, I);
+    bool doLineBlend = (blendResult.w == BLEND_DOMINANT ||
+                !((blendResult.z != BLEND_NONE && !IsPixEqual(E, A)) || (blendResult.x != BLEND_NONE && !IsPixEqual(E, I)) ||
+                  (IsPixEqual(A, D) && IsPixEqual(D, G) && IsPixEqual(G, H) && IsPixEqual(H, I) && !IsPixEqual(E, G))));
+
+    float2 origin = float2(-1.0 / sqrt(2.0), 0.0);
+    float2 direction = float2(1.0, 1.0);
+    if(doLineBlend)
+    {
+      bool haveShallowLine = (STEEP_DIRECTION_THRESHOLD * dist_H_A <= dist_D_I) && VECTOR_NEQ(E,A) && VECTOR_NEQ(B,A);
+      bool haveSteepLine  = (STEEP_DIRECTION_THRESHOLD * dist_D_I <= dist_H_A) && VECTOR_NEQ(E,I) && VECTOR_NEQ(F,I);
+      origin = haveShallowLine? float2(-0.25, 0.0) : float2(-0.5, 0.0);
+      direction.y += haveShallowLine? 1.0: 0.0;
+      direction.x += haveSteepLine? 1.0: 0.0;
+    }
+    origin = origin;
+    direction = direction;
+
+    float4 blendPix = lerp(H,D, step(DistYCbCr(E, D), DistYCbCr(E, H)));
+    float blendW = lerp(Hw,Dw, step(DistYCbCr(E, D), DistYCbCr(E, H)));
+    res = lerp(res, blendPix, get_left_ratio(pos, origin, direction, scale));
+    resW = lerp(resW, blendW, get_left_ratio(pos, origin, direction, scale));
+  }
+
+  // Pixel Tap Mapping: -|-|x|x|-
+  //                    -|A|B|C|x
+  //                    -|D|E|F|x
+  //                    -|-|H|I|-
+  //                    -|-|-|-|-
+  if(blendResult.y != BLEND_NONE)
+  {
+    float dist_B_I = DistYCbCr(B, I);
+    float dist_F_A = DistYCbCr(F, A);
+    bool doLineBlend = (blendResult.y == BLEND_DOMINANT ||
+                !((blendResult.x != BLEND_NONE && !IsPixEqual(E, I)) || (blendResult.z != BLEND_NONE && !IsPixEqual(E, A)) ||
+                  (IsPixEqual(I, F) && IsPixEqual(F, C) && IsPixEqual(C, B) && IsPixEqual(B, A) && !IsPixEqual(E, C))));
+
+    float2 origin = float2(1.0 / sqrt(2.0), 0.0);
+    float2 direction = float2(-1.0, -1.0);
+
+    if(doLineBlend)
+    {
+      bool haveShallowLine = (STEEP_DIRECTION_THRESHOLD * dist_B_I <= dist_F_A) && VECTOR_NEQ(E,I) && VECTOR_NEQ(H,I);
+      bool haveSteepLine  = (STEEP_DIRECTION_THRESHOLD * dist_F_A <= dist_B_I) && VECTOR_NEQ(E,A) && VECTOR_NEQ(D,A);
+      origin = haveShallowLine? float2(0.25, 0.0) : float2(0.5, 0.0);
+      direction.y -= haveShallowLine? 1.0: 0.0;
+      direction.x -= haveSteepLine? 1.0: 0.0;
+    }
+
+    float4 blendPix = lerp(F,B, step(DistYCbCr(E, B), DistYCbCr(E, F)));
+    float blendW = lerp(Fw,Bw, step(DistYCbCr(E, B), DistYCbCr(E, F)));
+    res = lerp(res, blendPix, get_left_ratio(pos, origin, direction, scale));
+    resW = lerp(resW, blendW, get_left_ratio(pos, origin, direction, scale));
+  }
+
+  // Pixel Tap Mapping: -|x|x|-|-
+  //                    x|A|B|C|-
+  //                    x|D|E|F|-
+  //                    -|G|H|-|-
+  //                    -|-|-|-|-
+  if(blendResult.x != BLEND_NONE)
+  {
+    float dist_D_C = DistYCbCr(D, C);
+    float dist_B_G = DistYCbCr(B, G);
+    bool doLineBlend = (blendResult.x == BLEND_DOMINANT ||
+                !((blendResult.w != BLEND_NONE && !IsPixEqual(E, C)) || (blendResult.y != BLEND_NONE && !IsPixEqual(E, G)) ||
+                  (IsPixEqual(C, B) && IsPixEqual(B, A) && IsPixEqual(A, D) && IsPixEqual(D, G) && !IsPixEqual(E, A))));
+
+    float2 origin = float2(0.0, -1.0 / sqrt(2.0));
+    float2 direction = float2(-1.0, 1.0);
+    if(doLineBlend)
+    {
+      bool haveShallowLine = (STEEP_DIRECTION_THRESHOLD * dist_D_C <= dist_B_G) && VECTOR_NEQ(E,C) && VECTOR_NEQ(F,C);
+      bool haveSteepLine  = (STEEP_DIRECTION_THRESHOLD * dist_B_G <= dist_D_C) && VECTOR_NEQ(E,G) && VECTOR_NEQ(H,G);
+      origin = haveShallowLine? float2(0.0, -0.25) : float2(0.0, -0.5);
+      direction.x -= haveShallowLine? 1.0: 0.0;
+      direction.y += haveSteepLine? 1.0: 0.0;
+    }
+
+    float4 blendPix = lerp(D,B, step(DistYCbCr(E, B), DistYCbCr(E, D)));
+    float blendW = lerp(Dw,Bw, step(DistYCbCr(E, B), DistYCbCr(E, D)));
+    res = lerp(res, blendPix, get_left_ratio(pos, origin, direction, scale));
+    resW = lerp(resW, blendW, get_left_ratio(pos, origin, direction, scale));
+  }
+
+  ialpha = res.w;
+  texcol = float4(res.xyz, resW);
+     
+  // Compensate for partially transparent sampling.
+  if (ialpha > 0.0)
+    texcol.rgb /= float3(ialpha, ialpha, ialpha);
+}
+
+#undef P
+
+)";
+  }
+}
+
 std::string GPU_HW_ShaderGen::GenerateBatchFragmentShader(GPU_HW::BatchRenderMode transparency,
                                                           GPU::TextureMode texture_mode, bool dithering,
                                                           bool interlacing)
@@ -588,7 +1062,7 @@ std::string GPU_HW_ShaderGen::GenerateBatchFragmentShader(GPU_HW::BatchRenderMod
   const bool use_dual_source =
     m_supports_dual_source_blend && ((transparency != GPU_HW::BatchRenderMode::TransparencyDisabled &&
                                       transparency != GPU_HW::BatchRenderMode::OnlyOpaque) ||
-                                     m_texture_filering);
+                                     m_texture_filter != GPUTextureFilter::Nearest);
 
   std::stringstream ss;
   WriteHeader(ss);
@@ -606,7 +1080,7 @@ std::string GPU_HW_ShaderGen::GenerateBatchFragmentShader(GPU_HW::BatchRenderMod
   DefineMacro(ss, "DITHERING_SCALED", m_scaled_dithering);
   DefineMacro(ss, "INTERLACING", interlacing);
   DefineMacro(ss, "TRUE_COLOR", m_true_color);
-  DefineMacro(ss, "TEXTURE_FILTERING", m_texture_filering);
+  DefineMacro(ss, "TEXTURE_FILTERING", m_texture_filter != GPUTextureFilter::Nearest);
   DefineMacro(ss, "UV_LIMITS", m_uv_limits);
   DefineMacro(ss, "USE_DUAL_SOURCE", use_dual_source);
 
@@ -708,43 +1182,14 @@ float4 SampleFromVRAM(uint4 texpage, float2 coords)
   #endif
 }
 
-void BilinearSampleFromVRAM(uint4 texpage, float2 coords, float4 uv_limits,
-                            out float4 texcol, out float ialpha)
-{
-  // Compute the coordinates of the four texels we will be interpolating between.
-  // Clamp this to the triangle texture coordinates.
-  float2 texel_top_left = frac(coords) - float2(0.5, 0.5);
-  float2 texel_offset = sign(texel_top_left);
-  float4 fcoords = max(coords.xyxy + float4(0.0, 0.0, texel_offset.x, texel_offset.y),
-                        float4(0.0, 0.0, 0.0, 0.0));
-
-  // Load four texels.
-  float4 s00 = SampleFromVRAM(texpage, clamp(fcoords.xy, uv_limits.xy, uv_limits.zw));
-  float4 s10 = SampleFromVRAM(texpage, clamp(fcoords.zy, uv_limits.xy, uv_limits.zw));
-  float4 s01 = SampleFromVRAM(texpage, clamp(fcoords.xw, uv_limits.xy, uv_limits.zw));
-  float4 s11 = SampleFromVRAM(texpage, clamp(fcoords.zw, uv_limits.xy, uv_limits.zw));
-
-  // Compute alpha from how many texels aren't pixel color 0000h.
-  float a00 = float(VECTOR_NEQ(s00, TRANSPARENT_PIXEL_COLOR));
-  float a10 = float(VECTOR_NEQ(s10, TRANSPARENT_PIXEL_COLOR));
-  float a01 = float(VECTOR_NEQ(s01, TRANSPARENT_PIXEL_COLOR));
-  float a11 = float(VECTOR_NEQ(s11, TRANSPARENT_PIXEL_COLOR));
-
-  // Bilinearly interpolate.
-  float2 weights = abs(texel_top_left);
-  texcol = lerp(lerp(s00, s10, weights.x), lerp(s01, s11, weights.x), weights.y);
-  ialpha = lerp(lerp(a00, a10, weights.x), lerp(a01, a11, weights.x), weights.y);
-
-  // Compensate for partially transparent sampling.
-  if (ialpha > 0.0)
-    texcol.rgb /= float3(ialpha, ialpha, ialpha);
-}
-
 #endif
 )";
 
   if (textured)
   {
+    if (m_texture_filter != GPUTextureFilter::Nearest)
+      WriteBatchTextureFilter(ss, m_texture_filter);
+
     if (m_uv_limits)
     {
       DeclareFragmentEntryPoint(ss, 1, 1,
@@ -794,7 +1239,7 @@ void BilinearSampleFromVRAM(uint4 texpage, float2 coords, float4 uv_limits,
 
     float4 texcol;
     #if TEXTURE_FILTERING
-      BilinearSampleFromVRAM(v_texpage, coords, uv_limits, texcol, ialpha);
+      FilteredSampleFromVRAM(v_texpage, coords, uv_limits, texcol, ialpha);
       if (ialpha < 0.5)
         discard;
     #else
@@ -809,7 +1254,7 @@ void BilinearSampleFromVRAM(uint4 texpage, float2 coords, float4 uv_limits,
       ialpha = 1.0;
     #endif
 
-    semitransparent = (texcol.a != 0.0);
+    semitransparent = (texcol.a >= 0.5);
 
     // If not using true color, truncate the framebuffer colors to 5-bit.
     #if !TRUE_COLOR
diff --git a/src/core/gpu_hw_shadergen.h b/src/core/gpu_hw_shadergen.h
index caeafac9f..c2c399404 100644
--- a/src/core/gpu_hw_shadergen.h
+++ b/src/core/gpu_hw_shadergen.h
@@ -8,7 +8,7 @@ class GPU_HW_ShaderGen
 {
 public:
   GPU_HW_ShaderGen(HostDisplay::RenderAPI render_api, u32 resolution_scale, bool true_color, bool scaled_dithering,
-                   bool texture_filtering, bool uv_limits, bool supports_dual_source_blend);
+                   GPUTextureFilter texture_filtering, bool uv_limits, bool supports_dual_source_blend);
   ~GPU_HW_ShaderGen();
 
   static bool UseGLSLBindingLayout();
@@ -45,12 +45,13 @@ private:
 
   void WriteCommonFunctions(std::stringstream& ss);
   void WriteBatchUniformBuffer(std::stringstream& ss);
+  void WriteBatchTextureFilter(std::stringstream& ss, GPUTextureFilter texture_filter);
 
   HostDisplay::RenderAPI m_render_api;
   u32 m_resolution_scale;
   bool m_true_color;
   bool m_scaled_dithering;
-  bool m_texture_filering;
+  GPUTextureFilter m_texture_filter;
   bool m_uv_limits;
   bool m_glsl;
   bool m_supports_dual_source_blend;
diff --git a/src/core/gpu_hw_vulkan.cpp b/src/core/gpu_hw_vulkan.cpp
index a9c44b697..d1c266e96 100644
--- a/src/core/gpu_hw_vulkan.cpp
+++ b/src/core/gpu_hw_vulkan.cpp
@@ -671,7 +671,7 @@ bool GPU_HW_Vulkan::CompilePipelines()
               if ((static_cast<TransparencyMode>(transparency_mode) != TransparencyMode::Disabled &&
                    (static_cast<BatchRenderMode>(render_mode) != BatchRenderMode::TransparencyDisabled &&
                     static_cast<BatchRenderMode>(render_mode) != BatchRenderMode::OnlyOpaque)) ||
-                  m_texture_filtering)
+                  m_texture_filtering != GPUTextureFilter::Nearest)
               {
                 gpbuilder.SetBlendAttachment(
                   0, true, VK_BLEND_FACTOR_ONE,
diff --git a/src/core/host_interface.cpp b/src/core/host_interface.cpp
index e421ed42e..03cbc51aa 100644
--- a/src/core/host_interface.cpp
+++ b/src/core/host_interface.cpp
@@ -372,7 +372,7 @@ void HostInterface::SetDefaultSettings(SettingsInterface& si)
   si.SetBoolValue("GPU", "UseDebugDevice", false);
   si.SetBoolValue("GPU", "TrueColor", false);
   si.SetBoolValue("GPU", "ScaledDithering", true);
-  si.SetBoolValue("GPU", "TextureFiltering", false);
+  si.SetStringValue("GPU", "TextureFilter", Settings::GetTextureFilterName(Settings::DEFAULT_GPU_TEXTURE_FILTER));
   si.SetBoolValue("GPU", "DisableInterlacing", false);
   si.SetBoolValue("GPU", "ForceNTSCTimings", false);
   si.SetBoolValue("GPU", "WidescreenHack", false);
@@ -543,7 +543,7 @@ void HostInterface::CheckForSettingsChanges(const Settings& old_settings)
         g_settings.gpu_max_run_ahead != old_settings.gpu_max_run_ahead ||
         g_settings.gpu_true_color != old_settings.gpu_true_color ||
         g_settings.gpu_scaled_dithering != old_settings.gpu_scaled_dithering ||
-        g_settings.gpu_texture_filtering != old_settings.gpu_texture_filtering ||
+        g_settings.gpu_texture_filter != old_settings.gpu_texture_filter ||
         g_settings.gpu_disable_interlacing != old_settings.gpu_disable_interlacing ||
         g_settings.gpu_force_ntsc_timings != old_settings.gpu_force_ntsc_timings ||
         g_settings.display_crop_mode != old_settings.display_crop_mode ||
diff --git a/src/core/settings.cpp b/src/core/settings.cpp
index 045a7a221..08f8517fb 100644
--- a/src/core/settings.cpp
+++ b/src/core/settings.cpp
@@ -102,7 +102,10 @@ void Settings::Load(SettingsInterface& si)
   gpu_use_debug_device = si.GetBoolValue("GPU", "UseDebugDevice", false);
   gpu_true_color = si.GetBoolValue("GPU", "TrueColor", true);
   gpu_scaled_dithering = si.GetBoolValue("GPU", "ScaledDithering", false);
-  gpu_texture_filtering = si.GetBoolValue("GPU", "TextureFiltering", false);
+  gpu_texture_filter =
+    ParseTextureFilterName(
+      si.GetStringValue("GPU", "TextureFilter", GetTextureFilterName(DEFAULT_GPU_TEXTURE_FILTER)).c_str())
+      .value_or(DEFAULT_GPU_TEXTURE_FILTER);
   gpu_disable_interlacing = si.GetBoolValue("GPU", "DisableInterlacing", false);
   gpu_force_ntsc_timings = si.GetBoolValue("GPU", "ForceNTSCTimings", false);
   gpu_widescreen_hack = si.GetBoolValue("GPU", "WidescreenHack", false);
@@ -217,7 +220,7 @@ void Settings::Save(SettingsInterface& si) const
   si.SetBoolValue("GPU", "UseDebugDevice", gpu_use_debug_device);
   si.SetBoolValue("GPU", "TrueColor", gpu_true_color);
   si.SetBoolValue("GPU", "ScaledDithering", gpu_scaled_dithering);
-  si.SetBoolValue("GPU", "TextureFiltering", gpu_texture_filtering);
+  si.SetStringValue("GPU", "TextureFilter", GetTextureFilterName(gpu_texture_filter));
   si.SetBoolValue("GPU", "DisableInterlacing", gpu_disable_interlacing);
   si.SetBoolValue("GPU", "ForceNTSCTimings", gpu_force_ntsc_timings);
   si.SetBoolValue("GPU", "WidescreenHack", gpu_widescreen_hack);
@@ -449,6 +452,35 @@ const char* Settings::GetRendererDisplayName(GPURenderer renderer)
   return s_gpu_renderer_display_names[static_cast<int>(renderer)];
 }
 
+static constexpr auto s_texture_filter_names = make_array("Nearest", "Bilinear", "JINC2", "xBRZ");
+static constexpr auto s_texture_filter_display_names =
+  make_array(TRANSLATABLE("GPUTextureFilter", "Nearest-Neighbor"), TRANSLATABLE("GPUTextureFilter", "Bilinear"),
+             TRANSLATABLE("GPUTextureFilter", "JINC2"), TRANSLATABLE("GPUTextureFilter", "xBRZ"));
+
+std::optional<GPUTextureFilter> Settings::ParseTextureFilterName(const char* str)
+{
+  int index = 0;
+  for (const char* name : s_texture_filter_names)
+  {
+    if (StringUtil::Strcasecmp(name, str) == 0)
+      return static_cast<GPUTextureFilter>(index);
+
+    index++;
+  }
+
+  return std::nullopt;
+}
+
+const char* Settings::GetTextureFilterName(GPUTextureFilter filter)
+{
+  return s_texture_filter_names[static_cast<int>(filter)];
+}
+
+const char* Settings::GetTextureFilterDisplayName(GPUTextureFilter filter)
+{
+  return s_texture_filter_display_names[static_cast<int>(filter)];
+}
+
 static std::array<const char*, 3> s_display_crop_mode_names = {{"None", "Overscan", "Borders"}};
 static std::array<const char*, 3> s_display_crop_mode_display_names = {
   {TRANSLATABLE("DisplayCropMode", "None"), TRANSLATABLE("DisplayCropMode", "Only Overscan Area"),
diff --git a/src/core/settings.h b/src/core/settings.h
index 26cc88491..aa5f63f9b 100644
--- a/src/core/settings.h
+++ b/src/core/settings.h
@@ -88,7 +88,7 @@ struct Settings
   bool gpu_use_debug_device = false;
   bool gpu_true_color = true;
   bool gpu_scaled_dithering = false;
-  bool gpu_texture_filtering = false;
+  GPUTextureFilter gpu_texture_filter = GPUTextureFilter::Nearest;
   bool gpu_disable_interlacing = false;
   bool gpu_force_ntsc_timings = false;
   bool gpu_widescreen_hack = false;
@@ -201,6 +201,10 @@ struct Settings
   static const char* GetRendererName(GPURenderer renderer);
   static const char* GetRendererDisplayName(GPURenderer renderer);
 
+  static std::optional<GPUTextureFilter> ParseTextureFilterName(const char* str);
+  static const char* GetTextureFilterName(GPUTextureFilter filter);
+  static const char* GetTextureFilterDisplayName(GPUTextureFilter filter);
+
   static std::optional<DisplayCropMode> ParseDisplayCropMode(const char* str);
   static const char* GetDisplayCropModeName(DisplayCropMode crop_mode);
   static const char* GetDisplayCropModeDisplayName(DisplayCropMode crop_mode);
@@ -227,6 +231,7 @@ struct Settings
 #else
   static constexpr GPURenderer DEFAULT_GPU_RENDERER = GPURenderer::HardwareOpenGL;
 #endif
+  static constexpr GPUTextureFilter DEFAULT_GPU_TEXTURE_FILTER = GPUTextureFilter::Nearest;
   static constexpr ConsoleRegion DEFAULT_CONSOLE_REGION = ConsoleRegion::Auto;
   static constexpr CPUExecutionMode DEFAULT_CPU_EXECUTION_MODE = CPUExecutionMode::Recompiler;
   static constexpr AudioBackend DEFAULT_AUDIO_BACKEND = AudioBackend::Cubeb;
diff --git a/src/core/types.h b/src/core/types.h
index 46482d951..5b1c3c9e8 100644
--- a/src/core/types.h
+++ b/src/core/types.h
@@ -66,6 +66,15 @@ enum class GPURenderer : u8
   Count
 };
 
+enum class GPUTextureFilter : u8
+{
+  Nearest,
+  Bilinear,
+  JINC2,
+  xBRZ,
+  Count
+};
+
 enum class DisplayCropMode : u8
 {
   None,
diff --git a/src/duckstation-libretro/libretro_host_interface.cpp b/src/duckstation-libretro/libretro_host_interface.cpp
index a5031841a..b8a0ae0ed 100644
--- a/src/duckstation-libretro/libretro_host_interface.cpp
+++ b/src/duckstation-libretro/libretro_host_interface.cpp
@@ -471,12 +471,12 @@ static std::array<retro_core_option_definition, 32> s_option_definitions = {{
    "others will break.",
    {{"true", "Enabled"}, {"false", "Disabled"}},
    "false"},
-  {"duckstation_GPU.TextureFiltering",
-   "Bilinear Texture Filtering",
+  {"duckstation_GPU.TextureFilter",
+   "Texture Filtering",
    "Smooths out the blockyness of magnified textures on 3D object by using bilinear filtering. Will have a "
    "greater effect on higher resolution scales. Only applies to the hardware renderers.",
-   {{"true", "Enabled"}, {"false", "Disabled"}},
-   "false"},
+   {{"Nearest", "Nearest-Neighbor"}, {"Bilinear", "Bilinear"}, {"JINC2", "JINC2"}, {"xBRZ", "xBRZ"}},
+   "Nearest"},
   {"duckstation_GPU.WidescreenHack",
    "Widescreen Hack",
    "Increases the field of view from 4:3 to 16:9 in 3D games. For 2D games, or games which use pre-rendered "
diff --git a/src/duckstation-qt/enhancementsettingswidget.cpp b/src/duckstation-qt/enhancementsettingswidget.cpp
index c00cbf6aa..cdaefc717 100644
--- a/src/duckstation-qt/enhancementsettingswidget.cpp
+++ b/src/duckstation-qt/enhancementsettingswidget.cpp
@@ -5,7 +5,8 @@
 #include "settingsdialog.h"
 #include "settingwidgetbinder.h"
 
-EnhancementSettingsWidget::EnhancementSettingsWidget(QtHostInterface* host_interface, QWidget* parent, SettingsDialog* dialog)
+EnhancementSettingsWidget::EnhancementSettingsWidget(QtHostInterface* host_interface, QWidget* parent,
+                                                     SettingsDialog* dialog)
   : QWidget(parent), m_host_interface(host_interface)
 {
   m_ui.setupUi(this);
@@ -16,8 +17,9 @@ EnhancementSettingsWidget::EnhancementSettingsWidget(QtHostInterface* host_inter
   SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.scaledDithering, "GPU", "ScaledDithering");
   SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.disableInterlacing, "GPU", "DisableInterlacing");
   SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.forceNTSCTimings, "GPU", "ForceNTSCTimings");
-  SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.linearTextureFiltering, "GPU",
-                                               "TextureFiltering");
+  SettingWidgetBinder::BindWidgetToEnumSetting(
+    m_host_interface, m_ui.textureFiltering, "GPU", "TextureFilter", &Settings::ParseTextureFilterName,
+    &Settings::GetTextureFilterDisplayName, Settings::DEFAULT_GPU_TEXTURE_FILTER);
   SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.widescreenHack, "GPU", "WidescreenHack");
 
   SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, m_ui.pgxpEnable, "GPU", "PGXPEnable", false);
@@ -62,7 +64,7 @@ EnhancementSettingsWidget::EnhancementSettingsWidget(QtHostInterface* host_inter
                                 "approximately 17% faster. <br>For variable "
                                 "frame rate games, it may not affect the speed."));
   dialog->registerWidgetHelp(
-    m_ui.linearTextureFiltering, tr("Bilinear Texture Filtering"), tr("Unchecked"),
+    m_ui.textureFiltering, tr("Texture Filtering"), tr("Unchecked"),
     tr("Smooths out the blockyness of magnified textures on 3D object by using bilinear filtering. <br>Will have a "
        "greater effect on higher resolution scales. Only applies to the hardware renderers."));
   dialog->registerWidgetHelp(
@@ -96,6 +98,12 @@ void EnhancementSettingsWidget::updateScaledDitheringEnabled()
 void EnhancementSettingsWidget::setupAdditionalUi()
 {
   QtUtils::FillComboBoxWithResolutionScales(m_ui.resolutionScale);
+
+  for (u32 i = 0; i < static_cast<u32>(GPUTextureFilter::Count); i++)
+  {
+    m_ui.textureFiltering->addItem(
+      qApp->translate("GPUTextureFilter", Settings::GetTextureFilterDisplayName(static_cast<GPUTextureFilter>(i))));
+  }
 }
 
 void EnhancementSettingsWidget::updatePGXPSettingsEnabled()
diff --git a/src/duckstation-qt/enhancementsettingswidget.ui b/src/duckstation-qt/enhancementsettingswidget.ui
index c3d556433..02b04fa8f 100644
--- a/src/duckstation-qt/enhancementsettingswidget.ui
+++ b/src/duckstation-qt/enhancementsettingswidget.ui
@@ -42,24 +42,34 @@
           <item row="0" column="1">
            <widget class="QComboBox" name="resolutionScale"/>
           </item>
-          <item row="1" column="0" colspan="2">
+          <item row="1" column="0">
+           <widget class="QLabel" name="label_3">
+            <property name="text">
+             <string>Texture Filtering:</string>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="1">
+           <widget class="QComboBox" name="textureFiltering"/>
+          </item>
+          <item row="2" column="0" colspan="2">
            <widget class="QCheckBox" name="trueColor">
             <property name="text">
              <string>True Color Rendering (24-bit, disables dithering)</string>
             </property>
            </widget>
           </item>
-          <item row="2" column="0" colspan="2">
+          <item row="3" column="0" colspan="2">
            <widget class="QCheckBox" name="scaledDithering">
             <property name="text">
              <string>Scaled Dithering (scale dither pattern to resolution)</string>
             </property>
            </widget>
           </item>
-          <item row="3" column="0" colspan="2">
-           <widget class="QCheckBox" name="linearTextureFiltering">
+          <item row="4" column="0" colspan="2">
+           <widget class="QCheckBox" name="widescreenHack">
             <property name="text">
-             <string>Bilinear Texture Filtering</string>
+             <string>Widescreen Hack (render 3D in 16:9)</string>
             </property>
            </widget>
           </item>
@@ -86,13 +96,6 @@
             </property>
            </widget>
           </item>
-          <item row="2" column="0" colspan="2">
-           <widget class="QCheckBox" name="widescreenHack">
-            <property name="text">
-             <string>Widescreen Hack</string>
-            </property>
-           </widget>
-          </item>
          </layout>
         </widget>
        </item>
diff --git a/src/duckstation-qt/gamepropertiesdialog.cpp b/src/duckstation-qt/gamepropertiesdialog.cpp
index b595ac76a..7194d51e2 100644
--- a/src/duckstation-qt/gamepropertiesdialog.cpp
+++ b/src/duckstation-qt/gamepropertiesdialog.cpp
@@ -283,9 +283,19 @@ void GamePropertiesDialog::populateGameSettings()
     m_ui.userResolutionScale->setCurrentIndex(0);
   }
 
+  if (gs.gpu_texture_filter.has_value())
+  {
+    QSignalBlocker sb(m_ui.userTextureFiltering);
+    m_ui.userTextureFiltering->setCurrentIndex(static_cast<int>(gs.gpu_texture_filter.value()) + 1);
+  }
+  else
+  {
+    QSignalBlocker sb(m_ui.userResolutionScale);
+    m_ui.userTextureFiltering->setCurrentIndex(0);
+  }
+
   populateBooleanUserSetting(m_ui.userTrueColor, gs.gpu_true_color);
   populateBooleanUserSetting(m_ui.userScaledDithering, gs.gpu_scaled_dithering);
-  populateBooleanUserSetting(m_ui.userBilinearTextureFiltering, gs.gpu_bilinear_texture_filtering);
   populateBooleanUserSetting(m_ui.userForceNTSCTimings, gs.gpu_force_ntsc_timings);
   populateBooleanUserSetting(m_ui.userWidescreenHack, gs.gpu_widescreen_hack);
   populateBooleanUserSetting(m_ui.userPGXP, gs.gpu_pgxp);
@@ -388,10 +398,17 @@ void GamePropertiesDialog::connectUi()
     saveGameSettings();
   });
 
+  connect(m_ui.userTextureFiltering, QOverload<int>::of(&QComboBox::currentIndexChanged), [this](int index) {
+    if (index <= 0)
+      m_game_settings.gpu_texture_filter.reset();
+    else
+      m_game_settings.gpu_texture_filter = static_cast<GPUTextureFilter>(index - 1);
+    saveGameSettings();
+  });
+
   connectBooleanUserSetting(m_ui.userTrueColor, &m_game_settings.gpu_true_color);
   connectBooleanUserSetting(m_ui.userScaledDithering, &m_game_settings.gpu_scaled_dithering);
   connectBooleanUserSetting(m_ui.userForceNTSCTimings, &m_game_settings.gpu_force_ntsc_timings);
-  connectBooleanUserSetting(m_ui.userBilinearTextureFiltering, &m_game_settings.gpu_bilinear_texture_filtering);
   connectBooleanUserSetting(m_ui.userWidescreenHack, &m_game_settings.gpu_widescreen_hack);
   connectBooleanUserSetting(m_ui.userPGXP, &m_game_settings.gpu_pgxp);
 
diff --git a/src/duckstation-qt/gamepropertiesdialog.ui b/src/duckstation-qt/gamepropertiesdialog.ui
index e6af3e8bf..b98563e59 100644
--- a/src/duckstation-qt/gamepropertiesdialog.ui
+++ b/src/duckstation-qt/gamepropertiesdialog.ui
@@ -252,6 +252,16 @@
           <item row="0" column="1">
            <widget class="QComboBox" name="userResolutionScale"/>
           </item>
+          <item row="1" column="0">
+           <widget class="QLabel" name="label_23">
+            <property name="text">
+             <string>Texture Filtering:</string>
+            </property>
+           </widget>
+          </item>
+          <item row="1" column="1">
+           <widget class="QComboBox" name="userTextureFiltering" />
+          </item>
           <item row="1" column="0" colspan="2">
            <layout class="QGridLayout" name="gridLayout">
             <item row="0" column="0">
@@ -274,7 +284,7 @@
               </property>
              </widget>
             </item>
-            <item row="2" column="0">
+            <item row="1" column="0">
              <widget class="QCheckBox" name="userWidescreenHack">
               <property name="text">
                <string>Widescreen Hack</string>
@@ -284,7 +294,7 @@
               </property>
              </widget>
             </item>
-            <item row="1" column="0">
+            <item row="1" column="1">
              <widget class="QCheckBox" name="userForceNTSCTimings">
               <property name="text">
                <string>Force NTSC Timings (60hz-on-PAL)</string>
@@ -294,17 +304,7 @@
               </property>
              </widget>
             </item>
-            <item row="1" column="1">
-             <widget class="QCheckBox" name="userBilinearTextureFiltering">
-              <property name="text">
-               <string>Bilinear Texture Filtering</string>
-              </property>
-              <property name="tristate">
-               <bool>true</bool>
-              </property>
-             </widget>
-            </item>
-            <item row="2" column="1">
+            <item row="2" column="0">
              <widget class="QCheckBox" name="userPGXP">
               <property name="text">
                <string>PGXP Geometry Correction</string>
diff --git a/src/duckstation-sdl/sdl_host_interface.cpp b/src/duckstation-sdl/sdl_host_interface.cpp
index c05269a55..2a05fe629 100644
--- a/src/duckstation-sdl/sdl_host_interface.cpp
+++ b/src/duckstation-sdl/sdl_host_interface.cpp
@@ -935,7 +935,23 @@ void SDLHostInterface::DrawQuickSettingsMenu()
 
   settings_changed |= ImGui::MenuItem("True (24-Bit) Color", nullptr, &m_settings_copy.gpu_true_color);
   settings_changed |= ImGui::MenuItem("Scaled Dithering", nullptr, &m_settings_copy.gpu_scaled_dithering);
-  settings_changed |= ImGui::MenuItem("Texture Filtering", nullptr, &m_settings_copy.gpu_texture_filtering);
+
+  if (ImGui::BeginMenu("Texture Filtering"))
+  {
+    const GPUTextureFilter current = m_settings_copy.gpu_texture_filter;
+    for (u32 i = 0; i < static_cast<u32>(GPUTextureFilter::Count); i++)
+    {
+      if (ImGui::MenuItem(Settings::GetTextureFilterDisplayName(static_cast<GPUTextureFilter>(i)), nullptr,
+                          i == static_cast<u32>(current)))
+      {
+        m_settings_copy.gpu_texture_filter = static_cast<GPUTextureFilter>(i);
+        settings_changed = true;
+      }
+    }
+
+    ImGui::EndMenu();
+  }
+
   settings_changed |= ImGui::MenuItem("Disable Interlacing", nullptr, &m_settings_copy.gpu_disable_interlacing);
   settings_changed |= ImGui::MenuItem("Widescreen Hack", nullptr, &m_settings_copy.gpu_widescreen_hack);
   settings_changed |= ImGui::MenuItem("Display Linear Filtering", nullptr, &m_settings_copy.display_linear_filtering);
@@ -1401,8 +1417,22 @@ void SDLHostInterface::DrawSettingsWindow()
           settings_changed = true;
         }
 
+        ImGui::Text("Texture Filtering:");
+        ImGui::SameLine(indent);
+        int gpu_texture_filter = static_cast<int>(m_settings_copy.gpu_texture_filter);
+        if (ImGui::Combo(
+              "##gpu_texture_filter", &gpu_texture_filter,
+              [](void*, int index, const char** out_text) {
+                *out_text = Settings::GetTextureFilterDisplayName(static_cast<GPUTextureFilter>(index));
+                return true;
+              },
+              nullptr, static_cast<int>(GPUTextureFilter::Count)))
+        {
+          m_settings_copy.gpu_texture_filter = static_cast<GPUTextureFilter>(gpu_texture_filter);
+          settings_changed = true;
+        }
+
         settings_changed |= ImGui::Checkbox("True 24-bit Color (disables dithering)", &m_settings_copy.gpu_true_color);
-        settings_changed |= ImGui::Checkbox("Texture Filtering", &m_settings_copy.gpu_texture_filtering);
         settings_changed |= ImGui::Checkbox("Disable Interlacing", &m_settings_copy.gpu_disable_interlacing);
         settings_changed |= ImGui::Checkbox("Force NTSC Timings", &m_settings_copy.gpu_force_ntsc_timings);
         settings_changed |= ImGui::Checkbox("Widescreen Hack", &m_settings_copy.gpu_widescreen_hack);
diff --git a/src/frontend-common/game_settings.cpp b/src/frontend-common/game_settings.cpp
index 4e655de20..46ced82ba 100644
--- a/src/frontend-common/game_settings.cpp
+++ b/src/frontend-common/game_settings.cpp
@@ -116,7 +116,7 @@ bool Entry::LoadFromStream(ByteStream* stream)
       !ReadOptionalFromStream(stream, &gpu_resolution_scale) || !ReadOptionalFromStream(stream, &gpu_true_color) ||
       !ReadOptionalFromStream(stream, &gpu_scaled_dithering) ||
       !ReadOptionalFromStream(stream, &gpu_force_ntsc_timings) ||
-      !ReadOptionalFromStream(stream, &gpu_bilinear_texture_filtering) ||
+      !ReadOptionalFromStream(stream, &gpu_texture_filter) ||
       !ReadOptionalFromStream(stream, &gpu_widescreen_hack) || !ReadOptionalFromStream(stream, &gpu_pgxp) ||
       !ReadOptionalFromStream(stream, &controller_1_type) || !ReadOptionalFromStream(stream, &controller_2_type) ||
       !ReadOptionalFromStream(stream, &memory_card_1_type) || !ReadOptionalFromStream(stream, &memory_card_2_type) ||
@@ -154,7 +154,7 @@ bool Entry::SaveToStream(ByteStream* stream) const
          WriteOptionalToStream(stream, display_aspect_ratio) && WriteOptionalToStream(stream, gpu_resolution_scale) &&
          WriteOptionalToStream(stream, gpu_true_color) && WriteOptionalToStream(stream, gpu_scaled_dithering) &&
          WriteOptionalToStream(stream, gpu_force_ntsc_timings) &&
-         WriteOptionalToStream(stream, gpu_bilinear_texture_filtering) &&
+         WriteOptionalToStream(stream, gpu_texture_filter) &&
          WriteOptionalToStream(stream, gpu_widescreen_hack) && WriteOptionalToStream(stream, gpu_pgxp) &&
          WriteOptionalToStream(stream, controller_1_type) && WriteOptionalToStream(stream, controller_2_type) &&
          WriteOptionalToStream(stream, memory_card_1_type) && WriteOptionalToStream(stream, memory_card_2_type) &&
@@ -201,7 +201,7 @@ static void ParseIniSection(Entry* entry, const char* section, const CSimpleIniA
     entry->gpu_scaled_dithering = StringUtil::FromChars<bool>(cvalue);
   cvalue = ini.GetValue(section, "GPUBilinearTextureFiltering", nullptr);
   if (cvalue)
-    entry->gpu_bilinear_texture_filtering = StringUtil::FromChars<bool>(cvalue);
+    entry->gpu_texture_filter = Settings::ParseTextureFilterName(cvalue);
   cvalue = ini.GetValue(section, "GPUForceNTSCTimings", nullptr);
   if (cvalue)
     entry->gpu_force_ntsc_timings = StringUtil::FromChars<bool>(cvalue);
@@ -265,11 +265,8 @@ static void StoreIniSection(const Entry& entry, const char* section, CSimpleIniA
     ini.SetValue(section, "GPUTrueColor", entry.gpu_true_color.value() ? "true" : "false");
   if (entry.gpu_scaled_dithering.has_value())
     ini.SetValue(section, "GPUScaledDithering", entry.gpu_scaled_dithering.value() ? "true" : "false");
-  if (entry.gpu_bilinear_texture_filtering.has_value())
-  {
-    ini.SetValue(section, "GPUBilinearTextureFiltering",
-                 entry.gpu_bilinear_texture_filtering.value() ? "true" : "false");
-  }
+  if (entry.gpu_texture_filter.has_value())
+    ini.SetValue(section, "GPUTextureFilter", Settings::GetTextureFilterName(entry.gpu_texture_filter.value()));
   if (entry.gpu_force_ntsc_timings.has_value())
     ini.SetValue(section, "GPUForceNTSCTimings", entry.gpu_force_ntsc_timings.value() ? "true" : "false");
   if (entry.gpu_widescreen_hack.has_value())
@@ -417,8 +414,8 @@ void Entry::ApplySettings(bool display_osd_messages) const
     g_settings.gpu_scaled_dithering = gpu_scaled_dithering.value();
   if (gpu_force_ntsc_timings.has_value())
     g_settings.gpu_force_ntsc_timings = gpu_force_ntsc_timings.value();
-  if (gpu_bilinear_texture_filtering)
-    g_settings.gpu_texture_filtering = gpu_bilinear_texture_filtering.value();
+  if (gpu_texture_filter.has_value())
+    g_settings.gpu_texture_filter = gpu_texture_filter.value();
   if (gpu_widescreen_hack.has_value())
     g_settings.gpu_widescreen_hack = gpu_widescreen_hack.value();
   if (gpu_pgxp.has_value())
diff --git a/src/frontend-common/game_settings.h b/src/frontend-common/game_settings.h
index 3f67eb08c..9e466d743 100644
--- a/src/frontend-common/game_settings.h
+++ b/src/frontend-common/game_settings.h
@@ -47,7 +47,7 @@ struct Entry
   std::optional<bool> gpu_true_color;
   std::optional<bool> gpu_scaled_dithering;
   std::optional<bool> gpu_force_ntsc_timings;
-  std::optional<bool> gpu_bilinear_texture_filtering;
+  std::optional<GPUTextureFilter> gpu_texture_filter;
   std::optional<bool> gpu_widescreen_hack;
   std::optional<bool> gpu_pgxp;
   std::optional<ControllerType> controller_1_type;