diff --git a/additional/dom-webgpu.js b/additional/dom-webgpu.js index c35456b..72ed20e 100644 --- a/additional/dom-webgpu.js +++ b/additional/dom-webgpu.js @@ -256,12 +256,17 @@ struct UIDispatchHeader { @group(1) @binding(0) var outTex : texture_storage_2d; @group(1) @binding(1) var prevTex : texture_2d; +// Renderer-reserved high flag bit (mirrors ui-shared.glsl::UI_FLAG_CLIP). +const UI_FLAG_CLIP : u32 = 0x80000000u; + fn uiResolvePixel(coord: vec2) -> bool { if (coord.x >= hdr.surfaceW || coord.y >= hdr.surfaceH) { return false; } - let fx = f32(coord.x); let fy = f32(coord.y); - if (fx < hdr.clipX || fy < hdr.clipY) { return false; } - if (fx >= hdr.clipX + hdr.clipW) { return false; } - if (fy >= hdr.clipY + hdr.clipH) { return false; } + if ((hdr.flags & UI_FLAG_CLIP) != 0u) { + let fx = f32(coord.x); let fy = f32(coord.y); + if (fx < hdr.clipX || fy < hdr.clipY) { return false; } + if (fx >= hdr.clipX + hdr.clipW) { return false; } + if (fy >= hdr.clipY + hdr.clipH) { return false; } + } return true; } diff --git a/implementations/Crafter.Graphics-Font.cpp b/implementations/Crafter.Graphics-Font.cpp index 18bbf89..6b310c8 100644 --- a/implementations/Crafter.Graphics-Font.cpp +++ b/implementations/Crafter.Graphics-Font.cpp @@ -56,6 +56,23 @@ Font::Font(const std::filesystem::path& fontFilePath) { this->ascent = ascent; this->descent = descent; 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(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(cp), &advance, &lsb); + return advanceUnits_.emplace(cp, advance).first->second; } std::uint32_t Font::GetLineWidth(const std::string_view text, float size) { @@ -65,13 +82,33 @@ std::uint32_t Font::GetLineWidth(const std::string_view text, float size) { while (i < text.size()) { std::uint32_t cp = DecodeUtf8(text, i); if (cp == 0) break; - int advance, lsb; - stbtt_GetCodepointHMetrics(&font, static_cast(cp), &advance, &lsb); - lineWidth += (int)(advance * scale); + lineWidth += (int)(AdvanceUnits(cp) * scale); } 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; + cumWidth += (int)(AdvanceUnits(cp) * 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; diff --git a/implementations/Crafter.Graphics-InputField.cpp b/implementations/Crafter.Graphics-InputField.cpp index b59f192..433fddf 100644 --- a/implementations/Crafter.Graphics-InputField.cpp +++ b/implementations/Crafter.Graphics-InputField.cpp @@ -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(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, diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index 6ac2c62..c3dee39 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -38,11 +38,27 @@ UIDispatchHeader UIRenderer::FillHeader(std::uint32_t itemBufferSlot, #else h.frameIdx = 0; #endif - h.flags = flags; + h.flags = ClipFlags(clipRectPx, window_->width, window_->height, flags); h._pad = 0; return h; } +std::uint32_t UIRenderer::ClipFlags(std::array clipRectPx, + std::uint32_t surfaceWidth, + std::uint32_t surfaceHeight, + std::uint32_t flags) noexcept { + // The reserved kUIFlagClip bit signals "clip rect is narrower than the + // surface". When the rect already covers the whole surface (the common + // {0,0,1e9,1e9} default), the four per-pixel clip compares in + // uiResolveScreenPixel can never reject an in-surface pixel, so we leave + // the bit clear and the shader skips them — pixel-identical output. + const bool clipCoversSurface = + clipRectPx[0] <= 0.0f && clipRectPx[1] <= 0.0f + && clipRectPx[0] + clipRectPx[2] >= static_cast(surfaceWidth) + && clipRectPx[1] + clipRectPx[3] >= static_cast(surfaceHeight); + return clipCoversSurface ? (flags & ~kUIFlagClip) : (flags | kUIFlagClip); +} + std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, float x, float baselineY, std::string_view utf8, diff --git a/implementations/Crafter.Graphics-UI.cpp b/implementations/Crafter.Graphics-UI.cpp index 3ca9ff5..4ec52e8 100644 --- a/implementations/Crafter.Graphics-UI.cpp +++ b/implementations/Crafter.Graphics-UI.cpp @@ -219,7 +219,15 @@ void UIRenderer::DispatchFused(GraphicsCommandBuffer cmd, pc.outImage = outImageSlot_; pc.fontTexture = fontAtlasImageSlot_; pc.fontSampler = fontAtlasSamplerSlot_; - pc.flags = 0; + // Per-category clip-active bits, reusing the same surface-coverage test the + // standard path uses (ClipFlags). A category whose clip already covers the + // surface clears its bit so the kernel skips that category's per-pixel clip + // compares (the common full-surface case) — pixel-identical either way. + auto clipBit = [&](const FusedBatch& b, std::uint32_t bit) -> std::uint32_t { + return (ClipFlags(b.clipRectPx, window_->width, window_->height, 0) & kUIFlagClip) ? bit : 0u; + }; + pc.flags = clipBit(quads, 0x1u) | clipBit(circles, 0x2u) + | clipBit(images, 0x4u) | clipBit(text, 0x8u); pc.surfaceWidth = window_->width; pc.surfaceHeight = window_->height; pc.frameIdx = window_->currentBuffer; @@ -239,15 +247,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, - .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, - .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, + // 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); diff --git a/interfaces/Crafter.Graphics-Font.cppm b/interfaces/Crafter.Graphics-Font.cppm index 90faa8b..759c98b 100644 --- a/interfaces/Crafter.Graphics-Font.cppm +++ b/interfaces/Crafter.Graphics-Font.cppm @@ -60,8 +60,29 @@ 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); + + // 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 asciiAdvance_; // -1 = uncached + std::unordered_map advanceUnits_; }; } diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index a0d904d..374ed5a 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -59,6 +59,12 @@ export namespace Crafter { }; static_assert(sizeof(UIDispatchHeader) == 48); + // Reserved `flags` bit (mirrors shaders/ui-shared.glsl::UI_FLAG_CLIP). + // FillHeader sets it when the clip rect is narrower than the surface so + // the standard shaders can skip the per-pixel clip compares otherwise. + // The remaining low bits stay free for user-defined feature flags. + inline constexpr std::uint32_t kUIFlagClip = 0x80000000u; + // ─── fused-dispatch push-constant header (issue #47) ──────────────── // Mirrors the PC block in shaders/ui-fused.comp.glsl byte-for-byte: every // member is vec4-aligned, no padding holes, exactly 128 bytes (the @@ -71,7 +77,7 @@ export namespace Crafter { std::uint32_t outImage; // swapchain image heap slot std::uint32_t fontTexture; // font-atlas image slot (text phase) std::uint32_t fontSampler; // font-atlas sampler slot (text phase) - std::uint32_t flags; // reserved — keep zeroed + std::uint32_t flags; // per-category clip-active bits (0x1 quads .. 0x8 text) std::uint32_t surfaceWidth; std::uint32_t surfaceHeight; std::uint32_t frameIdx; @@ -200,6 +206,18 @@ export namespace Crafter { std::array clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}, std::uint32_t flags = 0) const noexcept; + // Builds the header `flags` word: the caller's user flags with the + // reserved kUIFlagClip bit set iff `clipRectPx` does not already cover + // the whole surface. When the clip rect spans the surface, the four + // per-pixel clip compares in uiResolveScreenPixel can never reject an + // in-surface pixel, so the bit stays clear and the shaders skip them. + // Static + dimension-parameterised so the decision is unit-testable + // without a live Window; FillHeader feeds it the current window size. + static std::uint32_t ClipFlags(std::array clipRectPx, + std::uint32_t surfaceWidth, + std::uint32_t surfaceHeight, + std::uint32_t flags) noexcept; + void DispatchQuads(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount, std::array clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f}); void DispatchCircles(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount, diff --git a/project.cpp b/project.cpp index fe5f67e..14769fc 100644 --- a/project.cpp +++ b/project.cpp @@ -443,6 +443,93 @@ extern "C" Configuration CrafterBuildProject(std::span a fc.GetInterfacesAndImplementations(ifaces, fusedImpls); fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(fusedTest)); + + // 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 advImpls(impls.begin(), impls.end()); + advImpls.emplace_back("tests/FontAdvanceCache/main"); + vc.GetInterfacesAndImplementations(ifaces, advImpls); + cfg.tests.push_back(std::move(advTest)); + + // Issue #50: uiResolveScreenPixel now gates its per-pixel clip-rect + // compares on the reserved kUIFlagClip bit, which FillHeader sets only + // when the clip rect is narrower than the surface. The decision lives + // in UIRenderer::ClipFlags — pure CPU logic over the clip rect and the + // surface size — so this test drives it directly with synthetic + // dimensions, no Window or GPU device needed at runtime. + Test clipTest; + Configuration& clc = clipTest.config; + clc.path = cfg.path; + clc.name = "UIClipFlag"; + clc.outputName = "UIClipFlag"; + clc.type = ConfigurationType::Executable; + clc.target = cfg.target; + clc.march = cfg.march; + clc.mtune = cfg.mtune; + clc.debug = cfg.debug; + clc.sysroot = cfg.sysroot; + clc.dependencies = cfg.dependencies; + clc.externalDependencies = cfg.externalDependencies; + clc.compileFlags = cfg.compileFlags; + clc.linkFlags = cfg.linkFlags; + clc.defines = cfg.defines; + clc.cFiles = cfg.cFiles; + std::vector clipImpls(impls.begin(), impls.end()); + 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 hitImpls(impls.begin(), impls.end()); + hitImpls.emplace_back("tests/InputFieldHitTest/main"); + hc.GetInterfacesAndImplementations(ifaces, hitImpls); + cfg.tests.push_back(std::move(hitTest)); } return cfg; diff --git a/shaders/ui-fused.comp.glsl b/shaders/ui-fused.comp.glsl index f43bbf2..998061b 100644 --- a/shaders/ui-fused.comp.glsl +++ b/shaders/ui-fused.comp.glsl @@ -46,6 +46,16 @@ layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; const float ON_EDGE = 128.0 / 255.0; const float DIST_SCALE = 32.0; +// Per-category clip-active bits packed into pc.misc.w (the fused analogue of +// ui-shared.glsl's UI_FLAG_CLIP). A bit is set only when that category's clip +// rect is narrower than the surface; when clear, the category skips its four +// per-pixel clip compares entirely (the common full-surface case). The branch +// is push-constant-uniform, so it never diverges. +const uint UI_FUSED_CLIP_QUADS = 0x1u; +const uint UI_FUSED_CLIP_CIRCLES = 0x2u; +const uint UI_FUSED_CLIP_IMAGES = 0x4u; +const uint UI_FUSED_CLIP_TEXT = 0x8u; + // Generic per-chunk cooperative-cull scratch, REUSED across the four phases // (they composite sequentially with a barrier between, so the storage is free // once a phase's last read completes). Keeping one shared set instead of four @@ -95,7 +105,8 @@ void main() { { uint heap = pc.itemBuffers.x; uint count = pc.itemCounts.x; - bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipQuads); + bool inClip = inSurface && + ((pc.misc.w & UI_FUSED_CLIP_QUADS) == 0u || uiPixelInClipRect(pxu, pc.clipQuads)); for (uint base = 0u; base < count; base += UI_CHUNK) { uint idx = base + lid; bool keep = false; @@ -151,7 +162,8 @@ void main() { { uint heap = pc.itemBuffers.y; uint count = pc.itemCounts.y; - bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipCircles); + bool inClip = inSurface && + ((pc.misc.w & UI_FUSED_CLIP_CIRCLES) == 0u || uiPixelInClipRect(pxu, pc.clipCircles)); for (uint base = 0u; base < count; base += UI_CHUNK) { uint idx = base + lid; bool keep = false; @@ -210,7 +222,8 @@ void main() { { uint heap = pc.itemBuffers.z; uint count = pc.itemCounts.z; - bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipImages); + bool inClip = inSurface && + ((pc.misc.w & UI_FUSED_CLIP_IMAGES) == 0u || uiPixelInClipRect(pxu, pc.clipImages)); for (uint base = 0u; base < count; base += UI_CHUNK) { uint idx = base + lid; bool keep = false; @@ -260,7 +273,8 @@ void main() { { uint heap = pc.itemBuffers.w; uint count = pc.itemCounts.w; - bool inClip = inSurface && uiPixelInClipRect(pxu, pc.clipText); + bool inClip = inSurface && + ((pc.misc.w & UI_FUSED_CLIP_TEXT) == 0u || uiPixelInClipRect(pxu, pc.clipText)); for (uint base = 0u; base < count; base += UI_CHUNK) { uint idx = base + lid; bool keep = false; diff --git a/shaders/ui-shared.glsl b/shaders/ui-shared.glsl index 18c4c15..baa50de 100644 --- a/shaders/ui-shared.glsl +++ b/shaders/ui-shared.glsl @@ -27,10 +27,19 @@ struct UIDispatchHeader { vec4 clipRectPx; // (xy, wh) — every standard shader honors this uint itemCount; uint frameIdx; - uint flags; // user-defined feature bits + uint flags; // feature bits — high bit reserved by the renderer uint _pad; // reserved — keep zeroed }; +// ─── header flag bits ─────────────────────────────────────────────────── +// `flags` is otherwise free for user-defined feature bits, but the renderer +// reserves the high bit: UI_FLAG_CLIP is set by UIRenderer::FillHeader when +// the clip rect does not already cover the whole surface. uiResolveScreenPixel +// gates its four clipRectPx compares on this bit so the overwhelmingly common +// full-surface case (clipRect == {0,0,1e9,1e9}) skips them entirely. The +// branch is push-constant-uniform across the dispatch, so it never diverges. +const uint UI_FLAG_CLIP = 0x80000000u; + // ─── standard item structs ────────────────────────────────────────────── // These match the C++ Crafter::QuadItem / CircleItem / ImageItem / GlyphItem // byte-for-byte under std430. @@ -121,9 +130,11 @@ QuadItem LoadQuadItem(uint heap, uint i) { bool uiResolveScreenPixel(UIDispatchHeader hdr, out ivec2 screenPx) { uvec2 px = gl_GlobalInvocationID.xy; if (px.x >= hdr.surfaceSize.x || px.y >= hdr.surfaceSize.y) return false; - if (float(px.x) < hdr.clipRectPx.x || float(px.y) < hdr.clipRectPx.y) return false; - if (float(px.x) >= hdr.clipRectPx.x + hdr.clipRectPx.z) return false; - if (float(px.y) >= hdr.clipRectPx.y + hdr.clipRectPx.w) return false; + if ((hdr.flags & UI_FLAG_CLIP) != 0u) { + if (float(px.x) < hdr.clipRectPx.x || float(px.y) < hdr.clipRectPx.y) return false; + if (float(px.x) >= hdr.clipRectPx.x + hdr.clipRectPx.z) return false; + if (float(px.y) >= hdr.clipRectPx.y + hdr.clipRectPx.w) return false; + } screenPx = ivec2(px); return true; } diff --git a/tests/FontAdvanceCache/font.ttf b/tests/FontAdvanceCache/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/tests/FontAdvanceCache/font.ttf differ diff --git a/tests/FontAdvanceCache/main.cpp b/tests/FontAdvanceCache/main.cpp new file mode 100644 index 0000000..d123790 --- /dev/null +++ b/tests/FontAdvanceCache/main.cpp @@ -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 + +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(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('A')); + font.GetLineWidth(text, 6.0f); + std::int32_t aSmall = font.AdvanceUnits(static_cast('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(font.AdvanceUnits(0x00E9)); + Check(static_cast(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; +} diff --git a/tests/InputFieldHitTest/font.ttf b/tests/InputFieldHitTest/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/tests/InputFieldHitTest/font.ttf differ diff --git a/tests/InputFieldHitTest/main.cpp b/tests/InputFieldHitTest/main.cpp new file mode 100644 index 0000000..0961337 --- /dev/null +++ b/tests/InputFieldHitTest/main.cpp @@ -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 + +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 Boundaries(std::string_view s) { + std::vector 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(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 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(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(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(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; +} diff --git a/tests/UIClipFlag/main.cpp b/tests/UIClipFlag/main.cpp new file mode 100644 index 0000000..1357e1e --- /dev/null +++ b/tests/UIClipFlag/main.cpp @@ -0,0 +1,105 @@ +/* +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 #50: uiResolveScreenPixel used to run four float +// compares against hdr.clipRectPx for every pixel of every dispatch, even +// though the clip rect is the full-surface default {0,0,1e9,1e9} in the +// overwhelmingly common case. The compares are now gated on the reserved +// kUIFlagClip bit, which UIRenderer::FillHeader sets (via ClipFlags) only when +// the clip rect is actually narrower than the surface. +// +// ClipFlags is the pure CPU decision behind that gate: given a clip rect, the +// surface size, and the caller's user flags, it returns the header flag word +// with kUIFlagClip set iff the rect does not cover the whole surface. This +// test drives it directly with synthetic dimensions — no Window or device. +// +// The contract it must hold for the shader gate to be correct: +// * full-surface (and over-covering) clips must NOT set the bit, so the +// shader skips the compares — and the skipped compares would never have +// rejected an in-surface pixel anyway, so output is pixel-identical; +// * any clip narrower than the surface on ANY edge MUST set the bit; +// * the caller's user flag bits are preserved either way; only the reserved +// high bit is touched. + +#include + +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; +} + +constexpr std::array kFullDefault = {0.0f, 0.0f, 1e9f, 1e9f}; + +bool ClipBitSet(std::array rect, std::uint32_t w, std::uint32_t h, + std::uint32_t userFlags = 0) { + return (UIRenderer::ClipFlags(rect, w, h, userFlags) & kUIFlagClip) != 0u; +} + +} // namespace + +int main() { + constexpr std::uint32_t W = 1920, H = 1080; + + // --- full-surface clips leave the bit clear (the fast path) ----------- + Check(!ClipBitSet(kFullDefault, W, H), + "the {0,0,1e9,1e9} default does not set the clip bit"); + Check(!ClipBitSet({0.0f, 0.0f, float(W), float(H)}, W, H), + "a clip exactly matching the surface does not set the clip bit"); + Check(!ClipBitSet({-5.0f, -5.0f, float(W) + 10.0f, float(H) + 10.0f}, W, H), + "a clip that over-covers the surface does not set the clip bit"); + + // --- any narrower edge sets the bit ----------------------------------- + Check(ClipBitSet({0.0f, 0.0f, float(W) - 1.0f, float(H)}, W, H), + "a clip one pixel short on the right edge sets the clip bit"); + Check(ClipBitSet({0.0f, 0.0f, float(W), float(H) - 1.0f}, W, H), + "a clip one pixel short on the bottom edge sets the clip bit"); + Check(ClipBitSet({1.0f, 0.0f, float(W), float(H)}, W, H), + "a clip inset on the left edge sets the clip bit"); + Check(ClipBitSet({0.0f, 1.0f, float(W), float(H)}, W, H), + "a clip inset on the top edge sets the clip bit"); + Check(ClipBitSet({100.0f, 100.0f, 200.0f, 200.0f}, W, H), + "a small interior clip rect sets the clip bit"); + + // --- user flag bits survive the decision ------------------------------ + constexpr std::uint32_t kUser = 0x0000000Fu; // four low user bits + Check(UIRenderer::ClipFlags(kFullDefault, W, H, kUser) == kUser, + "full-surface clip preserves user flags and clears the reserved bit"); + Check(UIRenderer::ClipFlags({0.0f, 0.0f, 200.0f, 200.0f}, W, H, kUser) + == (kUser | kUIFlagClip), + "narrow clip preserves user flags and sets the reserved bit"); + // A caller that (wrongly) pre-set the reserved bit must not flip the + // decision for a full-surface clip — ClipFlags owns that bit. + Check(UIRenderer::ClipFlags(kFullDefault, W, H, kUIFlagClip | kUser) == kUser, + "full-surface clip clears a stale reserved bit the caller passed in"); + + if (failures != 0) { + std::println(std::cerr, "{} case(s) failed", failures); + return 1; + } + std::println(std::cout, "all UI clip-flag cases passed"); + return 0; +}