diff --git a/include/plot/render/bitmap_font.hpp b/include/plot/render/bitmap_font.hpp index 7ec0386..8c7be53 100644 --- a/include/plot/render/bitmap_font.hpp +++ b/include/plot/render/bitmap_font.hpp @@ -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(kGlyphPx) / static_cast(kAtlasWidth), + static_cast(kGlyphPx) / static_cast(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; diff --git a/include/plot/render/primitives.hpp b/include/plot/render/primitives.hpp index 5eefdca..8c1605a 100644 --- a/include/plot/render/primitives.hpp +++ b/include/plot/render/primitives.hpp @@ -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& 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 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 diff --git a/include/plot/render/program.hpp b/include/plot/render/program.hpp index c29b847..03e3400 100644 --- a/include/plot/render/program.hpp +++ b/include/plot/render/program.hpp @@ -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; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index ea5c608..41f3689 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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; diff --git a/include/plot/render/text.hpp b/include/plot/render/text.hpp index d1f0edc..c683696 100644 --- a/include/plot/render/text.hpp +++ b/include/plot/render/text.hpp @@ -1,5 +1,8 @@ #pragma once +#include + #include +#include 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 diff --git a/src/render/bitmap_font.cpp b/src/render/bitmap_font.cpp index 1ea87ed..d6d5fa0 100644 --- a/src/render/bitmap_font.cpp +++ b/src/render/bitmap_font.cpp @@ -166,23 +166,20 @@ constexpr u32 ink_width(const u8* rows) noexcept { return any ? hi - lo + 1u : 0u; } -/// The glyph table the pipeline stages from: one entry per slot, its rect -/// normalised by the atlas size with `v` growing downward (see `Glyph`), a zero -/// bearing -- the ink sits at the cell's top-left -- and the ink-derived advance. -/// It is built from the same two tables `pack_atlas()` packs, so the rect a glyph -/// reports and the cell its ink was written to cannot disagree. +/// The glyph table the pipeline stages from: one entry per slot, its rect at the +/// slot's place in the grid and one cell big -- the cell `cell_uv()` declares, +/// so a glyph's rect and the size the draw is told cannot disagree -- a zero +/// bearing (the ink sits at the cell's top-left) and the ink-derived advance. +/// Built from the same tables `pack_atlas()` packs. constexpr std::array build_glyphs() noexcept { std::array glyphs{}; - constexpr f32 kCell = static_cast(BitmapFont::kGlyphPx); - constexpr f32 kU = kCell / static_cast(BitmapFont::kAtlasWidth); - constexpr f32 kV = kCell / static_cast(BitmapFont::kAtlasHeight); for (u32 slot = 0; slot < BitmapFont::kGlyphCount; ++slot) { - const f32 u0 = static_cast(slot % BitmapFont::kAtlasCols) * kU; - const f32 v0 = static_cast(slot / BitmapFont::kAtlasCols) * kV; + const f32 u0 = static_cast(slot % BitmapFont::kAtlasCols) * BitmapFont::kCellUV.x; + const f32 v0 = static_cast(slot / BitmapFont::kAtlasCols) * BitmapFont::kCellUV.y; const u32 ink = ink_width(kGlyphBits[slot]); const u32 spaced = ink + 1u; // one pixel of side bearing, floored at two - glyphs[slot] = Glyph{u0, v0, u0 + kU, v0 + kV, 0.0f, 0.0f, - static_cast(spaced < 2u ? 2u : spaced)}; + glyphs[slot] = Glyph{u0, v0, u0 + BitmapFont::kCellUV.x, v0 + BitmapFont::kCellUV.y, 0.0f, + 0.0f, static_cast(spaced < 2u ? 2u : spaced)}; } return glyphs; } diff --git a/src/render/primitives.cpp b/src/render/primitives.cpp index 07f537e..c8c90c9 100644 --- a/src/render/primitives.cpp +++ b/src/render/primitives.cpp @@ -1,10 +1,18 @@ #include +#include #include 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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 30ed291..d496ae5 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -136,6 +136,8 @@ const render::Glyph* Renderer::AxisFont::glyph(u32 codepoint) const { return &scaled_; } +Vec2 Renderer::AxisFont::cell_uv() const { return font_ != nullptr ? font_->cell_uv() : Vec2{}; } + const u8* Renderer::AxisFont::atlas_pixels() const { return font_ != nullptr ? font_->atlas_pixels() : nullptr; } @@ -262,6 +264,10 @@ void Renderer::draw(Plot& plot) { // measure with the size the text pipeline draws with, and re-read the style // each frame so a changed axis_font_px takes effect on the next one. font_backend_.reset(font_, style_.axis_font_px); + // The cell every glyph staged this frame is drawn in, declared from the backend + // rather than left to whichever glyph is staged first: the instances carry rect + // origins, and the draw carries this size (see TextBackend::cell_uv). + glyphs_.set_cell(font_backend_.cell_uv()); // A backend whose revision moved -- a font swapped, or re-baked at another size // -- is re-uploaded here, before anything is staged from it, so the frame draws // its glyphs from the atlas they were baked into. The first sync happened in @@ -385,11 +391,11 @@ void Renderer::flush_text() noexcept { upload_text_vertices(glyphs_.data()); text_program_.bind(); text_program_.set_viewport({viewport_.w, viewport_.h}); - // The cell comes from the glyphs staged and the atlas they came from -- never - // from a constant -- so a font swap moves the quads with the artwork: - // `glyph_uv` is the rect every instance is read through and `px_range` is that - // same cell on screen. - const Vec2 cell_uv = glyphs_.glyph_cell_uv(); + // The cell comes from the backend and the atlas it uploaded -- never from a + // constant, and never from whichever glyph happened to be staged first -- so a + // font swap moves the quads with the artwork: `glyph_uv` is the rect every + // instance is read through and `px_range` is that same cell on screen. + const Vec2 cell_uv = font_backend_.cell_uv(); const Vec2 atlas_texels = atlas_.texel_size(); text_program_.set_glyph_uv(cell_uv); text_program_.set_px_range({cell_uv.x * atlas_texels.x * font_backend_.scale(), diff --git a/tests/test_gl_context.cpp b/tests/test_gl_context.cpp index a1963ec..a1ee7f3 100644 --- a/tests/test_gl_context.cpp +++ b/tests/test_gl_context.cpp @@ -384,7 +384,7 @@ PLOT_TEST(text_pipeline_draws_a_glyph) { program.bind(); program.set_viewport({static_cast(ctx.width), static_cast(ctx.height)}); program.set_px_range({static_cast(BitmapFont::kGlyphPx), static_cast(BitmapFont::kGlyphPx)}); - program.set_glyph_uv(batch.glyph_cell_uv()); + program.set_glyph_uv(font.cell_uv()); program.set_texture_size(atlas.texel_size()); program.set_color(plot::Color::hex(0xFF00FF)); atlas.bind(); diff --git a/tests/test_primitives.cpp b/tests/test_primitives.cpp index a358b35..6392d46 100644 --- a/tests/test_primitives.cpp +++ b/tests/test_primitives.cpp @@ -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('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('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('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('A')); + wide.u1 = wide.u0 + 0.75f; + PLOT_CHECK(!plot::render::glyph_is_cell(wide, cell)); + plot::render::Glyph tall = *backend.glyph(static_cast('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('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::render::BitmapFont::kGlyphPx) / + static_cast(plot::render::BitmapFont::kAtlasWidth), + 1e-9); + PLOT_CHECK_NEAR(font.cell_uv().y, static_cast(plot::render::BitmapFont::kGlyphPx) / + static_cast(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('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); +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 24f2321..763e35b 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -15,7 +15,11 @@ #include #include +#include #include +#include +#include +#include #include namespace { @@ -129,6 +133,7 @@ class OnePxPerStringBackend final : public plot::render::TextBackend { plot::f32 height() const override { return 1.0f; } plot::f32 ascent() const override { return 1.0f; } const plot::render::Glyph* glyph(plot::u32) const override { return nullptr; } + plot::Vec2 cell_uv() const override { return plot::Vec2{}; } // no atlas, no cell const plot::u8* atlas_pixels() const override { return nullptr; } plot::u32 atlas_width() const override { return 0u; } plot::u32 atlas_height() const override { return 0u; } @@ -161,6 +166,14 @@ class BakeableBackend final : public plot::render::TextBackend { ++revision_; } + /// Trims the published glyph's rect to an ink box, which is what breaks the + /// fixed-cell grid (see `TextBackend::cell_uv`): the rect is no longer the cell + /// `cell_uv()` declares, so the staging path's check fails. Used by the case + /// that pins that check. + void trim_glyph_rect(plot::f32 width) { + glyph_.u1 = glyph_.u0 + width; + } + /// Publishes no atlas at all (the "no text" a backend is allowed to be), and /// bumps the revision -- which is how a backend that *starts* publishing one /// announces it. @@ -178,6 +191,7 @@ class BakeableBackend final : public plot::render::TextBackend { plot::f32 height() const override { return static_cast(size_); } plot::f32 ascent() const override { return static_cast(size_); } const plot::render::Glyph* glyph(plot::u32) const override { return &glyph_; } + plot::Vec2 cell_uv() const override { return pixels_.empty() ? plot::Vec2{} : plot::Vec2{1.0f, 1.0f}; } const plot::u8* atlas_pixels() const override { return pixels_.empty() ? nullptr : pixels_.data(); } plot::u32 atlas_width() const override { return size_; } plot::u32 atlas_height() const override { return size_; } @@ -252,9 +266,9 @@ InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& at plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA); program.bind(); program.set_viewport({static_cast(ctx.width), static_cast(ctx.height)}); - // The same two numbers the renderer's flush computes: the staged cell, and that - // cell's size on screen at this scale. - const plot::Vec2 cell = batch.glyph_cell_uv(); + // The same two numbers the renderer's flush computes: the backend's cell, and + // that cell's size on screen at this scale. + const plot::Vec2 cell = backend.cell_uv(); const plot::Vec2 texels = atlas.texel_size(); program.set_glyph_uv(cell); program.set_px_range({cell.x * texels.x * px, cell.y * texels.y * px}); @@ -1409,3 +1423,36 @@ PLOT_TEST(a_backend_without_an_atlas_uploads_nothing) { PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); ctx.destroy(); } + +// The fixed-cell check itself, in the build where it is fatal. A backend whose +// glyph rects are ink boxes (see `TextBackend::cell_uv`) would draw every glyph at +// the size the draw was told, reading past its own rows of the atlas -- so the +// staging path asserts `glyph_is_cell()` and this case pins that it does, not +// merely that the predicate can fail. +// +// The assertion's death is the observable, and the only protocol for observing a +// death is a child process: staging this glyph in-process would take the suite +// with it. The child allocates nothing before it dies -- the check comes before +// the first `push_back` -- and its own `assert` line lands on stderr above this +// case's line, which is the evidence for a human reading the log. +PLOT_TEST(staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build) { +#ifndef NDEBUG + BakeableBackend backend; + backend.trim_glyph_rect(0.5f); // half a cell: no longer the declared cell + PLOT_CHECK(!plot::render::glyph_is_cell(*backend.glyph(0u), backend.cell_uv())); + + std::fflush(nullptr); + const pid_t child = ::fork(); + PLOT_CHECK(child >= 0); + if (child == 0) { + plot::render::TextBatch batch; + batch.begin(); + batch.add_text("X", {0.0f, 0.0f}, 1.0f, backend); // dies here, in debug + ::_exit(0); // reached only when the check did not fire + } + int status = 0; + PLOT_CHECK(::waitpid(child, &status, 0) == child); + PLOT_CHECK(WIFSIGNALED(status)); // it did not return + PLOT_CHECK(WTERMSIG(status) == SIGABRT); // and it was the assertion +#endif +}