Make the host per-monitor DPI aware and scale the ImGui overlay

The window sized itself to GetSystemMetrics(SM_CXSCREEN/CYSCREEN) but the
process was DPI-unaware, so on a scaled display (e.g. 4K @ 150%) Windows
handed us a virtualized resolution and bitmap-stretched the whole window up
to native -- softening the mirror, which is the tool's entire point.

Declare per-monitor-v2 awareness at startup so GetSystemMetrics/GetDpiForWindow
report true pixels. That alone would shrink the fixed-13px ImGui overlay to
crisp-but-tiny, so pair it with UI scaling: rebuild the default-font atlas at a
DPI-scaled SizePixels (crisp at the target size, unlike FontGlobalScale's
bitmap stretch) and ScaleAllSizes() the style. Net: same physical size as
before, now sharp.

- common/include/coop/dpi.hpp: pure DPI->scale math (uses USER_DEFAULT_SCREEN_DPI
  and a named kBaseFontPx, not bare 96/13 literals), with a zero fallback and
  clamping. Unit-tested by tests/dpi_test.cpp.
- imgui_layer: apply_dpi() at init from GetDpiForWindow; set_dpi() for runtime
  changes (rebuild atlas + reset/scale style + invalidate the DX11 font texture).
- d3d11_window: latch WM_DPICHANGED (honor the suggested rect), expose
  take_dpi_change(); main loop polls it and calls imgui.set_dpi() between frames.

The DPI math is unit-tested; the actual awareness + font rasterization + live
WM_DPICHANGED rescale are verified by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:23:41 +02:00
parent e4aafa08db
commit d73b43ad0d
8 changed files with 198 additions and 1 deletions

View File

@@ -334,6 +334,22 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
self->resize_height_ = HIWORD(lparam);
}
return 0;
case WM_DPICHANGED:
// Per-monitor-v2: the DPI of the display we're on changed. Resize to the rect Windows suggests
// in lparam (its recommended handling; the resulting WM_SIZE repaints the swap chain via the
// deferred-resize path above), then latch the new DPI for the overlay to rescale its font/style.
if (self != nullptr)
{
if (const auto* suggested = reinterpret_cast<const RECT*>(lparam); suggested != nullptr)
{
SetWindowPos(hwnd, nullptr, suggested->left, suggested->top,
suggested->right - suggested->left, suggested->bottom - suggested->top,
SWP_NOZORDER | SWP_NOACTIVATE);
}
self->dpi_pending_ = true;
self->pending_dpi_ = HIWORD(wparam); // X and Y DPI are equal; HIWORD is the Y value
}
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.

View File

