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:
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user