Add game-frame-synced flip toggle + multi-series colored perf graphs

Frame sync: new "Sync flip to game frames" toggle in the Video mirror panel
(Hooked source only -- WGC frames are delivered by the compositor at monitor
refresh and don't carry the game's true present cadence, so it's disabled there).
When on, the main loop waits for the hook's next published frame (its generation
bump) before rendering and presents with sync interval 0, so the tool flips in
lockstep with the game instead of vsync. The wait pumps messages to stay
responsive and times out after 200 ms so a paused/stalled game can't hang the
overlay. timeBeginPeriod(1) keeps the wait's Sleep(1) granular; links winmm.
render_frame() gained a sync_interval parameter (default 1 = vsync).

Perf graphs: the old graphs drew the tool's frametime and FPS as single same-color
lines. Replaced with a custom multi-series plotter (ImDrawList polylines) that
overlays Tool (blue), Game present (green), and Hook publish (orange) -- or Tool +
WGC capture in WGC mode -- in distinct colors with a colored legend, for both an
FPS (0-144) and a frametime (0-33 ms) view. Game/hook rates come from an EdgeRate
tracker that measures the instantaneous rate the moment each counter advances, so
the lines have real per-frame resolution rather than 0.5 s stair-steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 11:55:10 +02:00
parent 22b0dff918
commit feb8fc5dae
6 changed files with 299 additions and 35 deletions

View File

@@ -36,6 +36,7 @@ target_link_libraries(coop_host PRIVATE
windowsapp windowsapp
xinput xinput
mmdevapi mmdevapi
winmm
ole32) ole32)
set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host") set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host")

View File

@@ -1,7 +1,5 @@
#include "capture_panel.hpp" #include "capture_panel.hpp"
#include <cstdio>
#include "imgui.h" #include "imgui.h"
#include "injection_panel.hpp" #include "injection_panel.hpp"
#include "ui/app_chrome.hpp" #include "ui/app_chrome.hpp"
@@ -13,6 +11,50 @@ namespace
{ {
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f); const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kRed(1.0f, 0.45f, 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<float>(i) / static_cast<float>(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 } // namespace
bool CapturePanel::init(ID3D11Device* device) 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) if (!have_source)
{ {
ImGui::TextDisabled(source_ == Source_Hooked 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 // Tool render rate: instantaneous 1/dt for this frame (the mirror renders into this
// the mirror's performance. Plot the recent history and summarise it. // window, so the host's frame timing is the mirror's performance).
ImGui::SeparatorText("Render performance"); const float dt = ImGui::GetIO().DeltaTime;
tool_fps_.push(dt > 0.0f ? 1.0f / dt : 0.0f);
float ms[FrameStats::kHistory] = {}; if (source_ == Source_Hooked)
const int n = stats.copy_frame_ms(ms); {
if (n == 0) 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..."); ImGui::TextDisabled("Gathering samples...");
return; 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) 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; GraphSeries gs[3];
stats.history_stats(min_ms, max_ms, avg_ms); for (int i = 0; i < n; ++i)
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 gs[i] = GraphSeries{names[i], fps[i], counts[i], cols[i]};
const float max_fps = min_ms > 0.0f ? 1000.0f / min_ms : 0.0f; }
ImGui::TextDisabled("FPS (0-144)");
plot_multiseries("##fps_multi", gs, n, 0.0f, 144.0f, 56.0f);
char overlay[64]; for (int i = 0; i < n; ++i)
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 gs[i].values = ms[i];
// to frame rather than auto-rescaling. }
ImGui::PlotLines("##frametime", ms, n, 0, overlay, 0.0f, 33.0f, ImVec2(-1.0f, 60.0f)); ImGui::TextDisabled("Frametime (0-33 ms)");
ImGui::Text("Frametime avg %.2f min %.2f max %.2f ms", avg_ms, min_ms, max_ms); plot_multiseries("##ms_multi", gs, n, 0.0f, 33.0f, 56.0f);
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);
} }
void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h) void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h)

View File

