perf(typography): use font blobs in place and share identical faces

Typography copied every font blob into a fresh heap buffer for each face
it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu
weight, the CJK subset merge, the emoji merge, and the icon and pickaxe
fonts. The blobs are INCBIN symbols in read-only memory that live for
the whole process, so the copies (16 faces, each with its own CJK copy
and, for 8 of them, an emoji copy) only cost memory. Styles with the
same weight and size also built separate, identical ImFonts.

Now:
- every font is added straight from the embedded data with
  FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so
  the blobs survive atlas clears and DPI or skin reloads;
- load() walks a style table and shares the ImFont between styles with
  the same (weight, scaled size, emoji) key. Emoji is part of the key,
  so no style gains or loses the emoji fallback. With the current
  ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13
  ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times
  instead of 16 and emoji 7 times instead of 8.

Measured on the Lite build, RssAnon 15 s after start, median of 3 runs:
- color emoji: 108.6 -> 78.8 MiB (-29.8 MiB);
- monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB).

RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read
end to end for copying. Full screenshot sweeps (610 screens per run; color
and mono emoji at 1280x720 in English and Japanese, color at 2560x1440)
match the previous build within run-to-run noise. The only content
differences are live values such as the mining tab's RAM readout (233 MB
before, 201 MB after).

Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed
the owned copy and the caller freed it again, a latent double free;
those IM_FREE calls are gone. The emoji-merge failure log now prints the
blob size instead of passing the float font size to %u.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 06:05:07 -05:00
co-authored by Claude Opus 5.5
parent 1f162ec830
commit e0f9d36780
2 changed files with 50 additions and 96 deletions
+49 -95
View File
@@ -151,64 +151,40 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
// For ImGui, we need to load fonts at specific pixel sizes. // For ImGui, we need to load fonts at specific pixel sizes.
// Font sizes come from Layout:: accessors (backed by UISchema JSON) // Font sizes come from Layout:: accessors (backed by UISchema JSON)
// Load fonts in order of TypeStyle enum // Style table (order == TypeStyle enum). `emoji` = the styles that have always received the emoji merge.
struct StyleDef { int weight; float px; const char* name; bool emoji; };
// H1: Light const StyleDef defs[kNumStyles] = {
fonts_[0] = loadFont(io, kWeightLight, Layout::kFontH1() * scale, "H1"); { kWeightLight, Layout::kFontH1(), "H1", false },
{ kWeightLight, Layout::kFontH2(), "H2", false },
// H2: Light { kWeightRegular, Layout::kFontH3(), "H3", false },
fonts_[1] = loadFont(io, kWeightLight, Layout::kFontH2() * scale, "H2"); { kWeightRegular, Layout::kFontH4(), "H4", false },
{ kWeightRegular, Layout::kFontH5(), "H5", false },
// H3: Regular { kWeightMedium, Layout::kFontH6(), "H6", false },
fonts_[2] = loadFont(io, kWeightRegular, Layout::kFontH3() * scale, "H3"); { kWeightRegular, Layout::kFontSubtitle1(), "Subtitle1", true },
{ kWeightMedium, Layout::kFontSubtitle2(), "Subtitle2", true },
// H4: Regular { kWeightRegular, Layout::kFontBody1(), "Body1", true },
fonts_[3] = loadFont(io, kWeightRegular, Layout::kFontH4() * scale, "H4"); { kWeightRegular, Layout::kFontBody2(), "Body2", true },
{ kWeightMedium, Layout::kFontButton(), "Button", true },
// H5: Regular { kWeightRegular, Layout::kFontCaption(), "Caption", true },
fonts_[4] = loadFont(io, kWeightRegular, Layout::kFontH5() * scale, "H5"); { kWeightRegular, Layout::kFontOverline(), "Overline", true },
{ kWeightMedium, Layout::kFontButtonSm(), "ButtonSm", true },
// H6: Medium { kWeightMedium, Layout::kFontButtonLg(), "ButtonLg", false },
fonts_[5] = loadFont(io, kWeightMedium, Layout::kFontH6() * scale, "H6"); };
static_assert(sizeof(fonts_) / sizeof(fonts_[0]) == kNumStyles, "style table must cover every TypeStyle");
// Subtitle1: Regular // Styles with the same (weight, scaled size, emoji) would build byte-identical ImFonts: load once and share
fonts_[6] = loadFont(io, kWeightRegular, Layout::kFontSubtitle1() * scale, "Subtitle1"); // the pointer. emoji is part of the key so no style gains or loses the emoji fallback (identical rendering).
// loadFont() adds the base face and its merges back-to-back (MergeMode attaches to Fonts.back()).
// Subtitle2: Medium for (int i = 0; i < kNumStyles; ++i) {
fonts_[7] = loadFont(io, kWeightMedium, Layout::kFontSubtitle2() * scale, "Subtitle2"); const float px = defs[i].px * scale;
int first = -1;
// Body1: Regular (shares with Subtitle1 if same size) for (int j = 0; j < i; ++j)
if (Layout::kFontBody1() == Layout::kFontSubtitle1()) { if (defs[j].weight == defs[i].weight && defs[j].px * scale == px && defs[j].emoji == defs[i].emoji) { first = j; break; }
fonts_[8] = fonts_[6]; // Reuse Subtitle1 font fonts_[i] = (first >= 0) ? fonts_[first] : loadFont(io, defs[i].weight, px, defs[i].name, defs[i].emoji);
} else {
fonts_[8] = loadFont(io, kWeightRegular, Layout::kFontBody1() * scale, "Body1");
} }
// Body2: Regular
fonts_[9] = loadFont(io, kWeightRegular, Layout::kFontBody2() * scale, "Body2");
// Button: Medium (shares with Subtitle2 if same size)
if (Layout::kFontButton() == Layout::kFontSubtitle2()) {
fonts_[10] = fonts_[7]; // Reuse Subtitle2 font
} else {
fonts_[10] = loadFont(io, kWeightMedium, Layout::kFontButton() * scale, "Button");
}
// Caption: Regular
fonts_[11] = loadFont(io, kWeightRegular, Layout::kFontCaption() * scale, "Caption");
// Overline: Regular
fonts_[12] = loadFont(io, kWeightRegular, Layout::kFontOverline() * scale, "Overline");
// ButtonSm: Medium
fonts_[13] = loadFont(io, kWeightMedium, Layout::kFontButtonSm() * scale, "ButtonSm");
// ButtonLg: Medium
fonts_[14] = loadFont(io, kWeightMedium, Layout::kFontButtonLg() * scale, "ButtonLg");
// Monospace (Ubuntu Mono) at caption size — used by the console so terminal text + // Monospace (Ubuntu Mono) at caption size — used by the console so terminal text +
// pretty-printed JSON columns align. Same size as Caption to keep line heights matched. // pretty-printed JSON columns align. Same size as Caption to keep line heights matched.
mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono"); mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono", false);
// --- Icon fonts at multiple sizes --- // --- Icon fonts at multiple sizes ---
// These are standalone fonts (not merged) so we can PushFont(iconXxx) for icon rendering. // These are standalone fonts (not merged) so we can PushFont(iconXxx) for icon rendering.
@@ -247,7 +223,7 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
return allLoaded; return allLoaded;
} }
ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* name) ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* name, bool wantEmoji)
{ {
// Select font data based on weight // Select font data based on weight
const unsigned char* fontData = nullptr; const unsigned char* fontData = nullptr;
@@ -273,12 +249,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
break; break;
} }
// ImGui needs to own a copy of the font data // Zero-copy: the INCBIN blob is immutable .rodata that outlives the atlas (ImGui 1.92 requires the data to
void* fontDataCopy = IM_ALLOC(fontDataLen); // persist for the atlas lifetime); FontDataOwnedByAtlas=false means ImGui never frees or writes it.
memcpy(fontDataCopy, fontData, fontDataLen);
ImFontConfig cfg; ImFontConfig cfg;
cfg.FontDataOwnedByAtlas = true; // ImGui will free this cfg.FontDataOwnedByAtlas = false;
cfg.OversampleH = 2; cfg.OversampleH = 2;
cfg.OversampleV = 1; cfg.OversampleV = 1;
cfg.PixelSnapH = true; cfg.PixelSnapH = true;
@@ -306,18 +280,15 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
weight == kWeightMono ? "Mono" : "Regular"; weight == kWeightMono ? "Mono" : "Regular";
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx", weightName, size); snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx", weightName, size);
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, fontDataLen, size, &cfg); ImFont* font = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(fontData), static_cast<int>(fontDataLen), size, &cfg);
if (font) { if (font) {
DEBUG_LOGF("Typography: Loaded %s (%.0fpx) as '%s'\n", name, size, cfg.Name); DEBUG_LOGF("Typography: Loaded %s (%.0fpx) as '%s'\n", name, size, cfg.Name);
// Merge CJK fallback glyphs (Chinese/Japanese/Korean) from subset font // Merge CJK fallback glyphs (Chinese/Japanese/Korean) from subset font
if (g_noto_cjk_subset_size > 0) { if (g_noto_cjk_subset_size > 0) {
void* cjkCopy = IM_ALLOC(g_noto_cjk_subset_size);
memcpy(cjkCopy, g_noto_cjk_subset_data, g_noto_cjk_subset_size);
ImFontConfig cjkCfg; ImFontConfig cjkCfg;
cjkCfg.FontDataOwnedByAtlas = true; cjkCfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
cjkCfg.MergeMode = true; // merge into the font we just loaded cjkCfg.MergeMode = true; // merge into the font we just loaded
cjkCfg.OversampleH = 1; cjkCfg.OversampleH = 1;
cjkCfg.OversampleV = 1; cjkCfg.OversampleV = 1;
@@ -338,7 +309,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
cjkCfg.GlyphRanges = cjkRanges; cjkCfg.GlyphRanges = cjkRanges;
snprintf(cjkCfg.Name, sizeof(cjkCfg.Name), "NotoSansCJK %.0fpx (merge)", size); snprintf(cjkCfg.Name, sizeof(cjkCfg.Name), "NotoSansCJK %.0fpx (merge)", size);
ImFont* mergeResult = io.Fonts->AddFontFromMemoryTTF(cjkCopy, g_noto_cjk_subset_size, size, &cjkCfg); ImFont* mergeResult = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(g_noto_cjk_subset_data),
static_cast<int>(g_noto_cjk_subset_size), size, &cjkCfg);
if (mergeResult) { if (mergeResult) {
DEBUG_LOGF("Typography: Merged CJK (%u bytes) into %s OK\n", DEBUG_LOGF("Typography: Merged CJK (%u bytes) into %s OK\n",
g_noto_cjk_subset_size, name); g_noto_cjk_subset_size, name);
@@ -348,15 +320,9 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
} }
} }
// Merge monochrome emoji for chat + user text (Q12). Only into the small text fonts — baking // Emoji (monochrome subset, or COLR color via FreeType) is merged only into the small text styles; load()
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no // passes `wantEmoji` from its style table (dedup keys on it). ImGui does no shaping (no ZWJ/flags); the
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The // >0xFFFF ranges need IMGUI_USE_WCHAR32.
// >0xFFFF ranges require IMGUI_USE_WCHAR32 (imconfig.h).
static const char* const kEmojiFonts[] = {
"Body1", "Body2", "Subtitle1", "Subtitle2", "Caption", "Overline", "Button", "ButtonSm"
};
bool wantEmoji = false;
for (const char* n : kEmojiFonts) if (strcmp(name, n) == 0) { wantEmoji = true; break; }
// Emoji blob: the COLR/CPAL color font (FreeType-rendered) when color emoji is enabled and this // Emoji blob: the COLR/CPAL color font (FreeType-rendered) when color emoji is enabled and this
// is a FreeType build, else the monochrome subset (default / non-FreeType path). // is a FreeType build, else the monochrome subset (default / non-FreeType path).
@@ -371,11 +337,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
} }
#endif #endif
if (wantEmoji && emojiSize > 0) { if (wantEmoji && emojiSize > 0) {
void* emojiCopy = IM_ALLOC(emojiSize);
memcpy(emojiCopy, emojiData, emojiSize);
ImFontConfig emojiCfg; ImFontConfig emojiCfg;
emojiCfg.FontDataOwnedByAtlas = true; emojiCfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
emojiCfg.MergeMode = true; // merge into the text font just loaded emojiCfg.MergeMode = true; // merge into the text font just loaded
emojiCfg.OversampleH = 1; emojiCfg.OversampleH = 1;
emojiCfg.OversampleV = 1; emojiCfg.OversampleV = 1;
@@ -396,17 +359,17 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "%s %.0fpx (merge)", snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "%s %.0fpx (merge)",
colorGlyphs ? "Twemoji" : "NotoEmoji", size); colorGlyphs ? "Twemoji" : "NotoEmoji", size);
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(emojiCopy, emojiSize, size, &emojiCfg); ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(emojiData),
static_cast<int>(emojiSize), size, &emojiCfg);
if (emojiMerge) { if (emojiMerge) {
DEBUG_LOGF("Typography: Merged %s emoji (%u bytes) into %s OK\n", DEBUG_LOGF("Typography: Merged %s emoji (%u bytes) into %s OK\n",
colorGlyphs ? "color" : "mono", emojiSize, name); colorGlyphs ? "color" : "mono", emojiSize, name);
} else { } else {
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, size); DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, emojiSize);
} }
} }
} else { } else {
DEBUG_LOGF("Typography: Failed to load %s\n", name); DEBUG_LOGF("Typography: Failed to load %s\n", name);
IM_FREE(fontDataCopy);
} }
return font; return font;
@@ -414,12 +377,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name) ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name)
{ {
// ImGui needs to own a copy of the font data
void* fontDataCopy = IM_ALLOC(g_material_icons_size);
memcpy(fontDataCopy, g_material_icons_data, g_material_icons_size);
ImFontConfig cfg; ImFontConfig cfg;
cfg.FontDataOwnedByAtlas = true; cfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
cfg.OversampleH = 2; cfg.OversampleH = 2;
cfg.OversampleV = 1; cfg.OversampleV = 1;
cfg.PixelSnapH = true; cfg.PixelSnapH = true;
@@ -431,12 +390,11 @@ ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name)
snprintf(cfg.Name, sizeof(cfg.Name), "MDIcons %.0fpx", size); snprintf(cfg.Name, sizeof(cfg.Name), "MDIcons %.0fpx", size);
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, g_material_icons_size, size, &cfg); ImFont* font = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(g_material_icons_data), static_cast<int>(g_material_icons_size), size, &cfg);
if (font) { if (font) {
DEBUG_LOGF("Typography: Loaded icon font %s (%.0fpx)\n", name, size); DEBUG_LOGF("Typography: Loaded icon font %s (%.0fpx)\n", name, size);
} else { } else {
DEBUG_LOGF("Typography: Failed to load icon font %s\n", name); DEBUG_LOGF("Typography: Failed to load icon font %s\n", name);
IM_FREE(fontDataCopy);
} }
return font; return font;
} }
@@ -448,11 +406,8 @@ ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name)
return nullptr; return nullptr;
} }
void* fontDataCopy = IM_ALLOC(g_mdi_pickaxe_subset_size);
memcpy(fontDataCopy, g_mdi_pickaxe_subset_data, g_mdi_pickaxe_subset_size);
ImFontConfig cfg; ImFontConfig cfg;
cfg.FontDataOwnedByAtlas = true; cfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
cfg.OversampleH = 2; cfg.OversampleH = 2;
cfg.OversampleV = 1; cfg.OversampleV = 1;
cfg.PixelSnapH = true; cfg.PixelSnapH = true;
@@ -468,12 +423,11 @@ ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name)
snprintf(cfg.Name, sizeof(cfg.Name), "MDIPickaxe %.0fpx", size); snprintf(cfg.Name, sizeof(cfg.Name), "MDIPickaxe %.0fpx", size);
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, g_mdi_pickaxe_subset_size, size, &cfg); ImFont* font = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(g_mdi_pickaxe_subset_data), static_cast<int>(g_mdi_pickaxe_subset_size), size, &cfg);
if (font) { if (font) {
DEBUG_LOGF("Typography: Loaded pickaxe font %s (%.0fpx)\n", name, size); DEBUG_LOGF("Typography: Loaded pickaxe font %s (%.0fpx)\n", name, size);
} else { } else {
DEBUG_LOGF("Typography: Failed to load pickaxe font %s\n", name); DEBUG_LOGF("Typography: Failed to load pickaxe font %s\n", name);
IM_FREE(fontDataCopy);
} }
return font; return font;
} }
+1 -1
View File
@@ -259,7 +259,7 @@ private:
Typography& operator=(const Typography&) = delete; Typography& operator=(const Typography&) = delete;
// Load fonts at a specific size with a specific weight // Load fonts at a specific size with a specific weight
ImFont* loadFont(ImGuiIO& io, int weight, float size, const char* name); ImFont* loadFont(ImGuiIO& io, int weight, float size, const char* name, bool wantEmoji);
// Font weight constants // Font weight constants
static constexpr int kWeightLight = 300; static constexpr int kWeightLight = 300;