perf(ui): gate per-pixel clip compares behind a flags bit (#50) #91

Merged
catbot merged 2 commits from claude/issue-50 into master 2026-06-16 18:59:48 +02:00
7 changed files with 311 additions and 21 deletions
Showing only changes of commit f2f5a770c0 - Show all commits

Merge remote-tracking branch 'origin/master' into claude/issue-50

# Conflicts:
#	project.cpp
catbot 2026-06-16 16:59:05 +00:00

View file

@ -72,6 +72,30 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) {
return lineWidth;
}
std::size_t Font::NearestCursorByte(std::string_view text, float size, float target) {
if (target <= 0.0f) return 0;
float scale = stbtt_ScaleForPixelHeight(&font, size);
std::size_t best = 0; // byte offset 0 → caret before the first glyph
float bestDist = target; // |target - 0|, and target > 0 here
float cumWidth = 0.0f;
std::size_t i = 0;
while (i < text.size()) {
std::uint32_t cp = DecodeUtf8(text, i); // i advances to the next boundary
if (cp == 0) break;
int advance, lsb;
stbtt_GetCodepointHMetrics(&font, static_cast<int>(cp), &advance, &lsb);
cumWidth += (int)(advance * scale); // match GetLineWidth's per-glyph rounding
float d = std::abs(target - cumWidth);
if (d < bestDist) {
bestDist = d;
best = i; // byte offset just past this codepoint
} else {
break; // monotonic advance ⇒ already past the closest boundary
}
}
return best;
}
float Font::LineHeight(float size) {
float scale = stbtt_ScaleForPixelHeight(&font, size);
return (ascent - descent + lineGap) * scale;

View file

@ -117,23 +117,12 @@ std::size_t Crafter::InputField_HitTestCursor(const InputField& f,
Font& font, float fontSize,
const InputFieldColors& colors)
{
// Single O(n) cumulative-advance pass over the value, keyed at codepoint
// byte boundaries — matching cursorPos, which is a byte offset. The old
// loop re-walked the prefix for every boundary (O(n^2) glyph metric
// lookups) and could even land mid-codepoint on multibyte input.
float target = clickX - (rect.x + colors.paddingX);
if (target <= 0.0f) return 0;
std::size_t best = 0;
float bestDist = std::abs(target);
for (std::size_t i = 1; i <= f.value.size(); ++i) {
std::string_view sub(f.value.data(), i);
float w = static_cast<float>(font.GetLineWidth(sub, fontSize));
float d = std::abs(target - w);
if (d < bestDist) {
bestDist = d;
best = i;
} else {
break;
}
}
return best;
return font.NearestCursorByte(f.value, fontSize, target);
}
void Crafter::DrawInputField(UIBuffer& buf, const InputField& f, Rect rect,

View file

@ -182,15 +182,41 @@ void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader
const void* push, std::uint32_t pushBytes,
std::uint32_t gx, std::uint32_t gy, std::uint32_t gz) {
if (!firstDispatchThisFrame_) {
VkMemoryBarrier mb {
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
// Every UI pass read-modify-writes the same swapchain storage image
// (later passes overdraw earlier ones), so each dispatch must observe
// the previous dispatch's writes. The only resource the hazard touches
// is that one image, so scope the dependency to its subresource with a
// VkImageMemoryBarrier rather than a queue-wide VkMemoryBarrier: same
// correctness, but only this image's shader caches are flushed —
// unrelated buffers/images are no longer invalidated. The image stays
// in VK_IMAGE_LAYOUT_GENERAL (bound as a storage image), so this is a
// pure memory dependency, no layout transition.
//
// The execution dependency is still COMPUTE->COMPUTE over the whole
// queue, so the passes do NOT overlap — this is a narrower cache flush
// only. Eliminating the barriers entirely requires fusing the passes
// into one dispatch (tracked in #47); do not attempt that here.
VkImageMemoryBarrier ib {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT,
.oldLayout = VK_IMAGE_LAYOUT_GENERAL,
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = window_->images[window_->currentBuffer],
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1,
},
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
0, 0, nullptr, 0, nullptr, 1, &ib);
}
firstDispatchThisFrame_ = false;
shader.Dispatch(cmd, push, pushBytes, gx, gy, gz);

View file

@ -60,6 +60,15 @@ namespace Crafter {
stbtt_fontinfo font;
Font(const std::filesystem::path& font);
std::uint32_t GetLineWidth(const std::string_view text, float size);
// Map a target advance (px from the line start) to the nearest UTF-8
// codepoint boundary, returning its BYTE offset into `text`. A single
// left-to-right pass accumulates per-glyph advances (O(n) metric
// lookups), instead of re-walking the prefix for every boundary as a
// naive caller would. Cumulative advance is monotonic (advances are
// non-negative), so the scan stops at the first boundary past the
// closest one. Returned offsets land on codepoint boundaries, which are
// exactly the valid positions for a byte-based text cursor.
std::size_t NearestCursorByte(std::string_view text, float size, float target);
float LineHeight(float size);
float AscentPx(float size);
float ScaleForSize(float size);

View file

@ -441,6 +441,36 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
clipImpls.emplace_back("tests/UIClipFlag/main");
clc.GetInterfacesAndImplementations(ifaces, clipImpls);
cfg.tests.push_back(std::move(clipTest));
// Issue #56: InputField_HitTestCursor mapped a click x to a cursor byte
// offset by re-walking the prefix for every boundary (O(n^2) glyph
// metric lookups) over raw byte boundaries. It now delegates to
// Font::NearestCursorByte — one cumulative-advance pass over codepoint
// boundaries. The mapping is pure CPU over a TrueType file, so this test
// drives it directly: no Vulkan device or window. The font is copied
// next to the binary; the test also probes the project-root path.
Test hitTest;
Configuration& hc = hitTest.config;
hc.path = cfg.path;
hc.name = "InputFieldHitTest";
hc.outputName = "InputFieldHitTest";
hc.type = ConfigurationType::Executable;
hc.target = cfg.target;
hc.march = cfg.march;
hc.mtune = cfg.mtune;
hc.debug = cfg.debug;
hc.sysroot = cfg.sysroot;
hc.dependencies = cfg.dependencies;
hc.externalDependencies = cfg.externalDependencies;
hc.compileFlags = cfg.compileFlags;
hc.linkFlags = cfg.linkFlags;
hc.defines = cfg.defines;
hc.cFiles = cfg.cFiles;
hc.files = { fs::path("tests/InputFieldHitTest/font.ttf") };
std::vector<fs::path> hitImpls(impls.begin(), impls.end());
hitImpls.emplace_back("tests/InputFieldHitTest/main");
hc.GetInterfacesAndImplementations(ifaces, hitImpls);
cfg.tests.push_back(std::move(hitTest));
}
return cfg;

Binary file not shown.

View file

@ -0,0 +1,212 @@
/*
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
*/
// Regression test for issue #56: InputField_HitTestCursor used to map a click
// x-coord to a cursor byte offset by calling Font::GetLineWidth on every
// prefix `value[0..i]` for i = 1..size — each call re-walks from byte 0, so it
// was O(n^2) glyph-metric lookups. Worse, it iterated raw byte boundaries, so
// on multibyte UTF-8 input it could return an offset that splits a codepoint
// (an invalid cursor position, since cursorPos is a byte offset edited via
// value.erase(cursorPos, 1)).
//
// The hit test now delegates to Font::NearestCursorByte: one left-to-right
// cumulative-advance pass (O(n) metrics) over codepoint boundaries, returning
// the byte offset of the boundary whose cumulative advance is nearest the
// target. This test pins:
// - the result matches an independent brute-force "nearest codepoint
// boundary" reference (built from per-prefix GetLineWidth) for many click
// positions across ASCII and multibyte strings,
// - clicks at/left of the text start return 0, clicks far past the end
// return value.size() (the end-of-text caret),
// - every returned offset lands on a codepoint boundary (never mid-sequence),
// - clicking exactly on a glyph's left edge lands on that glyph's boundary.
//
// Pure CPU + a TrueType file — no Vulkan device or window needed. The font is
// copied next to the binary; the test also probes the project-root path.
#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/InputFieldHitTest/font.ttf",
"../../examples/HelloUI/font.ttf" }) {
if (std::filesystem::exists(cand)) return cand;
}
return "font.ttf";
}
// All codepoint-boundary byte offsets of `s`, including 0 and s.size().
std::vector<std::size_t> Boundaries(std::string_view s) {
std::vector<std::size_t> b{0};
std::size_t i = 0;
while (i < s.size()) {
std::uint32_t cp = DecodeUtf8(s, i); // advances i to the next boundary
if (cp == 0) break;
b.push_back(i);
}
return b;
}
// Independent reference: the codepoint boundary whose prefix advance is
// nearest `target`, ties resolved to the earlier boundary (matching the
// implementation's strict `d < bestDist`). Built with per-prefix GetLineWidth
// — the slow path the optimisation replaces, used here only as the oracle.
std::size_t NearestBoundaryRef(Font& font, std::string_view s, float size, float target) {
if (target <= 0.0f) return 0;
auto bounds = Boundaries(s);
std::size_t best = 0;
float bestDist = std::abs(target); // boundary 0 has width 0
for (std::size_t k = 1; k < bounds.size(); ++k) {
float w = static_cast<float>(font.GetLineWidth(s.substr(0, bounds[k]), size));
float d = std::abs(target - w);
if (d < bestDist) {
bestDist = d;
best = bounds[k];
}
// No early break: the reference scans every boundary regardless, so a
// mismatch would also catch any over-eager break in the implementation.
}
return best;
}
bool IsBoundary(std::string_view s, std::size_t off) {
if (off > s.size()) return false;
for (std::size_t b : Boundaries(s)) if (b == off) return true;
return false;
}
const InputFieldColors kColors{
{0, 0, 0, 1}, {0, 0, 0, 1}, {0, 0, 0, 1}, {0, 0, 0, 1},
{1, 1, 1, 1}, {1, 1, 1, 1},
};
// Drive the public hit-test for a click at window-x `clickX`, given a field
// rect at `rectX`. paddingX is taken from kColors.
std::size_t Hit(const InputField& f, Font& font, float size, float rectX, float clickX) {
Rect rect{rectX, 0.0f, 200.0f, 24.0f};
return InputField_HitTestCursor(f, rect, clickX, font, size, kColors);
}
} // namespace
int main() {
Font font(FindFont());
constexpr float kSize = 18.0f;
const float rectX = 30.0f;
const float textStart = rectX + kColors.paddingX; // x where the text begins
const std::array<std::string_view, 4> samples = {
"Hello, world",
"1234567890",
"iiiiWWWWmmmm", // wildly varying advances
"caf\xC3\xA9 na\xC3\xAFve \xE2\x9C\x93", // "café naïve ✓" — 2- and 3-byte cps
};
// 1. Across each sample, sweep target px and compare to the oracle.
bool sweepOk = true;
bool allBoundaries = true;
for (std::string_view s : samples) {
InputField f;
f.type = InputFieldType::Text;
f.value = std::string(s);
float fullW = static_cast<float>(font.GetLineWidth(s, kSize));
// Step finely enough to cross every glyph edge several times, and run
// past the end of the text to exercise the end-of-text caret.
for (float t = -10.0f; t <= fullW + 40.0f; t += 1.0f) {
std::size_t got = Hit(f, font, kSize, rectX, textStart + t);
std::size_t ref = NearestBoundaryRef(font, s, kSize, t);
if (got != ref) {
sweepOk = false;
std::println(" mismatch on \"{}\": target={} got={} ref={}",
s, t, got, ref);
}
if (!IsBoundary(s, got)) allBoundaries = false;
}
}
Check(sweepOk, "hit test matches the brute-force nearest-boundary oracle across a px sweep");
Check(allBoundaries, "every returned offset lands on a UTF-8 codepoint boundary");
// 2. Clicks at or left of the text start return 0.
{
InputField f; f.value = "Hello";
Check(Hit(f, font, kSize, rectX, textStart) == 0,
"click exactly at the text start returns offset 0");
Check(Hit(f, font, kSize, rectX, textStart - 5.0f) == 0,
"click left of the text start returns offset 0");
Check(Hit(f, font, kSize, rectX, 0.0f) == 0,
"click far to the left of the field returns offset 0");
}
// 3. A click far past the end lands on the end-of-text caret (value.size()).
{
InputField f; f.value = "Hello";
float fullW = static_cast<float>(font.GetLineWidth(f.value, kSize));
Check(Hit(f, font, kSize, rectX, textStart + fullW + 1000.0f) == f.value.size(),
"click far past the end returns value.size()");
}
// 4. An empty field always resolves to offset 0.
{
InputField f; f.value = "";
Check(Hit(f, font, kSize, rectX, textStart + 50.0f) == 0,
"empty field returns offset 0 regardless of click x");
}
// 5. Clicking just past a glyph's right edge selects that glyph's boundary
// (cursor sits after the glyph). Walk the boundaries of an ASCII string
// and click slightly past each cumulative advance.
{
std::string_view s = "abcXYZ";
InputField f; f.value = std::string(s);
auto bounds = Boundaries(s);
bool edgeOk = true;
for (std::size_t k = 1; k < bounds.size(); ++k) {
float w = static_cast<float>(font.GetLineWidth(s.substr(0, bounds[k]), kSize));
// Click a hair before the glyph's right edge → nearest boundary is
// this one (its advance is the closest of all boundaries).
std::size_t got = Hit(f, font, kSize, rectX, textStart + w - 0.5f);
if (got != bounds[k]) {
edgeOk = false;
std::println(" edge mismatch at boundary {} (w={}): got {}",
bounds[k], w, got);
}
}
Check(edgeOk, "clicking near a glyph's right edge resolves to that glyph's boundary");
}
if (failures == 0) std::println("\nAll InputField hit-test checks passed.");
else std::println("\n{} InputField hit-test check(s) FAILED.", failures);
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}