Crafter.Graphics/tests/FontAdvanceCache/main.cpp
catbot c169986835 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>
2026-06-16 17:00:48 +00:00

144 lines
6.4 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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;
}