Crafter.Graphics/interfaces/Crafter.Graphics-FontAtlas.cppm
Jorijn van der Graaf 781d95ae75
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel
Shaders
- ui-text and ui-fused turned a sample of the R8_UNORM atlas into a
  distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue +
  kPixelDistScale * distance into each byte, so an atlas pixel is
  kPixelDistScale / 255 of a sample; 32 made every distance 1024/255
  (about 4) times too large, and the coverage ramp, meant to span one
  screen pixel, spanned a quarter of one: nearly every edge pixel came out
  fully on or fully off, and text looked pixelated at every size. On an
  RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels
  where stb_truetype's own rasterizer has 415. Wrong since the v3 UI
  (1f56973), and copied into the fused kernel (574242d).
- The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and
  UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's
  own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a
  glyph's ramp when it is loaded and uiGlyphCoverage the coverage per
  pixel. Both kernels call them, and a copy of either should; the UI_
  prefix keeps them clear of a copied kernel's own names.
- stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded
  value and the decode takes its middle: decoding b pulled every outline
  1/64 atlas pixel inwards.
- The ramp is capped at what a byte holds, about 4 atlas pixels each way
  of the outline. With the corrected decode a wider one left every glyph
  quad's padding translucent below 4 px.
- The UV span is taken with abs(): a glyph turned by swapping its UVs got
  a negative scale, a ramp of 0.0001 and hard edges.
- A glyph scaled differently along x and y ramps along the distance's
  gradient, from two one-texel differences. With the corrected decode, one
  width for both axes, the more minified one's, would leave a row stretched
  three times with stems three pixels soft. Glyphs scaled alike, as
  ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal
  worked out with the glyph.
- The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded
  as 1024.

WebGPU
- The WGSL text kernel decoded the distance right but ramped over one
  atlas pixel whatever the size: half a screen pixel at 16 px, two at
  64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient
  path, and renders TextAntialiasing's scene as Vulkan does (run through
  Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels,
  where the old kernel gave 0.55, 2.19 and 0.89).

FontAtlas
- kOnEdgeValue and kPixelDistScale say the kernels carry copies of them.

Tests
- TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched
  three times, a 'g' turned by its UVs and empty atlas texels shrunk to
  12.8 atlas pixels per screen pixel, through DispatchText and again
  through DispatchFused, and holds them to stb_truetype's antialiased
  rasterizer, to the mirror image and to nothing. On master the two plain
  rows have 0.31 and 0.21 times the reference's edge pixels; with the
  decode fixed alone the stretched row had 1.62 times, the turned 'g' was
  127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07
  and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor
  sets. It skips a swapchain that isn't 8-bit, which the capture can't
  read, and captures the first frame.
- FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their
  copies of the two constants to FontAtlas's. No GPU.

Docs
- AGENTS.consumer.md has a custom text shader decode through the helpers
  and gives the encoding; README says so in a line; AGENTS.md lists the
  tests and where the decode lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:23:40 +01:00

339 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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;
// stb_truetype writes kOnEdgeValue + kPixelDistScale * distance (in
// atlas pixels) into each SDF byte. The text kernels decode it with
// copies of these two, UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE in
// ui-shared.glsl and in the WGSL of dom-webgpu.js; the
// FontAtlasDecode test fails until all three agree.
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<std::uint32_t>(x0);
y = static_cast<std::uint32_t>(y0);
w = static_cast<std::uint32_t>(x1 - x0);
h = static_cast<std::uint32_t>(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<const std::uint32_t> Evicted() const noexcept { return evicted_; }
std::span<const Cell> 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<const GlyphHandle> 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<typename F>
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<std::uint32_t> 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<Shelf> shelves_;
std::vector<Slot> slots_;
std::vector<std::uint32_t> freeSlots_;
std::vector<std::uint32_t> evicted_;
std::vector<Cell> cleared_;
std::vector<std::uint32_t> 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<Box> failedEvicting_;
std::vector<Box> 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<std::uint8_t> image;
#else
WebGPUTextureRef textureHandle = 0;
std::vector<std::uint8_t> 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<const GlyphHandle> 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<const void*>{}(k.font);
std::size_t h2 = std::hash<std::uint32_t>{}(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<Key, Entry, KeyHash> cache_;
// owners_[slot]: the glyph holding a cell, to drop when it is evicted.
std::vector<Key> owners_;
// The glyphs asked for since the last Settle that found no free room,
// in the order they were first asked for.
std::vector<Key> 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);
};
}