GS: add SGSR upscaling (Qualcomm Snapdragon Game Super Resolution 1)

A third display upscaler beside FSR1, and the first one written for the
hardware this app actually runs on: FSR1's two compute passes are the
expensive outlier on mobile, while SGSR is a single pass Qualcomm designed
for Adreno.

The filter is Qualcomm's, BSD-3-Clause, unchanged in substance. What differs
is the shape around it -- theirs is a fragment shader over a fullscreen
triangle, this is a compute pass, because that is what GSDevice already knows
how to schedule. So the interpolated texcoord becomes a UV computed from the
invocation id and the fragment output becomes an imageStore. The crop
handling and the widened 0..2 sharpness range come from the Eden Emulator
Project's port (GPL-3.0-or-later, compatible with PCSX2's GPL-3.0+); PCSX2
hands the pass a display rectangle inside a larger target, which is the same
problem FsrEasuConOffset solves for FSR1.

Deliberately a strict subset of what FSR1 already requires -- same descriptor
types, same rgba8 storage image, textureGather with a constant component and
no offset, which is core Vulkan 1.0 and needs no optional feature. So any
driver already running FSR1 can run this, Turnip included, and there is no
vendor gate on either. A driver that cannot compile the pipeline clears
Features().sgsr and the renderer falls back to plain bilinear with an OSD
notice, rather than failing.

The Android upscaler control becomes a picker rather than an on/off toggle:
three mutually exclusive upscalers expressed as two toggles that silently
switch each other off is a worse way to say it than one list. FSR1 and SGSR
share the existing sharpness slider -- the number means different things to
each, but it is the same intent, and a second slider would only invite the
two to disagree.

★ The Settings.kt clamp on the persisted enum was still bounded at
UPSCALER_FSR1, and would have silently rewritten any SGSR selection back to
Off. That clamp's own comment warns about exactly this failure; it still had
to be updated by hand. Worth remembering the next time the enum grows.

