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

+39 -4
View File
@@ -1,10 +1,18 @@
#include <plot/render/primitives.hpp>
#include <cassert>
#include <cmath>
namespace plot::render {
namespace {
/// Two declared cells agree when neither size differs by more than the atlas'
/// tolerance: the test `glyph_is_cell()` makes, for a cell against a cell.
bool same_cell(Vec2 a, Vec2 b) noexcept {
const auto near = [](f32 p, f32 q) { return std::fabs(p - q) <= kAtlasCellTolerance; };
return near(a.x, b.x) && near(a.y, b.y);
}
/// `axis` as a unit vector: the cell's local +x, which is what the instance and
/// the shader both take (see `TextInstance`). A zero, infinite or NaN direction
/// means "unrotated" -- a caller's arithmetic slip must not turn a label into NaN
@@ -62,14 +70,41 @@ void LineBatch::add_polyline_xy(const f32* xy, u32 n) {
void TextBatch::begin() noexcept {
v_.clear();
cell_uv_ = Vec2{};
cell_declared_ = false;
}
void TextBatch::clear() noexcept {
v_.clear();
cell_uv_ = Vec2{};
cell_declared_ = false;
}
void TextBatch::set_cell(Vec2 cell_uv) noexcept {
// One cell per draw (see TextBackend::cell_uv): the draw carries a single
// `u_glyph_uv`, so a second, different cell inside one batch would hand it to
// glyphs staged for another size. A debug build says so; a release build takes
// the last one, as it takes the rest of its state.
assert((!cell_declared_ || same_cell(cell_uv_, cell_uv)) &&
"plot::render::TextBatch: a second, different atlas cell in one batch");
cell_uv_ = cell_uv;
cell_declared_ = true;
}
void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) {
if (!cell_declared_) {
// Nobody declared a cell: a caller staging raw glyphs, where the glyph's own
// rect is the only authority there is. `add_text` and the renderer both
// declare one from the backend, so the draw never depends on this.
cell_uv_ = Vec2{glyph.u1 - glyph.u0, glyph.v1 - glyph.v0};
cell_declared_ = true;
}
// A fixed-cell grid (see TextBackend::cell_uv): an instance carries the rect's
// *origin*, so a glyph whose rect is not this cell would be drawn at the wrong
// size -- and read past its own row of the atlas doing it -- with nothing
// downstream able to tell. Loud in a debug build; a release build draws on with
// the cell it was told, which is what it did before this check existed.
assert(glyph_is_cell(glyph, cell_uv_) &&
"plot::render::TextBatch: staged glyph's rect is not the atlas cell");
// The cell's own axes: the run direction and its perpendicular, which is the
// cell's +v axis -- the one kTextVS builds the quad from. The bearing is in
// font pixels, so it scales with the run and follows the same axes: a glyph
@@ -82,10 +117,6 @@ void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux)
v_.push_back(glyph.v0);
v_.push_back(unit_ux.x);
v_.push_back(unit_ux.y);
// One cell for the whole batch: every glyph a batch stages comes from the same
// atlas, so the rect the shader reads is the one staged here (see
// glyph_cell_uv()).
cell_uv_ = Vec2{glyph.u1 - glyph.u0, glyph.v1 - glyph.v0};
}
void TextBatch::add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 axis) {
@@ -97,6 +128,10 @@ Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBacken
if (utf8 == nullptr) {
return pen_px;
}
// The cell is the backend's, declared once for the whole run: the staged
// instances carry origins only, and the draw carries this size (see
// TextBackend::cell_uv).
set_cell(font.cell_uv());
const Vec2 ux = unit_axis(axis);
for (const char* p = utf8; *p != '\0';) {
// The backend's own codepoints and the backend's own advances -- the same