Vulkan capture: preserve the game's sync mode, drop the capture throttle

The capture must never change vsync, and must not frame-limit itself.

- Removed the ~150 Hz capture throttle from VkCapture. It was wrong: vsync already
  paces capture (a 144 Hz FIFO game presents 144x/s, so we mirror 144x/s). The only
  limiter left is ring backpressure (skip a frame if the reaper is behind), which is
  correctness, not a cap, and never touches the game's present thread or sync mode.
- The layer/hook already pass VkSwapchainCreateInfoKHR straight through, so the
  present mode (= the sync mode) is untouched. Added a presentMode log to prove it.

Measured on Sphere Spectacle (direct launch, layer attached): presentMode=2 (FIFO),
steady 144.0 fps, and with the throttle gone the mirror now tracks it at 144/s
(was capped ~130). The earlier 400-600 fps reading was a direct-launch artifact --
a non-foreground windowed FIFO app isn't throttled by DWM -- not the layer, and not
the case through Steam (144). vk_validate now states the mirror follows the present
rate (no throttle) and still asserts a present-rate floor.

Full suite 21/21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 08:48:44 +02:00
parent d4412ff610
commit 9fee13789d
5 changed files with 30 additions and 35 deletions

View File

@@ -110,10 +110,6 @@ default** and covers anything the hooked path doesn't.
### Current tasks ### Current tasks
- **The Vulkan capture must not change the swapchain's sync mode.** If the game vsyncs (e.g. FIFO at
144 Hz), it must still vsync with the layer/hook attached; if it didn't, it must not start. The
capture is forbidden from throttling or frame-limiting — that is vsync's job. Remove the ~150 Hz
capture throttle and prove (measure) the present mode/rate is identical with and without the layer.
- **DX12 capture is measurably slower than the other backends — find out why and improve it.** The - **DX12 capture is measurably slower than the other backends — find out why and improve it.** The
present-thread overhead guard shows DX12 ~0.34 ms vs DX11 ~0.05 ms / OpenGL ~0.09 ms (the D3D11On12 present-thread overhead guard shows DX12 ~0.34 ms vs DX11 ~0.05 ms / OpenGL ~0.09 ms (the D3D11On12
bridge). Investigate and reduce it. bridge). Investigate and reduce it.
@@ -133,7 +129,10 @@ rate while mirroring (guarded by `vk_capture_perf_test`; every GPU backend's hoo
present-thread overhead bound) — and the **inline-hook (suspended-inject) Vulkan path**, which now present-thread overhead bound) — and the **inline-hook (suspended-inject) Vulkan path**, which now
captures Sphere Spectacle correctly once the harness launches the exe with the right working captures Sphere Spectacle correctly once the harness launches the exe with the right working
directory and `vk_hook` intercepts present/swapchain resolved via `vkGetInstanceProcAddr` (not just directory and `vk_hook` intercepts present/swapchain resolved via `vkGetInstanceProcAddr` (not just
`vkGetDeviceProcAddr`). Both Vulkan paths are verified on the real game. See **Lessons learned** and `vkGetDeviceProcAddr`). Both Vulkan paths are verified on the real game. The capture also **preserves
the game's sync mode** — it passes the swapchain's present mode through untouched and no longer
throttles the mirror (vsync paces it): Sphere Spectacle requests `FIFO` and holds a steady 144 Hz
with the layer attached. See **Lessons learned** and
the test suite for each. Open directions: per-game profiles, multi-guest virtual-pad mapping, and the test suite for each. Open directions: per-game profiles, multi-guest virtual-pad mapping, and
continuous raw-mouse *movement* forwarding (the MKB event stream is position-based today). continuous raw-mouse *movement* forwarding (the MKB event stream is position-based today).
@@ -537,10 +536,19 @@ Non-obvious things that cost time and constrain the design:
cadence; a hard FPS gate is meaningless"), which hid a real **144→3 FPS** stall: the read-back ran cadence; a hard FPS gate is meaningless"), which hid a real **144→3 FPS** stall: the read-back ran
on the present thread and spent ~370 ms/frame doing a CPU read of write-combined staging memory. on the present thread and spent ~370 ms/frame doing a CPU read of write-combined staging memory.
After moving the read-back to a reaper thread (`coop::hook::VkCapture`), `present_calls` (counted After moving the read-back to a reaper thread (`coop::hook::VkCapture`), `present_calls` (counted
every present, independent of the throttled mirror) reflects the game's true rate, so the tool now every present) reflects the game's true rate, so the tool now **asserts** it stays healthy while
**asserts** it stays healthy while capturing. The mirror is deliberately throttled to ~150 Hz, so capturing. Lesson: a perf check must assert a bound — if you find yourself explaining why a number
`copied < present_calls` is expected, not a drop. Lesson: a perf check must assert a bound — if you is fine, make the test prove it.
find yourself explaining why a number is fine, make the test prove it. - **The capture must not touch the game's sync mode — and must not throttle itself.** The swapchain's
**present mode** *is* the sync mode (`FIFO` = vsync, `IMMEDIATE`/`MAILBOX` = off); `VkCapture` and
the layer pass `VkSwapchainCreateInfoKHR` straight through, so whatever the game asked for is what
it gets — proven by logging `ci->presentMode` (Sphere Spectacle = `FIFO`, and it holds a steady
144 Hz with the layer attached). An earlier ~150 Hz *capture* throttle was wrong and is removed:
capture follows the present rate, which **vsync** already paces (a 144 Hz FIFO game presents 144×/s,
so we mirror 144×/s). The only limiter left is ring backpressure — if the reaper can't keep up we
skip a frame rather than stall the game — which is correctness, not a frame cap. (A direct-launch
window that isn't composited/foreground can present uncapped because DWM doesn't throttle a
non-foreground windowed FIFO app — that's the OS, not us, and not the case under Steam.)
- **A render client that predates our injection has no knowable format — measure it.** - **A render client that predates our injection has no knowable format — measure it.**
We inject into already-running games, so we usually never see the game's We inject into already-running games, so we usually never see the game's
`IAudioClient::Initialize`; the render-hook then assumes the device mix format for that `IAudioClient::Initialize`; the render-hook then assumes the device mix format for that

View File

@@ -8,17 +8,6 @@
namespace coop::hook namespace coop::hook
{ {
namespace
{
double now_ms()
{
LARGE_INTEGER f, c;
QueryPerformanceFrequency(&f);
QueryPerformanceCounter(&c);
return 1000.0 * static_cast<double>(c.QuadPart) / static_cast<double>(f.QuadPart);
}
} // namespace
VkCapture::~VkCapture() VkCapture::~VkCapture()
{ {
shutdown(); shutdown();
@@ -340,11 +329,10 @@ bool VkCapture::present(VkImage image, VkFormat fmt, std::uint32_t w, std::uint3
{ {
return false; return false;
} }
const double t = now_ms(); // No time-based throttle here: capture follows the game's present rate, which vsync paces (if the
if (t - m_last_submit_ms < kMinCaptureIntervalMs) // game vsyncs at 144 Hz it presents 144x/s, so we capture 144x/s). The only limiter is ring
{ // backpressure below -- if the reaper can't keep up we skip a frame rather than block the game --
return false; // throttle: mirror at ~150 Hz, not the game's (possibly 400+) present rate // which is correctness, not a frame cap (it never stalls the present thread or touches sync mode).
}
// Pick a slot whose previous capture the reaper has finished. None free -> the reaper is behind, // Pick a slot whose previous capture the reaper has finished. None free -> the reaper is behind,
// so skip this frame (the game keeps its rate; the mirror just drops a frame). // so skip this frame (the game keeps its rate; the mirror just drops a frame).
int idx = -1; int idx = -1;
@@ -408,7 +396,6 @@ bool VkCapture::present(VkImage image, VkFormat fmt, std::uint32_t w, std::uint3
{ {
return false; return false;
} }
m_last_submit_ms = t;
s.w = w; s.w = w;
s.h = h; s.h = h;
s.fmt = fmt; s.fmt = fmt;

View File

@@ -146,11 +146,6 @@ private:
VkCommandPool m_pool = VK_NULL_HANDLE; VkCommandPool m_pool = VK_NULL_HANDLE;
Slot m_slots[kSlots]; Slot m_slots[kSlots];
int m_next = 0; int m_next = 0;
// Cap capture to ~150 Hz: a guest stream is at most the host's refresh (usually 60, at most 144),
// so mirroring every present of an uncapped 400+ FPS game is pure wasted reaper CPU. Throttling
// here -- not on the game -- keeps the mirror smooth while freeing the cores the game wants.
static constexpr double kMinCaptureIntervalMs = 1000.0 / 150.0;
double m_last_submit_ms = 0.0;
// --- D3D11 shared texture (reaper thread only) --- // --- D3D11 shared texture (reaper thread only) ---
ID3D11Device* m_d3d = nullptr; ID3D11Device* m_d3d = nullptr;

View File

@@ -422,9 +422,9 @@ int main(int argc, char** argv)
// Performance gate (this is the check the earlier version refused to make -- it reported the rate // Performance gate (this is the check the earlier version refused to make -- it reported the rate
// and rationalized it, which hid the 144->3 FPS stall). The capture runs off the present thread, // and rationalized it, which hid the 144->3 FPS stall). The capture runs off the present thread,
// so the game must keep a healthy present rate while we mirror. present_calls counts EVERY present // so the game must keep a healthy present rate while we mirror. The capture is NOT throttled --
// (not throttled); the mirror is intentionally throttled to ~150 Hz, so copied < present is normal // it follows the present rate, which vsync paces -- so the mirror rate tracks the present rate. A
// and not a drop. A present rate that collapses (the bug was ~3/s) fails here. // present rate that collapses (the bug was ~3/s) fails here.
if (alive && find_pid(L"sphere.exe") == pid) if (alive && find_pid(L"sphere.exe") == pid)
{ {
const std::uint64_t p0 = block->video.present_calls; const std::uint64_t p0 = block->video.present_calls;
@@ -437,7 +437,7 @@ int main(int argc, char** argv)
// so its delta is the true mirror rate even while we're just sleeping here. // so its delta is the true mirror rate even while we're just sleeping here.
const double mirror = static_cast<double>(g1 - g0) / 3.0; const double mirror = static_cast<double>(g1 - g0) / 3.0;
std::printf(" present rate while capturing = %.1f /s; mirror rate = %.1f /s (capture is off the " std::printf(" present rate while capturing = %.1f /s; mirror rate = %.1f /s (capture is off the "
"present thread + throttled)\n", "present thread; the mirror follows the present rate -- vsync paces it)\n",
fps, mirror); fps, mirror);
check(fps > 30.0, "game keeps a healthy present rate while capturing (no present-thread stall)"); check(fps > 30.0, "game keeps a healthy present rate while capturing (no present-thread stall)");
} }

View File

@@ -288,6 +288,11 @@ VKAPI_ATTR VkResult VKAPI_CALL layer_QueuePresentKHR(VkQueue queue, const VkPres
VKAPI_ATTR VkResult VKAPI_CALL layer_CreateSwapchainKHR(VkDevice device, const VkSwapchainCreateInfoKHR* ci, VKAPI_ATTR VkResult VKAPI_CALL layer_CreateSwapchainKHR(VkDevice device, const VkSwapchainCreateInfoKHR* ci,
const VkAllocationCallbacks* a, VkSwapchainKHR* out) const VkAllocationCallbacks* a, VkSwapchainKHR* out)
{ {
// Log the game's chosen present mode (= its sync mode) -- 0 IMMEDIATE, 1 MAILBOX, 2 FIFO (vsync),
// 3 FIFO_RELAXED. We pass `ci` straight through, so whatever the game asked for is what it gets;
// this proves the layer never changes vsync.
logvk("CreateSwapchain: presentMode=%d (0=IMMEDIATE 1=MAILBOX 2=FIFO 3=FIFO_RELAXED) %ux%u minImageCount=%u",
static_cast<int>(ci->presentMode), ci->imageExtent.width, ci->imageExtent.height, ci->minImageCount);
const VkResult r = g_real_create_swapchain(device, ci, a, out); const VkResult r = g_real_create_swapchain(device, ci, a, out);
if (g_active && r == VK_SUCCESS && out && g_get_swapchain_images) if (g_active && r == VK_SUCCESS && out && g_get_swapchain_images)
{ {