diff --git a/README.md b/README.md index 7cabb56..86c1535 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,6 @@ default** and covers anything the hooked path doesn't. ### 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 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. @@ -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 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 -`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 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 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 - every present, independent of the throttled mirror) reflects the game's true rate, so the tool now - **asserts** it stays healthy while capturing. The mirror is deliberately throttled to ~150 Hz, so - `copied < present_calls` is expected, not a drop. Lesson: a perf check must assert a bound — if you - find yourself explaining why a number is fine, make the test prove it. + every present) reflects the game's true rate, so the tool now **asserts** it stays healthy while + capturing. Lesson: a perf check must assert a bound — if you 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.** 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 diff --git a/hook/src/vk_capture.cpp b/hook/src/vk_capture.cpp index 000db64..4fdf476 100644 --- a/hook/src/vk_capture.cpp +++ b/hook/src/vk_capture.cpp @@ -8,17 +8,6 @@ namespace coop::hook { -namespace -{ -double now_ms() -{ - LARGE_INTEGER f, c; - QueryPerformanceFrequency(&f); - QueryPerformanceCounter(&c); - return 1000.0 * static_cast(c.QuadPart) / static_cast(f.QuadPart); -} -} // namespace - VkCapture::~VkCapture() { shutdown(); @@ -340,11 +329,10 @@ bool VkCapture::present(VkImage image, VkFormat fmt, std::uint32_t w, std::uint3 { return false; } - const double t = now_ms(); - if (t - m_last_submit_ms < kMinCaptureIntervalMs) - { - return false; // throttle: mirror at ~150 Hz, not the game's (possibly 400+) present rate - } + // No time-based throttle here: capture follows the game's present rate, which vsync paces (if the + // 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 -- + // 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, // so skip this frame (the game keeps its rate; the mirror just drops a frame). int idx = -1; @@ -408,7 +396,6 @@ bool VkCapture::present(VkImage image, VkFormat fmt, std::uint32_t w, std::uint3 { return false; } - m_last_submit_ms = t; s.w = w; s.h = h; s.fmt = fmt; diff --git a/hook/src/vk_capture.hpp b/hook/src/vk_capture.hpp index 5146cf0..60ce9c3 100644 --- a/hook/src/vk_capture.hpp +++ b/hook/src/vk_capture.hpp @@ -146,11 +146,6 @@ private: VkCommandPool m_pool = VK_NULL_HANDLE; Slot m_slots[kSlots]; 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) --- ID3D11Device* m_d3d = nullptr; diff --git a/tools/vk_validate/main.cpp b/tools/vk_validate/main.cpp index 4a22446..c7d2e3c 100644 --- a/tools/vk_validate/main.cpp +++ b/tools/vk_validate/main.cpp @@ -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 // 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 - // (not throttled); the mirror is intentionally throttled to ~150 Hz, so copied < present is normal - // and not a drop. A present rate that collapses (the bug was ~3/s) fails here. + // so the game must keep a healthy present rate while we mirror. The capture is NOT throttled -- + // it follows the present rate, which vsync paces -- so the mirror rate tracks the present rate. A + // present rate that collapses (the bug was ~3/s) fails here. if (alive && find_pid(L"sphere.exe") == pid) { 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. const double mirror = static_cast(g1 - g0) / 3.0; 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); check(fps > 30.0, "game keeps a healthy present rate while capturing (no present-thread stall)"); } diff --git a/vk_layer/coop_vk_layer.cpp b/vk_layer/coop_vk_layer.cpp index c34f601..6b9b9bf 100644 --- a/vk_layer/coop_vk_layer.cpp +++ b/vk_layer/coop_vk_layer.cpp @@ -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, 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(ci->presentMode), ci->imageExtent.width, ci->imageExtent.height, ci->minImageCount); const VkResult r = g_real_create_swapchain(device, ci, a, out); if (g_active && r == VK_SUCCESS && out && g_get_swapchain_images) {