2026-05-18 04:58:52 +02:00
|
|
|
/*
|
|
|
|
|
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<float,4> 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_->currentBuffer;
|
|
|
|
|
#else
|
|
|
|
|
h.frameIdx = 0;
|
|
|
|
|
#endif
|
2026-06-16 16:57:07 +00:00
|
|
|
h.flags = ClipFlags(clipRectPx, window_->width, window_->height, flags);
|
2026-05-18 04:58:52 +02:00
|
|
|
h._pad = 0;
|
|
|
|
|
return h;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 16:57:07 +00:00
|
|
|
std::uint32_t UIRenderer::ClipFlags(std::array<float,4> 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<float>(surfaceWidth)
|
|
|
|
|
&& clipRectPx[1] + clipRectPx[3] >= static_cast<float>(surfaceHeight);
|
|
|
|
|
return clipCoversSurface ? (flags & ~kUIFlagClip) : (flags | kUIFlagClip);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
|
|
|
|
float x, float baselineY,
|
|
|
|
|
std::string_view utf8,
|
|
|
|
|
std::array<float,4> color,
|
|
|
|
|
GlyphItem* out, std::uint32_t outCapacity,
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
float* outAdvance,
|
|
|
|
|
TextAlign align) {
|
2026-05-18 04:58:52 +02:00
|
|
|
if (fontAtlas == nullptr) {
|
|
|
|
|
std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)");
|
|
|
|
|
std::abort();
|
|
|
|
|
}
|
|
|
|
|
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
// 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.
|
2026-06-18 13:19:11 +00:00
|
|
|
// 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);
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
if (it == shapedRuns_.end()) {
|
|
|
|
|
// Miss: shape from scratch. Ensure() each glyph so brand-new ones
|
|
|
|
|
// rasterise into the atlas and mark it dirty for the next upload —
|
|
|
|
|
// a hit skips this, which is safe because atlas entries are never
|
|
|
|
|
// evicted once placed.
|
|
|
|
|
const float scale = pxSize / FontAtlas::kBaseSize;
|
|
|
|
|
ShapedRun run;
|
|
|
|
|
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; }
|
2026-05-18 04:58:52 +02:00
|
|
|
|
2026-06-16 15:43:58 +00:00
|
|
|
const Glyph* g = fontAtlas->EnsureAndGet(font, cp);
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
if (g == nullptr) continue;
|
2026-05-18 04:58:52 +02:00
|
|
|
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
if (g->w > 0 && g->h > 0) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
cursor += g->advance * scale;
|
2026-05-18 04:58:52 +02:00
|
|
|
}
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
run.advance = cursor;
|
|
|
|
|
|
|
|
|
|
if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear();
|
2026-06-18 13:19:11 +00:00
|
|
|
ShapedRunKey key{&font, pxSize, color, std::string(utf8)};
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
it = shapedRuns_.emplace(std::move(key), std::move(run)).first;
|
2026-05-18 04:58:52 +02:00
|
|
|
}
|
|
|
|
|
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-18 04:58:52 +02:00
|
|
|
return written;
|
|
|
|
|
}
|
perf(text): cache shaped runs in ShapeText (#52)
ShapeText re-decoded UTF-8, re-looked-up every glyph, and recomputed
layout every frame for static strings (DrawText / EmitCenteredLabel run
inside per-frame onBuild). DrawText and EmitCenteredLabel also did a
second full pass over the glyphs to apply alignment.
Memoize the shaped run, keyed by (Font*, pxSize, color, utf8), as an
origin-relative vector<GlyphItem>. A cache hit skips decode/lookup/layout
and just translates the cached run into the output buffer by
(x + alignShift, baselineY). The alignment shift (Center = -advance/2,
Right = -advance) is folded into ShapeText's single pass, dropping the
second loop in both callers. TextAlign moves to the :UI partition so
ShapeText can take it.
Pure CPU memoization — the glyph buffer is fully rewritten and re-flushed
every frame, so a hit is byte-equivalent. A miss still calls Ensure so new
glyphs rasterise and dirty the atlas; hits don't (atlas entries are never
evicted). InvalidateFont drops a font's runs before it is destroyed (the
cache, like FontAtlas's glyph cache, keys on the Font pointer). A soft cap
bounds memory against pathological per-frame-unique text.
Adds the ShapeTextCache test (15 checks): hit == miss byte-for-byte, hits
don't touch the atlas, translate/alignment/truncation/InvalidateFont.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:40:52 +00:00
|
|
|
|
|
|
|
|
void UIRenderer::InvalidateFont(const Font& font) noexcept {
|
|
|
|
|
std::erase_if(shapedRuns_, [&font](const auto& kv) {
|
|
|
|
|
return kv.first.font == &font;
|
|
|
|
|
});
|
|
|
|
|
}
|