perf(font): cache per-codepoint advances in font units (#57)

Font::GetLineWidth called stbtt_GetCodepointHMetrics for every glyph on
every invocation. InputField_HitTestCursor rescans the whole field once
per character, making caret hit-testing O(n²) in HMetrics lookups.

Memoise advances per Font via Font::AdvanceUnits: ASCII in a flat array,
the rest in a map. Advances are cached in unscaled font *units* and
rescaled per call — Glyph::advance in the FontAtlas is baked at kBaseSize
and is wrong at any other size, so it cannot be reused. The per-glyph
(int) truncation in GetLineWidth is preserved exactly.

Adds the FontAdvanceCache CPU-only regression test covering size
independence of the cached unit advance, the per-glyph-truncated rescale
pipeline, cache-hit determinism, width scaling with size, and the
non-ASCII map path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-16 17:00:48 +00:00
commit c169986835
5 changed files with 203 additions and 3 deletions

View file

@ -56,6 +56,23 @@ Font::Font(const std::filesystem::path& fontFilePath) {
this->ascent = ascent; this->ascent = ascent;
this->descent = descent; this->descent = descent;
this->lineGap = lineGap; this->lineGap = lineGap;
asciiAdvance_.fill(-1);
}
std::int32_t Font::AdvanceUnits(std::uint32_t cp) {
if (cp < asciiAdvance_.size()) {
if (asciiAdvance_[cp] < 0) {
int advance = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
asciiAdvance_[cp] = advance;
}
return asciiAdvance_[cp];
}
if (auto it = advanceUnits_.find(cp); it != advanceUnits_.end()) return it->second;
int advance = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
return advanceUnits_.emplace(cp, advance).first->second;
} }
std::uint32_t Font::GetLineWidth(const std::string_view text, float size) { std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
@ -65,9 +82,7 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
while (i < text.size()) { while (i < text.size()) {
std::uint32_t cp = DecodeUtf8(text, i); std::uint32_t cp = DecodeUtf8(text, i);
if (cp == 0) break; if (cp == 0) break;
int advance, lsb; lineWidth += (int)(AdvanceUnits(cp) * scale);
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
lineWidth += (int)(advance * scale);
} }
return lineWidth; return lineWidth;
} }

View file

@ -63,5 +63,17 @@ namespace Crafter {
float LineHeight(float size); float LineHeight(float size);
float AscentPx(float size); float AscentPx(float size);
float ScaleForSize(float size); float ScaleForSize(float size);
// Horizontal advance for `cp` in unscaled font units, cached per Font.
// Stored in font units (not pixels) so it can be rescaled for any
// `size`; Glyph::advance in the FontAtlas is baked at kBaseSize and is
// wrong at other sizes, so it cannot be reused here. ASCII lands in a
// flat array (the common path for caret hit-testing, which rescans the
// whole field per character), everything else in the map.
std::int32_t AdvanceUnits(std::uint32_t cp);
private:
std::array<std::int32_t, 128> asciiAdvance_; // -1 = uncached
std::unordered_map<std::uint32_t, std::int32_t> advanceUnits_;
}; };
} }

View file

@ -413,6 +413,35 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
memImpls.emplace_back("tests/MemoryTypeFallback/main"); memImpls.emplace_back("tests/MemoryTypeFallback/main");
mc.GetInterfacesAndImplementations(ifaces, memImpls); mc.GetInterfacesAndImplementations(ifaces, memImpls);
cfg.tests.push_back(std::move(memTest)); cfg.tests.push_back(std::move(memTest));
// Issue #57: Font::GetLineWidth memoises per-codepoint advances in
// font units (Font::AdvanceUnits) and rescales per call, instead of
// calling stbtt_GetCodepointHMetrics for every glyph on every caret
// query. Pure CPU — Font only touches stb_truetype — so this drives
// the public API directly with no Vulkan device. The font file is
// copied next to the binary; the test also probes the project root.
Test advTest;
Configuration& vc = advTest.config;
vc.path = cfg.path;
vc.name = "FontAdvanceCache";
vc.outputName = "FontAdvanceCache";
vc.type = ConfigurationType::Executable;
vc.target = cfg.target;
vc.march = cfg.march;
vc.mtune = cfg.mtune;
vc.debug = cfg.debug;
vc.sysroot = cfg.sysroot;
vc.dependencies = cfg.dependencies;
vc.externalDependencies = cfg.externalDependencies;
vc.compileFlags = cfg.compileFlags;
vc.linkFlags = cfg.linkFlags;
vc.defines = cfg.defines;
vc.cFiles = cfg.cFiles;
vc.files = { fs::path("tests/FontAdvanceCache/font.ttf") };
std::vector<fs::path> advImpls(impls.begin(), impls.end());
advImpls.emplace_back("tests/FontAdvanceCache/main");
vc.GetInterfacesAndImplementations(ifaces, advImpls);
cfg.tests.push_back(std::move(advTest));
} }
return cfg; return cfg;

Binary file not shown.

View file

