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 index afc754ed85..dcc6d15a06 100644 --- a/dist/post_shaders/Bloom.fx +++ b/dist/post_shaders/Bloom.fx @@ -59,7 +59,7 @@ float4 PS_Bloom(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique Bloom < ui_label = "Bloom"; - ui_tooltip = "Blooms bright areas into a soft glow."; + 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 diff --git a/dist/post_shaders/CRT.fx b/dist/post_shaders/CRT.fx index 030d10b170..51bb810e9f 100644 --- a/dist/post_shaders/CRT.fx +++ b/dist/post_shaders/CRT.fx @@ -63,7 +63,7 @@ float4 PS_CRT(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique CRT < ui_label = "CRT"; - ui_tooltip = "Curved scanlines and the phosphor mask of a CRT television."; + 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 diff --git a/dist/post_shaders/Cartoon.fx b/dist/post_shaders/Cartoon.fx index a65a5fcbb8..3dbe9d77b0 100644 --- a/dist/post_shaders/Cartoon.fx +++ b/dist/post_shaders/Cartoon.fx @@ -68,7 +68,7 @@ float4 PS_Cartoon(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target 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."; + 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 diff --git a/dist/post_shaders/CartoonSoft.fx b/dist/post_shaders/CartoonSoft.fx index bf89c5f6c6..bee08d8b43 100644 --- a/dist/post_shaders/CartoonSoft.fx +++ b/dist/post_shaders/CartoonSoft.fx @@ -89,7 +89,7 @@ float4 PS_CartoonSoft(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Targe technique CartoonSoft < ui_label = "Cartoon Soft"; - ui_tooltip = "Ink outlines and flat colour bands, with the outline held back in the shadows."; + 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 diff --git a/dist/post_shaders/ChromaticAberration.fx b/dist/post_shaders/ChromaticAberration.fx index 12337c7856..e051a038ef 100644 --- a/dist/post_shaders/ChromaticAberration.fx +++ b/dist/post_shaders/ChromaticAberration.fx @@ -42,7 +42,7 @@ float4 PS_ChromaticAberration(float4 pos : SV_Position, float2 uv : TEXCOORD) : technique ChromaticAberration < ui_label = "Chromatic Aberration"; - ui_tooltip = "Splits the colour channels apart towards the edges of the screen, like a cheap lens."; + 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 diff --git a/dist/post_shaders/ColorGrade.fx b/dist/post_shaders/ColorGrade.fx index c725692d02..cf1f10ecad 100644 --- a/dist/post_shaders/ColorGrade.fx +++ b/dist/post_shaders/ColorGrade.fx @@ -53,7 +53,7 @@ float4 PS_ColorGrade(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique ColorGrade < ui_label = "Colour Grade"; - ui_tooltip = "Global saturation, brightness, contrast and gamma."; + ui_tooltip = "The four basic colour controls in one pass: saturation, brightness, contrast and gamma. Reach for this before anything more specialised."; > { pass diff --git a/dist/post_shaders/Deband.fx b/dist/post_shaders/Deband.fx index 0841d5c674..fe4f3a3423 100644 --- a/dist/post_shaders/Deband.fx +++ b/dist/post_shaders/Deband.fx @@ -80,7 +80,7 @@ float4 PS_Deband(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique Deband < ui_label = "Deband"; - ui_tooltip = "Smooths the visible steps in gradients such as skies, then dithers whatever survives."; + ui_tooltip = "Smooths the visible steps in gradients such as skies, then dithers whatever survives. Worth adding whenever large flat areas show rings."; > { pass diff --git a/dist/post_shaders/Denoise.fx b/dist/post_shaders/Denoise.fx index 2765d0682e..2921535fe3 100644 --- a/dist/post_shaders/Denoise.fx +++ b/dist/post_shaders/Denoise.fx @@ -72,7 +72,7 @@ float4 PS_Denoise(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique Denoise < ui_label = "Denoise"; - ui_tooltip = "Edge preserving blur that clears dithering and compression noise. Ported from Anime4K."; + ui_tooltip = "Edge preserving blur that clears dithering and compression noise while leaving outlines sharp. Ported from Anime4K."; > { pass diff --git a/dist/post_shaders/NaturalColors.fx b/dist/post_shaders/NaturalColors.fx index 7e53a0f9ab..0a35ad4ff2 100644 --- a/dist/post_shaders/NaturalColors.fx +++ b/dist/post_shaders/NaturalColors.fx @@ -49,7 +49,7 @@ float4 PS_Natural(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique NaturalColors < ui_label = "Natural Colours"; - ui_tooltip = "Warmer and more saturated look. Ported from the PPSSPP shader."; + ui_tooltip = "Warms the picture and lifts saturation for a less washed out look. Ported from the PPSSPP shader."; > { pass diff --git a/dist/post_shaders/Scanlines.fx b/dist/post_shaders/Scanlines.fx index e6f6223576..2f24dc5ed5 100644 --- a/dist/post_shaders/Scanlines.fx +++ b/dist/post_shaders/Scanlines.fx @@ -55,7 +55,7 @@ float4 PS_Scanlines(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique Scanlines < ui_label = "Scanlines"; - ui_tooltip = "Horizontal scanlines of a CRT display."; + 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 diff --git a/dist/post_shaders/Sharpen.fx b/dist/post_shaders/Sharpen.fx index 9e757c4199..36850e818b 100644 --- a/dist/post_shaders/Sharpen.fx +++ b/dist/post_shaders/Sharpen.fx @@ -32,7 +32,7 @@ float4 PS_Sharpen(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique Sharpen < ui_label = "Sharpen"; - ui_tooltip = "Unsharp mask that brings back edge detail lost to scaling."; + ui_tooltip = "Unsharp mask that brings back edge detail lost to scaling. Most useful after upscaling a low internal resolution."; > { pass diff --git a/dist/post_shaders/Vignette.fx b/dist/post_shaders/Vignette.fx index cea8736948..351990a43e 100644 --- a/dist/post_shaders/Vignette.fx +++ b/dist/post_shaders/Vignette.fx @@ -41,7 +41,7 @@ float4 PS_Vignette(float4 pos : SV_Position, float2 uv : TEXCOORD) : SV_Target technique Vignette < ui_label = "Vignette"; - ui_tooltip = "Darkens the corners of the screen."; + 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 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 fa11d0e894..b0ca1c3bb5 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 @@ -389,7 +391,8 @@ class QuickSettings(val emulationFragment: EmulationFragment) { choices: List, onPicked: (Int) -> Unit ) { - val inflater = LayoutInflater.from(emulationFragment.requireContext()) + 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( @@ -398,9 +401,7 @@ class QuickSettings(val emulationFragment: EmulationFragment) { val choiceGroup = itemView.findViewById(R.id.add_choices) choices.forEachIndexed { index, name -> - val radioButton = com.google.android.material.radiobutton.MaterialRadioButton( - emulationFragment.requireContext() - ) + val radioButton = com.google.android.material.radiobutton.MaterialRadioButton(context) radioButton.text = name radioButton.id = View.generateViewId() radioButton.setPadding(16, 8, 16, 8) @@ -412,25 +413,113 @@ class QuickSettings(val emulationFragment: EmulationFragment) { choiceGroup.addView(radioButton) } + val closedBackground = MaterialColors.getColor( + button, + com.google.android.material.R.attr.colorSecondaryContainer + ) + val closedForeground = MaterialColors.getColor( + button, + com.google.android.material.R.attr.colorOnSecondaryContainer + ) + val openBackground = MaterialColors.getColor( + button, + com.google.android.material.R.attr.colorErrorContainer + ) + val openForeground = MaterialColors.getColor( + button, + com.google.android.material.R.attr.colorOnErrorContainer + ) + + 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.text = context.getString(R.string.post_processing_hide_effects) + button.setIconResource(R.drawable.ic_clear) + button.backgroundTintList = ColorStateList.valueOf(openBackground) + button.setTextColor(openForeground) + button.iconTint = ColorStateList.valueOf(openForeground) } else { - choiceGroup.visibility = View.GONE + choiceGroup.animate() + .alpha(0.0f) + .translationY(slide) + .setDuration(160) + .withEndAction { + choiceGroup.visibility = View.GONE + } + .start() + + button.text = context.getString(R.string.post_processing_add) + button.setIconResource(R.drawable.ic_add) + button.backgroundTintList = ColorStateList.valueOf(closedBackground) + button.setTextColor(closedForeground) + button.iconTint = ColorStateList.valueOf(closedForeground) } } 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 -> + NativePostProcessing.setEnabled(checked) + saveSettings() + } + + 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 = + YuzuApplication.appContext.getString(R.string.post_processing_preset_locked) + if (NativePostProcessing.isPresetModified()) { + summary = + YuzuApplication.appContext.getString(R.string.post_processing_preset_modified) + } + addPresetBand(container, preset, summary) + return + } + val labels = mutableListOf() val files = mutableListOf() val techniques = mutableListOf() @@ -465,7 +554,7 @@ class QuickSettings(val emulationFragment: EmulationFragment) { val body = addShaderCard(title, summary, container) { NativePostProcessing.remove(index) - NativePostProcessing.persist() + NativePostProcessing.persistFor(emulationFragment.shouldUseCustom) onStructureChanged() } @@ -478,7 +567,7 @@ class QuickSettings(val emulationFragment: EmulationFragment) { addEffectPicker(container, labels) { picked -> NativePostProcessing.append(files[picked], techniques[picked]) - NativePostProcessing.persist() + NativePostProcessing.persistFor(emulationFragment.shouldUseCustom) onStructureChanged() } } @@ -519,7 +608,7 @@ class QuickSettings(val emulationFragment: EmulationFragment) { component, uniform.min + position * uniform.step ) - NativePostProcessing.persist() + NativePostProcessing.persistFor(emulationFragment.shouldUseCustom) } } } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/CardSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/CardSetting.kt new file mode 100644 index 0000000000..9350550d37 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/CardSetting.kt @@ -0,0 +1,17 @@ +// 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 CardSetting( + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val expanded: Boolean, + val runnable: () -> Unit +) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_CARD +} 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..b2b49c34e7 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,7 @@ abstract class SettingsItem( const val TYPE_LAUNCHABLE = 13 const val TYPE_PATH = 14 const val TYPE_GPU_UNSWIZZLE = 15 + const val TYPE_CARD = 16 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..ed4fb37569 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,7 @@ 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.ListItemSettingCardBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding @@ -106,6 +107,10 @@ class SettingsAdapter( GpuUnswizzleViewHolder(ListItemSettingBinding.inflate(inflater), this) } + SettingsItem.TYPE_CARD -> { + CardViewHolder(ListItemSettingCardBinding.inflate(inflater), 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 48c0c4b093..01577fb30e 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 @@ -50,6 +50,10 @@ class SettingsFragmentPresenter( private val expandedShaderSlots = mutableSetOf() + private var shaderPickerOpen = false + + private var presetPickerOpen = false + private val context get() = YuzuApplication.appContext // Extension for altering settings list based on each setting's properties @@ -212,7 +216,70 @@ class SettingsFragmentPresenter( return@apply } + val active = NativePostProcessing.getActivePreset() + var presetSummary = context.getString(R.string.post_processing_preset_none) + if (active.isNotEmpty()) { + presetSummary = active + if (NativePostProcessing.isPresetModified()) { + presetSummary = context.getString(R.string.post_processing_preset_modified) + } + } + + add( + CardSetting( + titleId = R.string.post_processing_preset, + descriptionString = presetSummary, + expanded = presetPickerOpen + ) { + presetPickerOpen = !presetPickerOpen + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + + if (presetPickerOpen) { + add( + RunnableSetting( + titleId = R.string.post_processing_preset_none, + isRunnable = true + ) { + NativePostProcessing.clearPreset() + presetPickerOpen = false + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + for (preset in NativePostProcessing.presets()) { + add( + RunnableSetting( + titleString = preset.name, + descriptionString = preset.description, + isRunnable = true + ) { + NativePostProcessing.applyPreset(preset.name) + NativePostProcessing.store() + presetPickerOpen = false + expandedShaderSlots.clear() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + } + } + + if (active.isNotEmpty()) { + add( + RunnableSetting( + titleId = R.string.post_processing_preset_reset, + isRunnable = true + ) { + NativePostProcessing.applyPreset(active) + NativePostProcessing.store() + expandedShaderSlots.clear() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + ) + } + val labels = mutableListOf() + val summaries = mutableListOf() val files = mutableListOf() val techniques = mutableListOf() for (effect in usable) { @@ -222,6 +289,7 @@ class SettingsFragmentPresenter( } else { labels.add(effect.label + " \u00b7 " + technique) } + summaries.add(effect.description) files.add(effect.file) techniques.add(technique) } @@ -243,17 +311,12 @@ class SettingsFragmentPresenter( } val isOpen = expandedShaderSlots.contains(index) - var chevron = R.drawable.ic_arrow_forward - if (isOpen) { - chevron = R.drawable.ic_dropdown_arrow - } add( - RunnableSetting( + CardSetting( titleString = header, descriptionString = summary, - isRunnable = true, - iconId = chevron + expanded = isOpen ) { if (isOpen) { expandedShaderSlots.remove(index) @@ -268,15 +331,6 @@ class SettingsFragmentPresenter( continue } - add( - IntSingleChoiceSetting( - buildSlotSelector(index, entry, files, techniques), - titleId = R.string.post_processing_effect, - choices = labels.toTypedArray(), - values = labels.indices.toList().toTypedArray() - ) - ) - if (effect != null) { for (uniform in effect.uniforms) { addUniform(this, index, uniform) @@ -290,7 +344,7 @@ class SettingsFragmentPresenter( isRunnable = true ) { NativePostProcessing.move(index, -1) - NativePostProcessing.persist() + NativePostProcessing.store() expandedShaderSlots.clear() settingsViewModel.setReloadListAndNotifyDataset(true) } @@ -303,7 +357,7 @@ class SettingsFragmentPresenter( isRunnable = true ) { NativePostProcessing.move(index, 1) - NativePostProcessing.persist() + NativePostProcessing.store() expandedShaderSlots.clear() settingsViewModel.setReloadListAndNotifyDataset(true) } @@ -315,7 +369,7 @@ class SettingsFragmentPresenter( isRunnable = true ) { NativePostProcessing.resetValues(index) - NativePostProcessing.persist() + NativePostProcessing.store() settingsViewModel.setReloadListAndNotifyDataset(true) } ) @@ -325,7 +379,7 @@ class SettingsFragmentPresenter( isRunnable = true ) { NativePostProcessing.remove(index) - NativePostProcessing.persist() + NativePostProcessing.store() expandedShaderSlots.clear() settingsViewModel.setReloadListAndNotifyDataset(true) } @@ -333,78 +387,32 @@ class SettingsFragmentPresenter( } add( - IntSingleChoiceSetting( - buildAddSelector(files, techniques), + CardSetting( titleId = R.string.post_processing_add, - choices = labels.toTypedArray(), - values = labels.indices.toList().toTypedArray() - ) + expanded = shaderPickerOpen + ) { + shaderPickerOpen = !shaderPickerOpen + settingsViewModel.setReloadListAndNotifyDataset(true) + } ) - } - } - private fun buildAddSelector( - files: List, - techniques: List - ): AbstractIntSetting = object : AbstractIntSetting { - override val key = "fx_add" - - override fun getInt(needsGlobal: Boolean): Int = -1 - - override fun setInt(value: Int) { - if (value < 0 || value >= files.size) { - return - } - NativePostProcessing.append(files[value], techniques[value]) - NativePostProcessing.persist() - settingsViewModel.setReloadListAndNotifyDataset(true) - } - - override val defaultValue = -1 - override fun getValueAsString(needsGlobal: Boolean): String = "" - override fun reset() {} - override val isRuntimeModifiable = true - override val pairedSettingKey = "" - override val isSwitchable = false - override val isSaveable = true - override var global: Boolean - get() = true - set(_) {} - } - - private fun buildSlotSelector( - index: Int, - entry: NativePostProcessing.ChainEntry, - files: List, - techniques: List - ): AbstractIntSetting = object : AbstractIntSetting { - override val key = "fx_slot_$index" - - override fun getInt(needsGlobal: Boolean): Int { - for (i in files.indices) { - if (files[i] == entry.file && techniques[i] == entry.technique) { - return i + 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) + } + ) } } - return -1 } - - override fun setInt(value: Int) { - NativePostProcessing.replace(index, files[value], techniques[value]) - NativePostProcessing.persist() - settingsViewModel.setReloadListAndNotifyDataset(true) - } - - override val defaultValue = 0 - override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() - override fun reset() {} - override val isRuntimeModifiable = true - override val pairedSettingKey = "" - override val isSwitchable = false - override val isSaveable = true - override var global: Boolean - get() = true - set(_) {} } private fun addUniform( diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/CardViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/CardViewHolder.kt new file mode 100644 index 0000000000..43b0042b92 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/CardViewHolder.kt @@ -0,0 +1,42 @@ +// 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.ListItemSettingCardBinding +import org.yuzu.yuzu_emu.features.settings.model.view.CardSetting +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 CardViewHolder(val binding: ListItemSettingCardBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: CardSetting + + override fun bind(item: SettingsItem) { + setting = item as CardSetting + + binding.textCardName.text = item.title + binding.textCardDescription.text = item.description + binding.textCardDescription.setVisible(item.description.isNotEmpty()) + + var rotation = 0f + if (setting.expanded) { + rotation = 180f + } + binding.cardExpand.rotation = rotation + } + + override fun onClick(clicked: View) { + val target = binding.cardExpand + var rotation = 180f + if (setting.expanded) { + rotation = 0f + } + target.animate().rotation(rotation).setDuration(200).start() + setting.runnable.invoke() + } + + override fun onLongClick(clicked: View): Boolean = true +} 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 index 757a3ee125..b43fa9b4a7 100644 --- 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 @@ -42,11 +42,40 @@ object NativePostProcessing { external fun store() + 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() { store() NativeConfig.saveGlobalConfig() } + fun persistFor(perGame: Boolean) { + store() + if (perGame) { + NativeConfig.savePerGameConfig() + } else { + NativeConfig.saveGlobalConfig() + } + } + external fun getShaderDirectory(): String @@ -100,6 +129,28 @@ object NativePostProcessing { 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()) diff --git a/src/android/app/src/main/jni/native_post_processing.cpp b/src/android/app/src/main/jni/native_post_processing.cpp index 10f312a955..71d5227eb1 100644 --- a/src/android/app/src/main/jni/native_post_processing.cpp +++ b/src/android/app/src/main/jni/native_post_processing.cpp @@ -10,7 +10,9 @@ #include "common/android/android_common.h" #ifdef HAS_RESHADE #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" #endif namespace { @@ -212,4 +214,101 @@ jstring Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_getShaderDirectory(JN #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 + 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 + 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 + 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 + 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 + 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/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/list_item_setting_card.xml b/src/android/app/src/main/res/layout/list_item_setting_card.xml new file mode 100644 index 0000000000..4b0c21a3ae --- /dev/null +++ b/src/android/app/src/main/res/layout/list_item_setting_card.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index ab9aaf106e..635e9f28d9 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -301,9 +301,14 @@ Post-Processing Effects ReShade FX effects applied after rendering Configure the effect chain for this game - Effect Add effect Remove + Remove effects + Preset active. Edit it in Post-Processing Effects before starting a game. + Preset active, with your own changes. + Preset + No preset + Reset preset values Move up Move down Reset to defaults diff --git a/src/common/fs/fs_paths.h b/src/common/fs/fs_paths.h index e4b4e3223c..ae25846fc2 100644 --- a/src/common/fs/fs_paths.h +++ b/src/common/fs/fs_paths.h @@ -23,6 +23,7 @@ #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" diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp index 02cc799d84..b895aac54c 100644 --- a/src/common/fs/path_util.cpp +++ b/src/common/fs/path_util.cpp @@ -160,6 +160,7 @@ 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); diff --git a/src/common/fs/path_util.h b/src/common/fs/path_util.h index b99828f738..983e4917e8 100644 --- a/src/common/fs/path_util.h +++ b/src/common/fs/path_util.h @@ -26,6 +26,7 @@ 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. diff --git a/src/common/settings.h b/src/common/settings.h index a40240e835..b6ae7ac494 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -388,13 +388,29 @@ struct Values { true, true}; - Setting post_shader_chain{linkage, - std::string(), - "post_shader_chain", - Category::Renderer, - Specialization::Default, - 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/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index 4b22a56793..78ed4ab8cf 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -312,6 +312,18 @@ if (ENABLE_RESHADE) 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 @@ -319,9 +331,12 @@ if (ENABLE_RESHADE) 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) 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 index 25f07f9cd6..88d0da7091 100644 --- a/src/video_core/post_processing/fx_chain.cpp +++ b/src/video_core/post_processing/fx_chain.cpp @@ -204,6 +204,14 @@ void FxChain::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()) { diff --git a/src/video_core/post_processing/fx_chain.h b/src/video_core/post_processing/fx_chain.h index 7bee523918..ce12e65a79 100644 --- a/src/video_core/post_processing/fx_chain.h +++ b/src/video_core/post_processing/fx_chain.h @@ -51,6 +51,8 @@ public: 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; 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..0d10962b9c --- /dev/null +++ b/src/video_core/post_processing/fx_preset.cpp @@ -0,0 +1,260 @@ +// 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()) != 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 6d79f07521..1868142f51 100644 --- a/src/video_core/renderer_vulkan/present/layer.cpp +++ b/src/video_core/renderer_vulkan/present/layer.cpp @@ -235,8 +235,10 @@ void Layer::SetPostProcessPass(const Device& device) { }; const u64 generation = VideoCore::FxChain::Instance().Snapshot().generation; + const bool enabled = Settings::values.post_shader_enabled.GetValue(); - if (post_process_generation == generation && post_process_extent.width == render_area.width && + if (post_process_generation == generation && post_process_enabled == enabled && + post_process_extent.width == render_area.width && post_process_extent.height == render_area.height) { return; } @@ -246,10 +248,11 @@ void Layer::SetPostProcessPass(const Device& device) { } post_process_generation = generation; + post_process_enabled = enabled; post_process_extent = render_area; post_process.reset(); - if (VideoCore::FxChain::Instance().Size() == 0) { + if (!enabled || VideoCore::FxChain::Instance().Size() == 0) { return; } diff --git a/src/video_core/renderer_vulkan/present/layer.h b/src/video_core/renderer_vulkan/present/layer.h index 4cc911713f..f290ba5bb4 100644 --- a/src/video_core/renderer_vulkan/present/layer.h +++ b/src/video_core/renderer_vulkan/present/layer.h @@ -104,6 +104,7 @@ private: #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/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp index 1f0b0c640a..4fd4c0233b 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp @@ -187,7 +187,7 @@ try } #ifdef HAS_RESHADE - VideoCore::FxChain::Instance().EnsureLoadedFromSettings(); + VideoCore::FxChain::Instance().LoadFromSettings(); #endif Report(); diff --git a/src/yuzu/configuration/configure_post_processing.cpp b/src/yuzu/configuration/configure_post_processing.cpp index 8b1d4c756b..6fe46da6c5 100644 --- a/src/yuzu/configuration/configure_post_processing.cpp +++ b/src/yuzu/configuration/configure_post_processing.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -20,8 +21,10 @@ #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 { @@ -74,6 +77,75 @@ ConfigurePostProcessing::ConfigurePostProcessing(QWidget* parent) : QDialog(pare 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); @@ -112,8 +184,12 @@ ConfigurePostProcessing::ConfigurePostProcessing(QWidget* parent) : QDialog(pare root->addWidget(buttons); VideoCore::ReloadFxCatalog(); + VideoCore::ReloadFxPresetCatalog(); + VideoCore::FxChain::Instance().LoadFromSettings(); VideoCore::FxChain::Instance().DropUnknownEntries(); + PopulatePresetCombo(); RebuildRows(); + RefreshPresetStatus(); } ConfigurePostProcessing::~ConfigurePostProcessing() = default; @@ -121,6 +197,47 @@ 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, diff --git a/src/yuzu/configuration/configure_post_processing.h b/src/yuzu/configuration/configure_post_processing.h index d9d40e230f..5873bbaa01 100644 --- a/src/yuzu/configuration/configure_post_processing.h +++ b/src/yuzu/configuration/configure_post_processing.h @@ -5,7 +5,9 @@ #include +class QCheckBox; class QComboBox; +class QLabel; class QVBoxLayout; class QWidget; @@ -29,7 +31,12 @@ private: 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{}; };