aboutsummaryrefslogtreecommitdiff
path: root/src/video_core/post_processing
diff options
context:
space:
mode:
Diffstat (limited to 'src/video_core/post_processing')
-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
10 files changed, 1329 insertions, 0 deletions
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