From 9603fb84d0c0b1ca9db7065d33f3a43d413303b6 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:12:39 +0200 Subject: [PATCH] Added F3 debug overlay: FPS counters, frametime graphs FPS/frametime counters (avg 0.5s), two fixed-time-interval frametime graphs (fps-independent, spikes preserved), present-wait metric (rex/ui/present_stats.h). Guest stats provider wired in OnCreateDialogs. Removed the old inline F3-only build watermark (moves to a standalone always-on drawer in a later commit). --- src/ac6recomp_app.h | 12 ++ .../include/rex/ui/overlay/debug_overlay.h | 38 +++++ .../include/rex/ui/present_stats.h | 20 +++ .../src/native/ui/d3d12/d3d12_presenter.cpp | 6 + .../rexglue-sdk/src/native/ui/presenter.cpp | 11 ++ .../src/ui/overlay/debug_overlay.cpp | 132 ++++++++++++++---- 6 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 thirdparty/rexglue-sdk/include/rex/ui/present_stats.h diff --git a/src/ac6recomp_app.h b/src/ac6recomp_app.h index 58c1f05c..9005ac39 100644 --- a/src/ac6recomp_app.h +++ b/src/ac6recomp_app.h @@ -15,6 +15,7 @@ #include "ac6_native_graphics.h" #include "ac6_native_graphics_overlay.h" +#include "render_hooks.h" #include "generated/ac6recomp_config.h" REXCVAR_DECLARE(std::string, ac6_graphics_backend); @@ -78,6 +79,17 @@ class Ac6recompApp : public rex::ReXApp { native_graphics_status_dialog_ = std::make_unique(drawer); native_graphics_status_dialog_->Show(); + + // Feed the F3 debug overlay the game's own frame stats so its guest + // frametime graph and counter reflect the real sim cadence (SDK cannot + // reach into the AC6 app layer, so it takes them through this callback). + // Must be here, not OnPostSetup: the debug overlay is created just before + // OnCreateDialogs, but OnPostSetup runs BEFORE the overlay exists, so a + // provider set there is silently dropped. + SetGuestFrameStats([]() -> rex::ui::FrameStats { + const ::ac6::FrameStats s = ::ac6::GetFrameStats(); + return rex::ui::FrameStats{s.frame_time_ms, s.fps, s.frame_count}; + }); } private: diff --git a/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h b/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h index 2ce520d2..86de1a88 100644 --- a/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h +++ b/thirdparty/rexglue-sdk/include/rex/ui/overlay/debug_overlay.h @@ -39,6 +39,44 @@ class DebugOverlayDialog : public ImGuiDialog { private: bool visible_ = false; FrameStatsProvider stats_provider_; + + // Frametime graph + throttled counter state. + static constexpr int kHistory = 240; + static constexpr double kCounterRefreshSeconds = 0.5; + + // Ring buffers of frame times in milliseconds. Host = the presenter/paint + // cadence (what an external tool like RTSS would measure); guest = the + // game's own per-frame sim delta (where framerate-dependent judder lives). + float host_ms_history_[kHistory] = {}; + float guest_ms_history_[kHistory] = {}; + int host_history_pos_ = 0; + int guest_history_pos_ = 0; + uint64_t last_guest_frame_count_ = 0; + + // Graph samples are pushed at a FIXED TIME interval (not per frame), so the + // graphs scroll at a consistent rate regardless of fps. kHistory samples then + // cover a fixed time window (240 * 1/60 s = ~4 s). Each sample is the max + // frametime since the last sample, so spikes between samples are preserved. + static constexpr double kGraphSampleSeconds = 1.0 / 60.0; + double graph_sample_accum_s_ = 0.0; + float graph_host_ms_max_ = 0.0f; + float graph_guest_ms_max_ = 0.0f; + + // Counter averaging window (refreshed every kCounterRefreshSeconds so the + // numbers are readable instead of flickering every frame). + double counter_window_s_ = 0.0; + int host_frames_in_window_ = 0; + uint64_t guest_frames_at_window_start_ = 0; + double window_guest_ms_min_ = 1.0e9; + double window_guest_ms_max_ = 0.0; + + // Held (displayed) counter values. + double disp_host_fps_ = 0.0; + double disp_host_ms_ = 0.0; + double disp_guest_fps_ = 0.0; + double disp_guest_ms_ = 0.0; + double disp_guest_ms_min_ = 0.0; + double disp_guest_ms_max_ = 0.0; }; } // namespace rex::ui diff --git a/thirdparty/rexglue-sdk/include/rex/ui/present_stats.h b/thirdparty/rexglue-sdk/include/rex/ui/present_stats.h new file mode 100644 index 00000000..e16ea9be --- /dev/null +++ b/thirdparty/rexglue-sdk/include/rex/ui/present_stats.h @@ -0,0 +1,20 @@ +/** + * @file rex/ui/present_stats.h + * @brief Lightweight presenter timing readouts for the debug overlay. + * + * @copyright Copyright (c) 2026 Project Gracemeria + * @license BSD 3-Clause License + */ +#pragma once + +namespace rex::ui { + +// Duration (ms) of the presenter's last swap-chain frame-latency wait - how long +// painting blocked on the GPU/display being ready to accept a new frame. A high, +// steady value means GPU-bound (the display/GPU is the bottleneck); ~0 means the +// pacing/limiter is the bottleneck (CPU-side), which is the healthy case. This is +// the analog of UnleashedRecomp's "Present Wait" profiler. +double GetLastPresentWaitMs(); +void SetLastPresentWaitMs(double ms); + +} // namespace rex::ui diff --git a/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp b/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp index 35b3ac2c..29c6630e 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp +++ b/thirdparty/rexglue-sdk/src/native/ui/d3d12/d3d12_presenter.cpp @@ -10,6 +10,7 @@ */ #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -585,8 +587,12 @@ void D3D12Presenter::PaintContext::DestroySwapChain() { Presenter::PaintResult D3D12Presenter::PaintAndPresentImpl(bool execute_ui_drawers) { if (paint_context_.HasFrameLatencyWaitableObject()) { + auto wait_begin = std::chrono::steady_clock::now(); DWORD wait_result = WaitForSingleObjectEx(paint_context_.swap_chain_latency_waitable_object, 1000, FALSE); + ui::SetLastPresentWaitMs( + std::chrono::duration(std::chrono::steady_clock::now() - wait_begin) + .count()); if (wait_result == WAIT_FAILED) { REXLOG_WARN("D3D12Presenter: Waiting for the swap chain frame latency object failed"); } else if (wait_result == WAIT_TIMEOUT) { diff --git a/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp b/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp index a40c36d8..2a4e50d4 100644 --- a/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp +++ b/thirdparty/rexglue-sdk/src/native/ui/presenter.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,16 @@ REXCVAR_DEFINE_BOOL(host_present_from_non_ui_thread, true, "UI/Presenter", REXCVAR_DEFINE_BOOL(present_letterbox, true, "UI/Presenter", "Enable letterboxing for non-native aspect ratios"); +namespace rex::ui { +namespace { +std::atomic g_last_present_wait_ms{0.0}; +} // namespace +double GetLastPresentWaitMs() { return g_last_present_wait_ms.load(std::memory_order_relaxed); } +void SetLastPresentWaitMs(double ms) { + g_last_present_wait_ms.store(ms, std::memory_order_relaxed); +} +} // namespace rex::ui + REXCVAR_DEFINE_INT32(present_safe_area_x, 90, "UI/Presenter", "Horizontal safe area percentage (0-100)") .range(0, 100); diff --git a/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp b/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp index c581507b..ad24369d 100644 --- a/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp +++ b/thirdparty/rexglue-sdk/src/ui/overlay/debug_overlay.cpp @@ -11,9 +11,13 @@ */ #include #include +#include #include #include +#include +#include + namespace rex::ui { DebugOverlayDialog::DebugOverlayDialog(ImGuiDrawer* imgui_drawer, FrameStatsProvider stats_provider) @@ -29,36 +33,110 @@ void DebugOverlayDialog::OnDraw(ImGuiIO& io) { if (!visible_) return; - ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(220, 60), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.5f); - if (ImGui::Begin("Debug##overlay", nullptr, ImGuiWindowFlags_NoCollapse)) { - ImGui::Text("Host: %.1f FPS (%.2f ms)", io.Framerate, 1000.0f / io.Framerate); - if (stats_provider_) { - auto stats = stats_provider_(); - if (stats.frame_count > 0) { - ImGui::Text("Guest: %.1f FPS (%.2f ms)", stats.fps, stats.frame_time_ms); - } - } - } - ImGui::End(); + // --- Sample this host frame -------------------------------------------- + const double host_ms = io.DeltaTime * 1000.0; - // Build stamp watermark -- centered near bottom of screen - auto text_size = ImGui::CalcTextSize(REXGLUE_BUILD_STAMP); - float padding = ImGui::GetStyle().WindowPadding.x * 2.0f; - float bottom_offset = io.DisplaySize.y * 0.03f; - ImGui::SetNextWindowPos(ImVec2((io.DisplaySize.x - text_size.x - padding) * 0.5f, - io.DisplaySize.y - text_size.y - bottom_offset)); - ImGui::SetNextWindowSize(ImVec2(0, 0)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, 0.5f)); - if (ImGui::Begin("##watermark", nullptr, - ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoBackground | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextUnformatted(REXGLUE_BUILD_STAMP); + FrameStats stats; + bool have_guest = false; + if (stats_provider_) { + stats = stats_provider_(); + have_guest = stats.frame_count > 0; + } + + // Counter spread metric: track guest frametime extremes per ACTUAL guest frame. + if (have_guest && stats.frame_count != last_guest_frame_count_) { + last_guest_frame_count_ = stats.frame_count; + window_guest_ms_min_ = (std::min)(window_guest_ms_min_, stats.frame_time_ms); + window_guest_ms_max_ = (std::max)(window_guest_ms_max_, stats.frame_time_ms); + } + + // Push graph samples at a FIXED TIME interval (kGraphSampleSeconds), not per + // frame, so both graphs scroll at a consistent rate regardless of fps. Each + // sample carries the max frametime since the last sample (spikes preserved). + graph_host_ms_max_ = (std::max)(graph_host_ms_max_, float(host_ms)); + if (have_guest) { + graph_guest_ms_max_ = (std::max)(graph_guest_ms_max_, float(stats.frame_time_ms)); + } + graph_sample_accum_s_ += io.DeltaTime; + if (graph_sample_accum_s_ >= kGraphSampleSeconds) { + bool first = true; + while (graph_sample_accum_s_ >= kGraphSampleSeconds) { + graph_sample_accum_s_ -= kGraphSampleSeconds; + // First push uses the accumulated max; extra pushes (a frame longer than + // several sample intervals - a hitch) repeat the current frametime so the + // graph fills the gap at the right time width instead of dropping to zero. + host_ms_history_[host_history_pos_] = first ? graph_host_ms_max_ : float(host_ms); + host_history_pos_ = (host_history_pos_ + 1) % kHistory; + guest_ms_history_[guest_history_pos_] = + first ? graph_guest_ms_max_ : (have_guest ? float(stats.frame_time_ms) : 0.0f); + guest_history_pos_ = (guest_history_pos_ + 1) % kHistory; + first = false; + } + graph_host_ms_max_ = 0.0f; + graph_guest_ms_max_ = 0.0f; + } + + // --- Throttled counters (averaged over kCounterRefreshSeconds) ---------- + counter_window_s_ += io.DeltaTime; + ++host_frames_in_window_; + if (counter_window_s_ >= kCounterRefreshSeconds) { + disp_host_fps_ = host_frames_in_window_ / counter_window_s_; + disp_host_ms_ = disp_host_fps_ > 0.0 ? 1000.0 / disp_host_fps_ : 0.0; + if (have_guest) { + const double guest_frames = double(stats.frame_count - guest_frames_at_window_start_); + disp_guest_fps_ = guest_frames / counter_window_s_; + disp_guest_ms_ = disp_guest_fps_ > 0.0 ? 1000.0 / disp_guest_fps_ : 0.0; + disp_guest_ms_min_ = window_guest_ms_min_ < 1.0e8 ? window_guest_ms_min_ : 0.0; + disp_guest_ms_max_ = window_guest_ms_max_; + } + counter_window_s_ = 0.0; + host_frames_in_window_ = 0; + guest_frames_at_window_start_ = have_guest ? stats.frame_count : 0; + window_guest_ms_min_ = 1.0e9; + window_guest_ms_max_ = 0.0; + } + + // --- Draw --------------------------------------------------------------- + ImGui::SetNextWindowPos(ImVec2(10, 10), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(320, 0), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.55f); + if (ImGui::Begin("Frame timing##overlay", nullptr, ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Host: %6.1f FPS %5.2f ms", disp_host_fps_, disp_host_ms_); + // Host = the presenter's paint/present rate = what an external tool like RTSS + // measures. It is DECOUPLED from the guest game rate below (the presenter + // paints on the UI thread at ~monitor refresh), so Host >> Guest is expected + // and is why RTSS reads the present rate, not the game fps. Present-wait = how + // long paint blocked on the GPU/display (high = GPU-bound; ~0 = pacing-bound). + ImGui::SameLine(); + ImGui::TextDisabled(" wait %.2f ms", GetLastPresentWaitMs()); + if (have_guest) { + ImGui::Text("Guest: %6.1f FPS %5.2f ms", disp_guest_fps_, disp_guest_ms_); + // Spread over the window is the numeric shake metric: a wide min/max at a + // steady average fps is exactly the uneven-pacing / aircraft-shake signal. + ImGui::TextDisabled("guest ft min %.2f max %.2f spread %.2f ms", disp_guest_ms_min_, + disp_guest_ms_max_, disp_guest_ms_max_ - disp_guest_ms_min_); + } + + ImGui::Separator(); + + // Both graphs share a fixed 0-40ms scale so host vs guest jaggedness is + // directly comparable. Flat host + jagged guest == sim-dt jitter (the shake + // is in the game's timing, not the presenter); jagged both == real delivery + // unevenness (look at the pacing/limiter). + char overlay[48]; + std::snprintf(overlay, sizeof(overlay), "host %.2f ms (now)", host_ms); + ImGui::PlotLines("##host_ft", host_ms_history_, kHistory, host_history_pos_, overlay, 0.0f, + 40.0f, ImVec2(0, 55)); + if (have_guest) { + std::snprintf(overlay, sizeof(overlay), "guest %.2f ms (now)", stats.frame_time_ms); + ImGui::PlotLines("##guest_ft", guest_ms_history_, kHistory, guest_history_pos_, overlay, + 0.0f, 40.0f, ImVec2(0, 55)); + } + ImGui::TextDisabled("graphs 0-40 ms, ~%.0fs window (max/%.0fms) | counters avg %.1fs", + kHistory * kGraphSampleSeconds, kGraphSampleSeconds * 1000.0, + kCounterRefreshSeconds); } ImGui::End(); - ImGui::PopStyleColor(); } } // namespace rex::ui