diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 4d666c8..30ef917 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -36,6 +36,7 @@ target_link_libraries(coop_host PRIVATE windowsapp xinput mmdevapi + winmm ole32) set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host") diff --git a/host/src/capture_panel.cpp b/host/src/capture_panel.cpp index 02bd23d..a5f6e53 100644 --- a/host/src/capture_panel.cpp +++ b/host/src/capture_panel.cpp @@ -1,7 +1,5 @@ #include "capture_panel.hpp" -#include - #include "imgui.h" #include "injection_panel.hpp" #include "ui/app_chrome.hpp" @@ -13,6 +11,50 @@ namespace { const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f); const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f); + +// One colored line for the multi-series perf graph. +struct GraphSeries +{ + const char* name; + const float* values; // oldest -> newest + int count; + ImVec4 color; +}; + +// Draw several FPS/ms series as colored polylines sharing one framed plot area, so the +// tool / game / hook rates can be told apart (ImGui::PlotLines only does one color). +void plot_multiseries(const char* id, const GraphSeries* series, int n_series, float y_min, float y_max, float height) +{ + const ImGuiStyle& style = ImGui::GetStyle(); + const float width = ImGui::GetContentRegionAvail().x; + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + const ImVec2 size(width > 1.0f ? width : 1.0f, height); + ImGui::InvisibleButton(id, size); // reserve + consume the rect + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 p1(p0.x + size.x, p0.y + size.y); + dl->AddRectFilled(p0, p1, ImGui::GetColorU32(ImGuiCol_FrameBg), style.FrameRounding); + dl->AddRect(p0, p1, ImGui::GetColorU32(ImGuiCol_Border), style.FrameRounding); + + const float range = (y_max > y_min) ? (y_max - y_min) : 1.0f; + ImVec2 pts[256]; + for (int s = 0; s < n_series; ++s) + { + const GraphSeries& g = series[s]; + if (g.count < 2) + { + continue; + } + int cnt = g.count > 256 ? 256 : g.count; + for (int i = 0; i < cnt; ++i) + { + const float t = static_cast(i) / static_cast(cnt - 1); + float norm = (g.values[i] - y_min) / range; + norm = norm < 0.0f ? 0.0f : (norm > 1.0f ? 1.0f : norm); + pts[i] = ImVec2(p0.x + t * size.x, p1.y - norm * size.y); + } + dl->AddPolyline(pts, cnt, ImGui::GetColorU32(g.color), ImDrawFlags_None, 1.6f); + } +} } // namespace bool CapturePanel::init(ID3D11Device* device) @@ -65,6 +107,23 @@ void CapturePanel::draw_ui(const FrameStats& stats) } } + // Frame-sync: pace the tool's flip to the game's published frames so the mirror + // flips exactly when the game produces a new frame. Only the Hooked source carries + // the game's true present cadence (WGC frames are handed over by the compositor at + // monitor refresh, so syncing to them is just vsync), so it's Hooked-only. + ImGui::BeginDisabled(source_ != Source_Hooked || !enabled_); + ImGui::Checkbox("Sync flip to game frames", &frame_sync_); + ImGui::EndDisabled(); + if (source_ != Source_Hooked) + { + ImGui::SameLine(); + ImGui::TextDisabled("(Hooked only)"); + } + else if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Present in lockstep with the game instead of vsync."); + } + if (!have_source) { ImGui::TextDisabled(source_ == Source_Hooked @@ -190,42 +249,97 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats) } } -void CapturePanel::draw_perf_graphs(const FrameStats& stats) +void CapturePanel::sample_graph_series(double now) { - // The mirror renders into this window, so the host's render frame timing is - // the mirror's performance. Plot the recent history and summarise it. - ImGui::SeparatorText("Render performance"); + // Tool render rate: instantaneous 1/dt for this frame (the mirror renders into this + // window, so the host's frame timing is the mirror's performance). + const float dt = ImGui::GetIO().DeltaTime; + tool_fps_.push(dt > 0.0f ? 1.0f / dt : 0.0f); - float ms[FrameStats::kHistory] = {}; - const int n = stats.copy_frame_ms(ms); - if (n == 0) + if (source_ == Source_Hooked) + { + const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{}; + game_fps_.push(game_edge_.sample(v.present_calls, now)); + hook_fps_.push(hook_edge_.sample(v.generation, now)); + } + else + { + wgc_fps_.push(wgc_edge_.sample(capture_.frames_arrived(), now)); + } + last_graph_time_ = now; +} + +void CapturePanel::draw_perf_graphs(const FrameStats& /*stats*/) +{ + sample_graph_series(ImGui::GetTime()); + + ImGui::SeparatorText("Performance"); + + // Tool is always plotted; the second/third series depend on the active source. + const ImVec4 col_tool(0.40f, 0.75f, 1.00f, 1.0f); // blue + const ImVec4 col_game(0.45f, 1.00f, 0.45f, 1.0f); // green + const ImVec4 col_hook(1.00f, 0.70f, 0.30f, 1.0f); // orange + + float fps[3][Series::kCap] = {}; + float ms[3][Series::kCap] = {}; + const char* names[3] = {}; + ImVec4 cols[3] = {}; + int counts[3] = {}; + int n = 0; + + const auto add = [&](const Series& s, const char* name, const ImVec4& col) { + const int c = s.copy(fps[n]); + for (int i = 0; i < c; ++i) + { + ms[n][i] = fps[n][i] > 1.0f ? 1000.0f / fps[n][i] : 0.0f; + } + names[n] = name; + cols[n] = col; + counts[n] = c; + ++n; + }; + + add(tool_fps_, "Tool", col_tool); + if (source_ == Source_Hooked) + { + add(game_fps_, "Game", col_game); + add(hook_fps_, "Hook", col_hook); + } + else + { + add(wgc_fps_, "WGC", col_hook); + } + + if (counts[0] < 2) { ImGui::TextDisabled("Gathering samples..."); return; } - float fps[FrameStats::kHistory] = {}; + // Legend: a colored label + the latest value of each line, so colors map to series. for (int i = 0; i < n; ++i) { - fps[i] = ms[i] > 0.0f ? 1000.0f / ms[i] : 0.0f; + ImGui::TextColored(cols[i], "%s %.0f", names[i], counts[i] > 0 ? fps[i][counts[i] - 1] : 0.0f); + if (i + 1 < n) + { + ImGui::SameLine(); + } } - float min_ms = 0.0f, max_ms = 0.0f, avg_ms = 0.0f; - stats.history_stats(min_ms, max_ms, avg_ms); - const float avg_fps = avg_ms > 0.0f ? 1000.0f / avg_ms : 0.0f; - const float min_fps = max_ms > 0.0f ? 1000.0f / max_ms : 0.0f; // slowest frame -> lowest FPS - const float max_fps = min_ms > 0.0f ? 1000.0f / min_ms : 0.0f; + GraphSeries gs[3]; + for (int i = 0; i < n; ++i) + { + gs[i] = GraphSeries{names[i], fps[i], counts[i], cols[i]}; + } + ImGui::TextDisabled("FPS (0-144)"); + plot_multiseries("##fps_multi", gs, n, 0.0f, 144.0f, 56.0f); - char overlay[64]; - std::snprintf(overlay, sizeof(overlay), "avg %.2f ms", avg_ms); - // Fixed 0..33 ms scale (30 FPS floor) so the line height is meaningful frame - // to frame rather than auto-rescaling. - ImGui::PlotLines("##frametime", ms, n, 0, overlay, 0.0f, 33.0f, ImVec2(-1.0f, 60.0f)); - ImGui::Text("Frametime avg %.2f min %.2f max %.2f ms", avg_ms, min_ms, max_ms); - - std::snprintf(overlay, sizeof(overlay), "avg %.0f FPS", avg_fps); - ImGui::PlotLines("##fps", fps, n, 0, overlay, 0.0f, 144.0f, ImVec2(-1.0f, 60.0f)); - ImGui::Text("FPS avg %.0f min %.0f max %.0f", avg_fps, min_fps, max_fps); + for (int i = 0; i < n; ++i) + { + gs[i].values = ms[i]; + } + ImGui::TextDisabled("Frametime (0-33 ms)"); + plot_multiseries("##ms_multi", gs, n, 0.0f, 33.0f, 56.0f); } void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h) diff --git a/host/src/capture_panel.hpp b/host/src/capture_panel.hpp index 64a7195..b3e0cf8 100644 --- a/host/src/capture_panel.hpp +++ b/host/src/capture_panel.hpp @@ -58,6 +58,14 @@ public: return source_ == Source_Hooked; } + // True when the operator asked to pace the tool's flip to the game's published + // frames (only meaningful with the Hooked source while mirroring). The main loop + // reads this to wait for a new hook frame and present without vsync. + [[nodiscard]] bool frame_sync_active() const + { + return frame_sync_ && enabled_ && source_ == Source_Hooked; + } + private: enum Source : int { @@ -87,6 +95,72 @@ private: } }; + // Turns a monotonic counter into an instantaneous rate the moment it advances (so a + // per-frame graph has real resolution instead of 0.5 s stair-steps); the rate is + // held between advances. Used for the game-present / hook-publish graph series. + struct EdgeRate + { + std::uint64_t last_count = 0; + double last_time = 0.0; + float fps = 0.0f; + bool primed = false; + float sample(std::uint64_t count, double now) + { + if (!primed) + { + last_count = count; + last_time = now; + primed = true; + } + else if (count != last_count) + { + const double dt = now - last_time; + if (dt > 0.0) + { + fps = static_cast(static_cast(count - last_count) / dt); + } + last_count = count; + last_time = now; + } + return fps; + } + }; + + // Fixed-length rolling history of one FPS series, plotted in the perf graph. + struct Series + { + static constexpr int kCap = 240; // ~2 s at 120 FPS, matches FrameStats + float v[kCap] = {}; + int pos = 0; + int count = 0; + void push(float fps) + { + v[pos] = fps; + pos = (pos + 1) % kCap; + if (count < kCap) + { + ++count; + } + } + // Copy oldest->newest into out (>= kCap floats); returns the number written. + int copy(float* out) const + { + const int start = (pos - count + kCap * 2) % kCap; + for (int i = 0; i < count; ++i) + { + out[i] = v[(start + i) % kCap]; + } + return count; + } + float latest() const + { + return count > 0 ? v[(pos - 1 + kCap) % kCap] : 0.0f; + } + }; + + // Push one sample into each graph series for the current frame/source. + void sample_graph_series(double now); + ID3D11Device* device_ = nullptr; FrameRenderer renderer_; WindowCapture capture_; @@ -95,10 +169,21 @@ private: HWND target_ = nullptr; bool enabled_ = false; int source_ = Source_Wgc; + bool frame_sync_ = false; // pace the tool flip to the hook's published frames // Pipeline metrics: game-present + capture rates and capture->display latency. RateTracker present_rate_; RateTracker capture_rate_; + + // Per-frame FPS history for the multi-series perf graph (colored per source). + Series tool_fps_; // host render rate + Series game_fps_; // game Present() rate (Hooked) + Series hook_fps_; // hook publish rate (Hooked) + Series wgc_fps_; // WGC frame-arrival rate (WGC) + EdgeRate game_edge_; // present_calls -> instantaneous fps + EdgeRate hook_edge_; // generation -> instantaneous fps + EdgeRate wgc_edge_; // frames_arrived -> instantaneous fps + double last_graph_time_ = 0.0; std::uint32_t last_video_gen_ = 0; long long qpc_freq_ = 0; double lat_sum_ = 0.0; diff --git a/host/src/d3d11_window.cpp b/host/src/d3d11_window.cpp index cd44b89..eb6ced7 100644 --- a/host/src/d3d11_window.cpp +++ b/host/src/d3d11_window.cpp @@ -163,7 +163,7 @@ bool D3D11Window::pump_messages() return true; } -void D3D11Window::render_frame(const RenderCallback& render) +void D3D11Window::render_frame(const RenderCallback& render, UINT sync_interval) { const float clear[4] = {0.06f, 0.06f, 0.08f, 1.0f}; context_->OMSetRenderTargets(1, rtv_.GetAddressOf(), nullptr); @@ -174,8 +174,9 @@ void D3D11Window::render_frame(const RenderCallback& render) render(); } - // vsync on: matches the captured stream cadence and avoids a busy spin. - swap_chain_->Present(1, 0); + // sync_interval 1 (default) vsyncs to the monitor; 0 presents immediately so the + // caller can pace the flip itself (frame-sync to the game's published frames). + swap_chain_->Present(sync_interval, 0); } LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) diff --git a/host/src/d3d11_window.hpp b/host/src/d3d11_window.hpp index b9ec024..3792980 100644 --- a/host/src/d3d11_window.hpp +++ b/host/src/d3d11_window.hpp @@ -31,7 +31,10 @@ public: bool pump_messages(); // Clears the back buffer, invokes render (where ImGui draws), and presents. - void render_frame(const RenderCallback& render); + // sync_interval is the DXGI Present sync interval: 1 = vsync (default, matches the + // monitor), 0 = present immediately (used by the frame-sync path, which paces the + // flip to the game's published frames instead of the monitor). + void render_frame(const RenderCallback& render, UINT sync_interval = 1); [[nodiscard]] HWND hwnd() const { diff --git a/host/src/main.cpp b/host/src/main.cpp index b906a59..6771930 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -5,10 +5,13 @@ // (Injection panel), spoofs the game's focus, and mirrors the game's window into // this window via Windows Graphics Capture (Video mirror panel). +#include #include #include +#include + #include #include "imgui.h" @@ -71,6 +74,41 @@ void draw_overlay_hidden_hint(double seconds_hidden) ImGui::End(); } +// Frame-sync: block until the injected hook publishes a new frame (its generation +// bumps) or a short timeout elapses, pumping window messages so the window stays +// responsive while we wait. Updates `last_gen` to the generation we should treat as +// just-presented. Returns false only if the app is quitting (WM_QUIT seen mid-wait). +bool wait_for_hooked_frame(coop::D3D11Window& window, coop::InjectionPanel& injection, std::uint32_t& last_gen) +{ + LARGE_INTEGER freq{}, start{}; + QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&start); + constexpr double kTimeoutMs = 200.0; // present anyway if the game stalls / is paused + for (;;) + { + const std::uint32_t gen = injection.video_share().generation; + if (gen != last_gen) + { + last_gen = gen; + return true; + } + LARGE_INTEGER now{}; + QueryPerformanceCounter(&now); + const double elapsed = + static_cast(now.QuadPart - start.QuadPart) * 1000.0 / static_cast(freq.QuadPart); + if (elapsed >= kTimeoutMs) + { + last_gen = gen; + return true; + } + if (!window.pump_messages()) + { + return false; // WM_QUIT + } + Sleep(1); // yield ~1 ms (timeBeginPeriod(1) keeps this granular) instead of busy-spinning + } +} + int run() { coop::D3D11Window window; @@ -121,8 +159,21 @@ int run() std::uint16_t last_rumble_l[coop::kMaxPads] = {}; std::uint16_t last_rumble_r[coop::kMaxPads] = {}; + // Frame-sync: the hook generation we last presented (so we wait for the next one). + std::uint32_t last_synced_gen = 0; + while (window.pump_messages()) { + // When the operator enabled "Sync flip to game frames" (Hooked source), pace the + // whole iteration to the game: wait for the next published frame before rendering, + // then present without vsync so the flip lands in lockstep with the game. + if (capture.frame_sync_active()) + { + if (!wait_for_hooked_frame(window, injection, last_synced_gen)) + { + break; + } + } #ifdef COOP_WITH_STEAM // Switch the active input backend to match the Controllers-panel toggle. const bool want_steam = controllers.steam_input_requested(); @@ -228,11 +279,16 @@ int run() const auto dst_w = static_cast(client.right - client.left); const auto dst_h = static_cast(client.bottom - client.top); - // Mirrored frame first (the window background), ImGui overlay on top. - window.render_frame([&]() { - capture.render(window.context(), dst_w, dst_h); - imgui.end_frame(); - }); + // Mirrored frame first (the window background), ImGui overlay on top. When + // frame-syncing, present immediately (interval 0) since the wait above already + // paced us to the game; otherwise vsync to the monitor. + const UINT sync_interval = capture.frame_sync_active() ? 0u : 1u; + window.render_frame( + [&]() { + capture.render(window.context(), dst_w, dst_h); + imgui.end_frame(); + }, + sync_interval); } return 0; @@ -245,7 +301,11 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) // WGC requires an initialized apartment; multi-threaded suits the // free-threaded frame pool. winrt::init_apartment(winrt::apartment_type::multi_threaded); + // 1 ms timer resolution so the frame-sync wait's Sleep(1) is actually ~1 ms (the + // default ~15 ms granularity would cap the synced present rate and add jitter). + timeBeginPeriod(1); const int result = run(); + timeEndPeriod(1); winrt::uninit_apartment(); return result; }