Files
CoopAllTheThings/host/src/capture/shared_texture.cpp
BlackMark ffaad6c4ae DX12 capture: fence the copy off the game's queue + add drop detection
Resolves the DX12 mirror stutter and makes dropped frames observable.

Decouple the D3D11On12 copy from the game's present queue. Submitting the
copy on the game's own present queue (the prior approach) ordered it
correctly but stalled the game's presents: GPU back-pressure, plus the
shared keyed-mutex AcquireSync is a CPU-blocking call on the render
thread. Running it on an independent queue avoids the stall but races the
game's render -> stale frames. Do both: run the copy on our own queue and
order it after the frame with an ID3D12Fence the game's present queue
signals (near-free) and our queue waits on. The present queue is still
recovered for late injection via the ExecuteCommandLists hook (now used to
signal the fence, not host the copy). Producer AcquireSync stays
non-blocking (timeout 0) so a busy mutex drops a mirror frame instead of
stalling the game.

Add drop detection (protocol v12 -> v13). The hook counts captures skipped
because the keyed mutex was busy (VideoShare.frames_dropped); the host
counts published frames it never displayed (generation gaps). The Video
panel shows "Frames lost: N/s capture  N/s display", red when nonzero.
This confirmed the game-window-vs-mirror behavior is a display-path
artifact (unfocused windows lose VRR/independent flip), not a capture loss.

Add a one-shot present-pattern log: per distinct swapchain (size/format/
buffer index) and per distinct present-flags value, with DXGI_PRESENT_TEST
spelled out as an occlusion probe that draws nothing -- which is why
Miles Morales shows ~2 presents per captured frame (the test present is
counted but produces no frame).

Docs: add the DX12 capture lessons to the README (rotating back buffer,
fence/own-queue, capture-at-Present decoupling from DWM) and drop the now
-moot DX12 overhead future-work item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:55:27 +02:00

141 lines
3.7 KiB
C++

#include "capture/shared_texture.hpp"
#include "capture/dxgi_format.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace coop
{
bool SharedTextureSource::init(ID3D11Device* device)
{
if (device == nullptr || FAILED(device->QueryInterface(IID_PPV_ARGS(&device_))))
{
return false;
}
device_->GetImmediateContext(&ctx_);
return ctx_ != nullptr;
}
void SharedTextureSource::reset()
{
srv_.Reset();
private_.Reset();
mutex_.Reset();
shared_.Reset();
pid_ = 0;
width_ = height_ = format_ = 0;
last_generation_ = 0;
frames_copied_ = 0;
frames_missed_ = 0;
}
bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
{
srv_.Reset();
private_.Reset();
mutex_.Reset();
shared_.Reset();
width_ = height_ = format_ = 0;
pid_ = pid;
if (pid == 0 || share.width == 0 || share.height == 0)
{
return false; // the hook hasn't shared a backbuffer yet
}
const std::wstring name = video_share_name(pid);
if (FAILED(device_->OpenSharedResourceByName(name.c_str(),
DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
IID_PPV_ARGS(&shared_))) ||
shared_ == nullptr)
{
return false;
}
if (FAILED(shared_.As(&mutex_)) || mutex_ == nullptr)
{
shared_.Reset();
return false;
}
// Private copy we sample from, so we only hold the keyed mutex during the copy.
// Use the plain-UNORM sibling of the (possibly sRGB) backbuffer format so the
// SRV passes the bytes through without an sRGB->linear decode that would darken
// the mirror (CopyResource is allowed within the shared typeless group).
D3D11_TEXTURE2D_DESC desc{};
desc.Width = share.width;
desc.Height = share.height;
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = srgb_to_unorm(static_cast<DXGI_FORMAT>(share.format));
desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
if (FAILED(device_->CreateTexture2D(&desc, nullptr, &private_)) || private_ == nullptr)
{
mutex_.Reset();
shared_.Reset();
return false;
}
if (FAILED(device_->CreateShaderResourceView(private_.Get(), nullptr, &srv_)))
{
srv_.Reset();
private_.Reset();
mutex_.Reset();
shared_.Reset();
return false;
}
width_ = share.width;
height_ = share.height;
format_ = share.format;
return true;
}
bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid)
{
if (device_ == nullptr || pid == 0)
{
reset();
return false;
}
// (Re)open whenever the target or the published backbuffer geometry changes.
if (pid != pid_ || share.width != width_ || share.height != height_ || share.format != format_)
{
if (!reopen(pid, share))
{
return srv_ != nullptr; // couldn't open yet; keep any prior frame
}
last_generation_ = 0; // force a copy of the current frame
}
if (shared_ == nullptr || mutex_ == nullptr)
{
return srv_ != nullptr;
}
if (share.generation == last_generation_)
{
return srv_ != nullptr; // no new frame; keep showing the last copy
}
// Bounded wait so a stalled producer can't hang the host's render thread.
if (mutex_->AcquireSync(kVideoMutexKey, 8) == S_OK)
{
ctx_->CopyResource(private_.Get(), shared_.Get());
mutex_->ReleaseSync(kVideoMutexKey);
// Generations between the last copy and this one were published but never shown
// (we only ever copy the newest). last_generation_ == 0 is the first copy after a
// (re)open, where the gap to a large generation is meaningless, so skip it.
if (last_generation_ != 0 && share.generation > last_generation_ + 1)
{
frames_missed_ += share.generation - last_generation_ - 1;
}
last_generation_ = share.generation;
++frames_copied_;
}
return srv_ != nullptr;
}
} // namespace coop