Repository navigation
fix(vk): Sharpness samples one picture pixel apart, RCAS-style, the same at any window size - #1256
Conversation
… sharpen in the manner of AMD FidelityFX RCAS The window pass took the sharpen's neighbours one window pixel apart: a fraction of a picture pixel when the window is larger than the picture, so what the slider did hung on the window's size. They are now one picture pixel apart (one window pixel when the window is the smaller), and the sharpen is RCAS's: a negative lobe on the four neighbours, the largest that keeps the result inside 0..1 for the neighbourhood, the slider 0..100% on 0..RCAS's strongest lobe, then held to the neighbourhood's light range (no halos, no clipping). tests/test_fg_scale_sharpen.py: the spacing, sharpen() compiled as C++ (unchanged at 0, never outside the neighbourhood or 0..1, edges steeper as the setting rises) and the SPIR-V header matching the shader.
hckrman101
left a comment
There was a problem hiding this comment.
Self-review notes.
Checked and fine:
- 0% is exactly main:
pc.sharp > 0.0still gates the whole block, andsharpen()at amount 0 returnscbit for bit anyway (lobe -0, divide by 1, clamp no-op). - Push constants unchanged (
{int mode; float sharp;}, 8 bytes on both sides); the C++ change is only the log line. - Spacing: the crop is blitted into
scale.croppedbefore the pass, sotextureSize(src, 0)is the cropped picture and 1/size is one picture pixel in uv for any ratio, per axis (non-integer and ultrawide fine). Downscale falls back to one window pixel. CLAMP_TO_EDGE linear sampler handles borders. - RCAS maths: lo/hi ring,
(1-hi)/(4-4*mn4)white room, min over channels, lobe capped at 0.25-1/16,(c + lobe*sum)/(1+4*lobe); the 1/1024 floors keep it finite at black/white. Same 4 taps as before, a few extra ALU ops: no perf concern. - Still sharpens UNORM (display-encoded) values, as before and as RCAS normally runs. Same shader/push constant for direct present and frame generation, all three Upscaling modes.
- New test passes on both CI runners; the two Python failures are main's own (test_output_resolution, test_program_cache_cleanup).
Worth a look before merge:
- Presets were tuned against the old window-pixel spacing (Minimum = 50%, Low = 35% at reduced resolution). With large window/picture ratios the same number is now much stronger (the 3x numbers in the description: +87% vs +27% at 100%). Either re-tune
apply_preset()in vk_menu.cpp or call out in the release notes that existing Sharpness values get stronger at low Resolution. - The header check only compares bytes when the local glslang's version line matches the header's (
// 1116.6.0), so CI (no matching glslang) never verifies fg_scale_comp_spv.h is the .comp compiled. Fine as-is since it reproduced main byte for byte locally, but the header is effectively unchecked in CI. - Minor deviations from RCAS noted inline (to_black denominator, saturated colours); neither is a bug.
#1166 (closed as duplicate of #1007) is about the game's own sharpening at native 4K, not this slider, so this PR neither fixes nor worsens it.
| // neighbourhood (smaller where the light is near black or white), then normalized | ||
| 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)); |
There was a problem hiding this comment.
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_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); |
There was a problem hiding this comment.
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.
| // 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)); |
There was a problem hiding this comment.
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.
| raise SystemExit("fg_scale.comp does not compile:\n" + r.stdout + r.stderr) | ||
| header = (SHADERS / "fg_scale_comp_spv.h").read_bytes() | ||
| made = out.read_bytes() | ||
| if made.splitlines()[0] != header.splitlines()[0]: |
There was a problem hiding this comment.
With a different glslang this only checks that the shader compiles, so CI never compares the committed header against the source. Acceptable, but it means the .h is trusted from the local regen.
…he comments say where the sharpen differs from RCAS fg_scale_comp_spv.h's first line is the sha256 of the fg_scale.comp it was made from, so the test checks the header against the shader without the glslang that made it (in CI); with that glslang it still compares every byte. tests/test_fg_scale_sharpen.py --write makes the header.
Each preset's Sharpness gives about the sharpening the old value gave on a 3840 x 2160 window at that preset's picture (2048 x 1152, or the handheld profile's 1536 x 864), on the slider's steps: Minimum 50 -> 30, Low 35 -> 30, Balanced 25 -> 30, High 30 -> 40, Ultra 40 -> 50. The readme says a Sharpness kept from an older version is stronger than it was when the window is much larger than the picture.
|
Sharpness: neighbours one picture pixel apart, contrast-adaptive in the manner of AMD FidelityFX RCAS
The Insert menu's Sharpness runs in the window pass (
gpu/vk/shaders/fg_scale.comp, the only sharpen on the Vulkan path: direct presents and frame generation's made frames both go throughpresent_image->framegen_scale, same shader, same push constant). It took its four neighbours1.0 / vec2(d)apart, one window pixel. When the window is larger than the picture that is a fraction of a picture pixel (0.53 for the 2048 x 1152 picture on a 3840 x 2160 screen, 0.33 for 1280 x 720), so what a given setting did depended on the window's size rather than on the picture. This was found in #1071, which makes the same change in its copy of the shader; this PR is a separate implementation of it, on main, with measurements.What changes
1.0 / vec2(textureSize(src, 0))), or one window pixel when the window is the smaller of the two, so a downscale does not sharpen detail the window cannot show.RX_VK_UPSCALE_LOG's line says what the number means now (sharpness 40% (of RCAS's strongest, neighbours one source pixel apart)).fg_scale_comp_spv.hregenerated as the other headers are:glslangValidator -V --target-env vulkan1.2 --vn FG_SCALE_COMP_SPV(glslang 16.6.0, which reproduces main's header byte for byte before the change).Measured
Headless 3840 x 2160 output, the default 2048 x 1152 picture, Catmull-Rom, frame generation off, resolution 100%, Kokiri Forest, same save each run. Metric: mean |Laplacian| of luma, at window scale and at picture scale (the shot box-averaged back to 2048 x 1152). In-game shots, 3 per run; Sharpness 0 is the average of three runs (0.0339 / 0.0725), run-to-run noise about 3%:
Since animation moves things a little between runs, the same numbers were also taken on one frame: the game's 2048 x 1152 picture from a run, put through main's and this PR's shader math on the CPU, so every variant sees the same input.
At this window size main's sharpen did work: its neighbours went through the linear sampler, which blends the picture pixels around them. The gap grows with the window-to-picture ratio. The same frame at 1280 x 720 on 3840 x 2160 (3x): main +11 / +21 / +27% at 40 / 80 / 100%, this PR +20 / +59 / +87% (picture scale +7 / +13 / +15% against +10 / +20 / +24%). With Upscaling Off (the linear filter) at 2048 x 1152: main +16 / +30 / +36%, this PR +16 / +39 / +54%. Neither adds halos (both hold the result to the neighbourhood), and the RCAS lobe no longer pushes pixels next to black or white into clipping.
For players following the DLSS advice to set Sharpness to about 80%: main's 80% at 2048 x 1152 on a 4K screen is about this PR's 70 to 80% (70% by window-scale detail, 80% by picture-scale detail). About 75% is a good default for that advice after this change, and the value now carries over between window sizes.
Tests
tests/test_fg_scale_sharpen.py(inrun_all.py): the sharpen's neighbour spacing is the picture's size; the shader's ownsharpen()compiled as C++ against 200,000 random neighbourhoods at 0 / 10 / 40 / 80 / 100%: unchanged at 0, never outside the pixel's neighbourhood or 0..1, flat areas and black or white left as they are, and a blurred edge steeper as the setting rises. With glslangValidator present the shader compiles, and with the version the header names the header matches its output.python -I -B tests/run_all.py: 150 passed, 0 failed, 7 skipped (Linux). Linux build of vk + game.Credits
The finding, and the idea of an RCAS-style sharpen here, come from #1071. The sharpen follows the math of AMD FidelityFX RCAS (MIT license), written from its description, not copied.
Presets and existing settings
The Quality presets' Sharpness values were tuned for the old spacing. Each is re-tuned to give about the same sharpening as before on a 3840 x 2160 window at that preset's picture (2048 x 1152 docked, 1536 x 864 with the Handheld profile; Render resolution does not change the picture the window pass sees), on the slider's steps: Minimum 50 -> 30, Low 35 -> 30, Balanced 25 -> 30, High 30 -> 40, Ultra 40 -> 50. A Sharpness already saved in hookshot_options.ini is kept as it is: it is stronger than before when the window is much larger than the picture (most of all with the Handheld profile on a 4K screen), and the readme says so.
The window pass's SPIR-V header now starts with the sha256 of the fg_scale.comp it was made from, so the test checks it against the shader in CI as well (
python -I -B tests/test_fg_scale_sharpen.py --writemakes it).