Repository navigation
fix(vk): Sharpness samples one picture pixel apart, RCAS-style, the same at any window size #1256
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0ca14b3
b48cb14
4a4a187
488ec95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
@@ -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)); | ||
| 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); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checked: |
||
| 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)); | ||
| } | ||
Large diffs are not rendered by default.
| 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() |
There was a problem hiding this comment.
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 useshi, which includesc. 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.