//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #include "vulkan/vulkan.h" #endif export module Crafter.Graphics:FontAtlas; import std; import :Font; import :GraphicsTypes; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM import :ImageVulkan; import :Device; #else import :WebGPU; #endif export namespace Crafter { // A glyph's cell in the atlas, as of when the glyph got it. Evicting the // glyph bumps the cell's generation, so a handle kept across frames (a // shaped run keeps one per glyph it draws) can tell that its cell, and the // UVs into it, now belong to another glyph. struct GlyphHandle { static constexpr std::uint32_t kNone = ~std::uint32_t{0}; std::uint32_t slot = kNone; std::uint32_t generation = 0; }; // Per-glyph metrics. UVs are 0..1 in atlas space; on-screen sizes / // offsets / advance are in *atlas pixels at the base size* and scale // linearly with the requested font size at draw time. w and h are 0 for a // glyph with no ink (a space). A glyph with ink that is not Resident() // found no room in the atlas: its UVs mean nothing, it draws blank, and its // advance and box still hold, so the text around it keeps its layout. struct Glyph { float u0 = 0, v0 = 0; float u1 = 0, v1 = 0; float w = 0, h = 0; float xoff = 0, yoff = 0; float advance = 0; GlyphHandle handle; bool Resident() const noexcept { return handle.slot != GlyphHandle::kNone; } }; class FontAtlas { public: static constexpr int kAtlasSize = 1024; static constexpr float kBaseSize = 32.0f; static constexpr int kPadding = 4; static constexpr int kOnEdgeValue = 128; static constexpr float kPixelDistScale = 32.0f; // Bounding box of the atlas pixels touched since the last upload, // used to copy only the sub-rect that actually changed instead of // the whole 1 MiB atlas. Half-open: covers [minX, maxX) × [minY, // maxY). `active` distinguishes "nothing dirty" from a zero-origin // rect. struct DirtyRect { int minX = 0, minY = 0, maxX = 0, maxY = 0; bool active = false; bool Empty() const noexcept { return !active; } void Reset() noexcept { active = false; } // Grow the box to include [x, x+w) × [y, y+h). No-op for an // empty rect (a glyph that rasterized to nothing marks nothing). void Add(int x, int y, int w, int h) noexcept { if (w <= 0 || h <= 0) return; if (!active) { minX = x; minY = y; maxX = x + w; maxY = y + h; active = true; } else { minX = std::min(minX, x); minY = std::min(minY, y); maxX = std::max(maxX, x + w); maxY = std::max(maxY, y + h); } } // Clamp the box to [0, limit] on both axes and emit it as an // (origin, extent) pair for a GPU copy. Returns false when // empty, leaving the outputs untouched. bool UploadBox(int limit, std::uint32_t& x, std::uint32_t& y, std::uint32_t& w, std::uint32_t& h) const noexcept { if (!active) return false; const int x0 = std::clamp(minX, 0, limit); const int y0 = std::clamp(minY, 0, limit); const int x1 = std::clamp(maxX, 0, limit); const int y1 = std::clamp(maxY, 0, limit); x = static_cast(x0); y = static_cast(y0); w = static_cast(x1 - x0); h = static_cast(y1 - y0); return true; } }; // Where glyphs go in the atlas, and which of them give up their cells // once it is full (#55). Glyphs sit on shelves: rows as tall as the // glyph that opened them, filled left to right. A glyph goes into the // first gap wide enough on a shelf at least as tall, else onto a new // shelf below the last. When there is neither, it takes the same // columns on one shelf at least as tall, or on several in a row that // are as tall together (the free rows below the last shelf counting // as one more), from the first one's top: free texels, and, when the // caller allows it, the cells of glyphs no frame in flight uses, the // least recently used first. A glyph spanning shelves is a glyph on // each. Without that, a glyph taller than the shelves opened before // the rows ran out would never fit, and a few glyphs drawn every // frame would pin every shelf they sit on rather than just their // columns. // A glyph stays in flight for framesInFlight + 1 frames after it was // used, the frame of its use included: a frame's GlyphItems hold its // UVs, and its uploads and dispatches may not have run until the // frame loop has waited its fence, at the start of the frame // framesInFlight later. Device::frameCounter already reads that frame // from the end of the frame before, when the deletion queue has not // yet moved on, and text can be shaped in between (an input callback, // onBeforeUpdate). Pure CPU and pixel-free, so a test drives it with // made-up boxes; FontAtlas rasterises into the cells and clears what // it reports. class Packer { public: struct Cell { int x = 0, y = 0, w = 0, h = 0; }; explicit Packer(int size = kAtlasSize) : size_(size) {} // A w×h cell for a glyph used in frame `now`, as a slot, or // GlyphHandle::kNone when nothing can make room. With `evict`, it // may take cold glyphs' cells: Evicted() then lists the slots it // took, whose glyphs are gone, and Cleared() the texels they // covered, for the caller to zero, since the new glyph covers only // part of them and a neighbour's filtering must not pick up a stale // glyph. The shelves' dirty spans already include both. A failure // evicts nothing. std::uint32_t Allocate(int w, int h, std::uint64_t now, std::uint32_t framesInFlight, bool evict); std::span Evicted() const noexcept { return evicted_; } std::span Cleared() const noexcept { return cleared_; } // The glyph in `slot` was used in frame `now`. void Touch(std::uint32_t slot, std::uint64_t now) noexcept { slots_[slot].lastUse = now; } // The same for the glyph of every handle that still holds its // cell; false when one doesn't. All of them, not only those before // the first that doesn't: the caller shapes the run again, and the // run's own glyphs must not then look cold to its missing ones. bool Touch(std::span handles, std::uint64_t now) noexcept; GlyphHandle Handle(std::uint32_t slot) const noexcept { return { slot, slots_[slot].generation }; } const Cell& CellOf(std::uint32_t slot) const noexcept { return slots_[slot].cell; } // Hands `upload` each shelf's span of texels changed since it was // last uploaded; `upload` copies the span up and resets it. template void ForEachDirty(F&& upload) { for (Shelf& s : shelves_) upload(s.dirty); } private: struct Slot { Cell cell; std::uint64_t lastUse = 0; std::uint32_t generation = 0; }; // A span per shelf (#129) stays a short run of rows, where one box // over every shelf written in a frame would take in the rows // between them too. struct Shelf { int y = 0; int height = 0; // Its glyphs, left to right: each covers the shelf's top row, // so their columns never overlap. std::vector slots; DirtyRect dirty; }; int size_; // The shelves cover rows [0, nextShelfY_), each starting where the // one above ends, and stay as they are opened; the rows below are // free, and zero. int nextShelfY_ = 0; std::vector shelves_; std::vector slots_; std::vector freeSlots_; std::vector evicted_; std::vector cleared_; std::vector run_; // Allocate's scratch: the glyphs on a run of shelves // The boxes that found no room in this frame, none holding another, // with eviction and without. Within a frame no glyph turns cold, // a placement only turns free or cold texels into its own glyph's, // and a shelf opens on free rows that the runs above it could // already reach (a box that fails is taller than they are), so the // room a box could get only shrinks. A box at least as wide and as // tall as one of these fails at once, so the glyphs of a frame that // doesn't fit don't each search every shelf. An eviction frees room // for placements without one, which forget theirs. struct Box { int w = 0, h = 0; }; std::vector failedEvicting_; std::vector failedFree_; std::uint64_t failedFrame_ = ~std::uint64_t{0}; std::uint32_t failedDepth_ = 0; // A cell at column x on shelves [first, last], from first's top. std::uint32_t Insert(std::size_t first, std::size_t last, int x, int w, int h, std::uint64_t now); // Takes the glyph in `slot` off every shelf it is on, lists its // cell as cleared and dirty, and frees the slot for another glyph. void Evict(std::uint32_t slot); // Opens a shelf `height` rows tall below the last. void OpenShelf(int height); // The shelf whose rows hold row y. std::size_t ShelfAt(int y) const noexcept; }; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM ImageVulkan image; #else WebGPUTextureRef textureHandle = 0; std::vector staging; #endif // `dirty` stays the cheap "is there anything to flush?" flag the // renderer polls each frame; it is the OR of every dirty span Update // copies. `dirtyRect` is the *whole-atlas* span — used only for the // one-shot zero-clear in Initialize; per-glyph dirt is tracked tight // per shelf (in the Packer) instead of inflating one tall union box // across scattered shelves (#129). `dirty` and the spans are always // armed and cleared together. bool dirty = false; DirtyRect dirtyRect; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM FontAtlas() = default; FontAtlas(const FontAtlas&) = delete; FontAtlas& operator=(const FontAtlas&) = delete; // The image goes with the atlas, once the GPU is done with it; in // sets mode the owner destroys the heap holding the image's view // before the atlas. ~FontAtlas() { if (image.image == VK_NULL_HANDLE) return; if (Device::queue != VK_NULL_HANDLE) vkQueueWaitIdle(Device::queue); image.Destroy(); } #endif void Initialize(GraphicsCommandBuffer cmd); // Returns the row-major byte pointer the CPU writes pixels into. // Same shape on both backends. std::uint8_t* PixelPtr() noexcept; // The glyph for `codepoint`: its metrics, cached per (Font*, // codepoint), and, when it has ink, a cell it is rasterised into on // first use if there is free room. Marks the glyph used in the current // frame (Device::frameCounter), which keeps its cell its own while a // frame in flight may sample it. Never fails: a glyph that finds no // free room comes back not Resident(), keeping its advance and box, // and draws blank. It waits for the next frame, whose first call here, // to Touch or to Update gives it a cell, evicting what the Packer // allows, once every glyph the frame before drew has been marked: an // eviction made while the frame is still asking for glyphs would take // ones it draws further down, which would then take others in turn. // The atlas keeps the Font* of a waiting glyph until then, so call // InvalidateFont before destroying a Font. The reference holds for // the frame: a glyph is evicted no sooner than framesInFlight + 1 // frames after its last use. const Glyph& EnsureAndGet(Font& font, std::uint32_t codepoint); // EnsureAndGet, saying whether the glyph can be drawn: it has a cell, // or no ink to put in one. bool Ensure(Font& font, std::uint32_t codepoint); // The cached glyph, or nullptr; neither places it nor marks it used. // The pointer holds until the next EnsureAndGet, Touch or Update, // which may evict the glyph and drop it. const Glyph* Lookup(Font& font, std::uint32_t codepoint) const; // Marks used in the current frame the glyphs whose handles a caller // kept (UIRenderer's shaped runs). False when one of them has been // evicted since: whatever was built from its UVs must be built again. bool Touch(std::span handles); // Forgets every glyph of `font`: its metrics, and the glyphs waiting // for a cell, whose Font* the atlas would otherwise read next frame. // Their cells stay until they are evicted, as a frame in flight may // still draw them. UIRenderer::InvalidateFont calls it. void InvalidateFont(const Font& font) noexcept; void Update(GraphicsCommandBuffer cmd); // Flag the [x, x+w) × [y, y+h) atlas region as needing re-upload, // keeping `dirty` in lockstep with the accumulated `dirtyRect`. void MarkDirty(int x, int y, int w, int h) noexcept { dirtyRect.Add(x, y, w, h); dirty = !dirtyRect.Empty(); } // How many glyphs have been rasterised into a cell, counting a glyph // again each time it comes back after an eviction. For tests: a glyph // that finds no room must not be rasterised for nothing, nor one the // same frame draws further down evicted for it. std::uint64_t GlyphsRasterized() const noexcept { return rasterized_; } private: struct Key { const Font* font; std::uint32_t cp; bool operator==(const Key&) const = default; }; struct KeyHash { std::size_t operator()(const Key& k) const noexcept { std::size_t h1 = std::hash{}(k.font); std::size_t h2 = std::hash{}(k.cp); return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2)); } }; struct Entry { Glyph glyph; std::uint64_t waiting = ~std::uint64_t{0}; // the frame it last joined waiting_ in }; // A glyph with no cell stays here, measured, so that it keeps its // layout and is not measured again; an evicted glyph is dropped. std::unordered_map cache_; // owners_[slot]: the glyph holding a cell, to drop when it is evicted. std::vector owners_; // The glyphs asked for since the last Settle that found no free room, // in the order they were first asked for. std::vector waiting_; std::uint64_t settled_ = ~std::uint64_t{0}; // the frame Settle last ran for Packer packer_; std::uint64_t rasterized_ = 0; // Gives the glyphs in waiting_ cells, evicting for them, at a frame's // first EnsureAndGet, Touch or Update. void Settle(std::uint64_t now); // Asks the packer for a cell for `e`, drops and clears the glyphs it // evicted for it, and rasterises `e` into the cell it got. False when // it got none. bool Place(const Font& font, const Key& key, Entry& e, std::uint64_t now, bool evict); }; }