Merge pull request #22719 from hrydgard/text-measure-cache

TextDrawer: Cache wrapped text, hash the string caches
This commit is contained in:
Henrik Rydgård authored and GitHub committed 2026-10-22 11:31:07 +02:00
commit b1611a3b47
5 files changed
+185 -50

No files matched your search

+4
View File
@@ -33,6 +33,10 @@ struct FontStyle {
FontStyleFlags flags = FontStyleFlags::Default;
};
inline constexpr bool operator==(const FontStyle &a, const FontStyle &b) {
return a.family == b.family && a.sizePts == b.sizePts && a.flags == b.flags;
}
inline constexpr bool operator<(const FontStyle &a, const FontStyle &b) {
if (a.family != b.family)
return a.family < b.family;
+62 -41
View File
@@ -7,6 +7,9 @@
#endif
#include <algorithm>
#include <cstring>
#include "ext/xxhash.h"
#include "Common/System/Display.h"
#include "Common/GPU/thin3d.h"
#include "Common/Data/Hash/Hash.h"
@@ -41,6 +44,15 @@ static bool IsRenderDocLoaded() {
#endif
}
size_t TextCacheKeyHash::operator()(const TextCacheKey &key) const {
uint32_t maxWidthBits, scaleBits;
memcpy(&maxWidthBits, &key.maxWidth, sizeof(maxWidthBits));
memcpy(&scaleBits, &key.scale, sizeof(scaleBits));
uint64_t seed = ((uint64_t)key.style.sizePts << 48) | ((uint64_t)key.style.family << 40) | ((uint64_t)key.style.flags << 32) | (uint32_t)key.flags;
seed ^= (((uint64_t)maxWidthBits << 32) | scaleBits) * 0x9E3779B97F4A7C15ULL;
return (size_t)XXH3_64bits_withSeed(key.text.data(), key.text.size(), seed);
}
TextDrawer::TextDrawer(Draw::DrawContext *draw) : draw_(draw) {
// These probably shouldn't be state.
dpiScale_ = CalculateDPIScale();
@@ -52,9 +64,25 @@ float TextDrawerWordWrapper::MeasureWidth(std::string_view str) {
return w;
}
void TextDrawer::WrapString(std::string &out, std::string_view str, float maxW, int flags) {
TextDrawerWordWrapper wrapper(this, str, maxW, flags);
out = wrapper.Wrapped();
const TextCacheKey &TextDrawer::LookupKey(std::string_view str, int flags, float maxWidth, float scale) {
lookupKey_.text.assign(str.data(), str.size());
lookupKey_.style = fontStyle_;
lookupKey_.flags = flags;
lookupKey_.maxWidth = maxWidth;
lookupKey_.scale = scale;
return lookupKey_;
}
const std::string &TextDrawer::WrapString(std::string_view str, float maxW, int flags) {
// The wrapper measures in scaled units, so the result depends on the scale too.
auto [iter, inserted] = wrapCache_.try_emplace(LookupKey(str, flags, maxW, fontScaleX_));
TextWrapEntry &entry = iter->second;
entry.lastUsedFrame = frameCount_;
if (inserted) {
TextDrawerWordWrapper wrapper(this, str, maxW, flags);
entry.wrapped = wrapper.Wrapped();
}
return entry.wrapped;
}
void TextDrawer::SetFontScale(float xscale, float yscale) {
@@ -74,13 +102,12 @@ void TextDrawer::DrawString(DrawBuffer &target, std::string_view str, float x, f
return;
}
const CacheKeyType key{ std::string(str), fontStyle_ };
target.Flush(true);
TextStringEntry *entry;
auto iter = cache_.find(key);
if (iter != cache_.end()) {
auto [iter, inserted] = cache_.try_emplace(LookupKey(str));
if (!inserted) {
entry = iter->second.get();
entry->lastUsedFrame = frameCount_;
if (!entry->texture) {
@@ -104,15 +131,15 @@ void TextDrawer::DrawString(DrawBuffer &target, std::string_view str, float x, f
texFormat = Draw::DataFormat::R8G8B8A8_UNORM;
}
entry = new TextStringEntry(frameCount_);
iter->second = std::make_unique<TextStringEntry>(frameCount_);
entry = iter->second.get();
// Convert the bitmap to a Thin3D compatible array of 16-bit pixels. Can't use a single channel format
// because we need white. Well, we could using swizzle, but not all our backends support that.
TextureDesc desc{};
std::vector<uint8_t> bitmapData;
if (!DrawStringBitmap(bitmapData, *entry, texFormat, str, align, emoji)) {
// Nothing drawn. Store that fact in the cache.
cache_[key] = std::unique_ptr<TextStringEntry>(entry);
// Nothing drawn. The entry without a texture records that fact.
return;
}
@@ -127,7 +154,6 @@ void TextDrawer::DrawString(DrawBuffer &target, std::string_view str, float x, f
desc.tag = "TextDrawer";
desc.swizzle = texFormat == Draw::DataFormat::R8_UNORM ? Draw::TextureSwizzle::R8_AS_PREMUL_ALPHA : Draw::TextureSwizzle::DEFAULT,
entry->texture = draw_->CreateTexture(desc);
cache_[key] = std::unique_ptr<TextStringEntry>(entry);
}
_dbg_assert_(entry->texture);
@@ -156,26 +182,20 @@ void TextDrawer::MeasureString(std::string_view str, float *w, float *h) {
str = str.substr(0, MAX_TEXT_LENGTH);
}
const CacheKeyType key{std::string(str), fontStyle_};
TextMeasureEntry *entry;
auto iter = sizeCache_.find(key);
if (iter != sizeCache_.end()) {
entry = iter->second.get();
} else {
entry = new TextMeasureEntry();
auto [iter, inserted] = sizeCache_.try_emplace(LookupKey(str));
TextMeasureEntry &entry = iter->second;
if (inserted) {
float extW, extH;
MeasureStringInternal(str, &extW, &extH);
entry->width = extW;
entry->height = extH;
entry.width = extW;
entry.height = extH;
// Hm, use the old calculation?
// int h = i == lines.size() - 1 ? entry->height : metrics.tmHeight + metrics.tmExternalLeading;
sizeCache_[key] = std::unique_ptr<TextMeasureEntry>(entry);
}
entry->lastUsedFrame = frameCount_;
*w = entry->width * fontScaleX_ * dpiScale_;
*h = entry->height * fontScaleY_ * dpiScale_;
entry.lastUsedFrame = frameCount_;
*w = entry.width * fontScaleX_ * dpiScale_;
*h = entry.height * fontScaleY_ * dpiScale_;
}
void TextDrawer::MeasureStringRect(std::string_view str, float maxWidth, float *w, float *h, int align) {
@@ -190,9 +210,7 @@ void TextDrawer::MeasureStringRect(std::string_view str, float maxWidth, float *
MeasureString(str, &plainW, &plainH);
if (wrap && plainW > maxWidth) {
std::string toMeasure = std::string(str);
WrapString(toMeasure, toMeasure, maxWidth, wrap);
MeasureString(toMeasure, w, h);
MeasureString(WrapString(str, maxWidth, wrap), w, h);
} else {
*w = plainW;
*h = plainH;
@@ -221,12 +239,8 @@ void TextDrawer::DrawStringRect(DrawBuffer &target, std::string_view str, const
str = str.substr(0, MAX_TEXT_LENGTH);
}
std::string toDraw(str);
int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
if (wrap) {
WrapString(toDraw, str, bounds.w, wrap);
}
DrawString(target, toDraw, x, y, color, align);
const int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
DrawString(target, wrap ? std::string_view(WrapString(str, bounds.w, wrap)) : str, x, y, color, align);
}
bool TextDrawer::DrawStringBitmapRect(std::vector<uint8_t> &bitmapData, TextStringEntry &entry, Draw::DataFormat texFormat, std::string_view str, const Bounds &bounds, int align, bool fullColor) {
@@ -235,12 +249,8 @@ bool TextDrawer::DrawStringBitmapRect(std::vector<uint8_t> &bitmapData, TextStri
str = str.substr(0, MAX_TEXT_LENGTH);
}
std::string toDraw(str);
int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
if (wrap) {
WrapString(toDraw, str, bounds.w, wrap);
}
return DrawStringBitmap(bitmapData, entry, texFormat, toDraw, align, fullColor);
const int wrap = align & (FLAG_WRAP_TEXT | FLAG_ELLIPSIZE_TEXT);
return DrawStringBitmap(bitmapData, entry, texFormat, wrap ? std::string_view(WrapString(str, bounds.w, wrap)) : str, align, fullColor);
}
void TextDrawer::ClearCache() {
@@ -250,6 +260,7 @@ void TextDrawer::ClearCache() {
}
cache_.clear();
sizeCache_.clear();
wrapCache_.clear();
fontStyle_ = {};
}
@@ -277,19 +288,29 @@ void TextDrawer::OncePerFrame() {
}
for (auto iter = sizeCache_.begin(); iter != sizeCache_.end(); ) {
if (frameCount_ - iter->second->lastUsedFrame > 100) {
if (frameCount_ - iter->second.lastUsedFrame > 100) {
iter = sizeCache_.erase(iter);
} else {
iter++;
}
}
for (auto iter = wrapCache_.begin(); iter != wrapCache_.end(); ) {
if (frameCount_ - iter->second.lastUsedFrame > 100) {
iter = wrapCache_.erase(iter);
} else {
iter++;
}
}
}
}
size_t TextDrawer::GetCacheDataSize() const {
size_t sz = 0;
for (const auto &iter : cache_) {
sz += iter.second->texture->DataSize();
if (iter.second->texture) {
sz += iter.second->texture->DataSize();
}
}
return sz;
}
+33 -5
View File
@@ -13,6 +13,7 @@
#include <memory>
#include <cstdint>
#include <map>
#include <unordered_map>
#include "Common/Data/Text/WrapText.h"
#include "Common/Render/DrawBuffer.h"
@@ -41,6 +42,28 @@ struct TextMeasureEntry {
int lastUsedFrame;
};
struct TextWrapEntry {
std::string wrapped;
int lastUsedFrame;
};
struct TextCacheKey {
std::string text;
FontStyle style;
int flags = 0;
// Wrapping only.
float maxWidth = 0.0f;
float scale = 0.0f;
bool operator==(const TextCacheKey &other) const {
return text == other.text && style == other.style && flags == other.flags && maxWidth == other.maxWidth && scale == other.scale;
}
};
struct TextCacheKeyHash {
size_t operator()(const TextCacheKey &key) const;
};
class TextDrawer {
public:
virtual ~TextDrawer() = default;
@@ -85,7 +108,8 @@ protected:
virtual bool SupportsColorEmoji() const = 0;
virtual void ClearFonts() = 0;
void WrapString(std::string &out, std::string_view str, float maxWidth, int flags);
// Wraps and/or ellipsizes, caching the result. The reference stays valid until the next OncePerFrame.
const std::string &WrapString(std::string_view str, float maxWidth, int flags);
// v has the 8-bit alpha.
static u16 AlphaToPremul4444(u32 v) {
@@ -114,8 +138,6 @@ protected:
return (a << 24) | (r << 16) | (g << 8) | b;
}
typedef std::pair<std::string, FontStyle> CacheKeyType;
Draw::DrawContext *draw_;
int frameCount_ = 0;
@@ -128,8 +150,14 @@ protected:
// We will clamp strings to this length to avoid various degenenerate behaviors.k
static constexpr size_t MAX_TEXT_LENGTH = 16384;
std::map<CacheKeyType, std::unique_ptr<TextStringEntry>> cache_;
std::map<CacheKeyType, std::unique_ptr<TextMeasureEntry>> sizeCache_;
// Fills in and returns lookupKey_, which reuses its string buffer, so a cache hit doesn't allocate.
const TextCacheKey &LookupKey(std::string_view str, int flags = 0, float maxWidth = 0.0f, float scale = 0.0f);
TextCacheKey lookupKey_;
std::unordered_map<TextCacheKey, std::unique_ptr<TextStringEntry>, TextCacheKeyHash> cache_;
std::unordered_map<TextCacheKey, TextMeasureEntry, TextCacheKeyHash> sizeCache_;
std::unordered_map<TextCacheKey, TextWrapEntry, TextCacheKeyHash> wrapCache_;
};
class TextDrawerWordWrapper : public WordWrapper {
+16 -4
View File
@@ -273,10 +273,16 @@ void UIContext::MeasureText(const FontStyle &style, float scaleX, float scaleY,
Draw()->SetFontScale(scaleX * sizeFactor, scaleY * sizeFactor);
Draw()->MeasureText(AtlasFontFromStyle(style), str, x, y);
} else {
textDrawer_->SetOrCreateFont(style);
// Switching fonts costs two lookups, and most measuring is done in the current style.
const bool switchFont = !(style == *fontStyle_);
if (switchFont) {
textDrawer_->SetOrCreateFont(style);
}
textDrawer_->SetFontScale(scaleX, scaleY);
textDrawer_->MeasureString(str, x, y);
textDrawer_->SetOrCreateFont(*fontStyle_);
if (switchFont) {
textDrawer_->SetOrCreateFont(*fontStyle_);
}
}
}
@@ -287,10 +293,16 @@ void UIContext::MeasureTextRect(const FontStyle &style, float scaleX, float scal
Draw()->SetFontScale(scaleX * sizeFactor, scaleY * sizeFactor);
Draw()->MeasureTextRect(AtlasFontFromStyle(style), str, maxWidth, x, y, align);
} else {
textDrawer_->SetOrCreateFont(style);
// Switching fonts costs two lookups, and most measuring is done in the current style.
const bool switchFont = !(style == *fontStyle_);
if (switchFont) {
textDrawer_->SetOrCreateFont(style);
}
textDrawer_->SetFontScale(scaleX, scaleY);
textDrawer_->MeasureStringRect(str, maxWidth, x, y, align);
textDrawer_->SetOrCreateFont(*fontStyle_);
if (switchFont) {
textDrawer_->SetOrCreateFont(*fontStyle_);
}
}
}
+70
View File
@@ -127,6 +127,7 @@
#include "Common/File/AndroidContentURI.h"
#include "Common/System/Request.h"
#include "Common/Render/Text/draw_text.h"
#include "unittest/JitHarness.h"
#include "unittest/TestVertexJit.h"
@@ -3475,6 +3476,74 @@ bool TestFatShortNames() {
return true;
}
// A TextDrawer whose "font" is ten units per byte and twenty per line, so we can test the
// shared caching and wrapping logic without a platform font backend.
class FakeTextDrawer : public TextDrawer {
public:
FakeTextDrawer() : TextDrawer(nullptr) {}
void SetOrCreateFont(const FontStyle &style) override { fontStyle_ = style; }
bool DrawStringBitmap(std::vector<uint8_t> &bitmapData, TextStringEntry &entry, Draw::DataFormat texFormat, std::string_view str, int align, bool fullColor) override { return false; }
int measureCalls = 0;
protected:
void MeasureStringInternal(std::string_view str, float *w, float *h) override {
measureCalls++;
std::vector<std::string_view> lines;
SplitString(str, '\n', lines);
int maxLen = 0;
for (auto line : lines) {
maxLen = std::max(maxLen, (int)line.size());
}
*w = maxLen * 10.0f;
*h = lines.size() * 20.0f;
}
bool SupportsColorEmoji() const override { return false; }
void ClearFonts() override {}
};
bool TestTextDrawerCache() {
FakeTextDrawer drawer;
drawer.SetForcedDPIScale(1.0f);
drawer.SetOrCreateFont(FontStyle(FontFamily::SansSerif, 20, FontStyleFlags::Default));
float w, h;
drawer.MeasureString("hello", &w, &h);
EXPECT_EQ_FLOAT(w, 50.0f);
EXPECT_EQ_FLOAT(h, 20.0f);
drawer.MeasureString("hello", &w, &h);
EXPECT_EQ_INT(drawer.measureCalls, 1);
// A different style is a different cache entry.
drawer.SetOrCreateFont(FontStyle(FontFamily::SansSerif, 30, FontStyleFlags::Default));
drawer.MeasureString("hello", &w, &h);
EXPECT_EQ_INT(drawer.measureCalls, 2);
const std::string_view para = "The quick brown fox jumps over the lazy dog, and then it does it again, and again, until the dog finally gets up and leaves.";
drawer.MeasureStringRect(para, 200.0f, &w, &h, FLAG_WRAP_TEXT);
// The trailing space of a wrapped line is measured too.
EXPECT_TRUE(w <= 210.0f);
EXPECT_TRUE(h > 20.0f);
float w2, h2;
int callsBefore = drawer.measureCalls;
drawer.MeasureStringRect(para, 200.0f, &w2, &h2, FLAG_WRAP_TEXT);
EXPECT_EQ_FLOAT(w, w2);
EXPECT_EQ_FLOAT(h, h2);
EXPECT_EQ_INT(drawer.measureCalls, callsBefore);
// Scaling the font changes where the text wraps.
drawer.SetFontScale(0.5f, 0.5f);
drawer.MeasureStringRect(para, 200.0f, &w2, &h2, FLAG_WRAP_TEXT);
EXPECT_TRUE(h2 < h * 0.5f);
drawer.SetFontScale(1.0f, 1.0f);
drawer.MeasureStringRect(para, 200.0f, &w, &h, FLAG_ELLIPSIZE_TEXT);
EXPECT_EQ_FLOAT(h, 20.0f);
EXPECT_TRUE(w <= 200.0f);
return true;
}
// Tab/Shift+Tab focus navigation walks the view hierarchy in declaration order rather than by
// geometry, so what it does is entirely determined by CollectTabOrder - which is worth pinning
// down, since the interesting cases (nesting, hidden tabs, disabled items) are all structural.
@@ -3786,6 +3855,7 @@ TestItem availableTests[] = {
TEST_ITEM(TextureReplacer),
TEST_ITEM(UITabOrder),
TEST_ITEM(UIFocusNavigation),
TEST_ITEM(TextDrawerCache),
TEST_ITEM(FatShortNames),
TEST_ITEM(GEDumpGameID),
TEST_ITEM(QueueBuf),