//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® /* Crafter®.Graphics Copyright (C) 2026 Catcrafts® catcrafts.net */ module; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #include "vulkan/vulkan.h" #endif #include "../lib/stb_truetype.h" module Crafter.Graphics:FontAtlas_impl; import :FontAtlas; import :Font; import :GraphicsTypes; import :Device; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM import :ImageVulkan; #else import :WebGPU; #endif import std; using namespace Crafter; // ─── Packer ───────────────────────────────────────────────────────────── std::uint32_t FontAtlas::Packer::Allocate(int w, int h, std::uint64_t now, std::uint32_t framesInFlight, bool evict) { evicted_.clear(); cleared_.clear(); if (w <= 0 || h <= 0 || w > size_ || h > size_) return GlyphHandle::kNone; if (failedFrame_ != now || failedDepth_ != framesInFlight) { failedFrame_ = now; failedDepth_ = framesInFlight; failedEvicting_.clear(); failedFree_.clear(); } auto refused = [&](const std::vector& boxes) { return std::ranges::any_of(boxes, [&](const Box& b) { return w >= b.w && h >= b.h; }); }; // A box no eviction found room for has no free room either. if (refused(failedEvicting_) || (!evict && refused(failedFree_))) return GlyphHandle::kNone; // A glyph used in frame u is drawn by that frame's dispatches, which the // frame loop has waited for at the start of frame u + framesInFlight; by // the end of the frame before, and the callbacks that run in between, // Device::frameCounter already reads u + framesInFlight. So a cell goes // from frame u + framesInFlight + 1 on: never sooner than two frames // after its use, even before a Window has set framesInFlight, and on the // DOM backend (one frame in flight) a glyph the last frame drew, the // likeliest to be drawn again, stays. const std::uint64_t depth = std::uint64_t{std::max(framesInFlight, 1)} + 1; auto cold = [&](std::uint32_t slot) { return slots_[slot].lastUse + depth <= now; }; auto end = [&](std::uint32_t slot) { return slots_[slot].cell.x + slots_[slot].cell.w; }; // The first gap wide enough on the first shelf tall enough, else a new // shelf as tall as the glyph. Until something is evicted a shelf's only // gap is its tail, so this fills the atlas exactly as the shelves always // did. for (std::size_t i = 0; i < shelves_.size(); ++i) { const Shelf& s = shelves_[i]; if (s.height < h) continue; int x = 0; bool gap = false; for (std::uint32_t slot : s.slots) { if (slots_[slot].cell.x - x >= w) { gap = true; break; } x = end(slot); } if (gap || size_ - x >= w) return Insert(i, i, x, w, h, now); } if (size_ - nextShelfY_ >= h) { OpenShelf(h); return Insert(shelves_.size() - 1, shelves_.size() - 1, 0, w, h, now); } // The same w columns of the fewest shelves from some shelf on that are // together at least h tall: one shelf as tall, or several shorter ones, // with the free rows below the last for the rows they are short. The // glyphs in flight there cut the columns into stretches of free texels // and cold glyphs; in each stretch at least w wide, a window starts at // either end and on either side of each cold glyph. The best window's // newest glyph was used longest ago (one over free texels alone has // none, and wins); then it evicts the fewest glyphs, then it leaves the // fewest rows of the run below the glyph. Without `evict` only windows // over free texels count. struct Window { std::size_t first = 0, last = 0; // the shelves it covers int x = 0; int extra = 0; // the free rows it opens as one more shelf std::uint64_t newest = 0; std::size_t count = 0; int waste = 0; bool found = false; } best; for (std::size_t i = 0; i < shelves_.size(); ++i) { std::size_t j = i; int height = shelves_[i].height; while (height < h && j + 1 < shelves_.size()) height += shelves_[++j].height; const int extra = std::max(h - height, 0); if (extra > size_ - nextShelfY_) break; // nor from any shelf further down // The run's glyphs by column, each once: one on several shelves is on // each one's list. A single shelf's list is in order already. std::span glyphs = shelves_[i].slots; if (j > i) { run_.clear(); for (std::size_t k = i; k <= j; ++k) run_.insert(run_.end(), shelves_[k].slots.begin(), shelves_[k].slots.end()); std::ranges::sort(run_, [&](std::uint32_t a, std::uint32_t b) { return slots_[a].cell.x != slots_[b].cell.x ? slots_[a].cell.x < slots_[b].cell.x : a < b; }); run_.erase(std::unique(run_.begin(), run_.end()), run_.end()); glyphs = run_; } int widest = 0; for (std::uint32_t slot : glyphs) widest = std::max(widest, slots_[slot].cell.w); auto consider = [&](int x) { Window win{ .first = i, .last = j, .x = x, .extra = extra, .waste = height + extra - h, .found = true }; // A glyph over [x, x + w) starts before x + w and ends after x, // so it starts at x - widest or later. auto it = std::ranges::lower_bound(glyphs, x - widest, {}, [&](std::uint32_t slot) { return slots_[slot].cell.x; }); for (; it != glyphs.end() && slots_[*it].cell.x < x + w; ++it) { if (end(*it) <= x) continue; win.newest = std::max(win.newest, slots_[*it].lastUse); ++win.count; } if (win.count > 0 && !evict) return; if (!best.found || win.newest < best.newest || (win.newest == best.newest && (win.count < best.count || (win.count == best.count && win.waste < best.waste)))) { best = win; } }; int left = 0; // where the stretch starts: past every glyph in flight so far auto stretch = [&](int right) { if (right - left < w) return; consider(left); consider(right - w); for (std::uint32_t slot : glyphs) { const Cell& c = slots_[slot].cell; if (!cold(slot) || c.x + c.w <= left || c.x >= right) continue; consider(std::clamp(c.x, left, right - w)); consider(std::clamp(c.x + c.w, left, right - w)); } }; for (std::uint32_t slot : glyphs) { if (cold(slot)) continue; stretch(slots_[slot].cell.x); left = std::max(left, end(slot)); } stretch(size_); } if (best.found) { // Collected before evicting: Evict edits the lists. run_.clear(); for (std::size_t k = best.first; k <= best.last; ++k) { for (std::uint32_t slot : shelves_[k].slots) { if (slots_[slot].cell.x < best.x + w && end(slot) > best.x) run_.push_back(slot); } } std::ranges::sort(run_); run_.erase(std::unique(run_.begin(), run_.end()), run_.end()); for (std::uint32_t slot : run_) Evict(slot); if (best.extra > 0) { OpenShelf(best.extra); best.last = shelves_.size() - 1; } return Insert(best.first, best.last, best.x, w, h, now); } // No box this one holds needs remembering any more. std::vector& failed = evict ? failedEvicting_ : failedFree_; std::erase_if(failed, [&](const Box& b) { return b.w >= w && b.h >= h; }); failed.push_back({ w, h }); return GlyphHandle::kNone; } std::uint32_t FontAtlas::Packer::Insert(std::size_t first, std::size_t last, int x, int w, int h, std::uint64_t now) { std::uint32_t slot; if (!freeSlots_.empty()) { slot = freeSlots_.back(); freeSlots_.pop_back(); } else { slot = static_cast(slots_.size()); slots_.emplace_back(); } const int y = shelves_[first].y; slots_[slot].cell = { x, y, w, h }; slots_[slot].lastUse = now; for (std::size_t k = first; k <= last; ++k) { std::vector& v = shelves_[k].slots; v.insert(std::ranges::find_if(v, [&](std::uint32_t other) { return slots_[other].cell.x > x; }), slot); } shelves_[first].dirty.Add(x, y, w, h); return slot; } void FontAtlas::Packer::Evict(std::uint32_t slot) { const Cell c = slots_[slot].cell; const std::size_t first = ShelfAt(c.y); for (std::size_t k = first; k < shelves_.size() && shelves_[k].y < c.y + c.h; ++k) { std::vector& v = shelves_[k].slots; if (auto it = std::ranges::find(v, slot); it != v.end()) v.erase(it); } cleared_.push_back(c); shelves_[first].dirty.Add(c.x, c.y, c.w, c.h); ++slots_[slot].generation; freeSlots_.push_back(slot); evicted_.push_back(slot); failedFree_.clear(); } void FontAtlas::Packer::OpenShelf(int height) { shelves_.push_back(Shelf{ .y = nextShelfY_, .height = height }); nextShelfY_ += height; } std::size_t FontAtlas::Packer::ShelfAt(int y) const noexcept { const auto above = std::ranges::upper_bound(shelves_, y, {}, &Shelf::y); return static_cast(above - shelves_.begin()) - 1; } bool FontAtlas::Packer::Touch(std::span handles, std::uint64_t now) noexcept { bool all = true; for (const GlyphHandle& handle : handles) { if (handle.slot < slots_.size() && slots_[handle.slot].generation == handle.generation) { slots_[handle.slot].lastUse = now; } else { all = false; } } return all; } // ─── FontAtlas ────────────────────────────────────────────────────────── namespace { // A glyph's advance and box at the atlas's base size, without rasterising // it. The box is the one stbtt_GetGlyphSDF rasterises into, the glyph's // bitmap box grown by the padding: a glyph is laid out, and its cell // found, before its SDF exists, and without one when there is no room. void Measure(const Font& font, std::uint32_t codepoint, Glyph& g) { const float fontScale = stbtt_ScaleForPixelHeight(&font.font, FontAtlas::kBaseSize); const int index = stbtt_FindGlyphIndex(&font.font, static_cast(codepoint)); int advanceUnits = 0, lsb = 0; stbtt_GetGlyphHMetrics(&font.font, index, &advanceUnits, &lsb); g.advance = advanceUnits * fontScale; int x0 = 0, y0 = 0, x1 = 0, y1 = 0; stbtt_GetGlyphBitmapBoxSubpixel(&font.font, index, fontScale, fontScale, 0.0f, 0.0f, &x0, &y0, &x1, &y1); if (x0 == x1 || y0 == y1) return; // no ink (a space): nothing to place g.xoff = static_cast(x0 - FontAtlas::kPadding); g.yoff = static_cast(y0 - FontAtlas::kPadding); g.w = static_cast(x1 - x0 + 2 * FontAtlas::kPadding); g.h = static_cast(y1 - y0 + 2 * FontAtlas::kPadding); } } std::uint8_t* FontAtlas::PixelPtr() noexcept { #ifndef CRAFTER_GRAPHICS_WINDOW_DOM return image.buffer.value; #else return staging.data(); #endif } void FontAtlas::Initialize(GraphicsCommandBuffer cmd) { #ifndef CRAFTER_GRAPHICS_WINDOW_DOM image.Create( kAtlasSize, kAtlasSize, /*mipLevels*/ 1, cmd, VK_FORMAT_R8_UNORM, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, // The atlas is sampled by the UI text *compute* shader, not an RT // pipeline — scope the upload barriers to the real consumer. VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, // Streamed: glyphs are rasterized into buffer.value on the CPU and // re-uploaded every frame via UpdateRegion, so the persistent staging // map must outlive the first upload rather than being released (#114). /*streamed*/ true ); std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize); #else (void)cmd; staging.assign(kAtlasSize * kAtlasSize, 0); textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize); #endif // The freshly-zeroed atlas backs an image whose contents are undefined // (Vulkan) / unwritten (WebGPU). Mark the whole extent dirty so the // first Update clears it once; thereafter only glyph sub-rects flip // dirty, and every untouched texel stays the zero it was uploaded as — // so partial uploads keep the image byte-identical to the staging copy. // An evicted glyph's texels are zeroed again and uploaded with the glyph // that takes its place, which keeps that so. MarkDirty(0, 0, kAtlasSize, kAtlasSize); } bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) { const Glyph& g = EnsureAndGet(font, codepoint); return g.w == 0 || g.Resident(); } const Glyph& FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) { const std::uint64_t now = Device::frameCounter; if (settled_ != now) Settle(now); const Key key{&font, codepoint}; auto [it, inserted] = cache_.try_emplace(key); Entry& e = it->second; if (inserted) Measure(font, codepoint, e.glyph); if (e.glyph.w > 0) { if (e.glyph.Resident()) { packer_.Touch(e.glyph.handle.slot, now); } else if (!Place(font, key, e, now, /*evict*/ false) && e.waiting != now) { e.waiting = now; waiting_.push_back(key); } } return e.glyph; } void FontAtlas::Settle(std::uint64_t now) { settled_ = now; // Every glyph the last frame drew was marked in it, and the Packer // keeps those: an eviction now takes only glyphs that frame did not draw, // where one made while a frame is still asking for glyphs takes any it // has yet to ask for. The waiting glyphs go in the order first asked for. const std::vector waiting = std::exchange(waiting_, {}); for (const Key& key : waiting) { auto it = cache_.find(key); if (it == cache_.end() || it->second.glyph.Resident()) continue; Place(*key.font, key, it->second, now, /*evict*/ true); } } bool FontAtlas::Place(const Font& font, const Key& key, Entry& e, std::uint64_t now, bool evict) { Glyph& g = e.glyph; const std::uint32_t slot = packer_.Allocate(static_cast(g.w), static_cast(g.h), now, Device::framesInFlight, evict); // The glyphs whose cells it took are forgotten, so their next use // measures and places them again; `e`, which has no cell yet, is never // among them. A key cached afresh since its glyph got the cell is left. for (std::uint32_t victim : packer_.Evicted()) { auto owner = cache_.find(owners_[victim]); if (owner != cache_.end() && owner->second.glyph.handle.slot == victim) cache_.erase(owner); } std::uint8_t* pixels = PixelPtr(); for (const Packer::Cell& c : packer_.Cleared()) { for (int row = c.y; row < c.y + c.h; ++row) { std::memset(pixels + static_cast(row) * kAtlasSize + c.x, 0, static_cast(c.w)); } } if (slot == GlyphHandle::kNone) return false; const Packer::Cell& cell = packer_.CellOf(slot); const float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize); // The index from the font as it is now: a Font replaced in place keeps // its address, and an index measured in the old one can lie past the // new one's glyphs. const int index = stbtt_FindGlyphIndex(&font.font, static_cast(key.cp)); int sw = 0, sh = 0, xoff = 0, yoff = 0; unsigned char* sdf = stbtt_GetGlyphSDF( &font.font, fontScale, index, kPadding, static_cast(kOnEdgeValue), kPixelDistScale, &sw, &sh, &xoff, &yoff ); if (sdf != nullptr) { // Measure took the box the way stbtt_GetGlyphSDF does, so the SDF is // the cell's size; the check only keeps the copy inside the cell. if (sw == cell.w && sh == cell.h) { for (int row = 0; row < sh; ++row) { std::memcpy( pixels + static_cast(cell.y + row) * kAtlasSize + cell.x, sdf + row * sw, static_cast(sw) ); } } stbtt_FreeSDF(sdf, nullptr); } ++rasterized_; g.u0 = static_cast(cell.x) / kAtlasSize; g.v0 = static_cast(cell.y) / kAtlasSize; g.u1 = static_cast(cell.x + cell.w) / kAtlasSize; g.v1 = static_cast(cell.y + cell.h) / kAtlasSize; g.handle = packer_.Handle(slot); if (owners_.size() <= slot) owners_.resize(slot + 1); owners_[slot] = key; // The packer has added the cell, and any it cleared, to its shelves' // spans (#129). dirty = true; return true; } const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const { auto it = cache_.find(Key{&font, codepoint}); return it == cache_.end() ? nullptr : &it->second.glyph; } bool FontAtlas::Touch(std::span handles) { const std::uint64_t now = Device::frameCounter; if (settled_ != now) Settle(now); return packer_.Touch(handles, now); } void FontAtlas::InvalidateFont(const Font& font) noexcept { std::erase_if(cache_, [&](const auto& entry) { return entry.first.font == &font; }); std::erase_if(waiting_, [&](const Key& key) { return key.font == &font; }); } void FontAtlas::Update(GraphicsCommandBuffer cmd) { const std::uint64_t now = Device::frameCounter; if (settled_ != now) Settle(now); if (!dirty) return; // Issue one tight copy per dirty span and reset it. Clamping to the atlas // keeps each extent provably valid even though Add() already works in // in-bounds glyph coordinates; UploadBox skips spans that never armed. // The spans are the whole-atlas zero-clear (dirtyRect, Initialize only) // plus each shelf's accumulated glyph run — so scattered glyphs upload as // several small rects, not one tall union covering the gaps between them. auto upload = [&](DirtyRect& r) { std::uint32_t x = 0, y = 0, w = 0, h = 0; if (!r.UploadBox(kAtlasSize, x, y, w, h)) return; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM image.UpdateRegion(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, x, y, w, h); #else // The staging buffer keeps the full atlas row stride, so srcBytesPerRow // stays kAtlasSize and (dstX, dstY) index the sub-rect's first texel. WebGPU::wgpuWriteAtlasRegion( textureHandle, staging.data(), kAtlasSize, kAtlasSize, kAtlasSize, static_cast(x), static_cast(y), static_cast(w), static_cast(h) ); #endif r.Reset(); }; #ifdef CRAFTER_GRAPHICS_WINDOW_DOM (void)cmd; #endif upload(dirtyRect); packer_.ForEachDirty(upload); dirty = false; }