aboutsummaryrefslogtreecommitdiff
path: root/src/video_core/renderer_vulkan/present
diff options
context:
space:
mode:
authorCamilleLaVey <camillelavey99@gmail.com>2026-08-17 00:30:19 +0200
committercrueter <crueter@eden-emu.dev>2026-08-17 00:30:19 +0200
commit2000fdfb7b27e5e2546098b44268fc74887b22a5 (patch)
tree924d533de5e75f9f08e02f67bddc946690ea5665 /src/video_core/renderer_vulkan/present
parentdc95cd09eea9749250fe31a3072684d341d19417 (diff)
[vulkan, android] Initial implementation of LSFG-VK (#4263)
Pretty much self explanatory, own implementation based on LSFG-VK available with some additional reverse engineer work for the actual use of shaders inside Lossless.dll, we don't store/ bundle any tools that bypass security and self ownership: to use this feature is required to own your own copy of Lossless Scaling and I encourage to support developers behind this work. Special thanks: 1.- THS - Original developer behind Lossless Scaling 2.- PancakeTAS - Developer behind LSFG-VK 3.- The Big Smolio The Sleeper (@gidoly) Co-authored-by: lizzie <lizzie@eden-emu.dev> Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4263
Diffstat (limited to 'src/video_core/renderer_vulkan/present')
-rw-r--r--src/video_core/renderer_vulkan/present/frame_gen.cpp361
-rw-r--r--src/video_core/renderer_vulkan/present/frame_gen.h57
-rw-r--r--src/video_core/renderer_vulkan/present/frame_gen_pacer.cpp240
-rw-r--r--src/video_core/renderer_vulkan/present/frame_gen_pacer.h46
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_alpha.cpp140
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_alpha.h62
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_beta.cpp147
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_beta.h48
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_chain.cpp103
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_chain.h87
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_common.cpp339
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_common.h227
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_delta.cpp283
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_delta.h63
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_gamma.cpp185
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_gamma.h54
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_generate.cpp130
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_generate.h54
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_mipmaps.cpp81
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_mipmaps.h45
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_shaders.cpp37
-rw-r--r--src/video_core/renderer_vulkan/present/lsfg_shaders.h30
-rw-r--r--src/video_core/renderer_vulkan/present/util.cpp38
-rw-r--r--src/video_core/renderer_vulkan/present/util.h10
24 files changed, 2861 insertions, 6 deletions
diff --git a/src/video_core/renderer_vulkan/present/frame_gen.cpp b/src/video_core/renderer_vulkan/present/frame_gen.cpp
new file mode 100644
index 0000000000..b0d16819e5
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/frame_gen.cpp
@@ -0,0 +1,361 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <string>
+#include <vector>
+
+#include "common/fs/file.h"
+#include "common/fs/fs.h"
+#include "common/fs/path_util.h"
+#include "common/settings.h"
+#include "video_core/renderer_vulkan/present/frame_gen.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/renderer_vulkan/vk_present_manager.h"
+#include "video_core/renderer_vulkan/vk_scheduler.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr size_t COLOR_CHANNELS = 4;
+constexpr u64 LSFG_REQUIRED_FRAMES = 2;
+constexpr u32 LSFG_RECURRENCE_FRAMES = 2;
+
+[[nodiscard]] f32 ManualFlowScale() {
+ return static_cast<f32>(Settings::values.frame_gen_flow_scale.GetValue()) / 100.0f;
+}
+
+[[nodiscard]] f32 ConfiguredFlowScale(VkExtent2D guest_extent, VkExtent2D presented_extent) {
+ if (!Settings::values.frame_gen_flow_scale_auto.GetValue()) {
+ return ManualFlowScale();
+ }
+ if (guest_extent.width == 0 || presented_extent.width == 0) {
+ return 1.0f;
+ }
+
+ const f32 rendered_width = static_cast<f32>(guest_extent.width) *
+ Settings::values.resolution_info.up_factor;
+ const f32 ratio = rendered_width / static_cast<f32>(presented_extent.width);
+
+ constexpr f32 FLOW_SCALE_STEPS = 20.0f;
+ const f32 stepped = std::ceil(ratio * FLOW_SCALE_STEPS) / FLOW_SCALE_STEPS;
+ return std::clamp(stepped, 0.25f, 1.0f);
+}
+
+bool IsBlueFirst(VkFormat format) {
+ return format == VK_FORMAT_B8G8R8A8_UNORM || format == VK_FORMAT_B8G8R8A8_SRGB;
+}
+
+VkDeviceSize BytesPerTexel(VkFormat format) {
+ switch (format) {
+ case VK_FORMAT_R8_UNORM:
+ return 1;
+ case VK_FORMAT_R16G16B16A16_SFLOAT:
+ return 8;
+ default:
+ return COLOR_CHANNELS;
+ }
+}
+
+void WritePortablePixmap(const std::filesystem::path& path, const std::string& magic,
+ VkExtent2D extent, std::span<const u8> pixels) {
+ Common::FS::IOFile file{path, Common::FS::FileAccessMode::Write,
+ Common::FS::FileType::BinaryFile};
+ if (!file.IsOpen()) {
+ return;
+ }
+
+ const std::string header = magic + "\n" + std::to_string(extent.width) + " " +
+ std::to_string(extent.height) + "\n255\n";
+ if (file.Write(header) != header.size()) {
+ return;
+ }
+
+ void(file.Write(pixels));
+ void(file.Flush());
+}
+
+void WriteGrayscalePgm(const std::filesystem::path& path, VkExtent2D extent,
+ std::span<const u8> pixels) {
+ const size_t expected = static_cast<size_t>(extent.width) * extent.height;
+ WritePortablePixmap(path, "P5", extent, pixels.subspan(0, std::min(expected, pixels.size())));
+}
+
+void WriteRaw(const std::filesystem::path& path, std::span<const u8> pixels) {
+ Common::FS::IOFile file{path, Common::FS::FileAccessMode::Write,
+ Common::FS::FileType::BinaryFile};
+ if (!file.IsOpen()) {
+ return;
+ }
+ void(file.Write(pixels));
+ void(file.Flush());
+}
+
+void WriteColorPpm(const std::filesystem::path& path, VkExtent2D extent,
+ std::span<const u8> pixels, bool blue_first) {
+ const size_t pixel_count = static_cast<size_t>(extent.width) * extent.height;
+ if (pixels.size() < pixel_count * COLOR_CHANNELS) {
+ return;
+ }
+
+ std::vector<u8> rgb(pixel_count * 3);
+ for (size_t i = 0; i < pixel_count; ++i) {
+ const u8 first = pixels[i * COLOR_CHANNELS];
+ const u8 green = pixels[i * COLOR_CHANNELS + 1];
+ const u8 third = pixels[i * COLOR_CHANNELS + 2];
+ rgb[i * 3] = blue_first ? third : first;
+ rgb[i * 3 + 1] = green;
+ rgb[i * 3 + 2] = blue_first ? first : third;
+ }
+
+ WritePortablePixmap(path, "P6", extent, rgb);
+}
+
+VkImageMemoryBarrier MakeTransitionBarrier(VkImage image, VkAccessFlags src_access,
+ VkAccessFlags dst_access, VkImageLayout old_layout,
+ VkImageLayout new_layout) {
+ return VkImageMemoryBarrier{
+ .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
+ .pNext = nullptr,
+ .srcAccessMask = src_access,
+ .dstAccessMask = dst_access,
+ .oldLayout = old_layout,
+ .newLayout = new_layout,
+ .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .image = image,
+ .subresourceRange{
+ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
+ .baseMipLevel = 0,
+ .levelCount = 1,
+ .baseArrayLayer = 0,
+ .layerCount = 1,
+ },
+ };
+}
+
+VkImageCopy MakeCopyRegion(VkExtent2D extent) {
+ return VkImageCopy{
+ .srcSubresource{
+ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
+ .mipLevel = 0,
+ .baseArrayLayer = 0,
+ .layerCount = 1,
+ },
+ .srcOffset = {},
+ .dstSubresource{
+ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
+ .mipLevel = 0,
+ .baseArrayLayer = 0,
+ .layerCount = 1,
+ },
+ .dstOffset = {},
+ .extent = {.width = extent.width, .height = extent.height, .depth = 1},
+ };
+}
+
+void CopyPresentedFrame(vk::CommandBuffer cmdbuf, VkImage source, LsfgImage& destination,
+ VkExtent2D extent) {
+ const auto make_barrier = MakeTransitionBarrier;
+
+ const std::array before{
+ make_barrier(source, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT,
+ VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL),
+ make_barrier(destination.Handle(), VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
+ destination.Layout(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
+ };
+ cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
+ VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
+ VK_PIPELINE_STAGE_TRANSFER_BIT, 0, {}, {}, before);
+
+ cmdbuf.CopyImage(source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, destination.Handle(),
+ VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, MakeCopyRegion(extent));
+
+ const std::array after{
+ make_barrier(source, VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
+ VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL),
+ make_barrier(destination.Handle(), VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT,
+ VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL),
+ };
+ cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_TRANSFER_BIT,
+ VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
+ VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
+ 0, {}, {}, after);
+
+ destination.SetLayout(VK_IMAGE_LAYOUT_GENERAL);
+}
+
+} // Anonymous namespace
+
+FrameGen::FrameGen(MemoryAllocator& memory_allocator_, Scheduler& scheduler_)
+ : memory_allocator{memory_allocator_}, scheduler{scheduler_} {}
+
+FrameGen::~FrameGen() = default;
+
+void FrameGen::Process(const Device& device, Frame* frame, VkFormat format,
+ VkExtent2D guest_extent) {
+ generated = false;
+
+ if (unavailable || !Settings::values.frame_gen.GetValue()) {
+ if (chain) {
+ scheduler.Finish();
+ chain.reset();
+ }
+ warm_streak = 0;
+ return;
+ }
+
+ if (!frame->storage_view) {
+ unavailable = true;
+ return;
+ }
+
+ if (!shaders) {
+ shaders.emplace(device);
+ if (!shaders->IsValid()) {
+ unavailable = true;
+ return;
+ }
+ }
+
+ peak_guest_extent.width = std::max(peak_guest_extent.width, guest_extent.width);
+ peak_guest_extent.height = std::max(peak_guest_extent.height, guest_extent.height);
+
+ const VkExtent2D extent{.width = frame->width, .height = frame->height};
+ const f32 flow_scale = ConfiguredFlowScale(peak_guest_extent, extent);
+ if (!chain || built_extent.width != extent.width || built_extent.height != extent.height ||
+ built_format != format || built_flow_scale != flow_scale) {
+ Rebuild(device, extent, format, flow_scale);
+ }
+
+ const u64 count = frame_count++;
+ last_count = count;
+ last_generations = plan.generations;
+
+ const bool warm = plan.warm && count + 1 >= LSFG_REQUIRED_FRAMES;
+ warm_streak = warm ? warm_streak + 1 : 0;
+ generated = warm && warm_streak >= LSFG_RECURRENCE_FRAMES && plan.generations > 0;
+
+ scheduler.RequestOutsideRenderPassOperationContext();
+ scheduler.Record([this, source = *frame->image, extent, count,
+ dispatch = warm](vk::CommandBuffer cmdbuf) {
+ CopyPresentedFrame(cmdbuf, source, chain->Input(count), extent);
+ if (dispatch) {
+ chain->DispatchShared(cmdbuf, count);
+ }
+ });
+
+ const bool dump_requested = generated && Settings::values.frame_gen_dump_flow.GetValue();
+ if (!dump_requested) {
+ dumped = false;
+ } else if (!dumped) {
+ DumpDebugImages(count);
+ dumped = true;
+ }
+}
+
+size_t FrameGen::WantedGenerations(size_t capacity) {
+ if (unavailable) {
+ plan = {};
+ return 0;
+ }
+ plan = pacer.Plan(capacity);
+ return plan.generations;
+}
+
+size_t FrameGen::GeneratedFrameCount() const {
+ return generated ? last_generations : 0;
+}
+
+void FrameGen::GenerateInto(const Device& device, Frame* destination, size_t generation) {
+ chain->SetTarget(device, last_generations, generation, destination->index,
+ *destination->storage_view);
+
+ const VkExtent2D extent{.width = destination->width, .height = destination->height};
+
+ scheduler.RequestOutsideRenderPassOperationContext();
+ scheduler.Record([this, count = last_count, generation_count = last_generations, generation,
+ target = destination->index, image = *destination->image,
+ extent](vk::CommandBuffer cmdbuf) {
+ chain->DispatchGeneration(cmdbuf, count, generation_count, generation, target, image,
+ extent);
+ });
+}
+
+void FrameGen::Rebuild(const Device& device, VkExtent2D extent, VkFormat format, f32 flow_scale) {
+ scheduler.Finish();
+ chain.reset();
+
+ built_flow_scale = flow_scale;
+
+ chain.emplace(device, memory_allocator, *shaders, extent, format, built_flow_scale);
+ built_extent = extent;
+ built_format = format;
+ frame_count = 0;
+ warm_streak = 0;
+ generated = false;
+}
+
+void FrameGen::DumpDebugImages(u64 count) {
+ const std::filesystem::path directory =
+ Common::FS::GetEdenPath(Common::FS::EdenPath::LosslessDir) / "debug";
+ if (!Common::FS::CreateDirs(directory)) {
+ return;
+ }
+
+ const auto dump = [&](const std::string& name, LsfgImage& image) {
+ const VkExtent2D extent = image.Extent();
+ const VkFormat format = image.Format();
+ const VkDeviceSize texel_size = BytesPerTexel(format);
+ const VkDeviceSize size =
+ static_cast<VkDeviceSize>(extent.width) * extent.height * texel_size;
+
+ vk::Buffer buffer = CreateWrappedBuffer(memory_allocator, size, MemoryUsage::Download);
+
+ scheduler.RequestOutsideRenderPassOperationContext();
+ scheduler.Record(
+ [handle = image.Handle(), dst = *buffer, extent](vk::CommandBuffer cmdbuf) {
+ DownloadColorImage(
+ cmdbuf, handle, dst,
+ VkExtent3D{.width = extent.width, .height = extent.height, .depth = 1});
+ });
+ scheduler.Finish();
+
+ buffer.Invalidate();
+ const std::span<u8> mapped = buffer.Mapped();
+
+ if (format == LSFG_FLOW_FORMAT) {
+ WriteGrayscalePgm(directory / (name + ".pgm"), extent, mapped);
+ } else if (texel_size == COLOR_CHANNELS) {
+ WriteColorPpm(directory / (name + ".ppm"), extent, mapped, IsBlueFirst(format));
+ } else {
+ WriteRaw(directory / (name + "_" + std::to_string(extent.width) + "x" +
+ std::to_string(extent.height) + ".f16"),
+ mapped.subspan(0, std::min<size_t>(size, mapped.size())));
+ }
+ };
+
+ dump("in0", chain->Input(0));
+ dump("in1", chain->Input(1));
+
+ for (size_t level = 0; level < LSFG_MIP_LEVELS; ++level) {
+ dump("flow_mip" + std::to_string(level), chain->FlowLevel(level));
+ }
+ for (size_t index = 0; index < 2; ++index) {
+ dump("alpha0_" + std::to_string(index), chain->AlphaOutput(0, count, index));
+ dump("alpha6_" + std::to_string(index), chain->AlphaOutput(LSFG_MIP_LEVELS - 1, count,
+ index));
+ }
+ for (size_t level = 0; level < LSFG_BETA_OUTPUTS; ++level) {
+ dump("beta_" + std::to_string(level), chain->BetaOutput(level));
+ }
+
+ dump("gamma0", chain->GammaOutput(0));
+ dump("gamma6", chain->GammaOutput(LSFG_MIP_LEVELS - 1));
+ dump("delta2_out1", chain->DeltaOutput1(LSFG_DELTA_INSTANCES - 1));
+ dump("delta2_out2", chain->DeltaOutput2(LSFG_DELTA_INSTANCES - 1));
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/frame_gen.h b/src/video_core/renderer_vulkan/present/frame_gen.h
new file mode 100644
index 0000000000..19c6d04289
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/frame_gen.h
@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <optional>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/frame_gen_pacer.h"
+#include "video_core/renderer_vulkan/present/lsfg_chain.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/vulkan_common/vulkan_memory_allocator.h"
+
+namespace Vulkan {
+
+class Device;
+class Scheduler;
+struct Frame;
+
+class FrameGen {
+public:
+ explicit FrameGen(MemoryAllocator& memory_allocator, Scheduler& scheduler);
+ ~FrameGen();
+
+ void Process(const Device& device, Frame* frame, VkFormat format, VkExtent2D guest_extent);
+
+ [[nodiscard]] size_t WantedGenerations(size_t capacity);
+
+ [[nodiscard]] size_t GeneratedFrameCount() const;
+
+ void GenerateInto(const Device& device, Frame* destination, size_t generation);
+
+private:
+ void Rebuild(const Device& device, VkExtent2D extent, VkFormat format, f32 flow_scale);
+ void DumpDebugImages(u64 count);
+
+ MemoryAllocator& memory_allocator;
+ Scheduler& scheduler;
+
+ std::optional<LsfgShaders> shaders;
+ std::optional<LsfgChain> chain;
+ FrameGenPacer pacer;
+ FrameGenPlan plan{};
+ VkExtent2D peak_guest_extent{};
+ VkExtent2D built_extent{};
+ VkFormat built_format{VK_FORMAT_UNDEFINED};
+ f32 built_flow_scale{};
+ u64 frame_count{};
+ u64 last_count{};
+ size_t last_generations{};
+ u32 warm_streak{};
+ bool generated{};
+ bool unavailable{};
+ bool dumped{};
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/frame_gen_pacer.cpp b/src/video_core/renderer_vulkan/present/frame_gen_pacer.cpp
new file mode 100644
index 0000000000..78a8c83b5a
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/frame_gen_pacer.cpp
@@ -0,0 +1,240 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <cmath>
+#include <utility>
+
+#include "common/settings.h"
+#include "video_core/renderer_vulkan/present/frame_gen_pacer.h"
+
+namespace Vulkan {
+
+namespace {
+
+using Clock = std::chrono::steady_clock;
+
+constexpr f32 INTERVAL_SMOOTHING = 0.25f;
+constexpr f32 MINIMUM_BASE_RATE = 10.0f;
+constexpr f32 BURST_CADENCE_RATIO = 3.0f;
+constexpr f32 BURST_TARGET_RATIO = 2.0f;
+constexpr f32 PROBE_THROUGHPUT_TOLERANCE = 0.95f;
+constexpr f32 PROBE_BASE_COLLAPSE_RATIO = 0.70f;
+constexpr f32 PROBE_MARGINAL_GAIN = 1.15f;
+constexpr f32 TARGET_SATISFIED_RATIO = 0.95f;
+constexpr f32 UNLOADED_BASE_RETENTION = 0.75f;
+constexpr f32 CREDIT_EPSILON = 1.0e-4f;
+constexpr u32 MAX_PROBE_FAILURES = 4;
+
+constexpr auto STABILIZATION_DURATION = std::chrono::seconds(1);
+constexpr auto PROBE_DURATION = std::chrono::seconds(1);
+constexpr auto DEFICIT_DURATION = std::chrono::seconds(1);
+constexpr auto PROBE_STEP_DELAY = std::chrono::milliseconds(250);
+
+[[nodiscard]] Clock::duration ProbeBackoff(u32 failures) {
+ switch (failures) {
+ case 1:
+ return std::chrono::seconds(5);
+ case 2:
+ return std::chrono::seconds(15);
+ case 3:
+ return std::chrono::seconds(30);
+ default:
+ return std::chrono::seconds(60);
+ }
+}
+
+} // Anonymous namespace
+
+FrameGenPlan FrameGenPacer::Plan(size_t capacity) {
+ const size_t ceiling = std::min(capacity, Settings::FrameGenMaxGenerations());
+ if (ceiling == 0) {
+ Reset();
+ return {};
+ }
+
+ const Clock::time_point now = Clock::now();
+ const size_t previous_generations = std::exchange(issued_generations, 0);
+ if (!last_frame) {
+ last_frame = now;
+ return {};
+ }
+
+ const Clock::duration interval = now - *last_frame;
+ const f32 interval_seconds = std::chrono::duration<f32>(interval).count();
+ last_frame = now;
+
+ if (interval_seconds <= 0.0f) {
+ Stabilize(now);
+ return {};
+ }
+
+ const f32 target_rate = static_cast<f32>(Settings::values.frame_gen_target_rate.GetValue());
+
+ if (smoothed_interval > 0.0f) {
+ f32 burst_threshold = BURST_CADENCE_RATIO / smoothed_interval;
+ if (target_rate > 0.0f) {
+ burst_threshold = std::max(burst_threshold, target_rate * BURST_TARGET_RATIO);
+ }
+ if (1.0f / interval_seconds > burst_threshold) {
+ DeferEvaluations(interval);
+ output_credit = 0.0f;
+ return {};
+ }
+ }
+
+ if (interval_seconds > 1.0f / MINIMUM_BASE_RATE) {
+ Stabilize(now);
+ return {};
+ }
+
+ smoothed_interval = smoothed_interval > 0.0f
+ ? smoothed_interval +
+ (interval_seconds - smoothed_interval) * INTERVAL_SMOOTHING
+ : interval_seconds;
+
+ if (previous_generations == 0) {
+ const f32 measured = 1.0f / smoothed_interval;
+ unloaded_base_rate =
+ unloaded_base_rate > 0.0f
+ ? unloaded_base_rate + (measured - unloaded_base_rate) * INTERVAL_SMOOTHING
+ : measured;
+ }
+
+ if (stable_until) {
+ if (now < *stable_until) {
+ return {};
+ }
+ stable_until.reset();
+ }
+
+ if (target_rate == 0.0f) {
+ limit = std::min(Settings::FrameGenGenerations(), ceiling);
+ output_credit = 0.0f;
+ issued_generations = limit;
+ return {.generations = limit, .warm = limit > 0};
+ }
+
+ UpdateLimit(now, 1.0f / smoothed_interval, target_rate, ceiling);
+
+ const size_t allowed = std::min(limit, ceiling);
+ const f32 desired_outputs = smoothed_interval * target_rate;
+ if (allowed == 0 || desired_outputs <= 1.0f) {
+ output_credit = 0.0f;
+ return {};
+ }
+
+ output_credit += desired_outputs;
+ const size_t outputs =
+ std::max<size_t>(1, static_cast<size_t>(std::floor(output_credit + CREDIT_EPSILON)));
+ const size_t generations = std::min(outputs - 1, allowed);
+
+ output_credit -= static_cast<f32>(generations + 1);
+ if (output_credit < 0.0f) {
+ output_credit = 0.0f;
+ } else if (generations == allowed && output_credit >= 1.0f) {
+ output_credit = std::fmod(output_credit, 1.0f);
+ }
+
+ issued_generations = generations;
+ return {.generations = generations, .warm = true};
+}
+
+void FrameGenPacer::UpdateLimit(Clock::time_point now, f32 base_rate, f32 target_rate,
+ size_t ceiling) {
+ limit = std::min(limit, ceiling);
+
+ if (probe_until) {
+ if (now < *probe_until) {
+ return;
+ }
+ probe_until.reset();
+ output_credit = 0.0f;
+
+ const f32 previous_output =
+ std::min(target_rate, probe_base_rate * static_cast<f32>(probe_previous_limit + 1));
+ const f32 current_output =
+ std::min(target_rate, base_rate * static_cast<f32>(limit + 1));
+
+ const bool throughput_regressed =
+ current_output < previous_output * PROBE_THROUGHPUT_TOLERANCE;
+ const bool collapsed_for_marginal_gain =
+ base_rate < probe_base_rate * PROBE_BASE_COLLAPSE_RATIO &&
+ current_output < previous_output * PROBE_MARGINAL_GAIN;
+ const bool emulation_slowed = unloaded_base_rate > 0.0f &&
+ base_rate < unloaded_base_rate * UNLOADED_BASE_RETENTION;
+
+ if (throughput_regressed || collapsed_for_marginal_gain || emulation_slowed) {
+ limit = probe_previous_limit;
+ probe_failures = std::min(probe_failures + 1, MAX_PROBE_FAILURES);
+ next_probe = now + ProbeBackoff(probe_failures);
+ deficit_since.reset();
+ return;
+ }
+
+ probe_failures = 0;
+ next_probe = now + PROBE_STEP_DELAY;
+ }
+
+ if (base_rate * static_cast<f32>(limit + 1) >= target_rate * TARGET_SATISFIED_RATIO ||
+ limit >= ceiling) {
+ deficit_since.reset();
+ return;
+ }
+
+ if (!deficit_since) {
+ deficit_since = now;
+ return;
+ }
+ if (now - *deficit_since < DEFICIT_DURATION) {
+ return;
+ }
+ if (next_probe && now < *next_probe) {
+ return;
+ }
+
+ probe_previous_limit = limit;
+ probe_base_rate = base_rate;
+ ++limit;
+ probe_until = now + PROBE_DURATION;
+ deficit_since.reset();
+ output_credit = 0.0f;
+}
+
+void FrameGenPacer::DeferEvaluations(Clock::duration amount) {
+ const auto defer = [amount](std::optional<Clock::time_point>& deadline) {
+ if (deadline) {
+ *deadline += amount;
+ }
+ };
+ defer(stable_until);
+ defer(probe_until);
+ defer(next_probe);
+ deficit_since.reset();
+}
+
+void FrameGenPacer::Stabilize(Clock::time_point now) {
+ stable_until = now + STABILIZATION_DURATION;
+ probe_until.reset();
+ deficit_since.reset();
+ smoothed_interval = 0.0f;
+ output_credit = 0.0f;
+}
+
+void FrameGenPacer::Reset() {
+ last_frame.reset();
+ stable_until.reset();
+ probe_until.reset();
+ next_probe.reset();
+ deficit_since.reset();
+ smoothed_interval = 0.0f;
+ output_credit = 0.0f;
+ probe_base_rate = 0.0f;
+ unloaded_base_rate = 0.0f;
+ issued_generations = 0;
+ probe_previous_limit = 0;
+ limit = 0;
+ probe_failures = 0;
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/frame_gen_pacer.h b/src/video_core/renderer_vulkan/present/frame_gen_pacer.h
new file mode 100644
index 0000000000..7f4b23920c
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/frame_gen_pacer.h
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <chrono>
+#include <optional>
+
+#include "common/common_types.h"
+
+namespace Vulkan {
+
+struct FrameGenPlan {
+ size_t generations{};
+ bool warm{};
+};
+
+class FrameGenPacer {
+public:
+ [[nodiscard]] FrameGenPlan Plan(size_t capacity);
+
+ void Reset();
+
+private:
+ using Clock = std::chrono::steady_clock;
+
+ void Stabilize(Clock::time_point now);
+ void DeferEvaluations(Clock::duration amount);
+ void UpdateLimit(Clock::time_point now, f32 base_rate, f32 target_rate, size_t ceiling);
+
+ std::optional<Clock::time_point> last_frame;
+ std::optional<Clock::time_point> stable_until;
+ std::optional<Clock::time_point> probe_until;
+ std::optional<Clock::time_point> next_probe;
+ std::optional<Clock::time_point> deficit_since;
+ f32 smoothed_interval{};
+ f32 output_credit{};
+ f32 probe_base_rate{};
+ f32 unloaded_base_rate{};
+ size_t issued_generations{};
+ size_t probe_previous_limit{};
+ size_t limit{};
+ u32 probe_failures{};
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_alpha.cpp b/src/video_core/renderer_vulkan/present/lsfg_alpha.cpp
new file mode 100644
index 0000000000..a735dd11f4
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_alpha.cpp
@@ -0,0 +1,140 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <vector>
+
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_alpha.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DISPATCH_TILE_SHIFT = 3;
+
+[[nodiscard]] u32 GroupCount(u32 size) {
+ return (size + (1u << DISPATCH_TILE_SHIFT) - 1) >> DISPATCH_TILE_SHIFT;
+}
+
+[[nodiscard]] VkExtent2D HalveExtent(VkExtent2D extent) {
+ return VkExtent2D{
+ .width = (extent.width + 1) >> 1,
+ .height = (extent.height + 1) >> 1,
+ };
+}
+
+} // Anonymous namespace
+
+LsfgAlphaPasses::LsfgAlphaPasses(const Device& device, const LsfgShaders& shaders) {
+ using namespace VideoCore::FrameGen::PerformanceShader;
+
+ passes[0] = LsfgPass(device, shaders, ALPHA[0],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[1] = LsfgPass(device, shaders, ALPHA[1],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[2] = LsfgPass(device, shaders, ALPHA[2],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[3] = LsfgPass(device, shaders, ALPHA[3],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+}
+
+LsfgAlpha::LsfgAlpha(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgAlphaPasses& passes_, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImage& input_)
+ : passes{&passes_}, input{&input_} {
+ const VkExtent2D half_extent = HalveExtent(input->Extent());
+ const VkExtent2D quarter_extent = HalveExtent(half_extent);
+
+ temp1 = LsfgImage(device, memory_allocator, half_extent);
+ temp2 = LsfgImage(device, memory_allocator, half_extent);
+ for (size_t i = 0; i < temp3.size(); ++i) {
+ temp3[i] = LsfgImage(device, memory_allocator, quarter_extent);
+ for (size_t j = 0; j < LSFG_HISTORY_SLOTS; ++j) {
+ out_images[j][i] = LsfgImage(device, memory_allocator, quarter_extent);
+ }
+ }
+
+ std::vector<VkDescriptorSetLayout> layouts;
+ for (size_t i = 0; i < LSFG_ALPHA_STAGES - 1; ++i) {
+ layouts.push_back(passes->Get(i).SetLayout());
+ }
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ layouts.push_back(passes->Get(3).SetLayout());
+ }
+ owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
+
+ for (size_t i = 0; i < LSFG_ALPHA_STAGES - 1; ++i) {
+ descriptor_sets[i] = owned_sets[i];
+ }
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ last_descriptor_sets[i] = owned_sets[LSFG_ALPHA_STAGES - 1 + i];
+ }
+
+ const VkSampler sampler = resources.GetSampler();
+
+ LsfgDescriptorWriter(descriptor_sets[0])
+ .AddSampler(sampler)
+ .AddSampledImage(*input)
+ .AddStorageImage(temp1)
+ .Build(device);
+ LsfgDescriptorWriter(descriptor_sets[1])
+ .AddSampler(sampler)
+ .AddSampledImage(temp1)
+ .AddStorageImage(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(descriptor_sets[2])
+ .AddSampler(sampler)
+ .AddSampledImage(temp2)
+ .AddStorageImages(temp3)
+ .Build(device);
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ LsfgDescriptorWriter(last_descriptor_sets[i])
+ .AddSampler(sampler)
+ .AddSampledImages(temp3)
+ .AddStorageImages(out_images[i])
+ .Build(device);
+ }
+}
+
+void LsfgAlpha::PushBarriers(LsfgBarriers& barriers, u64 frame_count, size_t stage) {
+ switch (stage) {
+ case 0:
+ barriers.WriteToRead(*input).ReadToWrite(temp1);
+ break;
+ case 1:
+ barriers.WriteToRead(temp1).ReadToWrite(temp2);
+ break;
+ case 2:
+ barriers.WriteToRead(temp2).ReadToWriteAll(temp3);
+ break;
+ default:
+ barriers.WriteToReadAll(temp3).ReadToWriteAll(out_images[frame_count % LSFG_HISTORY_SLOTS]);
+ break;
+ }
+}
+
+void LsfgAlpha::DispatchStage(vk::CommandBuffer cmdbuf, u64 frame_count, size_t stage) {
+ const VkExtent2D extent = stage < 2 ? temp1.Extent() : temp3[0].Extent();
+ const VkDescriptorSet set = stage < LSFG_ALPHA_STAGES - 1
+ ? descriptor_sets[stage]
+ : last_descriptor_sets[frame_count % LSFG_HISTORY_SLOTS];
+
+ passes->Get(stage).BindSet(cmdbuf, set);
+ cmdbuf.Dispatch(GroupCount(extent.width), GroupCount(extent.height), 1);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_alpha.h b/src/video_core/renderer_vulkan/present/lsfg_alpha.h
new file mode 100644
index 0000000000..1dc8864431
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_alpha.h
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr size_t LSFG_ALPHA_STAGES = 4;
+
+class LsfgAlphaPasses {
+public:
+ LsfgAlphaPasses() = default;
+ LsfgAlphaPasses(const Device& device, const LsfgShaders& shaders);
+
+ [[nodiscard]] const LsfgPass& Get(size_t stage) const {
+ return passes[stage];
+ }
+
+private:
+ std::array<LsfgPass, LSFG_ALPHA_STAGES> passes;
+};
+
+class LsfgAlpha {
+public:
+ LsfgAlpha() = default;
+ LsfgAlpha(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgAlphaPasses& passes_, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImage& input);
+
+ void PushBarriers(LsfgBarriers& barriers, u64 frame_count, size_t stage);
+ void DispatchStage(vk::CommandBuffer cmdbuf, u64 frame_count, size_t stage);
+
+ [[nodiscard]] LsfgImageHistory& Outputs() {
+ return out_images;
+ }
+
+private:
+ const LsfgAlphaPasses* passes{};
+ LsfgImage* input{};
+
+ std::array<VkDescriptorSet, LSFG_ALPHA_STAGES - 1> descriptor_sets{};
+ std::array<VkDescriptorSet, LSFG_HISTORY_SLOTS> last_descriptor_sets{};
+ vk::DescriptorSets owned_sets;
+
+ LsfgImage temp1;
+ LsfgImage temp2;
+ LsfgImagePair temp3;
+ LsfgImageHistory out_images;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_beta.cpp b/src/video_core/renderer_vulkan/present/lsfg_beta.cpp
new file mode 100644
index 0000000000..6dddc7c21d
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_beta.cpp
@@ -0,0 +1,147 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <vector>
+
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_beta.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DISPATCH_TILE_SHIFT = 3;
+constexpr u32 OUTPUT_TILE_SHIFT = 5;
+
+[[nodiscard]] u32 GroupCount(u32 size, u32 shift) {
+ return (size + (1u << shift) - 1) >> shift;
+}
+
+} // Anonymous namespace
+
+LsfgBeta::LsfgBeta(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgShaders& shaders, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImageHistory& inputs_)
+ : inputs{&inputs_} {
+ using namespace VideoCore::FrameGen::PerformanceShader;
+
+ passes[0] = LsfgPass(device, shaders, BETA[0],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {6, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ for (size_t i = 1; i < LSFG_BETA_STAGES - 1; ++i) {
+ passes[i] = LsfgPass(device, shaders, BETA[i],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ }
+ passes[4] = LsfgPass(device, shaders, BETA[4],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {6, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+
+ const VkExtent2D extent = (*inputs)[0][0].Extent();
+ for (size_t i = 0; i < temp1.size(); ++i) {
+ temp1[i] = LsfgImage(device, memory_allocator, extent);
+ temp2[i] = LsfgImage(device, memory_allocator, extent);
+ }
+ for (size_t i = 0; i < LSFG_BETA_OUTPUTS; ++i) {
+ const VkExtent2D level_extent{
+ .width = extent.width >> i,
+ .height = extent.height >> i,
+ };
+ out_images[i] = LsfgImage(device, memory_allocator, level_extent, LSFG_FLOW_FORMAT);
+ }
+
+ std::vector<VkDescriptorSetLayout> layouts;
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ layouts.push_back(passes[0].SetLayout());
+ }
+ for (size_t i = 1; i < LSFG_BETA_STAGES; ++i) {
+ layouts.push_back(passes[i].SetLayout());
+ }
+ owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
+
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ first_descriptor_sets[i] = owned_sets[i];
+ }
+ for (size_t i = 0; i < LSFG_BETA_STAGES - 1; ++i) {
+ descriptor_sets[i] = owned_sets[LSFG_HISTORY_SLOTS + i];
+ }
+
+ const VkSampler sampler = resources.GetSampler();
+ const VkSampler border_sampler = resources.GetSampler(
+ VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, VK_COMPARE_OP_NEVER, true);
+
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ LsfgDescriptorWriter(first_descriptor_sets[i])
+ .AddSampler(border_sampler)
+ .AddSampledImages((*inputs)[(i + 1) % LSFG_HISTORY_SLOTS])
+ .AddSampledImages((*inputs)[(i + 2) % LSFG_HISTORY_SLOTS])
+ .AddSampledImages((*inputs)[i % LSFG_HISTORY_SLOTS])
+ .AddStorageImages(temp1)
+ .Build(device);
+ }
+ LsfgDescriptorWriter(descriptor_sets[0])
+ .AddSampler(sampler)
+ .AddSampledImages(temp1)
+ .AddStorageImages(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(descriptor_sets[1])
+ .AddSampler(sampler)
+ .AddSampledImages(temp2)
+ .AddStorageImages(temp1)
+ .Build(device);
+ LsfgDescriptorWriter(descriptor_sets[2])
+ .AddSampler(sampler)
+ .AddSampledImages(temp1)
+ .AddStorageImages(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(descriptor_sets[3])
+ .AddUniformBuffer(resources.GetBuffer(0.5f), LsfgResources::BufferSize())
+ .AddSampler(sampler)
+ .AddSampledImages(temp2)
+ .AddStorageImages(out_images)
+ .Build(device);
+}
+
+void LsfgBeta::Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count) {
+ const VkExtent2D extent = temp1[0].Extent();
+ const u32 groups_x = GroupCount(extent.width, DISPATCH_TILE_SHIFT);
+ const u32 groups_y = GroupCount(extent.height, DISPATCH_TILE_SHIFT);
+
+ LsfgBarriers barriers(cmdbuf);
+ for (auto& slot : *inputs) {
+ barriers.WriteToReadAll(slot);
+ }
+ barriers.ReadToWriteAll(temp1).Build();
+
+ passes[0].Bind(cmdbuf, first_descriptor_sets[frame_count % LSFG_HISTORY_SLOTS]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
+ passes[1].Bind(cmdbuf, descriptor_sets[0]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp2).ReadToWriteAll(temp1).Build();
+ passes[2].Bind(cmdbuf, descriptor_sets[1]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
+ passes[3].Bind(cmdbuf, descriptor_sets[2]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp2).ReadToWriteAll(out_images).Build();
+ passes[4].Bind(cmdbuf, descriptor_sets[3]);
+ cmdbuf.Dispatch(GroupCount(extent.width, OUTPUT_TILE_SHIFT),
+ GroupCount(extent.height, OUTPUT_TILE_SHIFT), 1);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_beta.h b/src/video_core/renderer_vulkan/present/lsfg_beta.h
new file mode 100644
index 0000000000..1e24b235cf
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_beta.h
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr size_t LSFG_BETA_STAGES = 5;
+constexpr size_t LSFG_BETA_OUTPUTS = 6;
+
+class LsfgBeta {
+public:
+ LsfgBeta() = default;
+ LsfgBeta(const Device& device, MemoryAllocator& memory_allocator, const LsfgShaders& shaders,
+ LsfgResources& resources, vk::DescriptorPool& descriptor_pool,
+ LsfgImageHistory& inputs);
+
+ void Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count);
+
+ [[nodiscard]] LsfgImage& Output(size_t level) {
+ return out_images[level];
+ }
+
+private:
+ LsfgImageHistory* inputs{};
+
+ std::array<LsfgPass, LSFG_BETA_STAGES> passes;
+ std::array<VkDescriptorSet, LSFG_HISTORY_SLOTS> first_descriptor_sets{};
+ std::array<VkDescriptorSet, LSFG_BETA_STAGES - 1> descriptor_sets{};
+ vk::DescriptorSets owned_sets;
+
+ LsfgImagePair temp1;
+ LsfgImagePair temp2;
+ std::array<LsfgImage, LSFG_BETA_OUTPUTS> out_images;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_chain.cpp b/src/video_core/renderer_vulkan/present/lsfg_chain.cpp
new file mode 100644
index 0000000000..972b13a1c4
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_chain.cpp
@@ -0,0 +1,103 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+
+#include "video_core/renderer_vulkan/present/lsfg_chain.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 FIXED_DESCRIPTOR_SETS = 64;
+constexpr u32 DESCRIPTOR_SETS_PER_SLOT = 112;
+constexpr size_t FIRST_DELTA_LEVEL = 4;
+
+} // Anonymous namespace
+
+LsfgChain::LsfgChain(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgShaders& shaders, VkExtent2D extent, VkFormat format,
+ f32 flow_scale)
+ : resources{device, memory_allocator, flow_scale},
+ descriptor_pool{CreateLsfgDescriptorPool(
+ device, FIXED_DESCRIPTOR_SETS +
+ DESCRIPTOR_SETS_PER_SLOT * static_cast<u32>(LSFG_GENERATION_SLOTS))} {
+ for (auto& image : frames) {
+ image = LsfgImage(device, memory_allocator, extent, format);
+ }
+
+ mipmaps = LsfgMipmaps(device, memory_allocator, shaders, resources, descriptor_pool, frames,
+ flow_scale);
+
+ alpha_passes = LsfgAlphaPasses(device, shaders);
+ for (size_t i = 0; i < LSFG_MIP_LEVELS; ++i) {
+ alpha[i] = LsfgAlpha(device, memory_allocator, alpha_passes, resources, descriptor_pool,
+ mipmaps.Output(i));
+ }
+
+ beta = LsfgBeta(device, memory_allocator, shaders, resources, descriptor_pool,
+ alpha[0].Outputs());
+
+ for (size_t i = 0; i < LSFG_MIP_LEVELS; ++i) {
+ const size_t level = LSFG_MIP_LEVELS - 1 - i;
+ gamma[i] = LsfgGamma(device, memory_allocator, shaders, resources, descriptor_pool,
+ alpha[level].Outputs(),
+ beta.Output(std::min(level, LSFG_BETA_OUTPUTS - 1)),
+ i == 0 ? nullptr : &gamma[i - 1].Output());
+
+ if (i < FIRST_DELTA_LEVEL) {
+ continue;
+ }
+
+ const size_t index = i - FIRST_DELTA_LEVEL;
+ delta[index] = LsfgDelta(
+ device, memory_allocator, shaders, resources, descriptor_pool, alpha[level].Outputs(),
+ beta.Output(level), i == FIRST_DELTA_LEVEL ? nullptr : &gamma[i - 1].Output(),
+ i == FIRST_DELTA_LEVEL ? nullptr : &delta[index - 1].Output1(),
+ i == FIRST_DELTA_LEVEL ? nullptr : &delta[index - 1].Output2());
+ }
+
+ generate = LsfgGenerate(device, shaders, resources, descriptor_pool, frames,
+ gamma[LSFG_MIP_LEVELS - 1].Output(),
+ delta[LSFG_DELTA_INSTANCES - 1].Output1(),
+ delta[LSFG_DELTA_INSTANCES - 1].Output2());
+}
+
+void LsfgChain::DispatchShared(vk::CommandBuffer cmdbuf, u64 frame_count) {
+ mipmaps.Dispatch(cmdbuf, frame_count);
+
+ for (size_t stage = 0; stage < LSFG_ALPHA_STAGES; ++stage) {
+ LsfgBarriers barriers(cmdbuf);
+ for (auto& level : alpha) {
+ level.PushBarriers(barriers, frame_count, stage);
+ }
+ barriers.Build();
+
+ alpha_passes.Get(stage).BindPipeline(cmdbuf);
+ for (auto& level : alpha) {
+ level.DispatchStage(cmdbuf, frame_count, stage);
+ }
+ }
+
+ beta.Dispatch(cmdbuf, frame_count);
+}
+
+void LsfgChain::DispatchGeneration(vk::CommandBuffer cmdbuf, u64 frame_count,
+ size_t generation_count, size_t generation, u32 target,
+ VkImage image, VkExtent2D extent) {
+ const size_t slot = LsfgGenerationSlot(generation_count, generation);
+ for (size_t i = 0; i < LSFG_MIP_LEVELS; ++i) {
+ gamma[i].Dispatch(cmdbuf, frame_count, slot);
+ if (i >= FIRST_DELTA_LEVEL) {
+ delta[i - FIRST_DELTA_LEVEL].Dispatch(cmdbuf, frame_count, slot);
+ }
+ }
+ generate.Dispatch(cmdbuf, frame_count, slot, target, image, extent);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_chain.h b/src/video_core/renderer_vulkan/present/lsfg_chain.h
new file mode 100644
index 0000000000..c8879f74a9
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_chain.h
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_alpha.h"
+#include "video_core/renderer_vulkan/present/lsfg_beta.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+#include "video_core/renderer_vulkan/present/lsfg_delta.h"
+#include "video_core/renderer_vulkan/present/lsfg_gamma.h"
+#include "video_core/renderer_vulkan/present/lsfg_generate.h"
+#include "video_core/renderer_vulkan/present/lsfg_mipmaps.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr size_t LSFG_DELTA_INSTANCES = 3;
+
+class LsfgChain {
+public:
+ LsfgChain(const Device& device, MemoryAllocator& memory_allocator, const LsfgShaders& shaders,
+ VkExtent2D extent, VkFormat format, f32 flow_scale);
+
+ LsfgChain(const LsfgChain&) = delete;
+ LsfgChain& operator=(const LsfgChain&) = delete;
+
+ void DispatchShared(vk::CommandBuffer cmdbuf, u64 frame_count);
+
+ void DispatchGeneration(vk::CommandBuffer cmdbuf, u64 frame_count, size_t generation_count,
+ size_t generation, u32 target, VkImage image, VkExtent2D extent);
+
+ void SetTarget(const Device& device, size_t generation_count, size_t generation, u32 target,
+ VkImageView view) {
+ generate.SetTarget(device, LsfgGenerationSlot(generation_count, generation), target, view);
+ }
+
+ [[nodiscard]] LsfgImage& Input(u64 frame_count) {
+ return frames[frame_count % frames.size()];
+ }
+
+ [[nodiscard]] LsfgImage& FlowLevel(size_t level) {
+ return mipmaps.Output(level);
+ }
+
+ [[nodiscard]] LsfgImage& AlphaOutput(size_t level, u64 frame_count, size_t index) {
+ return alpha[level].Outputs()[frame_count % LSFG_HISTORY_SLOTS][index];
+ }
+
+ [[nodiscard]] LsfgImage& BetaOutput(size_t level) {
+ return beta.Output(level);
+ }
+
+ [[nodiscard]] LsfgImage& GammaOutput(size_t index) {
+ return gamma[index].Output();
+ }
+
+ [[nodiscard]] LsfgImage& DeltaOutput1(size_t index) {
+ return delta[index].Output1();
+ }
+
+ [[nodiscard]] LsfgImage& DeltaOutput2(size_t index) {
+ return delta[index].Output2();
+ }
+
+private:
+ LsfgResources resources;
+ vk::DescriptorPool descriptor_pool;
+
+ LsfgImagePair frames;
+ LsfgMipmaps mipmaps;
+ LsfgAlphaPasses alpha_passes;
+ std::array<LsfgAlpha, LSFG_MIP_LEVELS> alpha;
+ LsfgBeta beta;
+ std::array<LsfgGamma, LSFG_MIP_LEVELS> gamma;
+ std::array<LsfgDelta, LSFG_DELTA_INSTANCES> delta;
+ LsfgGenerate generate;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_common.cpp b/src/video_core/renderer_vulkan/present/lsfg_common.cpp
new file mode 100644
index 0000000000..d2df2755e5
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_common.cpp
@@ -0,0 +1,339 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <cstring>
+
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DESCRIPTORS_PER_TYPE = 4096;
+
+struct LsfgConstants {
+ std::array<u32, 2> input_offset;
+ u32 first_iter;
+ u32 first_iter_s;
+ u32 advanced_color_kind;
+ u32 hdr_support;
+ f32 resolution_inv_scale;
+ f32 timestamp;
+ f32 ui_threshold;
+ std::array<u32, 3> padding;
+};
+static_assert(sizeof(LsfgConstants) == 48);
+
+vk::Image CreateChainImage(MemoryAllocator& memory_allocator, VkExtent2D extent, VkFormat format) {
+ const VkImageCreateInfo image_ci{
+ .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
+ .pNext = nullptr,
+ .flags = 0,
+ .imageType = VK_IMAGE_TYPE_2D,
+ .format = format,
+ .extent = {.width = extent.width, .height = extent.height, .depth = 1},
+ .mipLevels = 1,
+ .arrayLayers = 1,
+ .samples = VK_SAMPLE_COUNT_1_BIT,
+ .tiling = VK_IMAGE_TILING_OPTIMAL,
+ .usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT |
+ VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
+ .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
+ .queueFamilyIndexCount = 0,
+ .pQueueFamilyIndices = nullptr,
+ .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
+ };
+ return memory_allocator.CreateImage(image_ci);
+}
+
+vk::Buffer CreateUniformBuffer(MemoryAllocator& memory_allocator, VkDeviceSize size) {
+ const VkBufferCreateInfo buffer_ci{
+ .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
+ .pNext = nullptr,
+ .flags = 0,
+ .size = size,
+ .usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
+ .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
+ .queueFamilyIndexCount = 0,
+ .pQueueFamilyIndices = nullptr,
+ };
+ return memory_allocator.CreateBuffer(buffer_ci, MemoryUsage::Upload);
+}
+
+VkImageMemoryBarrier MakeBarrier(const LsfgImage& image, VkAccessFlags src_access,
+ VkAccessFlags dst_access) {
+ return VkImageMemoryBarrier{
+ .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
+ .pNext = nullptr,
+ .srcAccessMask = src_access,
+ .dstAccessMask = dst_access,
+ .oldLayout = image.Layout(),
+ .newLayout = VK_IMAGE_LAYOUT_GENERAL,
+ .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .image = image.Handle(),
+ .subresourceRange{
+ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
+ .baseMipLevel = 0,
+ .levelCount = 1,
+ .baseArrayLayer = 0,
+ .layerCount = 1,
+ },
+ };
+}
+
+} // Anonymous namespace
+
+LsfgImage::LsfgImage(const Device& device, MemoryAllocator& memory_allocator, VkExtent2D extent_,
+ VkFormat format_)
+ : extent{std::max(1u, extent_.width), std::max(1u, extent_.height)}, format{format_} {
+ image = CreateChainImage(memory_allocator, extent, format);
+ view = CreateWrappedImageView(device, image, format);
+}
+
+LsfgBarriers& LsfgBarriers::Push(LsfgImage& image, VkAccessFlags src_access,
+ VkAccessFlags dst_access) {
+ barriers.push_back(MakeBarrier(image, src_access, dst_access));
+ image.SetLayout(VK_IMAGE_LAYOUT_GENERAL);
+ return *this;
+}
+
+LsfgBarriers& LsfgBarriers::WriteToRead(LsfgImage& image) {
+ return Push(image, VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT);
+}
+
+LsfgBarriers& LsfgBarriers::ReadToWrite(LsfgImage& image) {
+ return Push(image, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_SHADER_WRITE_BIT);
+}
+
+LsfgBarriers& LsfgBarriers::WriteToRead(LsfgImage* image) {
+ return image == nullptr ? *this : WriteToRead(*image);
+}
+
+LsfgBarriers& LsfgBarriers::ReadToWrite(LsfgImage* image) {
+ return image == nullptr ? *this : ReadToWrite(*image);
+}
+
+LsfgBarriers& LsfgBarriers::DiscardToWrite(VkImage image) {
+ barriers.push_back(VkImageMemoryBarrier{
+ .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
+ .pNext = nullptr,
+ .srcAccessMask = 0,
+ .dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
+ .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
+ .newLayout = VK_IMAGE_LAYOUT_GENERAL,
+ .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .image = image,
+ .subresourceRange{
+ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
+ .baseMipLevel = 0,
+ .levelCount = 1,
+ .baseArrayLayer = 0,
+ .layerCount = 1,
+ },
+ });
+ return *this;
+}
+
+VkDeviceSize LsfgResources::BufferSize() {
+ return sizeof(LsfgConstants);
+}
+
+VkSampler LsfgResources::GetSampler(VkSamplerAddressMode address_mode, VkCompareOp compare_op,
+ bool white_border) {
+ const u64 key = static_cast<u64>(address_mode) | (static_cast<u64>(compare_op) << 8) |
+ (static_cast<u64>(white_border) << 16);
+
+ const auto it = samplers.find(key);
+ if (it != samplers.end()) {
+ return *it->second;
+ }
+
+ const auto [entry, inserted] =
+ samplers.emplace(key, CreateLsfgSampler(*device, address_mode, compare_op, white_border));
+ return *entry->second;
+}
+
+VkBuffer LsfgResources::GetBuffer(f32 timestamp, bool first_iter, bool first_iter_s) {
+ u32 timestamp_bits{};
+ std::memcpy(&timestamp_bits, &timestamp, sizeof(timestamp_bits));
+ const u64 key = static_cast<u64>(timestamp_bits) | (static_cast<u64>(first_iter) << 32) |
+ (static_cast<u64>(first_iter_s) << 33);
+
+ const auto it = buffers.find(key);
+ if (it != buffers.end()) {
+ return *it->second;
+ }
+
+ vk::Buffer buffer = CreateUniformBuffer(*memory_allocator, sizeof(LsfgConstants));
+
+ const LsfgConstants constants{
+ .input_offset = {0, 0},
+ .first_iter = first_iter ? 1u : 0u,
+ .first_iter_s = first_iter_s ? 1u : 0u,
+ .advanced_color_kind = 0,
+ .hdr_support = 0,
+ .resolution_inv_scale = 1.0f / flow_scale,
+ .timestamp = timestamp,
+ .ui_threshold = 0.5f,
+ .padding = {0, 0, 0},
+ };
+
+ const std::span<u8> mapped = buffer.Mapped();
+ std::memcpy(mapped.data(), &constants, sizeof(constants));
+ buffer.Flush();
+
+ const auto [entry, inserted] = buffers.emplace(key, std::move(buffer));
+ return *entry->second;
+}
+
+void LsfgBarriers::Build() {
+ if (barriers.empty()) {
+ return;
+ }
+ cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
+ VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, {}, {}, barriers);
+ barriers.clear();
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::PushImage(VkDescriptorType type, VkSampler sampler,
+ VkImageView view) {
+ image_infos.push_back(VkDescriptorImageInfo{
+ .sampler = sampler,
+ .imageView = view,
+ .imageLayout = view == VK_NULL_HANDLE ? VK_IMAGE_LAYOUT_UNDEFINED
+ : VK_IMAGE_LAYOUT_GENERAL,
+ });
+ writes.push_back(VkWriteDescriptorSet{
+ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
+ .pNext = nullptr,
+ .dstSet = set,
+ .dstBinding = binding++,
+ .dstArrayElement = 0,
+ .descriptorCount = 1,
+ .descriptorType = type,
+ .pImageInfo = &image_infos.back(),
+ .pBufferInfo = nullptr,
+ .pTexelBufferView = nullptr,
+ });
+ return *this;
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::AddSampler(VkSampler sampler) {
+ return PushImage(VK_DESCRIPTOR_TYPE_SAMPLER, sampler, VK_NULL_HANDLE);
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::AddSampledImage(const LsfgImage& image) {
+ return PushImage(VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, VK_NULL_HANDLE, image.View());
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::AddSampledImage(const LsfgImage* image) {
+ return PushImage(VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, VK_NULL_HANDLE,
+ image == nullptr ? VK_NULL_HANDLE : image->View());
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::AddStorageImage(const LsfgImage& image) {
+ return PushImage(VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, VK_NULL_HANDLE, image.View());
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::AddStorageView(VkImageView view) {
+ return PushImage(VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, VK_NULL_HANDLE, view);
+}
+
+LsfgDescriptorWriter& LsfgDescriptorWriter::AddUniformBuffer(VkBuffer buffer, VkDeviceSize size) {
+ buffer_infos.push_back(VkDescriptorBufferInfo{
+ .buffer = buffer,
+ .offset = 0,
+ .range = size,
+ });
+ writes.push_back(VkWriteDescriptorSet{
+ .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
+ .pNext = nullptr,
+ .dstSet = set,
+ .dstBinding = binding++,
+ .dstArrayElement = 0,
+ .descriptorCount = 1,
+ .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,
+ .pImageInfo = nullptr,
+ .pBufferInfo = &buffer_infos.back(),
+ .pTexelBufferView = nullptr,
+ });
+ return *this;
+}
+
+void LsfgDescriptorWriter::Build(const Device& device) {
+ if (writes.empty()) {
+ return;
+ }
+ device.GetLogical().UpdateDescriptorSets(writes, {});
+ writes.clear();
+}
+
+LsfgPass::LsfgPass(const Device& device, const LsfgShaders& shaders, u32 shader_id,
+ LsfgBindings bindings) {
+ std::vector<VkDescriptorType> types;
+ for (const auto& [count, type] : bindings) {
+ types.insert(types.end(), count, type);
+ }
+ descriptor_count = static_cast<u32>(types.size());
+
+ descriptor_set_layout = CreateWrappedDescriptorSetLayout(
+ device, std::span<const VkDescriptorType>{types}, VK_SHADER_STAGE_COMPUTE_BIT);
+ pipeline_layout = CreateWrappedPipelineLayout(device, descriptor_set_layout);
+ pipeline = CreateWrappedComputePipeline(device, pipeline_layout, shaders.Get(shader_id));
+}
+
+void LsfgPass::Bind(vk::CommandBuffer cmdbuf, VkDescriptorSet set) const {
+ BindPipeline(cmdbuf);
+ BindSet(cmdbuf, set);
+}
+
+void LsfgPass::BindPipeline(vk::CommandBuffer cmdbuf) const {
+ cmdbuf.BindPipeline(VK_PIPELINE_BIND_POINT_COMPUTE, *pipeline);
+}
+
+void LsfgPass::BindSet(vk::CommandBuffer cmdbuf, VkDescriptorSet set) const {
+ cmdbuf.BindDescriptorSets(VK_PIPELINE_BIND_POINT_COMPUTE, *pipeline_layout, 0, set, {});
+}
+
+vk::DescriptorPool CreateLsfgDescriptorPool(const Device& device, u32 max_sets) {
+ return CreateWrappedDescriptorPool(
+ device, DESCRIPTORS_PER_TYPE, max_sets,
+ {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_DESCRIPTOR_TYPE_SAMPLER,
+ VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE});
+}
+
+vk::Sampler CreateLsfgSampler(const Device& device, VkSamplerAddressMode address_mode,
+ VkCompareOp compare_op, bool white_border) {
+ return device.GetLogical().CreateSampler(VkSamplerCreateInfo{
+ .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
+ .pNext = nullptr,
+ .flags = 0,
+ .magFilter = VK_FILTER_LINEAR,
+ .minFilter = VK_FILTER_LINEAR,
+ .mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR,
+ .addressModeU = address_mode,
+ .addressModeV = address_mode,
+ .addressModeW = address_mode,
+ .mipLodBias = 0.0f,
+ .anisotropyEnable = VK_FALSE,
+ .maxAnisotropy = 0.0f,
+ .compareEnable = VK_FALSE,
+ .compareOp = compare_op,
+ .minLod = 0.0f,
+ .maxLod = VK_LOD_CLAMP_NONE,
+ .borderColor = white_border ? VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE
+ : VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK,
+ .unnormalizedCoordinates = VK_FALSE,
+ });
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_common.h b/src/video_core/renderer_vulkan/present/lsfg_common.h
new file mode 100644
index 0000000000..d1e3271a6d
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_common.h
@@ -0,0 +1,227 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+#include <deque>
+#include <initializer_list>
+#include <map>
+#include <utility>
+#include <vector>
+
+#include "common/common_types.h"
+#include "video_core/vulkan_common/vulkan_memory_allocator.h"
+#include "video_core/vulkan_common/vulkan_wrapper.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr VkFormat LSFG_DEFAULT_FORMAT = VK_FORMAT_R8G8B8A8_UNORM;
+constexpr VkFormat LSFG_FLOW_FORMAT = VK_FORMAT_R8_UNORM;
+constexpr VkFormat LSFG_MOTION_FORMAT = VK_FORMAT_R16G16B16A16_SFLOAT;
+
+constexpr size_t LSFG_HISTORY_SLOTS = 3;
+constexpr size_t LSFG_MAX_TARGETS = 7;
+constexpr size_t LSFG_MAX_GENERATIONS = 3;
+
+constexpr size_t LSFG_GENERATION_SLOTS = LSFG_MAX_GENERATIONS * (LSFG_MAX_GENERATIONS + 1) / 2;
+
+[[nodiscard]] constexpr size_t LsfgGenerationSlot(size_t generation_count, size_t generation) {
+ return (generation_count - 1) * generation_count / 2 + generation;
+}
+
+[[nodiscard]] constexpr f32 LsfgTimestamp(size_t generation, size_t generation_count) {
+ return static_cast<f32>(generation + 1) / static_cast<f32>(generation_count + 1);
+}
+
+[[nodiscard]] constexpr size_t LsfgSlotCount(size_t slot) {
+ size_t count = 1;
+ while (LsfgGenerationSlot(count + 1, 0) <= slot) {
+ ++count;
+ }
+ return count;
+}
+
+[[nodiscard]] constexpr f32 LsfgSlotTimestamp(size_t slot) {
+ const size_t count = LsfgSlotCount(slot);
+ return LsfgTimestamp(slot - LsfgGenerationSlot(count, 0), count);
+}
+
+class LsfgImage {
+public:
+ LsfgImage() = default;
+ LsfgImage(const Device& device, MemoryAllocator& memory_allocator, VkExtent2D extent_,
+ VkFormat format = LSFG_DEFAULT_FORMAT);
+
+ [[nodiscard]] VkImage Handle() const {
+ return *image;
+ }
+
+ [[nodiscard]] VkImageView View() const {
+ return *view;
+ }
+
+ [[nodiscard]] VkExtent2D Extent() const {
+ return extent;
+ }
+
+ [[nodiscard]] VkFormat Format() const {
+ return format;
+ }
+
+ [[nodiscard]] VkImageLayout Layout() const {
+ return layout;
+ }
+
+ void SetLayout(VkImageLayout new_layout) {
+ layout = new_layout;
+ }
+
+private:
+ vk::Image image;
+ vk::ImageView view;
+ VkExtent2D extent{};
+ VkFormat format{VK_FORMAT_UNDEFINED};
+ VkImageLayout layout{VK_IMAGE_LAYOUT_UNDEFINED};
+};
+
+using LsfgImagePair = std::array<LsfgImage, 2>;
+using LsfgImageHistory = std::array<LsfgImagePair, LSFG_HISTORY_SLOTS>;
+
+class LsfgResources {
+public:
+ LsfgResources() = default;
+ LsfgResources(const Device& device_, MemoryAllocator& memory_allocator_, f32 flow_scale_)
+ : device{&device_}, memory_allocator{&memory_allocator_}, flow_scale{flow_scale_} {}
+
+ [[nodiscard]] VkSampler GetSampler(
+ VkSamplerAddressMode address_mode = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER,
+ VkCompareOp compare_op = VK_COMPARE_OP_NEVER, bool white_border = false);
+
+ [[nodiscard]] VkBuffer GetBuffer(f32 timestamp = 0.0f, bool first_iter = false,
+ bool first_iter_s = false);
+
+ [[nodiscard]] static VkDeviceSize BufferSize();
+
+private:
+ const Device* device{};
+ MemoryAllocator* memory_allocator{};
+ f32 flow_scale{1.0f};
+
+ std::map<u64, vk::Sampler> samplers;
+ std::map<u64, vk::Buffer> buffers;
+};
+
+class LsfgBarriers {
+public:
+ explicit LsfgBarriers(vk::CommandBuffer cmdbuf_) : cmdbuf{cmdbuf_} {}
+
+ LsfgBarriers& WriteToRead(LsfgImage& image);
+ LsfgBarriers& ReadToWrite(LsfgImage& image);
+ LsfgBarriers& WriteToRead(LsfgImage* image);
+ LsfgBarriers& ReadToWrite(LsfgImage* image);
+ LsfgBarriers& DiscardToWrite(VkImage image);
+
+ template <typename Range>
+ LsfgBarriers& WriteToReadAll(Range& images) {
+ for (auto& image : images) {
+ WriteToRead(image);
+ }
+ return *this;
+ }
+
+ template <typename Range>
+ LsfgBarriers& ReadToWriteAll(Range& images) {
+ for (auto& image : images) {
+ ReadToWrite(image);
+ }
+ return *this;
+ }
+
+ void Build();
+
+private:
+ LsfgBarriers& Push(LsfgImage& image, VkAccessFlags src_access, VkAccessFlags dst_access);
+
+ vk::CommandBuffer cmdbuf;
+ std::vector<VkImageMemoryBarrier> barriers;
+};
+
+class LsfgDescriptorWriter {
+public:
+ explicit LsfgDescriptorWriter(VkDescriptorSet set_) : set{set_} {}
+
+ LsfgDescriptorWriter& AddSampler(VkSampler sampler);
+ LsfgDescriptorWriter& AddSampledImage(const LsfgImage& image);
+ LsfgDescriptorWriter& AddSampledImage(const LsfgImage* image);
+ LsfgDescriptorWriter& AddStorageImage(const LsfgImage& image);
+ LsfgDescriptorWriter& AddStorageView(VkImageView view);
+ LsfgDescriptorWriter& AddUniformBuffer(VkBuffer buffer, VkDeviceSize size);
+
+ template <typename Range>
+ LsfgDescriptorWriter& AddSampledImages(const Range& images) {
+ for (const auto& image : images) {
+ AddSampledImage(image);
+ }
+ return *this;
+ }
+
+ template <typename Range>
+ LsfgDescriptorWriter& AddStorageImages(const Range& images) {
+ for (const auto& image : images) {
+ AddStorageImage(image);
+ }
+ return *this;
+ }
+
+ void Build(const Device& device);
+
+private:
+ LsfgDescriptorWriter& PushImage(VkDescriptorType type, VkSampler sampler, VkImageView view);
+
+ VkDescriptorSet set;
+ u32 binding{};
+ std::deque<VkDescriptorImageInfo> image_infos;
+ std::deque<VkDescriptorBufferInfo> buffer_infos;
+ std::vector<VkWriteDescriptorSet> writes;
+};
+
+using LsfgBindings = std::initializer_list<std::pair<u32, VkDescriptorType>>;
+
+class LsfgPass {
+public:
+ LsfgPass() = default;
+ LsfgPass(const Device& device, const LsfgShaders& shaders, u32 shader_id,
+ LsfgBindings bindings);
+
+ [[nodiscard]] VkDescriptorSetLayout SetLayout() const {
+ return *descriptor_set_layout;
+ }
+
+ [[nodiscard]] u32 DescriptorCount() const {
+ return descriptor_count;
+ }
+
+ void Bind(vk::CommandBuffer cmdbuf, VkDescriptorSet set) const;
+ void BindPipeline(vk::CommandBuffer cmdbuf) const;
+ void BindSet(vk::CommandBuffer cmdbuf, VkDescriptorSet set) const;
+
+private:
+ vk::DescriptorSetLayout descriptor_set_layout;
+ vk::PipelineLayout pipeline_layout;
+ vk::Pipeline pipeline;
+ u32 descriptor_count{};
+};
+
+[[nodiscard]] vk::DescriptorPool CreateLsfgDescriptorPool(const Device& device, u32 max_sets);
+
+[[nodiscard]] vk::Sampler CreateLsfgSampler(const Device& device, VkSamplerAddressMode address_mode,
+ VkCompareOp compare_op, bool white_border);
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_delta.cpp b/src/video_core/renderer_vulkan/present/lsfg_delta.cpp
new file mode 100644
index 0000000000..dc6eec383f
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_delta.cpp
@@ -0,0 +1,283 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <vector>
+
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_delta.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DISPATCH_TILE_SHIFT = 3;
+
+[[nodiscard]] u32 GroupCount(u32 size) {
+ return (size + (1u << DISPATCH_TILE_SHIFT) - 1) >> DISPATCH_TILE_SHIFT;
+}
+
+} // Anonymous namespace
+
+LsfgDelta::LsfgDelta(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgShaders& shaders, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImageHistory& inputs_,
+ LsfgImage& flow_input_, LsfgImage* previous_gamma_, LsfgImage* previous1_,
+ LsfgImage* previous2_)
+ : inputs{&inputs_}, flow_input{&flow_input_}, previous_gamma{previous_gamma_},
+ previous1{previous1_}, previous2{previous2_} {
+ using namespace VideoCore::FrameGen::PerformanceShader;
+
+ passes[0] = LsfgPass(device, shaders, DELTA[0],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {5, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {3, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[1] = LsfgPass(device, shaders, DELTA[1],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {3, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[2] = LsfgPass(device, shaders, DELTA[2],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[3] = LsfgPass(device, shaders, DELTA[3],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[4] = LsfgPass(device, shaders, DELTA[4],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {4, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[5] = LsfgPass(device, shaders, DELTA[5],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {6, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ for (size_t i = 6; i < LSFG_DELTA_STAGES - 1; ++i) {
+ passes[i] = LsfgPass(device, shaders, DELTA[i],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ }
+ passes[9] = LsfgPass(device, shaders, DELTA[9],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+
+ const VkExtent2D extent = (*inputs)[0][0].Extent();
+ for (auto& image : temp1) {
+ image = LsfgImage(device, memory_allocator, extent);
+ }
+ for (auto& image : temp2) {
+ image = LsfgImage(device, memory_allocator, extent);
+ }
+ out_image1 = LsfgImage(device, memory_allocator, extent, LSFG_MOTION_FORMAT);
+ out_image2 = LsfgImage(device, memory_allocator, extent, LSFG_MOTION_FORMAT);
+
+ std::vector<VkDescriptorSetLayout> layouts;
+ for (size_t slot = 0; slot < LSFG_GENERATION_SLOTS; ++slot) {
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ layouts.push_back(passes[0].SetLayout());
+ }
+ for (size_t i = 1; i <= 4; ++i) {
+ layouts.push_back(passes[i].SetLayout());
+ }
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ layouts.push_back(passes[5].SetLayout());
+ }
+ for (size_t i = 6; i < LSFG_DELTA_STAGES; ++i) {
+ layouts.push_back(passes[i].SetLayout());
+ }
+ }
+ owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
+
+ const VkSampler sampler = resources.GetSampler();
+ const VkSampler border_sampler = resources.GetSampler(
+ VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, VK_COMPARE_OP_NEVER, true);
+ const VkSampler edge_sampler =
+ resources.GetSampler(VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_COMPARE_OP_ALWAYS, false);
+ size_t next = 0;
+ for (size_t slot = 0; slot < LSFG_GENERATION_SLOTS; ++slot) {
+ Generation& pass = generations[slot];
+ const VkBuffer buffer =
+ resources.GetBuffer(LsfgSlotTimestamp(slot), false, previous_gamma == nullptr);
+
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ pass.first_descriptor_sets[i] = owned_sets[next++];
+ }
+ for (size_t i = 0; i < 4; ++i) {
+ pass.descriptor_sets[i] = owned_sets[next++];
+ }
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ pass.sixth_descriptor_sets[i] = owned_sets[next++];
+ }
+ for (size_t i = 4; i < LSFG_DELTA_STAGES - 2; ++i) {
+ pass.descriptor_sets[i] = owned_sets[next++];
+ }
+
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ LsfgDescriptorWriter(pass.first_descriptor_sets[i])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(border_sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImages((*inputs)[(i + 2) % LSFG_HISTORY_SLOTS])
+ .AddSampledImages((*inputs)[i % LSFG_HISTORY_SLOTS])
+ .AddSampledImage(previous_gamma)
+ .AddStorageImages(temp1)
+ .Build(device);
+ LsfgDescriptorWriter(pass.sixth_descriptor_sets[i])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(border_sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImages((*inputs)[(i + 2) % LSFG_HISTORY_SLOTS])
+ .AddSampledImages((*inputs)[i % LSFG_HISTORY_SLOTS])
+ .AddSampledImage(previous_gamma)
+ .AddSampledImage(previous1)
+ .AddStorageImage(temp2[0])
+ .Build(device);
+ }
+ LsfgDescriptorWriter(pass.descriptor_sets[0])
+ .AddSampler(sampler)
+ .AddSampledImages(temp1)
+ .AddStorageImages(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[1])
+ .AddSampler(sampler)
+ .AddSampledImages(temp2)
+ .AddStorageImage(temp1[0])
+ .AddStorageImage(temp1[1])
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[2])
+ .AddSampler(sampler)
+ .AddSampledImage(temp1[0])
+ .AddSampledImage(temp1[1])
+ .AddStorageImages(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[3])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImages(temp2)
+ .AddSampledImage(previous_gamma)
+ .AddSampledImage(*flow_input)
+ .AddStorageImage(out_image1)
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[4])
+ .AddSampler(sampler)
+ .AddSampledImage(temp2[0])
+ .AddStorageImage(temp1[0])
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[5])
+ .AddSampler(sampler)
+ .AddSampledImage(temp1[0])
+ .AddStorageImage(temp2[0])
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[6])
+ .AddSampler(sampler)
+ .AddSampledImage(temp2[0])
+ .AddStorageImage(temp1[0])
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[7])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImage(temp1[0])
+ .AddSampledImage(previous2)
+ .AddStorageImage(out_image2)
+ .Build(device);
+ }
+}
+
+void LsfgDelta::Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count, size_t slot) {
+ const Generation& pass = generations[slot];
+
+ const VkExtent2D extent = temp1[0].Extent();
+ const u32 groups_x = GroupCount(extent.width);
+ const u32 groups_y = GroupCount(extent.height);
+
+ const size_t history = frame_count % LSFG_HISTORY_SLOTS;
+ const size_t previous_history = (frame_count + 2) % LSFG_HISTORY_SLOTS;
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll((*inputs)[previous_history])
+ .WriteToReadAll((*inputs)[history])
+ .WriteToRead(previous_gamma)
+ .ReadToWriteAll(temp1)
+ .Build();
+ passes[0].Bind(cmdbuf, pass.first_descriptor_sets[history]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
+ passes[1].Bind(cmdbuf, pass.descriptor_sets[0]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp2).ReadToWriteAll(temp1).Build();
+ passes[2].Bind(cmdbuf, pass.descriptor_sets[1]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
+ passes[3].Bind(cmdbuf, pass.descriptor_sets[2]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll(temp2)
+ .WriteToRead(previous_gamma)
+ .WriteToRead(*flow_input)
+ .ReadToWrite(out_image1)
+ .Build();
+ passes[4].Bind(cmdbuf, pass.descriptor_sets[3]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll((*inputs)[previous_history])
+ .WriteToReadAll((*inputs)[history])
+ .WriteToRead(previous_gamma)
+ .WriteToRead(previous1)
+ .ReadToWriteAll(temp2)
+ .Build();
+ passes[5].Bind(cmdbuf, pass.sixth_descriptor_sets[history]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll(temp2)
+ .ReadToWrite(temp1[0])
+ .ReadToWrite(temp1[1])
+ .Build();
+ passes[6].Bind(cmdbuf, pass.descriptor_sets[4]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToRead(temp1[0])
+ .WriteToRead(temp1[1])
+ .ReadToWriteAll(temp2)
+ .Build();
+ passes[7].Bind(cmdbuf, pass.descriptor_sets[5]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll(temp2)
+ .ReadToWrite(temp1[0])
+ .ReadToWrite(temp1[1])
+ .Build();
+ passes[8].Bind(cmdbuf, pass.descriptor_sets[6]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToRead(temp1[0])
+ .WriteToRead(temp1[1])
+ .WriteToRead(previous2)
+ .ReadToWrite(out_image2)
+ .Build();
+ passes[9].Bind(cmdbuf, pass.descriptor_sets[7]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_delta.h b/src/video_core/renderer_vulkan/present/lsfg_delta.h
new file mode 100644
index 0000000000..1bbcfe6745
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_delta.h
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr size_t LSFG_DELTA_STAGES = 10;
+constexpr size_t LSFG_DELTA_TEMPS = 3;
+
+class LsfgDelta {
+public:
+ LsfgDelta() = default;
+ LsfgDelta(const Device& device, MemoryAllocator& memory_allocator, const LsfgShaders& shaders,
+ LsfgResources& resources, vk::DescriptorPool& descriptor_pool,
+ LsfgImageHistory& inputs, LsfgImage& flow_input, LsfgImage* previous_gamma,
+ LsfgImage* previous1, LsfgImage* previous2);
+
+ void Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count, size_t slot);
+
+ [[nodiscard]] LsfgImage& Output1() {
+ return out_image1;
+ }
+
+ [[nodiscard]] LsfgImage& Output2() {
+ return out_image2;
+ }
+
+private:
+ struct Generation {
+ std::array<VkDescriptorSet, LSFG_HISTORY_SLOTS> first_descriptor_sets{};
+ std::array<VkDescriptorSet, LSFG_HISTORY_SLOTS> sixth_descriptor_sets{};
+ std::array<VkDescriptorSet, LSFG_DELTA_STAGES - 2> descriptor_sets{};
+ };
+
+ LsfgImageHistory* inputs{};
+ LsfgImage* flow_input{};
+ LsfgImage* previous_gamma{};
+ LsfgImage* previous1{};
+ LsfgImage* previous2{};
+
+ std::array<LsfgPass, LSFG_DELTA_STAGES> passes;
+ std::array<Generation, LSFG_GENERATION_SLOTS> generations{};
+ vk::DescriptorSets owned_sets;
+
+ std::array<LsfgImage, LSFG_DELTA_TEMPS> temp1;
+ LsfgImagePair temp2;
+ LsfgImage out_image1;
+ LsfgImage out_image2;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_gamma.cpp b/src/video_core/renderer_vulkan/present/lsfg_gamma.cpp
new file mode 100644
index 0000000000..067d124b6f
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_gamma.cpp
@@ -0,0 +1,185 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <vector>
+
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_gamma.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DISPATCH_TILE_SHIFT = 3;
+
+[[nodiscard]] u32 GroupCount(u32 size) {
+ return (size + (1u << DISPATCH_TILE_SHIFT) - 1) >> DISPATCH_TILE_SHIFT;
+}
+
+} // Anonymous namespace
+
+LsfgGamma::LsfgGamma(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgShaders& shaders, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImageHistory& inputs_,
+ LsfgImage& flow_input_, LsfgImage* previous_)
+ : inputs{&inputs_}, flow_input{&flow_input_}, previous{previous_} {
+ using namespace VideoCore::FrameGen::PerformanceShader;
+
+ passes[0] = LsfgPass(device, shaders, GAMMA[0],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {5, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {3, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[1] = LsfgPass(device, shaders, GAMMA[1],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {3, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[2] = LsfgPass(device, shaders, GAMMA[2],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[3] = LsfgPass(device, shaders, GAMMA[3],
+ {{1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+ passes[4] = LsfgPass(device, shaders, GAMMA[4],
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {4, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+
+ const VkExtent2D extent = (*inputs)[0][0].Extent();
+ for (auto& image : temp1) {
+ image = LsfgImage(device, memory_allocator, extent);
+ }
+ for (auto& image : temp2) {
+ image = LsfgImage(device, memory_allocator, extent);
+ }
+ out_image = LsfgImage(device, memory_allocator, extent, LSFG_MOTION_FORMAT);
+
+ std::vector<VkDescriptorSetLayout> layouts;
+ for (size_t slot = 0; slot < LSFG_GENERATION_SLOTS; ++slot) {
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ layouts.push_back(passes[0].SetLayout());
+ }
+ for (size_t i = 1; i < LSFG_GAMMA_STAGES; ++i) {
+ layouts.push_back(passes[i].SetLayout());
+ }
+ }
+ owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
+
+ const VkSampler sampler = resources.GetSampler();
+ const VkSampler border_sampler = resources.GetSampler(
+ VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, VK_COMPARE_OP_NEVER, true);
+ const VkSampler edge_sampler =
+ resources.GetSampler(VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_COMPARE_OP_ALWAYS, false);
+
+ size_t next = 0;
+ for (size_t slot = 0; slot < LSFG_GENERATION_SLOTS; ++slot) {
+ Generation& pass = generations[slot];
+ const VkBuffer buffer =
+ resources.GetBuffer(LsfgSlotTimestamp(slot), previous == nullptr);
+
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ pass.first_descriptor_sets[i] = owned_sets[next++];
+ }
+ for (size_t i = 0; i < LSFG_GAMMA_STAGES - 1; ++i) {
+ pass.descriptor_sets[i] = owned_sets[next++];
+ }
+
+ for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
+ LsfgDescriptorWriter(pass.first_descriptor_sets[i])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(border_sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImages((*inputs)[(i + 2) % LSFG_HISTORY_SLOTS])
+ .AddSampledImages((*inputs)[i % LSFG_HISTORY_SLOTS])
+ .AddSampledImage(previous)
+ .AddStorageImages(temp1)
+ .Build(device);
+ }
+ LsfgDescriptorWriter(pass.descriptor_sets[0])
+ .AddSampler(sampler)
+ .AddSampledImages(temp1)
+ .AddStorageImages(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[1])
+ .AddSampler(sampler)
+ .AddSampledImages(temp2)
+ .AddStorageImage(temp1[0])
+ .AddStorageImage(temp1[1])
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[2])
+ .AddSampler(sampler)
+ .AddSampledImage(temp1[0])
+ .AddSampledImage(temp1[1])
+ .AddStorageImages(temp2)
+ .Build(device);
+ LsfgDescriptorWriter(pass.descriptor_sets[3])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImages(temp2)
+ .AddSampledImage(previous)
+ .AddSampledImage(*flow_input)
+ .AddStorageImage(out_image)
+ .Build(device);
+ }
+}
+
+void LsfgGamma::Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count, size_t slot) {
+ const Generation& pass = generations[slot];
+
+ const VkExtent2D extent = temp1[0].Extent();
+ const u32 groups_x = GroupCount(extent.width);
+ const u32 groups_y = GroupCount(extent.height);
+
+ const size_t history = frame_count % LSFG_HISTORY_SLOTS;
+ const size_t previous_history = (frame_count + 2) % LSFG_HISTORY_SLOTS;
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll((*inputs)[previous_history])
+ .WriteToReadAll((*inputs)[history])
+ .WriteToRead(previous)
+ .ReadToWriteAll(temp1)
+ .Build();
+ passes[0].Bind(cmdbuf, pass.first_descriptor_sets[history]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
+ passes[1].Bind(cmdbuf, pass.descriptor_sets[0]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll(temp2)
+ .ReadToWrite(temp1[0])
+ .ReadToWrite(temp1[1])
+ .Build();
+ passes[2].Bind(cmdbuf, pass.descriptor_sets[1]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToRead(temp1[0])
+ .WriteToRead(temp1[1])
+ .ReadToWriteAll(temp2)
+ .Build();
+ passes[3].Bind(cmdbuf, pass.descriptor_sets[2]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll(temp2)
+ .WriteToRead(previous)
+ .WriteToRead(*flow_input)
+ .ReadToWrite(out_image)
+ .Build();
+ passes[4].Bind(cmdbuf, pass.descriptor_sets[3]);
+ cmdbuf.Dispatch(groups_x, groups_y, 1);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_gamma.h b/src/video_core/renderer_vulkan/present/lsfg_gamma.h
new file mode 100644
index 0000000000..848c380bfe
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_gamma.h
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr size_t LSFG_GAMMA_STAGES = 5;
+constexpr size_t LSFG_GAMMA_TEMPS = 3;
+
+class LsfgGamma {
+public:
+ LsfgGamma() = default;
+ LsfgGamma(const Device& device, MemoryAllocator& memory_allocator, const LsfgShaders& shaders,
+ LsfgResources& resources, vk::DescriptorPool& descriptor_pool,
+ LsfgImageHistory& inputs, LsfgImage& flow_input, LsfgImage* previous);
+
+ void Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count, size_t slot);
+
+ [[nodiscard]] LsfgImage& Output() {
+ return out_image;
+ }
+
+private:
+ struct Generation {
+ std::array<VkDescriptorSet, LSFG_HISTORY_SLOTS> first_descriptor_sets{};
+ std::array<VkDescriptorSet, LSFG_GAMMA_STAGES - 1> descriptor_sets{};
+ };
+
+ LsfgImageHistory* inputs{};
+ LsfgImage* flow_input{};
+ LsfgImage* previous{};
+
+ std::array<LsfgPass, LSFG_GAMMA_STAGES> passes;
+ std::array<Generation, LSFG_GENERATION_SLOTS> generations{};
+ vk::DescriptorSets owned_sets;
+
+ std::array<LsfgImage, LSFG_GAMMA_TEMPS> temp1;
+ LsfgImagePair temp2;
+ LsfgImage out_image;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_generate.cpp b/src/video_core/renderer_vulkan/present/lsfg_generate.cpp
new file mode 100644
index 0000000000..4473b59a2f
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_generate.cpp
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <vector>
+
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_generate.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DISPATCH_TILE_SHIFT = 4;
+
+[[nodiscard]] u32 GroupCount(u32 size) {
+ return (size + (1u << DISPATCH_TILE_SHIFT) - 1) >> DISPATCH_TILE_SHIFT;
+}
+
+VkImageMemoryBarrier MakeTargetBarrier(VkImage image, VkAccessFlags src_access,
+ VkAccessFlags dst_access, VkImageLayout old_layout) {
+ return VkImageMemoryBarrier{
+ .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
+ .pNext = nullptr,
+ .srcAccessMask = src_access,
+ .dstAccessMask = dst_access,
+ .oldLayout = old_layout,
+ .newLayout = VK_IMAGE_LAYOUT_GENERAL,
+ .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
+ .image = image,
+ .subresourceRange{
+ .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
+ .baseMipLevel = 0,
+ .levelCount = 1,
+ .baseArrayLayer = 0,
+ .layerCount = 1,
+ },
+ };
+}
+
+} // Anonymous namespace
+
+LsfgGenerate::LsfgGenerate(const Device& device, const LsfgShaders& shaders,
+ LsfgResources& resources, vk::DescriptorPool& descriptor_pool,
+ LsfgImagePair& frames_, LsfgImage& motion_, LsfgImage& detail1_,
+ LsfgImage& detail2_)
+ : frames{&frames_}, motion{&motion_}, detail1{&detail1_}, detail2{&detail2_} {
+ using namespace VideoCore::FrameGen::PerformanceShader;
+
+ pass = LsfgPass(device, shaders, GENERATE,
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {2, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {5, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+
+ sampler = resources.GetSampler();
+ edge_sampler =
+ resources.GetSampler(VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, VK_COMPARE_OP_ALWAYS, false);
+
+ const std::vector<VkDescriptorSetLayout> layouts(
+ LSFG_GENERATION_SLOTS * LSFG_MAX_TARGETS * 2, pass.SetLayout());
+ owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
+
+ size_t next = 0;
+ for (size_t slot = 0; slot < LSFG_GENERATION_SLOTS; ++slot) {
+ Generation& target = generations[slot];
+ target.buffer = resources.GetBuffer(LsfgSlotTimestamp(slot));
+
+ for (auto& entry : target.targets) {
+ for (auto& set : entry.descriptor_sets) {
+ set = owned_sets[next++];
+ }
+ }
+ }
+}
+
+void LsfgGenerate::SetTarget(const Device& device, size_t slot, u32 target, VkImageView view) {
+ Target& entry = generations[slot].targets[target];
+ if (entry.view == view) {
+ return;
+ }
+ entry.view = view;
+
+ for (size_t i = 0; i < entry.descriptor_sets.size(); ++i) {
+ LsfgDescriptorWriter(entry.descriptor_sets[i])
+ .AddUniformBuffer(generations[slot].buffer, LsfgResources::BufferSize())
+ .AddSampler(sampler)
+ .AddSampler(edge_sampler)
+ .AddSampledImage((*frames)[1 - i])
+ .AddSampledImage((*frames)[i])
+ .AddSampledImage(*motion)
+ .AddSampledImage(*detail1)
+ .AddSampledImage(*detail2)
+ .AddStorageView(view)
+ .Build(device);
+ }
+}
+
+void LsfgGenerate::Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count, size_t slot, u32 target,
+ VkImage image, VkExtent2D extent) {
+ const Target& entry = generations[slot].targets[target];
+
+ LsfgBarriers(cmdbuf)
+ .WriteToReadAll(*frames)
+ .WriteToRead(*motion)
+ .WriteToRead(*detail1)
+ .WriteToRead(*detail2)
+ .DiscardToWrite(image)
+ .Build();
+
+ pass.Bind(cmdbuf, entry.descriptor_sets[frame_count % entry.descriptor_sets.size()]);
+ cmdbuf.Dispatch(GroupCount(extent.width), GroupCount(extent.height), 1);
+
+ const std::array after{MakeTargetBarrier(
+ image, VK_ACCESS_SHADER_WRITE_BIT,
+ VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT,
+ VK_IMAGE_LAYOUT_GENERAL)};
+ cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
+ VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
+ VK_PIPELINE_STAGE_TRANSFER_BIT,
+ 0, {}, {}, after);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_generate.h b/src/video_core/renderer_vulkan/present/lsfg_generate.h
new file mode 100644
index 0000000000..75cbd8d2e4
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_generate.h
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+class LsfgGenerate {
+public:
+ LsfgGenerate() = default;
+ LsfgGenerate(const Device& device, const LsfgShaders& shaders, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImagePair& frames, LsfgImage& motion,
+ LsfgImage& detail1, LsfgImage& detail2);
+
+ void SetTarget(const Device& device, size_t slot, u32 target, VkImageView view);
+
+ void Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count, size_t slot, u32 target,
+ VkImage image, VkExtent2D extent);
+
+private:
+ struct Target {
+ std::array<VkDescriptorSet, 2> descriptor_sets{};
+ VkImageView view{};
+ };
+
+ struct Generation {
+ std::array<Target, LSFG_MAX_TARGETS> targets{};
+ VkBuffer buffer{};
+ };
+
+ LsfgImagePair* frames{};
+ LsfgImage* motion{};
+ LsfgImage* detail1{};
+ LsfgImage* detail2{};
+ VkSampler sampler{};
+ VkSampler edge_sampler{};
+
+ LsfgPass pass;
+ std::array<Generation, LSFG_GENERATION_SLOTS> generations{};
+ vk::DescriptorSets owned_sets;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_mipmaps.cpp b/src/video_core/renderer_vulkan/present/lsfg_mipmaps.cpp
new file mode 100644
index 0000000000..3f31f58d68
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_mipmaps.cpp
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <algorithm>
+#include <vector>
+
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_mipmaps.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+namespace {
+
+constexpr u32 DISPATCH_TILE_SHIFT = 6;
+
+[[nodiscard]] u32 GroupCount(u32 size) {
+ return (size + (1u << DISPATCH_TILE_SHIFT) - 1) >> DISPATCH_TILE_SHIFT;
+}
+
+} // Anonymous namespace
+
+LsfgMipmaps::LsfgMipmaps(const Device& device, MemoryAllocator& memory_allocator,
+ const LsfgShaders& shaders, LsfgResources& resources,
+ vk::DescriptorPool& descriptor_pool, LsfgImagePair& frames_,
+ f32 flow_scale)
+ : frames{&frames_} {
+ using namespace VideoCore::FrameGen::PerformanceShader;
+
+ pass = LsfgPass(device, shaders, MIPMAPS,
+ {{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLER},
+ {1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
+ {LSFG_MIP_LEVELS, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
+
+ const VkExtent2D input_extent = (*frames)[0].Extent();
+ flow_extent = VkExtent2D{
+ .width = std::max(1u, static_cast<u32>(static_cast<f32>(input_extent.width) * flow_scale)),
+ .height = std::max(1u, static_cast<u32>(static_cast<f32>(input_extent.height) * flow_scale)),
+ };
+
+ for (size_t i = 0; i < LSFG_MIP_LEVELS; ++i) {
+ const VkExtent2D level_extent{
+ .width = flow_extent.width >> i,
+ .height = flow_extent.height >> i,
+ };
+ out_images[i] = LsfgImage(device, memory_allocator, level_extent, LSFG_FLOW_FORMAT);
+ }
+
+ const std::vector<VkDescriptorSetLayout> layouts(descriptor_sets.size(), pass.SetLayout());
+ owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
+
+ const VkSampler sampler = resources.GetSampler();
+ const VkBuffer buffer = resources.GetBuffer();
+
+ for (size_t i = 0; i < descriptor_sets.size(); ++i) {
+ descriptor_sets[i] = owned_sets[i];
+ LsfgDescriptorWriter(descriptor_sets[i])
+ .AddUniformBuffer(buffer, LsfgResources::BufferSize())
+ .AddSampler(sampler)
+ .AddSampledImage((*frames)[i])
+ .AddStorageImages(out_images)
+ .Build(device);
+ }
+}
+
+void LsfgMipmaps::Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count) {
+ const size_t slot = frame_count % descriptor_sets.size();
+
+ LsfgBarriers(cmdbuf).WriteToRead((*frames)[slot]).ReadToWriteAll(out_images).Build();
+
+ pass.Bind(cmdbuf, descriptor_sets[slot]);
+ cmdbuf.Dispatch(GroupCount(flow_extent.width), GroupCount(flow_extent.height), 1);
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_mipmaps.h b/src/video_core/renderer_vulkan/present/lsfg_mipmaps.h
new file mode 100644
index 0000000000..7fa5b7b224
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_mipmaps.h
@@ -0,0 +1,45 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <array>
+
+#include "common/common_types.h"
+#include "video_core/renderer_vulkan/present/lsfg_common.h"
+
+namespace Vulkan {
+
+class Device;
+class LsfgShaders;
+
+constexpr size_t LSFG_MIP_LEVELS = 7;
+
+class LsfgMipmaps {
+public:
+ LsfgMipmaps() = default;
+ LsfgMipmaps(const Device& device, MemoryAllocator& memory_allocator, const LsfgShaders& shaders,
+ LsfgResources& resources, vk::DescriptorPool& descriptor_pool,
+ LsfgImagePair& frames, f32 flow_scale);
+
+ void Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count);
+
+ [[nodiscard]] LsfgImage& Output(size_t level) {
+ return out_images[level];
+ }
+
+private:
+ LsfgImagePair* frames{};
+
+ LsfgPass pass;
+ std::array<VkDescriptorSet, 2> descriptor_sets{};
+ vk::DescriptorSets owned_sets;
+
+ VkExtent2D flow_extent{};
+ std::array<LsfgImage, LSFG_MIP_LEVELS> out_images;
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_shaders.cpp b/src/video_core/renderer_vulkan/present/lsfg_shaders.cpp
new file mode 100644
index 0000000000..aafe0d6419
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_shaders.cpp
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "common/settings.h"
+#include "video_core/frame_gen/lossless_dll.h"
+#include "video_core/renderer_vulkan/present/lsfg_shaders.h"
+#include "video_core/renderer_vulkan/present/util.h"
+#include "video_core/vulkan_common/vulkan_device.h"
+
+namespace Vulkan {
+
+LsfgShaders::LsfgShaders(const Device& device) {
+ if (!device.IsVulkanMemoryModelSupported() || !device.HasNullDescriptor()) {
+ return;
+ }
+
+ const bool allow_fp16 = device.IsFloat16Supported();
+ const bool prefer_fp16 = allow_fp16 && Settings::values.frame_gen_fp16.GetValue();
+
+ VideoCore::FrameGen::ShaderModules code;
+ if (VideoCore::FrameGen::LoadShaderModules(code, allow_fp16, prefer_fp16) !=
+ VideoCore::FrameGen::LosslessStatus::Ok) {
+ return;
+ }
+
+ for (const auto& [id, words] : code) {
+ modules.emplace(id, CreateWrappedShaderModule(device, words));
+ }
+ valid = true;
+}
+
+VkShaderModule LsfgShaders::Get(u32 shader_id) const {
+ const auto hit = modules.find(shader_id);
+ return hit == modules.end() ? VK_NULL_HANDLE : *hit->second;
+}
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/lsfg_shaders.h b/src/video_core/renderer_vulkan/present/lsfg_shaders.h
new file mode 100644
index 0000000000..cab37110d6
--- /dev/null
+++ b/src/video_core/renderer_vulkan/present/lsfg_shaders.h
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <map>
+
+#include "common/common_types.h"
+#include "video_core/vulkan_common/vulkan_wrapper.h"
+
+namespace Vulkan {
+
+class Device;
+
+class LsfgShaders {
+public:
+ explicit LsfgShaders(const Device& device);
+
+ [[nodiscard]] bool IsValid() const {
+ return valid;
+ }
+
+ [[nodiscard]] VkShaderModule Get(u32 shader_id) const;
+
+private:
+ std::map<u32, vk::ShaderModule> modules;
+ bool valid{};
+};
+
+} // namespace Vulkan
diff --git a/src/video_core/renderer_vulkan/present/util.cpp b/src/video_core/renderer_vulkan/present/util.cpp
index 2b83902bc0..11250687d6 100644
--- a/src/video_core/renderer_vulkan/present/util.cpp
+++ b/src/video_core/renderer_vulkan/present/util.cpp
@@ -320,15 +320,16 @@ vk::DescriptorPool CreateWrappedDescriptorPool(const Device& device, size_t max_
});
}
-vk::DescriptorSetLayout CreateWrappedDescriptorSetLayout(
- const Device& device, std::initializer_list<VkDescriptorType> types) {
+vk::DescriptorSetLayout CreateWrappedDescriptorSetLayout(const Device& device,
+ std::span<const VkDescriptorType> types,
+ VkShaderStageFlags stages) {
std::vector<VkDescriptorSetLayoutBinding> bindings(types.size());
for (size_t i = 0; i < types.size(); i++) {
bindings[i] = {
.binding = static_cast<u32>(i),
- .descriptorType = std::data(types)[i],
+ .descriptorType = types[i],
.descriptorCount = 1,
- .stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT,
+ .stageFlags = stages,
.pImmutableSamplers = nullptr,
};
}
@@ -342,6 +343,13 @@ vk::DescriptorSetLayout CreateWrappedDescriptorSetLayout(
});
}
+vk::DescriptorSetLayout CreateWrappedDescriptorSetLayout(
+ const Device& device, std::initializer_list<VkDescriptorType> types,
+ VkShaderStageFlags stages) {
+ return CreateWrappedDescriptorSetLayout(
+ device, std::span<const VkDescriptorType>{std::data(types), types.size()}, stages);
+}
+
vk::DescriptorSets CreateWrappedDescriptorSets(vk::DescriptorPool& pool,
vk::Span<VkDescriptorSetLayout> layouts) {
return pool.Allocate(VkDescriptorSetAllocateInfo{
@@ -353,6 +361,28 @@ vk::DescriptorSets CreateWrappedDescriptorSets(vk::DescriptorPool& pool,
});
}
+vk::Pipeline CreateWrappedComputePipeline(const Device& device, vk::PipelineLayout& layout,
+ VkShaderModule shader) {
+ return device.GetLogical().CreateComputePipeline(VkComputePipelineCreateInfo{
+ .sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
+ .pNext = nullptr,
+ .flags = 0,
+ .stage =
+ {
+ .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
+ .pNext = nullptr,
+ .flags = 0,
+ .stage = VK_SHADER_STAGE_COMPUTE_BIT,
+ .module = shader,
+ .pName = "main",
+ .pSpecializationInfo = nullptr,
+ },
+ .layout = *layout,
+ .basePipelineHandle = VK_NULL_HANDLE,
+ .basePipelineIndex = 0,
+ });
+}
+
vk::PipelineLayout CreateWrappedPipelineLayout(const Device& device,
vk::DescriptorSetLayout& layout) {
return device.GetLogical().CreatePipelineLayout(VkPipelineLayoutCreateInfo{
diff --git a/src/video_core/renderer_vulkan/present/util.h b/src/video_core/renderer_vulkan/present/util.h
index 38cc6203c5..ecc263286d 100644
--- a/src/video_core/renderer_vulkan/present/util.h
+++ b/src/video_core/renderer_vulkan/present/util.h
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
+// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
@@ -38,7 +38,11 @@ vk::DescriptorPool CreateWrappedDescriptorPool(const Device& device, size_t max_
std::initializer_list<VkDescriptorType> types = {
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER});
vk::DescriptorSetLayout CreateWrappedDescriptorSetLayout(
- const Device& device, std::initializer_list<VkDescriptorType> types);
+ const Device& device, std::initializer_list<VkDescriptorType> types,
+ VkShaderStageFlags stages = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT);
+vk::DescriptorSetLayout CreateWrappedDescriptorSetLayout(const Device& device,
+ std::span<const VkDescriptorType> types,
+ VkShaderStageFlags stages);
vk::DescriptorSets CreateWrappedDescriptorSets(vk::DescriptorPool& pool,
vk::Span<VkDescriptorSetLayout> layouts);
vk::PipelineLayout CreateWrappedPipelineLayout(const Device& device,
@@ -46,6 +50,8 @@ vk::PipelineLayout CreateWrappedPipelineLayout(const Device& device,
vk::Pipeline CreateWrappedPipeline(const Device& device, vk::RenderPass& renderpass,
vk::PipelineLayout& layout,
std::tuple<vk::ShaderModule&, vk::ShaderModule&> shaders);
+vk::Pipeline CreateWrappedComputePipeline(const Device& device, vk::PipelineLayout& layout,
+ VkShaderModule shader);
vk::Pipeline CreateWrappedPremultipliedBlendingPipeline(
const Device& device, vk::RenderPass& renderpass, vk::PipelineLayout& layout,
std::tuple<vk::ShaderModule&, vk::ShaderModule&> shaders);