aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt441
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxPresetNameSetting.kt36
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FxUniformSetting.kt67
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt1
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt1
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxButtonSetting.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxPresetSetting.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxShaderCardSetting.kt19
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/FxToolbarSetting.kt19
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt4
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt32
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt214
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxButtonViewHolder.kt28
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxPresetViewHolder.kt35
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxShaderCardViewHolder.kt100
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/FxToolbarViewHolder.kt57
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt35
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativePostProcessing.kt242
-rw-r--r--src/android/app/src/main/jni/CMakeLists.txt1
-rw-r--r--src/android/app/src/main/jni/native_config.cpp14
-rw-r--r--src/android/app/src/main/jni/native_post_processing.cpp350
-rw-r--r--src/android/app/src/main/res/drawable/ic_post_processing.xml10
-rw-r--r--src/android/app/src/main/res/drawable/shader_card_background.xml13
-rw-r--r--src/android/app/src/main/res/layout/item_quick_settings_add.xml58
-rw-r--r--src/android/app/src/main/res/layout/item_quick_settings_preset.xml47
-rw-r--r--src/android/app/src/main/res/layout/item_quick_settings_shader.xml82
-rw-r--r--src/android/app/src/main/res/layout/item_setting_fx_actions.xml20
-rw-r--r--src/android/app/src/main/res/layout/item_setting_fx_slider.xml41
-rw-r--r--src/android/app/src/main/res/layout/list_item_setting_fx_button.xml19
-rw-r--r--src/android/app/src/main/res/layout/list_item_setting_fx_preset.xml65
-rw-r--r--src/android/app/src/main/res/layout/list_item_setting_fx_shader.xml82
-rw-r--r--src/android/app/src/main/res/layout/list_item_setting_fx_toolbar.xml64
-rw-r--r--src/android/app/src/main/res/values/strings.xml19
-rw-r--r--src/common/fs/fs_paths.h2
-rw-r--r--src/common/fs/path_util.cpp2
-rw-r--r--src/common/fs/path_util.h2
-rw-r--r--src/common/settings.h24
-rw-r--r--src/qt_common/config/shared_translation.cpp1
-rw-r--r--src/video_core/CMakeLists.txt44
-rw-r--r--src/video_core/post_processing/GenerateBundledEffects.cmake50
-rw-r--r--src/video_core/post_processing/GenerateBundledPresets.cmake50
-rw-r--r--src/video_core/post_processing/fx_chain.cpp321
-rw-r--r--src/video_core/post_processing/fx_chain.h86
-rw-r--r--src/video_core/post_processing/fx_compile.cpp105
-rw-r--r--src/video_core/post_processing/fx_compile.h29
-rw-r--r--src/video_core/post_processing/fx_effect.cpp307
-rw-r--r--src/video_core/post_processing/fx_effect.h77
-rw-r--r--src/video_core/post_processing/fx_preset.cpp261
-rw-r--r--src/video_core/post_processing/fx_preset.h43
-rw-r--r--src/video_core/renderer_vulkan/present/layer.cpp50
-rw-r--r--src/video_core/renderer_vulkan/present/layer.h12
-rw-r--r--src/video_core/renderer_vulkan/present/post_process.cpp899
-rw-r--r--src/video_core/renderer_vulkan/present/post_process.h140
-rw-r--r--src/video_core/renderer_vulkan/renderer_vulkan.cpp7
-rw-r--r--src/yuzu/CMakeLists.txt6
-rw-r--r--src/yuzu/configuration/configure_post_processing.cpp511
-rw-r--r--src/yuzu/configuration/configure_post_processing.h42
-rw-r--r--src/yuzu/main.ui6
-rw-r--r--src/yuzu/main_window.cpp24
-rw-r--r--src/yuzu/main_window.h9
62 files changed, 5381 insertions, 0 deletions
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<Int>()
+
+ private fun forgetShaderSlot(index: Int) {
+ val shifted = mutableSetOf<Int>()
+ 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<String>,
+ 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<ViewGroup>(R.id.setting_header)
+ val titleView = itemView.findViewById<TextView>(R.id.setting_title)
+ val valueView = itemView.findViewById<TextView>(R.id.setting_value)
+ val expandIcon = itemView.findViewById<android.widget.ImageView>(R.id.expand_icon)
+ val radioGroup = itemView.findViewById<RadioGroup>(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<ViewGroup>(R.id.slider_container)
+ val titleView = itemView.findViewById<TextView>(R.id.slider_title)
+ val valueDisplay = itemView.findViewById<TextView>(R.id.slider_value_display)
+ val slider = itemView.findViewById<com.google.android.material.slider.Slider>(
+ 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<DrawerLayout>(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<ViewGroup>(R.id.shader_header)
+ val titleView = itemView.findViewById<TextView>(R.id.shader_title)
+ val summaryView = itemView.findViewById<TextView>(R.id.shader_summary)
+ val removeView = itemView.findViewById<android.widget.ImageView>(R.id.shader_remove)
+ val expandIcon = itemView.findViewById<android.widget.ImageView>(R.id.shader_expand)
+ val bodyView = itemView.findViewById<ViewGroup>(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<String>,
+ 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<com.google.android.material.button.MaterialButton>(
+ R.id.add_button
+ )
+ val removeButton =
+ itemView.findViewById<com.google.android.material.button.MaterialButton>(
+ R.id.remove_all_button
+ )
+ val choiceGroup = itemView.findViewById<RadioGroup>(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<TextView>(R.id.preset_title)
+ val summaryView = itemView.findViewById<TextView>(R.id.preset_summary)
+ val switchView = itemView.findViewById<MaterialSwitch>(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<String>()
+ val files = mutableListOf<String>()
+ val techniques = mutableListOf<String>()
+
+ 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<NativePostProcessing.Uniform>,
+ 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<SettingsItem>()
+ private val expandedShaderSlots = mutableSetOf<Int>()
+
+ 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<SettingsItem>) {
+ 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<String>()
+ val summaries = mutableListOf<String>()
+ val files = mutableListOf<String>()
+ val techniques = mutableListOf<String>()
+ 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<NativePostProcessing.Uniform>()
+ 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<SettingsItem>) {
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<ViewGroup>(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
@@ -173,6 +173,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,
R.string.lossless_scaling_description,
R.drawable.ic_duck,
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<String>,
+ val defaults: List<Float>
+ ) {
+ 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<String>,
+ val uniforms: List<Uniform>
+ ) {
+ 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<Preset> {
+ val out = mutableListOf<Preset>()
+ 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<Effect> {
+ val out = mutableListOf<Effect>()
+ 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<ChainEntry> {
+ val out = mutableListOf<ChainEntry>()
+ 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<String> {
+ if (this == null) {
+ return emptyList()
+ }
+ val out = mutableListOf<String>()
+ for (i in 0 until length()) {
+ out.add(optString(i))
+ }
+ return out
+ }
+
+ private fun JSONArray?.toFloatList(): List<Float> {
+ if (this == null) {
+ return emptyList()
+ }
+ val out = mutableListOf<Float>()
+ for (i in 0 until length()) {
+ out.add(optDouble(i, 0.0).toFloat())
+ }
+ return out
+ }
+
+ private fun JSONArray?.toUniformList(): List<Uniform> {
+ if (this == null) {
+ return emptyList()
+ }
+ val out = mutableListOf<Uniform>()
+ 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<AndroidConfig> global_config;
std::unique_ptr<AndroidConfig> per_game_config;
+#ifdef HAS_RESHADE
+static void ResetFxChainToGlobal() {
+ VideoCore::UseGlobalFxSettings();
+ VideoCore::FxChain::Instance().LoadFromSettings();
+}
+#else
+static void ResetFxChainToGlobal() {}
+#endif
+
template <typename T>
Settings::Setting<T>* 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<AndroidConfig>();
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 <array>
+#include <memory>
+#include <string>
+
+#include <jni.h>
+#include <nlohmann/json.hpp>
+
+#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<AndroidConfig> 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<int>(uniform.kind);
+ out["uiType"] = static_cast<int>(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<f32, 4> 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<size_t>(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<size_t>(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<size_t>(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<size_t>(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<size_t>(index), Common::Android::GetJString(env, juniform));
+ return value[static_cast<size_t>(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<jboolean>(VideoCore::FxChain::Instance().HasValue(
+ static_cast<size_t>(index), Common::Android::GetJString(env, juniform)));
+#else
+ return static_cast<jboolean>(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<size_t>(index);
+
+ auto current = chain.GetValue(slot, uniform);
+ if (!chain.HasValue(slot, uniform)) {
+ current = DefaultValueOf(slot, uniform);
+ }
+
+ current[static_cast<size_t>(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<jboolean>(VideoCore::IsActiveFxPresetModified());
+#else
+ return static_cast<jboolean>(false);
+#endif
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_applyPreset(JNIEnv* env, jobject obj,
+ jstring jname) {
+#ifdef HAS_RESHADE
+ BeginFxEdit();
+ return static_cast<jboolean>(
+ VideoCore::ApplyFxPreset(Common::Android::GetJString(env, jname)));
+#else
+ return static_cast<jboolean>(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<jboolean>(
+ VideoCore::SaveFxPreset(Common::Android::GetJString(env, jname),
+ Common::Android::GetJString(env, jdescription)));
+#else
+ return static_cast<jboolean>(false);
+#endif
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_utils_NativePostProcessing_deletePreset(JNIEnv* env, jobject obj,
+ jstring jname) {
+#ifdef HAS_RESHADE
+ BeginFxEdit();
+ return static_cast<jboolean>(
+ VideoCore::DeleteFxPreset(Common::Android::GetJString(env, jname)));
+#else
+ return static_cast<jboolean>(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<jboolean>(Settings::values.post_shader_enabled.GetValue());
+#else
+ return static_cast<jboolean>(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 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:fillType="evenOdd"
+ android:pathData="M8.5,3 L19.4,3 Q21,3 21,4.6 L21,16.5 L19.4,16.5 L19.4,4.6 L8.5,4.6 Z M5,7 L15,7 Q17,7 17,9 L17,19 Q17,21 15,21 L5,21 Q3,21 3,19 L3,9 Q3,7 5,7 Z M5.2,8.6 L14.8,8.6 Q15.4,8.6 15.4,9.2 L15.4,18.8 Q15.4,19.4 14.8,19.4 L5.2,19.4 Q4.6,19.4 4.6,18.8 L4.6,9.2 Q4.6,8.6 5.2,8.6 Z M10,10.9 A3.1,3.1 0 0 1 10,17.1 Z"/>
+</vector>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+
+ <solid android:color="?attr/colorSurfaceVariant" />
+
+ <corners android:radius="16dp" />
+
+ <stroke
+ android:width="1dp"
+ android:color="?attr/colorOutline" />
+
+</shape>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="4dp"
+ android:baselineAligned="false"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/add_button"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:minHeight="48dp"
+ android:paddingStart="12dp"
+ android:paddingEnd="12dp"
+ android:text="@string/post_processing_add"
+ app:icon="@drawable/ic_add"
+ app:iconPadding="6dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/remove_all_button"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_marginStart="8dp"
+ android:layout_weight="1"
+ android:minHeight="48dp"
+ android:paddingStart="12dp"
+ android:paddingEnd="12dp"
+ android:text="@string/post_processing_remove_all"
+ android:visibility="gone"
+ app:icon="@drawable/ic_delete"
+ app:iconPadding="6dp" />
+
+ </LinearLayout>
+
+ <RadioGroup
+ android:id="@+id/add_choices"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:paddingTop="4dp"
+ android:paddingBottom="8dp"
+ android:visibility="gone" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
+ android:background="@drawable/shader_card_background"
+ android:orientation="horizontal"
+ android:paddingStart="20dp"
+ android:paddingEnd="16dp"
+ android:paddingTop="16dp"
+ android:paddingBottom="16dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/preset_title"
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/preset_summary"
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/preset_switch"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_marginStart="16dp"
+ android:minHeight="48dp" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
+ android:background="@drawable/shader_card_background"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:id="@+id/shader_header"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingStart="16dp"
+ android:paddingEnd="8dp"
+ android:paddingTop="12dp"
+ android:paddingBottom="12dp"
+ android:background="?attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/shader_title"
+ style="@style/TextAppearance.Material3.TitleSmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/shader_summary"
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:id="@+id/shader_remove"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:layout_gravity="center_vertical"
+ android:padding="8dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:clickable="true"
+ android:focusable="true"
+ android:src="@drawable/ic_delete"
+ android:contentDescription="@string/post_processing_remove"
+ app:tint="?attr/colorOnSurfaceVariant" />
+
+ <ImageView
+ android:id="@+id/shader_expand"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:layout_marginStart="4dp"
+ android:src="@drawable/ic_dropdown_arrow"
+ android:contentDescription=""
+ app:tint="?attr/colorPrimary" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/shader_body"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingBottom="8dp"
+ android:visibility="gone" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fx_reset"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="48dp"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:text="@string/post_processing_reset" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingStart="20dp"
+ android:paddingEnd="20dp"
+ android:paddingTop="8dp"
+ android:paddingBottom="4dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/fx_slider_title"
+ style="@style/TextAppearance.Material3.TitleSmall"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/fx_slider_value"
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="8dp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <com.google.android.material.slider.Slider
+ android:id="@+id/fx_slider"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:stepSize="1"
+ android:valueFrom="0"
+ android:valueTo="100" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
+ android:orientation="horizontal">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fx_button"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="48dp"
+ android:ellipsize="end"
+ android:maxLines="1" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
+ android:background="@drawable/shader_card_background"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:id="@+id/preset_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true"
+ android:orientation="horizontal"
+ android:paddingStart="16dp"
+ android:paddingEnd="8dp"
+ android:paddingTop="12dp"
+ android:paddingBottom="12dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/preset_name"
+ style="@style/TextAppearance.Material3.TitleSmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/preset_description"
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:id="@+id/preset_delete"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:layout_gravity="center_vertical"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:clickable="true"
+ android:contentDescription="@string/post_processing_preset_delete"
+ android:focusable="true"
+ android:padding="8dp"
+ android:src="@drawable/ic_delete"
+ android:visibility="gone"
+ app:tint="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
+ android:background="@drawable/shader_card_background"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:id="@+id/shader_header"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true"
+ android:orientation="horizontal"
+ android:paddingStart="20dp"
+ android:paddingEnd="8dp"
+ android:paddingTop="14dp"
+ android:paddingBottom="14dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/shader_title"
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/shader_summary"
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:id="@+id/shader_remove"
+ android:layout_width="40dp"
+ android:layout_height="40dp"
+ android:layout_gravity="center_vertical"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:clickable="true"
+ android:contentDescription="@string/post_processing_remove"
+ android:focusable="true"
+ android:padding="8dp"
+ android:src="@drawable/ic_delete"
+ app:tint="?attr/colorOnSurfaceVariant" />
+
+ <ImageView
+ android:id="@+id/shader_expand"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:layout_marginStart="4dp"
+ android:contentDescription=""
+ android:src="@drawable/ic_dropdown_arrow"
+ app:tint="?attr/colorPrimary" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/shader_body"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingBottom="8dp"
+ android:visibility="gone" />
+
+</LinearLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ android:layout_marginTop="6dp"
+ android:layout_marginBottom="6dp"
+ android:baselineAligned="false"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fx_add"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:minHeight="48dp"
+ android:paddingStart="12dp"
+ android:paddingEnd="12dp"
+ android:text="@string/post_processing_add"
+ app:icon="@drawable/ic_add"
+ app:iconPadding="6dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fx_presets"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_marginStart="8dp"
+ android:layout_weight="1.4"
+ android:ellipsize="end"
+ android:maxLines="1"
+ android:minHeight="48dp"
+ android:paddingStart="12dp"
+ android:paddingEnd="12dp"
+ android:text="@string/post_processing_presets"
+ app:icon="@drawable/ic_dropdown_arrow"
+ app:iconGravity="textEnd"
+ app:iconPadding="6dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fx_create_preset"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:layout_marginStart="8dp"
+ android:contentDescription="@string/post_processing_preset_new"
+ app:icon="@drawable/ic_add" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/fx_remove_all"
+ style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:layout_marginStart="8dp"
+ android:contentDescription="@string/post_processing_remove_all"
+ app:icon="@drawable/ic_delete" />
+
+</LinearLayout>
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 @@
<string name="gpu_driver_fetcher">GPU driver fetcher</string>
<string name="gpu_driver_manager">GPU driver manager</string>
<string name="install_gpu_driver_description">Install alternative drivers for potentially better performance or accuracy</string>
+ <string name="post_processing">Post-Processing Effects</string>
+ <string name="post_processing_description">ReShade FX effects applied after rendering</string>
+ <string name="post_processing_per_game_description">Configure the effect chain for this game</string>
+ <string name="post_processing_add">Add effect</string>
+ <string name="post_processing_remove">Remove</string>
+ <string name="post_processing_open_list">Open list</string>
+ <string name="post_processing_close_list">Close list</string>
+ <string name="post_processing_remove_all">Remove effects</string>
+ <string name="post_processing_preset_locked">Preset active. Edit it in Post-Processing Effects before starting a game.</string>
+ <string name="post_processing_preset_modified">Values changed from the original preset.</string>
+ <string name="post_processing_presets">Presets</string>
+ <string name="post_processing_preset_new">New preset</string>
+ <string name="post_processing_preset_new_description">Saves the effects you have loaded, with their current values, as a preset you can pick later.</string>
+ <string name="post_processing_preset_name_invalid">Give the preset a name without an equals sign.</string>
+ <string name="post_processing_preset_none">No preset</string>
+ <string name="post_processing_preset_delete">Delete preset</string>
+ <string name="post_processing_preset_reset">Reset preset values</string>
+ <string name="post_processing_reset">Reset to default</string>
+ <string name="post_processing_empty">No effects found. Place .fx files in this folder:</string>
<string name="frame_gen">Frame generation</string>
<string name="frame_gen_per_game_description">Configure frame generation for this game</string>
<string name="frame_gen_description">Insert interpolated frames between rendered ones using Lossless Scaling. Forces FIFO presentation while enabled.</string>
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<std::string> post_shader_chain{linkage,
+ std::string(),
+ "post_shader_chain",
+ Category::Renderer,
+ Specialization::Default,
+ true,
+ true};
+
+ SwitchableSetting<std::string> post_shader_preset{linkage,
+ std::string(),
+ "post_shader_preset",
+ Category::Renderer,
+ Specialization::Default,
+ true,
+ true};
+
+ SwitchableSetting<bool> post_shader_enabled{linkage,
+ true,
+ "post_shader_enabled",
+ Category::Renderer,
+ Specialization::Default,
+ true,
+ true};
+
SwitchableSetting<bool> 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<TranslationMap> 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 <string_view>
+
+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 <string_view>
+
+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 <algorithm>
+#include <cstdlib>
+
+#include <fmt/format.h>
+
+#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<std::string_view> Split(std::string_view value, char separator) {
+ std::vector<std::string_view> 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<FxChainEntry> ParseFxChain(std::string_view value) {
+ std::vector<FxChainEntry> 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<f32, 4> 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<const FxChainEntry> 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<FxChainEntry> 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<std::ptrdiff_t>(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<std::ptrdiff_t>(index) + delta;
+ if (target < 0 || target >= static_cast<std::ptrdiff_t>(entries.size())) {
+ return;
+ }
+ std::swap(entries[index], entries[static_cast<size_t>(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<FxChainEntry> 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<f32, 4>& value) {
+ std::scoped_lock lock{mutex};
+ if (index >= entries.size()) {
+ return;
+ }
+ entries[index].values[std::string(uniform)] = value;
+}
+
+std::array<f32, 4> 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<std::string, std::array<f32, 4>> 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 <array>
+#include <atomic>
+#include <map>
+#include <mutex>
+#include <span>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "common/common_types.h"
+
+namespace VideoCore {
+
+struct FxChainEntry {
+ std::string file;
+ std::string technique;
+ std::map<std::string, std::array<f32, 4>> values;
+
+ bool operator==(const FxChainEntry&) const = default;
+};
+
+struct FxChainSnapshot {
+ std::vector<FxChainEntry> entries;
+ u64 generation{};
+};
+
+std::vector<FxChainEntry> ParseFxChain(std::string_view value);
+
+std::string SerializeFxChain(std::span<const FxChainEntry> entries);
+
+void UseGlobalFxSettings();
+
+void UsePerGameFxSettings();
+
+class FxChain {
+public:
+ static FxChain& Instance();
+
+ FxChainSnapshot Snapshot() const;
+
+ std::vector<FxChainEntry> 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<FxChainEntry> next);
+
+ void SetValue(size_t index, std::string_view uniform, const std::array<f32, 4>& value);
+
+ std::array<f32, 4> GetValue(size_t index, std::string_view uniform) const;
+
+ std::map<std::string, std::array<f32, 4>> 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<FxChainEntry> entries;
+ std::atomic<u64> 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 <cstring>
+#include <memory>
+#include <set>
+
+#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<reshadefx::codegen> 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<std::string> 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<u32> 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 <filesystem>
+#include <map>
+#include <string>
+#include <vector>
+
+#include "common/common_types.h"
+#include "effect_module.hpp"
+
+namespace VideoCore {
+
+struct FxCompileResult {
+ reshadefx::effect_module module;
+ std::map<std::string, std::vector<u32>> 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 <algorithm>
+
+#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<FxEffectDesc> catalog;
+bool catalog_scanned = false;
+
+const reshadefx::annotation* FindAnnotation(const std::vector<reshadefx::annotation>& 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<reshadefx::annotation>& 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<reshadefx::annotation>& 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<f32>(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<std::string> SplitItems(const std::string& items) {
+ std::vector<std::string> 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<u32>(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<f32>(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<std::filesystem::path> GetFxIncludePaths(const std::filesystem::path& effect_path) {
+ const auto root = GetFxRootDirectory();
+ std::vector<std::filesystem::path> 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<std::filesystem::path> 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<FxEffectDesc>& 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 <array>
+#include <filesystem>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#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<std::string> items;
+ std::array<f32, 4> default_value{};
+};
+
+struct FxEffectDesc {
+ std::string file;
+ std::string name;
+ std::string label;
+ std::string description;
+ std::vector<std::string> techniques;
+ std::vector<FxUniformDesc> uniforms;
+ std::string error;
+
+ bool Valid() const {
+ return error.empty() && !techniques.empty();
+ }
+};
+
+std::filesystem::path GetFxRootDirectory();
+
+std::vector<std::filesystem::path> 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<FxEffectDesc>& 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 <algorithm>
+
+#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<FxPresetDesc> 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<std::string> 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<std::filesystem::path> 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<FxPresetDesc>& 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 <filesystem>
+#include <string>
+#include <string_view>
+#include <vector>
+
+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<FxPresetDesc>& 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<std::monostate, FXAA, SMAA> anti_alias{};
std::variant<std::monostate, SGSR, FSR> sr_filter{};
+#ifdef HAS_RESHADE
+ std::optional<PostProcessChain> post_process{};
+ u64 post_process_generation{};
+ bool post_process_enabled{};
+ VkExtent2D post_process_extent{};
+#endif
std::vector<u64> 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 <algorithm>
+#include <cstring>
+#include <random>
+
+#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<VkPipelineShaderStageCreateInfo, 2> 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<VkColorComponentFlags>(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<u32>(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<u32>(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<u32>(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<u32>(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<f32>(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<u32>(binding_count, binding.entry_point_binding + 1);
+ }
+
+ const std::vector<VkDescriptorType> sampler_types(
+ std::max<size_t>(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<u32>(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<u32>(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<u32>(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<size_t>(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<VkDescriptorSetLayout> 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<VkDescriptorSetLayout> 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<u8> staging(effect.uniform_size, 0);
+ const f32 elapsed =
+ std::chrono::duration<f32>(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<f32, 4> 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<f32>(m_frame_count);
+ break;
+ case UniformSource::Timer:
+ value[0] = elapsed * 1000.0f;
+ break;
+ case UniformSource::Random: {
+ const int low = static_cast<int>(uniform.args[0]);
+ int high = static_cast<int>(uniform.args[1]);
+ if (high <= low) {
+ high = low + 1;
+ }
+ std::uniform_int_distribution<int> dist(low, high);
+ value[0] = static_cast<f32>(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<s32>(value[i]);
+ std::memcpy(staging.data() + offset, &element, sizeof(s32));
+ }
+ }
+ }
+
+ const std::span<u8> 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<VkDescriptorImageInfo> image_infos;
+ std::vector<VkWriteDescriptorSet> 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<f32>(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 <array>
+#include <chrono>
+#include <map>
+#include <string>
+#include <vector>
+
+#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<f32, 4> fallback{};
+ std::array<f32, 4> 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<SamplerBinding> sampler_bindings{};
+ std::vector<vk::Framebuffer> 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<std::string, vk::ShaderModule> shaders{};
+ std::vector<Texture> textures{};
+ std::vector<Sampler> samplers{};
+ std::vector<Pass> passes{};
+ std::vector<UniformWrite> uniforms{};
+ u32 uniform_size{};
+ std::vector<vk::Buffer> 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<vk::Image, 2> images{};
+ std::array<vk::ImageView, 2> 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<Effect> m_effects{};
+ std::vector<FrameImages> 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
$<$<VERSION_LESS:$<CXX_COMPILER_VERSION>,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 <array>
+#include <cmath>
+#include <string>
+
+#include <QAction>
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDialogButtonBox>
+#include <QFrame>
+#include <QGridLayout>
+#include <QInputDialog>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QMenu>
+#include <QPushButton>
+#include <QScrollArea>
+#include <QSlider>
+#include <QToolButton>
+#include <QVBoxLayout>
+
+#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<float, 4> CurrentValue(int index, const VideoCore::FxUniformDesc& uniform) {
+ auto& chain = VideoCore::FxChain::Instance();
+ if (chain.HasValue(static_cast<size_t>(index), uniform.name)) {
+ return chain.GetValue(static_cast<size_t>(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<int>(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<int>(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<float, 4> next{};
+ if (checked) {
+ next[0] = 1.0f;
+ }
+ VideoCore::FxChain::Instance().SetValue(static_cast<size_t>(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<int>(i));
+ }
+ if (combo->count() == 0) {
+ combo->addItem(tr("Enabled"), 1);
+ combo->addItem(tr("Disabled"), 0);
+ }
+ const int current = static_cast<int>(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<float, 4> next{};
+ next[0] = static_cast<float>(selected);
+ VideoCore::FxChain::Instance().SetValue(static_cast<size_t>(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<int>(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<float>(steps) * desc.ui_step;
+ VideoCore::FxChain::Instance().SetValue(static_cast<size_t>(index), name, next);
+ VideoCore::FxChain::Instance().StoreToSettings();
+ value_label->setText(FormatValue(desc, next[component]));
+ });
+
+ grid->addWidget(name_label, static_cast<int>(component), 0);
+ grid->addWidget(slider, static_cast<int>(component), 1);
+ grid->addWidget(value_label, static_cast<int>(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<size_t>(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<size_t>(index), -1);
+ ApplyStructuralChange();
+ });
+ header->addWidget(up_button);
+
+ auto* down_button = new QToolButton(group);
+ down_button->setText(QStringLiteral("▼"));
+ down_button->setEnabled(static_cast<size_t>(index) + 1 < VideoCore::FxChain::Instance().Size());
+ connect(down_button, &QToolButton::clicked, this, [this, index]() {
+ VideoCore::FxChain::Instance().Move(static_cast<size_t>(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<size_t>(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<size_t>(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<int>(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 <QDialog>
+
+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 @@
<addaction name="action_Show_Filter_Bar"/>
<addaction name="action_Show_Status_Bar"/>
<addaction name="action_Show_Performance_Overlay"/>
+ <addaction name="action_Post_Processing_Shaders"/>
<addaction name="separator"/>
<addaction name="menu_Reset_Window_Size"/>
<addaction name="menu_View_Debugging"/>
@@ -604,6 +605,11 @@
<string>Show &amp;Performance Overlay</string>
</property>
</action>
+ <action name="action_Post_Processing_Shaders">
+ <property name="text">
+ <string>Post-Processing &amp;Shaders...</string>
+ </property>
+ </action>
<action name="action_Carousel_View">
<property name="checkable">
<bool>true</bool>
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;