Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions gpu/vk/shaders/fg_scale.comp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#version 450
// the frame to the window's size (the Insert menu's Upscaling, Sharpness): Catmull-Rom or Lanczos 2, or the linear filter, then
// a contrast-limited sharpen that cannot go past the light around the pixel, so it adds no halos and no flashes
// a contrast-adaptive sharpen in the manner of AMD FidelityFX RCAS that cannot go past the light around the pixel, so it adds no halos,
// no clipping and no flashes
layout(local_size_x = 8, local_size_y = 8) in;
layout(binding = 0) uniform sampler2D src;
layout(binding = 1, rgba8) uniform writeonly image2D dst;
Expand Down Expand Up @@ -36,18 +37,38 @@ vec3 filtered(vec2 uv) {
}
return sum / weight;
}
// The Sharpness slider (pc.sharp 0 to 1) is RCAS's strength: 0 the picture as filtered, 1 its strongest lobe.
const float kMaxLobe = 0.25 - 1.0 / 16.0;
vec3 sharpen(vec3 c, vec3 n, vec3 s, vec3 w, vec3 e, float amount) {
// RCAS: the four neighbours weighted by a negative lobe, the largest that keeps the result inside 0..1 for this
// neighbourhood (smaller where the light is near black or white), then normalized. Two differences from RCAS's own
// maths: the room to black divides by 4 x the lightest of the pixel and its neighbours (RCAS: of the neighbours
// alone), never more room than RCAS gives, so only more careful; and like RCAS the room is the smallest of the three
// channels, so a saturated colour (a channel near 0 or 1, not only near black or white) is sharpened little
const vec3 lo4 = min(min(n, s), min(w, e)), hi4 = max(max(n, s), max(w, e));
const vec3 lo = min(lo4, c), hi = max(hi4, c);
const vec3 to_black = lo / max(4.0 * hi, vec3(1.0 / 1024.0));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RCAS divides by 4*mx4 (neighbours only); this uses hi, which includes c. That is never larger room than RCAS, so only more conservative, and the final clamp to [lo,hi] already guarantees 0..1. Fine, just not literally RCAS; maybe say so in the comment.

const vec3 to_white = (1.0 - hi) / max(4.0 - 4.0 * lo4, vec3(1.0 / 1024.0));
const vec3 room = min(to_black, to_white);
const float lobe = -clamp(min(min(room.r, room.g), room.b), 0.0, kMaxLobe) * clamp(amount, 0.0, 1.0);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking the min room over r/g/b means any pixel with a channel near 0 or 1 (saturated colours, not just black/white) gets almost no lobe. RCAS does the same, so it's faithful, but it explains why the effect is weaker on strongly coloured areas than the luma metric suggests.

const vec3 sharp = (c + lobe * (n + s + w + e)) / (1.0 + 4.0 * lobe);
// (and no further than the lightest and darkest pixel around it: an edge gets steeper, no halo beside it)
return clamp(sharp, lo, hi);
}
void main() {
const ivec2 g = ivec2(gl_GlobalInvocationID.xy);
const ivec2 d = imageSize(dst);
if (any(greaterThanEqual(g, d))) return;
const vec2 uv = (vec2(g) + 0.5) / vec2(d);
vec3 c = filtered(uv);
if (pc.sharp > 0.0) {
const vec2 e = 1.0 / vec2(d);
// The neighbours one picture pixel away (one window pixel when the window is the smaller). They were one window
// pixel away, a fraction of a picture pixel when the window is larger (0.53 for 2048 x 1152 on a 3840 x 2160
// screen): what the slider did then hung on the window's size, not on the picture.
const vec2 e = max(1.0 / vec2(textureSize(src, 0)), 1.0 / vec2(d));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked: src is the cropped picture when a crop is requested (blit into scale.cropped first), so this is one picture pixel per axis at any ratio. Neighbours are bilinear at the output pixel's sub-texel phase while c is Catmull-Rom/Lanczos, same as main; no new issue.

const vec3 n = textureLod(src, uv + vec2(0.0, -e.y), 0.0).rgb, s = textureLod(src, uv + vec2(0.0, e.y), 0.0).rgb;
const vec3 w = textureLod(src, uv + vec2(-e.x, 0.0), 0.0).rgb, r = textureLod(src, uv + vec2(e.x, 0.0), 0.0).rgb;
const vec3 lo = min(min(min(n, s), min(w, r)), c), hi = max(max(max(n, s), max(w, r)), c);
c = clamp(c + pc.sharp * (c - (n + s + w + r) * 0.25), lo, hi);
c = sharpen(c, n, s, w, r, pc.sharp);
}
imageStore(dst, g, vec4(c, 1.0));
}
550 changes: 305 additions & 245 deletions gpu/vk/shaders/fg_scale_comp_spv.h

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion gpu/vk/vk_framegen.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ VkImage framegen_scale(VkCommandBuffer cmd, VkImageView src, VkImage image, uint
static bool reported = false;
if (report && !reported) {
reported = true;
logf("vk: upscale dispatched: mode %d, sharpness %d, source %ux%u, crop request %d,%d %dx%d, destination %ux%u",
logf("vk: upscale dispatched: mode %d, sharpness %d%% (of RCAS's strongest, neighbours one source pixel apart), source %ux%u, "
"crop request %d,%d %dx%d, destination %ux%u",
opts.upscale, opts.sharpness, sw, sh, crop ? crop[0] : 0, crop ? crop[1] : 0,
crop ? crop[2] : (int)sw, crop ? crop[3] : (int)sh, dw, dh);
}
Expand Down
13 changes: 8 additions & 5 deletions gpu/vk/vk_menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -350,26 +350,29 @@ void sync_preset_budgets(int p) {
default: opts.vram_mb = 0; opts.mirror_mb = 0; break;
}
}
// (Sharpness: tuned for the window pass's neighbours one picture pixel apart, each about the old setting's sharpening on a
// 3840 x 2160 window at that preset's picture, 2048 x 1152 or the handheld profile's 1536 x 864: same frame, mean |Laplacian|
// of the window and of the picture, on the slider's steps)
void apply_preset(int p) {
switch (p) {
case 1: // Minimum (4GB VRAM / iGPU)
opts.resolution = 4; opts.upscale = 2; opts.sharpness = 50;
opts.resolution = 4; opts.upscale = 2; opts.sharpness = 30;
opts.framegen = 2; opts.pace = 1; opts.realtime = 1; opts.handheld = 1;
break;
case 2: // Low (6GB VRAM)
opts.resolution = 2; opts.upscale = 2; opts.sharpness = 35;
opts.resolution = 2; opts.upscale = 2; opts.sharpness = 30;
opts.framegen = 2; opts.pace = 1; opts.realtime = 1; opts.handheld = 1;
break;
case 3: // Balanced (8GB VRAM)
opts.resolution = 1; opts.upscale = 1; opts.sharpness = 25;
opts.resolution = 1; opts.upscale = 1; opts.sharpness = 30;
opts.framegen = 2; opts.pace = 1; opts.realtime = 1; opts.handheld = 0;
break;
case 4: // High (12GB VRAM)
opts.resolution = 1; opts.upscale = 2; opts.sharpness = 30;
opts.resolution = 1; opts.upscale = 2; opts.sharpness = 40;
opts.framegen = 2; opts.pace = 1; opts.realtime = 1; opts.handheld = 0;
break;
case 5: // Ultra (16GB+ VRAM)
opts.resolution = 1; opts.upscale = 2; opts.sharpness = 40;
opts.resolution = 1; opts.upscale = 2; opts.sharpness = 50;
opts.framegen = 3; opts.pace = 1; opts.realtime = 1; opts.handheld = 0;
break;
default:
Expand Down
4 changes: 3 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,9 @@ choices and starts the game again, so progress since your last save is lost.
edges smoothed). Both look softer than the game's own: set **Sharpness** to around 80% with them. **DLSS model**
chooses their network (below). **Render
resolution** is the 3D's share of the output. The **Window scaling filter** then scales the frame to your window,
and **Sharpness** sharpens it.
and **Sharpness** sharpens it, by the picture's pixels: when the window is much larger than the picture (the Handheld
profile's 1536x864 on a 4K screen), a Sharpness kept from an older version is stronger than it was, so a lower one may
suit.
- **Output resolution:** its rows below show the sizes for the output you chose. Docked only. On a 1920x1080 display,
the 1920x1080 option keeps HUD and text sharp when the game fills the screen by mapping them pixel for pixel. At
3840x2160 the game's render targets get a bigger GPU pool, so a full-size 3D fits. For one run, set `HOS_OUTPUT_RES`
Expand Down
1 change: 1 addition & 0 deletions tests/run_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"tests/test_text_speed.py": ["--cc", "gcc"],
"tests/test_audio_in.py": ["--cc", "gcc", "--cxx", "g++"],
"tests/test_upscale_fallback.py": ["--cxx", "g++"],
"tests/test_fg_scale_sharpen.py": ["--cxx", "g++"],
"tests/test_taau_env_lookup.py": ["--cxx", "g++"],
"tests/test_target_overlap.py": ["--cxx", "g++"],
"tests/test_menu_hit.py": ["--cxx", "g++"],
Expand Down
182 changes: 182 additions & 0 deletions tests/test_fg_scale_sharpen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""The window pass's Sharpness (gpu/vk/shaders/fg_scale.comp): its neighbours are one picture (source) pixel apart, not one
window pixel (inside the same picture pixel when the window is the larger, so Sharpness did next to nothing), and its
per-pixel math, the shader's own sharpen() compiled as C++, is the picture unchanged at 0, never past the lightest or
darkest pixel around it nor outside 0..1 (no halos, no clipping) at any setting, and steeper edges as the setting rises.
fg_scale_comp_spv.h names the hash of the fg_scale.comp it was made from (its first line), checked against the shader as it
is now with or without glslang; with glslangValidator the shader compiles, and with the version the header names, the rest of
the header is its output byte for byte. --write makes the header: glslang's output with that line on top.
python -I -B tests/test_fg_scale_sharpen.py [--cxx g++] [--glslang glslangValidator] [--write]
"""
import argparse
import hashlib
import re
import shutil
import subprocess
import tempfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SHADERS = ROOT / "gpu" / "vk" / "shaders"


def source_line(comp):
"""the header's first line: the shader's hash (line ends as LF, as the checkout has them) and how the header is made"""
digest = hashlib.sha256(comp.read_bytes().replace(b"\r\n", b"\n")).hexdigest()
return ("// fg_scale.comp sha256 %s (tests/test_fg_scale_sharpen.py --write: glslangValidator -V --target-env vulkan1.2 "
"--vn FG_SCALE_COMP_SPV)\n" % digest).encode()


def compile_header(glslang, comp, out):
r = subprocess.run([glslang, "-V", "--target-env", "vulkan1.2", "--vn", "FG_SCALE_COMP_SPV", "-o", str(out), str(comp)],
capture_output=True, text=True)
if r.returncode:
raise SystemExit("fg_scale.comp does not compile:\n" + r.stdout + r.stderr)
return out.read_bytes()


def block(text, marker):
start = text.index(marker)
end = text.index("{", start) + 1
depth = 1
while depth:
depth += (text[end] == "{") - (text[end] == "}")
end += 1
return text[start:end]


# (just enough of GLSL's vec3 for sharpen())
SHIM = r'''
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
struct vec3 {
float r, g, b;
vec3() : r(0), g(0), b(0) {}
explicit vec3(float v) : r(v), g(v), b(v) {}
vec3(float x, float y, float z) : r(x), g(y), b(z) {}
};
static vec3 map2(vec3 a, vec3 b, float (*f)(float, float)) { return {f(a.r, b.r), f(a.g, b.g), f(a.b, b.b)}; }
static float fadd(float a, float b) { return a + b; }
static float fsub(float a, float b) { return a - b; }
static float fmul(float a, float b) { return a * b; }
static float fdiv(float a, float b) { return a / b; }
static float fmin_(float a, float b) { return std::min(a, b); }
static float fmax_(float a, float b) { return std::max(a, b); }
static vec3 operator+(vec3 a, vec3 b) { return map2(a, b, fadd); }
static vec3 operator-(vec3 a, vec3 b) { return map2(a, b, fsub); }
static vec3 operator*(vec3 a, vec3 b) { return map2(a, b, fmul); }
static vec3 operator/(vec3 a, vec3 b) { return map2(a, b, fdiv); }
static vec3 operator+(float a, vec3 b) { return vec3(a) + b; }
static vec3 operator-(float a, vec3 b) { return vec3(a) - b; }
static vec3 operator*(float a, vec3 b) { return vec3(a) * b; }
static vec3 operator/(vec3 a, float b) { return a / vec3(b); }
static vec3 min(vec3 a, vec3 b) { return map2(a, b, fmin_); }
static vec3 max(vec3 a, vec3 b) { return map2(a, b, fmax_); }
static float min(float a, float b) { return std::min(a, b); }
static float max(float a, float b) { return std::max(a, b); }
static float clamp(float v, float lo, float hi) { return std::min(std::max(v, lo), hi); }
static vec3 clamp(vec3 v, vec3 lo, vec3 hi) { return min(max(v, lo), hi); }
'''

FIXTURE = r'''
#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL:%d %s\n", __LINE__, #x); std::exit(1); } } while (0)
static unsigned seed = 12345;
static float rnd() { seed = seed * 1664525u + 1013904223u; return (seed >> 8) / 16777216.0f; }
static vec3 rnd3() { return {rnd(), rnd(), rnd()}; }
static float ch(vec3 v, int i) { return i == 0 ? v.r : i == 1 ? v.g : v.b; }
// a blurred edge (the picture as it reaches the window pass), sampled one picture pixel apart around x
static float edge(float x) { return 0.1f + 0.8f / (1.0f + std::exp(-2.0f * x)); }
int main() {
const float amounts[] = {0.0f, 0.1f, 0.4f, 0.8f, 1.0f};
for (int i = 0; i < 200000; i++) {
const vec3 c = rnd3(), n = rnd3(), s = rnd3(), w = rnd3(), e = rnd3();
for (float a : amounts) {
const vec3 o = sharpen(c, n, s, w, e, a);
for (int k = 0; k < 3; k++) {
const float lo = std::min({ch(c, k), ch(n, k), ch(s, k), ch(w, k), ch(e, k)});
const float hi = std::max({ch(c, k), ch(n, k), ch(s, k), ch(w, k), ch(e, k)});
CHECK(ch(o, k) >= lo - 1e-6f && ch(o, k) <= hi + 1e-6f); // no halo: inside the neighbourhood
CHECK(ch(o, k) >= 0.0f && ch(o, k) <= 1.0f); // no clipping
CHECK(std::isfinite(ch(o, k)));
if (a == 0.0f) CHECK(ch(o, k) == ch(c, k)); // 0: the picture unchanged
}
}
}
// flat light, black and white stay as they are at full strength
for (float v : {0.0f, 0.5f, 1.0f}) {
const vec3 o = sharpen(vec3(v), vec3(v), vec3(v), vec3(v), vec3(v), 1.0f);
CHECK(o.r == v && o.g == v && o.b == v);
}
// a blurred edge gets steeper as the setting rises: the pixel on the light side lighter, never past the light beyond it
float last = edge(0.6f);
for (float a : {0.25f, 0.5f, 0.75f, 1.0f}) {
const vec3 c(edge(0.6f)), mid(edge(0.6f));
const vec3 o = sharpen(c, mid, mid, vec3(edge(-0.4f)), vec3(edge(1.6f)), a);
CHECK(o.r > last && o.r <= edge(1.6f));
last = o.r;
}
CHECK(last - edge(0.6f) > 0.01f); // (a visible step, not a rounding)
std::printf("PASS: sharpen() is the picture at 0, stays inside its neighbourhood and 0..1, and steepens edges with the setting\n");
}
'''


def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--cxx", default="g++")
p.add_argument("--glslang", default="glslangValidator")
p.add_argument("--write", action="store_true", help="make fg_scale_comp_spv.h from fg_scale.comp")
a = p.parse_args()
comp = SHADERS / "fg_scale.comp"
header_path = SHADERS / "fg_scale_comp_spv.h"
if a.write:
glslang = shutil.which(a.glslang)
if not glslang:
raise SystemExit("--write needs %s" % a.glslang)
with tempfile.TemporaryDirectory(prefix="fg-sharpen-") as tmp:
header_path.write_bytes(source_line(comp) + compile_header(glslang, comp, Path(tmp) / "out.h"))
print("wrote %s" % header_path)
return
header = header_path.read_bytes()
first, _, hbody = header.partition(b"\n")
if first + b"\n" != source_line(comp):
raise SystemExit("fg_scale_comp_spv.h was not made from this fg_scale.comp (its first line names another hash): "
"python -I -B tests/test_fg_scale_sharpen.py --write")
text = comp.read_text(encoding="utf-8")
body = block(text, "void main()")
sharp = body[body.index("if (pc.sharp > 0.0)"):]
if "textureSize(src, 0)" not in sharp:
raise SystemExit("the sharpen's neighbours are not spaced by the picture's size (textureSize(src, 0))")
spacing = re.search(r"const vec2 e = ([^;]*);", sharp)
if not spacing or "textureSize(src, 0)" not in spacing.group(1):
raise SystemExit("the neighbour spacing e is not one picture pixel")
for tap in ("uv + vec2(0.0, -e.y)", "uv + vec2(0.0, e.y)", "uv + vec2(-e.x, 0.0)", "uv + vec2(e.x, 0.0)"):
if tap not in sharp:
raise SystemExit("missing the neighbour at %s" % tap)
lobe = re.search(r"const float kMaxLobe = [^;]*;", text).group(0)
fn = block(text, "vec3 sharpen(")
fixture = SHIM + lobe.replace("const float", "static const float") + "\nstatic " + fn + FIXTURE
with tempfile.TemporaryDirectory(prefix="fg-sharpen-") as tmp:
cpp = Path(tmp) / "fixture.cpp"
exe = Path(tmp) / "fixture"
cpp.write_text(fixture, encoding="utf-8")
subprocess.run([a.cxx, "-std=c++20", "-O1", "-Wall", "-Wextra", "-Wno-unused-function", str(cpp), "-o", str(exe)], check=True)
subprocess.run([str(exe)], check=True)
glslang = shutil.which(a.glslang)
if not glslang:
print("PASS: the header names this shader (no %s: its compile not checked)" % a.glslang)
return
made = compile_header(glslang, comp, Path(tmp) / "fg_scale_comp_spv.h")
if made.splitlines()[0] != hbody.splitlines()[0]:
print("PASS: the header names this shader, and it compiles (glslang %s, the header's is %s: bytes not compared)" %
(made.splitlines()[0].decode().strip(), hbody.splitlines()[0].decode().strip()))
return
if made != hbody:
raise SystemExit("fg_scale_comp_spv.h is not fg_scale.comp compiled: python -I -B tests/test_fg_scale_sharpen.py --write")
print("PASS: fg_scale_comp_spv.h is fg_scale.comp compiled")


if __name__ == "__main__":
main()
Loading