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:
44
common/include/coop/dpi.hpp
Normal file
44
common/include/coop/dpi.hpp
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// DPI -> UI scale math for the host overlay, factored out of ImGui/window code so it's unit-testable
|
||||||
|
// without a live window. The host is per-monitor-DPI-aware (see wWinMain), so GetSystemMetrics and
|
||||||
|
// GetDpiForWindow report true pixels; we scale ImGui's font and style by this factor ourselves to keep
|
||||||
|
// the overlay the same physical size -- and crisp -- across display scalings instead of letting Windows
|
||||||
|
// bitmap-stretch a virtualized-resolution window.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <windows.h> // USER_DEFAULT_SCREEN_DPI (== 96, the 100%-scale baseline)
|
||||||
|
|
||||||
|
namespace coop
|
||||||
|
{
|
||||||
|
|
||||||
|
// ImGui's built-in default font (ProggyClean) rasterizes at this pixel size at 100% scale. Named once
|
||||||
|
// here so the DPI math scales from a single owned constant instead of a bare 13 sprinkled around.
|
||||||
|
inline constexpr float kBaseFontPx = 13.0f;
|
||||||
|
|
||||||
|
// Bound the derived scale: never shrink the overlay below 100% (a readability floor), and cap absurd
|
||||||
|
// values so a bogus DPI report can't blow the font atlas up to an enormous texture.
|
||||||
|
inline constexpr float kMinUiScale = 1.0f;
|
||||||
|
inline constexpr float kMaxUiScale = 8.0f;
|
||||||
|
|
||||||
|
// UI scale factor for a monitor DPI (dots per inch, as GetDpiForWindow / WM_DPICHANGED report it).
|
||||||
|
// 96 DPI == 100% == 1.0; 144 DPI (150%) == 1.5. A zero/unknown DPI falls back to the 96 baseline so
|
||||||
|
// callers never derive a zero-size font.
|
||||||
|
inline float dpi_scale_from(unsigned dpi)
|
||||||
|
{
|
||||||
|
if (dpi == 0)
|
||||||
|
{
|
||||||
|
dpi = USER_DEFAULT_SCREEN_DPI;
|
||||||
|
}
|
||||||
|
const float scale = static_cast<float>(dpi) / static_cast<float>(USER_DEFAULT_SCREEN_DPI);
|
||||||
|
return std::clamp(scale, kMinUiScale, kMaxUiScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-font pixel size for a monitor DPI: the base size scaled so glyphs rasterize crisp at the
|
||||||
|
// target size. Pair with ImGui::GetStyle().ScaleAllSizes(dpi_scale_from(dpi)) for spacing/padding.
|
||||||
|
inline float scaled_font_px(unsigned dpi)
|
||||||
|
{
|
||||||
|
return kBaseFontPx * dpi_scale_from(dpi);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace coop
|
||||||
@@ -334,6 +334,22 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
|
|||||||
self->resize_height_ = HIWORD(lparam);
|
self->resize_height_ = HIWORD(lparam);
|
||||||
}
|
}
|
||||||
return 0;
|
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:
|
case WM_SYSKEYDOWN:
|
||||||
// F10 is our screenshot key; ImGui already saw this message (handler runs above),
|
// 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.
|
// so swallow it here to stop DefWindowProc from flicking into Win32 menu mode.
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ public:
|
|||||||
return device_lost_reason_;
|
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
|
[[nodiscard]] HWND hwnd() const
|
||||||
{
|
{
|
||||||
return hwnd_;
|
return hwnd_;
|
||||||
@@ -91,6 +106,8 @@ private:
|
|||||||
bool resize_pending_ = false;
|
bool resize_pending_ = false;
|
||||||
UINT resize_width_ = 0;
|
UINT resize_width_ = 0;
|
||||||
UINT resize_height_ = 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;
|
bool device_lost_ = false;
|
||||||
HRESULT device_lost_reason_ = S_OK;
|
HRESULT device_lost_reason_ = S_OK;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <imgui_impl_dx11.h>
|
#include <imgui_impl_dx11.h>
|
||||||
#include <imgui_impl_win32.h>
|
#include <imgui_impl_win32.h>
|
||||||
|
|
||||||
|
#include "coop/dpi.hpp"
|
||||||
#include "coop/tool_paths.hpp"
|
#include "coop/tool_paths.hpp"
|
||||||
#include "ui/app_chrome.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);
|
ini_path_ = to_utf8(ini_w);
|
||||||
io.IniFilename = ini_path_.c_str();
|
io.IniFilename = ini_path_.c_str();
|
||||||
set_layout_persisted(had_layout);
|
set_layout_persisted(had_layout);
|
||||||
ImGui::StyleColorsDark();
|
|
||||||
|
|
||||||
if (!ImGui_ImplWin32_Init(hwnd))
|
if (!ImGui_ImplWin32_Init(hwnd))
|
||||||
{
|
{
|
||||||
@@ -65,9 +65,49 @@ bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* cont
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
initialized_ = true;
|
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;
|
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()
|
void ImGuiLayer::begin_frame()
|
||||||
{
|
{
|
||||||
ImGui_ImplDX11_NewFrame();
|
ImGui_ImplDX11_NewFrame();
|
||||||
|
|||||||
@@ -22,8 +22,18 @@ public:
|
|||||||
void begin_frame();
|
void begin_frame();
|
||||||
void end_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:
|
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;
|
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
|
// 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.
|
// layout .ini path must outlive the context. Empty until init() sets it.
|
||||||
std::string ini_path_;
|
std::string ini_path_;
|
||||||
|
|||||||
@@ -387,6 +387,13 @@ int run()
|
|||||||
|
|
||||||
while (window.pump_messages())
|
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
|
// 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,
|
// 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.
|
// 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)
|
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
|
// WGC requires an initialized apartment; multi-threaded suits the
|
||||||
// free-threaded frame pool.
|
// free-threaded frame pool.
|
||||||
winrt::init_apartment(winrt::apartment_type::multi_threaded);
|
winrt::init_apartment(winrt::apartment_type::multi_threaded);
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ add_executable(tool_paths_test tool_paths_test.cpp)
|
|||||||
target_link_libraries(tool_paths_test PRIVATE coop_common)
|
target_link_libraries(tool_paths_test PRIVATE coop_common)
|
||||||
add_test(NAME tool_paths_test COMMAND tool_paths_test)
|
add_test(NAME tool_paths_test COMMAND tool_paths_test)
|
||||||
|
|
||||||
|
# Unit test for the DPI -> UI scale math (baseline/known scalings, zero fallback, clamping, font px).
|
||||||
|
add_executable(dpi_test dpi_test.cpp)
|
||||||
|
target_link_libraries(dpi_test PRIVATE coop_common)
|
||||||
|
add_test(NAME dpi_test COMMAND dpi_test)
|
||||||
|
|
||||||
# Unit test for the audio mixer math (decode/sum/soft-clip/encode). Header-only.
|
# Unit test for the audio mixer math (decode/sum/soft-clip/encode). Header-only.
|
||||||
add_executable(audio_mix_test audio_mix_test.cpp)
|
add_executable(audio_mix_test audio_mix_test.cpp)
|
||||||
target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
||||||
@@ -388,6 +393,7 @@ coop_output_subdir(tests
|
|||||||
shared_memory_test
|
shared_memory_test
|
||||||
wav_test
|
wav_test
|
||||||
tool_paths_test
|
tool_paths_test
|
||||||
|
dpi_test
|
||||||
mkb_map_test
|
mkb_map_test
|
||||||
audio_mix_test
|
audio_mix_test
|
||||||
tone_analysis_test
|
tone_analysis_test
|
||||||
|
|||||||
52
tests/dpi_test.cpp
Normal file
52
tests/dpi_test.cpp
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Unit test for the DPI -> UI scale math (common/include/coop/dpi.hpp): the 96-DPI baseline, common
|
||||||
|
// Windows scalings, the zero-DPI fallback, clamping at both ends, and the default-font pixel-size
|
||||||
|
// derivation. Pure math -- no window, no ImGui -- so it's the reasonably-testable slice of the
|
||||||
|
// per-monitor DPI feature (the actual awareness + font rasterization are verified by hand).
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "coop/dpi.hpp"
|
||||||
|
|
||||||
|
using namespace coop;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int g_failures = 0;
|
||||||
|
void check(bool ok, const char* what)
|
||||||
|
{
|
||||||
|
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool approx(float a, float b)
|
||||||
|
{
|
||||||
|
return std::fabs(a - b) < 1e-4f;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
// Baseline and the common Windows display scalings.
|
||||||
|
check(approx(dpi_scale_from(96), 1.0f), "96 DPI -> 1.0 (100%)");
|
||||||
|
check(approx(dpi_scale_from(120), 1.25f), "120 DPI -> 1.25 (125%)");
|
||||||
|
check(approx(dpi_scale_from(144), 1.5f), "144 DPI -> 1.5 (150%)");
|
||||||
|
check(approx(dpi_scale_from(192), 2.0f), "192 DPI -> 2.0 (200%)");
|
||||||
|
|
||||||
|
// A zero / unknown DPI falls back to the 96 baseline (never 0 -> a zero-size font).
|
||||||
|
check(approx(dpi_scale_from(0), 1.0f), "0 DPI -> 1.0 fallback");
|
||||||
|
|
||||||
|
// Clamping: never below 100%, and capped at the max for an absurd report.
|
||||||
|
check(approx(dpi_scale_from(48), kMinUiScale), "48 DPI clamps up to the min UI scale");
|
||||||
|
check(approx(dpi_scale_from(100000), kMaxUiScale), "absurd DPI clamps to the max UI scale");
|
||||||
|
|
||||||
|
// Font size scales from the single named base constant, matching ImGui's 13px default at 100%.
|
||||||
|
check(approx(kBaseFontPx, 13.0f), "base font px is ImGui's 13px default");
|
||||||
|
check(approx(scaled_font_px(96), 13.0f), "font px at 100% == base");
|
||||||
|
check(approx(scaled_font_px(144), 19.5f), "font px at 150% == 13 * 1.5");
|
||||||
|
check(approx(scaled_font_px(0), kBaseFontPx), "font px at unknown DPI == base (fallback)");
|
||||||
|
|
||||||
|
std::printf(g_failures == 0 ? "PASS dpi_test\n" : "FAILED dpi_test (%d)\n", g_failures);
|
||||||
|
return g_failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user