From 311c2dd1cd967253af6da69b336369ff4dd971b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valdis=20Bogd=C4=81ns?= Date: Sun, 12 Apr 2026 17:41:01 +0300 Subject: [PATCH] Implement screenshot functionality with overlays and game-only options (#4248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement screenshot functionality with overlays and game-only options * CLang 🤦 * video: F12 game screenshot, Alt+F12 HUD screenshot - Capture game-only screenshots from the VideoOut image before FSR/PP scaling (native guest output res) - Capture overlay screenshots from the swapchain/output image; HDR screenshots are tone-mapped to SDR PNG - Split screenshot request counters + consumption for game-only vs with-overlays - Add A2R10G10B10 readback handling and force opaque alpha in PNG output - Update default hotkeys (keep backward-compat with hotkey_renderdoc_capture) - Ignore tmp/artifacts/ * Add legacy capture binding support in input configuration --------- Co-authored-by: w1naenator --- README.md | 3 +- src/common/config.cpp | 15 +- src/input/input_handler.cpp | 19 +- src/input/input_handler.h | 7 +- src/sdl_window.cpp | 9 +- src/video_core/renderdoc.cpp | 36 ++ src/video_core/renderdoc.h | 28 + .../renderer_vulkan/vk_presenter.cpp | 497 +++++++++++++++++- 8 files changed, 588 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 3f24dfd40..5fec0efd2 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ For more information on how to test, debug and report issues with the emulator o F10 | FPS Counter Ctrl+F10 | Video Debug Info F11 | Fullscreen -F12 | Trigger RenderDoc Capture +F12 | Trigger RenderDoc Capture (or game-only screenshot if RenderDoc is unavailable) +Alt+F12 | Capture screenshot including HUD/dialog overlays > [!NOTE] > Xbox and DualShock controllers work out of the box. diff --git a/src/common/config.cpp b/src/common/config.cpp index 79d3f799f..60efdc656 100644 --- a/src/common/config.cpp +++ b/src/common/config.cpp @@ -1436,7 +1436,8 @@ std::filesystem::path GetInputConfigFile(const string& game_id) { } if (game_id == "global") { std::map default_bindings_to_add = { - {"hotkey_renderdoc_capture", "f12"}, + {"hotkey_capture_frame", "f12"}, + {"hotkey_screenshot_with_overlays", "lalt, f12"}, {"hotkey_fullscreen", "f11"}, {"hotkey_show_fps", "f10"}, {"hotkey_pause", "f9"}, @@ -1448,6 +1449,8 @@ std::filesystem::path GetInputConfigFile(const string& game_id) { {"hotkey_volume_up", "kpplus"}, {"hotkey_volume_down", "kpminus"}, }; + string legacy_capture_binding; + bool legacy_capture_binding_found = false; std::ifstream global_in(config_file); string line; while (std::getline(global_in, line)) { @@ -1459,9 +1462,19 @@ std::filesystem::path GetInputConfigFile(const string& game_id) { continue; } std::string output_string = line.substr(0, equal_pos); + if (output_string == "hotkey_renderdoc_capture") { + legacy_capture_binding = line.substr(equal_pos + 1); + legacy_capture_binding_found = true; + } default_bindings_to_add.erase(output_string); } global_in.close(); + if (legacy_capture_binding_found) { + if (auto it = default_bindings_to_add.find("hotkey_capture_frame"); + it != default_bindings_to_add.end()) { + it->second = legacy_capture_binding; + } + } std::ofstream global_out(config_file, std::ios::app); for (auto const& b : default_bindings_to_add) { global_out << b.first << " = " << b.second << "\n"; diff --git a/src/input/input_handler.cpp b/src/input/input_handler.cpp index cf258235d..35433393e 100644 --- a/src/input/input_handler.cpp +++ b/src/input/input_handler.cpp @@ -166,7 +166,8 @@ std::filesystem::path GetInputConfigFile(const std::string& game_id) { } if (game_id == "global") { std::map default_bindings_to_add = { - {"hotkey_renderdoc_capture", "f12"}, + {"hotkey_capture_frame", "f12"}, + {"hotkey_screenshot_with_overlays", "lalt, f12"}, {"hotkey_fullscreen", "f11"}, {"hotkey_show_fps", "f10"}, {"hotkey_pause", "f9"}, @@ -180,6 +181,8 @@ std::filesystem::path GetInputConfigFile(const std::string& game_id) { {"hotkey_volume_up", "kpplus"}, {"hotkey_volume_down", "kpminus"}, }; + std::string legacy_capture_binding; + bool legacy_capture_binding_found = false; std::ifstream global_in(config_file); std::string line; while (std::getline(global_in, line)) { @@ -191,9 +194,19 @@ std::filesystem::path GetInputConfigFile(const std::string& game_id) { continue; } std::string output_string = line.substr(0, equal_pos); + if (output_string == "hotkey_renderdoc_capture") { + legacy_capture_binding = line.substr(equal_pos + 1); + legacy_capture_binding_found = true; + } default_bindings_to_add.erase(output_string); } global_in.close(); + if (legacy_capture_binding_found) { + if (auto it = default_bindings_to_add.find("hotkey_capture_frame"); + it != default_bindings_to_add.end()) { + it->second = legacy_capture_binding; + } + } std::ofstream global_out(config_file, std::ios::app); for (auto const& b : default_bindings_to_add) { global_out << b.first << " = " << b.second << "\n"; @@ -699,6 +712,7 @@ void ControllerOutput::AddUpdate(InputEvent event) { *new_param = (event.active ? event.axis_value : 0) + *new_param; } } + void ControllerOutput::FinalizeUpdate(u8 gamepad_index) { auto PushSDLEvent = [&](u32 event_type) { if (new_button_state) { @@ -763,6 +777,9 @@ void ControllerOutput::FinalizeUpdate(u8 gamepad_index) { case HOTKEY_RENDERDOC: PushSDLEvent(SDL_EVENT_RDOC_CAPTURE); break; + case HOTKEY_SCREENSHOT_WITH_OVERLAYS: + PushSDLEvent(SDL_EVENT_SCREENSHOT_WITH_OVERLAYS); + break; case HOTKEY_ADD_VIRTUAL_USER: PushSDLEvent(SDL_EVENT_ADD_VIRTUAL_USER); break; diff --git a/src/input/input_handler.h b/src/input/input_handler.h index ee286aea9..5e7d200d2 100644 --- a/src/input/input_handler.h +++ b/src/input/input_handler.h @@ -41,6 +41,7 @@ #define SDL_EVENT_ADD_VIRTUAL_USER SDL_EVENT_USER + 11 #define SDL_EVENT_REMOVE_VIRTUAL_USER SDL_EVENT_USER + 12 #define SDL_EVENT_RDOC_CAPTURE SDL_EVENT_USER + 13 +#define SDL_EVENT_SCREENSHOT_WITH_OVERLAYS SDL_EVENT_USER + 14 #define LEFTJOYSTICK_HALFMODE 0x00010000 #define RIGHTJOYSTICK_HALFMODE 0x00020000 @@ -62,6 +63,7 @@ #define HOTKEY_VOLUME_DOWN 0xf000000b #define HOTKEY_ADD_VIRTUAL_USER 0xf000000c #define HOTKEY_REMOVE_VIRTUAL_USER 0xf000000d +#define HOTKEY_SCREENSHOT_WITH_OVERLAYS 0xf000000e #define SDL_UNMAPPED UINT32_MAX - 1 @@ -156,6 +158,8 @@ const std::map string_to_hotkey_map = { {"hotkey_toggle_mouse_to_joystick", HOTKEY_TOGGLE_MOUSE_TO_JOYSTICK}, {"hotkey_toggle_mouse_to_gyro", HOTKEY_TOGGLE_MOUSE_TO_GYRO}, {"hotkey_toggle_mouse_to_touchpad", HOTKEY_TOGGLE_MOUSE_TO_TOUCHPAD}, + {"hotkey_capture_frame", HOTKEY_RENDERDOC}, + {"hotkey_screenshot_with_overlays", HOTKEY_SCREENSHOT_WITH_OVERLAYS}, {"hotkey_renderdoc_capture", HOTKEY_RENDERDOC}, {"hotkey_add_virtual_user", HOTKEY_ADD_VIRTUAL_USER}, {"hotkey_remove_virtual_user", HOTKEY_REMOVE_VIRTUAL_USER}, @@ -526,7 +530,7 @@ public: class ControllerAllOutputs { public: - static constexpr u64 output_count = 40; + static constexpr u64 output_count = 41; std::array data = { // Important: these have to be the first, or else they will update in the wrong order ControllerOutput(LEFTJOYSTICK_HALFMODE), @@ -574,6 +578,7 @@ public: ControllerOutput(HOTKEY_TOGGLE_MOUSE_TO_GYRO), ControllerOutput(HOTKEY_TOGGLE_MOUSE_TO_TOUCHPAD), ControllerOutput(HOTKEY_RENDERDOC), + ControllerOutput(HOTKEY_SCREENSHOT_WITH_OVERLAYS), ControllerOutput(HOTKEY_ADD_VIRTUAL_USER), ControllerOutput(HOTKEY_REMOVE_VIRTUAL_USER), ControllerOutput(HOTKEY_VOLUME_UP), diff --git a/src/sdl_window.cpp b/src/sdl_window.cpp index 060197533..83ced7bd4 100644 --- a/src/sdl_window.cpp +++ b/src/sdl_window.cpp @@ -285,7 +285,14 @@ void WindowSDL::WaitEvent() { } break; case SDL_EVENT_RDOC_CAPTURE: - VideoCore::TriggerCapture(); + if (VideoCore::IsRenderDocLoaded()) { + VideoCore::TriggerCapture(); + } else { + VideoCore::RequestScreenshot(VideoCore::ScreenshotRequest::GameOnly); + } + break; + case SDL_EVENT_SCREENSHOT_WITH_OVERLAYS: + VideoCore::RequestScreenshot(VideoCore::ScreenshotRequest::WithOverlays); break; default: break; diff --git a/src/video_core/renderdoc.cpp b/src/video_core/renderdoc.cpp index b02752212..3f5a5b09f 100644 --- a/src/video_core/renderdoc.cpp +++ b/src/video_core/renderdoc.cpp @@ -5,6 +5,7 @@ #include "core/emulator_settings.h" #include "video_core/renderdoc.h" +#include #include #ifdef _WIN32 @@ -23,6 +24,8 @@ enum class CaptureState { InProgress, }; static CaptureState capture_state{CaptureState::Idle}; +static std::atomic screenshot_game_only_count{0}; +static std::atomic screenshot_with_overlays_count{0}; RENDERDOC_API_1_6_0* rdoc_api{}; @@ -125,4 +128,37 @@ void SetOutputDir(const std::filesystem::path& path, const std::string& prefix) rdoc_api->SetCaptureFilePathTemplate(fmt::UTF((path / prefix).u8string()).data.data()); } +bool IsRenderDocLoaded() { + return rdoc_api != nullptr; +} + +void RequestScreenshot(const ScreenshotRequest request) { + switch (request) { + case ScreenshotRequest::GameOnly: + screenshot_game_only_count.fetch_add(1, std::memory_order_relaxed); + break; + case ScreenshotRequest::WithOverlays: + screenshot_with_overlays_count.fetch_add(1, std::memory_order_relaxed); + break; + case ScreenshotRequest::None: + default: + break; + } +} + +u32 ConsumeGameOnlyScreenshotRequests() { + return screenshot_game_only_count.exchange(0, std::memory_order_acq_rel); +} + +u32 ConsumeWithOverlaysScreenshotRequests() { + return screenshot_with_overlays_count.exchange(0, std::memory_order_acq_rel); +} + +ScreenshotRequests ConsumeScreenshotRequests() { + return ScreenshotRequests{ + .game_only_count = ConsumeGameOnlyScreenshotRequests(), + .with_overlays_count = ConsumeWithOverlaysScreenshotRequests(), + }; +} + } // namespace VideoCore diff --git a/src/video_core/renderdoc.h b/src/video_core/renderdoc.h index 91e242d04..5e87be572 100644 --- a/src/video_core/renderdoc.h +++ b/src/video_core/renderdoc.h @@ -3,7 +3,9 @@ #pragma once +#include #include +#include "common/types.h" namespace VideoCore { @@ -22,4 +24,30 @@ void TriggerCapture(); /// Sets output directory for captures void SetOutputDir(const std::filesystem::path& path, const std::string& prefix); +/// Returns true when RenderDoc API was loaded and is usable. +bool IsRenderDocLoaded(); + +enum class ScreenshotRequest : u32 { + None = 0, + GameOnly = 1, + WithOverlays = 2, +}; + +struct ScreenshotRequests { + u32 game_only_count{}; + u32 with_overlays_count{}; +}; + +/// Queues an in-emulator screenshot request to be consumed by the presenter. +void RequestScreenshot(ScreenshotRequest request); + +/// Atomically consumes and returns pending "game only" screenshot request counter. +u32 ConsumeGameOnlyScreenshotRequests(); + +/// Atomically consumes and returns pending "with overlays" screenshot request counter. +u32 ConsumeWithOverlaysScreenshotRequests(); + +/// Atomically consumes and returns pending screenshot request counters. +ScreenshotRequests ConsumeScreenshotRequests(); + } // namespace VideoCore diff --git a/src/video_core/renderer_vulkan/vk_presenter.cpp b/src/video_core/renderer_vulkan/vk_presenter.cpp index 7d403e7ee..edb736dcd 100644 --- a/src/video_core/renderer_vulkan/vk_presenter.cpp +++ b/src/video_core/renderer_vulkan/vk_presenter.cpp @@ -3,6 +3,8 @@ #include "common/debug.h" #include "common/elf_info.h" +#include "common/io_file.h" +#include "common/path_util.h" #include "common/singleton.h" #include "core/debug_state.h" #include "core/devtools/layer.h" @@ -11,12 +13,32 @@ #include "imgui/renderer/imgui_core.h" #include "imgui/renderer/imgui_impl_vulkan.h" #include "sdl_window.h" +#include "video_core/buffer_cache/buffer.h" +#include "video_core/renderdoc.h" #include "video_core/renderer_vulkan/vk_platform.h" #include "video_core/renderer_vulkan/vk_presenter.h" #include "video_core/renderer_vulkan/vk_rasterizer.h" #include "video_core/texture_cache/image.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include namespace Vulkan { @@ -102,6 +124,353 @@ static vk::Rect2D FitImage(s32 frame_width, s32 frame_height, s32 swapchain_widt dst_rect.offset.x, dst_rect.offset.y); } +enum class ScreenshotKind : u8 { + GameOnly, + WithOverlays, +}; + +struct ScreenshotReadback { + ScreenshotKind kind{}; + std::vector paths{}; + VideoCore::Buffer buffer; + u32 width{}; + u32 height{}; + vk::Format format{}; + bool hdr_encoded{}; + + ScreenshotReadback(const Instance& instance, Scheduler& scheduler, ScreenshotKind kind_, + std::vector paths_, const u32 width_, + const u32 height_, const vk::Format format_, const bool hdr_encoded_) + : kind{kind_}, paths{std::move(paths_)}, + buffer{instance, + scheduler, + VideoCore::MemoryUsage::Download, + 0, + vk::BufferUsageFlagBits::eTransferDst, + static_cast(width_) * static_cast(height_) * 4}, + width{width_}, height{height_}, format{format_}, hdr_encoded{hdr_encoded_} {} +}; + +static std::string SanitizeFilenameComponent(std::string value) { + for (char& c : value) { + const unsigned char uc = static_cast(c); + if (!std::isalnum(uc) && c != '_' && c != '-') { + c = '_'; + } + } + if (value.empty()) { + return "UNKNOWN"; + } + return value; +} + +static std::vector BuildScreenshotPaths(const ScreenshotKind kind, + const u32 count) { + static std::atomic screenshot_sequence{0}; + std::vector paths{}; + if (count == 0) { + return paths; + } + + const auto& screenshots_dir = Common::FS::GetUserPath(Common::FS::PathType::ScreenshotsDir); + std::filesystem::create_directories(screenshots_dir); + + const auto game_id = + SanitizeFilenameComponent(std::string(Common::ElfInfo::Instance().GameSerial())); + const auto now = std::chrono::system_clock::now(); + const auto now_time = std::chrono::system_clock::to_time_t(now); + const auto ms = + std::chrono::duration_cast(now.time_since_epoch()).count() % + 1000; + + std::tm local_tm{}; +#ifdef _WIN32 + localtime_s(&local_tm, &now_time); +#else + localtime_r(&now_time, &local_tm); +#endif + + std::ostringstream stamp; + stamp << std::put_time(&local_tm, "%Y%m%d_%H%M%S") << '_' << std::setw(3) << std::setfill('0') + << ms; + + const char* suffix = kind == ScreenshotKind::GameOnly ? "game" : "hud"; + const auto first_sequence = screenshot_sequence.fetch_add(count, std::memory_order_relaxed); + + paths.reserve(count); + const auto stamp_str = stamp.str(); + for (u32 i = 0; i < count; ++i) { + paths.emplace_back(screenshots_dir / fmt::format("{}_{}_{}_{:06}.png", game_id, stamp_str, + suffix, first_sequence + i)); + } + + return paths; +} + +static float PqToNits(const float encoded) { + // ST.2084 inverse EOTF + constexpr float m1 = 2610.0f / 16384.0f; + constexpr float m2 = 2523.0f / 32.0f; + constexpr float c1 = 3424.0f / 4096.0f; + constexpr float c2 = 2413.0f / 128.0f; + constexpr float c3 = 2392.0f / 128.0f; + + const float v = std::clamp(encoded, 0.0f, 1.0f); + const float vp = std::pow(v, 1.0f / m2); + const float num = std::max(vp - c1, 0.0f); + const float den = std::max(c2 - c3 * vp, 1e-6f); + return 10000.0f * std::pow(num / den, 1.0f / m1); +} + +static float ToneMapToSdrLinear(const float nits) { + // Map absolute HDR luminance into SDR [0,1], preserving 100-nit white. + constexpr float sdr_white_nits = 100.0f; + const float x = std::max(nits, 0.0f) / sdr_white_nits; + const float mapped = (2.0f * x) / (1.0f + x); + return std::clamp(mapped, 0.0f, 1.0f); +} + +static float LinearToSrgb(const float linear) { + const float x = std::clamp(linear, 0.0f, 1.0f); + if (x <= 0.0031308f) { + return 12.92f * x; + } + return 1.055f * std::pow(x, 1.0f / 2.4f) - 0.055f; +} + +static const std::array& GetPqDecodeNitsLut() { + static const std::array lut = [] { + std::array values{}; + for (size_t i = 0; i < values.size(); ++i) { + values[i] = PqToNits(static_cast(i) / 1023.0f); + } + return values; + }(); + return lut; +} + +static const std::array& GetUnorm10ToU8Lut() { + static const std::array lut = [] { + std::array values{}; + for (size_t i = 0; i < values.size(); ++i) { + values[i] = static_cast((i * 255u + 511u) / 1023u); + } + return values; + }(); + return lut; +} + +static void CopyImageToReadback(const vk::CommandBuffer& cmdbuf, const vk::Image image, + const vk::ImageLayout layout, ScreenshotReadback& readback) { + const vk::BufferImageCopy copy_region = { + .bufferOffset = 0, + .bufferRowLength = 0, + .bufferImageHeight = 0, + .imageSubresource = + { + .aspectMask = vk::ImageAspectFlagBits::eColor, + .mipLevel = 0, + .baseArrayLayer = 0, + .layerCount = 1, + }, + .imageOffset = {0, 0, 0}, + .imageExtent = {readback.width, readback.height, 1}, + }; + cmdbuf.copyImageToBuffer(image, layout, readback.buffer.Handle(), copy_region); +} + +static bool ConvertReadbackToRgba8(const ScreenshotReadback& readback, std::vector& out_rgba) { + const u64 pixel_count = static_cast(readback.width) * static_cast(readback.height); + const u64 byte_size = pixel_count * 4; + if (readback.buffer.mapped_data.size() < byte_size) { + LOG_ERROR(Render_Vulkan, "Screenshot readback buffer size mismatch (have {}, need {})", + readback.buffer.mapped_data.size(), byte_size); + return false; + } + + const auto src = + std::span{readback.buffer.mapped_data.data(), static_cast(byte_size)}; + out_rgba.resize(static_cast(byte_size)); + + switch (readback.format) { + case vk::Format::eR8G8B8A8Unorm: + case vk::Format::eR8G8B8A8Srgb: + std::memcpy(out_rgba.data(), src.data(), out_rgba.size()); + for (u64 i = 0; i < pixel_count; ++i) { + out_rgba[static_cast(i) * 4 + 3] = 255; + } + return true; + case vk::Format::eB8G8R8A8Unorm: + case vk::Format::eB8G8R8A8Srgb: + for (u64 i = 0; i < pixel_count; ++i) { + const size_t o = static_cast(i) * 4; + out_rgba[o + 0] = src[o + 2]; + out_rgba[o + 1] = src[o + 1]; + out_rgba[o + 2] = src[o + 0]; + out_rgba[o + 3] = 255; + } + return true; + case vk::Format::eA2R10G10B10UnormPack32: { + const auto& pq_decode_lut = GetPqDecodeNitsLut(); + const auto& unorm10_to_u8 = GetUnorm10ToU8Lut(); + + for (u64 i = 0; i < pixel_count; ++i) { + const size_t o = static_cast(i) * 4; + const u32 packed = static_cast(src[o + 0]) | (static_cast(src[o + 1]) << 8) | + (static_cast(src[o + 2]) << 16) | + (static_cast(src[o + 3]) << 24); + const u32 b = (packed >> 0) & 0x3FF; + const u32 g = (packed >> 10) & 0x3FF; + const u32 r = (packed >> 20) & 0x3FF; + + if (readback.hdr_encoded) { + // Rec.2020 + PQ. Convert to SDR Rec.709 for PNG output. + const float r2020 = pq_decode_lut[r]; + const float g2020 = pq_decode_lut[g]; + const float b2020 = pq_decode_lut[b]; + + const float r709_nits = 1.6605f * r2020 - 0.5876f * g2020 - 0.0728f * b2020; + const float g709_nits = -0.1246f * r2020 + 1.1329f * g2020 - 0.0083f * b2020; + const float b709_nits = -0.0182f * r2020 - 0.1006f * g2020 + 1.1187f * b2020; + + const float r_srgb = LinearToSrgb(ToneMapToSdrLinear(r709_nits)); + const float g_srgb = LinearToSrgb(ToneMapToSdrLinear(g709_nits)); + const float b_srgb = LinearToSrgb(ToneMapToSdrLinear(b709_nits)); + + out_rgba[o + 0] = static_cast(std::clamp(r_srgb, 0.0f, 1.0f) * 255.0f + 0.5f); + out_rgba[o + 1] = static_cast(std::clamp(g_srgb, 0.0f, 1.0f) * 255.0f + 0.5f); + out_rgba[o + 2] = static_cast(std::clamp(b_srgb, 0.0f, 1.0f) * 255.0f + 0.5f); + } else { + out_rgba[o + 0] = unorm10_to_u8[r]; + out_rgba[o + 1] = unorm10_to_u8[g]; + out_rgba[o + 2] = unorm10_to_u8[b]; + } + out_rgba[o + 3] = 255; + } + return true; + } + case vk::Format::eA2B10G10R10UnormPack32: { + const auto& pq_decode_lut = GetPqDecodeNitsLut(); + const auto& unorm10_to_u8 = GetUnorm10ToU8Lut(); + + for (u64 i = 0; i < pixel_count; ++i) { + const size_t o = static_cast(i) * 4; + const u32 packed = static_cast(src[o + 0]) | (static_cast(src[o + 1]) << 8) | + (static_cast(src[o + 2]) << 16) | + (static_cast(src[o + 3]) << 24); + const u32 r = (packed >> 0) & 0x3FF; + const u32 g = (packed >> 10) & 0x3FF; + const u32 b = (packed >> 20) & 0x3FF; + + if (readback.hdr_encoded) { + // HDR swapchain path is Rec.2020 + PQ. Convert to SDR Rec.709 for PNG output. + const float r2020 = pq_decode_lut[r]; + const float g2020 = pq_decode_lut[g]; + const float b2020 = pq_decode_lut[b]; + + const float r709_nits = 1.6605f * r2020 - 0.5876f * g2020 - 0.0728f * b2020; + const float g709_nits = -0.1246f * r2020 + 1.1329f * g2020 - 0.0083f * b2020; + const float b709_nits = -0.0182f * r2020 - 0.1006f * g2020 + 1.1187f * b2020; + + const float r_srgb = LinearToSrgb(ToneMapToSdrLinear(r709_nits)); + const float g_srgb = LinearToSrgb(ToneMapToSdrLinear(g709_nits)); + const float b_srgb = LinearToSrgb(ToneMapToSdrLinear(b709_nits)); + + out_rgba[o + 0] = static_cast(std::clamp(r_srgb, 0.0f, 1.0f) * 255.0f + 0.5f); + out_rgba[o + 1] = static_cast(std::clamp(g_srgb, 0.0f, 1.0f) * 255.0f + 0.5f); + out_rgba[o + 2] = static_cast(std::clamp(b_srgb, 0.0f, 1.0f) * 255.0f + 0.5f); + } else { + out_rgba[o + 0] = unorm10_to_u8[r]; + out_rgba[o + 1] = unorm10_to_u8[g]; + out_rgba[o + 2] = unorm10_to_u8[b]; + } + out_rgba[o + 3] = 255; + } + return true; + } + default: + LOG_WARNING(Render_Vulkan, "Unsupported screenshot format: {}", + vk::to_string(readback.format)); + return false; + } +} + +static bool WritePng(const std::filesystem::path& path, const std::span rgba, + const u32 width, const u32 height) { + Common::FS::IOFile file(path, Common::FS::FileAccessMode::Create); + if (!file.IsOpen()) { + return false; + } + + png_structp png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); + if (png_ptr == nullptr) { + return false; + } + png_infop info_ptr = png_create_info_struct(png_ptr); + if (info_ptr == nullptr) { + png_destroy_write_struct(&png_ptr, nullptr); + return false; + } + + if (setjmp(png_jmpbuf(png_ptr)) != 0) { + png_destroy_write_struct(&png_ptr, &info_ptr); + return false; + } + + png_init_io(png_ptr, file.file); + png_set_IHDR(png_ptr, info_ptr, width, height, 8, PNG_COLOR_TYPE_RGBA, PNG_INTERLACE_NONE, + PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); + png_write_info(png_ptr, info_ptr); + + thread_local std::vector rows; + rows.resize(height); + for (u32 y = 0; y < height; ++y) { + rows[y] = const_cast(rgba.data() + static_cast(y) * width * 4); + } + + png_write_image(png_ptr, rows.data()); + png_write_end(png_ptr, info_ptr); + png_destroy_write_struct(&png_ptr, &info_ptr); + return true; +} + +static void SavePendingScreenshots(const std::vector& readbacks) { + for (const auto& readback : readbacks) { + if (readback.paths.empty()) { + continue; + } + + std::vector rgba; + if (!ConvertReadbackToRgba8(readback, rgba)) { + continue; + } + + const auto& primary_path = readback.paths.front(); + if (!WritePng(primary_path, rgba, readback.width, readback.height)) { + LOG_ERROR(Render_Vulkan, "Failed saving screenshot to {}", primary_path.string()); + continue; + } + + LOG_INFO(Render_Vulkan, "Saved screenshot: {}", primary_path.string()); + + for (size_t i = 1; i < readback.paths.size(); ++i) { + const auto& path = readback.paths[i]; + std::error_code ec{}; + std::filesystem::copy_file(primary_path, path, std::filesystem::copy_options::none, ec); + if (ec) { + // Fallback for platforms/filesystems where copy_file can fail for transient + // reasons. + if (!WritePng(path, rgba, readback.width, readback.height)) { + LOG_ERROR(Render_Vulkan, "Failed saving screenshot to {}", path.string()); + continue; + } + } + + LOG_INFO(Render_Vulkan, "Saved screenshot: {}", path.string()); + } + } +} + Presenter::Presenter(Frontend::WindowSDL& window_, AmdGpu::Liverpool* liverpool_) : window{window_}, liverpool{liverpool_}, instance{window, EmulatorSettings.GetGpuId(), EmulatorSettings.IsVkValidationEnabled(), @@ -338,11 +707,32 @@ Frame* Presenter::PrepareFrame(const Libraries::VideoOut::BufferAttributeGroup& auto& image = texture_cache.GetImage(image_id); auto image_view = *image.FindView(view_info).image_view; - image.Transit(vk::ImageLayout::eShaderReadOnlyOptimal, vk::AccessFlagBits2::eShaderRead, {}); - const vk::Extent2D image_size = {image.info.size.width, image.info.size.height}; expected_ratio = static_cast(image_size.width) / static_cast(image_size.height); + const u32 capture_game_only_count = VideoCore::ConsumeGameOnlyScreenshotRequests(); + std::vector pending_screenshots; + if (capture_game_only_count > 0) { + pending_screenshots.reserve(1); + const bool hdr_encoded = + attribute.attrib.pixel_format == Libraries::VideoOut::PixelFormat::A2R10G10B10Bt2020Pq; + pending_screenshots.emplace_back( + instance, draw_scheduler, ScreenshotKind::GameOnly, + BuildScreenshotPaths(ScreenshotKind::GameOnly, capture_game_only_count), + image_size.width, image_size.height, view_info.format, hdr_encoded); + auto& readback = pending_screenshots.back(); + + // Capture the guest output before any host-side scaling (FSR/PP) is applied. + image.Transit(vk::ImageLayout::eTransferSrcOptimal, vk::AccessFlagBits2::eTransferRead, {}, + cmdbuf); + CopyImageToReadback(cmdbuf, image.GetImage(), vk::ImageLayout::eTransferSrcOptimal, + readback); + } + + // Continue with host-side passes that draw the displayed (scaled) frame. + image.Transit(vk::ImageLayout::eShaderReadOnlyOptimal, vk::AccessFlagBits2::eShaderRead, {}, + cmdbuf); + image_view = fsr_pass.Render(cmdbuf, image_view, image_size, {frame->width, frame->height}, fsr_settings, frame->is_hdr); pp_pass.Render(cmdbuf, image_view, image_size, *frame, pp_settings); @@ -350,6 +740,14 @@ Frame* Presenter::PrepareFrame(const Libraries::VideoOut::BufferAttributeGroup& DebugState.game_resolution = {image_size.width, image_size.height}; DebugState.output_resolution = {frame->width, frame->height}; + std::shared_ptr> deferred_screenshots{}; + if (!pending_screenshots.empty()) { + deferred_screenshots = + std::make_shared>(std::move(pending_screenshots)); + draw_scheduler.DeferPriorityOperation( + [deferred_screenshots]() { SavePendingScreenshots(*deferred_screenshots); }); + } + // Flush frame creation commands. frame->ready_semaphore = draw_scheduler.GetMasterSemaphore()->Handle(); frame->ready_tick = draw_scheduler.CurrentTick(); @@ -471,6 +869,11 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame) { auto& scheduler = present_scheduler; const auto cmdbuf = scheduler.CommandBuffer(); + const u32 capture_with_overlays_count = VideoCore::ConsumeWithOverlaysScreenshotRequests(); + std::vector pending_screenshots; + if (capture_with_overlays_count > 0) { + pending_screenshots.reserve(1); + } if (EmulatorSettings.IsVkHostMarkersEnabled()) { cmdbuf.beginDebugUtilsLabelEXT(vk::DebugUtilsLabelEXT{ @@ -519,22 +922,7 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame) { }, }; - const vk::ImageMemoryBarrier post_barrier{ - .srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite, - .dstAccessMask = vk::AccessFlagBits::eMemoryRead, - .oldLayout = vk::ImageLayout::eColorAttachmentOptimal, - .newLayout = vk::ImageLayout::ePresentSrcKHR, - .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, - .image = swapchain_image, - .subresourceRange{ - .aspectMask = vk::ImageAspectFlagBits::eColor, - .baseMipLevel = 0, - .levelCount = 1, - .baseArrayLayer = 0, - .layerCount = VK_REMAINING_ARRAY_LAYERS, - }, - }; + bool swapchain_copied_for_screenshot = false; cmdbuf.pipelineBarrier(vk::PipelineStageFlagBits::eColorAttachmentOutput, vk::PipelineStageFlagBits::eColorAttachmentOutput, @@ -594,6 +982,63 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame) { } ImGui::Core::Render(cmdbuf, swapchain_image_view, swapchain.GetExtent()); + if (capture_with_overlays_count > 0) { + pending_screenshots.emplace_back( + instance, scheduler, ScreenshotKind::WithOverlays, + BuildScreenshotPaths(ScreenshotKind::WithOverlays, capture_with_overlays_count), + extent.width, extent.height, + swapchain.GetHDR() ? vk::Format::eA2B10G10R10UnormPack32 + : swapchain.GetSurfaceFormat().format, + swapchain.GetHDR()); + auto& readback = pending_screenshots.back(); + + const vk::ImageMemoryBarrier to_transfer{ + .srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite, + .dstAccessMask = vk::AccessFlagBits::eTransferRead, + .oldLayout = vk::ImageLayout::eColorAttachmentOptimal, + .newLayout = vk::ImageLayout::eTransferSrcOptimal, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = swapchain_image, + .subresourceRange{ + .aspectMask = vk::ImageAspectFlagBits::eColor, + .baseMipLevel = 0, + .levelCount = 1, + .baseArrayLayer = 0, + .layerCount = VK_REMAINING_ARRAY_LAYERS, + }, + }; + + cmdbuf.pipelineBarrier(vk::PipelineStageFlagBits::eColorAttachmentOutput, + vk::PipelineStageFlagBits::eTransfer, + vk::DependencyFlagBits::eByRegion, {}, {}, to_transfer); + CopyImageToReadback(cmdbuf, swapchain_image, vk::ImageLayout::eTransferSrcOptimal, + readback); + swapchain_copied_for_screenshot = true; + } + + const vk::AccessFlags post_src_access_mask = + swapchain_copied_for_screenshot ? vk::AccessFlagBits::eTransferRead + : vk::AccessFlagBits::eColorAttachmentWrite; + const vk::ImageLayout post_old_layout = swapchain_copied_for_screenshot + ? vk::ImageLayout::eTransferSrcOptimal + : vk::ImageLayout::eColorAttachmentOptimal; + const vk::ImageMemoryBarrier post_barrier{ + .srcAccessMask = post_src_access_mask, + .dstAccessMask = vk::AccessFlagBits::eMemoryRead, + .oldLayout = post_old_layout, + .newLayout = vk::ImageLayout::ePresentSrcKHR, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = swapchain_image, + .subresourceRange{ + .aspectMask = vk::ImageAspectFlagBits::eColor, + .baseMipLevel = 0, + .levelCount = 1, + .baseArrayLayer = 0, + .layerCount = VK_REMAINING_ARRAY_LAYERS, + }, + }; cmdbuf.pipelineBarrier(vk::PipelineStageFlagBits::eAllCommands, vk::PipelineStageFlagBits::eAllCommands, vk::DependencyFlagBits::eByRegion, {}, {}, post_barrier); @@ -607,6 +1052,14 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame) { } // Flush vulkan commands. + std::shared_ptr> deferred_screenshots{}; + if (!pending_screenshots.empty()) { + deferred_screenshots = + std::make_shared>(std::move(pending_screenshots)); + scheduler.DeferPriorityOperation( + [deferred_screenshots]() { SavePendingScreenshots(*deferred_screenshots); }); + } + SubmitInfo info{}; info.AddWait(swapchain.GetImageAcquiredSemaphore()); info.AddWait(frame->ready_semaphore, frame->ready_tick); @@ -615,9 +1068,11 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame) { scheduler.Flush(info); // Present to swapchain. - std::scoped_lock submit_lock{Scheduler::submit_mutex}; - if (!swapchain.Present()) { - swapchain.Recreate(window.GetWidth(), window.GetHeight()); + { + std::scoped_lock submit_lock{Scheduler::submit_mutex}; + if (!swapchain.Present()) { + swapchain.Recreate(window.GetWidth(), window.GetHeight()); + } } free_frame();