aboutsummaryrefslogtreecommitdiff
path: root/src/android/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/android/app')
-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
35 files changed, 2271 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>