Phase 1b: video mirror via Windows Graphics Capture

Mirror the injected game's window into the host's borderless window so Steam
RPT streams a live copy of the game. No injection needed for video.

- capture/window_capture: WGC capture of a target HWND. Wraps our D3D11 device
  as an IDirect3DDevice, creates a free-threaded frame pool + capture session,
  hides the cursor and (best-effort) the capture border. Frames arrive on a WGC
  thread and are handed to the render thread, which copies the newest into a
  shader-resource texture and draws it -- keeping all D3D11 context use on one
  thread. Handles window resize via frame-pool Recreate.
- capture/frame_renderer: fullscreen-triangle shader that blits the captured
  texture letterboxed (aspect-preserved) into the window.
- capture_panel: "Mirror game window" toggle + lifecycle; target HWND comes
  from the hook's reported game window.
- main: winrt apartment init; mirrored frame drawn as background, ImGui on top.
- CMake: link windowsapp + d3dcompiler.

Verified: builds clean; host starts (apartment init + shader compile succeed).
Visual capture quality/latency to be judged on a real game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 01:03:59 +02:00
parent a0a12d69fe
commit e38df6cf88
10 changed files with 615 additions and 12 deletions

View File

@@ -0,0 +1,119 @@
#include "capture/frame_renderer.hpp"
#include <algorithm>
#include <d3dcompiler.h>
using Microsoft::WRL::ComPtr;
namespace coop
{
namespace
{
// Fullscreen triangle generated from SV_VertexID -- no vertex/index buffers
// needed. Samples the source texture across the [0,1] UV range.
constexpr char kShaderSource[] = R"(
Texture2D g_tex : register(t0);
SamplerState g_smp : register(s0);
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
VSOut vs_main(uint id : SV_VertexID)
{
VSOut o;
o.uv = float2((id << 1) & 2, id & 2);
o.pos = float4(o.uv * float2(2, -2) + float2(-1, 1), 0, 1);
return o;
}
float4 ps_main(VSOut i) : SV_Target
{
return g_tex.Sample(g_smp, i.uv);
}
)";
ComPtr<ID3DBlob> compile(const char* entry, const char* target)
{
ComPtr<ID3DBlob> blob;
ComPtr<ID3DBlob> errors;
const HRESULT hr = D3DCompile(kShaderSource, sizeof(kShaderSource) - 1, "frame_renderer", nullptr, nullptr, entry,
target, D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, blob.GetAddressOf(), errors.GetAddressOf());
if (FAILED(hr))
{
return nullptr;
}
return blob;
}
} // namespace
bool FrameRenderer::init(ID3D11Device* device)
{
ComPtr<ID3DBlob> vs_blob = compile("vs_main", "vs_5_0");
ComPtr<ID3DBlob> ps_blob = compile("ps_main", "ps_5_0");
if (vs_blob == nullptr || ps_blob == nullptr)
{
return false;
}
if (FAILED(device->CreateVertexShader(vs_blob->GetBufferPointer(), vs_blob->GetBufferSize(), nullptr,
vs_.GetAddressOf())))
{
return false;
}
if (FAILED(device->CreatePixelShader(ps_blob->GetBufferPointer(), ps_blob->GetBufferSize(), nullptr,
ps_.GetAddressOf())))
{
return false;
}
D3D11_SAMPLER_DESC sd = {};
sd.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR;
sd.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP;
sd.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP;
sd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP;
sd.ComparisonFunc = D3D11_COMPARISON_NEVER;
if (FAILED(device->CreateSamplerState(&sd, sampler_.GetAddressOf())))
{
return false;
}
return true;
}
void FrameRenderer::draw(ID3D11DeviceContext* ctx, ID3D11ShaderResourceView* srv, std::uint32_t src_w,
std::uint32_t src_h, std::uint32_t dst_w, std::uint32_t dst_h)
{
if (srv == nullptr || src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0)
{
return;
}
// Letterbox: fit the source rect inside the destination, preserving aspect.
const float scale = std::min(static_cast<float>(dst_w) / src_w, static_cast<float>(dst_h) / src_h);
const float vp_w = src_w * scale;
const float vp_h = src_h * scale;
D3D11_VIEWPORT vp = {};
vp.TopLeftX = (static_cast<float>(dst_w) - vp_w) * 0.5f;
vp.TopLeftY = (static_cast<float>(dst_h) - vp_h) * 0.5f;
vp.Width = vp_w;
vp.Height = vp_h;
vp.MinDepth = 0.0f;
vp.MaxDepth = 1.0f;
ctx->RSSetViewports(1, &vp);
ctx->IASetInputLayout(nullptr);
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
ctx->VSSetShader(vs_.Get(), nullptr, 0);
ctx->PSSetShader(ps_.Get(), nullptr, 0);
ctx->PSSetShaderResources(0, 1, &srv);
ctx->PSSetSamplers(0, 1, sampler_.GetAddressOf());
ctx->Draw(3, 0);
// Unbind the SRV so the same texture can be a copy destination next frame.
ID3D11ShaderResourceView* null_srv = nullptr;
ctx->PSSetShaderResources(0, 1, &null_srv);
}
} // namespace coop

