Add capture pipeline rate + latency metrics to the Video panel

The FPS readout only measured the host's own render rate, hiding capture stutter.
The Video panel now shows a "Pipeline rates" section:
- Tool render (host FPS / frametime, as before);
- hooked source: Game present (/s, from VideoShare.present_calls deltas), Hook
  publish (/s, generation deltas), and capture->display latency avg/min/max ms;
- WGC source: WGC capture (/s) from a new WindowCapture frame-arrival counter
  (game present + latency are n/a, since WGC frames aren't game-timestamped).

Latency uses a system-wide clock: protocol v11->v12 adds VideoShare.present_qpc,
stamped by the hook at publish (publish_video_frame); the host measures
now_qpc - present_qpc per newly published frame, windowed to min/avg/max each second.

Verified: present_hook_test (x64 + x86) now asserts present_qpc is stamped; full
build x64 + x86 clean; ctest x64 9/9, x86 3/3. The live rate/latency numbers need a
real game mirroring to read meaningfully; wiring validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 05:25:17 +02:00
parent 9f5b7c3272
commit 5e05d38be8
10 changed files with 121 additions and 9 deletions

View File

@@ -151,6 +151,7 @@ void WindowCapture::on_frame_arrived(winrt::Direct3D11CaptureFramePool const& po
// Just grab the newest frame and stash it; the render thread does the copy.
auto frame = pool.TryGetNextFrame();
std::lock_guard<std::mutex> lock(mutex_);
++frames_arrived_; // capture-rate metric (this is the WGC delivery cadence)
if (pending_ != nullptr)
{
pending_.Close(); // drop the un-consumed previous frame back to the pool

View File

@@ -46,6 +46,12 @@ public:
return height_;
}
// Cumulative frames WGC has delivered (for the capture-rate metric).
[[nodiscard]] std::uint64_t frames_arrived() const
{
return frames_arrived_;
}
// Render thread: consume the newest frame (if any) and draw it letterboxed
// into a dst_w x dst_h target via `renderer`.
void draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h);
@@ -60,6 +66,7 @@ private:
Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> latest_srv_;
std::uint32_t width_ = 0;
std::uint32_t height_ = 0;
std::uint64_t frames_arrived_ = 0; // incremented in on_frame_arrived
std::mutex mutex_; // guards pending_ handoff only (no context work under it)
winrt::Windows::Graphics::Capture::Direct3D11CaptureFrame pending_{nullptr};

View File

@@ -18,6 +18,9 @@ const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
bool CapturePanel::init(ID3D11Device* device)
{
device_ = device;
LARGE_INTEGER freq{};
QueryPerformanceFrequency(&freq);
qpc_freq_ = freq.QuadPart;
shared_.init(device); // best effort; the Hooked source is unavailable if it fails
return renderer_.init(device);
}
@@ -112,11 +115,81 @@ void CapturePanel::draw_ui(const FrameStats& stats)
}
}
draw_pipeline_metrics(stats);
draw_perf_graphs(stats);
ImGui::End();
}
void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
{
if (!enabled_)
{
return;
}
const double now = ImGui::GetTime();
ImGui::SeparatorText("Pipeline rates");
ImGui::Text("Tool render: %.0f FPS (%.2f ms)", stats.fps(), stats.avg_ms());
if (source_ == Source_Hooked)
{
const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{};
ImGui::Text("Game present: %.0f /s", present_rate_.sample(v.present_calls, now));
ImGui::Text("Hook publish: %.0f /s", capture_rate_.sample(v.generation, now));
// On each newly published frame, measure now - present_qpc (system-wide clock).
if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0)
{
last_video_gen_ = v.generation;
LARGE_INTEGER now_qpc{};
QueryPerformanceCounter(&now_qpc);
const double ms =
static_cast<double>(now_qpc.QuadPart - v.present_qpc) * 1000.0 / static_cast<double>(qpc_freq_);
if (ms >= 0.0 && ms < 1000.0) // ignore clock edge cases
{
lat_sum_ += ms;
if (lat_n_ == 0 || ms < lat_wmin_)
{
lat_wmin_ = ms;
}
if (ms > lat_wmax_)
{
lat_wmax_ = ms;
}
++lat_n_;
}
}
if (now - lat_window_start_ >= 1.0) // publish min/avg/max once a second
{
if (lat_n_ > 0)
{
lat_avg_ = static_cast<float>(lat_sum_ / lat_n_);
lat_min_ = static_cast<float>(lat_wmin_);
lat_max_ = static_cast<float>(lat_wmax_);
}
lat_sum_ = 0.0;
lat_n_ = 0;
lat_wmin_ = 0.0;
lat_wmax_ = 0.0;
lat_window_start_ = now;
}
if (lat_avg_ > 0.0f)
{
ImGui::Text("Capture->display: avg %.1f min %.1f max %.1f ms", lat_avg_, lat_min_, lat_max_);
}
else
{
ImGui::TextDisabled("Capture->display latency: measuring...");
}
}
else
{
ImGui::TextDisabled("Game present: n/a (WGC has no game frame timing)");
ImGui::Text("WGC capture: %.0f /s", capture_rate_.sample(capture_.frames_arrived(), now));
ImGui::TextDisabled("Latency: n/a (WGC frames aren't game-timestamped)");
}
}
void CapturePanel::draw_perf_graphs(const FrameStats& stats)
{
// The mirror renders into this window, so the host's render frame timing is

View File

@@ -66,6 +66,26 @@ private:
};
void draw_perf_graphs(const FrameStats& stats);
void draw_pipeline_metrics(const FrameStats& stats);
// Turns a monotonic counter into a rate (recomputed ~2x/second).
struct RateTracker
{
std::uint64_t last_count = 0;
double last_time = 0.0;
double rate = 0.0;
double sample(std::uint64_t count, double now)
{
if (now - last_time >= 0.5)
{
const double dt = now - last_time;
rate = dt > 0.0 ? static_cast<double>(count - last_count) / dt : 0.0;
last_count = count;
last_time = now;
}
return rate;
}
};
ID3D11Device* device_ = nullptr;
FrameRenderer renderer_;
@@ -75,6 +95,18 @@ private:
HWND target_ = nullptr;
bool enabled_ = false;
int source_ = Source_Wgc;
// Pipeline metrics: game-present + capture rates and capture->display latency.
RateTracker present_rate_;
RateTracker capture_rate_;
std::uint32_t last_video_gen_ = 0;
long long qpc_freq_ = 0;
double lat_sum_ = 0.0;
int lat_n_ = 0;
double lat_wmin_ = 0.0;
double lat_wmax_ = 0.0;
double lat_window_start_ = 0.0;
float lat_min_ = 0.0f, lat_avg_ = 0.0f, lat_max_ = 0.0f; // published min/avg/max ms
};
} // namespace coop

View File

@@ -107,6 +107,7 @@ VideoShareView IpcServer::video_share() const
v.height = s.height;
v.format = s.format;
v.present_calls = s.present_calls;
v.present_qpc = s.present_qpc;
return v;
}

View File

@@ -53,6 +53,7 @@ struct VideoShareView
std::uint32_t height = 0;
std::uint32_t format = 0;
std::uint64_t present_calls = 0; // cumulative Present() detours (diagnostic)
std::int64_t present_qpc = 0; // QPC stamp of the last published frame
};
class IpcServer