@ -0,0 +1,144 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// Issue #57: Font::GetLineWidth used to call stbtt_GetCodepointHMetrics for
// every glyph on every call. Caret hit-testing (InputField_HitTestCursor)
// rescans the whole field once per character, so that is O(n²) HMetrics calls.
// Advances are now memoised per Font in *font units* (Font::AdvanceUnits):
// ASCII in a flat array, the rest in a map, rescaled per call.
//
// The cache must be in unscaled units, NOT pixels — the FontAtlas's
// Glyph::advance is baked at kBaseSize and is wrong at any other size, so it
// cannot be reused here. What is asserted:
//
// - AdvanceUnits is size-independent: the value for a codepoint is identical
// no matter which size was queried first (proves it stores units, not px).
// - GetLineWidth equals the documented pipeline: sum of per-glyph
// (int)(units * scaleForSize), with the truncation applied PER GLYPH.
// - Repeated calls (cache hits) are byte-identical to the first (miss).
// - Width scales with size — a 2× size is wider, ruling out a baked-at-one-
// size advance that would make every size render the same width.
// - The empty string is zero.
// - Codepoints above ASCII (the map path) behave like the array path.
//
// Pure CPU: Font only touches stb_truetype, so no Vulkan device is needed.
#include <cstdlib>
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
std::filesystem::path FindFont() {
for (const char* cand : {
"font.ttf",
"tests/FontAdvanceCache/font.ttf",
"../../examples/HelloUI/font.ttf" }) {
if (std::filesystem::exists(cand)) return cand;
}
return "font.ttf";
}
// The exact contract GetLineWidth must honour: truncate each glyph's scaled
// advance to int independently, then accumulate. Rescaling the cached unit
// advance per call is what keeps it correct at sizes other than kBaseSize.
std::uint32_t ExpectedWidth(Font& font, std::string_view text, float size) {
float scale = font.ScaleForSize(size);
std::uint32_t w = 0;
std::size_t i = 0;
while (i < text.size()) {
std::uint32_t cp = DecodeUtf8(text, i);
if (cp == 0) break;
w += static_cast<int>(font.AdvanceUnits(cp) * scale);
}
return w;
}
} // namespace
int main() {
Font font(FindFont());
const std::string_view text = "Hello, World!";
// ── 1. AdvanceUnits is size-independent (stored in font units). ─────────
// Prime the cache via a large size, capture the unit advance, then prime
// again via a tiny size. The unit value must not move — if the cache stored
// pixels it would differ wildly between the two priming sizes.
font.GetLineWidth(text, 96.0f);
std::int32_t aBig = font.AdvanceUnits(static_cast<std::uint32_t>('A'));
font.GetLineWidth(text, 6.0f);
std::int32_t aSmall = font.AdvanceUnits(static_cast<std::uint32_t>('A'));
Check(aBig == aSmall && aBig > 0,
"AdvanceUnits('A') is a stable, positive font-unit value across sizes");
// ── 2. GetLineWidth matches the per-glyph-truncated, rescaled pipeline. ─
bool pipelineOk = true;
for (float size : {6.0f, 11.0f, 18.0f, 32.0f, 64.0f, 100.0f}) {
std::uint32_t got = font.GetLineWidth(text, size);
std::uint32_t exp = ExpectedWidth(font, text, size);
if (got != exp) {
pipelineOk = false;
std::println(" size {}: got {} expected {}", size, got, exp);
}
}
Check(pipelineOk, "GetLineWidth == sum of per-glyph (int)(units * scale) at every size");
// ── 3. Cache hits are byte-identical to the first (miss) call. ──────────
std::uint32_t first = font.GetLineWidth(text, 18.0f);
bool stable = true;
for (int k = 0; k < 8; ++k) stable = stable && (font.GetLineWidth(text, 18.0f) == first);
Check(stable && first > 0, "repeated GetLineWidth calls are identical (cache hit == miss)");
// ── 4. Width scales with size — not baked at a single size. ─────────────
std::uint32_t w18 = font.GetLineWidth(text, 18.0f);
std::uint32_t w36 = font.GetLineWidth(text, 36.0f);
Check(w36 > w18 && w36 > w18 + (w18 / 2),
"doubling the size roughly doubles the width (advance is rescaled, not baked)");
// ── 5. Empty string is zero. ────────────────────────────────────────────
Check(font.GetLineWidth("", 18.0f) == 0, "empty string has zero width");
// ── 6. Non-ASCII codepoints (the map path) behave like the array path. ──
// U+00E9 'é' (UTF-8 0xC3 0xA9) is > 127 so it lands in advanceUnits_, not
// the flat ASCII array. Its advance must be stable and a prefix narrower
// than the whole.
const std::string_view accented = "café"; // 'é' is two UTF-8 bytes
std::uint32_t accWidth = font.GetLineWidth(accented, 24.0f);
std::uint32_t accExp = ExpectedWidth(font, accented, 24.0f);
Check(accWidth == accExp && accWidth > font.GetLineWidth("caf", 24.0f),
"non-ASCII codepoint goes through the map path and widens the line");
std::uint32_t eFirst = static_cast<std::uint32_t>(font.AdvanceUnits(0x00E9));
Check(static_cast<std::uint32_t>(font.AdvanceUnits(0x00E9)) == eFirst,
"AdvanceUnits for a mapped codepoint is stable across calls");
if (failures == 0) std::println("\nAll font advance-cache checks passed.");
else std::println("\n{} font advance-cache check(s) FAILED.", failures);
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}