View File

@@ -0,0 +1,30 @@
// Draws a captured frame texture into the current render target as a letterboxed
// full-screen image (aspect-preserved; the cleared background fills the bars).
#pragma once
#include <cstdint>
#include <d3d11.h>
#include <wrl/client.h>
namespace coop
{
class FrameRenderer
{
public:
bool init(ID3D11Device* device);
// Draws `srv` (a srcW x srcH image) centered and scaled to fit within a
// dstW x dstH target. Sets its own viewport; callers that draw afterwards
// (e.g. ImGui) should restore theirs.
void draw(ID3D11DeviceContext* ctx, ID3D11ShaderResourceView* srv, std::uint32_t src_w, std::uint32_t src_h,
std::uint32_t dst_w, std::uint32_t dst_h);
private:
Microsoft::WRL::ComPtr<ID3D11VertexShader> vs_;
Microsoft::WRL::ComPtr<ID3D11PixelShader> ps_;
Microsoft::WRL::ComPtr<ID3D11SamplerState> sampler_;
};
} // namespace coop

View File

@@ -0,0 +1,218 @@
#include "capture/window_capture.hpp"
#include "capture/frame_renderer.hpp"
// Interop headers bridge classic D3D11/HWND types and the WinRT capture API.
#include <windows.graphics.capture.interop.h>
#include <windows.graphics.directx.direct3d11.interop.h>
// Foundation must be included for IClosable::Close() definitions used below.
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Graphics.DirectX.h>
using Microsoft::WRL::ComPtr;
namespace winrt
{
using namespace Windows::Graphics;
using namespace Windows::Graphics::Capture;
using namespace Windows::Graphics::DirectX;
using namespace Windows::Graphics::DirectX::Direct3D11;
} // namespace winrt
namespace coop
{
namespace
{
constexpr auto kPixelFormat = winrt::DirectXPixelFormat::B8G8R8A8UIntNormalized;
// Pulls the underlying ID3D11Texture2D out of a captured frame's surface.
ComPtr<ID3D11Texture2D> texture_from_surface(winrt::IDirect3DSurface const& surface)
{
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
ComPtr<ID3D11Texture2D> texture;
if (access)
{
access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
}
return texture;
}
} // namespace
WindowCapture::~WindowCapture()
{
stop();
}
bool WindowCapture::start(HWND target, ID3D11Device* device)
{
stop();
if (target == nullptr || device == nullptr || !IsWindow(target))
{
return false;
}
try
{
device_ = device;
// Wrap our D3D11 device as the WinRT device the frame pool renders on.
ComPtr<IDXGIDevice> dxgi_device;
if (FAILED(device->QueryInterface(IID_PPV_ARGS(dxgi_device.GetAddressOf()))))
{
return false;
}
winrt::com_ptr<::IInspectable> inspectable;
if (FAILED(CreateDirect3D11DeviceFromDXGIDevice(dxgi_device.Get(), inspectable.put())))
{
return false;
}
winrt_device_ = inspectable.as<winrt::IDirect3DDevice>();
// Create a capture item for the target window via the interop factory.
auto interop = winrt::get_activation_factory<winrt::GraphicsCaptureItem, ::IGraphicsCaptureItemInterop>();
if (FAILED(interop->CreateForWindow(target, winrt::guid_of<winrt::GraphicsCaptureItem>(),
winrt::put_abi(item_))))
{
return false;
}
pool_size_ = item_.Size();
frame_pool_ =
winrt::Direct3D11CaptureFramePool::CreateFreeThreaded(winrt_device_, kPixelFormat, 2, pool_size_);
session_ = frame_pool_.CreateCaptureSession(item_);
frame_token_ = frame_pool_.FrameArrived({this, &WindowCapture::on_frame_arrived});
// Best-effort: hide the cursor and the yellow capture border (the border
// API requires a recent Windows build, hence the guard).
try
{
session_.IsCursorCaptureEnabled(false);
}
catch (...)
{
}
try
{
session_.IsBorderRequired(false);
}
catch (...)
{
}
session_.StartCapture();
target_ = target;
return true;
}
catch (...)
{
stop();
return false;
}
}
void WindowCapture::stop()
{
if (frame_pool_ != nullptr && frame_token_)
{
frame_pool_.FrameArrived(frame_token_);
frame_token_ = {};
}
if (session_ != nullptr)
{
session_.Close();
session_ = nullptr;
}
if (frame_pool_ != nullptr)
{
frame_pool_.Close();
frame_pool_ = nullptr;
}
{
std::lock_guard<std::mutex> lock(mutex_);
pending_ = nullptr;
}
item_ = nullptr;
winrt_device_ = nullptr;
latest_srv_.Reset();
latest_.Reset();
device_.Reset();
target_ = nullptr;
width_ = 0;
height_ = 0;
}
void WindowCapture::on_frame_arrived(winrt::Direct3D11CaptureFramePool const& pool,
winrt::Windows::Foundation::IInspectable const&)
{
// 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_);
if (pending_ != nullptr)
{
pending_.Close(); // drop the un-consumed previous frame back to the pool
}
pending_ = frame;
}
void WindowCapture::draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ctx, std::uint32_t dst_w,
std::uint32_t dst_h)
{
winrt::Direct3D11CaptureFrame frame{nullptr};
{
std::lock_guard<std::mutex> lock(mutex_);
frame = pending_;
pending_ = nullptr;
}
if (frame != nullptr)
{
if (ComPtr<ID3D11Texture2D> src = texture_from_surface(frame.Surface()))
{
D3D11_TEXTURE2D_DESC desc = {};
src->GetDesc(&desc);
if (latest_ == nullptr || desc.Width != width_ || desc.Height != height_)
{
latest_srv_.Reset();
latest_.Reset();
D3D11_TEXTURE2D_DESC dst = desc;
dst.Usage = D3D11_USAGE_DEFAULT;
dst.BindFlags = D3D11_BIND_SHADER_RESOURCE;
dst.CPUAccessFlags = 0;
dst.MiscFlags = 0;
if (SUCCEEDED(device_->CreateTexture2D(&dst, nullptr, latest_.GetAddressOf())))
{
device_->CreateShaderResourceView(latest_.Get(), nullptr, latest_srv_.GetAddressOf());
width_ = desc.Width;
height_ = desc.Height;
}
}
if (latest_ != nullptr)
{
ctx->CopyResource(latest_.Get(), src.Get());
}
}
frame.Close();
// If the window resized, the capture item changes size; re-fit the pool.
const winrt::SizeInt32 size = item_.Size();
if (size.Width != pool_size_.Width || size.Height != pool_size_.Height)
{
pool_size_ = size;
frame_pool_.Recreate(winrt_device_, kPixelFormat, 2, size);
}
}
if (latest_srv_ != nullptr)
{
renderer.draw(ctx, latest_srv_.Get(), width_, height_, dst_w, dst_h);
}
}
} // namespace coop

