Add F10 back-buffer screenshot (PNG, focus-independent)

Capture the rendered back buffer to a timestamped PNG next to the exe via
WIC, triggered by F10 (delivered even when unfocused). The capture runs in
render_frame just before Present so it includes the ImGui overlay, and reads
off the GPU so it works regardless of window focus, z-order, or occlusion. A
brief toast confirms the save (drawn the next frame, so it's never in the shot).
F10 chosen to avoid Steam's F12; its WM_SYSKEYDOWN is swallowed so Windows
doesn't enter menu mode. Documented in the Help menu + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 00:58:38 +02:00
parent 31db2d82c6
commit f72da74f78
5 changed files with 203 additions and 5 deletions

View File

@@ -1,5 +1,7 @@
#include "d3d11_window.hpp"
#include <wincodec.h> // WIC PNG encoder for screenshots
#include <imgui_impl_win32.h>
// Forward declared in the ImGui Win32 backend; lets ImGui consume input first.
@@ -13,7 +15,56 @@ namespace coop
namespace
{
constexpr wchar_t kWindowClass[] = L"CoopAllTheThingsWindow";
// Encode a tightly-packed/row-pitched RGBA8 image to a PNG file via WIC. `src` is the
// CPU-mapped staging copy of the back buffer (stride = row_pitch). Pure COM, so no
// extra link dependency. Returns false on any failure (caller swallows it -- a missed
// screenshot is never fatal).
bool write_rgba8_png(const std::wstring& path, UINT width, UINT height, const BYTE* src, UINT row_pitch)
{
ComPtr<IWICImagingFactory> factory;
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(factory.GetAddressOf()))))
{
return false;
}
ComPtr<IWICBitmap> bitmap; // wrap the back-buffer bytes (RGBA, matches the swap chain)
if (FAILED(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppRGBA, row_pitch,
row_pitch * height, const_cast<BYTE*>(src),
bitmap.GetAddressOf())))
{
return false;
}
ComPtr<IWICStream> stream;
if (FAILED(factory->CreateStream(stream.GetAddressOf())) ||
FAILED(stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE)))
{
return false;
}
ComPtr<IWICBitmapEncoder> encoder;
if (FAILED(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf())) ||
FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache)))
{
return false;
}
ComPtr<IWICBitmapFrameEncode> frame;
ComPtr<IPropertyBag2> props;
if (FAILED(encoder->CreateNewFrame(frame.GetAddressOf(), props.GetAddressOf())) ||
FAILED(frame->Initialize(props.Get())) || FAILED(frame->SetSize(width, height)))
{
return false;
}
// Let the encoder pick its native pixel format; WriteSource converts our RGBA to it.
WICPixelFormatGUID fmt = GUID_WICPixelFormat32bppBGRA;
frame->SetPixelFormat(&fmt);
if (FAILED(frame->WriteSource(bitmap.Get(), nullptr)) || FAILED(frame->Commit()) ||
FAILED(encoder->Commit()))
{
return false;
}
return true;
}
} // namespace
D3D11Window::~D3D11Window()
{
@@ -174,11 +225,69 @@ void D3D11Window::render_frame(const RenderCallback& render, UINT sync_interval)
render();
}
// Screenshot (F10): capture after the overlay is drawn but before Present -- the
// flip-model back buffer is undefined once presented.
if (!pending_screenshot_.empty())
{
if (save_backbuffer_png(pending_screenshot_))
{
saved_screenshot_ = pending_screenshot_;
}
pending_screenshot_.clear();
}
// sync_interval 1 (default) vsyncs to the monitor; 0 presents immediately so the
// caller can pace the flip itself (frame-sync to the game's published frames).
swap_chain_->Present(sync_interval, 0);
}
void D3D11Window::request_screenshot(std::wstring path)
{
pending_screenshot_ = std::move(path);
}
std::wstring D3D11Window::take_screenshot_result()
{
std::wstring result;
result.swap(saved_screenshot_);
return result;
}
bool D3D11Window::save_backbuffer_png(const std::wstring& path)
{
ComPtr<ID3D11Texture2D> back;
if (FAILED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back.GetAddressOf()))))
{
return false;
}
D3D11_TEXTURE2D_DESC desc{};
back->GetDesc(&desc);
// A CPU-readable staging copy we can Map (the back buffer itself isn't readable).
D3D11_TEXTURE2D_DESC staging = desc;
staging.Usage = D3D11_USAGE_STAGING;
staging.BindFlags = 0;
staging.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
staging.MiscFlags = 0;
ComPtr<ID3D11Texture2D> cpu;
if (FAILED(device_->CreateTexture2D(&staging, nullptr, cpu.GetAddressOf())))
{
return false;
}
context_->CopyResource(cpu.Get(), back.Get());
D3D11_MAPPED_SUBRESOURCE map{};
if (FAILED(context_->Map(cpu.Get(), 0, D3D11_MAP_READ, 0, &map)))
{
return false;
}
// The swap chain is DXGI_FORMAT_R8G8B8A8_UNORM (see create_device), i.e. RGBA bytes.
const bool ok =
write_rgba8_png(path, desc.Width, desc.Height, static_cast<const BYTE*>(map.pData), map.RowPitch);
context_->Unmap(cpu.Get(), 0);
return ok;
}
LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
if (msg == WM_NCCREATE)
@@ -204,6 +313,15 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
self->resize_height_ = HIWORD(lparam);
}
return 0;
case WM_SYSKEYDOWN:
// F10 is our screenshot key; ImGui already saw this message (handler runs above),
// so swallow it here to stop DefWindowProc from flicking into Win32 menu mode.
// Alt+F4 (VK_F4) falls through to DefWindowProc so it still closes the window.
if (wparam == VK_F10)
{
return 0;
}
break;
case WM_DESTROY:
// Reached by the close button, Alt+F4 (DefWindowProc turns it into WM_CLOSE ->
// DestroyWindow), and our own teardown. Esc deliberately does NOT quit -- it's a