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.
// Font sizes come from Layout:: accessors (backed by UISchema JSON)
// Load fonts in order of TypeStyle enum
// H1: Light
fonts_[0] = loadFont(io, kWeightLight, Layout::kFontH1() * scale, "H1");
// H2: Light
fonts_[1] = loadFont(io, kWeightLight, Layout::kFontH2() * scale, "H2");
// H3: Regular
fonts_[2] = loadFont(io, kWeightRegular, Layout::kFontH3() * scale, "H3");
// H4: Regular
fonts_[3] = loadFont(io, kWeightRegular, Layout::kFontH4() * scale, "H4");
// H5: Regular
fonts_[4] = loadFont(io, kWeightRegular, Layout::kFontH5() * scale, "H5");
// H6: Medium
fonts_[5] = loadFont(io, kWeightMedium, Layout::kFontH6() * scale, "H6");
// Subtitle1: Regular
fonts_[6] = loadFont(io, kWeightRegular, Layout::kFontSubtitle1() * scale, "Subtitle1");
// Subtitle2: Medium
fonts_[7] = loadFont(io, kWeightMedium, Layout::kFontSubtitle2() * scale, "Subtitle2");
// Body1: Regular (shares with Subtitle1 if same size)
if (Layout::kFontBody1() == Layout::kFontSubtitle1()) {
fonts_[8] = fonts_[6]; // Reuse Subtitle1 font
} else {
fonts_[8] = loadFont(io, kWeightRegular, Layout::kFontBody1() * scale, "Body1");
// 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; };
const StyleDef defs[kNumStyles] = {
{ kWeightLight, Layout::kFontH1(), "H1", false },
{ kWeightLight, Layout::kFontH2(), "H2", false },
{ kWeightRegular, Layout::kFontH3(), "H3", false },
{ kWeightRegular, Layout::kFontH4(), "H4", false },
{ kWeightRegular, Layout::kFontH5(), "H5", false },
{ kWeightMedium, Layout::kFontH6(), "H6", false },
{ kWeightRegular, Layout::kFontSubtitle1(), "Subtitle1", true },
{ kWeightMedium, Layout::kFontSubtitle2(), "Subtitle2", true },
{ kWeightRegular, Layout::kFontBody1(), "Body1", true },
{ kWeightRegular, Layout::kFontBody2(), "Body2", true },
{ kWeightMedium, Layout::kFontButton(), "Button", true },
{ kWeightRegular, Layout::kFontCaption(), "Caption", true },
{ kWeightRegular, Layout::kFontOverline(), "Overline", true },
{ kWeightMedium, Layout::kFontButtonSm(), "ButtonSm", true },
{ kWeightMedium, Layout::kFontButtonLg(), "ButtonLg", false },
};
static_assert(sizeof(fonts_) / sizeof(fonts_[0]) == kNumStyles, "style table must cover every TypeStyle");
// Styles with the same (weight, scaled size, emoji) would build byte-identical ImFonts: load once and share
// 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()).
for (int i = 0; i < kNumStyles; ++i) {
const float px = defs[i].px * scale;
int first = -1;
for (int j = 0; j < i; ++j)
if (defs[j].weight == defs[i].weight && defs[j].px * scale == px && defs[j].emoji == defs[i].emoji) { first = j; break; }
fonts_[i] = (first >= 0) ? fonts_[first] : loadFont(io, defs[i].weight, px, defs[i].name, defs[i].emoji);
}
// 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 +
// 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 ---
// 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;
}
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
const unsigned char* fontData = nullptr;
@@ -273,12 +249,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
break;
}
// ImGui needs to own a copy of the font data
void* fontDataCopy = IM_ALLOC(fontDataLen);
memcpy(fontDataCopy, fontData, fontDataLen);
// Zero-copy: the INCBIN blob is immutable .rodata that outlives the atlas (ImGui 1.92 requires the data to
// persist for the atlas lifetime); FontDataOwnedByAtlas=false means ImGui never frees or writes it.
ImFontConfig cfg;
cfg.FontDataOwnedByAtlas = true; // ImGui will free this
cfg.FontDataOwnedByAtlas = false;
cfg.OversampleH = 2;
cfg.OversampleV = 1;
cfg.PixelSnapH = true;
@@ -306,18 +280,15 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
weight == kWeightMono ? "Mono" : "Regular";
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) {
DEBUG_LOGF("Typography: Loaded %s (%.0fpx) as '%s'\n", name, size, cfg.Name);
// Merge CJK fallback glyphs (Chinese/Japanese/Korean) from subset font
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;
cjkCfg.FontDataOwnedByAtlas = true;
cjkCfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
cjkCfg.MergeMode = true; // merge into the font we just loaded
cjkCfg.OversampleH = 1;
cjkCfg.OversampleV = 1;
@@ -338,7 +309,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
cjkCfg.GlyphRanges = cjkRanges;
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) {
DEBUG_LOGF("Typography: Merged CJK (%u bytes) into %s OK\n",
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
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The
// >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 (monochrome subset, or COLR color via FreeType) is merged only into the small text styles; load()
// passes `wantEmoji` from its style table (dedup keys on it). ImGui does no shaping (no ZWJ/flags); the
// >0xFFFF ranges need IMGUI_USE_WCHAR32.
// 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).
@@ -371,11 +337,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
}
#endif
if (wantEmoji && emojiSize > 0) {
void* emojiCopy = IM_ALLOC(emojiSize);
memcpy(emojiCopy, emojiData, emojiSize);
ImFontConfig emojiCfg;
emojiCfg.FontDataOwnedByAtlas = true;
emojiCfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
emojiCfg.MergeMode = true; // merge into the text font just loaded
emojiCfg.OversampleH = 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)",
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) {
DEBUG_LOGF("Typography: Merged %s emoji (%u bytes) into %s OK\n",
colorGlyphs ? "color" : "mono", emojiSize, name);
} 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 {
DEBUG_LOGF("Typography: Failed to load %s\n", name);
IM_FREE(fontDataCopy);
}
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)
{
// 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;
cfg.FontDataOwnedByAtlas = true;
cfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
cfg.OversampleH = 2;
cfg.OversampleV = 1;
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);
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) {
DEBUG_LOGF("Typography: Loaded icon font %s (%.0fpx)\n", name, size);
} else {
DEBUG_LOGF("Typography: Failed to load icon font %s\n", name);
IM_FREE(fontDataCopy);
}
return font;
}
@@ -448,11 +406,8 @@ ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name)
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;
cfg.FontDataOwnedByAtlas = true;
cfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
cfg.OversampleH = 2;
cfg.OversampleV = 1;
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);
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) {
DEBUG_LOGF("Typography: Loaded pickaxe font %s (%.0fpx)\n", name, size);
} else {
DEBUG_LOGF("Typography: Failed to load pickaxe font %s\n", name);
IM_FREE(fontDataCopy);
}
return font;
}
+1 -1
View File
@@ -259,7 +259,7 @@ private:
Typography& operator=(const Typography&) = delete;
// 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
static constexpr int kWeightLight = 300;