//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® /* Crafter®.Graphics Copyright (C) 2026 Catcrafts® catcrafts.net */ // Backend-agnostic UIRenderer methods. FillHeader and ShapeText do not // touch any GPU API; they only read window dimensions and (for ShapeText) // the CPU-side font atlas. Split out so both Vulkan and WebGPU impls // share the same source. module Crafter.Graphics:UI_shared_impl; import :UI; import :Window; import :Font; import :FontAtlas; import :GraphicsTypes; import std; using namespace Crafter; UIDispatchHeader UIRenderer::FillHeader(std::uint32_t itemBufferSlot, std::uint32_t itemCount, std::array clipRectPx, std::uint32_t flags) const noexcept { UIDispatchHeader h{}; h.outImage = outImageSlot_; h.itemBuffer = itemBufferSlot; h.surfaceWidth = window_->width; h.surfaceHeight = window_->height; h.clipX = clipRectPx[0]; h.clipY = clipRectPx[1]; h.clipW = clipRectPx[2]; h.clipH = clipRectPx[3]; h.itemCount = itemCount; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM h.frameIdx = window_->currentFrame; #else h.frameIdx = 0; #endif 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, std::array color, GlyphItem* out, std::uint32_t outCapacity, float* outAdvance, TextAlign align) { if (fontAtlas == nullptr) { std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)"); std::abort(); } // Look up (or build) the origin-relative shaped run. It is stored with // the pen starting at x=0, baseline=0 so a cache hit only needs the // translate below. The whole string is shaped (no outCapacity limit) so // a later call with a larger buffer still gets the full run. // Probe with a borrowing view key (text is a string_view onto utf8) so a // cache hit copies and hashes no string. The owning std::string is only // built on a miss, below, for the emplace. ShapedRunViewKey viewKey{&font, pxSize, color, utf8}; auto it = shapedRuns_.find(viewKey); if (it == shapedRuns_.end()) { // Miss: shape from scratch. ShapedRun run; ShapeRun(font, pxSize, utf8, color, run); // Evict the least-recently-used run (LRU list back) on overflow rather // than clearing the whole cache. The back is whatever has gone longest // without a reshape — a stale unique string, never a stable label // (those are reshaped every frame and sit at the front), so the hot set // survives and the next frame doesn't trigger a full-UI reshape. if (shapedRuns_.size() >= kMaxShapedRuns) { const ShapedRunKey* victim = shapedRunsLru_.back(); shapedRunsLru_.pop_back(); shapedRuns_.erase(*victim); } // Materialise the owning key (the probe above borrowed utf8) only now, // on the miss path, for the emplace. ShapedRunKey key{&font, pxSize, color, std::string(utf8)}; it = shapedRuns_.emplace(std::move(key), std::move(run)).first; it->second.lruIt = shapedRunsLru_.insert(shapedRunsLru_.begin(), &it->first); } else { // Hit: promote to most-recently-used. splice is O(1) and keeps lruIt // (and every other iterator) valid. shapedRunsLru_.splice(shapedRunsLru_.begin(), shapedRunsLru_, it->second.lruIt); // The atlas only sees the glyphs it is asked for, so a hit tells it // the run's are in use, or it would take their cells once full (#55). // If it already has taken one, the run samples whatever glyph holds // the cell now: shape it again, as a run with a glyph that had no // room must be until the glyph gets one. Touched first either way, so // the run's glyphs that still hold their cells are marked before the // shaping asks for its missing ones. ShapedRun& hit = it->second; const bool cellsHeld = fontAtlas->Touch(hit.cells); if (!hit.complete || !cellsHeld) { ShapeRun(font, pxSize, utf8, color, hit); } } const ShapedRun& run = it->second; if (outAdvance) *outAdvance = run.advance; // Fold the alignment shift into the translate so callers don't need a // second pass over the glyphs. const float alignShift = (align == TextAlign::Center) ? -run.advance * 0.5f : (align == TextAlign::Right) ? -run.advance : 0.0f; const float tx = x + alignShift; std::uint32_t written = 0; for (const GlyphItem& src : run.glyphs) { if (written >= outCapacity) break; GlyphItem& gi = out[written++]; gi = src; gi.x += tx; gi.y += baselineY; } return written; } void UIRenderer::ShapeRun(Font& font, float pxSize, std::string_view utf8, std::array color, ShapedRun& run) { // EnsureAndGet each glyph: brand-new ones rasterise into the atlas and // mark it dirty for the next upload, and every one is marked used in this // frame. A glyph that finds no room draws nothing, but still advances // the pen, so the rest of the line stays where it belongs. const float scale = pxSize / FontAtlas::kBaseSize; run.glyphs.clear(); run.cells.clear(); run.complete = true; float cursor = 0.0f; std::size_t i = 0; while (i < utf8.size()) { std::uint32_t cp = DecodeUtf8(utf8, i); if (cp == 0) break; if (cp == '\n') { continue; } const Glyph& g = fontAtlas->EnsureAndGet(font, cp); if (g.w > 0 && g.h > 0) { if (g.Resident()) { GlyphItem gi; gi.x = cursor + g.xoff * scale; gi.y = g.yoff * scale; gi.w = g.w * scale; gi.h = g.h * scale; gi.u0 = g.u0; gi.v0 = g.v0; gi.u1 = g.u1; gi.v1 = g.v1; gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3]; run.glyphs.push_back(gi); run.cells.push_back(g.handle); } else { run.complete = false; } } cursor += g.advance * scale; } run.advance = cursor; } void UIRenderer::InvalidateFont(const Font& font) noexcept { // Drop matching runs from both the map and the LRU order list, keeping the // two in sync (the list points at keys owned by the map). for (auto it = shapedRuns_.begin(); it != shapedRuns_.end(); ) { if (it->first.font == &font) { shapedRunsLru_.erase(it->second.lruIt); it = shapedRuns_.erase(it); } else { ++it; } } // The atlas's glyphs of the font too: another Font at the same address // would get their metrics and cells, and the glyphs waiting for a cell // keep the Font* until the next frame. if (fontAtlas != nullptr) fontAtlas->InvalidateFont(font); } bool UIRenderer::IsShapedRunCached(const Font& font, float pxSize, std::array color, std::string_view utf8) const { return shapedRuns_.find(ShapedRunViewKey{&font, pxSize, color, utf8}) != shapedRuns_.end(); }