Against Sphere Spectacle (144 FPS, runs without Steam) the implicit-layer
capture dropped the game to ~3 FPS. Measured cause (per-stage trace in the
layer): the read-back ran on the game's PRESENT THREAD and spent ~370 ms per
1080p frame -- not the GPU copy (~2 ms) but the CPU swizzle, because the staging
buffer was a plain HOST_VISIBLE|HOST_COHERENT type (write-combined / uncached on
a discrete GPU), where a scattered CPU read runs at PCIe latency. 3 captures/s =
the 3 FPS the user saw.
Test-first: tests/vk_capture_perf_test reproduces the stall as a deterministic
unit test (372 ms/present, ratio 1.0 -> FAIL via `--sync`), then proves the fix
(0.02 ms/present, byte-correct BGRA->RGBA, ratio ~0 -> PASS).
Fix: extract the near-identical read-back from vk_hook.cpp and coop_vk_layer.cpp
into one shared coop::hook::VkCapture that:
* has the present thread only record + submit the copy (sub-ms) and return;
* runs a dedicated reaper thread for the fence wait + swizzle + D3D upload, off
the critical path, with a ring of in-flight slots (game never waits);
* allocates HOST_CACHED staging (fast CPU read), invalidating when non-coherent;
* throttles capture to ~150 Hz (a guest stream is <= the host refresh; no point
mirroring an uncapped 400+ FPS game and burning reaper CPU).
Real-game A/B: present rate now matches the no-capture baseline (605->470 vs
593->405 over the same ramp) with the mirror at ~130 fps -- no measurable impact.
Also adds present-thread overhead guards to the other GPU backends' hook tests
(present_overhead.hpp): DX11 0.05 ms, DX12 0.34 ms, OpenGL 0.09 ms overhead, all
asserted < one 60 Hz frame, so any future synchronous-stall regression fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
223 lines
7.3 KiB
C++
223 lines
7.3 KiB
C++
// In-process self-test for the OpenGL capture path (hook/src/opengl_hook.cpp).
|
|
// Plays both "game" and "host": installs the swap hooks, creates a real OpenGL
|
|
// context, clears the backbuffer to a known color, and calls SwapBuffers. With
|
|
// the hook live that must (1) fire the SwapBuffers detour, (2) glReadPixels the
|
|
// backbuffer and upload it into the shared keyed-mutex texture, and (3) publish
|
|
// the descriptor over IPC. A second D3D device then opens the shared texture by
|
|
// name and reads it back, proving the OpenGL->shared-texture path end to end --
|
|
// no game, no Steam.
|
|
//
|
|
// Needs an OpenGL context + a D3D11 device; reports SKIP and exits 0 if either
|
|
// is unavailable (headless CI), mirroring present_hook_test.
|
|
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
|
|
#include <windows.h>
|
|
|
|
#include <gl/GL.h>
|
|
|
|
#include <d3d11_1.h>
|
|
#include <dxgi1_2.h>
|
|
|
|
#include "coop/protocol.hpp"
|
|
#include "coop/shared_memory.hpp"
|
|
#include "ipc_client.hpp"
|
|
#include "opengl_hook.hpp"
|
|
#include "present_overhead.hpp"
|
|
|
|
using namespace coop;
|
|
|
|
namespace
|
|
{
|
|
int g_failures = 0;
|
|
void check(bool ok, const char* what)
|
|
{
|
|
if (!ok)
|
|
{
|
|
std::printf(" FAIL: %s\n", what);
|
|
++g_failures;
|
|
}
|
|
}
|
|
template <typename T>
|
|
void release(T*& p)
|
|
{
|
|
if (p)
|
|
{
|
|
p->Release();
|
|
p = nullptr;
|
|
}
|
|
}
|
|
constexpr int kW = 64;
|
|
constexpr int kH = 64;
|
|
bool near_byte(std::uint8_t got, int expected)
|
|
{
|
|
return std::abs(static_cast<int>(got) - expected) <= 3;
|
|
}
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
// --- Host side: SharedBlock so the hook's IpcClient connects. ---
|
|
SharedMemory shm;
|
|
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
|
{
|
|
std::printf("FAIL: create shared memory\n");
|
|
return 1;
|
|
}
|
|
auto* block = shm.as<SharedBlock>();
|
|
block->version = kProtocolVersion;
|
|
block->sequence.store(0, std::memory_order_relaxed);
|
|
block->magic = kProtocolMagic;
|
|
|
|
hook::IpcClient ipc;
|
|
check(ipc.connect(10, 5), "IPC client connect");
|
|
|
|
if (!hook::install_opengl_hooks(ipc))
|
|
{
|
|
std::printf("SKIP: could not install the OpenGL swap hooks\n");
|
|
return 0;
|
|
}
|
|
|
|
// --- Game side: a real OpenGL context on a hidden window. ---
|
|
WNDCLASSEXW wc{};
|
|
wc.cbSize = sizeof(wc);
|
|
wc.lpfnWndProc = DefWindowProcW;
|
|
wc.hInstance = GetModuleHandleW(nullptr);
|
|
wc.lpszClassName = L"coop_gl_test";
|
|
RegisterClassExW(&wc);
|
|
// WS_POPUP so the client area is exactly kW x kH (an overlapped window can't
|
|
// shrink below its minimum caption size, which would skew the captured dims).
|
|
HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_POPUP, 0, 0, kW, kH, nullptr, nullptr,
|
|
wc.hInstance, nullptr);
|
|
ShowWindow(hwnd, SW_SHOWNOACTIVATE); // a mapped window makes the backbuffer reliable
|
|
HDC hdc = GetDC(hwnd);
|
|
|
|
PIXELFORMATDESCRIPTOR pfd{};
|
|
pfd.nSize = sizeof(pfd);
|
|
pfd.nVersion = 1;
|
|
pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER;
|
|
pfd.iPixelType = PFD_TYPE_RGBA;
|
|
pfd.cColorBits = 32;
|
|
const int pf = ChoosePixelFormat(hdc, &pfd);
|
|
HGLRC glrc = nullptr;
|
|
if (pf == 0 || !SetPixelFormat(hdc, pf, &pfd) || (glrc = wglCreateContext(hdc)) == nullptr ||
|
|
!wglMakeCurrent(hdc, glrc))
|
|
{
|
|
std::printf("SKIP: could not create an OpenGL context on this machine\n");
|
|
hook::remove_opengl_hooks();
|
|
return 0;
|
|
}
|
|
|
|
// Clear the backbuffer to a known color, then SwapBuffers (fires the detour).
|
|
for (int frame = 0; frame < 3; ++frame)
|
|
{
|
|
glViewport(0, 0, kW, kH);
|
|
glClearColor(0.20f, 0.40f, 0.60f, 1.0f); // -> ~{51,102,153,255}
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
glFinish();
|
|
SwapBuffers(hdc);
|
|
}
|
|
|
|
std::printf("swaps=%llu frames_shared=%llu video{gen=%u %ux%u fmt=%u}\n",
|
|
static_cast<unsigned long long>(hook::opengl_swaps()),
|
|
static_cast<unsigned long long>(hook::opengl_frames_shared()), block->video.generation.load(),
|
|
block->video.width, block->video.height, block->video.format);
|
|
|
|
check(hook::opengl_swaps() >= 3, "SwapBuffers detour fired");
|
|
check(hook::opengl_frames_shared() > 0, "backbuffer uploaded into the shared texture");
|
|
check(block->video.generation.load() > 0, "video generation published to IPC");
|
|
check(block->video.width == kW && block->video.height == kH, "shared dimensions published");
|
|
|
|
// --- Consumer: open the shared texture by name, copy to staging, verify color.
|
|
if (hook::opengl_frames_shared() > 0)
|
|
{
|
|
ID3D11Device* devB = nullptr;
|
|
ID3D11DeviceContext* ctxB = nullptr;
|
|
if (SUCCEEDED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0,
|
|
D3D11_SDK_VERSION, &devB, nullptr, &ctxB)))
|
|
{
|
|
ID3D11Device1* dev1 = nullptr;
|
|
devB->QueryInterface(IID_PPV_ARGS(&dev1));
|
|
const std::wstring name = video_share_name(GetCurrentProcessId());
|
|
ID3D11Texture2D* sharedB = nullptr;
|
|
IDXGIKeyedMutex* km = nullptr;
|
|
if (dev1 != nullptr &&
|
|
SUCCEEDED(dev1->OpenSharedResourceByName(name.c_str(),
|
|
DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
|
|
IID_PPV_ARGS(&sharedB))))
|
|
{
|
|
sharedB->QueryInterface(IID_PPV_ARGS(&km));
|
|
D3D11_TEXTURE2D_DESC sd{};
|
|
sharedB->GetDesc(&sd);
|
|
sd.Usage = D3D11_USAGE_STAGING;
|
|
sd.BindFlags = 0;
|
|
sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
|
|
sd.MiscFlags = 0;
|
|
ID3D11Texture2D* staging = nullptr;
|
|
devB->CreateTexture2D(&sd, nullptr, &staging);
|
|
if (km != nullptr && staging != nullptr && km->AcquireSync(kVideoMutexKey, 1000) == S_OK)
|
|
{
|
|
ctxB->CopyResource(staging, sharedB);
|
|
km->ReleaseSync(kVideoMutexKey);
|
|
D3D11_MAPPED_SUBRESOURCE m{};
|
|
if (SUCCEEDED(ctxB->Map(staging, 0, D3D11_MAP_READ, 0, &m)))
|
|
{
|
|
const auto* px = static_cast<const std::uint8_t*>(m.pData);
|
|
std::printf("readback pixel0 = {%u,%u,%u,%u}\n", px[0], px[1], px[2], px[3]);
|
|
check(near_byte(px[0], 51) && near_byte(px[1], 102) && near_byte(px[2], 153),
|
|
"shared texture carries the rendered color");
|
|
ctxB->Unmap(staging, 0);
|
|
}
|
|
else
|
|
{
|
|
check(false, "map staging texture");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
check(false, "acquire keyed mutex + copy shared texture");
|
|
}
|
|
release(staging);
|
|
}
|
|
else
|
|
{
|
|
check(false, "open shared texture by name");
|
|
}
|
|
release(km);
|
|
release(sharedB);
|
|
release(dev1);
|
|
}
|
|
release(ctxB);
|
|
release(devB);
|
|
}
|
|
|
|
// --- Performance regression guard: the SwapBuffers hook's glReadPixels read-back must not stall
|
|
// the present thread (measure SwapBuffers with the hook live vs. removed). ---
|
|
{
|
|
auto render = [&] {
|
|
glViewport(0, 0, kW, kH);
|
|
glClearColor(0.20f, 0.40f, 0.60f, 1.0f);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
glFinish();
|
|
};
|
|
auto present = [&] { SwapBuffers(hdc); };
|
|
const double hooked = cooptest::avg_present_ms(120, render, present);
|
|
hook::remove_opengl_hooks(); // baseline: same context, hook removed
|
|
const double base = cooptest::avg_present_ms(120, render, present);
|
|
std::printf("present-thread: hooked %.3f ms, unhooked %.3f ms, capture overhead %.3f ms\n", hooked, base,
|
|
hooked - base);
|
|
check(hooked - base < cooptest::kPresentOverheadBudgetMs,
|
|
"OpenGL capture stays off the present thread (overhead < one 60 Hz frame)");
|
|
}
|
|
|
|
wglMakeCurrent(nullptr, nullptr);
|
|
wglDeleteContext(glrc);
|
|
ReleaseDC(hwnd, hdc);
|
|
DestroyWindow(hwnd);
|
|
UnregisterClassW(wc.lpszClassName, wc.hInstance);
|
|
|
|
std::printf(g_failures == 0 ? "OPENGL HOOK TEST PASS\n" : "OPENGL HOOK TEST FAILED (%d)\n", g_failures);
|
|
return g_failures == 0 ? 0 : 1;
|
|
}
|