View File

@@ -0,0 +1,75 @@
// Mirrors a target window into a D3D11 texture using Windows.Graphics.Capture
// (no injection). Frames arrive on a WGC thread; the newest is handed to the
// render thread, which copies and draws it -- so all D3D11 context use stays on
// one thread.
#pragma once
#include <cstdint>
#include <mutex>
#include <d3d11.h>
#include <windows.h>
#include <wrl/client.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
namespace coop
{
class FrameRenderer;
class WindowCapture
{
public:
~WindowCapture();
// Begins capturing `target`. Returns false if WGC is unavailable or the
// window can't be captured.
bool start(HWND target, ID3D11Device* device);
void stop();
[[nodiscard]] bool running() const
{
return session_ != nullptr;
}
[[nodiscard]] HWND target() const
{
return target_;
}
[[nodiscard]] std::uint32_t frame_width() const
{
return width_;
}
[[nodiscard]] std::uint32_t frame_height() const
{
return height_;
}
// 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);
private:
void on_frame_arrived(winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& pool,
winrt::Windows::Foundation::IInspectable const&);
HWND target_ = nullptr;
Microsoft::WRL::ComPtr<ID3D11Device> device_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> latest_;
Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> latest_srv_;
std::uint32_t width_ = 0;
std::uint32_t height_ = 0;
std::mutex mutex_; // guards pending_ handoff only (no context work under it)
winrt::Windows::Graphics::Capture::Direct3D11CaptureFrame pending_{nullptr};
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrt_device_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool frame_pool_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
winrt::event_token frame_token_{};
winrt::Windows::Graphics::SizeInt32 pool_size_{0, 0};
};
} // namespace coop