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:
34
README.md
34
README.md
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user