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

@@ -76,14 +76,6 @@ is removed from this list once done — so the top item is always next. The
self-verifiable tooling / UI / input items come first; the game-pipeline items that
need a real game (and Remote Play) to fully validate come last.
- **Real capture metrics + latency stats.** The current FPS readout only measures
how fast the host renders its own window, which hides capture stutter. Add a
three-line frametime/FPS graph — **game present rate** (from `VideoShare` present
deltas), **capture rate** (generation deltas / WGC arrivals), and **tool render
rate** — plus a **capture→display latency** stat: stamp each published frame with
a `QueryPerformanceCounter` value in `VideoShare`, and the host reports
`host-present QPC − game-present QPC` (min/avg/max ms) for the matched frame. QPC
is system-wide, so the two processes' timestamps compare directly.
- **DX12 hooked capture (Spider-Man: Miles Morales).** Miles Morales is D3D12, so
the Present hook fires but `GetBuffer(0)` as `ID3D11Texture2D` fails (the
backbuffer is an `ID3D12Resource`) and the hook idles; WGC works but stutters. Add

View File

@@ -12,7 +12,7 @@ namespace coop
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version.
inline constexpr std::uint32_t kProtocolVersion = 11;
inline constexpr std::uint32_t kProtocolVersion = 12;
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
@@ -167,6 +167,7 @@ struct VideoShare
std::uint32_t height;
std::uint32_t format; // DXGI_FORMAT of the shared texture
std::uint64_t present_calls; // cumulative Present() detours (diagnostic)
std::int64_t present_qpc; // QueryPerformanceCounter at the last publish
};
// --- Mouse + keyboard forwarding -------------------------------------------

View File

@@ -217,6 +217,9 @@ public:
block_->video.width = width;
block_->video.height = height;
block_->video.format = format;
LARGE_INTEGER qpc{};
QueryPerformanceCounter(&qpc);
block_->video.present_qpc = qpc.QuadPart; // system-wide clock; host compares directly
block_->video.generation.fetch_add(1, std::memory_order_release);
}
}

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

View File

@@ -146,6 +146,7 @@ int main()
check(hook::present_calls() >= 3, "Present detour fired");
check(hook::present_frames_shared() > 0, "backbuffer copied into the shared texture");
check(block->video.generation.load() > 0, "video generation published to IPC");
check(block->video.present_qpc > 0, "present QPC stamped for the latency metric");
check(block->video.width == kW && block->video.height == kH, "shared dimensions published");
check(block->video.format == DXGI_FORMAT_R8G8B8A8_UNORM, "shared format published");