refactor(render): the atlas is a declared fixed-cell grid, and staging checks it

The text pipeline stages a glyph's rect *origin* per instance and carries the
rect's *size* once per draw, so a backend whose glyphs have differing rect sizes
-- a TrueType packing trimmed to ink, which is Task 8's shape -- would have drawn
every glyph at whichever size it happened to be told, with nothing downstream
able to tell. Make that constraint structural rather than documented:

- TextBackend::cell_uv() declares the one cell size, and the contract (an atlas
  is a fixed-cell grid; a proportional face draws its glyphs *into* a uniform
  cell instead of trimming each rect to its ink, and why the pipeline needs it)
  is stated on the atlas accessors.
- glyph_is_cell() is the check; add_text()/set_cell() declare the cell from the
  backend, and the staging path asserts it for every glyph in a debug build.
  A release build draws on with the cell it was told, as before.
- Renderer::flush_text() takes u_glyph_uv from the backend, not from the batch,
  so the draw's one rect size can never come from whichever glyph was staged
  first.
This commit is contained in:
Martino Ferrari committed 2026-09-28 10:09:50 +02:00
1 parent e074f57ddf
commit ffc060d6ca
11 files changed
+364 -43

No files matched your search

+10
View File
@@ -40,6 +40,13 @@ class BitmapFont final : public TextBackend {
static constexpr u32 kAtlasRows = 7;
static constexpr u32 kAtlasWidth = kAtlasCols * kGlyphPx; ///< 128 texels
static constexpr u32 kAtlasHeight = kAtlasRows * kGlyphPx; ///< 56 texels
/// The atlas cell, normalised: one `kGlyphPx` square of the grid. Every glyph's
/// rect is this, and `cell_uv()` reports it -- one number, derived from the same
/// two constants `pack_atlas()` packs, so the grid the pipeline draws and the
/// grid the ink was written into cannot disagree.
static constexpr Vec2 kCellUV{static_cast<f32>(kGlyphPx) / static_cast<f32>(kAtlasWidth),
static_cast<f32>(kGlyphPx) / static_cast<f32>(kAtlasHeight)};
static_assert(kAtlasCols * kAtlasRows >= kGlyphCount, "atlas too small for the glyph set");
BitmapFont() noexcept = default;
@@ -71,6 +78,9 @@ class BitmapFont final : public TextBackend {
/// what a label outside the coverage set should show.
const Glyph* glyph(u32 codepoint) const override;
/// `kCellUV`: the 8x8 cell every glyph occupies.
Vec2 cell_uv() const override { return kCellUV; }
/// The packed atlas, as `TextBackend` sees it: the bytes `atlas_image()`
/// returns, `kAtlasWidth` by `kAtlasHeight`, GL_R8-ready. Never null.
const u8* atlas_pixels() const override;
+22 -6
View File
@@ -61,15 +61,25 @@ class LineBatch {
/// buffer.hpp).
///
/// Every glyph a batch stages is read through one atlas rect of one size — its
/// cell — so that size is recorded once (`glyph_cell_uv()`) and handed to the
/// draw, rather than riding along with every instance.
/// cell (see `TextBackend::cell_uv`) — so that size is declared once
/// (`set_cell()`) and handed to the draw, rather than riding along with every
/// instance. In a debug build every staged glyph is checked against it.
class TextBatch {
public:
/// Starts a frame: empties the batch and the staged cell, keeps the capacity.
/// Starts a frame: empties the batch and forgets the declared cell, keeps the
/// capacity.
void begin() noexcept;
/// Same as `begin()`.
void clear() noexcept;
/// Declares the atlas cell the glyphs staged from here on are drawn in -- the
/// backend's `cell_uv()`. `add_text()` does this itself from the backend it is
/// handed; a caller staging glyphs one at a time says it here, so the draw's
/// single rect size comes from the backend rather than from whichever glyph was
/// staged first. A debug build asserts that no second, different cell arrives
/// in one batch.
void set_cell(Vec2 cell_uv) noexcept;
/// Stages one glyph cell whose *pen* is `pen_px`: the cell's top-left corner is
/// the pen moved by the glyph's bearing (see `Glyph`), scaled and measured
/// along the run's own axes, so a glyph keeps its place inside a rotated run.
@@ -101,9 +111,11 @@ class TextBatch {
/// Interleaved `x,y,u,v,ax,ay`, six floats per staged glyph.
const std::vector<f32>& data() const noexcept { return v_; }
/// The staged glyphs' cell in the atlas, normalised: `(u1 - u0, v1 - v0)` of
/// the glyphs staged, which the draw passes as `u_glyph_uv` (see
/// `TextProgram::set_glyph_uv`). `(0, 0)` while nothing is staged.
/// The cell the staged glyphs are drawn in: what `set_cell()` declared, or --
/// for a caller that stages raw glyphs without ever declaring one -- the rect
/// size of the first glyph staged, since nothing else can be the authority
/// there. The renderer declares the cell from the backend it draws with, so it
/// never depends on that fallback. `(0, 0)` while nothing is staged.
Vec2 glyph_cell_uv() const noexcept { return cell_uv_; }
private:
@@ -113,6 +125,10 @@ class TextBatch {
std::vector<f32> v_;
Vec2 cell_uv_{};
/// False until a cell arrives -- from `set_cell()`, or from the first glyph
/// staged by a caller that declared none. It keeps a zero-sized cell
/// distinguishable from "nothing said yet".
bool cell_declared_ = false;
};
} // namespace plot::render
+3 -3
View File
@@ -52,9 +52,9 @@ class TextProgram {
/// height -- a cell need not be square).
void set_px_range(Vec2 px) const noexcept;
/// `u_glyph_uv`: the glyph cell's size in the atlas, normalised. It is the
/// rect every staged glyph is read through, so it is one size per draw: a
/// batch stages glyphs from one atlas, and `TextBatch::glyph_cell_uv()` is
/// where it comes from.
/// rect every staged glyph is read through, so it is one size per draw -- and
/// it is the *backend's* (`TextBackend::cell_uv`), never a glyph's, because a
/// draw may stage glyphs whose origins differ even when their size cannot.
void set_glyph_uv(Vec2 uv) const noexcept;
/// `u_tex_size`: atlas size in texels.
void set_texture_size(Vec2 texels) const noexcept;
+5
View File
@@ -197,6 +197,11 @@ class Renderer {
/// wrapper until the next call, which is enough for a caller measuring or
/// laying out one glyph at a time.
const render::Glyph* glyph(u32 codepoint) const override;
/// The font's cell: the atlas is the font's own, and a cell's size is a
/// property of the atlas, not of the size text is drawn at, so this forwards
/// unchanged -- which is what makes it the authoritative value the staging
/// path declares and the draw is told.
Vec2 cell_uv() const override;
const u8* atlas_pixels() const override;
u32 atlas_width() const override;
u32 atlas_height() const override;
+58 -9
View File
@@ -1,5 +1,8 @@
#pragma once
#include <cmath>
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
namespace plot::render {
@@ -20,6 +23,12 @@ namespace plot::render {
/// (top-left origin, font pixels) and `advance` is how far the pen moves along
/// the baseline. Both are in font pixels, so a caller scaling a run multiplies
/// them by the same factor it draws the atlas with.
///
/// **The rect is the atlas cell**, not the ink box: every glyph of a backend
/// occupies one cell of one size, so `u1 - u0` and `v1 - v0` are the same pair for
/// every codepoint -- the pair `TextBackend::cell_uv()` reports. The ink sits
/// inside the cell at the bearing. See `cell_uv()` for why the pipeline needs
/// that, and `glyph_is_cell()` for the check that enforces it.
struct Glyph {
f32 u0 = 0.0f;
f32 v0 = 0.0f;
@@ -35,6 +44,25 @@ struct Glyph {
/// backend draws for "no glyph" rather than colliding with a real codepoint.
inline constexpr u32 kInvalidCodepoint = 0xFFFFFFFFu;
/// Tolerance for comparing two normalised atlas sizes. A backend derives a rect
/// size as `u1 - u0`, a subtraction of two rounded fractions, so a *correct* cell
/// comes back a few 1e-8 out; the smallest rect that is genuinely a different
/// size differs by one atlas texel, which is 1e-4 or coarser on any atlas a GPU
/// will hold. 1e-6 separates "the same cell, rounded twice" from "a different
/// rect" with orders of magnitude to spare on both sides.
inline constexpr f32 kAtlasCellTolerance = 1e-6f;
/// True when `glyph`'s rect is the atlas cell `cell_uv` -- the invariant an atlas
/// is required to keep (see `TextBackend::cell_uv`).
///
/// It is a function rather than an assertion inside one caller because it is the
/// check the staging path asserts for every glyph it stages, and a test has to be
/// able to drive it over a backend that breaks it without aborting the suite.
inline bool glyph_is_cell(const Glyph& glyph, Vec2 cell_uv) noexcept {
const auto near = [](f32 a, f32 b) { return std::fabs(a - b) <= kAtlasCellTolerance; };
return near(glyph.u1 - glyph.u0, cell_uv.x) && near(glyph.v1 - glyph.v0, cell_uv.y);
}
/// Decodes one UTF-8 sequence from `p` and advances `p` past it -- one byte when
/// the sequence is malformed, so a damaged label still terminates.
///
@@ -58,15 +86,10 @@ u32 next_codepoint(const char*& p) noexcept;
/// renderer knowing anything about fonts. `measure()`/`height()`/`ascent()` are
/// in the atlas' font pixels: the pipeline scales them, never re-rasterises.
///
/// **One atlas, one cell size.** A staged glyph carries the *origin* of its rect
/// and the draw carries the rect's size, once, for every instance in it (see
/// `TextProgram::set_glyph_uv`) -- which is what keeps a glyph at six floats while
/// still letting it be rotated. So every glyph a backend publishes must occupy a
/// rect of the same size: its cell, with the ink inside it at `(xoff, yoff)`. A
/// proportional face therefore bakes each glyph into a cell wide enough for the
/// widest advance and spends the atlas area rather than the correctness; a
/// backend whose rects differ in size would have its glyphs read through
/// whichever rect was staged first.
/// **One atlas, one cell size**: the atlas is a *fixed-cell grid* and
/// `cell_uv()` is where that size is declared -- see it for the full contract
/// and for what a proportional face has to do to satisfy it. The staging path
/// asserts `glyph_is_cell()` for every glyph it stages in a debug build.
class TextBackend {
public:
TextBackend() noexcept = default;
@@ -90,10 +113,36 @@ class TextBackend {
/// publishes one as the glyph for the codepoints it cannot render). The
/// returned pointer stays valid for the backend's lifetime, so callers may hold
/// it across a frame.
///
/// Its rect is the atlas **cell**, not the ink box: see `cell_uv()`.
virtual const Glyph* glyph(u32 codepoint) const = 0;
/// The atlas size of one cell, as a normalised rect size: `(u1 - u0, v1 - v0)`
/// of every glyph the atlas holds -- the size `glyph()` reports each of them
/// with. `(0, 0)` is legal and means "no text".
///
/// **An atlas is a fixed-cell grid, and this is where that is declared.** Each
/// glyph is drawn in a cell of this one size, because the pipeline stages a
/// glyph's rect *origin* per instance and carries the rect's *size* once for
/// the whole draw: six floats per glyph instead of eight, no per-glyph size in
/// the vertex shader, and rotation for two of those floats. The staging path
/// asserts `glyph_is_cell()` -- every staged glyph's rect against this value --
/// in a debug build, so a backend that breaks the contract fails loudly rather
/// than drawing every glyph at whichever size it happened to be told.
///
/// **For implementers:** a proportional face therefore draws its glyphs *into*
/// a uniform cell -- the ink placed inside it at `xoff`/`yoff`, the pen
/// advanced by `advance` -- rather than trimming each atlas rect to its ink,
/// and spends the atlas area instead of the correctness. The cell has to be
/// wide enough for the widest advance and tall enough for the tallest ink, and
/// this must agree with the rects `glyph()` reports; a backend whose rects are
/// ink boxes (a naive TrueType packing, which trims each bitmap to its ink)
/// violates it.
virtual Vec2 cell_uv() const = 0;
/// 8-bit single-channel coverage, row-major, `atlas_width() * atlas_height()`
/// bytes, top row first. Uploaded as GL_R8. Null is legal and means "no text".
/// The bytes are a grid of `cell_uv()`-sized cells.
virtual const u8* atlas_pixels() const = 0;
/// Atlas size in texels. Zero width or height means "no text", like a null