Suggested by CamilleLaVey, who authored the upstream changes (eden-emu #4293).
This commit is contained in:
jpolo1224
2026-08-24 15:44:20 -04:00
parent cf5577b773
commit 001ca40803
10 changed files with 383 additions and 14 deletions
+114
View File
@@ -0,0 +1,114 @@
// Snapdragon Game Super Resolution 1.0, "mobile" variant.
//
// SPDX-FileCopyrightText: Copyright (c) 2025, Qualcomm Innovation Center, Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
//
// The filter body below is Qualcomm's, unchanged in substance. What differs from their sample is
// the shape around it: theirs is a fragment shader over a fullscreen triangle, and this is a
// compute pass, because that is what GSDevice already knows how to schedule (see fsr1.glsl and
// DoFSR1Pass). So the interpolated texcoord becomes an explicit UV computed from the invocation
// id, and the fragment output becomes an imageStore.
//
// The crop handling comes from the Eden Emulator Project's port (GPL-3.0-or-later), which found
// that the source rect has to be mapped explicitly rather than assumed to be the whole texture --
// PCSX2 hands us a display rectangle inside a larger target for exactly the same reason FSR1's
// FsrEasuConOffset takes an offset. The sharpness range being 0..2 rather than 0..1 comes from
// there too; the original was too tight to be useful at the top end.
//
// Brought to ARMSX2 at the suggestion of CamilleLaVey, who authored the Eden changes this is
// based on (eden-emu/eden PR #4293).
#define EDGE_THRESHOLD (8.0 / 255.0)
layout(push_constant) uniform const_buffer
{
// Output extent, for the bounds check. A dispatch is rounded up to whole workgroups, so the
// last one runs partly outside the image.
uvec2 dstSize;
// The displayed region inside the source texture, normalised. PCSX2's merge target is bigger
// than the picture in it; without this the filter would upscale the padding too.
vec2 uvOffset;
vec2 uvScale;
// Source texture dimensions and their reciprocal. Qualcomm's "size" and "scale".
vec2 srcSize;
vec2 invSrcSize;
// 0..2. 1.0 is Qualcomm's own default; above that is oversharpened and is offered because
// the range was too tight to be useful at the top end.
float edgeSharpness;
};
layout(set = 0, binding = 0) uniform sampler2D imgSrc;
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D imgDst;
vec4 weightY(vec4 dx, vec4 dy, vec4 std)
{
vec4 x = ((dx * dx) + (dy * dy)) * 0.55f + std;
return (x - 1.f) * (x - 4.f) * 3.8125f; // approx. of (x - 1) * (x - 4)^3
}
layout(local_size_x = 8, local_size_y = 8) in;
void main()
{
const uvec2 pos = gl_GlobalInvocationID.xy;
if (pos.x >= dstSize.x || pos.y >= dstSize.y)
return;
// Centre of this output pixel, mapped into the displayed region of the source.
const vec2 texcoord = uvOffset + ((vec2(pos) + vec2(0.5f)) / vec2(dstSize)) * uvScale;
vec4 color = textureLod(imgSrc, texcoord, 0.0f);
// image coord
vec2 icoord = (texcoord * srcSize + vec2(-0.5f, 0.5f));
vec2 icoord_pixel = floor(icoord);
vec2 coord = icoord_pixel * invSrcSize;
vec2 pl = icoord - icoord_pixel;
// left: 0, right: 1, upDown: 2
mat3x4 dg = mat3x4(
textureGather(imgSrc, coord, 1),
textureGather(imgSrc, coord + vec2(2.f * invSrcSize.x, 0.0f), 1),
vec4(
textureGather(imgSrc, coord + vec2(invSrcSize.x, -invSrcSize.y), 1).wz,
textureGather(imgSrc, coord + vec2(invSrcSize.x, +invSrcSize.y), 1).yx
)
);
float edgeVote = abs(dg[0].z - dg[0].y) + abs(color.y - dg[0].y) + abs(color.y - dg[0].z);
if (edgeVote > EDGE_THRESHOLD)
{
float mean = (dg[0].y + dg[0].z + dg[1].x + dg[1].w) * 0.25f;
dg = dg - mean;
vec4 sum = abs(dg[0]) + abs(dg[1]) + abs(dg[2]);
float std = 2.181818f / (sum.x + sum.y + sum.z + sum.w);
mat2x4 w = mat2x4(
weightY(
pl.xxxx + vec4(+1.0f, +0.0f, +0.0f, +1.0f),
pl.yyyy + vec4(-1.0f, -1.0f, +0.0f, +0.0f),
clamp(abs(dg[0]) * std, 0.0f, 1.0f)
) + weightY(
pl.xxxx + vec4(-1.0f, -2.0f, -2.0f, -1.0f),
pl.yyyy + vec4(-1.0f, -1.0f, +0.0f, +0.0f),
clamp(abs(dg[1]) * std, 0.0f, 1.0f)
) + weightY(
pl.xxxx + vec4(+0.0f, -1.0f, -1.0f, +0.0f),
pl.yyyy + vec4(+1.0f, +1.0f, -2.0f, -2.0f),
clamp(abs(dg[2]) * std, 0.0f, 1.0f)
),
dg[0] + dg[1] + dg[2]
);
// compute final y with bounds
vec2 yb = vec2(
min(min(dg[0].y, dg[0].z), min(dg[1].x, dg[1].w)), // min
max(max(dg[0].y, dg[0].z), max(dg[1].x, dg[1].w)) // max
);
vec2 fvy = vec2(
w[0].x + w[0].y + w[0].z + w[0].w,
w[1].x + w[1].y + w[1].z + w[1].w
);
float fy = clamp((fvy.y / fvy.x) * edgeSharpness, yb[0], yb[1]);
// Smooth high contrast input
float dy = clamp(fy - color.y + mean, -23.0f / 255.0f, 23.0f / 255.0f);
color = clamp(color + dy, 0.0f, 1.0f);
}
color.w = 1.0f; // assume alpha channel is not used
imageStore(imgDst, ivec2(pos), color);
}