macOS Retina: sharp UI via high-DPI backing + ImGui dynamic glyph rasterization

This commit is contained in:
mchughalex 2026-07-11 03:51:27 +01:00
parent 700e7108c3
commit cd8ba3bd61
3 changed files with 133 additions and 32 deletions

View file

@ -75,6 +75,12 @@ class ImGuiDrawer : public WindowInputListener, public UIDrawer {
void SetupFontTexture(); void SetupFontTexture();
void SetupFonts();
// Services ImGui's texture create/update/destroy requests when
// ImGuiBackendFlags_RendererHasTextures is active.
void ProcessImGuiTextureRequests(ImDrawData* data);
void RenderDrawLists(ImDrawData* data, UIDrawContext& ui_draw_context); void RenderDrawLists(ImDrawData* data, UIDrawContext& ui_draw_context);
void AddDialogImpl(ImGuiDialog* dialog); void AddDialogImpl(ImGuiDialog* dialog);
@ -111,6 +117,9 @@ class ImGuiDrawer : public WindowInputListener, public UIDrawer {
// Resources specific to an immediate drawer - must be destroyed before // Resources specific to an immediate drawer - must be destroyed before
// detaching the presenter. // detaching the presenter.
std::unique_ptr<ImmediateTexture> font_texture_; std::unique_ptr<ImmediateTexture> font_texture_;
// Textures created on ImGui's request when ImGuiBackendFlags_RendererHasTextures
// is active (dynamic glyph rasterization); keyed back to ImGui via TexID.
std::vector<std::unique_ptr<ImmediateTexture>> imgui_managed_textures_;
// Bit mask of ImGui mouse buttons the drawer has seen pressed, used for // Bit mask of ImGui mouse buttons the drawer has seen pressed, used for
// window mouse capture bookkeeping (io.MouseDown can't be used - input is // window mouse capture bookkeeping (io.MouseDown can't be used - input is

View file

@ -130,39 +130,18 @@ void ImGuiDrawer::Initialize() {
// Windows. // Windows.
io.IniFilename = nullptr; io.IniFilename = nullptr;
// Setup the font glyphs. SetupFonts();
ImFontConfig font_config;
font_config.OversampleH = font_config.OversampleV = 1;
font_config.PixelSnapH = true;
static const ImWchar font_glyph_ranges[] = {
0x0020,
0x00FF, // Basic Latin + Latin Supplement
0,
};
io.Fonts->AddFontFromMemoryCompressedBase85TTF(kProggyTinyCompressedDataBase85, 10.0f,
&font_config, font_glyph_ranges);
#if REX_PLATFORM_WIN32 #if defined(__APPLE__)
// TODO(benvanik): jp font on other platforms? // Dialogs push runtime font sizes (PushFont(nullptr, 18..22)); the legacy
// https://github.com/Koruri/kibitaki looks really good, but is 1.5MiB. // prebaked atlas can only bitmap-scale its 10px bake to serve them, which
const char* jp_font_path = "C:\\Windows\\Fonts\\msgothic.ttc"; // blurs, doubly so under the Retina logical->physical magnification in
if (std::filesystem::exists(jp_font_path)) { // RenderDrawLists. With this flag glyphs rasterize on demand at the drawn
ImFontConfig jp_font_config; // size, and io.DisplayFramebufferScale (set per-frame in Draw) additionally
jp_font_config.MergeMode = true; // rasterizes them at display pixel density while layout stays logical.
jp_font_config.OversampleH = jp_font_config.OversampleV = 1; io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures;
jp_font_config.PixelSnapH = true;
jp_font_config.FontNo = 0;
io.Fonts->AddFontFromFileTTF(jp_font_path, 12.0f, &jp_font_config,
io.Fonts->GetGlyphRangesJapanese());
} else {
REXLOG_WARN("Unable to load Japanese font; JP characters will be boxes");
}
#endif #endif
if (font_setup_) {
font_setup_(io.Fonts);
}
auto& style = ImGui::GetStyle(); auto& style = ImGui::GetStyle();
style.ScrollbarRounding = 0; style.ScrollbarRounding = 0;
style.WindowRounding = 0; style.WindowRounding = 0;
@ -343,11 +322,53 @@ std::optional<ImGuiKey> ImGuiDrawer::VirtualKeyToImGuiKey(VirtualKey vkey) {
} }
} }
void ImGuiDrawer::SetupFonts() {
ImGuiIO& io = GetIO();
io.Fonts->Clear();
ImFontConfig font_config;
font_config.OversampleH = font_config.OversampleV = 1;
font_config.PixelSnapH = true;
static const ImWchar font_glyph_ranges[] = {
0x0020,
0x00FF, // Basic Latin + Latin Supplement
0,
};
io.Fonts->AddFontFromMemoryCompressedBase85TTF(kProggyTinyCompressedDataBase85, 10.0f,
&font_config, font_glyph_ranges);
#if REX_PLATFORM_WIN32
// TODO(benvanik): jp font on other platforms?
// https://github.com/Koruri/kibitaki looks really good, but is 1.5MiB.
const char* jp_font_path = "C:\\Windows\\Fonts\\msgothic.ttc";
if (std::filesystem::exists(jp_font_path)) {
ImFontConfig jp_font_config;
jp_font_config.MergeMode = true;
jp_font_config.OversampleH = jp_font_config.OversampleV = 1;
jp_font_config.PixelSnapH = true;
jp_font_config.FontNo = 0;
io.Fonts->AddFontFromFileTTF(jp_font_path, 12.0f, &jp_font_config,
io.Fonts->GetGlyphRangesJapanese());
} else {
REXLOG_WARN("Unable to load Japanese font; JP characters will be boxes");
}
#endif
if (font_setup_) {
font_setup_(io.Fonts);
}
}
void ImGuiDrawer::SetupFontTexture() { void ImGuiDrawer::SetupFontTexture() {
if (font_texture_ || !immediate_drawer_) { if (font_texture_ || !immediate_drawer_) {
return; return;
} }
ImGuiIO& io = GetIO(); ImGuiIO& io = GetIO();
if (io.BackendFlags & ImGuiBackendFlags_RendererHasTextures) {
// Textures are created on demand in ProcessImGuiTextureRequests; the
// legacy whole-atlas prebake must not run.
return;
}
unsigned char* pixels; unsigned char* pixels;
int width, height; int width, height;
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height); io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
@ -379,8 +400,20 @@ void ImGuiDrawer::SetImmediateDrawer(ImmediateDrawer* new_immediate_drawer) {
return; return;
} }
if (immediate_drawer_) { if (immediate_drawer_) {
GetIO().Fonts->TexID = ImTextureID{}; ImGuiIO& io = GetIO();
io.Fonts->TexID = ImTextureID{};
font_texture_.reset(); font_texture_.reset();
if (io.BackendFlags & ImGuiBackendFlags_RendererHasTextures) {
// Hand dynamic textures back to ImGui as destroyed so it re-requests
// creation from the next immediate drawer.
for (ImTextureData* tex : ImGui::GetPlatformIO().Textures) {
if (tex->TexID != ImTextureID_Invalid) {
tex->SetTexID(ImTextureID_Invalid);
tex->SetStatus(ImTextureStatus_Destroyed);
}
}
imgui_managed_textures_.clear();
}
} }
immediate_drawer_ = new_immediate_drawer; immediate_drawer_ = new_immediate_drawer;
if (immediate_drawer_) { if (immediate_drawer_) {
@ -405,6 +438,14 @@ void ImGuiDrawer::Draw(UIDrawContext& ui_draw_context) {
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
#if defined(__APPLE__)
// Retina: glyphs must rasterize at display pixel density to survive the
// logical->physical magnification in RenderDrawLists. Per-frame because the
// window can move between displays with different densities.
float fb_scale = float(window_->GetDpi()) / float(window_->GetMediumDpi());
io.DisplayFramebufferScale = ImVec2(fb_scale, fb_scale);
#endif
uint64_t current_frame_time_ticks = rex::chrono::Clock::QueryHostTickCount(); uint64_t current_frame_time_ticks = rex::chrono::Clock::QueryHostTickCount();
io.DeltaTime = io.DeltaTime =
float(double(current_frame_time_ticks - last_frame_time_ticks_) / frame_time_tick_frequency_); float(double(current_frame_time_ticks - last_frame_time_ticks_) / frame_time_tick_frequency_);
@ -458,9 +499,55 @@ void ImGuiDrawer::Draw(UIDrawContext& ui_draw_context) {
} }
} }
void ImGuiDrawer::ProcessImGuiTextureRequests(ImDrawData* data) {
if (!data->Textures) {
return;
}
for (ImTextureData* tex : *data->Textures) {
switch (tex->Status) {
case ImTextureStatus_WantCreate:
case ImTextureStatus_WantUpdates: {
// The ImmediateDrawer has no partial-update API - recreate the whole
// texture from ImGui's CPU-side copy. This only happens when new
// glyph sizes/densities are first drawn, then the atlas settles.
assert_true(tex->Format == ImTextureFormat_RGBA32);
auto texture = immediate_drawer_->CreateTexture(
uint32_t(tex->Width), uint32_t(tex->Height), ImmediateTextureFilter::kLinear, true,
static_cast<const uint8_t*>(tex->GetPixels()));
if (tex->TexID != ImTextureID_Invalid) {
auto* old_texture = reinterpret_cast<ImmediateTexture*>(tex->TexID);
std::erase_if(imgui_managed_textures_,
[old_texture](const auto& t) { return t.get() == old_texture; });
}
tex->SetTexID(reinterpret_cast<ImTextureID>(texture.get()));
tex->SetStatus(ImTextureStatus_OK);
imgui_managed_textures_.push_back(std::move(texture));
break;
}
case ImTextureStatus_WantDestroy: {
if (tex->UnusedFrames < 1) {
break;
}
auto* old_texture = reinterpret_cast<ImmediateTexture*>(tex->TexID);
std::erase_if(imgui_managed_textures_,
[old_texture](const auto& t) { return t.get() == old_texture; });
tex->SetTexID(ImTextureID_Invalid);
tex->SetStatus(ImTextureStatus_Destroyed);
break;
}
default:
break;
}
}
}
void ImGuiDrawer::RenderDrawLists(ImDrawData* data, UIDrawContext& ui_draw_context) { void ImGuiDrawer::RenderDrawLists(ImDrawData* data, UIDrawContext& ui_draw_context) {
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
if (io.BackendFlags & ImGuiBackendFlags_RendererHasTextures) {
ProcessImGuiTextureRequests(data);
}
immediate_drawer_->Begin(ui_draw_context, io.DisplaySize.x, io.DisplaySize.y); immediate_drawer_->Begin(ui_draw_context, io.DisplaySize.x, io.DisplaySize.y);
for (int i = 0; i < data->CmdListsCount; ++i) { for (int i = 0; i < data->CmdListsCount; ++i) {

View file

@ -27,7 +27,12 @@ constexpr uint32_t kDefaultDpi = 96;
constexpr uint32_t kDefaultCursorAutoHideMilliseconds = 1000; constexpr uint32_t kDefaultCursorAutoHideMilliseconds = 1000;
constexpr uint32_t kCursorAutoHideEvent = SDL_EVENT_USER + 1; constexpr uint32_t kCursorAutoHideEvent = SDL_EVENT_USER + 1;
REXCVAR_DEFINE_BOOL(sdl_high_pixel_density, !REX_PLATFORM_MAC, "UI/SDL", // Enabled on macOS: without a high-DPI backing the Metal drawable stays at
// logical point size and CoreAnimation upscales the whole window to the Retina
// panel, blurring everything (the settings UI most visibly). Internal 3D render
// cost is governed separately by the resolution-scale cvars, so this only makes
// the final composite and UI render at native panel resolution.
REXCVAR_DEFINE_BOOL(sdl_high_pixel_density, true, "UI/SDL",
"Request high-DPI backing pixels for SDL windows") "Request high-DPI backing pixels for SDL windows")
.lifecycle(rex::cvar::Lifecycle::kRequiresRestart); .lifecycle(rex::cvar::Lifecycle::kRequiresRestart);