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>
This commit is contained in:
2026-06-21 19:55:27 +02:00
parent efc16b5eea
commit ffaad6c4ae
10 changed files with 241 additions and 49 deletions

View File

@@ -93,9 +93,6 @@ for what's left.
`GetRawInputData`, e.g. Trails through Daybreak) or **DirectInput**
(`IDirectInputDevice8::GetDeviceState/GetDeviceData`) don't see it. Add hooks for
those paths to synthesize the forwarded input there too.
- **DX12 hooked-capture overhead.** The D3D11On12 bridge mirrors D3D12 games but is
noticeably heavier than the native D3D11 path (see Lessons learned); reducing its
per-frame cost (cache wrapped resources, lighter sync) is a follow-up.
## Building
@@ -280,6 +277,37 @@ Non-obvious things that cost time and constrain the design:
slot catches every instance; but `IAudioClient::GetService` is **14**, not 13
(`SetEventHandle` sits at 13 between `Reset` and `GetService`). Count every
inherited `IUnknown`/base method when adding a hook.
- **D3D12 capture copies the *rotating* back buffer, not `GetBuffer(0)`.** D3D11
flip-model keeps `GetBuffer(0)` pointing at the live back buffer, but D3D12 rotates
buffers explicitly — the game renders into the buffer at
`IDXGISwapChain3::GetCurrentBackBufferIndex()`, which advances each `Present`.
Grabbing buffer 0 copies a stale buffer on N-1 of every N frames, so the mirror
silently runs at refresh/N — yet the Present counter, published FPS, generation, and
latency all read full rate (they count Presents, not unique content), so the metrics
look perfect while the eye sees missing frames. Query the current index right before
the trampoline `Present` (that's the just-rendered buffer) and copy that one.
- **Keep the D3D12 capture copy off the game's present queue, but ordered after its
frame.** The D3D11 path copies on the game's immediate context, so it's naturally
ordered after the frame and on the game's own timeline. For D3D12 the D3D11On12
bridge needs a queue: running the copy on the *game's* present queue orders it
correctly but stalls the game's own 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. The fix is both: run the copy on **our own** queue, and order it with a
**fence** the game's present queue signals after its frame (a near-free op) and our
queue waits on. The present queue is recovered for late injection by hooking
`ID3D12CommandQueue::ExecuteCommandLists` (the per-frame method, not creation). Make
the producer-side `AcquireSync` non-blocking (`timeout 0`) so a busy mutex drops a
*mirror* frame instead of stalling the game; the Video panel's "Frames lost" line
surfaces both capture- and display-stage drops.
- **Capturing at `Present` decouples the mirror from DWM composition.** The hook copies
the backbuffer inside the game's `Present`, which the game issues at its true render
rate regardless of how DWM composites that *window*. So an unfocused game window can
judder (DWM under-composites background windows; only the focused window gets VRR /
independent flip) while the mirror — which receives every `Present` — stays smooth.
This is why the game window not being focused doesn't matter: it isn't the surface
anyone sees. The same focus rule explains why an unfocused *tool* window can render
below the game's rate (it loses VRR), so in use the mirror is the focused window.
- **SafetyHook on x86 has two traps that froze 32-bit Slaps and Beans.** (1)
`InlineHook::call()` invokes the trampoline as `__cdecl`, but most targets are
`__stdcall` (COM methods like `IDXGISwapChain::Present`, the WASAPI interfaces,