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

+156
View File
@@ -328,3 +328,159 @@ PLOT_TEST(text_batch_advances_along_its_basis) {
PLOT_CHECK_NEAR(b.data()[4], 0.6, 1e-5);
PLOT_CHECK_NEAR(b.data()[5], 0.8, 1e-5);
}
/// A backend whose atlas is *not* a fixed-cell grid: it declares the cell its
/// pipeline needs and then publishes one glyph trimmed to its ink, which is what
/// a naive TrueType packing produces (`stbtt_GetCodepointBitmap` hands back an ink
/// box, not a cell). The pipeline cannot draw that glyph correctly -- a staged
/// instance carries the rect's origin, and the draw carries one size for the whole
/// run -- so the staging path asserts `glyph_is_cell()` for every glyph it stages.
class TrimmedCellBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char*) const override { return 8.0f; }
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 8.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 0.25f}; }
plot::u32 revision() const override { return 1u; } // baked once, never re-baked
const plot::render::Glyph* glyph(plot::u32 codepoint) const override {
// 'i' is trimmed to half a cell wide, as an ink box would be.
return codepoint == static_cast<plot::u32>('i') ? &trimmed_ : &cell_;
}
const plot::u8* atlas_pixels() const override { return atlas_; }
plot::u32 atlas_width() const override { return 8u; }
plot::u32 atlas_height() const override { return 4u; }
private:
// Two cells of a 2x2 grid of 0.5 x 0.25, one of them trimmed to the ink.
const plot::render::Glyph cell_{0.0f, 0.0f, 0.5f, 0.25f, 0.0f, 0.0f, 4.0f};
const plot::render::Glyph trimmed_{0.5f, 0.0f, 0.6875f, 0.25f, 0.0f, 0.0f, 4.0f};
const plot::u8 atlas_[8 * 4] = {};
};
// The fixed-cell contract, and the check that enforces it. A backend that trims
// each atlas rect to its ink -- the shape a TrueType packing produces, and the
// reason the contract is stated on `TextBackend::cell_uv()` -- publishes glyphs
// the pipeline would draw at the wrong size: the instance carries the rect's
// *origin* only, so a 3/8-cell rect would be read as a half cell, past its own
// rows.
//
// This drives the predicate the staging path asserts, rather than staging the
// offending glyph, because `add_text()` with this backend aborts the test binary
// in a debug build -- which is the point of the assertion.
PLOT_TEST(a_trimmed_glyph_is_not_the_atlas_cell) {
TrimmedCellBackend backend;
const plot::Vec2 cell = backend.cell_uv();
PLOT_CHECK_NEAR(cell.x, 0.5, 1e-6);
PLOT_CHECK_NEAR(cell.y, 0.25, 1e-6);
// The glyph that occupies a whole cell passes ...
PLOT_CHECK(plot::render::glyph_is_cell(*backend.glyph(static_cast<plot::u32>('A')), cell));
// ... and the one trimmed to its ink does not: this is what the staging path
// asserts in a debug build.
PLOT_CHECK(!plot::render::glyph_is_cell(*backend.glyph(static_cast<plot::u32>('i')), cell));
// The check is per axis and per glyph, not a single "is it square" test: a rect
// that matches on one axis and not the other is still not the cell.
plot::render::Glyph wide = *backend.glyph(static_cast<plot::u32>('A'));
wide.u1 = wide.u0 + 0.75f;
PLOT_CHECK(!plot::render::glyph_is_cell(wide, cell));
plot::render::Glyph tall = *backend.glyph(static_cast<plot::u32>('A'));
tall.v1 = tall.v0 + 0.5f;
PLOT_CHECK(!plot::render::glyph_is_cell(tall, cell));
// Rounding a correct cell through the atlas arithmetic twice is not a failure:
// a rect is a subtraction of two rounded fractions, and the tolerance is two
// orders of magnitude above that slop and below one texel.
plot::render::Glyph rounded = *backend.glyph(static_cast<plot::u32>('A'));
rounded.u1 = rounded.u0 + cell.x + 1e-7f;
PLOT_CHECK(plot::render::glyph_is_cell(rounded, cell));
// A real font's grid holds for every glyph it publishes, not just the first.
plot::render::BitmapFont font;
for (plot::u32 codepoint = 32u; codepoint < 127u; ++codepoint) {
const plot::render::Glyph* g = font.glyph(codepoint);
PLOT_CHECK(g != nullptr);
PLOT_CHECK(plot::render::glyph_is_cell(*g, font.cell_uv()));
}
PLOT_CHECK_NEAR(font.cell_uv().x, static_cast<plot::f32>(plot::render::BitmapFont::kGlyphPx) /
static_cast<plot::f32>(plot::render::BitmapFont::kAtlasWidth),
1e-9);
PLOT_CHECK_NEAR(font.cell_uv().y, static_cast<plot::f32>(plot::render::BitmapFont::kGlyphPx) /
static_cast<plot::f32>(plot::render::BitmapFont::kAtlasHeight),
1e-9);
}
/// A backend whose cell is nothing like the bitmap font's -- half by a quarter of
/// a small atlas -- so anything a batch reports that equals it came from the
/// backend and not from a glyph of some other font.
class HalfCellBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char*) const override { return 4.0f; }
plot::f32 height() const override { return 8.0f; }
plot::f32 ascent() const override { return 6.0f; }
plot::Vec2 cell_uv() const override { return plot::Vec2{0.5f, 0.25f}; }
plot::u32 revision() const override { return 1u; }
const plot::render::Glyph* glyph(plot::u32) const override { return &cell_; }
const plot::u8* atlas_pixels() const override { return atlas_; }
plot::u32 atlas_width() const override { return 8u; }
plot::u32 atlas_height() const override { return 4u; }
private:
// One cell, at the second slot of a 2x2 grid. Its rect is the declared cell to
// within the atlas tolerance (5e-7) but not bit for bit -- which is what any
// backend's rounded arithmetic looks like, and what makes "the backend's
// number" and "this glyph's rect size" two distinguishable values.
const plot::render::Glyph cell_{0.5f, 0.0f, 1.0000005f, 0.2500005f, 0.0f, 0.0f, 4.0f};
const plot::u8 atlas_[8 * 4] = {};
};
// The draw's one rect size is the *backend's* `cell_uv()`, and it is known before
// a glyph is staged: a batch that took it from whatever glyph it happened to
// stage first could not answer the first of these, and would take another font's
// grid the moment a caller staged two backends into one batch. `add_text()`
// declares it from the backend it is handed, and `set_cell()` is how a caller
// staging glyphs one at a time -- the renderer's clipped walk -- says it.
PLOT_TEST(text_batch_takes_its_cell_from_the_backend) {
HalfCellBackend backend;
plot::render::TextBatch b;
b.begin();
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.0, 1e-6); // nothing declared, nothing staged
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, 0.0, 1e-6);
b.set_cell(backend.cell_uv());
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.5, 1e-6); // declared, still nothing staged
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, 0.25, 1e-6);
// A staged run keeps it -- and keeps the *backend's* number, not the staged
// glyph's: this backend's rects are 5e-7 off its declared cell (inside the
// tolerance, so it is a legal grid), and an implementation that recorded
// whatever glyph it staged first would land off these by exactly that.
b.begin();
const plot::Vec2 end = b.add_text("AB", {0.0f, 0.0f}, 1.0f, backend);
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, backend.cell_uv().x, 1e-12);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, backend.cell_uv().y, 1e-12);
PLOT_CHECK(backend.cell_uv().x != backend.glyph(0u)->u1 - backend.glyph(0u)->u0);
PLOT_CHECK(plot::render::glyph_is_cell(*backend.glyph(0u), b.glyph_cell_uv()));
PLOT_CHECK_NEAR(end.x, 2.0f * backend.glyph(static_cast<plot::u32>('A'))->advance, 1e-6);
// The instances carry the cell's *origin* in the atlas; the size rides along
// once, above, which is the whole reason a glyph fits in six floats.
PLOT_CHECK_NEAR(b.data()[2], backend.glyph(0u)->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[3], backend.glyph(0u)->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[4], 1.0, 1e-6); // unrotated basis
// And the real font's grid, staged the same way, reports the font's cell -- the
// two never mix silently, and the batch's value tracks the backend it was given.
plot::render::BitmapFont font;
b.begin();
b.add_text("A", {0.0f, 0.0f}, 1.0f, font);
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, font.cell_uv().x, 1e-9);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, font.cell_uv().y, 1e-9);
PLOT_CHECK(b.glyph_cell_uv().x != backend.cell_uv().x);
// begin() forgets the declaration along with the instances, so a frame cannot
// inherit the previous one's cell.
b.begin();
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, 0.0, 1e-6);
}