// Copyright 2023 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include "VideoCommon/Present.h" #include "Common/ChunkFile.h" #include "Core/Config/GraphicsSettings.h" #include "Core/Config/MainSettings.h" #include "Core/CoreTiming.h" #include "Core/HW/VideoInterface.h" #include "Core/Host.h" #include "Core/System.h" #include "InputCommon/ControllerInterface/ControllerInterface.h" #include "Present.h" #include "VideoCommon/AbstractGfx.h" #include "VideoCommon/FrameDumper.h" #include "VideoCommon/FramebufferManager.h" #include "VideoCommon/GeometryShaderManager.h" #include "VideoCommon/OnScreenUI.h" #include "VideoCommon/PostProcessing.h" #include "VideoCommon/VertexManagerBase.h" #include "VideoCommon/VideoConfig.h" #include "VideoCommon/VideoEvents.h" #include "VideoCommon/Widescreen.h" #ifdef ENABLE_VR #include #include "Common/Logging/Log.h" #include "Common/Timer.h" #include "VideoCommon/VR/OpenXRManager.h" #endif std::unique_ptr g_presenter; namespace VideoCommon { // Stretches the native/internal analog resolution aspect ratio from ~4:3 to ~16:9 static float SourceAspectRatioToWidescreen(float source_aspect) { return source_aspect * ((16.0f / 9.0f) / (4.0f / 3.0f)); } static std::tuple FindClosestIntegerResolution(float width, float height, float aspect_ratio) { // We can't round both the x and y resolution as that might generate an aspect ratio // further away from the target one, we also can't either ceil or floor both sides, // so we find the combination or flooring and ceiling that is closest to the target ar. const int ceiled_width = static_cast(std::ceil(width)); const int ceiled_height = static_cast(std::ceil(height)); const int floored_width = static_cast(std::floor(width)); const int floored_height = static_cast(std::floor(height)); int int_width = floored_width; int int_height = floored_height; float min_aspect_ratio_distance = std::numeric_limits::max(); for (const int new_width : std::array{ceiled_width, floored_width}) { for (const int new_height : std::array{ceiled_height, floored_height}) { const float new_aspect_ratio = static_cast(new_width) / new_height; const float aspect_ratio_distance = std::abs((new_aspect_ratio / aspect_ratio) - 1.f); if (aspect_ratio_distance < min_aspect_ratio_distance) { min_aspect_ratio_distance = aspect_ratio_distance; int_width = new_width; int_height = new_height; } } } return std::make_tuple(int_width, int_height); } static void TryToSnapToXFBSize(int& width, int& height, int xfb_width, int xfb_height) { // Screen is blanking (e.g. game booting up), nothing to do here if (xfb_width == 0 || xfb_height == 0) return; // If there's only 1 pixel of either horizontal or vertical resolution difference, // make the output size match a multiple of the XFB native resolution, // to achieve the highest quality (least scaling). // The reason why the threshold is 1 pixel (per internal resolution multiplier) is because of // minor inaccuracies of the VI aspect ratio (and because some resolutions are rounded // while other are floored). const unsigned int efb_scale = g_framebuffer_manager->GetEFBScale(); const unsigned int pixel_difference_width = std::abs(width - xfb_width); const unsigned int pixel_difference_height = std::abs(height - xfb_height); // We ignore this if there's an offset on both hor and ver size, // as then we'd be changing the aspect ratio too much and would need to // re-calculate a lot of stuff (like black bars). if ((pixel_difference_width <= efb_scale && pixel_difference_height == 0) || (pixel_difference_height <= efb_scale && pixel_difference_width == 0)) { width = xfb_width; height = xfb_height; } } Presenter::Presenter() { auto& video_events = GetVideoEvents(); m_config_changed = video_events.config_changed_event.Register([this](u32 bits) { ConfigChanged(bits); }); m_end_field_hook = video_events.vi_end_field_event.Register( [this] { m_immediate_swap_happened_this_field.store(false, std::memory_order_relaxed); }); } Presenter::~Presenter() { // Disable ControllerInterface's aspect ratio adjustments so mapping dialog behaves normally. g_controller_interface.SetAspectRatioAdjustment(1); } bool Presenter::Initialize() { UpdateDrawRectangle(); m_immediate_swap_happened_this_field.store(false, std::memory_order_relaxed); #ifdef ENABLE_VR // The Android GLES OpenXR path uses a headless (pbuffer) GL context — Meta's runtime // never starts sessions for GLES contexts bound to a window surface — but the presenter // must still fully initialize (post processor for the eye blits) and present each frame // (Present() drives the XR frame lifecycle). const bool xr_headless_present = g_ActiveConfig.VRSessionActive() && g_backend_info.api_type == APIType::OpenGL; #else constexpr bool xr_headless_present = false; #endif if (!g_gfx->IsHeadless() || xr_headless_present) { SetBackbuffer(g_gfx->GetSurfaceInfo()); m_post_processor = std::make_unique(); if (!m_post_processor->Initialize(m_backbuffer_format)) return false; m_onscreen_ui = std::make_unique(); if (!m_onscreen_ui->Initialize(m_backbuffer_width, m_backbuffer_height, m_backbuffer_scale)) return false; // Draw a blank frame (and complete OnScreenUI initialization) g_gfx->BindBackbuffer({{0.0f, 0.0f, 0.0f, 1.0f}}); g_gfx->PresentBackbuffer(); } return true; } bool Presenter::FetchXFB(u32 xfb_addr, u32 fb_width, u32 fb_stride, u32 fb_height, u64 ticks) { ReleaseXFBContentLock(); u64 old_xfb_id = m_last_xfb_id; if (fb_width == 0 || fb_height == 0) { // Game is blanking the screen m_xfb_entry.reset(); m_xfb_rect = MathUtil::Rectangle(); m_last_xfb_id = std::numeric_limits::max(); } else { m_xfb_entry = g_texture_cache->GetXFBTexture(xfb_addr, fb_width, fb_height, fb_stride, &m_xfb_rect); m_last_xfb_id = m_xfb_entry->id; m_xfb_entry->AcquireContentLock(); #ifdef ENABLE_VR // Pair the frame about to be presented with the pose it was rendered with (stamped // at its XFB copy). With ImmediateXFB off this present runs mid-way through the // NEXT frame's draw stream, where the live submit snapshot may already hold the // next frame's pose — submitting that misplaces the content under ATW. if (g_ActiveConfig.stereo_mode == StereoMode::OpenXR && VR::g_openxr) VR::g_openxr->SelectPresentPoseForXFB(xfb_addr); #endif } m_last_xfb_addr = xfb_addr; m_last_xfb_ticks = ticks; m_last_xfb_width = fb_width; m_last_xfb_stride = fb_stride; m_last_xfb_height = fb_height; return old_xfb_id == m_last_xfb_id; } void Presenter::ViSwap(u32 xfb_addr, u32 fb_width, u32 fb_stride, u32 fb_height, u64 ticks, TimePoint presentation_time) { bool is_duplicate = FetchXFB(xfb_addr, fb_width, fb_stride, fb_height, ticks); PresentInfo present_info{ .present_count = m_present_count++, .emulated_timestamp = ticks, .intended_present_time = presentation_time, }; if (is_duplicate) { present_info.frame_count = m_frame_count - 1; // Previous frame present_info.reason = PresentInfo::PresentReason::VideoInterfaceDuplicate; } else { present_info.frame_count = m_frame_count++; present_info.reason = PresentInfo::PresentReason::VideoInterface; } if (m_xfb_entry) { // With no references, this XFB copy wasn't stitched together // so just use its name directly if (m_xfb_entry->references.empty()) { if (!m_xfb_entry->texture_info_name.empty()) present_info.xfb_copy_hashes.push_back(m_xfb_entry->texture_info_name); } else { for (const auto& reference : m_xfb_entry->references) { if (!reference->texture_info_name.empty()) present_info.xfb_copy_hashes.push_back(reference->texture_info_name); } } } auto& video_events = GetVideoEvents(); video_events.before_present_event.Trigger(present_info); if (!is_duplicate || !g_ActiveConfig.bSkipPresentingDuplicateXFBs) { Present(&present_info); ProcessFrameDumping(ticks); video_events.after_present_event.Trigger(present_info); } } void Presenter::ImmediateSwap(u32 xfb_addr, u32 fb_width, u32 fb_stride, u32 fb_height) { if (m_immediate_swap_happened_this_field.exchange(true, std::memory_order_relaxed) && Config::Get(Config::GFX_HACK_CAP_IMMEDIATE_XFB)) { return; } const u64 ticks = m_next_swap_estimated_ticks; FetchXFB(xfb_addr, fb_width, fb_stride, fb_height, ticks); // Normal path: full events and frame counting PresentInfo present_info{ .frame_count = m_frame_count++, .present_count = m_present_count++, .reason = PresentInfo::PresentReason::Immediate, .emulated_timestamp = ticks, .intended_present_time = m_next_swap_estimated_time, }; auto& video_events = GetVideoEvents(); video_events.before_present_event.Trigger(present_info); Present(&present_info); ProcessFrameDumping(ticks); video_events.after_present_event.Trigger(present_info); } void Presenter::SetNextSwapEstimatedTime(u64 ticks, TimePoint host_time) { m_next_swap_estimated_ticks = ticks; m_next_swap_estimated_time = host_time; } void Presenter::ProcessFrameDumping(u64 ticks) const { if (g_frame_dumper->IsFrameDumping() && m_xfb_entry) { MathUtil::Rectangle target_rect; switch (Config::Get(Config::GFX_FRAME_DUMPS_RESOLUTION_TYPE)) { default: case FrameDumpResolutionType::WindowResolution: { if (!g_gfx->IsHeadless()) { target_rect = GetTargetRectangle(); break; } [[fallthrough]]; } case FrameDumpResolutionType::XFBAspectRatioCorrectedResolution: { target_rect = m_xfb_rect; const bool allow_stretch = false; auto [float_width, float_height] = ScaleToDisplayAspectRatio(m_xfb_rect.GetWidth(), m_xfb_rect.GetHeight(), allow_stretch); const float draw_aspect_ratio = CalculateDrawAspectRatio(allow_stretch); auto [int_width, int_height] = FindClosestIntegerResolution(float_width, float_height, draw_aspect_ratio); target_rect = MathUtil::Rectangle(0, 0, int_width, int_height); break; } case FrameDumpResolutionType::XFBRawResolution: { target_rect = m_xfb_rect; break; } } int width = target_rect.GetWidth(); int height = target_rect.GetHeight(); const int resolution_lcm = g_frame_dumper->GetRequiredResolutionLeastCommonMultiple(); // Ensure divisibility by the dumper LCM and a min of 1 to make it compatible with all the // video encoders. Note that this is theoretically only necessary when recording videos and not // screenshots. // We always scale positively to make sure the least amount of information is lost. // // TODO: this should be added as black padding on the edges by the frame dumper. if ((width % resolution_lcm) != 0 || width == 0) width += resolution_lcm - (width % resolution_lcm); if ((height % resolution_lcm) != 0 || height == 0) height += resolution_lcm - (height % resolution_lcm); // Remove any black borders, there would be no point in including them in the recording target_rect.left = 0; target_rect.top = 0; target_rect.right = width; target_rect.bottom = height; // TODO: any scaling done by this won't be gamma corrected, // we should either apply post processing as well, or port its gamma correction code g_frame_dumper->DumpCurrentFrame(m_xfb_entry->texture.get(), m_xfb_rect, target_rect, ticks, m_frame_count); } } void Presenter::SetBackbuffer(int backbuffer_width, int backbuffer_height) { const bool is_first = m_backbuffer_width == 0 && m_backbuffer_height == 0; const bool size_changed = (m_backbuffer_width != backbuffer_width || m_backbuffer_height != backbuffer_height); m_backbuffer_width = backbuffer_width; m_backbuffer_height = backbuffer_height; UpdateDrawRectangle(); OnBackbufferSet(size_changed, is_first); } void Presenter::SetBackbuffer(SurfaceInfo info) { const bool is_first = m_backbuffer_width == 0 && m_backbuffer_height == 0; const bool size_changed = (m_backbuffer_width != (int)info.width || m_backbuffer_height != (int)info.height); m_backbuffer_width = info.width; m_backbuffer_height = info.height; m_backbuffer_scale = info.scale; m_backbuffer_format = info.format; if (m_onscreen_ui) m_onscreen_ui->SetScale(info.scale); OnBackbufferSet(size_changed, is_first); } void Presenter::OnBackbufferSet(bool size_changed, bool is_first_set) { UpdateDrawRectangle(); // Automatically update the resolution scale if the window size changed, // or if the game XFB resolution changed. if (size_changed && !is_first_set && g_ActiveConfig.iEFBScale == EFB_SCALE_AUTO_INTEGRAL && m_auto_resolution_scale != AutoIntegralScale()) { g_framebuffer_manager->RecreateEFBFramebuffer(g_ActiveConfig.iEFBScale); } if (size_changed || is_first_set) { m_auto_resolution_scale = AutoIntegralScale(); } } void Presenter::ConfigChanged(u32 changed_bits) { // Check for post-processing shader changes. Done up here as it doesn't affect anything outside // the post-processor. Note that options are applied every frame, so no need to check those. if (changed_bits & ConfigChangeBits::CONFIG_CHANGE_BIT_POST_PROCESSING_SHADER && m_post_processor) { // The existing shader must not be in use when it's destroyed g_gfx->WaitForGPUIdle(); m_post_processor->RecompileShader(); } // Stereo mode change requires recompiling our post processing pipeline and imgui pipelines for // rendering the UI. if (changed_bits & ConfigChangeBits::CONFIG_CHANGE_BIT_STEREO_MODE) { if (m_onscreen_ui) m_onscreen_ui->RecompileImGuiPipeline(); if (m_post_processor) m_post_processor->RecompilePipeline(); } } std::tuple, MathUtil::Rectangle> Presenter::ConvertStereoRectangle(const MathUtil::Rectangle& rc) const { // Resize target to half its original size auto draw_rc = rc; if (g_ActiveConfig.stereo_mode == StereoMode::TAB) { // The height may be negative due to flipped rectangles int height = rc.bottom - rc.top; draw_rc.top += height / 4; draw_rc.bottom -= height / 4; } else { int width = rc.right - rc.left; draw_rc.left += width / 4; draw_rc.right -= width / 4; } // Create two target rectangle offset to the sides of the backbuffer auto left_rc = draw_rc; auto right_rc = draw_rc; if (g_ActiveConfig.stereo_mode == StereoMode::TAB) { left_rc.top -= m_backbuffer_height / 4; left_rc.bottom -= m_backbuffer_height / 4; right_rc.top += m_backbuffer_height / 4; right_rc.bottom += m_backbuffer_height / 4; } else { left_rc.left -= m_backbuffer_width / 4; left_rc.right -= m_backbuffer_width / 4; right_rc.left += m_backbuffer_width / 4; right_rc.right += m_backbuffer_width / 4; } return std::make_tuple(left_rc, right_rc); } float Presenter::CalculateDrawAspectRatio(bool allow_stretch) const { auto aspect_mode = g_ActiveConfig.aspect_mode; float resulting_aspect_ratio; if (!allow_stretch && aspect_mode == AspectMode::Stretch) aspect_mode = AspectMode::Auto; // If stretch is enabled, we prefer the aspect ratio of the window. if (aspect_mode == AspectMode::Stretch) { resulting_aspect_ratio = (static_cast(m_backbuffer_width) / static_cast(m_backbuffer_height)); } else { // The actual aspect ratio of the XFB texture is irrelevant, the VI one is the one that matters const auto& vi = Core::System::GetInstance().GetVideoInterface(); const float source_aspect_ratio = vi.GetAspectRatio(); // This will scale up the source ~4:3 resolution to its equivalent ~16:9 resolution if (aspect_mode == AspectMode::ForceWide || (aspect_mode == AspectMode::Auto && g_widescreen->IsGameWidescreen())) { resulting_aspect_ratio = SourceAspectRatioToWidescreen(source_aspect_ratio); } else if (aspect_mode == AspectMode::Custom) { resulting_aspect_ratio = source_aspect_ratio * (g_ActiveConfig.GetCustomAspectRatio() / (4.0f / 3.0f)); } // For the "custom stretch" mode, we force the exact target aspect ratio, without // acknowledging the difference between the source aspect ratio and 4:3. else if (aspect_mode == AspectMode::CustomStretch) { resulting_aspect_ratio = g_ActiveConfig.GetCustomAspectRatio(); } else if (aspect_mode == AspectMode::Raw) { resulting_aspect_ratio = m_xfb_entry ? (static_cast(m_last_xfb_width) / m_last_xfb_height) : 1.f; } else { resulting_aspect_ratio = source_aspect_ratio; } } if (g_ActiveConfig.stereo_per_eye_resolution_full) { if (g_ActiveConfig.stereo_mode == StereoMode::SBS) { // Render twice as wide if using side-by-side 3D, since the 3D will halve the horizontal // resolution resulting_aspect_ratio *= 2.0; } else if (g_ActiveConfig.stereo_mode == StereoMode::TAB) { // Render twice as tall if using top-and-bottom 3D, since the 3D will halve the vertical // resolution resulting_aspect_ratio /= 2.0; } } return resulting_aspect_ratio; } void Presenter::AdjustRectanglesToFitBounds(MathUtil::Rectangle* target_rect, MathUtil::Rectangle* source_rect, int fb_width, int fb_height) { const int orig_target_width = target_rect->GetWidth(); const int orig_target_height = target_rect->GetHeight(); const int orig_source_width = source_rect->GetWidth(); const int orig_source_height = source_rect->GetHeight(); if (target_rect->left < 0) { const int offset = -target_rect->left; target_rect->left = 0; source_rect->left += offset * orig_source_width / orig_target_width; } if (target_rect->right > fb_width) { const int offset = target_rect->right - fb_width; target_rect->right -= offset; source_rect->right -= offset * orig_source_width / orig_target_width; } if (target_rect->top < 0) { const int offset = -target_rect->top; target_rect->top = 0; source_rect->top += offset * orig_source_height / orig_target_height; } if (target_rect->bottom > fb_height) { const int offset = target_rect->bottom - fb_height; target_rect->bottom -= offset; source_rect->bottom -= offset * orig_source_height / orig_target_height; } } void Presenter::ReleaseXFBContentLock() { if (m_xfb_entry) m_xfb_entry->ReleaseContentLock(); } void Presenter::ChangeSurface(void* new_surface_handle) { std::lock_guard lock(m_swap_mutex); m_new_surface_handle = new_surface_handle; m_surface_changed.Set(); } void Presenter::ResizeSurface() { std::lock_guard lock(m_swap_mutex); m_surface_resized.Set(); } void* Presenter::GetNewSurfaceHandle() { void* handle = m_new_surface_handle; m_new_surface_handle = nullptr; return handle; } u32 Presenter::AutoIntegralScale() const { // Take the source/native resolution (XFB) and stretch it on the target (window) aspect ratio. // If the target resolution is larger (on either x or y), we scale the source // by a integer multiplier until it won't have to be scaled up anymore. // NOTE: this might conflict with "Config::MAIN_RENDER_WINDOW_AUTOSIZE", // as they mutually influence each other. u32 source_width = m_last_xfb_width; u32 source_height = m_last_xfb_height; const u32 target_width = m_target_rectangle.GetWidth(); const u32 target_height = m_target_rectangle.GetHeight(); const float source_aspect_ratio = (float)source_width / source_height; const float target_aspect_ratio = (float)target_width / target_height; if (source_aspect_ratio >= target_aspect_ratio) source_width = std::round(source_height * target_aspect_ratio); else source_height = std::round(source_width / target_aspect_ratio); const u32 width_scale = source_width > 0 ? ((target_width + (source_width - 1)) / source_width) : 1; const u32 height_scale = source_height > 0 ? ((target_height + (source_height - 1)) / source_height) : 1; // Limit to the max to avoid creating textures larger than their max supported resolution. return std::min(std::max(width_scale, height_scale), static_cast(Config::Get(Config::GFX_MAX_EFB_SCALE))); } void Presenter::SetSuggestedWindowSize(int width, int height) { // While trying to guess the best window resolution, we can't allow it to use the // "AspectMode::Stretch" setting because that would self influence the output result, // given it would be based on the previous frame resolution const bool allow_stretch = false; const auto [out_width, out_height] = CalculateOutputDimensions(width, height, allow_stretch); // Track the last values of width/height to avoid sending a window resize event every frame. if (out_width == m_last_window_request_width && out_height == m_last_window_request_height) return; m_last_window_request_width = out_width; m_last_window_request_height = out_height; // Pass in the suggested window size. This might not always be acknowledged. Host_RequestRenderWindowSize(out_width, out_height); } // Crop to exact forced aspect ratios if enabled and not AspectMode::Stretch. std::tuple Presenter::ApplyStandardAspectCrop(float width, float height, bool allow_stretch) const { auto aspect_mode = g_ActiveConfig.aspect_mode; if (!allow_stretch && aspect_mode == AspectMode::Stretch) aspect_mode = AspectMode::Auto; if (!g_ActiveConfig.bCrop || aspect_mode == AspectMode::Stretch || aspect_mode == AspectMode::Raw) return {width, height}; // Force aspect ratios by cropping the image. const float current_aspect = width / height; float expected_aspect; switch (aspect_mode) { default: case AspectMode::Auto: expected_aspect = g_widescreen->IsGameWidescreen() ? (16.0f / 9.0f) : (4.0f / 3.0f); break; case AspectMode::ForceWide: expected_aspect = 16.0f / 9.0f; break; case AspectMode::ForceStandard: expected_aspect = 4.0f / 3.0f; break; // For the custom (relative) case, we want to crop from the native aspect ratio // to the specific target one, as they likely have a small difference case AspectMode::Custom: // There should be no cropping needed in the custom stretch case, // as output should always exactly match the target aspect ratio case AspectMode::CustomStretch: expected_aspect = g_ActiveConfig.GetCustomAspectRatio(); break; } if (current_aspect > expected_aspect) { // keep height, crop width width = height * expected_aspect; } else { // keep width, crop height height = width / expected_aspect; } return {width, height}; } void Presenter::UpdateDrawRectangle() { const float draw_aspect_ratio = CalculateDrawAspectRatio(); // Update aspect ratio hack values // Won't take effect until next frame // Don't know if there is a better place for this code so there isn't a 1 frame delay if (g_ActiveConfig.bWidescreenHack) { const auto& vi = Core::System::GetInstance().GetVideoInterface(); float source_aspect_ratio = vi.GetAspectRatio(); // If the game is meant to be in widescreen (or forced to), // scale the source aspect ratio to it. if (g_widescreen->IsGameWidescreen()) source_aspect_ratio = SourceAspectRatioToWidescreen(source_aspect_ratio); const float adjust = source_aspect_ratio / draw_aspect_ratio; if (adjust > 1) { // Vert+ g_Config.fAspectRatioHackW = 1; g_Config.fAspectRatioHackH = 1 / adjust; } else { // Hor+ g_Config.fAspectRatioHackW = adjust; g_Config.fAspectRatioHackH = 1; } } else { // Hack is disabled. g_Config.fAspectRatioHackW = 1; g_Config.fAspectRatioHackH = 1; } // The rendering window size const float win_width = static_cast(m_backbuffer_width); const float win_height = static_cast(m_backbuffer_height); const float win_aspect_ratio = win_width / win_height; // FIXME: this breaks at very low widget sizes // Make ControllerInterface aware of the render window region actually being used // to adjust mouse cursor inputs. // This also fails to acknowledge "g_ActiveConfig.bCrop". g_controller_interface.SetAspectRatioAdjustment(draw_aspect_ratio / win_aspect_ratio); float draw_width = draw_aspect_ratio; float draw_height = 1; // Crop the picture to a standard aspect ratio. (if enabled) auto [crop_width, crop_height] = ApplyStandardAspectCrop(draw_width, draw_height); const float crop_aspect_ratio = crop_width / crop_height; // scale the picture to fit the rendering window if (win_aspect_ratio >= crop_aspect_ratio) { // the window is flatter than the picture draw_width *= win_height / crop_height; crop_width *= win_height / crop_height; draw_height *= win_height / crop_height; crop_height = win_height; } else { // the window is skinnier than the picture draw_width *= win_width / crop_width; draw_height *= win_width / crop_width; crop_height *= win_width / crop_width; crop_width = win_width; } int int_draw_width; int int_draw_height; if (g_ActiveConfig.aspect_mode != AspectMode::Raw || !m_xfb_entry) { // Find the best integer resolution: the closest aspect ratio with the least black bars. // This should have no influence if "AspectMode::Stretch" is active. const float updated_draw_aspect_ratio = draw_width / draw_height; const auto int_draw_res = FindClosestIntegerResolution(draw_width, draw_height, updated_draw_aspect_ratio); int_draw_width = std::get<0>(int_draw_res); int_draw_height = std::get<1>(int_draw_res); if (!g_ActiveConfig.bCrop) { if (g_ActiveConfig.aspect_mode != AspectMode::Stretch) { TryToSnapToXFBSize(int_draw_width, int_draw_height, m_xfb_rect.GetWidth(), m_xfb_rect.GetHeight()); } // We can't draw something bigger than the window, it will crop int_draw_width = std::min(int_draw_width, static_cast(win_width)); int_draw_height = std::min(int_draw_height, static_cast(win_height)); } } else { int_draw_width = m_xfb_rect.GetWidth(); int_draw_height = m_xfb_rect.GetHeight(); } m_target_rectangle.left = static_cast(std::round(win_width / 2.0 - int_draw_width / 2.0)); m_target_rectangle.top = static_cast(std::round(win_height / 2.0 - int_draw_height / 2.0)); m_target_rectangle.right = m_target_rectangle.left + int_draw_width; m_target_rectangle.bottom = m_target_rectangle.top + int_draw_height; } std::tuple Presenter::ScaleToDisplayAspectRatio(const int width, const int height, bool allow_stretch) const { // Scale either the width or height depending the content aspect ratio. // This way we preserve as much resolution as possible when scaling. float scaled_width = static_cast(width); float scaled_height = static_cast(height); const float draw_aspect = CalculateDrawAspectRatio(allow_stretch); if (scaled_width / scaled_height >= draw_aspect) scaled_height = scaled_width / draw_aspect; else scaled_width = scaled_height * draw_aspect; return std::make_tuple(scaled_width, scaled_height); } std::tuple Presenter::CalculateOutputDimensions(int width, int height, bool allow_stretch) const { // Protect against zero width and height, a minimum of 1 will do width = std::max(width, 1); height = std::max(height, 1); auto [scaled_width, scaled_height] = ScaleToDisplayAspectRatio(width, height, allow_stretch); // Apply crop if enabled. std::tie(scaled_width, scaled_height) = ApplyStandardAspectCrop(scaled_width, scaled_height, allow_stretch); auto aspect_mode = g_ActiveConfig.aspect_mode; if (!allow_stretch && aspect_mode == AspectMode::Stretch) aspect_mode = AspectMode::Auto; if (!g_ActiveConfig.bCrop && aspect_mode != AspectMode::Stretch) { // Find the closest integer resolution for the aspect ratio, // this avoids a small black line from being drawn on one of the four edges const float draw_aspect_ratio = CalculateDrawAspectRatio(allow_stretch); auto [int_width, int_height] = FindClosestIntegerResolution(scaled_width, scaled_height, draw_aspect_ratio); if (aspect_mode != AspectMode::Raw) { TryToSnapToXFBSize(int_width, int_height, m_xfb_rect.GetWidth(), m_xfb_rect.GetHeight()); } width = int_width; height = int_height; } else { width = static_cast(std::ceil(scaled_width)); height = static_cast(std::ceil(scaled_height)); } return std::make_tuple(width, height); } #ifdef ENABLE_VR bool Presenter::StartOpenXRFrameNow(double* wait_frame_ms, double* locate_views_ms, bool do_locate_views) { if (wait_frame_ms) *wait_frame_ms = 0.0; if (locate_views_ms) *locate_views_ms = 0.0; if (!g_ActiveConfig.VRSessionActive() || !VR::g_openxr) return false; const auto wait_begin_start = std::chrono::high_resolution_clock::now(); if (!VR::g_openxr->PollEvents() || !VR::g_openxr->IsSessionRunning()) return false; if (!VR::g_openxr->WaitFrame() || !VR::g_openxr->BeginFrame()) return false; const auto wait_begin_end = std::chrono::high_resolution_clock::now(); if (wait_frame_ms) { *wait_frame_ms = std::chrono::duration(wait_begin_end - wait_begin_start).count(); } if (do_locate_views && VR::g_openxr->ShouldRender()) { const auto locate_start = std::chrono::high_resolution_clock::now(); if (!VR::g_openxr->LocateViews()) return false; const auto locate_end = std::chrono::high_resolution_clock::now(); if (locate_views_ms) { *locate_views_ms = std::chrono::duration(locate_end - locate_start).count(); } auto& geometry_shader_manager = Core::System::GetInstance().GetGeometryShaderManager(); geometry_shader_manager.SetProjectionChanged(); // Head-pose lock: LocateViews above still refreshes m_eye_views with the latest // predicted pose, but we intentionally do NOT invalidate the GS cache here. The // cache is only invalidated by BPStructs' XFB-copy boundary (guaranteed // FIFO-ordered between game frames), so every draw within a single game frame // sees one consistent pose snapshot. The lock applies when ImmediateXFB is off // (VRLockHeadPosePerFrame) — a per-present invalidate would land mid-frame there. if (!g_ActiveConfig.VRLockHeadPosePerFrame()) geometry_shader_manager.InvalidateVRHeadPose(); } return true; } void Presenter::PrepareNextOpenXRFrame() { if (!g_ActiveConfig.VRSessionActive() || !VR::g_openxr) { m_openxr_frame_prepared = false; return; } if (VR::g_openxr->IsFrameThreadActive()) { // The pacing thread owns xrWaitFrame/xrBeginFrame/xrEndFrame; here we only refresh // the eye poses the next game frame will be rendered with. const auto locate_start = std::chrono::high_resolution_clock::now(); if (VR::g_openxr->IsSessionRunning() && VR::g_openxr->ShouldRender() && VR::g_openxr->LocateViews()) { auto& geometry_shader_manager = Core::System::GetInstance().GetGeometryShaderManager(); geometry_shader_manager.SetProjectionChanged(); // Head-pose lock: see StartOpenXRFrameNow — the GS pose cache is only // invalidated at the XFB-copy boundary while the lock is in effect. if (!g_ActiveConfig.VRLockHeadPosePerFrame()) geometry_shader_manager.InvalidateVRHeadPose(); } m_last_openxr_wait_frame_ms = 0.0; m_last_openxr_locate_views_ms = std::chrono::duration(std::chrono::high_resolution_clock::now() - locate_start) .count(); } else { m_openxr_frame_prepared = StartOpenXRFrameNow(&m_last_openxr_wait_frame_ms, &m_last_openxr_locate_views_ms); } // Rolling window: how much of the presenting (video/emu) thread each game frame // spends inside blocking XR calls. On single-core games this time is stolen // straight from emulation — the number the pacing thread exists to erase. m_openxr_timing_frames++; m_openxr_timing_wait_ms += m_last_openxr_wait_frame_ms; m_openxr_timing_locate_ms += m_last_openxr_locate_views_ms; const u64 now_us = Common::Timer::NowUs(); if (m_openxr_timing_window_start_us == 0) m_openxr_timing_window_start_us = now_us; if (now_us - m_openxr_timing_window_start_us >= 5'000'000 && m_openxr_timing_frames > 0) { INFO_LOG_FMT(VIDEO, "XRTiming(present thread): {} frames | per frame: wait+begin={:.2f}ms " "locate={:.2f}ms | pacing_thread={}", m_openxr_timing_frames, m_openxr_timing_wait_ms / m_openxr_timing_frames, m_openxr_timing_locate_ms / m_openxr_timing_frames, VR::g_openxr->IsFrameThreadActive()); m_openxr_timing_window_start_us = now_us; m_openxr_timing_frames = 0; m_openxr_timing_wait_ms = 0.0; m_openxr_timing_locate_ms = 0.0; } // The VR full-EFB clear no longer happens here: with ImmediateXFB off, Present() // runs mid-way through the next frame's draw stream, and clearing here wiped // half-drawn frames (flicker unless "Don't Clear Screen" was on). It is now // requested at the XFB-copy boundary (BPStructs) and deferred to the next frame's // first draw (FramebufferManager::FlushPendingVRClearEFB). } void Presenter::BlitCurrentSourceToOpenXREyes(const AbstractTexture* source_texture, const MathUtil::Rectangle& source_rc) { if (!source_texture || !VR::g_openxr || !VR::g_openxr->GetSwapchain() || !VR::g_openxr->ShouldRender()) { return; } if (source_texture->GetLayers() < 2) return; VR::IOpenXRSwapchain* sc = VR::g_openxr->GetSwapchain(); AbstractFramebuffer* saved_fb = g_gfx->GetCurrentFramebuffer(); const float vr_gamma = g_ActiveConfig.vr_gamma; const bool apply_gamma = vr_gamma > 1.01f; bool saved_correct_gamma = false; bool saved_sdr_gamma_srgb = false; float saved_custom_gamma = 2.2f; if (apply_gamma) { saved_correct_gamma = g_ActiveConfig.color_correction.bCorrectGamma; saved_sdr_gamma_srgb = g_ActiveConfig.color_correction.bSDRDisplayGammaSRGB; saved_custom_gamma = g_ActiveConfig.color_correction.fSDRDisplayCustomGamma; g_ActiveConfig.color_correction.bCorrectGamma = true; g_ActiveConfig.color_correction.bSDRDisplayGammaSRGB = false; g_ActiveConfig.color_correction.fSDRDisplayCustomGamma = vr_gamma; } auto restore_post_process_state = [&] { if (apply_gamma) { g_ActiveConfig.color_correction.bCorrectGamma = saved_correct_gamma; g_ActiveConfig.color_correction.bSDRDisplayGammaSRGB = saved_sdr_gamma_srgb; g_ActiveConfig.color_correction.fSDRDisplayCustomGamma = saved_custom_gamma; } if (saved_fb) g_gfx->SetFramebuffer(saved_fb); }; const MathUtil::Rectangle eye_rect{ 0, 0, static_cast(sc->GetEyeWidth()), static_cast(sc->GetEyeHeight())}; // With passthrough on, anything the game leaves transparent must stay transparent in // the OpenXR swapchain so the compositor can show the camera feed there. const float clear_alpha = g_ActiveConfig.VRPassthroughEnabled() ? 0.0f : 1.0f; if (sc->SupportsLayeredRendering() && m_post_processor->CanBlitFromTextureLayered()) { AbstractFramebuffer* layered_fb = sc->AcquireLayeredFramebuffer(); if (layered_fb) { g_gfx->SetAndClearFramebuffer(layered_fb, {0.f, 0.f, 0.f, clear_alpha}); if (m_post_processor->BlitFromTextureLayered(eye_rect, source_rc, source_texture)) { sc->ReleaseLayeredTexture(); restore_post_process_state(); return; } static bool s_logged_layered_blit_fallback = false; if (!s_logged_layered_blit_fallback) { WARN_LOG_FMT(VIDEO, "OpenXR: layered post-process blit failed; falling back to " "per-eye swapchains."); s_logged_layered_blit_fallback = true; } sc->ReleaseLayeredTexture(); } } for (uint32_t eye = 0; eye < 2; ++eye) { AbstractFramebuffer* eye_fb = sc->AcquireEyeFramebuffer(eye); if (!eye_fb) continue; // Replay submits must also populate the acquired OpenXR image; binding it without a blit can // leak stale swapchain contents from an older frame. g_gfx->SetAndClearFramebuffer(eye_fb, {0.f, 0.f, 0.f, clear_alpha}); m_post_processor->BlitFromTexture(eye_rect, source_rc, source_texture, static_cast(eye)); sc->ReleaseEyeTexture(eye); } restore_post_process_state(); } bool Presenter::IsOpenXRFlat() const { return VR::g_openxr && g_ActiveConfig.vr_flat_screen && g_ActiveConfig.stereo_mode != StereoMode::OpenXR; } void Presenter::BlitCurrentSourceToOpenXRFlat(const AbstractTexture* source_texture, const MathUtil::Rectangle& source_rc) { if (!source_texture || !VR::g_openxr || !VR::g_openxr->GetSwapchain() || !VR::g_openxr->ShouldRender()) { return; } VR::IOpenXRSwapchain* sc = VR::g_openxr->GetSwapchain(); AbstractFramebuffer* flat_fb = sc->AcquireFlatFramebuffer(); if (!flat_fb) return; AbstractFramebuffer* saved_fb = g_gfx->GetCurrentFramebuffer(); // The game aspect drives the world size of the quad; the mono frame is stretched to fill the // (roughly square) eye-0 image, and the non-square quad undoes the stretch. const int src_w = source_rc.GetWidth(); const int src_h = source_rc.GetHeight(); if (src_w > 0 && src_h > 0) VR::g_openxr->SetFlatScreenAspect(static_cast(src_w) / static_cast(src_h)); const MathUtil::Rectangle flat_rect{ 0, 0, static_cast(sc->GetEyeWidth()), static_cast(sc->GetEyeHeight())}; g_gfx->SetAndClearFramebuffer(flat_fb, {0.f, 0.f, 0.f, 1.f}); m_post_processor->BlitFromTexture(flat_rect, source_rc, source_texture, 0); sc->ReleaseFlatTexture(); if (saved_fb) g_gfx->SetFramebuffer(saved_fb); } bool Presenter::SubmitOpenXRFrameFromCurrentSource(const AbstractTexture* source_texture, const MathUtil::Rectangle& source_rc, bool blit_source) { if (!VR::g_openxr) return false; if (VR::IOpenXRSwapchain* sc = VR::g_openxr->GetSwapchain()) { // Bracket the eye-image release (inside the blit) and the pose publish (inside // SubmitFrame) as one unit so the pacing thread's eager heartbeat can't fire between // them and submit a new image with a stale pose. No-op when the pacing thread is off. VR::OpenXRManager::ScopedVideoFrameHandoff handoff( VR::g_openxr->IsFrameThreadActive() ? VR::g_openxr.get() : nullptr); if (IsOpenXRFlat()) { // Flat mode always (re)blits at submit time: on PC the earlier RenderXFBToScreen path // only mirrors to the desktop window, and on Quest the direct-to-HMD branch skips the // stereo eye blit for flat mode. BlitCurrentSourceToOpenXRFlat(source_texture, source_rc); return sc->SubmitFlatFrame(); } if (blit_source) BlitCurrentSourceToOpenXREyes(source_texture, source_rc); return sc->SubmitFrame(); } // No swapchain: with the pacing thread active it heartbeats empty frames itself; // the legacy flow must close the begun frame here. if (VR::g_openxr->IsFrameThreadActive()) return true; return VR::g_openxr->EndFrame({}); } #endif void Presenter::RenderXFBToScreen(const MathUtil::Rectangle& target_rc, const AbstractTexture* source_texture, const MathUtil::Rectangle& source_rc) { if (g_ActiveConfig.stereo_mode == StereoMode::QuadBuffer && g_backend_info.bUsesExplictQuadBuffering) { // Quad-buffered stereo is annoying on GL. g_gfx->SelectLeftBuffer(); m_post_processor->BlitFromTexture(target_rc, source_rc, source_texture, 0); g_gfx->SelectRightBuffer(); m_post_processor->BlitFromTexture(target_rc, source_rc, source_texture, 1); g_gfx->SelectMainBuffer(); } else if (g_ActiveConfig.stereo_mode == StereoMode::SBS || g_ActiveConfig.stereo_mode == StereoMode::TAB) { const auto [left_rc, right_rc] = ConvertStereoRectangle(target_rc); m_post_processor->BlitFromTexture(left_rc, source_rc, source_texture, 0); m_post_processor->BlitFromTexture(right_rc, source_rc, source_texture, 1); } #ifdef ENABLE_VR else if (g_ActiveConfig.stereo_mode == StereoMode::OpenXR) { // Mirror view on the desktop window. switch (g_ActiveConfig.vr_mirror_view) { case OpenXRMirrorView::BothEyes: { const auto [left_rc, right_rc] = ConvertStereoRectangle(target_rc); m_post_processor->BlitFromTexture(left_rc, source_rc, source_texture, 0); m_post_processor->BlitFromTexture(right_rc, source_rc, source_texture, 1); break; } case OpenXRMirrorView::LeftEye: m_post_processor->BlitFromTexture(target_rc, source_rc, source_texture, 0); break; case OpenXRMirrorView::RightEye: m_post_processor->BlitFromTexture(target_rc, source_rc, source_texture, 1); break; case OpenXRMirrorView::None: break; } // Eye blit. With the pacing thread active, defer it to submit time (below) so the // swapchain-image release ends up adjacent to the pose publish — otherwise the // release and publish straddle DrawImGui + PresentBackbuffer, and an eager heartbeat // firing in that gap submits the new image with the previous pose (stutter). if (!VR::g_openxr || !VR::g_openxr->IsFrameThreadActive()) BlitCurrentSourceToOpenXREyes(source_texture, source_rc); } #endif // Every other case will be treated the same (stereo or not). // If there's multiple source layers, they should all be copied. else { m_post_processor->BlitFromTexture(target_rc, source_rc, source_texture); } } void Presenter::Present(PresentInfo* present_info) { m_present_count++; #ifdef ENABLE_VR // See Initialize(): the Android GLES OpenXR path runs with a headless GL context, but // Present() must still run — it pumps the XR event loop and performs the eye blits. const bool xr_headless_present = g_ActiveConfig.VRSessionActive() && g_backend_info.api_type == APIType::OpenGL; #else constexpr bool xr_headless_present = false; #endif if ((g_gfx->IsHeadless() && !xr_headless_present) || (!m_onscreen_ui && !m_xfb_entry)) return; if (!g_gfx->SupportsUtilityDrawing()) { // Video Software doesn't support drawing a UI or doing post-processing // So just show the XFB if (m_xfb_entry) { g_gfx->ShowImage(m_xfb_entry->texture.get(), m_xfb_rect); // Update the window size based on the frame that was just rendered. // Due to depending on guest state, we need to call this every frame. SetSuggestedWindowSize(m_xfb_rect.GetWidth(), m_xfb_rect.GetHeight()); } return; } // Since we use the common pipelines here and draw vertices if a batch is currently being // built by the vertex loader, we end up trampling over its pointer, as we share the buffer // with the loader, and it has not been unmapped yet. Force a pipeline flush to avoid this. g_vertex_manager->Flush(); UpdateDrawRectangle(); #ifdef ENABLE_VR // OpenXR frame lifecycle. // // Pacing thread active (default): the thread owns WaitFrame/BeginFrame/EndFrame and // heartbeats the last frame at HMD cadence; Present() only blits the eyes and // publishes the new layers. Nothing here blocks on the XR runtime. // // Legacy flow (UseXRPacingThread = false): frame N was pre-begun at the end of // Present(N-1) so BeginFrame→EndFrame brackets the full game render time; the blits // and EndFrame run inline here. #if defined(ANDROID) const bool openxr_direct_to_hmd = g_ActiveConfig.VRSessionActive() && VR::g_openxr && g_ActiveConfig.vr_android_direct_to_hmd; #else constexpr bool openxr_direct_to_hmd = false; #endif bool vr_frame_started = false; if (g_ActiveConfig.VRSessionActive() && VR::g_openxr) { if (VR::g_openxr->IsFrameThreadActive()) { vr_frame_started = VR::g_openxr->IsSessionRunning(); } else { vr_frame_started = m_openxr_frame_prepared; if (!vr_frame_started) { vr_frame_started = StartOpenXRFrameNow(); m_openxr_frame_prepared = vr_frame_started; } } } // VI duplicate presents re-show an XFB the pacing thread is already heartbeating // with its stamped pose; re-blitting and re-publishing the identical content only // adds GPU work and reopens pose/content pairing races. The legacy (inline) flow // cannot skip — its pre-begun XR frame must still be ended below. const bool vr_skip_duplicate_publish = vr_frame_started && present_info != nullptr && present_info->reason == PresentInfo::PresentReason::VideoInterfaceDuplicate && VR::g_openxr && VR::g_openxr->IsFrameThreadActive(); #endif #ifndef ENABLE_VR constexpr bool openxr_direct_to_hmd = false; #endif g_gfx->BeginUtilityDrawing(); const bool backbuffer_bound = !openxr_direct_to_hmd && g_gfx->BindBackbuffer({{0.0f, 0.0f, 0.0f, 1.0f}}); // Render the XFB to the screen. if (backbuffer_bound && m_xfb_entry) { // Adjust the source rectangle instead of using an oversized viewport to render the XFB. auto render_target_rc = GetTargetRectangle(); auto render_source_rc = m_xfb_rect; AdjustRectanglesToFitBounds(&render_target_rc, &render_source_rc, m_backbuffer_width, m_backbuffer_height); RenderXFBToScreen(render_target_rc, m_xfb_entry->texture.get(), render_source_rc); } #ifdef ENABLE_VR else if (openxr_direct_to_hmd && vr_frame_started && m_xfb_entry && !IsOpenXRFlat() && !vr_skip_duplicate_publish) { // Stereo direct-to-HMD path. Flat mode blits at submit time // (SubmitOpenXRFrameFromCurrentSource) instead, so it is skipped here. auto replay_target_rc = GetTargetRectangle(); auto replay_source_rc = m_xfb_rect; AdjustRectanglesToFitBounds(&replay_target_rc, &replay_source_rc, m_backbuffer_width, m_backbuffer_height); BlitCurrentSourceToOpenXREyes(m_xfb_entry->texture.get(), replay_source_rc); } #endif if (m_onscreen_ui) { m_onscreen_ui->Finalize(); if (backbuffer_bound) m_onscreen_ui->DrawImGui(); } // Present to the window system. { std::lock_guard guard(m_swap_mutex); if (present_info != nullptr && !g_ActiveConfig.VRSessionActive()) { const auto present_time = GetUpdatedPresentationTime(present_info->intended_present_time); Core::System::GetInstance().GetCoreTiming().SleepUntil(present_time); // Perhaps in the future a more accurate time can be acquired from the various backends. present_info->actual_present_time = Clock::now(); present_info->present_time_accuracy = PresentInfo::PresentTimeAccuracy::PresentInProgress; } if (!openxr_direct_to_hmd) g_gfx->PresentBackbuffer(); } #ifdef ENABLE_VR if (vr_frame_started) { if (!vr_skip_duplicate_publish) { // Pacing thread active: the eye blit was deferred from RenderXFBToScreen to here so // its swapchain release sits right next to the pose publish (both inside the handoff // bracket in SubmitOpenXRFrameFromCurrentSource). Legacy flow already blit inline, so // it only publishes/ends here. Flat and direct-to-HMD manage their own blit. const bool blit_at_submit = VR::g_openxr && VR::g_openxr->IsFrameThreadActive() && !openxr_direct_to_hmd && !IsOpenXRFlat(); SubmitOpenXRFrameFromCurrentSource(m_xfb_entry ? m_xfb_entry->texture.get() : nullptr, m_xfb_rect, blit_at_submit); } m_openxr_frame_prepared = false; } #endif if (m_xfb_entry) { // Update the window size based on the frame that was just rendered. // Due to depending on guest state, we need to call this every frame. SetSuggestedWindowSize(m_xfb_rect.GetWidth(), m_xfb_rect.GetHeight()); } if (m_onscreen_ui) m_onscreen_ui->BeginImGuiFrame(m_backbuffer_width, m_backbuffer_height); g_gfx->EndUtilityDrawing(); #ifdef ENABLE_VR // Refresh eye poses for the next game frame (and pre-begin the next XR frame on the // legacy flow), then clear the EFB so the frame starts on a clean slate. PrepareNextOpenXRFrame(); #endif } TimePoint Presenter::GetUpdatedPresentationTime(TimePoint intended_presentation_time) { const auto now = Clock::now(); const auto arrival_offset = std::min(now - intended_presentation_time, DT{}); if (!Config::Get(Config::MAIN_SMOOTH_EARLY_PRESENTATION)) { m_presentation_time_offset = arrival_offset; // When SmoothEarlyPresentation is off and ImmediateXFB or RushFramePresentation are on, // present as soon as possible as the goal is to achieve low input latency. if (g_ActiveConfig.bImmediateXFB || Config::Get(Config::MAIN_RUSH_FRAME_PRESENTATION)) return now; return intended_presentation_time; } // Adjust slowly backward in time but quickly forward in time. // This keeps the pacing moderately smooth even if games produce regular sporadic bumps. // This was tuned to handle the terrible pacing in Brawl with "Immediate XFB". // Super Mario Galaxy 1 + 2 still perform poorly here in SingleCore mode. const auto adjustment_divisor = (arrival_offset < m_presentation_time_offset) ? 100 : 2; m_presentation_time_offset += (arrival_offset - m_presentation_time_offset) / adjustment_divisor; return intended_presentation_time + m_presentation_time_offset; } void Presenter::SetKeyMap(const DolphinKeyMap& key_map) { if (m_onscreen_ui) m_onscreen_ui->SetKeyMap(key_map); } void Presenter::SetKey(u32 key, bool is_down, const char* chars) { if (m_onscreen_ui) m_onscreen_ui->SetKey(key, is_down, chars); } void Presenter::SetMousePos(float x, float y) { if (m_onscreen_ui) m_onscreen_ui->SetMousePos(x, y); } void Presenter::SetMousePress(u32 button_mask) { if (m_onscreen_ui) m_onscreen_ui->SetMousePress(button_mask); } void Presenter::DoState(PointerWrap& p) { p.Do(m_frame_count); p.Do(m_last_xfb_ticks); p.Do(m_last_xfb_addr); p.Do(m_last_xfb_width); p.Do(m_last_xfb_stride); p.Do(m_last_xfb_height); // If we're loading and there is a last XFB, re-display it. if (p.IsReadMode() && m_last_xfb_stride != 0) { // This technically counts as the end of the frame GetVideoEvents().after_frame_event.Trigger(Core::System::GetInstance()); m_next_swap_estimated_ticks = m_last_xfb_ticks; m_next_swap_estimated_time = Clock::now(); m_immediate_swap_happened_this_field.store(false, std::memory_order_relaxed); ImmediateSwap(m_last_xfb_addr, m_last_xfb_width, m_last_xfb_stride, m_last_xfb_height); } } } // namespace VideoCommon