diff options
| author | Duck912 <350528965+Duck912@users.noreply.github.com> | 2026-11-02 16:07:08 -0500 |
|---|---|---|
| committer | Duck912 <350528965+Duck912@users.noreply.github.com> | 2026-11-02 16:28:01 -0500 |
| commit | 960ebcc73d998c7c596c3377b0ec8a1d6eefccf1 (patch) | |
| tree | 3382749be8d24a053c5f74d28e2388a9dd704eb8 /src/video_core/host_shaders | |
| parent | bb8fdfac21434db0bfd62c56b0a409ce26070a38 (diff) | |
video_core: sharpen the temporal DLSS stand-in
Fire Emblem: Fortune's Weave's DLSS input matches Eden's (same 720p
image, same detail), but the stand-in's 1440p output was blurrier than a
plain bilinear 2x of it (Laplacian 0.0066 vs 0.0081; Eden's real DLSS
0.0111), and the image drifted darker over time:
- the sample kernel was about twice too wide for the output resolution;
- the history was kept in the output's B10G11R11 format, whose 5-6 bit
mantissas round away a frame's 0.03-0.1 share;
- the 1-sigma neighbourhood clip removed detail that only some jitter
positions sample (railings, thin edges).
The history is now an RGBA16F pair the shader writes as a storage image,
with no per-frame copy. Still pixels use a third of the kernel width and
a 1.5-sigma clip, so detail from many jitter positions accumulates.
Moving pixels keep the old kernel and 1-sigma clip, and so do pixels
that moved recently or whose history falls far outside the
neighbourhood (content that changes without motion vectors), remembered
in the history's alpha. Static output goes from 0.0066 to 0.0102 with
no brightness drift and no new ghosting.
With resolution scaling the stand-in used to fall back to a bilinear
blit. The images are now scaled up as BlitImage does, the jitter is
scaled to host pixels and the history follows the framebuffer's render
area.
RCAS (temporal_sharpen.frag) sharpens a copy of each result, never the
history, in range-compressed space since the output is HDR. Its strength
is the sharpness slider, relabelled "RCAS Sharpness" (0% turns it off).
EDEN_NX2_TEMPORAL_KERNEL, _CLIP, _KERNEL_MOTION, _CLIP_MOTION and
_WEIGHT tune the filter (all four filter values at 1 give the old one),
and EDEN_NX2_TEMPORAL_SHARPEN=<stops>|off overrides the slider.
Assisted-by: Claude Opus 5.5 (debugging, implementation and drafting)
Signed-off-by: Duck912 <350528965+Duck912@users.noreply.github.com>
Diffstat (limited to 'src/video_core/host_shaders')
| -rw-r--r-- | src/video_core/host_shaders/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/video_core/host_shaders/temporal_sharpen.frag | 49 | ||||
| -rw-r--r-- | src/video_core/host_shaders/temporal_upscale.frag | 85 |
3 files changed, 116 insertions, 19 deletions
diff --git a/src/video_core/host_shaders/CMakeLists.txt b/src/video_core/host_shaders/CMakeLists.txt index 9ff7563ff7..1678395679 100644 --- a/src/video_core/host_shaders/CMakeLists.txt +++ b/src/video_core/host_shaders/CMakeLists.txt @@ -72,6 +72,7 @@ set(SHADER_FILES ${CMAKE_CURRENT_SOURCE_DIR}/smaa_blending_weight_calculation.frag ${CMAKE_CURRENT_SOURCE_DIR}/smaa_neighborhood_blending.vert ${CMAKE_CURRENT_SOURCE_DIR}/smaa_neighborhood_blending.frag + ${CMAKE_CURRENT_SOURCE_DIR}/temporal_sharpen.frag ${CMAKE_CURRENT_SOURCE_DIR}/temporal_upscale.frag ${CMAKE_CURRENT_SOURCE_DIR}/vulkan_blit_depth_stencil.frag ${CMAKE_CURRENT_SOURCE_DIR}/vulkan_color_clear.frag diff --git a/src/video_core/host_shaders/temporal_sharpen.frag b/src/video_core/host_shaders/temporal_sharpen.frag new file mode 100644 index 0000000000..45a13013cb --- /dev/null +++ b/src/video_core/host_shaders/temporal_sharpen.frag @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// RCAS (FidelityFX FSR 1's contrast-adaptive sharpening) on the DLSS stand-in's result, before the +// game post-processes it. The input is linear HDR; RCAS's limits assume 0 to 1, so it sharpens in +// the stand-in's range-compressed space. + +#version 460 core +#extension GL_ARB_separate_shader_objects : enable +#extension GL_ARB_shading_language_420pack : enable +#extension GL_GOOGLE_include_directive : enable +#extension GL_EXT_shader_explicit_arithmetic_types : require + +layout(binding = 0) uniform sampler2D InputTexture; + +layout(push_constant) uniform PushConstants { + // The vertex stage's texture scale and offset come first. + layout(offset = 16) uvec4 Const0; // FsrRcasCon +}; + +layout(location = 0) in vec2 texcoord; +layout(location = 0) out vec4 color; + +#define A_GPU 1 +#define A_GLSL 1 +#include "ffx_a.h" + +vec3 Compress(vec3 c) { + return c / (1.0 + max(max(c.r, c.g), c.b)); +} + +vec3 Expand(vec3 c) { + c = clamp(c, vec3(0.0), vec3(1.0 - 1.0 / 65504.0)); + return c / (1.0 - max(max(c.r, c.g), c.b)); +} + +#define FSR_RCAS_F 1 +AF4 FsrRcasLoadF(ASU2 p) { + return AF4(Compress(texelFetch(InputTexture, p, 0).rgb), 1.0); +} +void FsrRcasInputF(inout AF1 r, inout AF1 g, inout AF1 b) {} + +#include "ffx_fsr1.h" + +void main() { + AF3 c; + FsrRcasF(c.r, c.g, c.b, AU2(gl_FragCoord.xy), Const0); + color = vec4(Expand(c), 1.0); +} diff --git a/src/video_core/host_shaders/temporal_upscale.frag b/src/video_core/host_shaders/temporal_upscale.frag index 0e76df2b43..ee31e035b1 100644 --- a/src/video_core/host_shaders/temporal_upscale.frag +++ b/src/video_core/host_shaders/temporal_upscale.frag @@ -11,12 +11,17 @@ layout(binding = 0) uniform sampler2D input_color; layout(binding = 1) uniform sampler2D motion_vectors; layout(binding = 2) uniform sampler2D history; +layout(binding = 3, rgba16f) uniform writeonly image2D next_history; layout(push_constant) uniform PushConstants { // The vertex stage's texture scale and offset come first. layout(offset = 16) vec2 jitter; // this frame's sample offset, in input pixels vec2 motion_scale; // motion vector -> UV offset to the previous frame float current_weight; // largest weight of this frame; 1 ignores the history + float kernel_scale; // accumulation kernel distance scale + float clip_width; // neighbourhood clip of still pixels, in deviations + float clip_motion; // clip of pixels moving a pixel or more per frame + float kernel_motion; // kernel distance scale of moving pixels }; layout(location = 0) in vec2 texcoord; // 0 to 1 across the output @@ -83,50 +88,92 @@ void main() { const vec2 target = texcoord * vec2(input_size); const ivec2 nearest = ivec2(floor(target + jitter)); + // The longest motion around the pixel, so that an edge follows the object in front. + vec2 motion = vec2(0.0); + float longest = -1.0; + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + // The motion vectors can be at another scale than the colour (resolution scaling). + const ivec2 input_texel = clamp(nearest + ivec2(x, y), ivec2(0), input_size - 1); + const ivec2 texel = clamp(ivec2((vec2(input_texel) + 0.5) * vec2(motion_size) / + vec2(input_size)), + ivec2(0), motion_size - 1); + const vec2 candidate = texelFetch(motion_vectors, texel, 0).rg; + const float length_squared = dot(candidate, candidate); + if (length_squared > longest) { + longest = length_squared; + motion = candidate; + } + } + } + const vec2 previous = texcoord + motion * motion_scale; + // Still pixels: a narrow kernel (detail from many jitter positions accumulates) + // and a looser clip (keeps detail only some positions sample, e.g. thin bars). Moving pixels + // (a quarter pixel or more per frame): the wide kernel and tight clip, as their history is unreliable. + // The history's alpha remembers recent motion, decaying over a few frames: background that a + // moving object has just uncovered has no motion of its own but a history of that object. + const vec2 history_position = previous * vec2(textureSize(history, 0)); + const float moved = smoothstep(0.02, 0.25, length(motion * motion_scale * vec2(textureSize(history, 0)))); + const float moving = max(moved, texture(history, history_position).a); + const float kernel = mix(kernel_scale, kernel_motion, moving); + const float clip = mix(clip_width, clip_motion, moving); + vec3 filtered = vec3(0.0); float total_weight = 0.0; float nearest_weight = 0.0; vec3 sum = vec3(0.0); vec3 squares = vec3(0.0); - vec2 motion = vec2(0.0); - float longest = -1.0; for (int y = -1; y <= 1; ++y) { for (int x = -1; x <= 1; ++x) { const ivec2 texel = clamp(nearest + ivec2(x, y), ivec2(0), input_size - 1); const vec3 sample_color = RgbToYCoCg(Compress(texelFetch(input_color, texel, 0).rgb)); // A Blackman-Harris-like window over the sample's distance to the output pixel. - const vec2 distance = vec2(texel) + 0.5 - jitter - target; + const vec2 distance = (vec2(texel) + 0.5 - jitter - target) * kernel; const float weight = exp(-2.29 * dot(distance, distance)); filtered += sample_color * weight; total_weight += weight; nearest_weight = max(nearest_weight, weight); sum += sample_color; squares += sample_color * sample_color; - // The longest motion around the pixel, so that an edge follows the object in front. - const vec2 candidate = - texelFetch(motion_vectors, clamp(texel, ivec2(0), motion_size - 1), 0).rg; - const float length_squared = dot(candidate, candidate); - if (length_squared > longest) { - longest = length_squared; - motion = candidate; - } } } - const vec3 current = filtered / total_weight; + const vec3 current = filtered / max(total_weight, 1e-6); const vec3 mean = sum / 9.0; const vec3 deviation = sqrt(max(squares / 9.0 - mean * mean, vec3(0.0))); - const vec3 low = mean - deviation; - const vec3 high = mean + deviation; + const vec3 low = mean - clip * deviation; + const vec3 high = mean + clip * deviation; - const vec2 previous = texcoord + motion * motion_scale; // A frame whose samples land close to this pixel counts more; one whose samples land between // pixels mostly keeps the history (from other jitter positions) there. float weight = current_weight * nearest_weight; + vec3 result = current; + float remembered = max(moved, 0.85 * moving); if (current_weight >= 1.0 || any(lessThan(previous, vec2(0.0))) || any(greaterThan(previous, vec2(1.0)))) { - weight = 1.0; + // No usable history: the wide kernel, as a narrow one would show the input's pixels. + vec3 wide = vec3(0.0); + float wide_total = 0.0; + for (int y = -1; y <= 1; ++y) { + for (int x = -1; x <= 1; ++x) { + const ivec2 texel = clamp(nearest + ivec2(x, y), ivec2(0), input_size - 1); + const vec2 distance = vec2(texel) + 0.5 - jitter - target; + const float wide_weight = exp(-2.29 * dot(distance, distance)); + wide += RgbToYCoCg(Compress(texelFetch(input_color, texel, 0).rgb)) * wide_weight; + wide_total += wide_weight; + } + } + result = wide / wide_total; + remembered = 1.0; + } else { + const vec3 unclipped = RgbToYCoCg(Compress(SampleHistory(history_position))); + const vec3 past = ClipToBox(unclipped, low, high); + result = mix(past, current, weight); + // A history far outside the neighbourhood means the content changed without + // motion vectors (some animations have none): treat the pixel as moving for a while. + const vec3 extent = 0.5 * (high - low) + 1.0 / 65504.0; + const vec3 units = abs(unclipped - 0.5 * (low + high)) / extent; + remembered = max(remembered, smoothstep(1.25, 2.5, max(units.x, max(units.y, units.z)))); } - const vec2 history_position = previous * vec2(textureSize(history, 0)); - const vec3 past = ClipToBox(RgbToYCoCg(Compress(SampleHistory(history_position))), low, high); - color = vec4(Expand(YCoCgToRgb(mix(past, current, weight))), 1.0); + color = vec4(Expand(YCoCgToRgb(result)), 1.0); + imageStore(next_history, ivec2(gl_FragCoord.xy), vec4(color.rgb, remembered)); } |
