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:
26
README.md
26
README.md
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user