2026-03-09 20:10:19 +01:00
|
|
|
/*
|
|
|
|
|
Crafter®.Graphics
|
|
|
|
|
Copyright (C) 2026 Catcrafts®
|
2026-05-01 23:35:37 +02:00
|
|
|
catcrafts.net
|
2026-03-09 20:10:19 +01:00
|
|
|
|
|
|
|
|
This library is free software; you can redistribute it and/or
|
|
|
|
|
modify it under the terms of the GNU Lesser General Public
|
2026-05-01 23:35:37 +02:00
|
|
|
License version 3.0 as published by the Free Software Foundation;
|
2026-03-09 20:10:19 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
*/
|
2026-05-02 21:08:20 +02:00
|
|
|
module;
|
2026-05-18 02:07:48 +02:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
2026-05-02 21:08:20 +02:00
|
|
|
#include "vulkan/vulkan.h"
|
2026-05-18 04:58:52 +02:00
|
|
|
#endif
|
2026-05-01 23:35:37 +02:00
|
|
|
export module Crafter.Graphics:UI;
|
2026-05-02 21:08:20 +02:00
|
|
|
import std;
|
|
|
|
|
import Crafter.Event;
|
|
|
|
|
import :Window;
|
|
|
|
|
import :RenderPass;
|
2026-05-18 04:58:52 +02:00
|
|
|
import :GraphicsTypes;
|
|
|
|
|
import :FontAtlas;
|
|
|
|
|
import :Font;
|
|
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
|
|
|
import :Device;
|
2026-05-02 21:08:20 +02:00
|
|
|
import :DescriptorHeapVulkan;
|
|
|
|
|
import :ImageVulkan;
|
|
|
|
|
import :VulkanBuffer;
|
|
|
|
|
import :ComputeShader;
|
2026-05-18 04:58:52 +02:00
|
|
|
#else
|
|
|
|
|
import :DescriptorHeapWebGPU;
|
|
|
|
|
import :WebGPU;
|
|
|
|
|
import :WebGPUBuffer;
|
|
|
|
|
import :WebGPUComputeShader;
|
|
|
|
|
#endif
|
2026-03-09 20:10:19 +01:00
|
|
|
|
2026-05-02 21:08:20 +02:00
|
|
|
export namespace Crafter {
|
|
|
|
|
// ─── push-constant header ───────────────────────────────────────────
|
|
|
|
|
// Mirrors shaders/ui-shared.glsl::UIDispatchHeader byte-for-byte. User
|
|
|
|
|
// shaders MUST embed this as the first member of their push-constant
|
|
|
|
|
// struct so UIRenderer::FillHeader works.
|
|
|
|
|
struct UIDispatchHeader {
|
|
|
|
|
std::uint32_t outImage;
|
|
|
|
|
std::uint32_t itemBuffer;
|
|
|
|
|
std::uint32_t surfaceWidth;
|
|
|
|
|
std::uint32_t surfaceHeight;
|
|
|
|
|
float clipX, clipY, clipW, clipH;
|
|
|
|
|
std::uint32_t itemCount;
|
|
|
|
|
std::uint32_t frameIdx;
|
|
|
|
|
std::uint32_t flags;
|
|
|
|
|
std::uint32_t _pad;
|
|
|
|
|
};
|
|
|
|
|
static_assert(sizeof(UIDispatchHeader) == 48);
|
|
|
|
|
|
2026-06-16 16:57:07 +00:00
|
|
|
// 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;
|
|
|
|
|
|
2026-06-16 17:03:24 +00:00
|
|
|
// ─── 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
|
|
|
|
|
// guaranteed push-constant minimum). This is a SEPARATE contract — the
|
|
|
|
|
// per-element Dispatch* path and the frozen 48-byte UIDispatchHeader are
|
|
|
|
|
// untouched. DispatchFused builds this; users never fill it directly.
|
|
|
|
|
struct UIFusedHeader {
|
|
|
|
|
std::uint32_t itemBuffers[4]; // heap slots: quads, circles, images, text
|
|
|
|
|
std::uint32_t itemCounts[4]; // item counts: quads, circles, images, text
|
|
|
|
|
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)
|
2026-06-16 17:09:06 +00:00
|
|
|
std::uint32_t flags; // per-category clip-active bits (0x1 quads .. 0x8 text)
|
2026-06-16 17:03:24 +00:00
|
|
|
std::uint32_t surfaceWidth;
|
|
|
|
|
std::uint32_t surfaceHeight;
|
|
|
|
|
std::uint32_t frameIdx;
|
|
|
|
|
std::uint32_t _pad; // reserved — keep zeroed
|
|
|
|
|
float clipQuads[4]; // (x, y, w, h) clip per category
|
|
|
|
|
float clipCircles[4];
|
|
|
|
|
float clipImages[4];
|
|
|
|
|
float clipText[4];
|
|
|
|
|
};
|
|
|
|
|
static_assert(sizeof(UIFusedHeader) == 128);
|
|
|
|
|
|
|
|
|
|
// One category's input to UIRenderer::DispatchFused. A zero itemCount
|
|
|
|
|
// makes that category a free no-op (its kernel loop is zero-trip).
|
|
|
|
|
// clipRectPx defaults to "no clip", matching the Dispatch* default.
|
|
|
|
|
struct FusedBatch {
|
|
|
|
|
std::uint32_t bufferSlot = 0;
|
|
|
|
|
std::uint32_t itemCount = 0;
|
|
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f};
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-02 21:08:20 +02:00
|
|
|
// ─── standard item PODs (match GLSL std430) ─────────────────────────
|
|
|
|
|
struct QuadItem {
|
|
|
|
|
float x, y, w, h;
|
|
|
|
|
float r, g, b, a;
|
2026-05-18 04:58:52 +02:00
|
|
|
float cTL, cTR, cBR, cBL;
|
|
|
|
|
float outline, oR, oG, oB;
|
2026-05-02 21:08:20 +02:00
|
|
|
};
|
|
|
|
|
static_assert(sizeof(QuadItem) == 64);
|
|
|
|
|
|
|
|
|
|
struct CircleItem {
|
|
|
|
|
float cx, cy, radius, _p0;
|
|
|
|
|
float r, g, b, a;
|
|
|
|
|
float outline, oR, oG, oB;
|
|
|
|
|
};
|
|
|
|
|
static_assert(sizeof(CircleItem) == 48);
|
|
|
|
|
|
|
|
|
|
struct ImageItem {
|
|
|
|
|
float x, y, w, h;
|
|
|
|
|
float u0, v0, u1, v1;
|
|
|
|
|
float tR, tG, tB, tA;
|
|
|
|
|
std::uint32_t texSlot, sampSlot, _p1, _p2;
|
|
|
|
|
};
|
|
|
|
|
static_assert(sizeof(ImageItem) == 64);
|
|
|
|
|
|
|
|
|
|
struct GlyphItem {
|
|
|
|
|
float x, y, w, h;
|
|
|
|
|
float u0, v0, u1, v1;
|
|
|
|
|
float r, g, b, a;
|
|
|
|
|
};
|
|
|
|
|
static_assert(sizeof(GlyphItem) == 48);
|
|
|
|
|
|
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
|
|
|
// Where the X coordinate sits relative to the emitted glyph run. Used by
|
|
|
|
|
// UIRenderer::ShapeText and the Tier 3 text components (DrawText). Lives
|
|
|
|
|
// here (not in :UIComponents) so ShapeText can fold the alignment shift
|
|
|
|
|
// into its single pass.
|
|
|
|
|
enum class TextAlign : std::uint8_t { Left, Center, Right };
|
|
|
|
|
|
2026-05-02 21:08:20 +02:00
|
|
|
// ─── tiny rect-carving helper ───────────────────────────────────────
|
|
|
|
|
struct Rect {
|
|
|
|
|
float x = 0, y = 0, w = 0, h = 0;
|
|
|
|
|
|
|
|
|
|
enum class Anchor { Top, Bottom, Left, Right };
|
|
|
|
|
|
|
|
|
|
Rect SubRect(float size, Anchor a) const noexcept {
|
|
|
|
|
switch (a) {
|
|
|
|
|
case Anchor::Top: return { x, y, w, std::min(size, h) };
|
|
|
|
|
case Anchor::Bottom: return { x, y + h - std::min(size, h), w, std::min(size, h) };
|
|
|
|
|
case Anchor::Left: return { x, y, std::min(size, w), h };
|
|
|
|
|
case Anchor::Right: return { x + w - std::min(size, w), y, std::min(size, w), h };
|
|
|
|
|
}
|
|
|
|
|
return *this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Rect Inset(float padding) const noexcept {
|
|
|
|
|
return Inset(padding, padding, padding, padding);
|
|
|
|
|
}
|
|
|
|
|
Rect Inset(float top, float right, float bottom, float left) const noexcept {
|
|
|
|
|
float nw = std::max(0.0f, w - left - right);
|
|
|
|
|
float nh = std::max(0.0f, h - top - bottom);
|
|
|
|
|
return { x + left, y + top, nw, nh };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Contains(float px, float py) const noexcept {
|
|
|
|
|
return px >= x && px < x + w && py >= y && py < y + h;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static Rect FromWindow(const Window& win) noexcept {
|
|
|
|
|
return { 0, 0, static_cast<float>(win.width), static_cast<float>(win.height) };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── per-frame callback args ────────────────────────────────────────
|
|
|
|
|
struct UIBuildArgs {
|
2026-05-18 04:58:52 +02:00
|
|
|
GraphicsCommandBuffer cmd;
|
|
|
|
|
std::uint32_t frameIdx;
|
2026-05-02 21:08:20 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── UIRenderer ─────────────────────────────────────────────────────
|
|
|
|
|
class UIRenderer : public RenderPass {
|
|
|
|
|
public:
|
2026-05-18 04:58:52 +02:00
|
|
|
GraphicsComputeShader drawQuads;
|
|
|
|
|
GraphicsComputeShader drawCircles;
|
|
|
|
|
GraphicsComputeShader drawImages;
|
|
|
|
|
GraphicsComputeShader drawText;
|
2026-06-16 17:03:24 +00:00
|
|
|
GraphicsComputeShader drawFused;
|
2026-05-02 21:08:20 +02:00
|
|
|
|
|
|
|
|
FontAtlas* fontAtlas = nullptr;
|
|
|
|
|
|
|
|
|
|
Crafter::Event<UIBuildArgs> onBuild;
|
|
|
|
|
|
|
|
|
|
UIRenderer() = default;
|
|
|
|
|
UIRenderer(const UIRenderer&) = delete;
|
|
|
|
|
UIRenderer& operator=(const UIRenderer&) = delete;
|
|
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
void Initialize(Window& window, GraphicsDescriptorHeap& heap, GraphicsCommandBuffer initCmd,
|
2026-05-02 21:08:20 +02:00
|
|
|
std::filesystem::path quadsSpv = "ui-quads.comp.spv",
|
|
|
|
|
std::filesystem::path circlesSpv = "ui-circles.comp.spv",
|
|
|
|
|
std::filesystem::path imagesSpv = "ui-images.comp.spv",
|
2026-06-16 17:03:24 +00:00
|
|
|
std::filesystem::path textSpv = "ui-text.comp.spv",
|
|
|
|
|
std::filesystem::path fusedSpv = "ui-fused.comp.spv");
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
void Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override;
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-06-17 19:44:33 +00:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
|
|
|
// A UI pass draws into the swapchain image entirely from compute
|
|
|
|
|
// shaders (see Dispatch), so the frame loop's inter-pass / frame-edge
|
|
|
|
|
// barriers only need to synchronise the COMPUTE_SHADER stage for it.
|
|
|
|
|
VkPipelineStageFlags SwapchainStage() const override {
|
|
|
|
|
return VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
|
|
|
|
|
}
|
|
|
|
|
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
|
|
|
|
|
|
2026-05-02 21:08:20 +02:00
|
|
|
UIDispatchHeader FillHeader(std::uint32_t itemBufferSlot,
|
|
|
|
|
std::uint32_t itemCount,
|
|
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f},
|
|
|
|
|
std::uint32_t flags = 0) const noexcept;
|
|
|
|
|
|
2026-06-16 16:57:07 +00:00
|
|
|
// 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<float,4> clipRectPx,
|
|
|
|
|
std::uint32_t surfaceWidth,
|
|
|
|
|
std::uint32_t surfaceHeight,
|
|
|
|
|
std::uint32_t flags) noexcept;
|
|
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
void DispatchQuads(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
2026-05-02 21:08:20 +02:00
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
2026-05-18 04:58:52 +02:00
|
|
|
void DispatchCircles(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
2026-05-02 21:08:20 +02:00
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
2026-05-18 04:58:52 +02:00
|
|
|
void DispatchImages(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
2026-05-02 21:08:20 +02:00
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
2026-05-24 13:32:08 +02:00
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
|
|
|
// WebGPU-only overload. WebGPU bind groups can only carry one
|
|
|
|
|
// texture/sampler per dispatch, so all items in `bufferSlot`
|
|
|
|
|
// share the same texture (`imageSlot`) and sampler (`samplerSlot`).
|
|
|
|
|
// The per-item `slots` field in ImageItem is ignored on this
|
|
|
|
|
// backend. On Vulkan the bindless heap resolves per-item slots,
|
|
|
|
|
// so the cross-backend path is to call the 4-arg overload above
|
|
|
|
|
// on native and this 6-arg overload on DOM.
|
|
|
|
|
void DispatchImages(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
|
|
|
|
std::uint16_t imageSlot, std::uint16_t samplerSlot,
|
|
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
|
|
|
|
#endif
|
2026-05-18 04:58:52 +02:00
|
|
|
void DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
2026-05-02 21:08:20 +02:00
|
|
|
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
|
|
|
|
|
2026-06-16 17:03:24 +00:00
|
|
|
// Fused UI dispatch (issue #47). Composites up to four standard
|
|
|
|
|
// categories — quads → circles → images → text, canonical
|
|
|
|
|
// back-to-front order — in ONE compute dispatch that loads the
|
|
|
|
|
// destination image once and stores once, eliminating the per-pass
|
|
|
|
|
// load+store and the inter-pass memory barriers a run of consecutive
|
|
|
|
|
// Dispatch* calls would pay. The win materialises at >= 2 non-empty
|
|
|
|
|
// categories; an empty category (itemCount 0) is a free no-op.
|
|
|
|
|
//
|
|
|
|
|
// Additive: the per-element Dispatch* calls and UIDispatchHeader are
|
|
|
|
|
// untouched. Keep using those for single-category or fine-grained
|
|
|
|
|
// work. To interleave a custom ui.Dispatch() between standard passes,
|
|
|
|
|
// bracket it with two DispatchFused calls — the dispatch boundary is
|
|
|
|
|
// the explicit, app-declared flush point.
|
|
|
|
|
//
|
|
|
|
|
// The text category uses the registered font atlas (set fontAtlas
|
|
|
|
|
// before Initialize); passing a non-empty text batch without one
|
|
|
|
|
// throws, exactly like DispatchText. On the WebGPU backend this falls
|
|
|
|
|
// back to issuing the per-element Dispatch* calls in canonical order
|
|
|
|
|
// (one texture/sampler per dispatch there — see DispatchImages),
|
|
|
|
|
// so the result matches; the load/store fusion is Vulkan-only.
|
|
|
|
|
void DispatchFused(GraphicsCommandBuffer cmd,
|
|
|
|
|
const FusedBatch& quads,
|
|
|
|
|
const FusedBatch& circles,
|
|
|
|
|
const FusedBatch& images,
|
|
|
|
|
const FusedBatch& text);
|
|
|
|
|
|
2026-05-18 05:39:17 +02:00
|
|
|
// Generic dispatch for user-authored shaders. On Vulkan, `shader` is
|
|
|
|
|
// a SPIR-V compute pipeline (bindless via VK_EXT_descriptor_heap, so
|
|
|
|
|
// any resource indices baked into push data resolve through the
|
|
|
|
|
// global heap). On DOM, `shader` carries a UICustomBinding list
|
|
|
|
|
// declared at Load time; the renderer reads the listed slot uints
|
|
|
|
|
// out of `push`, resolves them against heap.bufferTable /
|
|
|
|
|
// imageTable / samplerTable, and builds the bind groups before
|
|
|
|
|
// dispatching.
|
|
|
|
|
void Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader& shader,
|
2026-05-02 21:08:20 +02:00
|
|
|
const void* push, std::uint32_t pushBytes,
|
|
|
|
|
std::uint32_t gx, std::uint32_t gy = 1, std::uint32_t gz = 1);
|
|
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
// Allocates a heap slot for the buffer and registers the GPU handle.
|
|
|
|
|
// Returns a move-only BufferSlot RAII handle.
|
2026-05-02 21:08:20 +02:00
|
|
|
template<typename T, bool Mapped>
|
2026-05-18 04:58:52 +02:00
|
|
|
BufferSlot RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer);
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
2026-05-02 21:08:20 +02:00
|
|
|
template<typename Pixel>
|
2026-05-05 00:02:04 +02:00
|
|
|
ImageSlot RegisterImage(ImageVulkan<Pixel>& image, VkFormat format,
|
|
|
|
|
VkImageLayout layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-05-05 00:02:04 +02:00
|
|
|
SamplerSlot RegisterSampler(const VkSamplerCreateInfo& info);
|
2026-05-18 04:58:52 +02:00
|
|
|
#endif
|
2026-05-05 00:02:04 +02:00
|
|
|
SamplerSlot RegisterLinearClampSampler();
|
2026-05-02 21:08:20 +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
|
|
|
// Shapes `utf8` into `out` (up to `outCapacity` glyphs), positioning
|
|
|
|
|
// the run at `(x, baselineY)` with horizontal `align`. Returns the
|
|
|
|
|
// number of glyphs written; `*outAdvance` (if non-null) receives the
|
|
|
|
|
// full advance width regardless of truncation.
|
|
|
|
|
//
|
|
|
|
|
// The origin-relative shaped run is memoized in a CPU cache keyed by
|
|
|
|
|
// (Font*, pxSize, color, utf8); a hit skips UTF-8 decode, glyph
|
|
|
|
|
// lookup, and layout, translating the cached run into `out` instead.
|
|
|
|
|
// Caching is byte-equivalent — the glyph buffer is fully rewritten
|
|
|
|
|
// and re-flushed every frame regardless. Call InvalidateFont before
|
|
|
|
|
// destroying a Font (the cache keys on the Font pointer).
|
2026-05-02 21:08:20 +02:00
|
|
|
std::uint32_t 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 = nullptr,
|
|
|
|
|
TextAlign align = TextAlign::Left);
|
|
|
|
|
|
|
|
|
|
// Drop every cached shaped run that references `font`. Must be called
|
|
|
|
|
// before a Font is destroyed: the run cache (and FontAtlas's glyph
|
|
|
|
|
// cache) key on the Font pointer, so a Font freed and a new one
|
|
|
|
|
// reallocated at the same address would otherwise alias stale runs.
|
|
|
|
|
void InvalidateFont(const Font& font) noexcept;
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-06-18 13:23:34 +00:00
|
|
|
// Shaped-run cache introspection (issue #123). Hits/misses are
|
|
|
|
|
// byte-identical and a re-shape of an already-rasterised string leaves
|
|
|
|
|
// the atlas clean, so the LRU eviction policy isn't observable through
|
|
|
|
|
// output alone — these let tests assert that the cache stays bounded
|
|
|
|
|
// and that the hot set survives an overflow. IsShapedRunCached is a
|
|
|
|
|
// pure query: it does not bump recency.
|
|
|
|
|
std::size_t ShapedRunCacheSize() const noexcept { return shapedRuns_.size(); }
|
|
|
|
|
bool IsShapedRunCached(const Font& font, float pxSize,
|
|
|
|
|
std::array<float,4> color,
|
|
|
|
|
std::string_view utf8) const;
|
|
|
|
|
|
2026-05-02 21:08:20 +02:00
|
|
|
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
|
|
|
|
|
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
|
|
|
|
|
|
2026-05-05 23:49:29 +02:00
|
|
|
std::uint16_t OutImageSlot() const noexcept { return outImageSlot_; }
|
|
|
|
|
|
2026-05-02 21:08:20 +02:00
|
|
|
private:
|
|
|
|
|
Window* window_ = nullptr;
|
2026-05-18 04:58:52 +02:00
|
|
|
GraphicsDescriptorHeap* heap_ = nullptr;
|
2026-05-02 21:08:20 +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
|
|
|
// ─── shaped-run cache ───────────────────────────────────────────
|
|
|
|
|
// Glyphs are stored origin-relative (pen at x=0, baseline=0); a hit
|
|
|
|
|
// only needs a translate by (x + alignShift, baselineY). The full
|
|
|
|
|
// string is part of the key (compared on lookup) so hash collisions
|
|
|
|
|
// can't return the wrong run.
|
2026-06-18 13:19:11 +00:00
|
|
|
//
|
|
|
|
|
// The map owns its key string, but lookups happen every frame on the
|
|
|
|
|
// onBuild path. To avoid copying the string into a key on each cache
|
|
|
|
|
// hit, the hash and equality functors are transparent (is_transparent)
|
|
|
|
|
// and accept a borrowing view key whose text is a std::string_view;
|
|
|
|
|
// the owning std::string is only materialised on a miss, for emplace.
|
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
|
|
|
struct ShapedRunKey {
|
|
|
|
|
const Font* font;
|
|
|
|
|
float pxSize;
|
|
|
|
|
std::array<float, 4> color;
|
|
|
|
|
std::string text;
|
2026-06-18 13:19:11 +00:00
|
|
|
};
|
|
|
|
|
struct ShapedRunViewKey {
|
|
|
|
|
const Font* font;
|
|
|
|
|
float pxSize;
|
|
|
|
|
std::array<float, 4> color;
|
|
|
|
|
std::string_view text;
|
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
|
|
|
};
|
|
|
|
|
struct ShapedRunKeyHash {
|
2026-06-18 13:19:11 +00:00
|
|
|
using is_transparent = void;
|
|
|
|
|
static std::size_t Hash(const Font* font, float pxSize,
|
|
|
|
|
const std::array<float, 4>& color,
|
|
|
|
|
std::string_view text) noexcept {
|
|
|
|
|
std::size_t h = std::hash<const void*>{}(font);
|
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
|
|
|
auto mix = [&h](std::size_t v) noexcept {
|
|
|
|
|
h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2);
|
|
|
|
|
};
|
2026-06-18 13:19:11 +00:00
|
|
|
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(pxSize)));
|
|
|
|
|
for (float c : color)
|
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
|
|
|
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(c)));
|
2026-06-18 13:19:11 +00:00
|
|
|
mix(std::hash<std::string_view>{}(text));
|
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
|
|
|
return h;
|
|
|
|
|
}
|
2026-06-18 13:19:11 +00:00
|
|
|
std::size_t operator()(const ShapedRunKey& k) const noexcept {
|
|
|
|
|
return Hash(k.font, k.pxSize, k.color, k.text);
|
|
|
|
|
}
|
|
|
|
|
std::size_t operator()(const ShapedRunViewKey& k) const noexcept {
|
|
|
|
|
return Hash(k.font, k.pxSize, k.color, k.text);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
struct ShapedRunKeyEqual {
|
|
|
|
|
using is_transparent = void;
|
|
|
|
|
static bool Eq(const Font* fa, float pa, const std::array<float, 4>& ca,
|
|
|
|
|
std::string_view ta,
|
|
|
|
|
const Font* fb, float pb, const std::array<float, 4>& cb,
|
|
|
|
|
std::string_view tb) noexcept {
|
|
|
|
|
return fa == fb && pa == pb && ca == cb && ta == tb;
|
|
|
|
|
}
|
|
|
|
|
bool operator()(const ShapedRunKey& a, const ShapedRunKey& b) const noexcept {
|
|
|
|
|
return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text);
|
|
|
|
|
}
|
|
|
|
|
bool operator()(const ShapedRunViewKey& a, const ShapedRunKey& b) const noexcept {
|
|
|
|
|
return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text);
|
|
|
|
|
}
|
|
|
|
|
bool operator()(const ShapedRunKey& a, const ShapedRunViewKey& b) const noexcept {
|
|
|
|
|
return Eq(a.font, a.pxSize, a.color, a.text, b.font, b.pxSize, b.color, b.text);
|
|
|
|
|
}
|
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
|
|
|
};
|
|
|
|
|
struct ShapedRun {
|
|
|
|
|
std::vector<GlyphItem> glyphs; // origin-relative
|
|
|
|
|
float advance = 0;
|
2026-06-18 13:23:34 +00:00
|
|
|
// Position of this run's key in shapedRunsLru_ (front = most
|
|
|
|
|
// recently used). Lets a hit splice the entry to the front and an
|
|
|
|
|
// overflow pop the least-recently-used entry, both O(1).
|
|
|
|
|
std::list<const ShapedRunKey*>::iterator lruIt{};
|
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
|
|
|
};
|
|
|
|
|
// Soft cap. A pathological caller drawing a fresh string every frame
|
2026-06-18 13:23:34 +00:00
|
|
|
// would grow this without bound; on overflow we evict the single
|
|
|
|
|
// least-recently-used run (issue #123) rather than clearing the whole
|
|
|
|
|
// cache. The hot set of stable labels — reshaped every frame, so always
|
|
|
|
|
// near the front — stays resident, while a churn of unique strings
|
|
|
|
|
// (FPS counters, timers) only recycles the cold tail. Correctness is
|
|
|
|
|
// unaffected either way; this just avoids the periodic full-UI reshape
|
|
|
|
|
// spike the old clear() caused.
|
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
|
|
|
static constexpr std::size_t kMaxShapedRuns = 8192;
|
2026-06-18 13:19:11 +00:00
|
|
|
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash, ShapedRunKeyEqual> shapedRuns_;
|
2026-06-18 13:23:34 +00:00
|
|
|
// LRU recency order for shapedRuns_; front = most recently used. Holds
|
|
|
|
|
// pointers to the keys owned by the map — stable across rehash because
|
|
|
|
|
// unordered_map is node-based — so eviction never re-hashes the world.
|
|
|
|
|
std::list<const ShapedRunKey*> shapedRunsLru_;
|
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
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
ImageSlot outImageSlot_;
|
2026-05-05 00:02:04 +02:00
|
|
|
ImageSlot fontAtlasImageSlot_;
|
|
|
|
|
SamplerSlot fontAtlasSamplerSlot_;
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
|
|
|
VkImageViewCreateInfo atlasViewCreateInfo_{};
|
2026-05-02 21:08:20 +02:00
|
|
|
bool firstDispatchThisFrame_ = true;
|
2026-05-18 04:58:52 +02:00
|
|
|
#endif
|
2026-05-02 21:08:20 +02:00
|
|
|
|
2026-05-12 00:24:48 +02:00
|
|
|
Crafter::EventListener<void> resizeSub_;
|
|
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
2026-05-02 21:08:20 +02:00
|
|
|
void WriteSwapchainDescriptors();
|
|
|
|
|
void WriteFontAtlasDescriptor();
|
|
|
|
|
void WriteBufferDescriptor(std::uint16_t slot, VkDeviceAddress address, std::uint32_t size);
|
|
|
|
|
void WriteSampledImageDescriptor(std::uint16_t slot,
|
|
|
|
|
const VkImageViewCreateInfo& viewInfo,
|
|
|
|
|
VkImageLayout layout);
|
2026-05-18 04:58:52 +02:00
|
|
|
#endif
|
2026-05-02 21:08:20 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── template-method implementations ────────────────────────────────
|
|
|
|
|
template<typename T, bool Mapped>
|
2026-05-18 04:58:52 +02:00
|
|
|
BufferSlot UIRenderer::RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer) {
|
2026-05-02 21:08:20 +02:00
|
|
|
auto range = heap_->AllocateBufferSlots(1);
|
2026-05-18 04:58:52 +02:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
2026-05-02 21:08:20 +02:00
|
|
|
WriteBufferDescriptor(range.firstElement, buffer.address, buffer.size);
|
2026-05-18 04:58:52 +02:00
|
|
|
#else
|
|
|
|
|
heap_->bufferTable[range.firstElement] = buffer.handle;
|
|
|
|
|
#endif
|
2026-05-05 00:02:04 +02:00
|
|
|
return BufferSlot{heap_, range.firstElement};
|
2026-05-02 21:08:20 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 04:58:52 +02:00
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
2026-05-02 21:08:20 +02:00
|
|
|
template<typename Pixel>
|
2026-05-05 00:02:04 +02:00
|
|
|
ImageSlot UIRenderer::RegisterImage(ImageVulkan<Pixel>& image, VkFormat format,
|
|
|
|
|
VkImageLayout layout) {
|
2026-05-02 21:08:20 +02:00
|
|
|
auto range = heap_->AllocateImageSlots(1);
|
|
|
|
|
VkImageViewCreateInfo info {
|
|
|
|
|
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
|
|
|
|
|
.image = image.image,
|
|
|
|
|
.viewType = VK_IMAGE_VIEW_TYPE_2D,
|
|
|
|
|
.format = format,
|
|
|
|
|
.components = {
|
|
|
|
|
VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY,
|
|
|
|
|
VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY,
|
|
|
|
|
},
|
|
|
|
|
.subresourceRange = {
|
|
|
|
|
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
|
|
|
|
|
.baseMipLevel = 0,
|
|
|
|
|
.levelCount = image.mipLevels,
|
|
|
|
|
.baseArrayLayer = 0,
|
|
|
|
|
.layerCount = 1,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
WriteSampledImageDescriptor(range.firstElement, info, layout);
|
2026-05-05 00:02:04 +02:00
|
|
|
return ImageSlot{heap_, range.firstElement};
|
2026-05-02 21:08:20 +02:00
|
|
|
}
|
2026-05-18 04:58:52 +02:00
|
|
|
#endif
|
2026-05-02 21:08:20 +02:00
|
|
|
}
|