Files
CoopAllTheThings/host/src/capture_panel.cpp
BlackMark 36b861d167 Phase 2: Present-hook video path (shared-texture mirror)
Add an injected IDXGISwapChain::Present / Present1 hook as a lower-latency,
border-free alternative to WGC. The hook copies the swapchain backbuffer into a
shared keyed-mutex texture (coop_video_<pid>); the host opens it by name and
samples it. New opt-in HookSubsys_Video (protocol v6 -> v7); the Video mirror
panel gains a WGC vs Hooked source toggle that installs/removes the subsystem.

Verified by present_hook_test (drives a real D3D11 swapchain end-to-end and reads
the rendered pixels back through the shared texture) and against Phantom Brave
(D3D9: hook installs cleanly and stays idle, WGC fallback). All 5 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:34:12 +02:00

182 lines
5.3 KiB
C++

#include "capture_panel.hpp"
#include <cstdio>
#include "imgui.h"
#include "injection_panel.hpp"
namespace coop
{
namespace
{
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
} // namespace
bool CapturePanel::init(ID3D11Device* device)
{
device_ = device;
shared_.init(device); // best effort; the Hooked source is unavailable if it fails
return renderer_.init(device);
}
void CapturePanel::draw_ui(const FrameStats& stats)
{
ImGui::SetNextWindowPos(ImVec2(460, 40), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Video mirror");
const unsigned long hook_pid = injection_ != nullptr ? injection_->target_pid() : 0;
const bool have_wgc_target = target_ != nullptr && IsWindow(target_);
const bool have_hook = hook_pid != 0;
const bool have_source = source_ == Source_Hooked ? have_hook : have_wgc_target;
ImGui::BeginDisabled(!have_source);
if (ImGui::Checkbox("Mirror game window", &enabled_) && !enabled_)
{
capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
injection_->request_video(false); // stop the in-game Present hook
}
}
ImGui::EndDisabled();
// Source selector. Switching tears down the other source and (un)installs the
// Present hook so the game only pays for the path actually in use.
ImGui::TextUnformatted("Source:");
ImGui::SameLine();
int prev_source = source_;
ImGui::RadioButton("WGC", &source_, Source_Wgc);
ImGui::SameLine();
ImGui::RadioButton("Hooked (Present)", &source_, Source_Hooked);
if (source_ != prev_source)
{
capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
injection_->request_video(enabled_ && source_ == Source_Hooked);
}
}
if (!have_source)
{
ImGui::TextDisabled(source_ == Source_Hooked
? "Inject into a game first (the Present hook is the source)."
: "Inject into a game first (its window is the source).");
}
if (source_ == Source_Wgc)
{
// Start/restart WGC capture when enabled and the target window changes.
if (enabled_ && have_wgc_target && capture_.target() != target_)
{
if (!capture_.start(target_, device_))
{
enabled_ = false;
ImGui::TextColored(kRed, "Failed to start capture.");
}
}
if (capture_.running())
{
ImGui::TextColored(kGreen, "Capturing %ux%u (WGC)", capture_.frame_width(), capture_.frame_height());
}
}
else // Source_Hooked
{
if (enabled_ && injection_ != nullptr)
{
// Keep the subsystem requested (a fresh inject may have reset control).
if (!injection_->video_requested())
{
injection_->request_video(true);
}
const VideoShareView share = injection_->video_share();
if (shared_.frames_copied() > 0 && shared_.width() > 0)
{
ImGui::TextColored(kGreen, "Mirroring %ux%u (hooked, %llu frames)", shared_.width(),
shared_.height(), static_cast<unsigned long long>(shared_.frames_copied()));
}
else if (share.present_calls > 0)
{
ImGui::TextColored(kGreen, "Present hooked (%llu calls); opening shared texture...",
static_cast<unsigned long long>(share.present_calls));
}
else
{
ImGui::TextDisabled("Waiting for hooked frames (the game may not render via DXGI).");
}
}
}
draw_perf_graphs(stats);
ImGui::End();
}
void CapturePanel::draw_perf_graphs(const FrameStats& stats)
{
// 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");
float ms[FrameStats::kHistory] = {};
const int n = stats.copy_frame_ms(ms);
if (n == 0)
{
ImGui::TextDisabled("Gathering samples...");
return;
}
float fps[FrameStats::kHistory] = {};
for (int i = 0; i < n; ++i)
{
fps[i] = ms[i] > 0.0f ? 1000.0f / ms[i] : 0.0f;
}
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;
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);
}
void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h)
{
if (!enabled_)
{
return;
}
if (source_ == Source_Hooked)
{
if (injection_ == nullptr)
{
return;
}
if (shared_.update(injection_->video_share(), injection_->target_pid()) && shared_.srv() != nullptr)
{
renderer_.draw(ctx, shared_.srv(), shared_.width(), shared_.height(), dst_w, dst_h);
}
}
else if (capture_.running())
{
capture_.draw_latest(renderer_, ctx, dst_w, dst_h);
}
}
} // namespace coop