@@ -60,6 +60,21 @@ public:
return device_lost_reason_;
}
// If a WM_DPICHANGED arrived since the last call (the window moved to a different-DPI monitor, or
// the display scale changed at runtime), returns true and writes that monitor's DPI to `dpi`,
// clearing the pending flag; otherwise returns false. The overlay layer polls this to rescale its
// fonts/style. One-shot, mirroring the deferred-resize handling in pump_messages().
[[nodiscard]] bool take_dpi_change(unsigned& dpi)
{
if (!dpi_pending_)
{
return false;
}
dpi = pending_dpi_;
dpi_pending_ = false;
return true;
}
[[nodiscard]] HWND hwnd() const
{
return hwnd_;
@@ -91,6 +106,8 @@ private:
bool resize_pending_ = false;
UINT resize_width_ = 0;
UINT resize_height_ = 0;
bool dpi_pending_ = false; // set by WM_DPICHANGED, consumed by take_dpi_change()
unsigned pending_dpi_ = 0; // the monitor DPI reported alongside that WM_DPICHANGED
bool device_lost_ = false;
HRESULT device_lost_reason_ = S_OK;

View File

@@ -6,6 +6,7 @@
#include <imgui_impl_dx11.h>
#include <imgui_impl_win32.h>
#include "coop/dpi.hpp"
#include "coop/tool_paths.hpp"
#include "ui/app_chrome.hpp"
@@ -54,7 +55,6 @@ bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* cont
ini_path_ = to_utf8(ini_w);
io.IniFilename = ini_path_.c_str();
set_layout_persisted(had_layout);
ImGui::StyleColorsDark();
if (!ImGui_ImplWin32_Init(hwnd))
{
@@ -65,9 +65,49 @@ bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* cont
return false;
}
initialized_ = true;
// Scale the overlay (font + style) to the monitor the window opened on. The process is
// per-monitor-DPI-aware (set in wWinMain), so GetDpiForWindow returns that monitor's real DPI.
apply_dpi(GetDpiForWindow(hwnd));
return true;
}
void ImGuiLayer::apply_dpi(unsigned dpi)
{
const float scale = dpi_scale_from(dpi);
ImGuiIO& io = ImGui::GetIO();
// Rebuild the font atlas at the DPI-scaled pixel size so ImGui's built-in default font is
// rasterized crisp at the target size, rather than bitmap-stretched the way io.FontGlobalScale
// would leave it. scaled_font_px() scales the one named base-size constant.
io.Fonts->Clear();
ImFontConfig cfg;
cfg.SizePixels = scaled_font_px(dpi);
io.Fonts->AddFontDefault(&cfg);
// Reset to the base dark theme, then scale spacing/padding/border sizes to match. Resetting
// first keeps repeated DPI changes from compounding (ScaleAllSizes multiplies the style in place).
ImGui::StyleColorsDark();
ImGui::GetStyle().ScaleAllSizes(scale);
// Drop the DX11 backend's cached font texture so it rebuilds from the new atlas next frame. Before
// the first frame nothing is built yet, so this is a harmless no-op during init().
if (initialized_)
{
ImGui_ImplDX11_InvalidateDeviceObjects();
}
dpi_scale_ = scale;
}
void ImGuiLayer::set_dpi(unsigned dpi)
{
if (!initialized_ || dpi_scale_from(dpi) == dpi_scale_)
{
return; // not up yet, or the scale didn't actually change -- skip a needless atlas rebuild
}
apply_dpi(dpi);
}
void ImGuiLayer::begin_frame()
{
ImGui_ImplDX11_NewFrame();

View File

@@ -22,8 +22,18 @@ public:
void begin_frame();
void end_frame();
// Rescale the overlay's font + style to a monitor DPI (dots per inch). Called from the window's
// deferred WM_DPICHANGED handling when the window moves to a different-DPI monitor or the display
// scale changes at runtime. No-op before init() or when the resulting scale is unchanged.
void set_dpi(unsigned dpi);
private:
// Rebuild the font atlas at the DPI-scaled size and re-apply the scaled dark style. Used by both
// init() (first apply) and set_dpi() (runtime change).
void apply_dpi(unsigned dpi);
bool initialized_ = false;
float dpi_scale_ = 1.0f; // last-applied UI scale; guards set_dpi against redundant atlas rebuilds
// Backing storage for io.IniFilename (ImGui keeps the pointer, not a copy), so the
// layout .ini path must outlive the context. Empty until init() sets it.
std::string ini_path_;

View File

@@ -387,6 +387,13 @@ int run()
while (window.pump_messages())
{
// Rescale the overlay if the window changed DPI (moved monitors, or the display scale changed).
// pump_messages() latches the new DPI; apply it here, outside any in-progress ImGui frame.
if (unsigned new_dpi = 0; window.take_dpi_change(new_dpi))
{
imgui.set_dpi(new_dpi);
}
// When the operator enabled "Sync flip to game frames" (Hooked source), pace the
// whole iteration to the game: wait for the next published frame before rendering,
// then present without vsync so the flip lands in lockstep with the game.
@@ -539,6 +546,11 @@ int run()
int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int)
{
// Per-monitor DPI awareness (v2): make GetSystemMetrics / GetDpiForWindow report true pixels so the
// mirror renders at the display's native resolution instead of a virtualized one that Windows would
// bitmap-stretch (softening the image). We scale the ImGui overlay ourselves to stay readable. Must
// run before any window is created.
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
// WGC requires an initialized apartment; multi-threaded suits the
// free-threaded frame pool.
winrt::init_apartment(winrt::apartment_type::multi_threaded);