@@ -58,6 +58,14 @@ public:
return source_ == Source_Hooked; 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: private:
enum Source : int 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<float>(static_cast<double>(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; ID3D11Device* device_ = nullptr;
FrameRenderer renderer_; FrameRenderer renderer_;
WindowCapture capture_; WindowCapture capture_;
@@ -95,10 +169,21 @@ private:
HWND target_ = nullptr; HWND target_ = nullptr;
bool enabled_ = false; bool enabled_ = false;
int source_ = Source_Wgc; 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. // Pipeline metrics: game-present + capture rates and capture->display latency.
RateTracker present_rate_; RateTracker present_rate_;
RateTracker capture_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; std::uint32_t last_video_gen_ = 0;
long long qpc_freq_ = 0; long long qpc_freq_ = 0;
double lat_sum_ = 0.0; double lat_sum_ = 0.0;

View File

@@ -163,7 +163,7 @@ bool D3D11Window::pump_messages()
return true; 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}; const float clear[4] = {0.06f, 0.06f, 0.08f, 1.0f};
context_->OMSetRenderTargets(1, rtv_.GetAddressOf(), nullptr); context_->OMSetRenderTargets(1, rtv_.GetAddressOf(), nullptr);
@@ -174,8 +174,9 @@ void D3D11Window::render_frame(const RenderCallback& render)
render(); render();
} }
// vsync on: matches the captured stream cadence and avoids a busy spin. // sync_interval 1 (default) vsyncs to the monitor; 0 presents immediately so the
swap_chain_->Present(1, 0); // 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) LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)

View File

@@ -31,7 +31,10 @@ public:
bool pump_messages(); bool pump_messages();
// Clears the back buffer, invokes render (where ImGui draws), and presents. // 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 [[nodiscard]] HWND hwnd() const
{ {

View File

@@ -5,10 +5,13 @@
// (Injection panel), spoofs the game's focus, and mirrors the game's window into // (Injection panel), spoofs the game's focus, and mirrors the game's window into
// this window via Windows Graphics Capture (Video mirror panel). // this window via Windows Graphics Capture (Video mirror panel).
#include <cstdint>
#include <memory> #include <memory>
#include <windows.h> #include <windows.h>
#include <timeapi.h>
#include <winrt/Windows.Foundation.h> #include <winrt/Windows.Foundation.h>
#include "imgui.h" #include "imgui.h"
@@ -71,6 +74,41 @@ void draw_overlay_hidden_hint(double seconds_hidden)
ImGui::End(); 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<double>(now.QuadPart - start.QuadPart) * 1000.0 / static_cast<double>(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() int run()
{ {
coop::D3D11Window window; coop::D3D11Window window;
@@ -121,8 +159,21 @@ int run()
std::uint16_t last_rumble_l[coop::kMaxPads] = {}; std::uint16_t last_rumble_l[coop::kMaxPads] = {};
std::uint16_t last_rumble_r[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()) 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 #ifdef COOP_WITH_STEAM
// Switch the active input backend to match the Controllers-panel toggle. // Switch the active input backend to match the Controllers-panel toggle.
const bool want_steam = controllers.steam_input_requested(); const bool want_steam = controllers.steam_input_requested();
@@ -228,11 +279,16 @@ int run()
const auto dst_w = static_cast<std::uint32_t>(client.right - client.left); const auto dst_w = static_cast<std::uint32_t>(client.right - client.left);
const auto dst_h = static_cast<std::uint32_t>(client.bottom - client.top); const auto dst_h = static_cast<std::uint32_t>(client.bottom - client.top);
// Mirrored frame first (the window background), ImGui overlay on top. // Mirrored frame first (the window background), ImGui overlay on top. When
window.render_frame([&]() { // frame-syncing, present immediately (interval 0) since the wait above already
capture.render(window.context(), dst_w, dst_h); // paced us to the game; otherwise vsync to the monitor.
imgui.end_frame(); 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; return 0;
@@ -245,7 +301,11 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int)
// WGC requires an initialized apartment; multi-threaded suits the // WGC requires an initialized apartment; multi-threaded suits the
// free-threaded frame pool. // free-threaded frame pool.
winrt::init_apartment(winrt::apartment_type::multi_threaded); 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(); const int result = run();
timeEndPeriod(1);
winrt::uninit_apartment(); winrt::uninit_apartment();
return result; return result;
} }