diff --git a/.patch/reshade/0001-from-chars-fallback.patch b/.patch/reshade/0001-from-chars-fallback.patch new file mode 100644 index 0000000000..9089b1b9d4 --- /dev/null +++ b/.patch/reshade/0001-from-chars-fallback.patch @@ -0,0 +1,11 @@ +--- a/source/effect_lexer.cpp ++++ b/source/effect_lexer.cpp +@@ -1181,7 +1181,7 @@ + } + else + { +-#if 0 ++#if !defined(__cpp_lib_to_chars) + exponent += decimal_location - mantissa_size; + + const bool exponent_negative = exponent < 0; diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b068b85b4..266331b0ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,8 @@ option(ENABLE_WERROR "Enable -Werror diagnostics" ON) # Lossless Scaling frame generation. Only Android. cmake_dependent_option(ENABLE_LSFG "Enable Lossless Scaling frame generation" ON "ANDROID" OFF) +option(ENABLE_RESHADE "Enable ReShade FX post-processing effects" ON) + # non-linux bundled qt are static if (YUZU_USE_BUNDLED_QT AND (APPLE OR NOT UNIX)) set(YUZU_STATIC_BUILD ON) diff --git a/cpmfile.json b/cpmfile.json index af773e0bd0..8b2099f40a 100644 --- a/cpmfile.json +++ b/cpmfile.json @@ -238,6 +238,14 @@ "repo": "stachenov/quazip", "version": "2e95c9001b" }, + "reshade": { + "hash": "6fc9b39821ca6e2d91160f05846b3a42159fec759f5d6fba91d1a57801a1bdaf6047740578c136f0b1a60a9c29ad160d87dbfe0dbfbcdf6ea879a8a078377b8c", + "patches": [ + "0001-from-chars-fallback.patch" + ], + "repo": "crosire/reshade", + "version": "v6.8.0" + }, "sdl3": { "hash": "df5a323af7ac366661a3c0e887969c72584d232f3cc211419d59b0487b620b6b2859d4549c9e8df002ee489290062e466fcfddf7edc0872a37b1f2845e81c0f3", "min_version": "3.2.10", diff --git a/dist/post_presets/Anime.fxp b/dist/post_presets/Anime.fxp new file mode 100644 index 0000000000..a0019905ce --- /dev/null +++ b/dist/post_presets/Anime.fxp @@ -0,0 +1,3 @@ +name=Anime +description=Cel shaded look: the picture is cleaned first, then the lighting is collapsed into flat bands with inked edges, finished with a small glow. +chain=Denoise.fx|Denoise|Strength=0.18,Radius=1.2;CelShading.fx|CelShading|Bands=5,BandContrast=0.35,BandEdge=0.2,Detail=0.7,OutlineStrength=0.7,OutlineThreshold=0.2,Saturation=1.3;Bloom.fx|Bloom|Radius=1.2,Amount=0.25 diff --git a/dist/post_presets/Cinematic.fxp b/dist/post_presets/Cinematic.fxp new file mode 100644 index 0000000000..582226f2da --- /dev/null +++ b/dist/post_presets/Cinematic.fxp @@ -0,0 +1,3 @@ +name=Cinematic +description=Film look: soft glow on the highlights, a filmic contrast curve, cool shadows against warm highlights, fine grain and a gentle vignette. +chain=Bloom.fx|Bloom|Radius=1.6,Amount=0.35;FilmicCurve.fx|FilmicCurve|Toe=1.35,Shoulder=1.3,Amount=0.85;SplitToning.fx|SplitToning|ShadowHue=210,ShadowStrength=0.25,HighlightHue=38,HighlightStrength=0.3;FilmGrain.fx|FilmGrain|Intensity=0.022;Vignette.fx|Vignette|Strength=0.5 diff --git a/dist/post_presets/Clean.fxp b/dist/post_presets/Clean.fxp new file mode 100644 index 0000000000..c44e519e0f --- /dev/null +++ b/dist/post_presets/Clean.fxp @@ -0,0 +1,3 @@ +name=Clean +description=Cleans up a low internal resolution: removes dithering and compression noise, smooths banded skies and brings edge detail back. The safe starting point if you are not sure what you want. +chain=Denoise.fx|Denoise|Strength=0.14,Curve=1.2;Deband.fx|Deband|;Sharpen.fx|Sharpen|Amount=0.75 diff --git a/dist/post_presets/Dreamy.fxp b/dist/post_presets/Dreamy.fxp new file mode 100644 index 0000000000..cbf56c77af --- /dev/null +++ b/dist/post_presets/Dreamy.fxp @@ -0,0 +1,3 @@ +name=Dreamy +description=Soft focus: the centre of the screen stays sharp while everything around it blurs, with a wide glow over the highlights. Good for slower games and cutscenes. +chain=Blur.fx|Blur|Radius=6,Strength=0.85,FocusSize=0.28,FocusSoftness=0.5;Bloom.fx|Bloom|Radius=2.0,Amount=0.55 diff --git a/dist/post_presets/Retro.fxp b/dist/post_presets/Retro.fxp new file mode 100644 index 0000000000..ac4059e641 --- /dev/null +++ b/dist/post_presets/Retro.fxp @@ -0,0 +1,3 @@ +name=Retro +description=Old television look: colour fringing at the edges of the screen, curved scanlines with a phosphor mask, and darkened corners. The scanline roll is switched off so it does not crawl. +chain=ChromaticAberration.fx|ChromaticAberration|Strength=1.0;CRT.fx|CRT|RollSpeed=0,Bleed=1.2;Vignette.fx|Vignette|Strength=0.7 diff --git a/dist/post_presets/Vivid.fxp b/dist/post_presets/Vivid.fxp new file mode 100644 index 0000000000..6a69a8e27f --- /dev/null +++ b/dist/post_presets/Vivid.fxp @@ -0,0 +1,3 @@ +name=Vivid +description=Punchier colour without stylising the image: warmer and more saturated, blacks and whites pushed to the ends of the range, and a light sharpen. +chain=NaturalColors.fx|NaturalColors|Luma=1.15,Chroma=1.35;Levels.fx|Levels|InputBlack=0.02,InputWhite=0.98;Sharpen.fx|Sharpen|Amount=0.5 diff --git a/dist/post_shaders/Bloom.fx b/dist/post_shaders/Bloom.fx new file mode 100644 index 0000000000..a450b21331 --- /dev/null +++ b/dist/post_shaders/Bloom.fx @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-License-Identifier: GPL-2.0-or-later +// Ported from bloomnoblur.fsh in PPSSPP. + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Radius < + ui_type = "slider"; + ui_label = "Radius"; + ui_tooltip = "How far the glow spreads from bright areas."; + ui_min = 0.0; ui_max = 4.0; ui_step = 0.05; +> = 1.0; + +uniform float Amount < + ui_type = "slider"; + ui_label = "Amount"; + ui_tooltip = "Strength of the glow added on top of the image."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 0.6; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float Weight(float3 color) +{ + float gray = (color.r + color.g + color.b) / 3.0; + float saturation = (abs(color.r - gray) + abs(color.g - gray) + abs(color.b - gray)) / 3.0; + return gray * gray / max(saturation, 0.25); +} + +float4 PS_Bloom(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 color = tex2D(BackBuffer, uv).rgb; + + float gray = (color.r + color.g + color.b) / 3.0; + float saturation = (abs(color.r - gray) + abs(color.g - gray) + abs(color.b - gray)) / 3.0; + float spread = 0.002 * gray / max(saturation, 0.25) * Radius; + + float3 sum = float3(0.0, 0.0, 0.0); + [unroll] for (int x = -3; x <= 3; x += 2) + { + [unroll] for (int y = -3; y <= 3; y += 2) + { + float3 tap = tex2D(BackBuffer, uv + float2(x, y) * spread).rgb; + sum += tap * Weight(tap); + } + } + sum /= 16.0; + + return float4(saturate(color + sum * Amount), 1.0); +} + +technique Bloom < + ui_label = "Bloom"; + ui_tooltip = "Bleeds light out of the brightest areas into a soft halo, the way a camera does when pointed at something too bright."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Bloom; + } +} diff --git a/dist/post_shaders/Blur.fx b/dist/post_shaders/Blur.fx new file mode 100644 index 0000000000..aa17d03c2e --- /dev/null +++ b/dist/post_shaders/Blur.fx @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Radius < + ui_type = "slider"; + ui_label = "Radius"; + ui_tooltip = "How far the blur reaches, in pixels."; + ui_min = 1.0; ui_max = 12.0; ui_step = 0.5; +> = 4.0; + +uniform float Strength < + ui_type = "slider"; + ui_label = "Strength"; + ui_tooltip = "Blend between the original image and the blurred one."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 1.0; + +uniform float FocusSize < + ui_type = "slider"; + ui_label = "Sharp Centre"; + ui_tooltip = "Size of the region left in focus at the centre of the screen. At zero the whole image is blurred evenly."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.0; + +uniform float FocusSoftness < + ui_type = "slider"; + ui_label = "Focus Falloff"; + ui_tooltip = "How gradually the sharp centre gives way to the blur."; + ui_min = 0.05; ui_max = 1.0; ui_step = 0.05; +> = 0.4; + +static const int TAPS = 17; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Blur(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + float3 original = tex2D(BackBuffer, uv).rgb; + + float2 centred = (uv - 0.5) * float2(BUFFER_WIDTH * BUFFER_RCP_HEIGHT, 1.0) * 2.0; + float distance_from_centre = length(centred); + float no_focus = 1.0 - step(0.001, FocusSize); + float focus = max(smoothstep(FocusSize, FocusSize + FocusSoftness, distance_from_centre), no_focus); + float amount = Strength * focus; + + float angle = frac(sin(dot(pos.xy, float2(12.9898, 78.233))) * 43758.5453) * 6.2831853; + float2 spoke = float2(cos(angle), sin(angle)); + + const float2 turn = float2(cos(2.39996323), sin(2.39996323)); + const float decay = exp(-2.0 / float(TAPS)); + float weight = exp(-1.0 / float(TAPS)); + + float3 sum = float3(0.0, 0.0, 0.0); + float total = 0.0; + [unroll] for (int i = 0; i < TAPS; ++i) + { + float reach = sqrt((float(i) + 0.5) / float(TAPS)); + sum += tex2D(BackBuffer, uv + spoke * reach * texel * Radius).rgb * weight; + total += weight; + weight *= decay; + spoke = float2(spoke.x * turn.x - spoke.y * turn.y, + spoke.x * turn.y + spoke.y * turn.x); + } + + float3 blurred = sum / total; + return float4(lerp(original, blurred, amount), 1.0); +} + +technique Blur < + ui_label = "Blur"; + ui_tooltip = "Gaussian blur with an optional sharp centre, for softening the picture or faking depth of field."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Blur; + } +} diff --git a/dist/post_shaders/CRT.fx b/dist/post_shaders/CRT.fx new file mode 100644 index 0000000000..51bb810e9f --- /dev/null +++ b/dist/post_shaders/CRT.fx @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-License-Identifier: GPL-2.0-or-later +// Ported from crt.fsh in PPSSPP, by KillaMaaki. + + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Timer < source = "timer"; > = 0.0; + +uniform float Density < + ui_type = "slider"; + ui_label = "Line Density"; + ui_min = 60.0; ui_max = 720.0; ui_step = 10.0; +> = 272.0; + +uniform float RollSpeed < + ui_type = "slider"; + ui_label = "Roll Speed"; + ui_tooltip = "Speed of the rolling bar. Zero disables it."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 1.0; + +uniform float Bleed < + ui_type = "slider"; + ui_label = "Colour Bleed"; + ui_tooltip = "Horizontal separation of the red and green channels."; + ui_min = 0.0; ui_max = 4.0; ui_step = 0.1; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_CRT(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float seconds = Timer * 0.001; + float scan = floor((uv.y + seconds * RollSpeed * 0.5) * Density); + float line_intensity = frac(scan * 0.5) * 2.0; + + float2 shift = float2(line_intensity * 0.0005, 0.0); + float2 bleed = float2(BUFFER_RCP_WIDTH * Bleed, 0.0); + + float r = tex2D(BackBuffer, uv + bleed + shift).r; + float g = tex2D(BackBuffer, uv - bleed + shift).g; + float b = tex2D(BackBuffer, uv).b; + + float3 color = float3(r, g * 0.99, b) * clamp(line_intensity, 0.85, 1.0); + + if (RollSpeed > 0.0) + { + float rollbar = sin((uv.y + seconds * RollSpeed) * 4.0); + color += rollbar * 0.02; + } + + return float4(saturate(color), 1.0); +} + +technique CRT < + ui_label = "CRT"; + ui_tooltip = "Curved scanlines and the phosphor mask of a CRT television, for games that were drawn with that kind of screen in mind."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_CRT; + } +} diff --git a/dist/post_shaders/Cartoon.fx b/dist/post_shaders/Cartoon.fx new file mode 100644 index 0000000000..3dbe9d77b0 --- /dev/null +++ b/dist/post_shaders/Cartoon.fx @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-FileCopyrightText: guest(r) +// SPDX-License-Identifier: GPL-2.0-or-later +// Ported from cartoon.fsh in PPSSPP, Advanced Cartoon shader I by guest(r). + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float EdgeStrength < + ui_type = "slider"; + ui_label = "Edge Strength"; + ui_tooltip = "Darkness of the ink outline drawn around detected edges."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 0.5; + +uniform float Levels < + ui_type = "slider"; + ui_label = "Colour Levels"; + ui_tooltip = "How many bands the colours are quantised into."; + ui_min = 2.0; ui_max = 16.0; ui_step = 1.0; +> = 4.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Cartoon(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float3 c00 = tex2D(BackBuffer, uv + texel * float2(-1.0, -1.0)).rgb; + float3 c10 = tex2D(BackBuffer, uv + texel * float2( 0.0, -1.0)).rgb; + float3 c20 = tex2D(BackBuffer, uv + texel * float2( 1.0, -1.0)).rgb; + float3 c01 = tex2D(BackBuffer, uv + texel * float2(-1.0, 0.0)).rgb; + float3 c11 = tex2D(BackBuffer, uv).rgb; + float3 c21 = tex2D(BackBuffer, uv + texel * float2( 1.0, 0.0)).rgb; + float3 c02 = tex2D(BackBuffer, uv + texel * float2(-1.0, 1.0)).rgb; + float3 c12 = tex2D(BackBuffer, uv + texel * float2( 0.0, 1.0)).rgb; + float3 c22 = tex2D(BackBuffer, uv + texel * float2( 1.0, 1.0)).rgb; + + const float3 dt = float3(1.0, 1.0, 1.0); + + float d1 = dot(abs(c00 - c22), dt); + float d2 = dot(abs(c20 - c02), dt); + float hl = dot(abs(c01 - c21), dt); + float vl = dot(abs(c10 - c12), dt); + float edge = EdgeStrength * (d1 + d2 + hl + vl) / (dot(c11, dt) + 0.15); + + float lc = Levels * length(c11); + float f = frac(lc); + f *= f; + lc = (floor(lc) + f * f) / Levels + 0.05; + + float3 unit = normalize(max(c11, 0.0001)); + float3 quant = Levels * unit; + float3 frct = frac(quant); + frct *= frct; + quant = floor(quant) + 0.05 * dt + frct * frct; + + float3 color = lc * (1.1 - edge * sqrt(edge)) * quant / Levels; + return float4(saturate(color), 1.0); +} + +technique Cartoon < + ui_label = "Cartoon"; + ui_tooltip = "Ink outlines and flat colour bands. Faithful port of the PPSSPP shader; the outline gets heavy in dark scenes, so try Cartoon Soft or Cel Shading if the blacks smear."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Cartoon; + } +} diff --git a/dist/post_shaders/CartoonSoft.fx b/dist/post_shaders/CartoonSoft.fx new file mode 100644 index 0000000000..bee08d8b43 --- /dev/null +++ b/dist/post_shaders/CartoonSoft.fx @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-FileCopyrightText: guest(r) +// SPDX-License-Identifier: GPL-2.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float EdgeStrength < + ui_type = "slider"; + ui_label = "Edge Strength"; + ui_tooltip = "Darkness of the ink outline drawn around detected edges."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 0.45; + +uniform float ShadowGuard < + ui_type = "slider"; + ui_label = "Shadow Guard"; + ui_tooltip = "Holds the outline back in dark areas. Raise it if shadows turn into black blobs."; + ui_min = 0.1; ui_max = 2.0; ui_step = 0.05; +> = 0.8; + +uniform float Levels < + ui_type = "slider"; + ui_label = "Colour Levels"; + ui_tooltip = "How many bands the colours are quantised into."; + ui_min = 2.0; ui_max = 16.0; ui_step = 1.0; +> = 6.0; + +uniform float Smoothing < + ui_type = "slider"; + ui_label = "Banding"; + ui_tooltip = "How far the picture is pushed towards flat bands."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.75; + +uniform float Saturation < + ui_type = "slider"; + ui_label = "Saturation"; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 1.15; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_CartoonSoft(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float3 c00 = tex2D(BackBuffer, uv + texel * float2(-1.0, -1.0)).rgb; + float3 c10 = tex2D(BackBuffer, uv + texel * float2( 0.0, -1.0)).rgb; + float3 c20 = tex2D(BackBuffer, uv + texel * float2( 1.0, -1.0)).rgb; + float3 c01 = tex2D(BackBuffer, uv + texel * float2(-1.0, 0.0)).rgb; + float3 c11 = tex2D(BackBuffer, uv).rgb; + float3 c21 = tex2D(BackBuffer, uv + texel * float2( 1.0, 0.0)).rgb; + float3 c02 = tex2D(BackBuffer, uv + texel * float2(-1.0, 1.0)).rgb; + float3 c12 = tex2D(BackBuffer, uv + texel * float2( 0.0, 1.0)).rgb; + float3 c22 = tex2D(BackBuffer, uv + texel * float2( 1.0, 1.0)).rgb; + + const float3 dt = float3(1.0, 1.0, 1.0); + const float3 luma_weights = float3(0.299, 0.587, 0.114); + + float d1 = dot(abs(c00 - c22), dt); + float d2 = dot(abs(c20 - c02), dt); + float hl = dot(abs(c01 - c21), dt); + float vl = dot(abs(c10 - c12), dt); + + float luma = dot(c11, luma_weights); + float response = (d1 + d2 + hl + vl) / (luma * 2.0 + ShadowGuard); + float ink = 1.0 - saturate(response * EdgeStrength); + + float scaled = luma * Levels; + float step_position = frac(scaled); + float eased = step_position * step_position * (3.0 - 2.0 * step_position); + float banded = (floor(scaled) + eased) / Levels; + float target = lerp(luma, banded, Smoothing); + + float3 tinted = c11 * (target / max(luma, 0.001)); + float3 grey = dot(tinted, luma_weights); + float3 color = lerp(grey, tinted, Saturation) * ink; + + return float4(saturate(color), 1.0); +} + +technique CartoonSoft < + ui_label = "Cartoon Soft"; + ui_tooltip = "Ink outlines and flat colour bands, with the outline held back in the shadows so dark scenes do not turn into black blobs."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_CartoonSoft; + } +} diff --git a/dist/post_shaders/CelShading.fx b/dist/post_shaders/CelShading.fx new file mode 100644 index 0000000000..fa1cae452c --- /dev/null +++ b/dist/post_shaders/CelShading.fx @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Bands < + ui_type = "slider"; + ui_label = "Shading Bands"; + ui_tooltip = "How many flat steps the lighting is collapsed into. Three or four gives the classic look."; + ui_min = 2.0; ui_max = 8.0; ui_step = 1.0; +> = 4.0; + +uniform float BandContrast < + ui_type = "slider"; + ui_label = "Band Contrast"; + ui_tooltip = "Spreads the bands towards pure black and white. Lower it if the shadows crush."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.5; + +uniform float BandEdge < + ui_type = "slider"; + ui_label = "Band Edge"; + ui_tooltip = "Width of the transition between bands. Low values give hard cel steps."; + ui_min = 0.02; ui_max = 1.0; ui_step = 0.02; +> = 0.15; + +uniform float Detail < + ui_type = "slider"; + ui_label = "Texture Detail"; + ui_tooltip = "How much surface texture survives the flattening. At zero the bands are completely flat."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.6; + +uniform float OutlineStrength < + ui_type = "slider"; + ui_label = "Outline Strength"; + ui_tooltip = "Opacity of the ink line drawn along detected edges."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.8; + +uniform float OutlineThreshold < + ui_type = "slider"; + ui_label = "Outline Threshold"; + ui_tooltip = "How strong an edge has to be before it is inked. Raise it to keep ink off textures and specular highlights."; + ui_min = 0.01; ui_max = 0.4; ui_step = 0.01; +> = 0.18; + +uniform float Saturation < + ui_type = "slider"; + ui_label = "Saturation"; + ui_tooltip = "Colour intensity of the flat regions."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 1.25; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_CelShading(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + const float3 luma_weights = float3(0.2126, 0.7152, 0.0722); + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float3 centre = tex2D(BackBuffer, uv).rgb; + float luma = dot(centre, luma_weights); + + float2 near = texel * 1.5; + float2 far = texel * 3.5; + + float a00 = dot(tex2D(BackBuffer, uv + near * float2(-1.0, -1.0)).rgb, luma_weights); + float a20 = dot(tex2D(BackBuffer, uv + near * float2( 1.0, -1.0)).rgb, luma_weights); + float a02 = dot(tex2D(BackBuffer, uv + near * float2(-1.0, 1.0)).rgb, luma_weights); + float a22 = dot(tex2D(BackBuffer, uv + near * float2( 1.0, 1.0)).rgb, luma_weights); + + float b00 = dot(tex2D(BackBuffer, uv + far * float2(-1.0, -1.0)).rgb, luma_weights); + float b20 = dot(tex2D(BackBuffer, uv + far * float2( 1.0, -1.0)).rgb, luma_weights); + float b02 = dot(tex2D(BackBuffer, uv + far * float2(-1.0, 1.0)).rgb, luma_weights); + float b22 = dot(tex2D(BackBuffer, uv + far * float2( 1.0, 1.0)).rgb, luma_weights); + + float gx = (a00 + a02) - (a20 + a22); + float gy = (a00 + a20) - (a02 + a22); + float gradient = sqrt(gx * gx + gy * gy); + float ink = smoothstep(OutlineThreshold, OutlineThreshold + 0.04, gradient); + + float lighting = (a00 + a20 + a02 + a22 + b00 + b20 + b02 + b22) * 0.125; + float detail = luma - lighting; + + float softness = max(BandEdge, 0.001); + float coord = lighting * Bands - softness * 0.5; + float index = floor(coord) + smoothstep(1.0 - softness, 1.0, frac(coord)); + + float centred = (index + 0.5) / Bands; + float stretched = index / max(Bands - 1.0, 1.0); + float banded = saturate(lerp(centred, stretched, BandContrast)); + + float target = saturate(banded + detail * Detail); + + float gain = min(target / max(luma, 0.001), 4.0); + float3 shaded = centre * gain; + float3 grey = dot(shaded, luma_weights); + float3 color = lerp(grey, shaded, Saturation); + color *= 1.0 - ink * OutlineStrength; + + return float4(saturate(color), 1.0); +} + +technique CelShading < + ui_label = "Cel Shading"; + ui_tooltip = "Collapses the lighting into a few hard steps and inks the edges, keeping texture detail and hue intact."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_CelShading; + } +} diff --git a/dist/post_shaders/ChromaticAberration.fx b/dist/post_shaders/ChromaticAberration.fx new file mode 100644 index 0000000000..e051a038ef --- /dev/null +++ b/dist/post_shaders/ChromaticAberration.fx @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Strength < + ui_type = "slider"; + ui_label = "Strength"; + ui_tooltip = "Channel separation in pixels, measured at the edge of the screen."; + ui_min = 0.0; ui_max = 8.0; ui_step = 0.1; +> = 1.5; + +uniform float Falloff < + ui_type = "slider"; + ui_label = "Falloff"; + ui_tooltip = "How fast the separation grows away from the centre. Higher keeps the middle clean."; + ui_min = 1.0; ui_max = 4.0; ui_step = 0.1; +> = 2.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_ChromaticAberration(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float2 direction = uv - float2(0.5, 0.5); + float radius = length(direction); + float2 unit = direction / max(radius, 0.0001); + float2 offset = unit * Strength * pow(radius * 2.0, Falloff) * texel; + + float red = tex2D(BackBuffer, uv + offset).r; + float green = tex2D(BackBuffer, uv).g; + float blue = tex2D(BackBuffer, uv - offset).b; + + return float4(red, green, blue, 1.0); +} + +technique ChromaticAberration < + ui_label = "Chromatic Aberration"; + ui_tooltip = "Splits the colour channels apart towards the edges of the screen, the way a cheap lens fails to focus every colour on the same spot."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_ChromaticAberration; + } +} diff --git a/dist/post_shaders/ColorGrade.fx b/dist/post_shaders/ColorGrade.fx new file mode 100644 index 0000000000..cf1f10ecad --- /dev/null +++ b/dist/post_shaders/ColorGrade.fx @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-License-Identifier: GPL-2.0-or-later +// Based on colorcorrection.fsh in PPSSPP. + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Saturation < + ui_type = "slider"; + ui_label = "Saturation"; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.01; +> = 1.0; + +uniform float Brightness < + ui_type = "slider"; + ui_label = "Brightness"; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.01; +> = 1.0; + +uniform float Contrast < + ui_type = "slider"; + ui_label = "Contrast"; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.01; +> = 1.0; + +uniform float Gamma < + ui_type = "slider"; + ui_label = "Gamma"; + ui_min = 0.5; ui_max = 2.0; ui_step = 0.01; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_ColorGrade(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 rgb = tex2D(BackBuffer, uv).rgb; + + float luma = dot(rgb, float3(0.2126, 0.7152, 0.0722)); + rgb = lerp(float3(luma, luma, luma), rgb, Saturation); + rgb *= Brightness; + rgb = (rgb - 0.5) * Contrast + 0.5; + rgb = pow(max(rgb, 0.0), 1.0 / max(Gamma, 0.0001)); + + return float4(saturate(rgb), 1.0); +} + +technique ColorGrade < + ui_label = "Colour Grade"; + ui_tooltip = "The four basic colour controls in one pass: saturation, brightness, contrast and gamma. Reach for this before anything more specialised."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_ColorGrade; + } +} diff --git a/dist/post_shaders/Deband.fx b/dist/post_shaders/Deband.fx new file mode 100644 index 0000000000..96086a9c6c --- /dev/null +++ b/dist/post_shaders/Deband.fx @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2015 Niklas Haas +// SPDX-License-Identifier: MIT + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Threshold < + ui_type = "slider"; + ui_label = "Threshold"; + ui_tooltip = "How flat a neighbourhood must be before it gets smoothed. Raise it to catch wider bands, lower it to keep more detail."; + ui_min = 0.002; ui_max = 0.05; ui_step = 0.001; +> = 0.012; + +uniform float Radius < + ui_type = "slider"; + ui_label = "Radius"; + ui_tooltip = "How far the sampling reaches, in pixels. Wider gradients need a larger radius."; + ui_min = 1.0; ui_max = 32.0; ui_step = 1.0; +> = 8.0; + +uniform float Grain < + ui_type = "slider"; + ui_label = "Dither"; + ui_tooltip = "Noise added to break up whatever banding survives the smoothing."; + ui_min = 0.0; ui_max = 0.02; ui_step = 0.001; +> = 0.004; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float Hash(float2 p) +{ + float3 scattered = frac(float3(p.x, p.y, p.x) * 0.1031); + scattered += dot(scattered, scattered.yzx + 33.33); + return frac((scattered.x + scattered.y) * scattered.z); +} + +float4 PS_Deband(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float3 centre = tex2D(BackBuffer, uv).rgb; + float base = Hash(pos.xy) * 6.2831853; + + float3 total = float3(0.0, 0.0, 0.0); + float3 deviation = float3(0.0, 0.0, 0.0); + + [unroll] for (int ring = 1; ring <= 2; ++ring) + { + float angle = base + float(ring) * 2.3999632; + float reach = Radius * float(ring) * 0.5; + float2 along = float2(cos(angle), sin(angle)) * reach; + float2 across = float2(-along.y, along.x); + + float3 s0 = tex2D(BackBuffer, uv + along * texel).rgb; + float3 s1 = tex2D(BackBuffer, uv - along * texel).rgb; + float3 s2 = tex2D(BackBuffer, uv + across * texel).rgb; + float3 s3 = tex2D(BackBuffer, uv - across * texel).rgb; + + total += s0 + s1 + s2 + s3; + deviation = max(deviation, max(max(abs(s0 - centre), abs(s1 - centre)), + max(abs(s2 - centre), abs(s3 - centre)))); + } + + float3 average = total * 0.125; + float3 flatness = float3(1.0, 1.0, 1.0) - + smoothstep(Threshold * 0.5, Threshold, deviation); + float3 result = lerp(centre, average, flatness); + + float dither = (Hash(pos.xy + float2(71.3, 41.7)) - 0.5) * Grain; + + return float4(saturate(result + dither), 1.0); +} + +technique Deband < + ui_label = "Deband"; + ui_tooltip = "Smooths the visible steps in gradients such as skies, then dithers whatever survives. Worth adding whenever large flat areas show rings."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Deband; + } +} diff --git a/dist/post_shaders/Denoise.fx b/dist/post_shaders/Denoise.fx new file mode 100644 index 0000000000..c87e33a446 --- /dev/null +++ b/dist/post_shaders/Denoise.fx @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2019-2021 bloc97 +// SPDX-License-Identifier: MIT + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Strength < + ui_type = "slider"; + ui_label = "Strength"; + ui_min = 0.01; ui_max = 0.5; ui_step = 0.01; +> = 0.1; + +uniform float Radius < + ui_type = "slider"; + ui_label = "Radius"; + ui_min = 0.3; ui_max = 2.0; ui_step = 0.05; +> = 1.0; + +uniform float Curve < + ui_type = "slider"; + ui_label = "Shadow Bias"; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.05; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float3 IntensityWeight(float3 value, float3 sigma, float3 centre) +{ + float3 scaled = (value - centre) / sigma; + return exp(-0.5 * scaled * scaled); +} + +float SpatialWeight(float distance, float sigma) +{ + float scaled = distance / sigma; + return exp(-0.5 * scaled * scaled); +} + +float4 PS_Denoise(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float3 centre = tex2D(BackBuffer, uv).rgb; + float3 intensity_sigma = max(pow(centre + 0.0001, Curve) * Strength, 0.0001); + float spatial_sigma = max(Radius, 0.05); + + float3 sum = float3(0.0, 0.0, 0.0); + float3 total = float3(0.0, 0.0, 0.0); + + [unroll] for (int y = -2; y <= 2; ++y) + { + [unroll] for (int x = -2; x <= 2; ++x) + { + float2 offset = float2(x, y); + float3 tap = tex2D(BackBuffer, uv + offset * texel).rgb; + float3 weight = IntensityWeight(tap, intensity_sigma, centre) * + SpatialWeight(length(offset), spatial_sigma); + sum += weight * tap; + total += weight; + } + } + + return float4(sum / total, 1.0); +} + +technique Denoise < + ui_label = "Denoise"; + ui_tooltip = "Edge preserving blur that clears dithering and compression noise while leaving outlines sharp. Ported from Anime4K."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Denoise; + } +} diff --git a/dist/post_shaders/FilmGrain.fx b/dist/post_shaders/FilmGrain.fx new file mode 100644 index 0000000000..4e21c18b20 --- /dev/null +++ b/dist/post_shaders/FilmGrain.fx @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Intensity < + ui_type = "slider"; + ui_label = "Intensity"; + ui_min = 0.0; ui_max = 0.2; ui_step = 0.005; +> = 0.03; + +uniform float Size < + ui_type = "slider"; + ui_label = "Size"; + ui_tooltip = "Grain cell size in pixels."; + ui_min = 1.0; ui_max = 4.0; ui_step = 1.0; +> = 1.0; + +uniform float Colored < + ui_type = "slider"; + ui_label = "Colour"; + ui_tooltip = "Zero gives monochrome grain, one gives independent noise per channel."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.01; +> = 0.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float Hash(float2 p) +{ + float3 scattered = frac(float3(p.x, p.y, p.x) * 0.1031); + scattered += dot(scattered, scattered.yzx + 33.33); + return frac((scattered.x + scattered.y) * scattered.z); +} + +float4 PS_FilmGrain(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 rgb = tex2D(BackBuffer, uv).rgb; + + float2 cell = floor(pos.xy / max(Size, 1.0)); + float mono = Hash(cell) - 0.5; + float3 chroma = float3(Hash(cell + 11.7), Hash(cell + 23.1), Hash(cell + 37.5)) - 0.5; + float3 noise = lerp(float3(mono, mono, mono), chroma, Colored); + + float luma = dot(rgb, float3(0.2126, 0.7152, 0.0722)); + float response = 1.0 - abs(luma * 2.0 - 1.0); + + return float4(saturate(rgb + noise * Intensity * response), 1.0); +} + +technique FilmGrain < + ui_label = "Film Grain"; + ui_tooltip = "Adds photographic grain, strongest in the midtones and fading out in blacks and whites."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_FilmGrain; + } +} diff --git a/dist/post_shaders/FilmicCurve.fx b/dist/post_shaders/FilmicCurve.fx new file mode 100644 index 0000000000..2b1b1e0773 --- /dev/null +++ b/dist/post_shaders/FilmicCurve.fx @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Exposure < + ui_type = "slider"; + ui_label = "Exposure"; + ui_min = 0.5; ui_max = 2.0; ui_step = 0.01; +> = 1.0; + +uniform float Toe < + ui_type = "slider"; + ui_label = "Toe"; + ui_tooltip = "Above 1.0 deepens the shadows, below 1.0 lifts them."; + ui_min = 0.5; ui_max = 2.0; ui_step = 0.01; +> = 1.2; + +uniform float Shoulder < + ui_type = "slider"; + ui_label = "Shoulder"; + ui_tooltip = "Above 1.0 opens up the highlights, below 1.0 compresses them."; + ui_min = 0.5; ui_max = 2.0; ui_step = 0.01; +> = 1.2; + +uniform float Amount < + ui_type = "slider"; + ui_label = "Amount"; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.01; +> = 0.7; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_FilmicCurve(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 rgb = tex2D(BackBuffer, uv).rgb; + + float3 curved = saturate(rgb * Exposure); + curved = pow(max(curved, 0.0), Toe); + curved = 1.0 - pow(max(1.0 - curved, 0.0), Shoulder); + + return float4(saturate(lerp(rgb, curved, Amount)), 1.0); +} + +technique FilmicCurve < + ui_label = "Filmic Curve"; + ui_tooltip = "Filmic contrast curve. Deepens the shadows and opens the highlights without clipping either end."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_FilmicCurve; + } +} diff --git a/dist/post_shaders/LensDistortion.fx b/dist/post_shaders/LensDistortion.fx new file mode 100644 index 0000000000..8e0649bfe7 --- /dev/null +++ b/dist/post_shaders/LensDistortion.fx @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Distortion < + ui_type = "slider"; + ui_label = "Distortion"; + ui_tooltip = "Positive bulges the picture outwards, negative pinches it inwards."; + ui_min = -0.5; ui_max = 0.5; ui_step = 0.01; +> = 0.1; + +uniform float Zoom < + ui_type = "slider"; + ui_label = "Zoom"; + ui_tooltip = "Scales the picture to hide the edges the warp pulls in."; + ui_min = 0.5; ui_max = 1.5; ui_step = 0.01; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_LensDistortion(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float aspect = BUFFER_WIDTH * BUFFER_RCP_HEIGHT; + float2 half_size = float2(aspect, 1.0); + float2 unit = half_size / length(half_size); + + float2 centred = (uv - 0.5) * 2.0 * unit; + float r2 = dot(centred, centred); + centred *= 1.0 + Distortion * r2; + centred /= max(Zoom, 0.001); + + float2 source = centred / (2.0 * unit) + 0.5; + + return float4(tex2D(BackBuffer, source).rgb, 1.0); +} + +technique LensDistortion < + ui_label = "Lens Distortion"; + ui_tooltip = "Barrel or pincushion warp, like looking through a wide angle lens."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_LensDistortion; + } +} diff --git a/dist/post_shaders/Levels.fx b/dist/post_shaders/Levels.fx new file mode 100644 index 0000000000..510fd3d070 --- /dev/null +++ b/dist/post_shaders/Levels.fx @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float InputBlack < + ui_type = "slider"; + ui_label = "Input Black"; + ui_tooltip = "Input level mapped to black. Raise it to deepen washed out shadows."; + ui_min = 0.0; ui_max = 0.5; ui_step = 0.005; +> = 0.0; + +uniform float InputWhite < + ui_type = "slider"; + ui_label = "Input White"; + ui_min = 0.5; ui_max = 1.0; ui_step = 0.005; +> = 1.0; + +uniform float Gamma < + ui_type = "slider"; + ui_label = "Gamma"; + ui_min = 0.2; ui_max = 3.0; ui_step = 0.01; +> = 1.0; + +uniform float OutputBlack < + ui_type = "slider"; + ui_label = "Output Black"; + ui_tooltip = "Lifts crushed shadows so detail stops collapsing into one flat black."; + ui_min = 0.0; ui_max = 0.5; ui_step = 0.005; +> = 0.0; + +uniform float OutputWhite < + ui_type = "slider"; + ui_label = "Output White"; + ui_min = 0.5; ui_max = 1.0; ui_step = 0.005; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Levels(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 rgb = tex2D(BackBuffer, uv).rgb; + + rgb = saturate((rgb - InputBlack) / max(InputWhite - InputBlack, 0.001)); + rgb = pow(max(rgb, 0.0), 1.0 / max(Gamma, 0.001)); + rgb = lerp(OutputBlack, OutputWhite, rgb); + + return float4(saturate(rgb), 1.0); +} + +technique Levels < + ui_label = "Levels"; + ui_tooltip = "Black point, white point, gamma and output range. Use it to fix crushed or washed out shadows."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Levels; + } +} diff --git a/dist/post_shaders/MotionBlur.fx b/dist/post_shaders/MotionBlur.fx new file mode 100644 index 0000000000..d541b5db9d --- /dev/null +++ b/dist/post_shaders/MotionBlur.fx @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Length < + ui_type = "slider"; + ui_label = "Length"; + ui_tooltip = "How far the streaks reach, as a percentage of screen height."; + ui_min = 0.0; ui_max = 20.0; ui_step = 0.5; +> = 5.0; + +uniform float Zoom < + ui_type = "slider"; + ui_label = "Zoom"; + ui_tooltip = "Streaks running out from the centre of the screen, as if rushing forward. The centre stays sharp."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 1.0; + +uniform float Pan < + ui_type = "slider"; + ui_label = "Pan"; + ui_tooltip = "Streaks running in one fixed direction, as if the camera were sweeping across."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.0; + +uniform float Angle < + ui_type = "slider"; + ui_label = "Pan Angle"; + ui_tooltip = "Direction of the sweep, in degrees."; + ui_min = 0.0; ui_max = 360.0; ui_step = 5.0; +> = 0.0; + +uniform float Spin < + ui_type = "slider"; + ui_label = "Spin"; + ui_tooltip = "Streaks curling around the centre, as if the camera were rolling. Negative turns the other way."; + ui_min = -1.0; ui_max = 1.0; ui_step = 0.05; +> = 0.0; + +static const int TAPS = 24; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_MotionBlur(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float aspect = BUFFER_WIDTH * BUFFER_RCP_HEIGHT; + float2 to_screen = float2(aspect, 1.0); + + float2 centred = (uv - 0.5) * to_screen * 2.0; + float2 outward = centred; + float2 around = float2(-centred.y, centred.x); + + float radians_angle = Angle * 0.01745329; + float2 sweep = float2(cos(radians_angle), sin(radians_angle)); + + float2 velocity = sweep * Pan + outward * Zoom + around * Spin; + velocity *= Length * 0.01; + velocity /= to_screen; + + float jitter = frac(sin(dot(pos.xy, float2(12.9898, 78.233))) * 43758.5453); + + float3 sum = float3(0.0, 0.0, 0.0); + [unroll] for (int i = 0; i < TAPS; ++i) + { + float t = (float(i) + jitter) / float(TAPS) - 0.5; + sum += tex2D(BackBuffer, uv + velocity * t).rgb; + } + + return float4(sum / float(TAPS), 1.0); +} + +technique MotionBlur < + ui_label = "Motion Blur"; + ui_tooltip = "Camera style motion blur: streaks the picture outwards, sideways or around, without touching still detail at the centre."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_MotionBlur; + } +} diff --git a/dist/post_shaders/NaturalColors.fx b/dist/post_shaders/NaturalColors.fx new file mode 100644 index 0000000000..0a35ad4ff2 --- /dev/null +++ b/dist/post_shaders/NaturalColors.fx @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-License-Identifier: GPL-2.0-or-later +// Ported from naturalA.fsh in PPSSPP, by ShadX, modified by SimoneT. + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Luma < + ui_type = "slider"; + ui_label = "Luma Curve"; + ui_tooltip = "Gamma applied to the luminance channel in YIQ space."; + ui_min = 0.5; ui_max = 2.0; ui_step = 0.01; +> = 1.2; + +uniform float Chroma < + ui_type = "slider"; + ui_label = "Chroma Gain"; + ui_tooltip = "Boost applied to the two colour difference channels."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.01; +> = 1.2; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Natural(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + const float3x3 RGBtoYIQ = float3x3(0.299, 0.587, 0.114, + 0.596, -0.275, -0.321, + 0.212, -0.523, 0.311); + + const float3x3 YIQtoRGB = float3x3(1.0, 0.95568806, 0.61985809, + 1.0, -0.27158180, -0.64687382, + 1.0, -1.10817733, 1.70506456); + + float3 rgb = tex2D(BackBuffer, uv).rgb; + float3 yiq = mul(RGBtoYIQ, rgb); + + yiq.x = pow(max(yiq.x, 0.0), Luma); + yiq.yz *= Chroma; + + return float4(saturate(mul(YIQtoRGB, yiq)), 1.0); +} + +technique NaturalColors < + ui_label = "Natural Colours"; + ui_tooltip = "Warms the picture and lifts saturation for a less washed out look. Ported from the PPSSPP shader."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Natural; + } +} diff --git a/dist/post_shaders/Reflections.fx b/dist/post_shaders/Reflections.fx new file mode 100644 index 0000000000..1ab193481e --- /dev/null +++ b/dist/post_shaders/Reflections.fx @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Timer < source = "timer"; > = 0.0; + +uniform float Horizon < + ui_type = "slider"; + ui_label = "Reflection Line"; + ui_tooltip = "Height on screen where the reflective floor begins. Everything above it is left untouched."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.01; +> = 0.55; + +uniform float Amount < + ui_type = "slider"; + ui_label = "Amount"; + ui_tooltip = "How strongly the reflection shows through the floor."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.35; + +uniform float Falloff < + ui_type = "slider"; + ui_label = "Falloff"; + ui_tooltip = "How quickly the reflection fades as the floor comes towards the viewer. At zero it stays even."; + ui_min = 0.0; ui_max = 4.0; ui_step = 0.1; +> = 1.2; + +uniform float Perspective < + ui_type = "slider"; + ui_label = "Perspective"; + ui_tooltip = "Stretches or squashes the mirrored image. One is a true mirror, higher values pull it towards the line."; + ui_min = 0.2; ui_max = 2.0; ui_step = 0.05; +> = 1.0; + +uniform float Ripple < + ui_type = "slider"; + ui_label = "Ripple"; + ui_tooltip = "Amplitude of the waves disturbing the reflection. At zero the mirror is perfectly still."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.0; + +uniform float RippleSpeed < + ui_type = "slider"; + ui_label = "Ripple Speed"; + ui_tooltip = "How fast the waves travel."; + ui_min = 0.0; ui_max = 4.0; ui_step = 0.1; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Reflections(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 color = tex2D(BackBuffer, uv).rgb; + + float depth = uv.y - Horizon; + float on_floor = step(0.0, depth); + float span = max(1.0 - Horizon, 0.001); + float distance_down = saturate(depth / span); + + float seconds = Timer * 0.001; + float wave = sin(uv.x * 38.0 + seconds * RippleSpeed * 2.0) * + sin(uv.y * 21.0 - seconds * RippleSpeed * 1.3); + float2 disturbance = float2(wave * 0.004, wave * 0.002) * Ripple * distance_down; + + float2 mirrored = float2(uv.x, Horizon - depth * Perspective) + disturbance; + float3 reflection = tex2D(BackBuffer, saturate(mirrored)).rgb; + + float fade = pow(max(1.0 - distance_down, 0.0001), Falloff); + float strength = Amount * fade * on_floor; + + return float4(lerp(color, reflection, strength), 1.0); +} + +technique Reflections < + ui_label = "Reflections"; + ui_tooltip = "Mirrors the picture into a reflective floor below a line you choose, with optional water ripples."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Reflections; + } +} diff --git a/dist/post_shaders/Scanlines.fx b/dist/post_shaders/Scanlines.fx new file mode 100644 index 0000000000..2f24dc5ed5 --- /dev/null +++ b/dist/post_shaders/Scanlines.fx @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-License-Identifier: GPL-2.0-or-later +// Ported from scanlines.fsh in PPSSPP. + + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Density < + ui_type = "slider"; + ui_label = "Line Density"; + ui_tooltip = "Number of scanline pairs across the screen."; + ui_min = 60.0; ui_max = 720.0; ui_step = 10.0; +> = 340.0; + +uniform float Intensity < + ui_type = "slider"; + ui_label = "Intensity"; + ui_tooltip = "How dark the gaps between lines become."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 0.5; + +uniform float Tint < + ui_type = "slider"; + ui_label = "Phosphor Tint"; + ui_tooltip = "Strength of the green-warm phosphor cast."; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.05; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Scanlines(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float line_pos = uv.y * Density * 0.5; + float gate = cos((frac(line_pos) - 0.5) * 3.1415926 * Intensity) * 1.5; + + float3 rgb = tex2D(BackBuffer, uv).rgb; + float3 color = rgb * 0.5 + 0.5 * rgb * rgb * 1.2; + + float3 phosphor = lerp(float3(1.0, 1.0, 1.0), float3(0.9, 1.0, 0.7), Tint); + color *= phosphor; + + float2 diff = uv - 0.5; + color *= 1.1 - 0.6 * (dot(diff, diff) * 2.0); + + return float4(saturate(color * saturate(gate)), 1.0); +} + +technique Scanlines < + ui_label = "Scanlines"; + ui_tooltip = "Darkens alternating rows of pixels to imitate the horizontal scanlines of a CRT. Cheaper than the full CRT effect when you only want the lines."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Scanlines; + } +} diff --git a/dist/post_shaders/Sharpen.fx b/dist/post_shaders/Sharpen.fx new file mode 100644 index 0000000000..36850e818b --- /dev/null +++ b/dist/post_shaders/Sharpen.fx @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Amount < + ui_type = "slider"; + ui_label = "Amount"; + ui_min = 0.0; ui_max = 3.0; ui_step = 0.01; +> = 0.6; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Sharpen(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 texel = float2(BUFFER_RCP_WIDTH, BUFFER_RCP_HEIGHT); + + float3 centre = tex2D(BackBuffer, uv).rgb; + float3 blur = tex2D(BackBuffer, uv + float2(-texel.x, 0.0)).rgb; + blur += tex2D(BackBuffer, uv + float2(texel.x, 0.0)).rgb; + blur += tex2D(BackBuffer, uv + float2(0.0, -texel.y)).rgb; + blur += tex2D(BackBuffer, uv + float2(0.0, texel.y)).rgb; + blur *= 0.25; + + return float4(centre + (centre - blur) * Amount, 1.0); +} + +technique Sharpen < + ui_label = "Sharpen"; + ui_tooltip = "Unsharp mask that brings back edge detail lost to scaling. Most useful after upscaling a low internal resolution."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Sharpen; + } +} diff --git a/dist/post_shaders/SplitToning.fx b/dist/post_shaders/SplitToning.fx new file mode 100644 index 0000000000..682ee5cf69 --- /dev/null +++ b/dist/post_shaders/SplitToning.fx @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float ShadowHue < + ui_type = "slider"; + ui_label = "Shadow Hue"; + ui_min = 0.0; ui_max = 360.0; ui_step = 1.0; +> = 210.0; + +uniform float ShadowStrength < + ui_type = "slider"; + ui_label = "Shadow Strength"; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.01; +> = 0.0; + +uniform float HighlightHue < + ui_type = "slider"; + ui_label = "Highlight Hue"; + ui_min = 0.0; ui_max = 360.0; ui_step = 1.0; +> = 45.0; + +uniform float HighlightStrength < + ui_type = "slider"; + ui_label = "Highlight Strength"; + ui_min = 0.0; ui_max = 1.0; ui_step = 0.01; +> = 0.0; + +uniform float Balance < + ui_type = "slider"; + ui_label = "Balance"; + ui_tooltip = "Moves the split between what counts as shadow and what counts as highlight."; + ui_min = -0.5; ui_max = 0.5; ui_step = 0.01; +> = 0.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float3 HueToRGB(float hue) +{ + float h = frac(hue / 360.0) * 6.0; + return saturate(float3(abs(h - 3.0) - 1.0, 2.0 - abs(h - 2.0), 2.0 - abs(h - 4.0))); +} + +float4 PS_SplitToning(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 rgb = tex2D(BackBuffer, uv).rgb; + + float luma = saturate(dot(rgb, float3(0.2126, 0.7152, 0.0722)) + Balance); + + float3 shadow_tint = HueToRGB(ShadowHue) - 0.5; + float3 highlight_tint = HueToRGB(HighlightHue) - 0.5; + + rgb += shadow_tint * ShadowStrength * 0.25 * (1.0 - luma); + rgb += highlight_tint * HighlightStrength * 0.25 * luma; + + return float4(saturate(rgb), 1.0); +} + +technique SplitToning < + ui_label = "Split Toning"; + ui_tooltip = "Tints the shadows and the highlights towards two different hues."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_SplitToning; + } +} diff --git a/dist/post_shaders/Vignette.fx b/dist/post_shaders/Vignette.fx new file mode 100644 index 0000000000..351990a43e --- /dev/null +++ b/dist/post_shaders/Vignette.fx @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2012 PPSSPP Project +// SPDX-License-Identifier: GPL-2.0-or-later +// Ported from vignette.fsh in PPSSPP, by Henrik Rydgard. + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Strength < + ui_type = "slider"; + ui_label = "Strength"; + ui_tooltip = "How dark the corners become."; + ui_min = 0.0; ui_max = 2.0; ui_step = 0.01; +> = 0.6; + +uniform float Aspect < + ui_type = "slider"; + ui_label = "Aspect"; + ui_min = 0.5; ui_max = 2.0; ui_step = 0.01; +> = 1.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float4 PS_Vignette(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float2 diff = uv - 0.5; + diff.x *= Aspect; + diff.y /= max(Aspect, 0.0001); + + float falloff = 1.0 - min(1.0, Strength * dot(diff, diff) * 2.0); + float3 rgb = tex2D(BackBuffer, uv).rgb; + + return float4(rgb * falloff, 1.0); +} + +technique Vignette < + ui_label = "Vignette"; + ui_tooltip = "Darkens the corners of the screen to pull the eye towards the middle, the way a camera lens falls off at its edges."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_Vignette; + } +} diff --git a/dist/post_shaders/WhiteBalance.fx b/dist/post_shaders/WhiteBalance.fx new file mode 100644 index 0000000000..29aea52fc2 --- /dev/null +++ b/dist/post_shaders/WhiteBalance.fx @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +texture BackBufferTex : COLOR; +sampler BackBuffer { Texture = BackBufferTex; }; + +uniform float Temperature < + ui_type = "slider"; + ui_label = "Temperature"; + ui_tooltip = "Negative cools the picture towards blue, positive warms it towards orange."; + ui_min = -100.0; ui_max = 100.0; ui_step = 1.0; +> = 0.0; + +uniform float Tint < + ui_type = "slider"; + ui_label = "Tint"; + ui_tooltip = "Negative shifts towards green, positive towards magenta."; + ui_min = -100.0; ui_max = 100.0; ui_step = 1.0; +> = 0.0; + +void VS_PostProcess(in uint id : SV_VertexID, out float4 pos : SV_Position, out float2 uv : TEXCOORD) +{ + uv = float2(float(id & 2), float((id & 1) << 1)); + pos = float4(uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); +} + +float3 WhitePointLMS(float t1, float t2) +{ + float shift = 0.05; + if (t1 < 0.0) + { + shift = 0.10; + } + float x = 0.31271 - t1 * shift; + float y = 2.87 * x - 3.0 * x * x - 0.27509507 + t2 * 0.05; + + float big_y = 1.0; + float big_x = big_y * x / y; + float big_z = big_y * (1.0 - x - y) / y; + + return float3( 0.7328 * big_x + 0.4296 * big_y - 0.1624 * big_z, + -0.7036 * big_x + 1.6975 * big_y + 0.0061 * big_z, + 0.0030 * big_x + 0.0136 * big_y + 0.9834 * big_z); +} + +float4 PS_WhiteBalance(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target +{ + float3 rgb = pow(max(tex2D(BackBuffer, uv).rgb, 0.0), 2.2); + + float3 balance = float3(0.949237, 1.03542, 1.08728) / + WhitePointLMS(Temperature / 65.0, Tint / 65.0); + + const float3x3 rgb_to_lms = float3x3(0.390405, 0.549941, 0.008926, + 0.070841, 0.963172, 0.001358, + 0.023108, 0.128021, 0.936245); + const float3x3 lms_to_rgb = float3x3( 2.858470, -1.628790, -0.024891, + -0.210182, 1.158200, 0.000324, + -0.041812, -0.118169, 1.068670); + + float3 lms = mul(rgb_to_lms, rgb) * balance; + rgb = mul(lms_to_rgb, lms); + + return float4(saturate(pow(max(rgb, 0.0), 1.0 / 2.2)), 1.0); +} + +technique WhiteBalance < + ui_label = "White Balance"; + ui_tooltip = "Corrects a picture that looks too cool, too warm or tinted."; +> +{ + pass + { + VertexShader = VS_PostProcess; + PixelShader = PS_WhiteBalance; + } +} diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index 0682f643e2..a211b7f013 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -195,6 +195,50 @@ else() endif() endif() +# reshadefx +if (ENABLE_RESHADE) + AddJsonPackage(NAME reshade DOWNLOAD_ONLY) + + set(RESHADEFX_SHIM_DIR ${CMAKE_CURRENT_BINARY_DIR}/reshadefx_shim) + file(WRITE ${RESHADEFX_SHIM_DIR}/spirv.hpp "#include \n") + file(WRITE ${RESHADEFX_SHIM_DIR}/GLSL.std.450.h "#include \n") + if (APPLE) + file(WRITE ${RESHADEFX_SHIM_DIR}/malloc.h "#include \n#include \n") + endif() + + add_library(reshadefx STATIC + ${reshade_SOURCE_DIR}/source/effect_codegen_spirv.cpp + ${reshade_SOURCE_DIR}/source/effect_expression.cpp + ${reshade_SOURCE_DIR}/source/effect_lexer.cpp + ${reshade_SOURCE_DIR}/source/effect_parser_exp.cpp + ${reshade_SOURCE_DIR}/source/effect_parser_stmt.cpp + ${reshade_SOURCE_DIR}/source/effect_preprocessor.cpp + ${reshade_SOURCE_DIR}/source/effect_symbol_table.cpp + ) + + target_include_directories(reshadefx SYSTEM PUBLIC ${reshade_SOURCE_DIR}/source) + target_include_directories(reshadefx PRIVATE ${RESHADEFX_SHIM_DIR}) + target_link_libraries(reshadefx PUBLIC SPIRV-Headers::SPIRV-Headers) + + if (NOT MSVC) + target_compile_options(reshadefx PRIVATE -w -fno-char8_t) + else() + target_compile_options(reshadefx PRIVATE /w /Zc:char8_t-) + endif() + + if (WIN32) + if (NOT MSVC) + target_compile_options(reshadefx PRIVATE -include share.h) + else() + target_compile_options(reshadefx PRIVATE /FIshare.h) + endif() + endif() + + if (NOT TARGET reshadefx::reshadefx) + add_library(reshadefx::reshadefx ALIAS reshadefx) + endif() +endif() + # Catch2 if (YUZU_TESTS OR DYNARMIC_TESTS) AddJsonPackage(catch2) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt index 7ba7772bd7..1136923fd0 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt @@ -3,6 +3,8 @@ package org.yuzu.yuzu_emu.dialogs +import android.content.res.ColorStateList +import android.graphics.Rect import android.view.LayoutInflater import android.view.MotionEvent import android.view.View @@ -18,11 +20,28 @@ import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.fragments.EmulationFragment import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.NativePostProcessing import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting import org.yuzu.yuzu_emu.features.settings.model.AbstractShortSetting import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting class QuickSettings(val emulationFragment: EmulationFragment) { + private val expandedShaders = mutableSetOf() + + private fun forgetShaderSlot(index: Int) { + val shifted = mutableSetOf() + for (slot in expandedShaders) { + if (slot < index) { + shifted.add(slot) + } + if (slot > index) { + shifted.add(slot - 1) + } + } + expandedShaders.clear() + expandedShaders.addAll(shifted) + } + private fun saveSettings() { if (emulationFragment.shouldUseCustom) { NativeConfig.savePerGameConfig() @@ -232,6 +251,428 @@ class QuickSettings(val emulationFragment: EmulationFragment) { container.addView(itemView) } + fun addChoice( + title: String, + container: ViewGroup, + choices: List, + selectedIndex: Int, + onSelected: (Int) -> Unit + ) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_menu, container, false) + val headerView = itemView.findViewById(R.id.setting_header) + val titleView = itemView.findViewById(R.id.setting_title) + val valueView = itemView.findViewById(R.id.setting_value) + val expandIcon = itemView.findViewById(R.id.expand_icon) + val radioGroup = itemView.findViewById(R.id.radio_group) + + titleView.text = title + + var current = "" + if (selectedIndex in choices.indices) { + current = choices[selectedIndex] + } + valueView.text = current + headerView.visibility = View.VISIBLE + + var isExpanded = false + choices.forEachIndexed { index, name -> + val radioButton = com.google.android.material.radiobutton.MaterialRadioButton( + emulationFragment.requireContext() + ) + radioButton.text = name + radioButton.id = View.generateViewId() + radioButton.isChecked = index == selectedIndex + radioButton.setPadding(16, 8, 16, 8) + + radioButton.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + valueView.text = name + onSelected(index) + } + } + radioGroup.addView(radioButton) + } + + headerView.setOnClickListener { + isExpanded = !isExpanded + if (isExpanded) { + radioGroup.visibility = View.VISIBLE + expandIcon.animate().rotation(180f).setDuration(200).start() + } else { + radioGroup.visibility = View.GONE + expandIcon.animate().rotation(0f).setDuration(200).start() + } + } + + container.addView(itemView) + } + + fun addStepSlider( + title: String, + container: ViewGroup, + steps: Int, + selectedStep: Int, + describe: (Int) -> String, + onCommitted: (Int) -> Unit, + onChanged: (Int) -> Unit + ) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_menu, container, false) + + val sliderContainer = itemView.findViewById(R.id.slider_container) + val titleView = itemView.findViewById(R.id.slider_title) + val valueDisplay = itemView.findViewById(R.id.slider_value_display) + val slider = itemView.findViewById( + R.id.setting_slider + ) + + titleView.text = title + sliderContainer.visibility = View.VISIBLE + + slider.valueFrom = 0f + slider.valueTo = steps.toFloat() + slider.stepSize = 1f + slider.value = selectedStep.toFloat().coerceIn(0f, steps.toFloat()) + valueDisplay.text = describe(slider.value.toInt()) + + slider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + val step = value.toInt() + onChanged(step) + valueDisplay.text = describe(step) + } + } + + var pressedValue = slider.value + + slider.setOnTouchListener { _, event -> + val drawer = emulationFragment.view?.findViewById(R.id.drawer_layout) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + drawer?.requestDisallowInterceptTouchEvent(true) + pressedValue = slider.value + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + drawer?.requestDisallowInterceptTouchEvent(false) + if (slider.value != pressedValue) { + onCommitted(slider.value.toInt()) + } + } + } + false + } + + container.addView(itemView) + } + + fun addShaderCard( + index: Int, + title: String, + summary: String, + container: ViewGroup, + onRemove: () -> Unit + ): ViewGroup { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_shader, container, false) + + val headerView = itemView.findViewById(R.id.shader_header) + val titleView = itemView.findViewById(R.id.shader_title) + val summaryView = itemView.findViewById(R.id.shader_summary) + val removeView = itemView.findViewById(R.id.shader_remove) + val expandIcon = itemView.findViewById(R.id.shader_expand) + val bodyView = itemView.findViewById(R.id.shader_body) + + titleView.text = title + if (summary.isEmpty()) { + summaryView.visibility = View.GONE + } else { + summaryView.text = summary + } + + var isExpanded = expandedShaders.contains(index) + if (isExpanded) { + bodyView.visibility = View.VISIBLE + expandIcon.rotation = 180f + } + + headerView.setOnClickListener { + isExpanded = !isExpanded + if (isExpanded) { + expandedShaders.add(index) + bodyView.visibility = View.VISIBLE + expandIcon.animate().rotation(180f).setDuration(200).start() + } else { + expandedShaders.remove(index) + bodyView.visibility = View.GONE + expandIcon.animate().rotation(0f).setDuration(200).start() + } + } + + removeView.setOnClickListener { + onRemove() + } + + container.addView(itemView) + return bodyView + } + + fun addEffectPicker( + container: ViewGroup, + choices: List, + hasEffects: Boolean, + onRemoveAll: () -> Unit, + onPicked: (Int) -> Unit + ) { + val context = emulationFragment.requireContext() + val inflater = LayoutInflater.from(context) + val itemView = inflater.inflate(R.layout.item_quick_settings_add, container, false) + + val button = itemView.findViewById( + R.id.add_button + ) + val removeButton = + itemView.findViewById( + R.id.remove_all_button + ) + val choiceGroup = itemView.findViewById(R.id.add_choices) + + choices.forEachIndexed { index, name -> + val radioButton = com.google.android.material.radiobutton.MaterialRadioButton(context) + radioButton.text = name + radioButton.id = View.generateViewId() + radioButton.setPadding(16, 8, 16, 8) + radioButton.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + onPicked(index) + } + } + choiceGroup.addView(radioButton) + } + + var closedLabel = R.string.post_processing_add + if (hasEffects) { + closedLabel = R.string.post_processing_open_list + removeButton.visibility = View.VISIBLE + } + button.setText(closedLabel) + + val removeBackground = MaterialColors.getColor( + removeButton, + com.google.android.material.R.attr.colorErrorContainer + ) + val removeForeground = MaterialColors.getColor( + removeButton, + com.google.android.material.R.attr.colorOnErrorContainer + ) + removeButton.backgroundTintList = ColorStateList.valueOf(removeBackground) + removeButton.setTextColor(removeForeground) + removeButton.iconTint = ColorStateList.valueOf(removeForeground) + removeButton.setOnClickListener { + onRemoveAll() + } + + val slide = context.resources.displayMetrics.density * 24.0f + + var isOpen = false + button.setOnClickListener { + isOpen = !isOpen + if (isOpen) { + choiceGroup.alpha = 0.0f + choiceGroup.translationY = slide + choiceGroup.visibility = View.VISIBLE + choiceGroup.animate() + .alpha(1.0f) + .translationY(0.0f) + .setDuration(220) + .withEndAction { + choiceGroup.requestRectangleOnScreen( + Rect(0, 0, choiceGroup.width, choiceGroup.height), + false + ) + } + .start() + + button.setText(R.string.post_processing_close_list) + button.setIconResource(R.drawable.ic_clear) + } else { + choiceGroup.animate() + .alpha(0.0f) + .translationY(slide) + .setDuration(160) + .withEndAction { + choiceGroup.visibility = View.GONE + } + .start() + + button.setText(closedLabel) + button.setIconResource(R.drawable.ic_add) + } + } + + container.addView(itemView) + } + + fun addPresetBand(container: ViewGroup, name: String, summary: String) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_preset, container, false) + + val titleView = itemView.findViewById(R.id.preset_title) + val summaryView = itemView.findViewById(R.id.preset_summary) + val switchView = itemView.findViewById(R.id.preset_switch) + + titleView.text = name + if (summary.isEmpty()) { + summaryView.visibility = View.GONE + } else { + summaryView.text = summary + } + + switchView.isChecked = NativePostProcessing.isEnabled() + switchView.setOnCheckedChangeListener { _, checked -> + emulationFragment.editPostProcessing { + NativePostProcessing.setEnabled(checked) + } + } + + container.addView(itemView) + } + + fun addPostProcessing(container: ViewGroup, onStructureChanged: () -> Unit) { + val usable = NativePostProcessing.catalog().filter { it.valid } + if (usable.isEmpty()) { + return + } + + val preset = NativePostProcessing.getActivePreset() + if (preset.isNotEmpty()) { + addDivider(container) + + var summary = "" + val described = NativePostProcessing.presets().firstOrNull { it.name == preset } + if (described != null) { + summary = described.description + } + if (summary.isEmpty()) { + summary = + YuzuApplication.appContext.getString(R.string.post_processing_preset_locked) + } + if (NativePostProcessing.isPresetModified()) { + summary = summary + "\n" + + YuzuApplication.appContext.getString(R.string.post_processing_preset_modified) + } + + addPresetBand(container, preset, summary) + return + } + + val labels = mutableListOf() + val files = mutableListOf() + val techniques = mutableListOf() + + for (effect in usable) { + for (technique in effect.techniques) { + var label = effect.label + if (effect.techniques.size > 1) { + label = effect.label + " \u00b7 " + technique + } + labels.add(label) + files.add(effect.file) + techniques.add(technique) + } + } + + addDivider(container) + + val chain = NativePostProcessing.chain() + chain.forEachIndexed { index, entry -> + val effect = usable.firstOrNull { it.file == entry.file } + + var title = entry.file + var summary = "" + if (effect != null) { + title = effect.label + if (effect.techniques.size > 1) { + title = effect.label + " \u00b7 " + entry.technique + } + summary = effect.description + } + + val body = addShaderCard(index, title, summary, container) { + emulationFragment.editPostProcessing { + NativePostProcessing.remove(index) + } + forgetShaderSlot(index) + onStructureChanged() + } + + if (effect != null) { + for (uniform in effect.uniforms) { + addUniformSliders(body, index, uniform) + } + } + } + + addEffectPicker( + container, + labels, + chain.isNotEmpty(), + { + emulationFragment.editPostProcessing { + NativePostProcessing.clearChain() + } + expandedShaders.clear() + onStructureChanged() + } + ) { picked -> + emulationFragment.editPostProcessing { + NativePostProcessing.append(files[picked], techniques[picked]) + } + onStructureChanged() + } + } + + private fun addUniformSliders( + container: ViewGroup, + index: Int, + uniform: NativePostProcessing.Uniform + ) { + if (uniform.uiType == NativePostProcessing.UI_HIDDEN) { + return + } + + for (component in 0 until uniform.components) { + var title = uniform.label + if (uniform.components > 1) { + title = uniform.label + " [" + component + "]" + } + + var value = uniform.defaultAt(component) + if (NativePostProcessing.hasValue(index, uniform.name)) { + value = NativePostProcessing.getValue(index, uniform.name, component) + } + + val steps = uniform.steps + val step = Math.round((value - uniform.min) / uniform.step) + + addStepSlider( + title, + container, + steps, + step, + { position -> uniform.describe(position) }, + { emulationFragment.persistPostProcessing() } + ) { position -> + NativePostProcessing.setValue( + index, + uniform.name, + component, + uniform.min + position * uniform.step + ) + } + } + } + fun addDivider(container: ViewGroup) { val inflater = LayoutInflater.from(emulationFragment.requireContext()) val dividerView = inflater.inflate(R.layout.item_quick_settings_divider, container, false) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxPresetNameSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxPresetNameSetting.kt new file mode 100644 index 0000000000..496cb5390a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxPresetNameSetting.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +class FxPresetNameSetting(private val onNamed: (String) -> Unit) : AbstractStringSetting { + override val key: String + get() = "fx_preset_name" + + override val defaultValue: Any + get() = "" + + override val isRuntimeModifiable: Boolean + get() = true + + override val pairedSettingKey: String + get() = "" + + override val isSwitchable: Boolean + get() = false + + override val isSaveable: Boolean + get() = true + + override var global: Boolean + get() = true + set(_) {} + + override fun getString(needsGlobal: Boolean): String = "" + + override fun setString(value: String) = onNamed(value) + + override fun getValueAsString(needsGlobal: Boolean): String = "" + + override fun reset() {} +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxUniformSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxUniformSetting.kt new file mode 100644 index 0000000000..23c7837e12 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxUniformSetting.kt @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativePostProcessing +import kotlin.math.roundToInt + +abstract class FxUniformSetting( + protected val index: Int, + protected val uniform: NativePostProcessing.Uniform, + protected val component: Int +) : AbstractSetting { + override val key: String + get() = "fx_${index}_${uniform.name}_$component" + + override val isRuntimeModifiable: Boolean + get() = true + + override val pairedSettingKey: String + get() = "" + + override val isSwitchable: Boolean + get() = false + + override val isSaveable: Boolean + get() = true + + override var global: Boolean + get() = true + set(_) {} + + protected fun currentValue(): Float { + if (NativePostProcessing.hasValue(index, uniform.name)) { + return NativePostProcessing.getValue(index, uniform.name, component) + } + return uniform.defaultAt(component) + } + + protected fun commit(value: Float) { + NativePostProcessing.setValue(index, uniform.name, component, value) + NativePostProcessing.store() + } + + override fun reset() = commit(uniform.defaultAt(component)) +} + +class FxUniformSliderSetting( + index: Int, + uniform: NativePostProcessing.Uniform, + component: Int +) : FxUniformSetting(index, uniform, component), AbstractIntSetting { + override val defaultValue: Any + get() = ((uniform.defaultAt(component) - uniform.min) / uniform.step).roundToInt() + + override fun getInt(needsGlobal: Boolean): Int = + ((currentValue() - uniform.min) / uniform.step).roundToInt() + + override fun setInt(value: Int) = commit(uniform.min + value * uniform.step) + + override fun getValueAsString(needsGlobal: Boolean): String { + if (uniform.kind == NativePostProcessing.KIND_FLOAT) { + return String.format("%.3f", currentValue()) + } + return currentValue().roundToInt().toString() + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt index fc4df4952d..23c5ceb88b 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt @@ -12,6 +12,7 @@ object Settings { SECTION_SYSTEM(R.string.preferences_system), SECTION_RENDERER(R.string.preferences_graphics), SECTION_FRAME_GEN(R.string.frame_gen), + SECTION_POST_PROCESSING(R.string.post_processing), SECTION_PERFORMANCE_STATS(R.string.stats_overlay_options), SECTION_INPUT_OVERLAY(R.string.input_overlay_options), SECTION_SOC_OVERLAY(R.string.soc_overlay_options), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt index 43fb1fe0fe..e3ccf9e998 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt @@ -13,6 +13,7 @@ enum class StringSetting(override val key: String) : AbstractStringSetting { DEVICE_NAME("device_name"), LOG_FILTER("log_filter"), PROGRAM_ARGS("program_args"), + POST_SHADER_CHAIN("post_shader_chain"), WEB_TOKEN("eden_token"), WEB_USERNAME("eden_username") diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxButtonSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxButtonSetting.kt new file mode 100644 index 0000000000..3fc999e267 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxButtonSetting.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes + +class FxButtonSetting( + @StringRes titleId: Int, + val onClick: () -> Unit +) : SettingsItem(emptySetting, titleId, "", 0, "") { + override val type = TYPE_FX_BUTTON +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxPresetSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxPresetSetting.kt new file mode 100644 index 0000000000..9873b1c396 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxPresetSetting.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +class FxPresetSetting( + titleString: String, + descriptionString: String = "", + val deletable: Boolean = false, + val onApply: () -> Unit, + val onDelete: () -> Unit = {} +) : SettingsItem(emptySetting, 0, titleString, 0, descriptionString) { + override val type = TYPE_FX_PRESET +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxShaderCardSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxShaderCardSetting.kt new file mode 100644 index 0000000000..e54ac3b5a0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxShaderCardSetting.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.utils.NativePostProcessing + +class FxShaderCardSetting( + titleString: String, + descriptionString: String, + val index: Int, + val expanded: Boolean, + val uniforms: List, + val onToggle: () -> Unit, + val onRemove: () -> Unit, + val onReset: () -> Unit +) : SettingsItem(emptySetting, 0, titleString, 0, descriptionString) { + override val type = TYPE_FX_SHADER +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxToolbarSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxToolbarSetting.kt new file mode 100644 index 0000000000..e62b0b914b --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxToolbarSetting.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes + +class FxToolbarSetting( + @StringRes val addLabelId: Int, + val listOpen: Boolean, + val presetLabel: String, + val hasEffects: Boolean, + val createPreset: StringInputSetting, + val onAdd: () -> Unit, + val onPresets: () -> Unit, + val onRemoveAll: () -> Unit +) : SettingsItem(emptySetting, 0, "", 0, "") { + override val type = TYPE_FX_TOOLBAR +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index 30d784267a..ab2ac7eb42 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -144,6 +144,10 @@ abstract class SettingsItem( const val TYPE_LAUNCHABLE = 13 const val TYPE_PATH = 14 const val TYPE_GPU_UNSWIZZLE = 15 + const val TYPE_FX_TOOLBAR = 16 + const val TYPE_FX_PRESET = 17 + const val TYPE_FX_SHADER = 18 + const val TYPE_FX_BUTTON = 19 const val FASTMEM_COMBINED = "fastmem_combined" const val GPU_UNSWIZZLE_COMBINED = "gpu_unswizzle_combined" diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt index d7311eded5..8d47db94e0 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt @@ -23,6 +23,10 @@ import com.google.android.material.timepicker.TimeFormat import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.SettingsNavigationDirections import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxButtonBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxPresetBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxShaderBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxToolbarBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding @@ -106,6 +110,34 @@ class SettingsAdapter( GpuUnswizzleViewHolder(ListItemSettingBinding.inflate(inflater), this) } + SettingsItem.TYPE_FX_TOOLBAR -> { + FxToolbarViewHolder( + ListItemSettingFxToolbarBinding.inflate(inflater, parent, false), + this + ) + } + + SettingsItem.TYPE_FX_PRESET -> { + FxPresetViewHolder( + ListItemSettingFxPresetBinding.inflate(inflater, parent, false), + this + ) + } + + SettingsItem.TYPE_FX_SHADER -> { + FxShaderCardViewHolder( + ListItemSettingFxShaderBinding.inflate(inflater, parent, false), + this + ) + } + + SettingsItem.TYPE_FX_BUTTON -> { + FxButtonViewHolder( + ListItemSettingFxButtonBinding.inflate(inflater, parent, false), + this + ) + } + else -> { HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index bdb10c3146..89e0084df0 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -18,6 +18,7 @@ import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.FxPresetNameSetting import org.yuzu.yuzu_emu.features.settings.model.ByteSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.features.settings.model.LongSetting @@ -30,6 +31,7 @@ import org.yuzu.yuzu_emu.features.settings.model.view.* import org.yuzu.yuzu_emu.utils.InputHandler import org.yuzu.yuzu_emu.utils.LosslessScalingHelper import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.NativePostProcessing import org.yuzu.yuzu_emu.utils.DirectoryInitialization import org.yuzu.yuzu_emu.utils.FullscreenHelper import androidx.core.content.edit @@ -44,6 +46,14 @@ class SettingsFragmentPresenter( ) { private var settingsList = ArrayList() + private val expandedShaderSlots = mutableSetOf() + + private var shaderPickerOpen = false + + private var presetPickerOpen = false + + private var postProcessingSynced = false + private val context get() = YuzuApplication.appContext // Extension for altering settings list based on each setting's properties @@ -163,6 +173,7 @@ class SettingsFragmentPresenter( MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) MenuTag.SECTION_FRAME_GEN -> addFrameGenSettings(sl) + MenuTag.SECTION_POST_PROCESSING -> addPostProcessingSettings(sl) MenuTag.SECTION_PERFORMANCE_STATS -> addPerformanceOverlaySettings(sl) MenuTag.SECTION_SOC_OVERLAY -> addSocOverlaySettings(sl) MenuTag.SECTION_INPUT_OVERLAY -> addInputOverlaySettings(sl) @@ -190,6 +201,209 @@ class SettingsFragmentPresenter( } } + private fun addPostProcessingSettings(sl: ArrayList) { + if (!postProcessingSynced) { + postProcessingSynced = true + NativePostProcessing.reload() + } + + val usable = NativePostProcessing.catalog().filter { it.valid } + + sl.apply { + if (usable.isEmpty()) { + add( + RunnableSetting( + titleId = R.string.post_processing_empty, + descriptionString = NativePostProcessing.getShaderDirectory(), + isRunnable = false + ) {} + ) + return@apply + } + + val labels = mutableListOf() + val summaries = mutableListOf() + val files = mutableListOf() + val techniques = mutableListOf() + for (effect in usable) { + for (technique in effect.techniques) { + if (effect.techniques.size == 1) { + labels.add(effect.label) + } else { + labels.add(effect.label + " \u00b7 " + technique) + } + summaries.add(effect.description) + files.add(effect.file) + techniques.add(technique) + } + } + + val chain = NativePostProcessing.chain() + val active = NativePostProcessing.getActivePreset() + + var addLabel = R.string.post_processing_add + if (chain.isNotEmpty()) { + addLabel = R.string.post_processing_open_list + } + if (shaderPickerOpen) { + addLabel = R.string.post_processing_close_list + } + + var presetLabel = context.getString(R.string.post_processing_presets) + if (active.isNotEmpty()) { + presetLabel = active + } + + val createPreset = StringInputSetting( + setting = FxPresetNameSetting { name -> + NativePostProcessing.savePreset(name, "") + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + titleId = R.string.post_processing_preset_new, + descriptionId = R.string.post_processing_preset_new_description, + validator = { it != null && it.isNotBlank() && !it.contains('=') }, + errorId = R.string.post_processing_preset_name_invalid + ) + + add( + FxToolbarSetting( + addLabelId = addLabel, + listOpen = shaderPickerOpen, + presetLabel = presetLabel, + hasEffects = chain.isNotEmpty(), + createPreset = createPreset, + onAdd = { + shaderPickerOpen = !shaderPickerOpen + presetPickerOpen = false + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + onPresets = { + presetPickerOpen = !presetPickerOpen + shaderPickerOpen = false + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + onRemoveAll = { + NativePostProcessing.clearChain() + NativePostProcessing.clearPreset() + NativePostProcessing.store() + expandedShaderSlots.clear() + shaderPickerOpen = false + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + ) + + if (shaderPickerOpen) { + for (choice in labels.indices) { + add( + RunnableSetting( + titleString = labels[choice], + descriptionString = summaries[choice], + isRunnable = true + ) { + NativePostProcessing.append(files[choice], techniques[choice]) + NativePostProcessing.store() + shaderPickerOpen = false + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + } + } + + if (presetPickerOpen) { + add( + FxPresetSetting( + titleString = context.getString(R.string.post_processing_preset_none), + onApply = { + NativePostProcessing.clearPreset() + presetPickerOpen = false + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + ) + for (preset in NativePostProcessing.presets()) { + add( + FxPresetSetting( + titleString = preset.name, + descriptionString = preset.description, + deletable = !preset.bundled, + onApply = { + NativePostProcessing.applyPreset(preset.name) + NativePostProcessing.store() + presetPickerOpen = false + expandedShaderSlots.clear() + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + onDelete = { + NativePostProcessing.deletePreset(preset.name) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + ) + } + } + + if (active.isNotEmpty()) { + add( + FxButtonSetting(titleId = R.string.post_processing_preset_reset) { + NativePostProcessing.applyPreset(active) + NativePostProcessing.store() + expandedShaderSlots.clear() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + } + + for (index in chain.indices) { + val entry = chain[index] + val effect = usable.firstOrNull { it.file == entry.file } + + var header = entry.file + var summary = "" + var uniforms = emptyList() + if (effect != null) { + header = effect.label + if (effect.techniques.size > 1) { + header = effect.label + " \u00b7 " + entry.technique + } + summary = effect.description + uniforms = effect.uniforms + } + + val isOpen = expandedShaderSlots.contains(index) + + add( + FxShaderCardSetting( + titleString = header, + descriptionString = summary, + index = index, + expanded = isOpen, + uniforms = uniforms, + onToggle = { + if (isOpen) { + expandedShaderSlots.remove(index) + } else { + expandedShaderSlots.add(index) + } + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + onRemove = { + NativePostProcessing.remove(index) + NativePostProcessing.store() + expandedShaderSlots.clear() + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + onReset = { + NativePostProcessing.resetValues(index) + NativePostProcessing.store() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + ) + } + } + } + private fun addConfigSettings(sl: ArrayList) { sl.apply { add( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxButtonViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxButtonViewHolder.kt new file mode 100644 index 0000000000..914f96085a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxButtonViewHolder.kt @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxButtonBinding +import org.yuzu.yuzu_emu.features.settings.model.view.FxButtonSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class FxButtonViewHolder( + val binding: ListItemSettingFxButtonBinding, + adapter: SettingsAdapter +) : SettingViewHolder(binding.root, adapter) { + private lateinit var setting: FxButtonSetting + + override fun bind(item: SettingsItem) { + setting = item as FxButtonSetting + + binding.fxButton.text = item.title + binding.fxButton.setOnClickListener { setting.onClick.invoke() } + } + + override fun onClick(clicked: View) {} + + override fun onLongClick(clicked: View): Boolean = true +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxPresetViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxPresetViewHolder.kt new file mode 100644 index 0000000000..d8634c1002 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxPresetViewHolder.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxPresetBinding +import org.yuzu.yuzu_emu.features.settings.model.view.FxPresetSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class FxPresetViewHolder( + val binding: ListItemSettingFxPresetBinding, + adapter: SettingsAdapter +) : SettingViewHolder(binding.root, adapter) { + private lateinit var setting: FxPresetSetting + + override fun bind(item: SettingsItem) { + setting = item as FxPresetSetting + + binding.presetName.text = item.title + binding.presetDescription.text = item.description + binding.presetDescription.setVisible(item.description.isNotEmpty()) + + binding.presetRow.setOnClickListener { setting.onApply.invoke() } + + binding.presetDelete.setVisible(setting.deletable) + binding.presetDelete.setOnClickListener { setting.onDelete.invoke() } + } + + override fun onClick(clicked: View) {} + + override fun onLongClick(clicked: View): Boolean = true +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxShaderCardViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxShaderCardViewHolder.kt new file mode 100644 index 0000000000..fc72f04225 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxShaderCardViewHolder.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.LayoutInflater +import android.view.View +import org.yuzu.yuzu_emu.databinding.ItemSettingFxActionsBinding +import org.yuzu.yuzu_emu.databinding.ItemSettingFxSliderBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxShaderBinding +import org.yuzu.yuzu_emu.features.settings.model.FxUniformSliderSetting +import org.yuzu.yuzu_emu.features.settings.model.view.FxShaderCardSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.NativePostProcessing +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class FxShaderCardViewHolder( + val binding: ListItemSettingFxShaderBinding, + adapter: SettingsAdapter +) : SettingViewHolder(binding.root, adapter) { + private lateinit var setting: FxShaderCardSetting + + override fun bind(item: SettingsItem) { + setting = item as FxShaderCardSetting + + binding.shaderTitle.text = item.title + binding.shaderSummary.text = item.description + binding.shaderSummary.setVisible(item.description.isNotEmpty()) + + var rotation = 0f + if (setting.expanded) { + rotation = 180f + } + binding.shaderExpand.rotation = rotation + + binding.shaderHeader.setOnClickListener { setting.onToggle.invoke() } + binding.shaderRemove.setOnClickListener { setting.onRemove.invoke() } + + binding.shaderBody.removeAllViews() + binding.shaderBody.setVisible(setting.expanded) + if (!setting.expanded) { + return + } + + val inflater = LayoutInflater.from(binding.root.context) + for (uniform in setting.uniforms) { + if (uniform.uiType == NativePostProcessing.UI_HIDDEN) { + continue + } + for (component in 0 until uniform.components) { + addSlider(inflater, uniform, component) + } + } + addActions(inflater) + } + + private fun addSlider( + inflater: LayoutInflater, + uniform: NativePostProcessing.Uniform, + component: Int + ) { + val row = ItemSettingFxSliderBinding.inflate(inflater, binding.shaderBody, false) + val value = FxUniformSliderSetting(setting.index, uniform, component) + + var title = uniform.label + if (uniform.components > 1) { + title = uniform.label + " [" + component + "]" + } + row.fxSliderTitle.text = title + + val steps = uniform.steps.toFloat() + row.fxSlider.valueFrom = 0f + row.fxSlider.valueTo = steps + row.fxSlider.stepSize = 1f + row.fxSlider.value = value.getInt(false).toFloat().coerceIn(0f, steps) + row.fxSliderValue.text = uniform.describe(row.fxSlider.value.toInt()) + + row.fxSlider.addOnChangeListener { _, position, fromUser -> + if (fromUser) { + value.setInt(position.toInt()) + row.fxSliderValue.text = uniform.describe(position.toInt()) + } + } + + binding.shaderBody.addView(row.root) + } + + private fun addActions(inflater: LayoutInflater) { + val row = ItemSettingFxActionsBinding.inflate(inflater, binding.shaderBody, false) + + row.fxReset.setOnClickListener { setting.onReset.invoke() } + + binding.shaderBody.addView(row.root) + } + + override fun onClick(clicked: View) {} + + override fun onLongClick(clicked: View): Boolean = true +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxToolbarViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxToolbarViewHolder.kt new file mode 100644 index 0000000000..21bad70040 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxToolbarViewHolder.kt @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.content.res.ColorStateList +import android.view.View +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ListItemSettingFxToolbarBinding +import org.yuzu.yuzu_emu.features.settings.model.view.FxToolbarSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class FxToolbarViewHolder( + val binding: ListItemSettingFxToolbarBinding, + adapter: SettingsAdapter +) : SettingViewHolder(binding.root, adapter) { + private lateinit var setting: FxToolbarSetting + + override fun bind(item: SettingsItem) { + setting = item as FxToolbarSetting + + binding.fxAdd.setText(setting.addLabelId) + var addIcon = R.drawable.ic_add + if (setting.listOpen) { + addIcon = R.drawable.ic_clear + } + binding.fxAdd.setIconResource(addIcon) + binding.fxAdd.setOnClickListener { setting.onAdd.invoke() } + + binding.fxPresets.text = setting.presetLabel + binding.fxPresets.setOnClickListener { setting.onPresets.invoke() } + + binding.fxCreatePreset.isEnabled = setting.hasEffects + binding.fxCreatePreset.setOnClickListener { + adapter.onStringInputClick(setting.createPreset, bindingAdapterPosition) + } + + val removeBackground = MaterialColors.getColor( + binding.fxRemoveAll, + com.google.android.material.R.attr.colorErrorContainer + ) + val removeForeground = MaterialColors.getColor( + binding.fxRemoveAll, + com.google.android.material.R.attr.colorOnErrorContainer + ) + binding.fxRemoveAll.backgroundTintList = ColorStateList.valueOf(removeBackground) + binding.fxRemoveAll.iconTint = ColorStateList.valueOf(removeForeground) + binding.fxRemoveAll.isEnabled = setting.hasEffects + binding.fxRemoveAll.setOnClickListener { setting.onRemoveAll.invoke() } + } + + override fun onClick(clicked: View) {} + + override fun onLongClick(clicked: View): Boolean = true +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 016ac57356..3f1f3da2ba 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -94,6 +94,7 @@ import org.yuzu.yuzu_emu.utils.InputHandler import org.yuzu.yuzu_emu.utils.Log import org.yuzu.yuzu_emu.utils.NativeConfig import org.yuzu.yuzu_emu.utils.NativeFreedrenoConfig +import org.yuzu.yuzu_emu.utils.NativePostProcessing import org.yuzu.yuzu_emu.utils.ViewUtils import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible import org.yuzu.yuzu_emu.utils.collect @@ -883,6 +884,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { if (shouldUseCustom) { SettingsFile.loadCustomConfig(game!!) } + refreshPostProcessing() + addQuickSettings() } } @@ -1087,6 +1090,34 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + private fun withPerGameConfig(create: Boolean, action: () -> Unit) { + val target = game + var owned = false + if (target != null && !NativeConfig.isPerGameConfigLoaded()) { + if (create || SettingsFile.getCustomSettingsFile(target).exists()) { + SettingsFile.loadCustomConfig(target) + owned = true + } + } + action() + if (owned) { + NativeConfig.unloadPerGameConfig() + } + } + + fun refreshPostProcessing() = withPerGameConfig(false) { + NativePostProcessing.reload() + } + + fun persistPostProcessing() = withPerGameConfig(true) { + NativePostProcessing.persist() + } + + fun editPostProcessing(action: () -> Unit) = withPerGameConfig(true) { + action() + NativePostProcessing.persist() + } + private fun addQuickSettings() { binding.quickSettingsSheet.apply { val container = binding.quickSettingsSheet.findViewById(R.id.quick_settings_container) @@ -1194,6 +1225,10 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { R.array.rendererAntiAliasingNames, R.array.rendererAntiAliasingValues ) + + quickSettings.addPostProcessing(container) { + addQuickSettings() + } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt index a2d6563e50..d2505c5f61 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -383,6 +383,20 @@ class GamePropertiesFragment : Fragment() { } ) ) + add( + SubmenuProperty( + R.string.post_processing, + R.string.post_processing_per_game_description, + R.drawable.ic_post_processing, + action = { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_POST_PROCESSING + ) + binding.root.findNavController().navigate(action) + } + ) + ) if (GpuDriverHelper.isAdrenoGpu()) { add( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt index bd4d8465f3..2b4cfc5ca8 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -171,6 +171,20 @@ class HomeSettingsFragment : Fragment() { ) ) } + add( + HomeSetting( + R.string.post_processing, + R.string.post_processing_description, + R.drawable.ic_post_processing, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_POST_PROCESSING + ) + binding.root.findNavController().navigate(action) + } + ) + ) add( HomeSetting( R.string.lossless_scaling, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativePostProcessing.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativePostProcessing.kt new file mode 100644 index 0000000000..2c04900064 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativePostProcessing.kt @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import org.json.JSONArray +import org.json.JSONObject + +object NativePostProcessing { + const val KIND_BOOL = 0 + const val KIND_INT = 1 + const val KIND_FLOAT = 2 + + const val UI_HIDDEN = 0 + const val UI_SLIDER = 1 + const val UI_DRAG = 2 + const val UI_COMBO = 3 + const val UI_RADIO = 4 + const val UI_CHECKBOX = 5 + const val UI_COLOR = 6 + const val UI_INPUT_BOX = 7 + + external fun getCatalogJson(): String + + external fun getChainJson(): String + + external fun append(file: String, technique: String) + + external fun replace(index: Int, file: String, technique: String) + + external fun remove(index: Int) + + external fun move(index: Int, delta: Int) + + external fun resetValues(index: Int) + + external fun getValue(index: Int, uniform: String, component: Int): Float + + external fun hasValue(index: Int, uniform: String): Boolean + + external fun setValue(index: Int, uniform: String, component: Int, value: Float) + + external fun store() + + external fun reload() + + external fun clearChain() + + external fun getPresetsJson(): String + + external fun getActivePreset(): String + + external fun isPresetModified(): Boolean + + external fun applyPreset(name: String): Boolean + + external fun savePreset(name: String, description: String): Boolean + + external fun deletePreset(name: String): Boolean + + external fun clearPreset() + + external fun isEnabled(): Boolean + + external fun setEnabled(enabled: Boolean) + + external fun getPresetDirectory(): String + + fun persist() { + val perGame = NativeConfig.isPerGameConfigLoaded() + store() + if (perGame) { + NativeConfig.savePerGameConfig() + } else { + NativeConfig.saveGlobalConfig() + } + } + + + external fun getShaderDirectory(): String + + data class Uniform( + val name: String, + val label: String, + val tooltip: String, + val category: String, + val kind: Int, + val uiType: Int, + val components: Int, + val min: Float, + val max: Float, + val step: Float, + val items: List, + val defaults: List + ) { + val steps: Int + get() { + val span = max - min + if (step <= 0f) { + return 1 + } + val count = Math.round(span / step) + if (count < 1) { + return 1 + } + return count + } + + fun describe(position: Int): String { + val value = min + position * step + if (kind == NativePostProcessing.KIND_FLOAT) { + return String.format("%.3f", value) + } + return Math.round(value).toString() + } + + fun defaultAt(component: Int): Float { + if (component < defaults.size) { + return defaults[component] + } + return 0f + } + } + + data class Effect( + val file: String, + val name: String, + val label: String, + val description: String, + val error: String, + val techniques: List, + val uniforms: List + ) { + val valid: Boolean + get() = error.isEmpty() && techniques.isNotEmpty() + } + + data class ChainEntry(val file: String, val technique: String) + + data class Preset( + val name: String, + val description: String, + val bundled: Boolean + ) + + fun presets(): List { + val out = mutableListOf() + val array = JSONArray(getPresetsJson()) + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + out.add( + Preset( + name = obj.optString("name"), + description = obj.optString("description"), + bundled = obj.optBoolean("bundled") + ) + ) + } + return out + } + + fun catalog(): List { + val out = mutableListOf() + val array = JSONArray(getCatalogJson()) + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + out.add( + Effect( + file = obj.optString("file"), + name = obj.optString("name"), + label = obj.optString("label"), + description = obj.optString("description"), + error = obj.optString("error"), + techniques = obj.optJSONArray("techniques").toStringList(), + uniforms = obj.optJSONArray("uniforms").toUniformList() + ) + ) + } + return out + } + + fun chain(): List { + val out = mutableListOf() + val array = JSONArray(getChainJson()) + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + out.add(ChainEntry(obj.optString("file"), obj.optString("technique"))) + } + return out + } + + fun findEffect(file: String): Effect? = catalog().firstOrNull { it.file == file } + + private fun JSONArray?.toStringList(): List { + if (this == null) { + return emptyList() + } + val out = mutableListOf() + for (i in 0 until length()) { + out.add(optString(i)) + } + return out + } + + private fun JSONArray?.toFloatList(): List { + if (this == null) { + return emptyList() + } + val out = mutableListOf() + for (i in 0 until length()) { + out.add(optDouble(i, 0.0).toFloat()) + } + return out + } + + private fun JSONArray?.toUniformList(): List { + if (this == null) { + return emptyList() + } + val out = mutableListOf() + for (i in 0 until length()) { + val obj: JSONObject = optJSONObject(i) ?: continue + out.add( + Uniform( + name = obj.optString("name"), + label = obj.optString("label"), + tooltip = obj.optString("tooltip"), + category = obj.optString("category"), + kind = obj.optInt("kind", KIND_FLOAT), + uiType = obj.optInt("uiType", UI_HIDDEN), + components = obj.optInt("components", 1), + min = obj.optDouble("min", 0.0).toFloat(), + max = obj.optDouble("max", 1.0).toFloat(), + step = obj.optDouble("step", 0.01).toFloat(), + items = obj.optJSONArray("items").toStringList(), + defaults = obj.optJSONArray("defaults").toFloatList() + ) + ) + } + return out + } +} diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt index 86c05d7c0d..337d9fea1b 100644 --- a/src/android/app/src/main/jni/CMakeLists.txt +++ b/src/android/app/src/main/jni/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(yuzu-android SHARED android_config.cpp android_config.h native_input.cpp + native_post_processing.cpp ) set_property(TARGET yuzu-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR}) diff --git a/src/android/app/src/main/jni/native_config.cpp b/src/android/app/src/main/jni/native_config.cpp index 09a79ce4d7..1b8e343986 100644 --- a/src/android/app/src/main/jni/native_config.cpp +++ b/src/android/app/src/main/jni/native_config.cpp @@ -15,10 +15,22 @@ #include "frontend_common/config.h" #include "frontend_common/settings_generator.h" #include "native.h" +#ifdef HAS_RESHADE +#include "video_core/post_processing/fx_chain.h" +#endif std::unique_ptr global_config; std::unique_ptr per_game_config; +#ifdef HAS_RESHADE +static void ResetFxChainToGlobal() { + VideoCore::UseGlobalFxSettings(); + VideoCore::FxChain::Instance().LoadFromSettings(); +} +#else +static void ResetFxChainToGlobal() {} +#endif + template Settings::Setting* getSetting(JNIEnv* env, jstring jkey) { auto key = Common::Android::GetJString(env, jkey); @@ -39,6 +51,7 @@ extern "C" { void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_initializeGlobalConfig(JNIEnv* env, jobject obj) { global_config = std::make_unique(); FrontendCommon::GenerateSettings(); + ResetFxChainToGlobal(); } void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_unloadGlobalConfig(JNIEnv* env, jobject obj) { @@ -47,6 +60,7 @@ void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_unloadGlobalConfig(JNIEnv* env, void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_reloadGlobalConfig(JNIEnv* env, jobject obj) { global_config->AndroidConfig::ReloadAllValues(); + ResetFxChainToGlobal(); } void Java_org_yuzu_yuzu_1emu_utils_NativeConfig_saveGlobalConfig(JNIEnv* env, jobject obj) { diff --git a/src/android/app/src/main/jni/native_post_processing.cpp b/src/android/app/src/main/jni/native_post_processing.cpp new file mode 100644 index 0000000000..7fb04e7f9f --- /dev/null +++ b/src/android/app/src/main/jni/native_post_processing.cpp @@ -0,0 +1,350 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include +#include + +#include "common/android/android_common.h" +#ifdef HAS_RESHADE +#include "android_config.h" +#include "video_core/post_processing/fx_chain.h" +#include "common/settings.h" +#include "video_core/post_processing/fx_effect.h" +#include "video_core/post_processing/fx_preset.h" + +extern std::unique_ptr per_game_config; +#endif + +namespace { + +#ifdef HAS_RESHADE +bool EditingPerGame() { + return per_game_config != nullptr; +} + +void BeginFxEdit() { + if (!EditingPerGame()) { + return; + } + VideoCore::UsePerGameFxSettings(); +} + +nlohmann::json SerializeUniform(const VideoCore::FxUniformDesc& uniform) { + nlohmann::json out; + out["name"] = uniform.name; + out["label"] = uniform.label; + out["tooltip"] = uniform.tooltip; + out["category"] = uniform.category; + out["kind"] = static_cast(uniform.kind); + out["uiType"] = static_cast(uniform.ui_type); + out["components"] = uniform.components; + out["min"] = uniform.ui_min; + out["max"] = uniform.ui_max; + out["step"] = uniform.ui_step; + out["items"] = uniform.items; + + nlohmann::json defaults = nlohmann::json::array(); + for (u32 i = 0; i < uniform.components; ++i) { + defaults.push_back(uniform.default_value[i]); + } + out["defaults"] = defaults; + + return out; +} + +std::array DefaultValueOf(size_t index, const std::string& uniform) { + const auto entries = VideoCore::FxChain::Instance().Entries(); + if (index >= entries.size()) { + return {}; + } + const VideoCore::FxEffectDesc* effect = VideoCore::FindFxEffect(entries[index].file); + if (effect == nullptr) { + return {}; + } + const VideoCore::FxUniformDesc* desc = VideoCore::FindFxUniform(*effect, uniform); + if (desc == nullptr) { + return {}; + } + return desc->default_value; +} +#endif + +} // Anonymous namespace + +extern "C" { + +jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getCatalogJson(JNIEnv* env, + jobject obj) { + nlohmann::json out = nlohmann::json::array(); + +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().DropUnknownEntries(); + + for (const auto& effect : VideoCore::GetFxCatalog()) { + nlohmann::json entry; + entry["file"] = effect.file; + entry["name"] = effect.name; + entry["label"] = effect.label; + entry["description"] = effect.description; + entry["error"] = effect.error; + entry["techniques"] = effect.techniques; + + nlohmann::json uniforms = nlohmann::json::array(); + for (const auto& uniform : effect.uniforms) { + uniforms.push_back(SerializeUniform(uniform)); + } + entry["uniforms"] = uniforms; + + out.push_back(entry); + } +#endif + + return Common::Android::ToJString(env, out.dump()); +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getChainJson(JNIEnv* env, jobject obj) { + nlohmann::json out = nlohmann::json::array(); + +#ifdef HAS_RESHADE + for (const auto& entry : VideoCore::FxChain::Instance().Entries()) { + nlohmann::json item; + item["file"] = entry.file; + item["technique"] = entry.technique; + + nlohmann::json values = nlohmann::json::object(); + for (const auto& [name, value] : entry.values) { + values[name] = {value[0], value[1], value[2], value[3]}; + } + item["values"] = values; + + out.push_back(item); + } +#endif + + return Common::Android::ToJString(env, out.dump()); +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_append(JNIEnv* env, jobject obj, + jstring jfile, jstring jtechnique) { +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().Append(Common::Android::GetJString(env, jfile), + Common::Android::GetJString(env, jtechnique)); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_replace(JNIEnv* env, jobject obj, + jint index, jstring jfile, + jstring jtechnique) { +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().Replace(static_cast(index), + Common::Android::GetJString(env, jfile), + Common::Android::GetJString(env, jtechnique)); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_remove(JNIEnv* env, jobject obj, + jint index) { +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().Remove(static_cast(index)); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_move(JNIEnv* env, jobject obj, jint index, + jint delta) { +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().Move(static_cast(index), delta); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_resetValues(JNIEnv* env, jobject obj, + jint index) { +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().ResetValues(static_cast(index)); +#endif +} + +jfloat Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getValue(JNIEnv* env, jobject obj, + jint index, jstring juniform, + jint component) { +#ifdef HAS_RESHADE + if (component < 0 || component >= 4) { + return 0.0f; + } + const auto value = VideoCore::FxChain::Instance().GetValue( + static_cast(index), Common::Android::GetJString(env, juniform)); + return value[static_cast(component)]; +#else + return 0.0f; +#endif +} + +jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_hasValue(JNIEnv* env, jobject obj, + jint index, + jstring juniform) { +#ifdef HAS_RESHADE + return static_cast(VideoCore::FxChain::Instance().HasValue( + static_cast(index), Common::Android::GetJString(env, juniform))); +#else + return static_cast(false); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_setValue(JNIEnv* env, jobject obj, + jint index, jstring juniform, + jint component, jfloat value) { +#ifdef HAS_RESHADE + if (component < 0 || component >= 4) { + return; + } + const std::string uniform = Common::Android::GetJString(env, juniform); + auto& chain = VideoCore::FxChain::Instance(); + const auto slot = static_cast(index); + + auto current = chain.GetValue(slot, uniform); + if (!chain.HasValue(slot, uniform)) { + current = DefaultValueOf(slot, uniform); + } + + current[static_cast(component)] = value; + chain.SetValue(slot, uniform, current); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_store(JNIEnv* env, jobject obj) { +#ifdef HAS_RESHADE + BeginFxEdit(); + VideoCore::FxChain::Instance().StoreToSettings(); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_reload(JNIEnv* env, jobject obj) { +#ifdef HAS_RESHADE + if (!EditingPerGame()) { + VideoCore::UseGlobalFxSettings(); + } + VideoCore::FxChain::Instance().LoadFromSettings(); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_clearChain(JNIEnv* env, jobject obj) { +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().Clear(); +#endif +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getShaderDirectory(JNIEnv* env, + jobject obj) { +#ifdef HAS_RESHADE + return Common::Android::ToJString(env, VideoCore::GetFxRootDirectory().string()); +#else + return Common::Android::ToJString(env, ""); +#endif +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getPresetsJson(JNIEnv* env, + jobject obj) { + nlohmann::json out = nlohmann::json::array(); +#ifdef HAS_RESHADE + VideoCore::ReloadFxPresetCatalog(); + + for (const auto& preset : VideoCore::GetFxPresetCatalog()) { + nlohmann::json entry; + entry["name"] = preset.name; + entry["description"] = preset.description; + entry["bundled"] = preset.bundled; + out.push_back(entry); + } +#endif + return Common::Android::ToJString(env, out.dump()); +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getActivePreset(JNIEnv* env, + jobject obj) { +#ifdef HAS_RESHADE + return Common::Android::ToJString(env, VideoCore::GetActiveFxPreset()); +#else + return Common::Android::ToJString(env, ""); +#endif +} + +jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_isPresetModified(JNIEnv* env, + jobject obj) { +#ifdef HAS_RESHADE + return static_cast(VideoCore::IsActiveFxPresetModified()); +#else + return static_cast(false); +#endif +} + +jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_applyPreset(JNIEnv* env, jobject obj, + jstring jname) { +#ifdef HAS_RESHADE + BeginFxEdit(); + return static_cast( + VideoCore::ApplyFxPreset(Common::Android::GetJString(env, jname))); +#else + return static_cast(false); +#endif +} + +jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_savePreset(JNIEnv* env, jobject obj, + jstring jname, + jstring jdescription) { +#ifdef HAS_RESHADE + BeginFxEdit(); + return static_cast( + VideoCore::SaveFxPreset(Common::Android::GetJString(env, jname), + Common::Android::GetJString(env, jdescription))); +#else + return static_cast(false); +#endif +} + +jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_deletePreset(JNIEnv* env, jobject obj, + jstring jname) { +#ifdef HAS_RESHADE + BeginFxEdit(); + return static_cast( + VideoCore::DeleteFxPreset(Common::Android::GetJString(env, jname))); +#else + return static_cast(false); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_clearPreset(JNIEnv* env, jobject obj) { +#ifdef HAS_RESHADE + BeginFxEdit(); + VideoCore::SetActiveFxPreset(std::string_view()); +#endif +} + +jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_isEnabled(JNIEnv* env, jobject obj) { +#ifdef HAS_RESHADE + return static_cast(Settings::values.post_shader_enabled.GetValue()); +#else + return static_cast(false); +#endif +} + +void Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_setEnabled(JNIEnv* env, jobject obj, + jboolean enabled) { +#ifdef HAS_RESHADE + BeginFxEdit(); + Settings::values.post_shader_enabled.SetValue(enabled != JNI_FALSE); +#endif +} + +jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getPresetDirectory(JNIEnv* env, + jobject obj) { +#ifdef HAS_RESHADE + return Common::Android::ToJString(env, VideoCore::GetFxPresetDirectory().string()); +#else + return Common::Android::ToJString(env, ""); +#endif +} + +} // extern "C" diff --git a/src/android/app/src/main/res/drawable/ic_post_processing.xml b/src/android/app/src/main/res/drawable/ic_post_processing.xml new file mode 100644 index 0000000000..06ce58d40e --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_post_processing.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/shader_card_background.xml b/src/android/app/src/main/res/drawable/shader_card_background.xml new file mode 100644 index 0000000000..40e840695a --- /dev/null +++ b/src/android/app/src/main/res/drawable/shader_card_background.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_quick_settings_add.xml b/src/android/app/src/main/res/layout/item_quick_settings_add.xml new file mode 100644 index 0000000000..1af62f917b --- /dev/null +++ b/src/android/app/src/main/res/layout/item_quick_settings_add.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_quick_settings_preset.xml b/src/android/app/src/main/res/layout/item_quick_settings_preset.xml new file mode 100644 index 0000000000..935ce4d45d --- /dev/null +++ b/src/android/app/src/main/res/layout/item_quick_settings_preset.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_quick_settings_shader.xml b/src/android/app/src/main/res/layout/item_quick_settings_shader.xml new file mode 100644 index 0000000000..dac3bb4de8 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_quick_settings_shader.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_setting_fx_actions.xml b/src/android/app/src/main/res/layout/item_setting_fx_actions.xml new file mode 100644 index 0000000000..c7df946416 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_setting_fx_actions.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/src/android/app/src/main/res/layout/item_setting_fx_slider.xml b/src/android/app/src/main/res/layout/item_setting_fx_slider.xml new file mode 100644 index 0000000000..1e2989195f --- /dev/null +++ b/src/android/app/src/main/res/layout/item_setting_fx_slider.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/list_item_setting_fx_button.xml b/src/android/app/src/main/res/layout/list_item_setting_fx_button.xml new file mode 100644 index 0000000000..8d207888cb --- /dev/null +++ b/src/android/app/src/main/res/layout/list_item_setting_fx_button.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/android/app/src/main/res/layout/list_item_setting_fx_preset.xml b/src/android/app/src/main/res/layout/list_item_setting_fx_preset.xml new file mode 100644 index 0000000000..a212f67f4b --- /dev/null +++ b/src/android/app/src/main/res/layout/list_item_setting_fx_preset.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/list_item_setting_fx_shader.xml b/src/android/app/src/main/res/layout/list_item_setting_fx_shader.xml new file mode 100644 index 0000000000..e6f7b12eb4 --- /dev/null +++ b/src/android/app/src/main/res/layout/list_item_setting_fx_shader.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/list_item_setting_fx_toolbar.xml b/src/android/app/src/main/res/layout/list_item_setting_fx_toolbar.xml new file mode 100644 index 0000000000..074c5a0662 --- /dev/null +++ b/src/android/app/src/main/res/layout/list_item_setting_fx_toolbar.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index f073000380..a9200bbca9 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -298,6 +298,25 @@ GPU driver fetcher GPU driver manager Install alternative drivers for potentially better performance or accuracy + Post-Processing Effects + ReShade FX effects applied after rendering + Configure the effect chain for this game + Add effect + Remove + Open list + Close list + Remove effects + Preset active. Edit it in Post-Processing Effects before starting a game. + Values changed from the original preset. + Presets + New preset + Saves the effects you have loaded, with their current values, as a preset you can pick later. + Give the preset a name without an equals sign. + No preset + Delete preset + Reset preset values + Reset to default + No effects found. Place .fx files in this folder: Frame generation Configure frame generation for this game Insert interpolated frames between rendered ones using Lossless Scaling. Forces FIFO presentation while enabled. diff --git a/src/common/fs/fs_paths.h b/src/common/fs/fs_paths.h index a31e609bda..ae25846fc2 100644 --- a/src/common/fs/fs_paths.h +++ b/src/common/fs/fs_paths.h @@ -23,6 +23,8 @@ #define LOSSLESS_DIR "lossless" #define NAND_DIR "nand" #define PLAY_TIME_DIR "play_time" +#define POST_PRESET_DIR "post_presets" +#define POST_SHADER_DIR "post_shaders" #define SCREENSHOTS_DIR "screenshots" #define SDMC_DIR "sdmc" #define SHADER_DIR "shader" diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp index b4c7eae343..b895aac54c 100644 --- a/src/common/fs/path_util.cpp +++ b/src/common/fs/path_util.cpp @@ -160,6 +160,8 @@ public: GenerateEdenPath(EdenPath::LosslessDir, eden_path / LOSSLESS_DIR); GenerateEdenPath(EdenPath::NANDDir, eden_path / NAND_DIR); GenerateEdenPath(EdenPath::PlayTimeDir, eden_path / PLAY_TIME_DIR); + GenerateEdenPath(EdenPath::PostPresetDir, eden_path / POST_PRESET_DIR); + GenerateEdenPath(EdenPath::PostShaderDir, eden_path / POST_SHADER_DIR); GenerateEdenPath(EdenPath::SaveDir, eden_path / NAND_DIR); GenerateEdenPath(EdenPath::ScreenshotsDir, eden_path / SCREENSHOTS_DIR); GenerateEdenPath(EdenPath::SDMCDir, eden_path / SDMC_DIR); diff --git a/src/common/fs/path_util.h b/src/common/fs/path_util.h index febed9c388..983e4917e8 100644 --- a/src/common/fs/path_util.h +++ b/src/common/fs/path_util.h @@ -26,6 +26,8 @@ enum class EdenPath { LosslessDir, // Where the user-supplied Lossless Scaling library is stored. NANDDir, // Where the emulated NAND is stored. PlayTimeDir, // Where play time data is stored. + PostPresetDir, + PostShaderDir, // Where user post-processing shaders are stored. SaveDir, // Where save data is stored. ScreenshotsDir, // Where yuzu screenshots are stored. SDMCDir, // Where the emulated SDMC is stored. diff --git a/src/common/settings.h b/src/common/settings.h index d45f0b7bbc..6b60d633a5 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -388,6 +388,30 @@ struct Values { true, true}; + SwitchableSetting post_shader_chain{linkage, + std::string(), + "post_shader_chain", + Category::Renderer, + Specialization::Default, + true, + true}; + + SwitchableSetting post_shader_preset{linkage, + std::string(), + "post_shader_preset", + Category::Renderer, + Specialization::Default, + true, + true}; + + SwitchableSetting post_shader_enabled{linkage, + true, + "post_shader_enabled", + Category::Renderer, + Specialization::Default, + true, + true}; + SwitchableSetting frame_gen{linkage, false, "frame_gen", Category::Renderer, Specialization::Default, true, false}; diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index 7fd55ce038..be2f986eaf 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -150,6 +150,7 @@ std::unique_ptr InitializeTranslations(QObject* parent) { INSERT(Settings, anti_aliasing, tr("Anti-Aliasing Method:"), tr("The anti-aliasing method to use.\nSMAA offers the best quality.\nFXAA " "can produce a more stable picture in lower resolutions.")); + INSERT(Settings, post_shader_chain, QString(), QString()); INSERT(Settings, fullscreen_mode, tr("Fullscreen Mode:"), tr("The method used to render the window in fullscreen.\nBorderless offers the best " "compatibility with the on-screen keyboard that some games request for " diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index 14bd13b8af..64d14ab2a6 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -302,6 +302,50 @@ if (ENABLE_LSFG) target_compile_definitions(video_core PUBLIC HAS_LSFG) endif() +if (ENABLE_RESHADE) + set(BUNDLED_FX_DIR ${CMAKE_SOURCE_DIR}/dist/post_shaders) + set(BUNDLED_FX_HEADER ${CMAKE_CURRENT_BINARY_DIR}/bundled_fx_effects.h) + file(GLOB BUNDLED_FX_FILES ${BUNDLED_FX_DIR}/*.fx) + + add_custom_command( + OUTPUT ${BUNDLED_FX_HEADER} + COMMAND ${CMAKE_COMMAND} -P + ${CMAKE_CURRENT_SOURCE_DIR}/post_processing/GenerateBundledEffects.cmake + ${BUNDLED_FX_DIR} ${BUNDLED_FX_HEADER} + DEPENDS ${BUNDLED_FX_FILES} + ) + + set(BUNDLED_PRESET_DIR ${CMAKE_SOURCE_DIR}/dist/post_presets) + set(BUNDLED_PRESET_HEADER ${CMAKE_CURRENT_BINARY_DIR}/bundled_fx_presets.h) + file(GLOB BUNDLED_PRESET_FILES ${BUNDLED_PRESET_DIR}/*.fxp) + + add_custom_command( + OUTPUT ${BUNDLED_PRESET_HEADER} + COMMAND ${CMAKE_COMMAND} -P + ${CMAKE_CURRENT_SOURCE_DIR}/post_processing/GenerateBundledPresets.cmake + ${BUNDLED_PRESET_DIR} ${BUNDLED_PRESET_HEADER} + DEPENDS ${BUNDLED_PRESET_FILES} + ) + + target_sources(video_core PRIVATE + post_processing/fx_chain.cpp + post_processing/fx_chain.h + post_processing/fx_compile.cpp + post_processing/fx_compile.h + post_processing/fx_effect.cpp + post_processing/fx_effect.h + post_processing/fx_preset.cpp + post_processing/fx_preset.h + renderer_vulkan/present/post_process.cpp + renderer_vulkan/present/post_process.h + ${BUNDLED_FX_HEADER} + ${BUNDLED_PRESET_HEADER} + ) + target_include_directories(video_core PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + target_link_libraries(video_core PRIVATE reshadefx::reshadefx) + target_compile_definitions(video_core PUBLIC HAS_RESHADE) +endif() + if (ENABLE_OPENGL) target_sources(video_core PRIVATE renderer_opengl/present/filters.cpp diff --git a/src/video_core/post_processing/GenerateBundledEffects.cmake b/src/video_core/post_processing/GenerateBundledEffects.cmake new file mode 100644 index 0000000000..d3aca02571 --- /dev/null +++ b/src/video_core/post_processing/GenerateBundledEffects.cmake @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +set(EFFECT_DIR ${CMAKE_ARGV3}) +set(HEADER_FILE ${CMAKE_ARGV4}) + +file(GLOB EFFECT_FILES ${EFFECT_DIR}/*.fx) +list(SORT EFFECT_FILES) + +set(ENTRIES "") +foreach(EFFECT_FILE IN LISTS EFFECT_FILES) + get_filename_component(EFFECT_NAME ${EFFECT_FILE} NAME) + file(READ ${EFFECT_FILE} EFFECT_BODY) + + string(REGEX REPLACE ";" "{{SEMICOLON}}" EFFECT_BODY "${EFFECT_BODY}") + string(REGEX REPLACE "\n" ";" EFFECT_BODY "${EFFECT_BODY}") + + set(EFFECT_TEXT "") + foreach(LINE IN LISTS EFFECT_BODY) + string(CONCAT EFFECT_TEXT "${EFFECT_TEXT}" " R\"(${LINE}\n)\"\n") + endforeach() + string(REGEX REPLACE "{{SEMICOLON}}" ";" EFFECT_TEXT "${EFFECT_TEXT}") + + string(CONCAT ENTRIES "${ENTRIES}" + " {\n \"${EFFECT_NAME}\",\n${EFFECT_TEXT} },\n") +endforeach() + +get_filename_component(OUTPUT_DIR ${HEADER_FILE} DIRECTORY) +make_directory(${OUTPUT_DIR}) + +file(WRITE ${HEADER_FILE} +"// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +namespace VideoCore { + +struct BundledFxEffect { + std::string_view name; + std::string_view source; +}; + +constexpr BundledFxEffect BUNDLED_FX_EFFECTS[]{ +${ENTRIES}}; + +} // namespace VideoCore +") diff --git a/src/video_core/post_processing/GenerateBundledPresets.cmake b/src/video_core/post_processing/GenerateBundledPresets.cmake new file mode 100644 index 0000000000..369a46748d --- /dev/null +++ b/src/video_core/post_processing/GenerateBundledPresets.cmake @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +set(PRESET_DIR ${CMAKE_ARGV3}) +set(HEADER_FILE ${CMAKE_ARGV4}) + +file(GLOB PRESET_FILES ${PRESET_DIR}/*.fxp) +list(SORT PRESET_FILES) + +set(ENTRIES "") +foreach(PRESET_FILE IN LISTS PRESET_FILES) + get_filename_component(PRESET_NAME ${PRESET_FILE} NAME) + file(READ ${PRESET_FILE} PRESET_BODY) + + string(REGEX REPLACE ";" "{{SEMICOLON}}" PRESET_BODY "${PRESET_BODY}") + string(REGEX REPLACE "\n" ";" PRESET_BODY "${PRESET_BODY}") + + set(PRESET_TEXT "") + foreach(LINE IN LISTS PRESET_BODY) + string(CONCAT PRESET_TEXT "${PRESET_TEXT}" " R\"(${LINE}\n)\"\n") + endforeach() + string(REGEX REPLACE "{{SEMICOLON}}" ";" PRESET_TEXT "${PRESET_TEXT}") + + string(CONCAT ENTRIES "${ENTRIES}" + " {\n \"${PRESET_NAME}\",\n${PRESET_TEXT} },\n") +endforeach() + +get_filename_component(OUTPUT_DIR ${HEADER_FILE} DIRECTORY) +make_directory(${OUTPUT_DIR}) + +file(WRITE ${HEADER_FILE} +"// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +namespace VideoCore { + +struct BundledFxPreset { + std::string_view name; + std::string_view source; +}; + +constexpr BundledFxPreset BUNDLED_FX_PRESETS[]{ +${ENTRIES}}; + +} // namespace VideoCore +") diff --git a/src/video_core/post_processing/fx_chain.cpp b/src/video_core/post_processing/fx_chain.cpp new file mode 100644 index 0000000000..882fdaf726 --- /dev/null +++ b/src/video_core/post_processing/fx_chain.cpp @@ -0,0 +1,321 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include + +#include "common/settings.h" +#include "video_core/post_processing/fx_chain.h" +#include "video_core/post_processing/fx_effect.h" + +namespace VideoCore { + +namespace { + +std::vector Split(std::string_view value, char separator) { + std::vector out; + size_t start = 0; + while (start <= value.size()) { + size_t end = value.find(separator, start); + if (end == std::string_view::npos) { + end = value.size(); + } + out.push_back(value.substr(start, end - start)); + start = end + 1; + } + return out; +} + +bool IsSerializableName(std::string_view value) { + return value.find_first_of(";|,=") == std::string_view::npos; +} + +} // Anonymous namespace + +std::vector ParseFxChain(std::string_view value) { + std::vector parsed; + + for (const std::string_view record : Split(value, ';')) { + if (record.empty()) { + continue; + } + + const auto fields = Split(record, '|'); + if (fields.size() < 2 || fields[0].empty() || fields[1].empty()) { + continue; + } + + FxChainEntry entry; + entry.file = std::string(fields[0]); + entry.technique = std::string(fields[1]); + + if (fields.size() >= 3) { + for (const std::string_view assignment : Split(fields[2], ',')) { + const size_t equals = assignment.find('='); + if (equals == std::string_view::npos) { + continue; + } + const std::string name(assignment.substr(0, equals)); + if (name.empty()) { + continue; + } + + std::array components{}; + size_t index = 0; + for (const std::string_view piece : Split(assignment.substr(equals + 1), '/')) { + if (index >= components.size()) { + break; + } + const std::string text(piece); + if (!text.empty()) { + components[index] = std::strtof(text.c_str(), nullptr); + } + ++index; + } + entry.values.emplace(name, components); + } + } + + parsed.push_back(std::move(entry)); + } + + return parsed; +} + +std::string SerializeFxChain(std::span entries) { + std::string out; + + for (const auto& entry : entries) { + if (!IsSerializableName(entry.file) || !IsSerializableName(entry.technique)) { + continue; + } + if (!out.empty()) { + out += ';'; + } + out += fmt::format("{}|{}|", entry.file, entry.technique); + + bool first = true; + for (const auto& [name, value] : entry.values) { + if (!IsSerializableName(name)) { + continue; + } + if (!first) { + out += ','; + } + first = false; + out += name; + out += '='; + for (size_t i = 0; i < value.size(); ++i) { + if (i > 0) { + out += '/'; + } + out += fmt::format("{}", value[i]); + } + } + } + + return out; +} + +void UseGlobalFxSettings() { + Settings::values.post_shader_chain.SetGlobal(true); + Settings::values.post_shader_preset.SetGlobal(true); + Settings::values.post_shader_enabled.SetGlobal(true); +} + +void UsePerGameFxSettings() { + const std::string chain = Settings::values.post_shader_chain.GetValue(); + const std::string preset = Settings::values.post_shader_preset.GetValue(); + const bool enabled = Settings::values.post_shader_enabled.GetValue(); + + Settings::values.post_shader_chain.SetGlobal(false); + Settings::values.post_shader_preset.SetGlobal(false); + Settings::values.post_shader_enabled.SetGlobal(false); + + Settings::values.post_shader_chain.SetValue(chain); + Settings::values.post_shader_preset.SetValue(preset); + Settings::values.post_shader_enabled.SetValue(enabled); +} + +FxChain& FxChain::Instance() { + static FxChain instance; + return instance; +} + +FxChainSnapshot FxChain::Snapshot() const { + std::scoped_lock lock{mutex}; + return FxChainSnapshot{ + .entries = entries, + .generation = generation.load(std::memory_order_relaxed), + }; +} + +std::vector FxChain::Entries() const { + std::scoped_lock lock{mutex}; + return entries; +} + +size_t FxChain::Size() const { + std::scoped_lock lock{mutex}; + return entries.size(); +} + +void FxChain::Append(std::string_view file, std::string_view technique) { + { + std::scoped_lock lock{mutex}; + FxChainEntry entry; + entry.file = std::string(file); + entry.technique = std::string(technique); + entries.push_back(std::move(entry)); + } + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::Replace(size_t index, std::string_view file, std::string_view technique) { + { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return; + } + if (entries[index].file == file && entries[index].technique == technique) { + return; + } + FxChainEntry entry; + entry.file = std::string(file); + entry.technique = std::string(technique); + entries[index] = std::move(entry); + } + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::Remove(size_t index) { + { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return; + } + entries.erase(entries.begin() + static_cast(index)); + } + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::Move(size_t index, int delta) { + { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return; + } + const std::ptrdiff_t target = static_cast(index) + delta; + if (target < 0 || target >= static_cast(entries.size())) { + return; + } + std::swap(entries[index], entries[static_cast(target)]); + } + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::Clear() { + { + std::scoped_lock lock{mutex}; + entries.clear(); + } + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::SetEntries(std::vector next) { + { + std::scoped_lock lock{mutex}; + entries = std::move(next); + } + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::SetValue(size_t index, std::string_view uniform, const std::array& value) { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return; + } + entries[index].values[std::string(uniform)] = value; +} + +std::array FxChain::GetValue(size_t index, std::string_view uniform) const { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return {}; + } + const auto it = entries[index].values.find(std::string(uniform)); + if (it == entries[index].values.end()) { + return {}; + } + return it->second; +} + +std::map> FxChain::EntryValues(size_t index) const { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return {}; + } + return entries[index].values; +} + +bool FxChain::HasValue(size_t index, std::string_view uniform) const { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return false; + } + return entries[index].values.contains(std::string(uniform)); +} + +void FxChain::ResetValues(size_t index) { + std::scoped_lock lock{mutex}; + if (index >= entries.size()) { + return; + } + entries[index].values.clear(); +} + +void FxChain::LoadFromSettings() { + auto parsed = ParseFxChain(Settings::values.post_shader_chain.GetValue()); + + std::scoped_lock lock{mutex}; + if (entries == parsed) { + return; + } + entries = std::move(parsed); + generation.fetch_add(1, std::memory_order_relaxed); +} + +void FxChain::StoreToSettings() const { + std::string serialized; + { + std::scoped_lock lock{mutex}; + serialized = SerializeFxChain(entries); + } + Settings::values.post_shader_chain.SetValue(serialized); +} + +void FxChain::DropUnknownEntries() { + bool changed = false; + { + std::scoped_lock lock{mutex}; + const auto removed = std::remove_if(entries.begin(), entries.end(), [](const FxChainEntry& entry) { + const FxEffectDesc* effect = FindFxEffect(entry.file); + if (effect == nullptr || !effect->Valid()) { + return true; + } + return std::find(effect->techniques.begin(), effect->techniques.end(), + entry.technique) == effect->techniques.end(); + }); + if (removed != entries.end()) { + entries.erase(removed, entries.end()); + changed = true; + } + } + if (changed) { + generation.fetch_add(1, std::memory_order_relaxed); + } +} + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_chain.h b/src/video_core/post_processing/fx_chain.h new file mode 100644 index 0000000000..066f7e428d --- /dev/null +++ b/src/video_core/post_processing/fx_chain.h @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +namespace VideoCore { + +struct FxChainEntry { + std::string file; + std::string technique; + std::map> values; + + bool operator==(const FxChainEntry&) const = default; +}; + +struct FxChainSnapshot { + std::vector entries; + u64 generation{}; +}; + +std::vector ParseFxChain(std::string_view value); + +std::string SerializeFxChain(std::span entries); + +void UseGlobalFxSettings(); + +void UsePerGameFxSettings(); + +class FxChain { +public: + static FxChain& Instance(); + + FxChainSnapshot Snapshot() const; + + std::vector Entries() const; + + size_t Size() const; + + void Append(std::string_view file, std::string_view technique); + + void Replace(size_t index, std::string_view file, std::string_view technique); + + void Remove(size_t index); + + void Move(size_t index, int delta); + + void Clear(); + + void SetEntries(std::vector next); + + void SetValue(size_t index, std::string_view uniform, const std::array& value); + + std::array GetValue(size_t index, std::string_view uniform) const; + + std::map> EntryValues(size_t index) const; + + bool HasValue(size_t index, std::string_view uniform) const; + + void ResetValues(size_t index); + + void LoadFromSettings(); + + void StoreToSettings() const; + + void DropUnknownEntries(); + +private: + FxChain() = default; + + mutable std::mutex mutex; + std::vector entries; + std::atomic generation{1}; +}; + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_compile.cpp b/src/video_core/post_processing/fx_compile.cpp new file mode 100644 index 0000000000..5ee11e9187 --- /dev/null +++ b/src/video_core/post_processing/fx_compile.cpp @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include "effect_codegen.hpp" +#include "effect_parser.hpp" +#include "effect_preprocessor.hpp" + +#include "common/fs/fs.h" +#include "common/fs/fs_util.h" +#include "video_core/post_processing/fx_compile.h" +#include "video_core/post_processing/fx_effect.h" + +namespace VideoCore { + +FxCompileResult CompileFxEffect(const std::filesystem::path& path, u32 width, u32 height, + u32 color_bit_depth) { + FxCompileResult result; + + if (!Common::FS::Exists(path)) { + result.error = "Effect file not found: " + Common::FS::PathToUTF8String(path); + return result; + } + + reshadefx::preprocessor preprocessor; + preprocessor.add_macro_definition("__RESHADE__", "60800"); + preprocessor.add_macro_definition("__RESHADE_PERFORMANCE_MODE__", "1"); + preprocessor.add_macro_definition("__RENDERER__", "0x20000"); + preprocessor.add_macro_definition("__VENDOR__", "0"); + preprocessor.add_macro_definition("__DEVICE__", "0"); + preprocessor.add_macro_definition("__APPLICATION__", "0"); + preprocessor.add_macro_definition("BUFFER_WIDTH", std::to_string(width)); + preprocessor.add_macro_definition("BUFFER_HEIGHT", std::to_string(height)); + preprocessor.add_macro_definition("BUFFER_RCP_WIDTH", "(1.0 / BUFFER_WIDTH)"); + preprocessor.add_macro_definition("BUFFER_RCP_HEIGHT", "(1.0 / BUFFER_HEIGHT)"); + preprocessor.add_macro_definition("BUFFER_COLOR_DEPTH", std::to_string(color_bit_depth)); + preprocessor.add_macro_definition("BUFFER_COLOR_BIT_DEPTH", std::to_string(color_bit_depth)); + + for (const auto& include : GetFxIncludePaths(path)) { + preprocessor.add_include_path(include); + } + + if (!preprocessor.append_file(path)) { + result.error = preprocessor.errors(); + if (result.error.empty()) { + result.error = "Failed to preprocess " + Common::FS::PathToUTF8String(path); + } + return result; + } + + std::unique_ptr backend( + reshadefx::create_codegen_spirv(true, false, false, false, true)); + + reshadefx::parser parser; + if (!parser.parse(preprocessor.output(), backend.get())) { + result.error = parser.errors(); + if (result.error.empty()) { + result.error = "Failed to parse " + Common::FS::PathToUTF8String(path); + } + return result; + } + + result.module = backend->module(); + + std::set wanted; + for (const auto& technique : result.module.techniques) { + for (const auto& pass : technique.passes) { + if (!pass.vs_entry_point.empty()) { + wanted.insert(pass.vs_entry_point); + } + if (!pass.ps_entry_point.empty()) { + wanted.insert(pass.ps_entry_point); + } + } + } + + for (const auto& name : wanted) { + std::string binary; + std::string assembly; + std::string errors; + if (!backend->assemble_code_for_entry_point(name, binary, assembly, errors)) { + result.error = "Failed to assemble entry point '" + name + "': " + errors; + return result; + } + if (binary.size() % sizeof(u32) != 0) { + result.error = "Entry point '" + name + "' produced a malformed SPIR-V module"; + return result; + } + + std::vector words(binary.size() / sizeof(u32)); + std::memcpy(words.data(), binary.data(), binary.size()); + result.entry_points.emplace(name, std::move(words)); + } + + if (result.entry_points.empty()) { + result.error = "Effect declares no usable entry points"; + } + + return result; +} + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_compile.h b/src/video_core/post_processing/fx_compile.h new file mode 100644 index 0000000000..f4b70f3a2c --- /dev/null +++ b/src/video_core/post_processing/fx_compile.h @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "common/common_types.h" +#include "effect_module.hpp" + +namespace VideoCore { + +struct FxCompileResult { + reshadefx::effect_module module; + std::map> entry_points; + std::string error; + + bool Succeeded() const { + return error.empty() && !entry_points.empty(); + } +}; + +FxCompileResult CompileFxEffect(const std::filesystem::path& path, u32 width, u32 height, + u32 color_bit_depth); + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_effect.cpp b/src/video_core/post_processing/fx_effect.cpp new file mode 100644 index 0000000000..7bb36b0467 --- /dev/null +++ b/src/video_core/post_processing/fx_effect.cpp @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "bundled_fx_effects.h" +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/fs_util.h" +#include "common/fs/path_util.h" +#include "common/logging.h" +#include "video_core/post_processing/fx_compile.h" +#include "video_core/post_processing/fx_effect.h" + +namespace VideoCore { + +namespace { + +constexpr u32 CATALOG_PROBE_WIDTH = 1280; +constexpr u32 CATALOG_PROBE_HEIGHT = 720; +constexpr u32 CATALOG_PROBE_DEPTH = 8; + +std::vector catalog; +bool catalog_scanned = false; + +const reshadefx::annotation* FindAnnotation(const std::vector& annotations, + std::string_view name) { + const auto it = std::find_if(annotations.begin(), annotations.end(), + [&](const reshadefx::annotation& a) { return a.name == name; }); + if (it == annotations.end()) { + return nullptr; + } + return &*it; +} + +std::string AnnotationString(const std::vector& annotations, + std::string_view name) { + const reshadefx::annotation* a = FindAnnotation(annotations, name); + if (a == nullptr) { + return std::string(); + } + return a->value.string_data; +} + +bool AnnotationFloat(const std::vector& annotations, std::string_view name, + f32& out) { + const reshadefx::annotation* a = FindAnnotation(annotations, name); + if (a == nullptr) { + return false; + } + if (a->type.is_floating_point()) { + out = a->value.as_float[0]; + return true; + } + if (a->type.is_integral()) { + out = static_cast(a->value.as_int[0]); + return true; + } + return false; +} + +FxUiType ParseUiType(std::string_view value) { + if (value == "slider") { + return FxUiType::Slider; + } + if (value == "drag") { + return FxUiType::Drag; + } + if (value == "combo") { + return FxUiType::Combo; + } + if (value == "radio") { + return FxUiType::Radio; + } + if (value == "check" || value == "checkbox") { + return FxUiType::CheckBox; + } + if (value == "color") { + return FxUiType::Color; + } + if (value == "input") { + return FxUiType::InputBox; + } + return FxUiType::Hidden; +} + +std::vector SplitItems(const std::string& items) { + std::vector out; + std::string current; + for (const char c : items) { + if (c == '\0') { + out.push_back(current); + current.clear(); + continue; + } + current += c; + } + if (!current.empty()) { + out.push_back(current); + } + return out; +} + +FxUniformDesc DescribeUniform(const reshadefx::uniform& info) { + FxUniformDesc desc; + desc.name = info.name; + desc.components = std::min(info.type.components(), 4); + + if (info.type.is_boolean()) { + desc.kind = FxUniformKind::Boolean; + } else if (info.type.is_integral()) { + desc.kind = FxUniformKind::Integer; + } else { + desc.kind = FxUniformKind::Floating; + } + + desc.label = AnnotationString(info.annotations, "ui_label"); + if (desc.label.empty()) { + desc.label = info.name; + } + desc.tooltip = AnnotationString(info.annotations, "ui_tooltip"); + desc.category = AnnotationString(info.annotations, "ui_category"); + desc.ui_type = ParseUiType(AnnotationString(info.annotations, "ui_type")); + desc.items = SplitItems(AnnotationString(info.annotations, "ui_items")); + + if (desc.kind == FxUniformKind::Boolean) { + desc.ui_min = 0.0f; + desc.ui_max = 1.0f; + desc.ui_step = 1.0f; + } else if (desc.kind == FxUniformKind::Integer) { + desc.ui_min = 0.0f; + desc.ui_max = 100.0f; + desc.ui_step = 1.0f; + } + + void(AnnotationFloat(info.annotations, "ui_min", desc.ui_min)); + void(AnnotationFloat(info.annotations, "ui_max", desc.ui_max)); + void(AnnotationFloat(info.annotations, "ui_step", desc.ui_step)); + + if (desc.ui_step <= 0.0f) { + desc.ui_step = 0.01f; + if (desc.kind != FxUniformKind::Floating) { + desc.ui_step = 1.0f; + } + } + if (desc.ui_max < desc.ui_min) { + std::swap(desc.ui_min, desc.ui_max); + } + + if (info.has_initializer_value) { + for (u32 i = 0; i < desc.components; ++i) { + if (desc.kind == FxUniformKind::Floating) { + desc.default_value[i] = info.initializer_value.as_float[i]; + } else { + desc.default_value[i] = static_cast(info.initializer_value.as_int[i]); + } + } + } + + return desc; +} + +FxEffectDesc DescribeEffect(const std::filesystem::path& path, const std::filesystem::path& root) { + FxEffectDesc desc; + desc.file = Common::FS::PathToUTF8String(std::filesystem::relative(path, root)); + desc.name = Common::FS::PathToUTF8String(path.stem()); + + const auto compiled = + CompileFxEffect(path, CATALOG_PROBE_WIDTH, CATALOG_PROBE_HEIGHT, CATALOG_PROBE_DEPTH); + if (!compiled.Succeeded()) { + desc.error = compiled.error; + return desc; + } + + for (const auto& technique : compiled.module.techniques) { + desc.techniques.push_back(technique.name); + } + + if (!compiled.module.techniques.empty()) { + const auto& annotations = compiled.module.techniques.front().annotations; + desc.label = AnnotationString(annotations, "ui_label"); + desc.description = AnnotationString(annotations, "ui_tooltip"); + } + if (desc.label.empty()) { + desc.label = desc.name; + } + + for (const auto& uniform : compiled.module.uniforms) { + FxUniformDesc uniform_desc = DescribeUniform(uniform); + if (uniform_desc.ui_type == FxUiType::Hidden) { + continue; + } + desc.uniforms.push_back(std::move(uniform_desc)); + } + + return desc; +} + +} // Anonymous namespace + +std::filesystem::path GetFxRootDirectory() { + return Common::FS::GetEdenPath(Common::FS::EdenPath::PostShaderDir); +} + +std::vector GetFxIncludePaths(const std::filesystem::path& effect_path) { + const auto root = GetFxRootDirectory(); + std::vector paths; + paths.push_back(effect_path.parent_path()); + paths.push_back(root); + paths.push_back(root / "Shaders"); + + const auto last = std::unique(paths.begin(), paths.end()); + paths.erase(last, paths.end()); + return paths; +} + +std::filesystem::path ResolveFxTexturePath(const std::filesystem::path& effect_path, + std::string_view source) { + const auto root = GetFxRootDirectory(); + const std::filesystem::path name{source}; + + const std::array candidates{ + effect_path.parent_path() / name, + root / "Textures" / name, + root / name, + }; + + for (const auto& candidate : candidates) { + if (Common::FS::Exists(candidate)) { + return candidate; + } + } + + return std::filesystem::path(); +} + +void ReloadFxCatalog() { + catalog.clear(); + catalog_scanned = true; + + const auto root = GetFxRootDirectory(); + if (!Common::FS::Exists(root) && !Common::FS::CreateDirs(root)) { + return; + } + + for (const auto& bundled : BUNDLED_FX_EFFECTS) { + const auto path = root / bundled.name; + if (Common::FS::Exists(path)) { + continue; + } + void(Common::FS::WriteStringToFile(path, Common::FS::FileType::TextFile, bundled.source)); + } + + std::vector effect_files; + Common::FS::IterateDirEntriesRecursively( + root, + [&](const std::filesystem::directory_entry& entry) { + if (entry.path().extension() == ".fx") { + effect_files.push_back(entry.path()); + } + return true; + }, + Common::FS::DirEntryFilter::File); + + std::sort(effect_files.begin(), effect_files.end()); + + for (const auto& file : effect_files) { + FxEffectDesc desc = DescribeEffect(file, root); + if (!desc.error.empty()) { + LOG_WARNING(Render, "Post-processing effect '{}' failed to compile:\n{}", desc.file, + desc.error); + } + catalog.push_back(std::move(desc)); + } + + const size_t usable = std::count_if(catalog.begin(), catalog.end(), + [](const FxEffectDesc& d) { return d.Valid(); }); + LOG_INFO(Render, "Loaded {} post-processing effects ({} usable)", catalog.size(), usable); +} + +const std::vector& GetFxCatalog() { + if (!catalog_scanned) { + ReloadFxCatalog(); + } + return catalog; +} + +const FxEffectDesc* FindFxEffect(std::string_view file) { + const auto& effects = GetFxCatalog(); + const auto it = std::find_if(effects.begin(), effects.end(), + [&](const FxEffectDesc& d) { return d.file == file; }); + if (it == effects.end()) { + return nullptr; + } + return &*it; +} + +const FxUniformDesc* FindFxUniform(const FxEffectDesc& effect, std::string_view name) { + const auto it = std::find_if(effect.uniforms.begin(), effect.uniforms.end(), + [&](const FxUniformDesc& u) { return u.name == name; }); + if (it == effect.uniforms.end()) { + return nullptr; + } + return &*it; +} + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_effect.h b/src/video_core/post_processing/fx_effect.h new file mode 100644 index 0000000000..bedf68809a --- /dev/null +++ b/src/video_core/post_processing/fx_effect.h @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +namespace VideoCore { + +enum class FxUniformKind { + Boolean, + Integer, + Floating, +}; + +enum class FxUiType { + Hidden, + Slider, + Drag, + Combo, + Radio, + CheckBox, + Color, + InputBox, +}; + +struct FxUniformDesc { + std::string name; + std::string label; + std::string tooltip; + std::string category; + FxUniformKind kind{FxUniformKind::Floating}; + u32 components{1}; + FxUiType ui_type{FxUiType::Hidden}; + f32 ui_min{0.0f}; + f32 ui_max{1.0f}; + f32 ui_step{0.01f}; + std::vector items; + std::array default_value{}; +}; + +struct FxEffectDesc { + std::string file; + std::string name; + std::string label; + std::string description; + std::vector techniques; + std::vector uniforms; + std::string error; + + bool Valid() const { + return error.empty() && !techniques.empty(); + } +}; + +std::filesystem::path GetFxRootDirectory(); + +std::vector GetFxIncludePaths(const std::filesystem::path& effect_path); + +std::filesystem::path ResolveFxTexturePath(const std::filesystem::path& effect_path, + std::string_view source); + +void ReloadFxCatalog(); + +const std::vector& GetFxCatalog(); + +const FxEffectDesc* FindFxEffect(std::string_view file); + +const FxUniformDesc* FindFxUniform(const FxEffectDesc& effect, std::string_view name); + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_preset.cpp b/src/video_core/post_processing/fx_preset.cpp new file mode 100644 index 0000000000..bd66a7d807 --- /dev/null +++ b/src/video_core/post_processing/fx_preset.cpp @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "bundled_fx_presets.h" +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "video_core/post_processing/fx_chain.h" +#include "video_core/post_processing/fx_preset.h" + +namespace VideoCore { + +namespace { + +std::vector preset_catalog; +bool preset_catalog_scanned = false; + +std::string Trim(std::string_view value) { + size_t begin = 0; + while (begin < value.size() && (value[begin] == ' ' || value[begin] == '\t')) { + ++begin; + } + size_t end = value.size(); + while (end > begin) { + const char c = value[end - 1]; + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { + break; + } + --end; + } + return std::string(value.substr(begin, end - begin)); +} + +FxPresetDesc ParsePreset(std::string_view text) { + FxPresetDesc desc; + + size_t start = 0; + while (start <= text.size()) { + size_t end = text.find('\n', start); + if (end == std::string_view::npos) { + end = text.size(); + } + const std::string_view line = text.substr(start, end - start); + start = end + 1; + + const size_t equals = line.find('='); + if (equals == std::string_view::npos) { + continue; + } + + const std::string key = Trim(line.substr(0, equals)); + const std::string value = Trim(line.substr(equals + 1)); + + if (key == "name") { + desc.name = value; + } else if (key == "description") { + desc.description = value; + } else if (key == "chain") { + desc.chain = value; + } + } + + return desc; +} + +std::string PresetFileName(std::string_view name) { + std::string out; + for (const char c : name) { + if (c == ' ') { + out += '_'; + continue; + } + const bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_'; + if (keep) { + out += c; + } + } + if (out.empty()) { + out = "preset"; + } + return out + ".fxp"; +} + +std::string ComposePreset(const FxPresetDesc& desc) { + std::string out; + out += "name="; + out += desc.name; + out += '\n'; + out += "description="; + out += desc.description; + out += '\n'; + out += "chain="; + out += desc.chain; + out += '\n'; + return out; +} + +} // Anonymous namespace + +std::filesystem::path GetFxPresetDirectory() { + return Common::FS::GetEdenPath(Common::FS::EdenPath::PostPresetDir); +} + +void ReloadFxPresetCatalog() { + preset_catalog.clear(); + preset_catalog_scanned = true; + + const auto root = GetFxPresetDirectory(); + if (!Common::FS::Exists(root) && !Common::FS::CreateDirs(root)) { + return; + } + + std::vector bundled_names; + for (const auto& bundled : BUNDLED_FX_PRESETS) { + const auto path = root / bundled.name; + bundled_names.emplace_back(bundled.name); + if (Common::FS::Exists(path)) { + continue; + } + void(Common::FS::WriteStringToFile(path, Common::FS::FileType::TextFile, bundled.source)); + } + + std::vector preset_files; + Common::FS::IterateDirEntriesRecursively( + root, + [&](const std::filesystem::directory_entry& entry) { + if (entry.path().extension() == ".fxp") { + preset_files.push_back(entry.path()); + } + return true; + }, + Common::FS::DirEntryFilter::File); + + std::sort(preset_files.begin(), preset_files.end()); + + for (const auto& file : preset_files) { + const std::string text = + Common::FS::ReadStringFromFile(file, Common::FS::FileType::TextFile); + + FxPresetDesc desc = ParsePreset(text); + if (desc.name.empty() || desc.chain.empty()) { + continue; + } + + desc.file = Common::FS::PathToUTF8String(file.filename()); + desc.bundled = std::find(bundled_names.begin(), bundled_names.end(), desc.file) != + bundled_names.end(); + preset_catalog.push_back(std::move(desc)); + } +} + +const std::vector& GetFxPresetCatalog() { + if (!preset_catalog_scanned) { + ReloadFxPresetCatalog(); + } + return preset_catalog; +} + +const FxPresetDesc* FindFxPreset(std::string_view name) { + const auto& presets = GetFxPresetCatalog(); + const auto it = std::find_if(presets.begin(), presets.end(), + [&](const FxPresetDesc& p) { return p.name == name; }); + if (it == presets.end()) { + return nullptr; + } + return &*it; +} + +bool ApplyFxPreset(std::string_view name) { + const FxPresetDesc* preset = FindFxPreset(name); + if (preset == nullptr) { + return false; + } + + FxChain::Instance().SetEntries(ParseFxChain(preset->chain)); + FxChain::Instance().DropUnknownEntries(); + FxChain::Instance().StoreToSettings(); + SetActiveFxPreset(preset->name); + return true; +} + +bool SaveFxPreset(std::string_view name, std::string_view description) { + if (!IsFxPresetName(name)) { + return false; + } + + const auto root = GetFxPresetDirectory(); + if (!Common::FS::Exists(root) && !Common::FS::CreateDirs(root)) { + return false; + } + + FxPresetDesc desc; + desc.name = std::string(name); + desc.description = std::string(description); + desc.chain = SerializeFxChain(FxChain::Instance().Entries()); + + const std::string body = ComposePreset(desc); + const std::string file = PresetFileName(name); + if (Common::FS::WriteStringToFile(root / file, Common::FS::FileType::TextFile, body) != + body.size()) { + return false; + } + + ReloadFxPresetCatalog(); + SetActiveFxPreset(name); + return true; +} + +bool DeleteFxPreset(std::string_view name) { + const FxPresetDesc* preset = FindFxPreset(name); + if (preset == nullptr || preset->bundled) { + return false; + } + + const auto path = GetFxPresetDirectory() / preset->file; + if (!Common::FS::RemoveFile(path)) { + return false; + } + + if (GetActiveFxPreset() == name) { + SetActiveFxPreset(std::string_view()); + } + ReloadFxPresetCatalog(); + return true; +} + +bool IsFxPresetName(std::string_view name) { + if (name.empty()) { + return false; + } + return name.find_first_of("\r\n=") == std::string_view::npos; +} + +std::string GetActiveFxPreset() { + return Settings::values.post_shader_preset.GetValue(); +} + +void SetActiveFxPreset(std::string_view name) { + Settings::values.post_shader_preset.SetValue(std::string(name)); +} + +bool IsActiveFxPresetModified() { + const std::string active = GetActiveFxPreset(); + if (active.empty()) { + return false; + } + + const FxPresetDesc* preset = FindFxPreset(active); + if (preset == nullptr) { + return true; + } + + return SerializeFxChain(FxChain::Instance().Entries()) != + SerializeFxChain(ParseFxChain(preset->chain)); +} + +} // namespace VideoCore diff --git a/src/video_core/post_processing/fx_preset.h b/src/video_core/post_processing/fx_preset.h new file mode 100644 index 0000000000..23372b9495 --- /dev/null +++ b/src/video_core/post_processing/fx_preset.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +namespace VideoCore { + +struct FxPresetDesc { + std::string file; + std::string name; + std::string description; + std::string chain; + bool bundled{}; +}; + +std::filesystem::path GetFxPresetDirectory(); + +void ReloadFxPresetCatalog(); + +const std::vector& GetFxPresetCatalog(); + +const FxPresetDesc* FindFxPreset(std::string_view name); + +bool ApplyFxPreset(std::string_view name); + +bool SaveFxPreset(std::string_view name, std::string_view description); + +bool DeleteFxPreset(std::string_view name); + +bool IsFxPresetName(std::string_view name); + +std::string GetActiveFxPreset(); + +void SetActiveFxPreset(std::string_view name); + +bool IsActiveFxPresetModified(); + +} // namespace VideoCore diff --git a/src/video_core/renderer_vulkan/present/layer.cpp b/src/video_core/renderer_vulkan/present/layer.cpp index 14935f92bb..1868142f51 100644 --- a/src/video_core/renderer_vulkan/present/layer.cpp +++ b/src/video_core/renderer_vulkan/present/layer.cpp @@ -18,6 +18,10 @@ #include "video_core/renderer_vulkan/present/sgsr.h" #include "video_core/renderer_vulkan/present/fxaa.h" #include "video_core/renderer_vulkan/present/layer.h" +#ifdef HAS_RESHADE +#include "video_core/post_processing/fx_chain.h" +#include "video_core/renderer_vulkan/present/post_process.h" +#endif #include "video_core/renderer_vulkan/present/present_push_constants.h" #include "video_core/renderer_vulkan/present/smaa.h" #include "video_core/renderer_vulkan/present/util.h" @@ -93,6 +97,9 @@ void Layer::ConfigureDraw(const Device& device, PresentPushConstants* out_push_c RefreshResources(device, framebuffer); SetAntiAliasPass(device); +#ifdef HAS_RESHADE + SetPostProcessPass(device); +#endif // Finish any pending renderpass scheduler.RequestOutsideRenderPassOperationContext(); @@ -115,6 +122,12 @@ void Layer::ConfigureDraw(const Device& device, PresentPushConstants* out_push_c smaa->Draw(device, scheduler, image_index, &source_image, &source_image_view); } +#ifdef HAS_RESHADE + if (post_process.has_value()) { + post_process->Draw(device, scheduler, image_index, &source_image, &source_image_view); + } +#endif + auto crop_rect = Tegra::NormalizeCrop(framebuffer, texture_width, texture_height); const VkExtent2D render_extent{ .width = scaled_width, @@ -214,6 +227,43 @@ void Layer::SetAntiAliasPass(const Device& device) { } } +#ifdef HAS_RESHADE +void Layer::SetPostProcessPass(const Device& device) { + const VkExtent2D render_area{ + .width = Settings::values.resolution_info.ScaleUp(raw_width), + .height = Settings::values.resolution_info.ScaleUp(raw_height), + }; + + const u64 generation = VideoCore::FxChain::Instance().Snapshot().generation; + const bool enabled = Settings::values.post_shader_enabled.GetValue(); + + if (post_process_generation == generation && post_process_enabled == enabled && + post_process_extent.width == render_area.width && + post_process_extent.height == render_area.height) { + return; + } + + for (const u64 tick : resource_ticks) { + scheduler.Wait(tick); + } + + post_process_generation = generation; + post_process_enabled = enabled; + post_process_extent = render_area; + post_process.reset(); + + if (!enabled || VideoCore::FxChain::Instance().Size() == 0) { + return; + } + + post_process.emplace(device, memory_allocator, scheduler, image_count, render_area); + + if (post_process->Empty()) { + post_process.reset(); + } +} +#endif + void Layer::ReleaseRawImages() { for (const u64 tick : resource_ticks) { scheduler.Wait(tick); diff --git a/src/video_core/renderer_vulkan/present/layer.h b/src/video_core/renderer_vulkan/present/layer.h index 4d75e86ba6..f290ba5bb4 100644 --- a/src/video_core/renderer_vulkan/present/layer.h +++ b/src/video_core/renderer_vulkan/present/layer.h @@ -15,6 +15,9 @@ #include "video_core/renderer_vulkan/present/fsr.h" #include "video_core/renderer_vulkan/present/sgsr.h" #include "video_core/renderer_vulkan/present/fxaa.h" +#ifdef HAS_RESHADE +#include "video_core/renderer_vulkan/present/post_process.h" +#endif #include "video_core/renderer_vulkan/present/smaa.h" namespace Layout { @@ -66,6 +69,9 @@ private: void RefreshResources(const Device& device, const Tegra::FramebufferConfig& framebuffer); void SetAntiAliasPass(const Device& device); +#ifdef HAS_RESHADE + void SetPostProcessPass(const Device& device); +#endif void ReleaseRawImages(); u64 CalculateBufferSize(const Tegra::FramebufferConfig& framebuffer) const; @@ -95,6 +101,12 @@ private: Settings::AntiAliasing anti_alias_setting{}; std::variant anti_alias{}; std::variant sr_filter{}; +#ifdef HAS_RESHADE + std::optional post_process{}; + u64 post_process_generation{}; + bool post_process_enabled{}; + VkExtent2D post_process_extent{}; +#endif std::vector resource_ticks{}; }; diff --git a/src/video_core/renderer_vulkan/present/post_process.cpp b/src/video_core/renderer_vulkan/present/post_process.cpp new file mode 100644 index 0000000000..07d75b7025 --- /dev/null +++ b/src/video_core/renderer_vulkan/present/post_process.cpp @@ -0,0 +1,899 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include "common/fs/fs.h" +#include "common/fs/fs_util.h" +#include "common/logging.h" +#include "video_core/post_processing/fx_chain.h" +#include "video_core/post_processing/fx_compile.h" +#include "video_core/post_processing/fx_effect.h" +#include "video_core/renderer_vulkan/present/post_process.h" +#include "video_core/renderer_vulkan/present/util.h" +#include "video_core/renderer_vulkan/vk_scheduler.h" +#include "video_core/vulkan_common/vulkan_device.h" + +namespace Vulkan { + +namespace { + +constexpr VkFormat BACKBUFFER_FORMAT = VK_FORMAT_R16G16B16A16_SFLOAT; +constexpr size_t NO_TEXTURE = ~size_t{0}; + +VkFormat ToVkFormat(reshadefx::texture_format format) { + switch (format) { + case reshadefx::texture_format::r8: + return VK_FORMAT_R8_UNORM; + case reshadefx::texture_format::r16f: + return VK_FORMAT_R16_SFLOAT; + case reshadefx::texture_format::r32f: + return VK_FORMAT_R32_SFLOAT; + case reshadefx::texture_format::rg8: + return VK_FORMAT_R8G8_UNORM; + case reshadefx::texture_format::rg16: + return VK_FORMAT_R16G16_UNORM; + case reshadefx::texture_format::rg16f: + return VK_FORMAT_R16G16_SFLOAT; + case reshadefx::texture_format::rg32f: + return VK_FORMAT_R32G32_SFLOAT; + case reshadefx::texture_format::rgba8: + return VK_FORMAT_R8G8B8A8_UNORM; + case reshadefx::texture_format::rgba16: + return VK_FORMAT_R16G16B16A16_UNORM; + case reshadefx::texture_format::rgba16f: + return VK_FORMAT_R16G16B16A16_SFLOAT; + case reshadefx::texture_format::rgba32f: + return VK_FORMAT_R32G32B32A32_SFLOAT; + case reshadefx::texture_format::rgb10a2: + return VK_FORMAT_A2B10G10R10_UNORM_PACK32; + default: + return VK_FORMAT_R8G8B8A8_UNORM; + } +} + +VkSamplerAddressMode ToAddressMode(reshadefx::texture_address_mode mode) { + switch (mode) { + case reshadefx::texture_address_mode::wrap: + return VK_SAMPLER_ADDRESS_MODE_REPEAT; + case reshadefx::texture_address_mode::mirror: + return VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT; + case reshadefx::texture_address_mode::border: + return VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + case reshadefx::texture_address_mode::clamp: + default: + return VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + } +} + +VkBlendFactor ToBlendFactor(reshadefx::blend_factor func) { + switch (func) { + case reshadefx::blend_factor::zero: + return VK_BLEND_FACTOR_ZERO; + case reshadefx::blend_factor::source_color: + return VK_BLEND_FACTOR_SRC_COLOR; + case reshadefx::blend_factor::source_alpha: + return VK_BLEND_FACTOR_SRC_ALPHA; + case reshadefx::blend_factor::one_minus_source_color: + return VK_BLEND_FACTOR_ONE_MINUS_SRC_COLOR; + case reshadefx::blend_factor::one_minus_source_alpha: + return VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + case reshadefx::blend_factor::dest_color: + return VK_BLEND_FACTOR_DST_COLOR; + case reshadefx::blend_factor::dest_alpha: + return VK_BLEND_FACTOR_DST_ALPHA; + case reshadefx::blend_factor::one_minus_dest_color: + return VK_BLEND_FACTOR_ONE_MINUS_DST_COLOR; + case reshadefx::blend_factor::one_minus_dest_alpha: + return VK_BLEND_FACTOR_ONE_MINUS_DST_ALPHA; + case reshadefx::blend_factor::one: + default: + return VK_BLEND_FACTOR_ONE; + } +} + +VkBlendOp ToBlendOp(reshadefx::blend_op op) { + switch (op) { + case reshadefx::blend_op::subtract: + return VK_BLEND_OP_SUBTRACT; + case reshadefx::blend_op::reverse_subtract: + return VK_BLEND_OP_REVERSE_SUBTRACT; + case reshadefx::blend_op::min: + return VK_BLEND_OP_MIN; + case reshadefx::blend_op::max: + return VK_BLEND_OP_MAX; + case reshadefx::blend_op::add: + default: + return VK_BLEND_OP_ADD; + } +} + +VkPrimitiveTopology ToTopology(reshadefx::primitive_topology topology) { + switch (topology) { + case reshadefx::primitive_topology::point_list: + return VK_PRIMITIVE_TOPOLOGY_POINT_LIST; + case reshadefx::primitive_topology::line_list: + return VK_PRIMITIVE_TOPOLOGY_LINE_LIST; + case reshadefx::primitive_topology::line_strip: + return VK_PRIMITIVE_TOPOLOGY_LINE_STRIP; + case reshadefx::primitive_topology::triangle_strip: + return VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP; + case reshadefx::primitive_topology::triangle_list: + default: + return VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + } +} + +vk::RenderPass CreateFxRenderPass(const Device& device, VkFormat format, bool clear) { + VkAttachmentLoadOp load_op = VK_ATTACHMENT_LOAD_OP_LOAD; + VkImageLayout initial_layout = VK_IMAGE_LAYOUT_GENERAL; + if (clear) { + load_op = VK_ATTACHMENT_LOAD_OP_CLEAR; + initial_layout = VK_IMAGE_LAYOUT_UNDEFINED; + } + + const VkAttachmentDescription attachment{ + .flags = 0, + .format = format, + .samples = VK_SAMPLE_COUNT_1_BIT, + .loadOp = load_op, + .storeOp = VK_ATTACHMENT_STORE_OP_STORE, + .stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE, + .stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, + .initialLayout = initial_layout, + .finalLayout = VK_IMAGE_LAYOUT_GENERAL, + }; + + const VkAttachmentReference reference{ + .attachment = 0, + .layout = VK_IMAGE_LAYOUT_GENERAL, + }; + + const VkSubpassDescription subpass{ + .flags = 0, + .pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, + .inputAttachmentCount = 0, + .pInputAttachments = nullptr, + .colorAttachmentCount = 1, + .pColorAttachments = &reference, + .pResolveAttachments = nullptr, + .pDepthStencilAttachment = nullptr, + .preserveAttachmentCount = 0, + .pPreserveAttachments = nullptr, + }; + + return device.GetLogical().CreateRenderPass(VkRenderPassCreateInfo{ + .sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .attachmentCount = 1, + .pAttachments = &attachment, + .subpassCount = 1, + .pSubpasses = &subpass, + .dependencyCount = 0, + .pDependencies = nullptr, + }); +} + +vk::Pipeline CreateFxPipeline(const Device& device, vk::RenderPass& renderpass, + vk::PipelineLayout& layout, VkShaderModule vertex_shader, + VkShaderModule fragment_shader, + const reshadefx::pass& pass) { + const std::array stages{{ + { + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .stage = VK_SHADER_STAGE_VERTEX_BIT, + .module = vertex_shader, + .pName = pass.vs_entry_point.c_str(), + .pSpecializationInfo = nullptr, + }, + { + .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .stage = VK_SHADER_STAGE_FRAGMENT_BIT, + .module = fragment_shader, + .pName = pass.ps_entry_point.c_str(), + .pSpecializationInfo = nullptr, + }, + }}; + + constexpr VkPipelineVertexInputStateCreateInfo vertex_input{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .vertexBindingDescriptionCount = 0, + .pVertexBindingDescriptions = nullptr, + .vertexAttributeDescriptionCount = 0, + .pVertexAttributeDescriptions = nullptr, + }; + + const VkPipelineInputAssemblyStateCreateInfo input_assembly{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .topology = ToTopology(pass.topology), + .primitiveRestartEnable = VK_FALSE, + }; + + constexpr VkPipelineViewportStateCreateInfo viewport_state{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .viewportCount = 1, + .pViewports = nullptr, + .scissorCount = 1, + .pScissors = nullptr, + }; + + constexpr VkPipelineRasterizationStateCreateInfo rasterization{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .depthClampEnable = VK_FALSE, + .rasterizerDiscardEnable = VK_FALSE, + .polygonMode = VK_POLYGON_MODE_FILL, + .cullMode = VK_CULL_MODE_NONE, + .frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, + .depthBiasEnable = VK_FALSE, + .depthBiasConstantFactor = 0.0f, + .depthBiasClamp = 0.0f, + .depthBiasSlopeFactor = 0.0f, + .lineWidth = 1.0f, + }; + + constexpr VkPipelineMultisampleStateCreateInfo multisampling{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .rasterizationSamples = VK_SAMPLE_COUNT_1_BIT, + .sampleShadingEnable = VK_FALSE, + .minSampleShading = 0.0f, + .pSampleMask = nullptr, + .alphaToCoverageEnable = VK_FALSE, + .alphaToOneEnable = VK_FALSE, + }; + + VkBool32 blend_enable = VK_FALSE; + if (pass.blend_enable[0]) { + blend_enable = VK_TRUE; + } + + const VkPipelineColorBlendAttachmentState blending{ + .blendEnable = blend_enable, + .srcColorBlendFactor = ToBlendFactor(pass.source_color_blend_factor[0]), + .dstColorBlendFactor = ToBlendFactor(pass.dest_color_blend_factor[0]), + .colorBlendOp = ToBlendOp(pass.color_blend_op[0]), + .srcAlphaBlendFactor = ToBlendFactor(pass.source_alpha_blend_factor[0]), + .dstAlphaBlendFactor = ToBlendFactor(pass.dest_alpha_blend_factor[0]), + .alphaBlendOp = ToBlendOp(pass.alpha_blend_op[0]), + .colorWriteMask = static_cast(pass.render_target_write_mask[0] & 0xF), + }; + + const VkPipelineColorBlendStateCreateInfo color_blend{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .logicOpEnable = VK_FALSE, + .logicOp = VK_LOGIC_OP_COPY, + .attachmentCount = 1, + .pAttachments = &blending, + .blendConstants = {0.0f, 0.0f, 0.0f, 0.0f}, + }; + + constexpr std::array dynamic_states{ + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR, + }; + + const VkPipelineDynamicStateCreateInfo dynamic_state{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .dynamicStateCount = static_cast(dynamic_states.size()), + .pDynamicStates = dynamic_states.data(), + }; + + return device.GetLogical().CreateGraphicsPipeline(VkGraphicsPipelineCreateInfo{ + .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .stageCount = static_cast(stages.size()), + .pStages = stages.data(), + .pVertexInputState = &vertex_input, + .pInputAssemblyState = &input_assembly, + .pTessellationState = nullptr, + .pViewportState = &viewport_state, + .pRasterizationState = &rasterization, + .pMultisampleState = &multisampling, + .pDepthStencilState = nullptr, + .pColorBlendState = &color_blend, + .pDynamicState = &dynamic_state, + .layout = *layout, + .renderPass = *renderpass, + .subpass = 0, + .basePipelineHandle = nullptr, + .basePipelineIndex = 0, + }); +} + +} // Anonymous namespace + +PostProcessChain::PostProcessChain(const Device& device, MemoryAllocator& allocator, + Scheduler& scheduler, size_t image_count, VkExtent2D extent) + : m_extent(extent) + , m_image_count(u32(image_count)) +{ + m_start = std::chrono::steady_clock::now(); + m_previous = m_start; + + CreatePingPongImages(device, allocator); + + m_fallback_sampler = CreateWrappedSampler(device); + m_fallback_image = CreateWrappedImage(allocator, VkExtent2D{1, 1}, VK_FORMAT_R8G8B8A8_UNORM); + m_fallback_view = CreateWrappedImageView(device, m_fallback_image, VK_FORMAT_R8G8B8A8_UNORM); + + if (!BuildEffects(device, allocator, scheduler)) { + m_effects.clear(); + } +} + +PostProcessChain::~PostProcessChain() = default; + +bool PostProcessChain::Empty() const { + return m_effects.empty(); +} + +void PostProcessChain::CreatePingPongImages(const Device& device, MemoryAllocator& allocator) { + m_frames.resize(m_image_count); + for (auto& frame : m_frames) { + for (size_t i = 0; i < frame.images.size(); ++i) { + frame.images[i] = CreateWrappedImage(allocator, m_extent, BACKBUFFER_FORMAT); + frame.views[i] = CreateWrappedImageView(device, frame.images[i], BACKBUFFER_FORMAT); + } + } +} + +bool PostProcessChain::BuildEffects(const Device& device, MemoryAllocator& allocator, + Scheduler& scheduler) { + const auto snapshot = VideoCore::FxChain::Instance().Snapshot(); + if (snapshot.entries.empty()) { + return true; + } + + const auto root = VideoCore::GetFxRootDirectory(); + + for (size_t entry_index = 0; entry_index < snapshot.entries.size(); ++entry_index) { + const auto& entry = snapshot.entries[entry_index]; + const auto path = root / entry.file; + + const auto compiled = VideoCore::CompileFxEffect(path, m_extent.width, m_extent.height, 8); + if (!compiled.Succeeded()) { + LOG_ERROR(Render_Vulkan, "Post-processing effect '{}' failed to compile:\n{}", + entry.file, compiled.error); + continue; + } + + const auto& module = compiled.module; + + const auto technique = std::find_if( + module.techniques.begin(), module.techniques.end(), + [&](const reshadefx::technique& t) { return t.name == entry.technique; }); + if (technique == module.techniques.end()) { + LOG_ERROR(Render_Vulkan, "Effect '{}' has no technique '{}'", entry.file, + entry.technique); + continue; + } + + Effect effect; + effect.entry_index = entry_index; + effect.file = entry.file; + effect.uniform_size = module.total_uniform_size; + for (const auto& [name, words] : compiled.entry_points) { + effect.shaders.emplace(name, CreateWrappedShaderModule(device, words)); + } + + for (const auto& texture : module.textures) { + Texture out; + out.name = texture.unique_name; + out.extent = VkExtent2D{texture.width, texture.height}; + out.format = ToVkFormat(texture.format); + + if (texture.semantic == "COLOR") { + out.is_backbuffer = true; + effect.textures.push_back(std::move(out)); + continue; + } + if (texture.semantic == "DEPTH") { + effect.textures.push_back(std::move(out)); + continue; + } + + out.image = CreateWrappedImage(allocator, out.extent, out.format); + out.view = CreateWrappedImageView(device, out.image, out.format); + effect.textures.push_back(std::move(out)); + } + + for (const auto& sampler : module.samplers) { + Sampler out; + out.texture_index = NO_TEXTURE; + for (size_t i = 0; i < effect.textures.size(); ++i) { + if (effect.textures[i].name == sampler.texture_name) { + out.texture_index = i; + break; + } + } + + VkFilter mag_filter = VK_FILTER_LINEAR; + VkFilter min_filter = VK_FILTER_LINEAR; + VkSamplerMipmapMode mip_mode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + const u32 filter = static_cast(sampler.filter); + if ((filter & 0x10) == 0) { + min_filter = VK_FILTER_NEAREST; + } + if ((filter & 0x04) == 0) { + mag_filter = VK_FILTER_NEAREST; + } + if ((filter & 0x01) == 0) { + mip_mode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + } + + out.sampler = device.GetLogical().CreateSampler(VkSamplerCreateInfo{ + .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .magFilter = mag_filter, + .minFilter = min_filter, + .mipmapMode = mip_mode, + .addressModeU = ToAddressMode(sampler.address_u), + .addressModeV = ToAddressMode(sampler.address_v), + .addressModeW = ToAddressMode(sampler.address_w), + .mipLodBias = sampler.lod_bias, + .anisotropyEnable = VK_FALSE, + .maxAnisotropy = 1.0f, + .compareEnable = VK_FALSE, + .compareOp = VK_COMPARE_OP_NEVER, + .minLod = sampler.min_lod, + .maxLod = sampler.max_lod, + .borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK, + .unnormalizedCoordinates = VK_FALSE, + }); + + effect.samplers.push_back(std::move(out)); + } + + for (const auto& uniform : module.uniforms) { + UniformWrite write; + write.name = uniform.name; + write.offset = uniform.offset; + write.components = std::min(uniform.type.components(), 4); + write.kind = UniformKind::Floating; + if (uniform.type.is_boolean()) { + write.kind = UniformKind::Boolean; + } else if (uniform.type.is_integral()) { + write.kind = UniformKind::Integer; + } + + for (const auto& annotation : uniform.annotations) { + if (annotation.name != "source") { + continue; + } + const std::string& source = annotation.value.string_data; + if (source == "frametime") { + write.source = UniformSource::FrameTime; + } else if (source == "framecount") { + write.source = UniformSource::FrameCount; + } else if (source == "timer") { + write.source = UniformSource::Timer; + } else if (source == "random") { + write.source = UniformSource::Random; + } else if (source == "pingpong") { + write.source = UniformSource::PingPong; + } + } + + if (uniform.has_initializer_value) { + for (u32 i = 0; i < write.components; ++i) { + if (write.kind == UniformKind::Floating) { + write.fallback[i] = uniform.initializer_value.as_float[i]; + } else { + write.fallback[i] = static_cast(uniform.initializer_value.as_int[i]); + } + } + } + + write.args = {0.0f, 1.0f, 1.0f, 0.0f}; + for (const auto& annotation : uniform.annotations) { + if (annotation.name == "min" && annotation.type.is_floating_point()) { + write.args[0] = annotation.value.as_float[0]; + } + if (annotation.name == "max" && annotation.type.is_floating_point()) { + write.args[1] = annotation.value.as_float[0]; + } + if (annotation.name == "step" && annotation.type.is_floating_point()) { + write.args[2] = annotation.value.as_float[0]; + } + } + write.state = write.args[0]; + + effect.uniforms.push_back(std::move(write)); + } + + effect.uniform_layout = CreateWrappedDescriptorSetLayout( + device, std::array{VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER}, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT); + + for (const auto& pass : technique->passes) { + Pass out; + out.num_vertices = pass.num_vertices; + out.clear = pass.clear_render_targets != 0; + out.target_texture = NO_TEXTURE; + out.extent = m_extent; + + const std::string& target = pass.render_target_names[0]; + if (target.empty()) { + out.writes_backbuffer = true; + } else { + for (size_t i = 0; i < effect.textures.size(); ++i) { + if (effect.textures[i].name == target) { + out.target_texture = i; + out.extent = effect.textures[i].extent; + break; + } + } + if (out.target_texture == NO_TEXTURE) { + LOG_WARNING(Render_Vulkan, "Effect '{}' pass targets unknown texture '{}'", + entry.file, target); + out.writes_backbuffer = true; + } + } + + if (pass.viewport_width != 0 && pass.viewport_height != 0) { + out.extent = VkExtent2D{pass.viewport_width, pass.viewport_height}; + } + + VkFormat target_format = BACKBUFFER_FORMAT; + if (!out.writes_backbuffer) { + target_format = effect.textures[out.target_texture].format; + } + + u32 binding_count = 0; + for (const auto& binding : pass.sampler_bindings) { + SamplerBinding entry_binding; + entry_binding.binding = binding.entry_point_binding; + entry_binding.sampler_index = binding.index; + out.sampler_bindings.push_back(entry_binding); + binding_count = std::max(binding_count, binding.entry_point_binding + 1); + } + + const std::vector sampler_types( + std::max(binding_count, 1), VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER); + out.sampler_layout = CreateWrappedDescriptorSetLayout( + device, sampler_types, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT); + + const std::array set_layouts{*effect.uniform_layout, *out.sampler_layout}; + out.pipeline_layout = + device.GetLogical().CreatePipelineLayout(VkPipelineLayoutCreateInfo{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .setLayoutCount = static_cast(set_layouts.size()), + .pSetLayouts = set_layouts.data(), + .pushConstantRangeCount = 0, + .pPushConstantRanges = nullptr, + }); + + const auto vertex_shader = effect.shaders.find(pass.vs_entry_point); + const auto fragment_shader = effect.shaders.find(pass.ps_entry_point); + if (vertex_shader == effect.shaders.end() || + fragment_shader == effect.shaders.end()) { + LOG_WARNING(Render_Vulkan, "Effect '{}' pass references a missing entry point", + entry.file); + continue; + } + + out.renderpass = CreateFxRenderPass(device, target_format, out.clear); + out.pipeline = CreateFxPipeline(device, out.renderpass, out.pipeline_layout, + *vertex_shader->second, *fragment_shader->second, pass); + + if (out.writes_backbuffer) { + out.backbuffer_slot = static_cast(effect.backbuffer_pass_count % 2); + ++effect.backbuffer_pass_count; + for (u32 image = 0; image < m_image_count; ++image) { + for (size_t slot = 0; slot < 2; ++slot) { + out.framebuffers.push_back(CreateWrappedFramebuffer( + device, out.renderpass, m_frames[image].views[slot], out.extent)); + } + } + } else { + out.framebuffers.push_back( + CreateWrappedFramebuffer(device, out.renderpass, + effect.textures[out.target_texture].view, out.extent)); + } + + effect.passes.push_back(std::move(out)); + } + + if (effect.passes.empty()) { + LOG_WARNING(Render_Vulkan, "Effect '{}' technique '{}' has no passes", entry.file, + entry.technique); + continue; + } + + const u32 buffer_size = std::max(effect.uniform_size, 4); + for (u32 i = 0; i < m_image_count; ++i) { + effect.uniform_buffers.push_back( + CreateWrappedBuffer(allocator, buffer_size, MemoryUsage::Upload)); + } + + size_t sampler_descriptor_count = 0; + size_t sampler_set_count = 0; + for (const auto& pass : effect.passes) { + sampler_descriptor_count += + m_image_count * std::max(pass.sampler_bindings.size(), 1); + sampler_set_count += m_image_count; + } + + effect.descriptor_pool = CreateWrappedDescriptorPool( + device, m_image_count + sampler_descriptor_count, m_image_count + sampler_set_count, + {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER}); + + const std::vector uniform_layouts(m_image_count, + *effect.uniform_layout); + effect.uniform_sets = CreateWrappedDescriptorSets(effect.descriptor_pool, uniform_layouts); + + for (auto& pass : effect.passes) { + const std::vector layouts(m_image_count, *pass.sampler_layout); + pass.sampler_sets = CreateWrappedDescriptorSets(effect.descriptor_pool, layouts); + } + + m_effects.push_back(std::move(effect)); + } + + return true; +} + +void PostProcessChain::PrepareImages(const Device& device, Scheduler& scheduler) { + if (m_images_ready) { + return; + } + + scheduler.Record([this](vk::CommandBuffer cmdbuf) { + ClearColorImage(cmdbuf, *m_fallback_image); + for (auto& frame : m_frames) { + for (auto& image : frame.images) { + ClearColorImage(cmdbuf, *image); + } + } + for (auto& effect : m_effects) { + for (auto& texture : effect.textures) { + if (texture.image) { + ClearColorImage(cmdbuf, *texture.image); + } + } + } + }); + scheduler.Finish(); + + m_images_ready = true; +} + +void PostProcessChain::UpdateUniforms(Effect& effect, size_t image_index, f32 delta_seconds) { + if (effect.uniform_size == 0) { + return; + } + + static thread_local std::mt19937 rng{std::random_device{}()}; + + std::vector staging(effect.uniform_size, 0); + const f32 elapsed = + std::chrono::duration(std::chrono::steady_clock::now() - m_start).count(); + const auto overrides = VideoCore::FxChain::Instance().EntryValues(effect.entry_index); + + for (auto& uniform : effect.uniforms) { + std::array value = uniform.fallback; + + const auto override = overrides.find(uniform.name); + if (override != overrides.end()) { + value = override->second; + } + + switch (uniform.source) { + case UniformSource::FrameTime: + value[0] = delta_seconds * 1000.0f; + break; + case UniformSource::FrameCount: + value[0] = static_cast(m_frame_count); + break; + case UniformSource::Timer: + value[0] = elapsed * 1000.0f; + break; + case UniformSource::Random: { + const int low = static_cast(uniform.args[0]); + int high = static_cast(uniform.args[1]); + if (high <= low) { + high = low + 1; + } + std::uniform_int_distribution dist(low, high); + value[0] = static_cast(dist(rng)); + break; + } + case UniformSource::PingPong: { + const f32 min_value = uniform.args[0]; + f32 max_value = uniform.args[1]; + if (max_value <= min_value) { + max_value = min_value + 1.0f; + } + f32 step = uniform.args[2]; + if (step == 0.0f) { + step = 1.0f; + } + uniform.state += uniform.direction * step * delta_seconds; + if (uniform.state >= max_value) { + uniform.state = max_value; + uniform.direction = -1.0f; + } + if (uniform.state <= min_value) { + uniform.state = min_value; + uniform.direction = 1.0f; + } + value[0] = uniform.state; + value[1] = uniform.direction; + break; + } + case UniformSource::Value: + default: + break; + } + + for (u32 i = 0; i < uniform.components; ++i) { + const size_t offset = uniform.offset + i * sizeof(u32); + if (offset + sizeof(u32) > staging.size()) { + break; + } + if (uniform.kind == UniformKind::Floating) { + const f32 element = value[i]; + std::memcpy(staging.data() + offset, &element, sizeof(f32)); + } else { + const s32 element = static_cast(value[i]); + std::memcpy(staging.data() + offset, &element, sizeof(s32)); + } + } + } + + const std::span mapped = effect.uniform_buffers[image_index].Mapped(); + if (mapped.size() >= staging.size()) { + std::memcpy(mapped.data(), staging.data(), staging.size()); + effect.uniform_buffers[image_index].Flush(); + } +} + +void PostProcessChain::UpdateDescriptors(const Device& device, Effect& effect, Pass& pass, + size_t image_index, VkImageView backbuffer_view) { + std::vector image_infos; + std::vector writes; + image_infos.reserve(pass.sampler_bindings.size() + 1); + + const VkDescriptorSet sampler_set = pass.sampler_sets[image_index]; + + for (const auto& binding : pass.sampler_bindings) { + VkImageView view = *m_fallback_view; + VkSampler handle = *m_fallback_sampler; + + if (binding.sampler_index < effect.samplers.size()) { + const Sampler& sampler = effect.samplers[binding.sampler_index]; + if (sampler.sampler) { + handle = *sampler.sampler; + } + if (sampler.texture_index != NO_TEXTURE) { + const Texture& texture = effect.textures[sampler.texture_index]; + if (texture.is_backbuffer) { + view = backbuffer_view; + } else if (texture.view) { + view = *texture.view; + } + } + } + + writes.push_back( + CreateWriteDescriptorSet(image_infos, handle, view, sampler_set, binding.binding)); + } + + const VkDescriptorBufferInfo buffer_info{ + .buffer = *effect.uniform_buffers[image_index], + .offset = 0, + .range = VK_WHOLE_SIZE, + }; + + writes.push_back(VkWriteDescriptorSet{ + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .pNext = nullptr, + .dstSet = effect.uniform_sets[image_index], + .dstBinding = 0, + .dstArrayElement = 0, + .descriptorCount = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, + .pImageInfo = nullptr, + .pBufferInfo = &buffer_info, + .pTexelBufferView = nullptr, + }); + + device.GetLogical().UpdateDescriptorSets(writes, {}); +} + +void PostProcessChain::Draw(const Device& device, Scheduler& scheduler, size_t image_index, + VkImage* inout_image, VkImageView* inout_image_view) { + if (m_effects.empty()) { + return; + } + + PrepareImages(device, scheduler); + + const auto now = std::chrono::steady_clock::now(); + const f32 delta_seconds = std::chrono::duration(now - m_previous).count(); + m_previous = now; + ++m_frame_count; + + FrameImages& frame = m_frames[image_index]; + VkImage current_image = *inout_image; + VkImageView current_view = *inout_image_view; + u32 slot = 0; + + for (auto& effect : m_effects) { + UpdateUniforms(effect, image_index, delta_seconds); + + for (size_t pass_index = 0; pass_index < effect.passes.size(); ++pass_index) { + Pass& pass = effect.passes[pass_index]; + + UpdateDescriptors(device, effect, pass, image_index, current_view); + + VkFramebuffer framebuffer{}; + VkImage target_image{}; + if (pass.writes_backbuffer) { + const u32 target_slot = (slot + 1) % 2; + framebuffer = *pass.framebuffers[image_index * 2 + target_slot]; + target_image = *frame.images[target_slot]; + } else { + framebuffer = *pass.framebuffers[0]; + target_image = *effect.textures[pass.target_texture].image; + } + + const VkImage source_image = current_image; + const VkRenderPass renderpass = *pass.renderpass; + const VkPipeline pipeline = *pass.pipeline; + const VkPipelineLayout layout = *pass.pipeline_layout; + const VkDescriptorSet uniform_set = effect.uniform_sets[image_index]; + const VkDescriptorSet sampler_set = pass.sampler_sets[image_index]; + const VkExtent2D extent = pass.extent; + const u32 vertices = pass.num_vertices; + + scheduler.RequestOutsideRenderPassOperationContext(); + scheduler.Record([=](vk::CommandBuffer cmdbuf) { + TransitionImageLayout(cmdbuf, source_image, VK_IMAGE_LAYOUT_GENERAL); + TransitionImageLayout(cmdbuf, target_image, VK_IMAGE_LAYOUT_GENERAL); + BeginRenderPass(cmdbuf, renderpass, framebuffer, extent); + cmdbuf.BindPipeline(VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + cmdbuf.BindDescriptorSets(VK_PIPELINE_BIND_POINT_GRAPHICS, layout, 0, + std::array{uniform_set, sampler_set}, {}); + cmdbuf.Draw(vertices, 1, 0, 0); + cmdbuf.EndRenderPass(); + TransitionImageLayout(cmdbuf, target_image, VK_IMAGE_LAYOUT_GENERAL); + }); + + if (pass.writes_backbuffer) { + slot = (slot + 1) % 2; + current_image = *frame.images[slot]; + current_view = *frame.views[slot]; + } + } + } + + *inout_image = current_image; + *inout_image_view = current_view; +} + +} // namespace Vulkan diff --git a/src/video_core/renderer_vulkan/present/post_process.h b/src/video_core/renderer_vulkan/present/post_process.h new file mode 100644 index 0000000000..9d42bd4751 --- /dev/null +++ b/src/video_core/renderer_vulkan/present/post_process.h @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "common/common_types.h" +#include "video_core/vulkan_common/vulkan_memory_allocator.h" +#include "video_core/vulkan_common/vulkan_wrapper.h" + +namespace Vulkan { + +class Device; +class Scheduler; + +class PostProcessChain { +public: + explicit PostProcessChain(const Device& device, MemoryAllocator& allocator, Scheduler& scheduler, + size_t image_count, VkExtent2D extent); + ~PostProcessChain(); + + void Draw(const Device& device, Scheduler& scheduler, size_t image_index, VkImage* inout_image, + VkImageView* inout_image_view); + + bool Empty() const; + +private: + enum class UniformKind : u32 { + Boolean, + Integer, + Floating, + }; + + enum class UniformSource : u32 { + Value, + FrameTime, + FrameCount, + Timer, + Random, + PingPong, + }; + + struct UniformWrite { + std::string name; + u32 offset{}; + u32 components{}; + UniformKind kind{UniformKind::Floating}; + UniformSource source{UniformSource::Value}; + std::array fallback{}; + std::array args{}; + f32 state{}; + f32 direction{1.0f}; + }; + + struct Texture { + std::string name; + vk::Image image{}; + vk::ImageView view{}; + VkExtent2D extent{}; + VkFormat format{}; + bool is_backbuffer{}; + }; + + struct Sampler { + vk::Sampler sampler{}; + size_t texture_index{}; + }; + + struct SamplerBinding { + u32 binding{}; + size_t sampler_index{}; + }; + + struct Pass { + vk::RenderPass renderpass{}; + vk::Pipeline pipeline{}; + vk::DescriptorSetLayout sampler_layout{}; + vk::PipelineLayout pipeline_layout{}; + vk::DescriptorSets sampler_sets{}; + std::vector sampler_bindings{}; + std::vector framebuffers{}; + size_t target_texture{}; + VkExtent2D extent{}; + u32 num_vertices{3}; + bool clear{}; + bool writes_backbuffer{}; + u32 backbuffer_slot{}; + }; + + struct Effect { + size_t entry_index{}; + std::string file{}; + std::map shaders{}; + std::vector textures{}; + std::vector samplers{}; + std::vector passes{}; + std::vector uniforms{}; + u32 uniform_size{}; + std::vector uniform_buffers{}; + vk::DescriptorSetLayout uniform_layout{}; + vk::DescriptorPool descriptor_pool{}; + vk::DescriptorSets uniform_sets{}; + size_t backbuffer_pass_count{}; + u32 backbuffer_slots{1}; + }; + + struct FrameImages { + std::array images{}; + std::array views{}; + }; + + bool BuildEffects(const Device& device, MemoryAllocator& allocator, Scheduler& scheduler); + void CreatePingPongImages(const Device& device, MemoryAllocator& allocator); + void PrepareImages(const Device& device, Scheduler& scheduler); + void UpdateUniforms(Effect& effect, size_t image_index, f32 delta_seconds); + void UpdateDescriptors(const Device& device, Effect& effect, Pass& pass, size_t image_index, + VkImageView backbuffer_view); + + const VkExtent2D m_extent; + const u32 m_image_count; + + std::vector m_effects{}; + std::vector m_frames{}; + + vk::Sampler m_fallback_sampler{}; + vk::Image m_fallback_image{}; + vk::ImageView m_fallback_view{}; + + std::chrono::steady_clock::time_point m_start{}; + std::chrono::steady_clock::time_point m_previous{}; + u64 m_frame_count{}; + bool m_images_ready{}; +}; + +} // namespace Vulkan diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp index 6290e79d93..4fd4c0233b 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp @@ -24,6 +24,9 @@ #include "video_core/gpu.h" #include "video_core/present.h" #include "video_core/renderer_vulkan/present/util.h" +#ifdef HAS_RESHADE +#include "video_core/post_processing/fx_chain.h" +#endif #include "video_core/renderer_vulkan/renderer_vulkan.h" #include "video_core/renderer_vulkan/vk_blit_screen.h" #include "video_core/renderer_vulkan/vk_rasterizer.h" @@ -183,6 +186,10 @@ try scheduler.RegisterOnSubmit([this] { turbo_mode->QueueSubmitted(); }); } +#ifdef HAS_RESHADE + VideoCore::FxChain::Instance().LoadFromSettings(); +#endif + Report(); } catch (const vk::Exception& exception) { LOG_ERROR(Render_Vulkan, "Vulkan initialization failed with error: {}", exception.what()); diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 021ed9ee3e..b93080f2a1 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -250,6 +250,12 @@ if (YUZU_CRASH_DUMPS) target_compile_definitions(yuzu PRIVATE YUZU_CRASH_DUMPS) endif() +if (ENABLE_RESHADE) + target_sources(yuzu PRIVATE + configuration/configure_post_processing.cpp + configuration/configure_post_processing.h) +endif() + if (CXX_CLANG) target_compile_definitions(yuzu PRIVATE $<$,15>:CANNOT_EXPLICITLY_INSTANTIATE>) diff --git a/src/yuzu/configuration/configure_post_processing.cpp b/src/yuzu/configuration/configure_post_processing.cpp new file mode 100644 index 0000000000..6fe46da6c5 --- /dev/null +++ b/src/yuzu/configuration/configure_post_processing.cpp @@ -0,0 +1,511 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/settings.h" +#include "video_core/post_processing/fx_chain.h" +#include "video_core/post_processing/fx_effect.h" +#include "video_core/post_processing/fx_preset.h" +#include "yuzu/configuration/configure_post_processing.h" + +namespace { + +std::array CurrentValue(int index, const VideoCore::FxUniformDesc& uniform) { + auto& chain = VideoCore::FxChain::Instance(); + if (chain.HasValue(static_cast(index), uniform.name)) { + return chain.GetValue(static_cast(index), uniform.name); + } + return uniform.default_value; +} + +int SliderSteps(const VideoCore::FxUniformDesc& uniform) { + const float span = uniform.ui_max - uniform.ui_min; + const int steps = static_cast(std::lround(span / uniform.ui_step)); + if (steps < 1) { + return 1; + } + return steps; +} + +QString FormatValue(const VideoCore::FxUniformDesc& uniform, float value) { + if (uniform.kind == VideoCore::FxUniformKind::Floating) { + return QString::number(value, 'f', 3); + } + return QString::number(static_cast(std::lround(value))); +} + +QString SlotLabel(const VideoCore::FxEffectDesc& effect, const std::string& technique) { + const QString name = QString::fromStdString(effect.label); + if (effect.techniques.size() == 1) { + return name; + } + return name + QStringLiteral(" · ") + QString::fromStdString(technique); +} + +} // Anonymous namespace + +ConfigurePostProcessing::ConfigurePostProcessing(QWidget* parent) : QDialog(parent) { + setWindowTitle(tr("Post-Processing Effects")); + setMinimumWidth(560); + setMinimumHeight(460); + + auto* root = new QVBoxLayout(this); + + auto* description = new QLabel( + tr("ReShade FX effects are loaded from the post_shaders folder in the Eden data " + "directory. Changes apply immediately while a game is running."), + this); + description->setWordWrap(true); + root->addWidget(description); + + enabled_box = new QCheckBox(tr("Enable post-processing"), this); + enabled_box->setChecked(Settings::values.post_shader_enabled.GetValue()); + connect(enabled_box, &QCheckBox::toggled, this, [](bool checked) { + Settings::values.post_shader_enabled.SetValue(checked); + }); + root->addWidget(enabled_box); + + auto* preset_row = new QHBoxLayout(); + preset_row->addWidget(new QLabel(tr("Preset:"), this)); + + preset_combo = new QComboBox(this); + preset_row->addWidget(preset_combo, 1); + + auto* apply_button = new QPushButton(tr("Apply"), this); + connect(apply_button, &QPushButton::clicked, this, [this]() { + const QString name = preset_combo->currentData().toString(); + if (name.isEmpty()) { + return; + } + VideoCore::ApplyFxPreset(name.toStdString()); + ApplyStructuralChange(); + }); + preset_row->addWidget(apply_button); + + auto* reset_button = new QPushButton(tr("Reset Values"), this); + reset_button->setToolTip(tr("Return every effect to the values the preset ships with.")); + connect(reset_button, &QPushButton::clicked, this, [this]() { + const std::string active = VideoCore::GetActiveFxPreset(); + if (active.empty()) { + return; + } + VideoCore::ApplyFxPreset(active); + ApplyStructuralChange(); + }); + preset_row->addWidget(reset_button); + + auto* save_button = new QPushButton(tr("Save As..."), this); + connect(save_button, &QPushButton::clicked, this, [this]() { + bool accepted = false; + const QString name = + QInputDialog::getText(this, tr("Save Preset"), tr("Preset name:"), QLineEdit::Normal, + QString(), &accepted); + if (!accepted || name.trimmed().isEmpty()) { + return; + } + VideoCore::SaveFxPreset(name.trimmed().toStdString(), std::string()); + PopulatePresetCombo(); + RefreshPresetStatus(); + }); + preset_row->addWidget(save_button); + + auto* delete_button = new QPushButton(tr("Delete"), this); + connect(delete_button, &QPushButton::clicked, this, [this]() { + const QString name = preset_combo->currentData().toString(); + if (name.isEmpty()) { + return; + } + VideoCore::DeleteFxPreset(name.toStdString()); + PopulatePresetCombo(); + RefreshPresetStatus(); + }); + preset_row->addWidget(delete_button); + + root->addLayout(preset_row); + + preset_status = new QLabel(this); + preset_status->setWordWrap(true); + root->addWidget(preset_status); + + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + slots_container = new QWidget(scroll); + slots_layout = new QVBoxLayout(slots_container); + slots_layout->setAlignment(Qt::AlignTop); + scroll->setWidget(slots_container); + root->addWidget(scroll, 1); + + auto* actions = new QHBoxLayout(); + + auto* add_button = new QPushButton(tr("Add Effect"), this); + auto* add_menu = new QMenu(add_button); + for (const auto& effect : VideoCore::GetFxCatalog()) { + if (!effect.Valid()) { + continue; + } + for (const auto& technique : effect.techniques) { + QAction* action = add_menu->addAction(SlotLabel(effect, technique)); + action->setToolTip(QString::fromStdString(effect.description)); + const std::string file = effect.file; + const std::string name = technique; + connect(action, &QAction::triggered, this, [this, file, name]() { + VideoCore::FxChain::Instance().Append(file, name); + ApplyStructuralChange(); + }); + } + } + add_button->setMenu(add_menu); + actions->addWidget(add_button); + + actions->addStretch(); + root->addLayout(actions); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::close); + root->addWidget(buttons); + + VideoCore::ReloadFxCatalog(); + VideoCore::ReloadFxPresetCatalog(); + VideoCore::FxChain::Instance().LoadFromSettings(); + VideoCore::FxChain::Instance().DropUnknownEntries(); + PopulatePresetCombo(); + RebuildRows(); + RefreshPresetStatus(); +} + +ConfigurePostProcessing::~ConfigurePostProcessing() = default; + +void ConfigurePostProcessing::ApplyStructuralChange() { + VideoCore::FxChain::Instance().StoreToSettings(); + RebuildRows(); + RefreshPresetStatus(); +} + +void ConfigurePostProcessing::PopulatePresetCombo() { + const QString previous = preset_combo->currentData().toString(); + preset_combo->clear(); + + for (const auto& preset : VideoCore::GetFxPresetCatalog()) { + const QString name = QString::fromStdString(preset.name); + preset_combo->addItem(name, name); + preset_combo->setItemData(preset_combo->count() - 1, + QString::fromStdString(preset.description), Qt::ToolTipRole); + } + + const int restored = preset_combo->findData(previous); + if (restored >= 0) { + preset_combo->setCurrentIndex(restored); + return; + } + + const int active = preset_combo->findData(QString::fromStdString(VideoCore::GetActiveFxPreset())); + if (active >= 0) { + preset_combo->setCurrentIndex(active); + } +} + +void ConfigurePostProcessing::RefreshPresetStatus() { + const std::string active = VideoCore::GetActiveFxPreset(); + if (active.empty()) { + preset_status->setText(tr("Custom chain. Pick a preset above and press Apply to replace it.")); + return; + } + + if (VideoCore::IsActiveFxPresetModified()) { + preset_status->setText( + tr("Active preset: %1 (modified). Press Reset Values to go back to how it ships.") + .arg(QString::fromStdString(active))); + return; + } + + preset_status->setText(tr("Active preset: %1").arg(QString::fromStdString(active))); +} + +void ConfigurePostProcessing::PopulateEffectCombo(QComboBox* combo, + const VideoCore::FxChainEntry& entry) const { + combo->clear(); + int selected = -1; + + for (const auto& effect : VideoCore::GetFxCatalog()) { + if (!effect.Valid()) { + continue; + } + for (const auto& technique : effect.techniques) { + const QString key = QString::fromStdString(effect.file + "|" + technique); + combo->addItem(SlotLabel(effect, technique), key); + combo->setItemData(combo->count() - 1, QString::fromStdString(effect.description), + Qt::ToolTipRole); + if (effect.file == entry.file && technique == entry.technique) { + selected = combo->count() - 1; + } + } + } + + if (selected >= 0) { + combo->setCurrentIndex(selected); + } +} + +void ConfigurePostProcessing::BuildUniformWidget(QWidget* parent, QVBoxLayout* layout, int index, + const VideoCore::FxUniformDesc& uniform) { + const auto value = CurrentValue(index, uniform); + const QString label = QString::fromStdString(uniform.label); + + if (uniform.ui_type == VideoCore::FxUiType::CheckBox) { + auto* box = new QCheckBox(label, parent); + box->setChecked(value[0] != 0.0f); + if (!uniform.tooltip.empty()) { + box->setToolTip(QString::fromStdString(uniform.tooltip)); + } + const std::string name = uniform.name; + connect(box, &QCheckBox::toggled, this, [index, name](bool checked) { + std::array next{}; + if (checked) { + next[0] = 1.0f; + } + VideoCore::FxChain::Instance().SetValue(static_cast(index), name, next); + VideoCore::FxChain::Instance().StoreToSettings(); + }); + layout->addWidget(box); + return; + } + + if (uniform.ui_type == VideoCore::FxUiType::Combo || + uniform.ui_type == VideoCore::FxUiType::Radio) { + auto* row = new QHBoxLayout(); + row->addWidget(new QLabel(label, parent)); + auto* combo = new QComboBox(parent); + for (size_t i = 0; i < uniform.items.size(); ++i) { + combo->addItem(QString::fromStdString(uniform.items[i]), static_cast(i)); + } + if (combo->count() == 0) { + combo->addItem(tr("Enabled"), 1); + combo->addItem(tr("Disabled"), 0); + } + const int current = static_cast(std::lround(value[0])); + if (current >= 0 && current < combo->count()) { + combo->setCurrentIndex(current); + } + if (!uniform.tooltip.empty()) { + combo->setToolTip(QString::fromStdString(uniform.tooltip)); + } + const std::string name = uniform.name; + connect(combo, &QComboBox::currentIndexChanged, this, [index, name](int selected) { + std::array next{}; + next[0] = static_cast(selected); + VideoCore::FxChain::Instance().SetValue(static_cast(index), name, next); + VideoCore::FxChain::Instance().StoreToSettings(); + }); + row->addWidget(combo, 1); + layout->addLayout(row); + return; + } + + auto* grid = new QGridLayout(); + for (unsigned component = 0; component < uniform.components; ++component) { + QString component_label = label; + if (uniform.components > 1) { + component_label = label + QStringLiteral(" [%1]").arg(component); + } + + auto* name_label = new QLabel(component_label, parent); + auto* value_label = new QLabel(parent); + value_label->setMinimumWidth(64); + value_label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + value_label->setText(FormatValue(uniform, value[component])); + + auto* slider = new QSlider(Qt::Horizontal, parent); + slider->setMinimum(0); + slider->setMaximum(SliderSteps(uniform)); + slider->setValue( + static_cast(std::lround((value[component] - uniform.ui_min) / uniform.ui_step))); + if (!uniform.tooltip.empty()) { + slider->setToolTip(QString::fromStdString(uniform.tooltip)); + } + + const std::string name = uniform.name; + const auto desc = uniform; + connect(slider, &QSlider::valueChanged, this, + [index, name, desc, component, value_label](int steps) { + auto next = CurrentValue(index, desc); + next[component] = desc.ui_min + static_cast(steps) * desc.ui_step; + VideoCore::FxChain::Instance().SetValue(static_cast(index), name, next); + VideoCore::FxChain::Instance().StoreToSettings(); + value_label->setText(FormatValue(desc, next[component])); + }); + + grid->addWidget(name_label, static_cast(component), 0); + grid->addWidget(slider, static_cast(component), 1); + grid->addWidget(value_label, static_cast(component), 2); + } + layout->addLayout(grid); +} + +QWidget* ConfigurePostProcessing::BuildSlot(int index, const VideoCore::FxChainEntry& entry) { + auto* group = new QFrame(slots_container); + group->setObjectName(QStringLiteral("fxSlot")); + group->setStyleSheet(QStringLiteral( + "QFrame#fxSlot { background-color: palette(alternate-base);" + " border: 1px solid palette(mid); border-radius: 6px; }")); + auto* layout = new QVBoxLayout(group); + + auto* header = new QHBoxLayout(); + + auto* toggle = new QToolButton(group); + toggle->setArrowType(Qt::RightArrow); + toggle->setAutoRaise(true); + toggle->setCheckable(true); + header->addWidget(toggle); + + auto* combo = new QComboBox(group); + PopulateEffectCombo(combo, entry); + connect(combo, &QComboBox::currentIndexChanged, this, [this, index, combo](int) { + const QString key = combo->currentData().toString(); + const qsizetype separator = key.indexOf(QLatin1Char('|')); + if (separator < 0) { + return; + } + VideoCore::FxChain::Instance().Replace(static_cast(index), + key.left(separator).toStdString(), + key.mid(separator + 1).toStdString()); + ApplyStructuralChange(); + }); + header->addWidget(combo, 1); + + auto* up_button = new QToolButton(group); + up_button->setText(QStringLiteral("▲")); + up_button->setEnabled(index > 0); + connect(up_button, &QToolButton::clicked, this, [this, index]() { + VideoCore::FxChain::Instance().Move(static_cast(index), -1); + ApplyStructuralChange(); + }); + header->addWidget(up_button); + + auto* down_button = new QToolButton(group); + down_button->setText(QStringLiteral("▼")); + down_button->setEnabled(static_cast(index) + 1 < VideoCore::FxChain::Instance().Size()); + connect(down_button, &QToolButton::clicked, this, [this, index]() { + VideoCore::FxChain::Instance().Move(static_cast(index), 1); + ApplyStructuralChange(); + }); + header->addWidget(down_button); + + auto* reset_button = new QToolButton(group); + reset_button->setText(QStringLiteral("⟲")); + reset_button->setToolTip(tr("Reset to defaults")); + connect(reset_button, &QToolButton::clicked, this, [this, index]() { + VideoCore::FxChain::Instance().ResetValues(static_cast(index)); + ApplyStructuralChange(); + }); + header->addWidget(reset_button); + + auto* remove_button = new QToolButton(group); + remove_button->setText(QStringLiteral("✕")); + connect(remove_button, &QToolButton::clicked, this, [this, index]() { + VideoCore::FxChain::Instance().Remove(static_cast(index)); + ApplyStructuralChange(); + }); + header->addWidget(remove_button); + + layout->addLayout(header); + + auto* body = new QWidget(group); + auto* body_layout = new QVBoxLayout(body); + body_layout->setContentsMargins(0, 0, 0, 0); + body->setVisible(false); + layout->addWidget(body); + + connect(toggle, &QToolButton::toggled, this, [toggle, body](bool open) { + body->setVisible(open); + if (open) { + toggle->setArrowType(Qt::DownArrow); + } else { + toggle->setArrowType(Qt::RightArrow); + } + }); + + const VideoCore::FxEffectDesc* effect = VideoCore::FindFxEffect(entry.file); + if (effect == nullptr) { + auto* missing = + new QLabel(tr("Effect '%1' was not found.").arg(QString::fromStdString(entry.file)), + group); + missing->setWordWrap(true); + body_layout->addWidget(missing); + return group; + } + + if (!effect->error.empty()) { + auto* failed = new QLabel( + tr("Effect failed to compile:\n%1").arg(QString::fromStdString(effect->error)), group); + failed->setWordWrap(true); + body_layout->addWidget(failed); + return group; + } + + std::string current_category; + for (const auto& uniform : effect->uniforms) { + if (uniform.category != current_category) { + current_category = uniform.category; + if (!current_category.empty()) { + auto* category = new QLabel(QString::fromStdString(current_category), group); + category->setStyleSheet(QStringLiteral("font-weight: bold;")); + body_layout->addWidget(category); + } + } + BuildUniformWidget(body, body_layout, index, uniform); + } + + return group; +} + +void ConfigurePostProcessing::RebuildRows() { + QLayoutItem* item = nullptr; + while ((item = slots_layout->takeAt(0)) != nullptr) { + if (item->widget() != nullptr) { + item->widget()->deleteLater(); + } + delete item; + } + + bool has_usable = false; + for (const auto& effect : VideoCore::GetFxCatalog()) { + if (effect.Valid()) { + has_usable = true; + break; + } + } + + if (!has_usable) { + auto* empty = new QLabel( + tr("No usable ReShade FX effects were found. Place .fx files in the post_shaders " + "folder."), + slots_container); + empty->setWordWrap(true); + slots_layout->addWidget(empty); + return; + } + + const auto entries = VideoCore::FxChain::Instance().Entries(); + for (size_t i = 0; i < entries.size(); ++i) { + slots_layout->addWidget(BuildSlot(static_cast(i), entries[i])); + } +} diff --git a/src/yuzu/configuration/configure_post_processing.h b/src/yuzu/configuration/configure_post_processing.h new file mode 100644 index 0000000000..5873bbaa01 --- /dev/null +++ b/src/yuzu/configuration/configure_post_processing.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QVBoxLayout; +class QWidget; + +namespace VideoCore { +struct FxChainEntry; +struct FxEffectDesc; +struct FxUniformDesc; +} + +class ConfigurePostProcessing : public QDialog { + Q_OBJECT + +public: + explicit ConfigurePostProcessing(QWidget* parent = nullptr); + ~ConfigurePostProcessing() override; + +private: + void RebuildRows(); + QWidget* BuildSlot(int index, const VideoCore::FxChainEntry& entry); + void BuildUniformWidget(QWidget* parent, QVBoxLayout* layout, int index, + const VideoCore::FxUniformDesc& uniform); + void PopulateEffectCombo(QComboBox* combo, const VideoCore::FxChainEntry& entry) const; + void ApplyStructuralChange(); + void PopulatePresetCombo(); + void RefreshPresetStatus(); + + QVBoxLayout* slots_layout{}; + QWidget* slots_container{}; + QComboBox* preset_combo{}; + QLabel* preset_status{}; + QCheckBox* enabled_box{}; +}; diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index 13ded52cc0..92b725fa11 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -148,6 +148,7 @@ + @@ -604,6 +605,11 @@ Show &Performance Overlay + + + Post-Processing &Shaders... + + true diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 9a167e18f7..95b91a4f38 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -13,6 +13,9 @@ #include "common/settings_enums.h" #include "frontend_common/settings_generator.h" #include "render/performance_overlay.h" +#ifdef HAS_RESHADE +#include "configuration/configure_post_processing.h" +#endif #include "updater/update_dialog.h" #include "common/fs/ryujinx_compat.h" @@ -1517,6 +1520,11 @@ void MainWindow::ConnectMenuEvents() { connect_menu(ui->action_Show_Filter_Bar, &MainWindow::OnToggleFilterBar); connect_menu(ui->action_Show_Status_Bar, &MainWindow::OnToggleStatusBar); connect_menu(ui->action_Show_Performance_Overlay, &MainWindow::OnTogglePerfOverlay); +#ifdef HAS_RESHADE + connect_menu(ui->action_Post_Processing_Shaders, &MainWindow::OnPostProcessingShaders); +#else + ui->action_Post_Processing_Shaders->setVisible(false); +#endif connect_menu(ui->action_Reset_Window_Size_720, &MainWindow::ResetWindowSize720); connect_menu(ui->action_Reset_Window_Size_900, &MainWindow::ResetWindowSize900); @@ -3893,6 +3901,22 @@ void MainWindow::OnTogglePerfOverlay() { perf_overlay->setVisible(ui->action_Show_Performance_Overlay->isChecked()); } +#ifdef HAS_RESHADE +void MainWindow::OnPostProcessingShaders() { + if (post_processing_dialog == nullptr) { + post_processing_dialog = new ConfigurePostProcessing(this); + connect(post_processing_dialog, &QDialog::finished, post_processing_dialog, [this]() { + post_processing_dialog->deleteLater(); + post_processing_dialog = nullptr; + }); + } + + post_processing_dialog->show(); + post_processing_dialog->raise(); + post_processing_dialog->activateWindow(); +} +#endif + void MainWindow::OnGameListRefresh() { // Resets metadata cache and reloads QtCommon::Game::ResetMetadata(false); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index 693f16b598..2e92df135d 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -56,6 +56,9 @@ class QSlider; class QHBoxLayout; class WaitTreeWidget; class PerformanceOverlay; +#ifdef HAS_RESHADE +class ConfigurePostProcessing; +#endif enum class GameListOpenTarget; enum class DumpRomFSTarget; class GameListPlaceholder; @@ -391,6 +394,9 @@ private slots: void OnToggleFilterBar(); void OnToggleStatusBar(); void OnTogglePerfOverlay(); +#ifdef HAS_RESHADE + void OnPostProcessingShaders(); +#endif void OnGameListRefresh(); void InitializeHotkeys(); void ToggleFullscreen(); @@ -495,6 +501,9 @@ private: QTimer shutdown_timer; OverlayDialog* shutdown_dialog{}; PerformanceOverlay* perf_overlay = nullptr; +#ifdef HAS_RESHADE + ConfigurePostProcessing* post_processing_dialog = nullptr; +#endif GameListPlaceholder* game_list_placeholder = nullptr;