refactor(render): TextBackend owns its atlas, and staged glyphs carry a rotation basis

This commit is contained in:
Martino Ferrari committed 2026-09-28 09:57:28 +02:00
1 parent 90e17a0a11
commit e074f57ddf
23 files changed
+1073 -227

No files matched your search

+20 -12
View File
@@ -10,6 +10,7 @@
#include <plot/render/primitives.hpp>
#include <plot/render/program.hpp>
#include <plot/render/shaders.hpp>
#include <plot/render/text_atlas.hpp>
#include <plot/render/vertex_array.hpp>
#include <cstdint>
@@ -249,6 +250,7 @@ PLOT_TEST(render_programs_are_inert_until_created) {
text.bind();
text.set_viewport({256.0f, 256.0f});
text.set_px_range({8.0f, 8.0f});
text.set_glyph_uv({1.0f / 16.0f, 1.0f / 7.0f});
text.set_texture_size({128.0f, 56.0f});
text.set_color(plot::Color::hex(0xFFFFFF));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
@@ -338,9 +340,9 @@ PLOT_TEST(line_pipeline_draws_an_aa_strip) {
}
// The text pipeline end to end: the R8 atlas upload, the per-instance glyph UV
// origin, the NEAREST texel-centre sampling, the tint and the same single y
// flip. Every one of the cell's 64 pixels is checked against the glyph bitmap,
// so a wrong atlas cell, a wrong UV or a transposed axis cannot pass.
// rect, the NEAREST texel-centre sampling, the tint and the same single y flip.
// Every one of the cell's 64 pixels is checked against the glyph bitmap, so a
// wrong atlas cell, a wrong UV or a transposed axis cannot pass.
PLOT_TEST(text_pipeline_draws_a_glyph) {
using plot::render::BitmapFont;
plottest::HeadlessContext ctx(256, 256);
@@ -348,22 +350,28 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
// The renderer's atlas: single channel, GL_NEAREST, straight alpha.
plot::gl::Texture atlas;
PLOT_CHECK(atlas.create_r8(BitmapFont::kAtlasWidth, BitmapFont::kAtlasHeight, &st));
atlas.upload_r8(BitmapFont::kAtlasWidth, BitmapFont::kAtlasHeight, BitmapFont::atlas_image());
atlas.set_filter(plot::gl::GL_NEAREST, plot::gl::GL_NEAREST);
// The backend's atlas, through the owner the renderer uses: single channel,
// GL_NEAREST, straight alpha, uploaded from the backend's own bytes.
BitmapFont font;
plot::render::TextAtlas atlas;
PLOT_CHECK(atlas.sync(font, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, static_cast<plot::f32>(BitmapFont::kAtlasWidth), 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast<plot::f32>(BitmapFont::kAtlasHeight), 1e-6);
plot::render::TextProgram program;
PLOT_CHECK(program.create(&st));
plot::render::TextVertexArray glyphs;
PLOT_CHECK(glyphs.create(&st));
BitmapFont font;
plot::render::TextBatch batch;
batch.begin();
batch.add_text({64.0f, 100.0f}, "A", font); // one cell at Rect (64, 100)
// One cell at Rect (64, 100), at the backend's own size: `px` of 1 means one
// font pixel per pixel, which is what makes the texel-to-pixel mapping below
// an identity.
batch.add_text("A", {64.0f, 100.0f}, 1.0f, font);
PLOT_CHECK(batch.glyph_count() == 1);
PLOT_CHECK(batch.data().size() == 6);
glyphs.upload(batch.data().data(), batch.glyph_count());
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
@@ -376,8 +384,8 @@ PLOT_TEST(text_pipeline_draws_a_glyph) {
program.bind();
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
program.set_px_range({static_cast<f32>(BitmapFont::kGlyphPx), static_cast<f32>(BitmapFont::kGlyphPx)});
program.set_texture_size({static_cast<f32>(BitmapFont::kAtlasWidth),
static_cast<f32>(BitmapFont::kAtlasHeight)});
program.set_glyph_uv(batch.glyph_cell_uv());
program.set_texture_size(atlas.texel_size());
program.set_color(plot::Color::hex(0xFF00FF));
atlas.bind();
glyphs.bind();
+65 -14
View File
@@ -247,33 +247,84 @@ PLOT_TEST(text_batch_stages_one_quad_per_glyph) {
plot::render::BitmapFont f;
plot::render::TextBatch b;
b.begin();
const plot::Vec2 end = b.add_text({0.0f, 0.0f}, "12.5 ms", f);
const plot::Vec2 end = b.add_text("12.5 ms", {0.0f, 0.0f}, 1.0f, f);
PLOT_CHECK(b.glyph_count() == 7);
PLOT_CHECK(b.data().size() == 28); // x,y,u,v per glyph
PLOT_CHECK(b.data().size() == 42); // x,y,u,v,ax,ay per glyph
PLOT_CHECK_NEAR(end.x, f.measure("12.5 ms"), 1e-6);
PLOT_CHECK_NEAR(end.y, 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first quad at the pen
PLOT_CHECK_NEAR(b.data()[0], 0.0, 1e-6); // first cell at the pen
PLOT_CHECK_NEAR(b.data()[1], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[4], f.advance('1'), 1e-6); // pen advanced per glyph
// The UV origin of the staged quad is the glyph's atlas cell.
const plot::Vec2 dot_uv =
plot::render::BitmapFont::atlas_origin(static_cast<plot::u32>('.') - 32u);
PLOT_CHECK_NEAR(b.data()[10], dot_uv.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[11], dot_uv.y, 1e-6);
PLOT_CHECK_NEAR(b.data()[6], f.advance('1'), 1e-6); // pen advanced per glyph
// The staged cell's UV is the glyph's own atlas rect (see `Glyph`), and the
// basis of an unrotated run is the screen's +x. '.' is the string's third
// glyph, so its instance is the third: floats 12..17.
const plot::render::Glyph* dot = f.glyph(static_cast<plot::u32>('.'));
PLOT_CHECK(dot != nullptr);
PLOT_CHECK_NEAR(b.data()[14], dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.data()[15], dot->v0, 1e-6);
PLOT_CHECK_NEAR(b.data()[16], 1.0, 1e-6); // ax
PLOT_CHECK_NEAR(b.data()[17], 0.0, 1e-6); // ay
// Every glyph a batch stages is read through one cell, and the batch publishes
// it for the draw (`u_glyph_uv`).
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, dot->u1 - dot->u0, 1e-6);
PLOT_CHECK_NEAR(b.glyph_cell_uv().y, dot->v1 - dot->v0, 1e-6);
// Non-ASCII runs stage one quad per codepoint, and preserve capacity on reuse.
const std::size_t cap = b.data().capacity();
b.begin();
b.add_text({0.0f, 0.0f}, "1.5\xC2\xB5s", f); // 5 codepoints, 6 bytes
PLOT_CHECK_NEAR(b.glyph_cell_uv().x, 0.0, 1e-6); // begin() clears the cell too
b.add_text("1.5\xC2\xB5s", {0.0f, 0.0f}, 1.0f, f); // 5 codepoints, 6 bytes
PLOT_CHECK(b.glyph_count() == 5);
PLOT_CHECK(b.data().capacity() == cap);
// A run drawn at twice the glyph cell size advances twice as far, so the
// staged label still ends where the scaled measurement says it does.
// A run drawn at twice the backend's size advances twice as far, so the staged
// label still ends where the scaled measurement says it does.
b.begin();
const plot::Vec2 scaled = b.add_text({10.0f, 4.0f}, "AW", f, 16.0f);
const plot::Vec2 scaled = b.add_text("AW", {10.0f, 4.0f}, 2.0f, f);
PLOT_CHECK(b.glyph_count() == 2);
PLOT_CHECK_NEAR(scaled.x, 10.0f + 2.0f * f.measure("AW"), 1e-6);
PLOT_CHECK_NEAR(scaled.y, 4.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[4], 10.0f + 2.0f * f.advance('A'), 1e-6); // second glyph's pen
PLOT_CHECK_NEAR(b.data()[6], 10.0f + 2.0f * f.advance('A'), 1e-6); // second cell's pen
}
// The pen follows the basis, not the screen axes. A run staged with `axis` has to
// be exactly as long as the plot layer measured it *in that direction* -- that is
// what reserves the room for a rotated y label -- and its cells have to march along
// the same direction, or the ink lands beside the pen instead of up from it.
PLOT_TEST(text_batch_advances_along_its_basis) {
plot::render::BitmapFont f;
plot::render::TextBatch b;
const plot::Vec2 pen{100.0f, 40.0f};
const f32 width = f.measure("12.5 ms");
// Unrotated, for the shape of the assertion: the run is `measure()` long and
// does not move in y.
b.begin();
const plot::Vec2 flat = b.add_text("12.5 ms", pen, 1.0f, f);
PLOT_CHECK_NEAR((flat - pen).length(), width, 1e-4);
PLOT_CHECK_NEAR(flat.y, pen.y, 1e-6);
// Up the screen: the same length, measured upward, with a unit basis on every
// staged cell.
b.begin();
const plot::Vec2 up = b.add_text("12.5 ms", pen, 1.0f, f, {0.0f, -1.0f});
PLOT_CHECK_NEAR((up - pen).length(), width, 1e-4);
PLOT_CHECK_NEAR(up.x, pen.x, 1e-6);
PLOT_CHECK_NEAR(up.y, pen.y - width, 1e-4);
for (plot::u32 i = 0; i < b.glyph_count(); ++i) {
PLOT_CHECK_NEAR(b.data()[6u * i + 4u], 0.0, 1e-6);
PLOT_CHECK_NEAR(b.data()[6u * i + 5u], -1.0, 1e-6);
}
// The second cell is one advance further up than the pen, which is the same rule
// the unrotated case follows in x.
PLOT_CHECK_NEAR(b.data()[6], pen.x, 1e-6);
PLOT_CHECK_NEAR(b.data()[7], pen.y - f.advance('1'), 1e-6);
// A diagonal direction is normalised, so a caller can hand over any direction
// and the basis the shader receives is still unit.
b.begin();
const plot::Vec2 diag = b.add_text("12.5 ms", pen, 1.0f, f, {3.0f, 4.0f});
PLOT_CHECK_NEAR((diag - pen).length(), width, 1e-4);
PLOT_CHECK_NEAR(b.data()[4], 0.6, 1e-5);
PLOT_CHECK_NEAR(b.data()[5], 0.8, 1e-5);
}
+337 -3
View File
@@ -115,18 +115,158 @@ bool pixel_is(const plot::u8 px[4], plot::Color c) {
return near(px[0], c.r) && near(px[1], c.g) && near(px[2], c.b);
}
/// No column held ink.
constexpr plot::u32 kNoColumn = 0xFFFFFFFFu;
/// A host's `TextBackend` that measures one pixel per string, when the labels the
/// renderer draws are tens of pixels wide: installed on a plot, it asks for a label
/// strip far too narrow for anything the text pipeline draws.
/// strip far too narrow for anything the text pipeline draws. It publishes no
/// atlas at all -- a backend that draws nothing is still a legal backend to
/// measure with, and the plot layer only ever asks it for metrics.
class OnePxPerStringBackend final : public plot::render::TextBackend {
public:
plot::f32 measure(const char*) const override { return 1.0f; }
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; }
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; }
plot::u32 revision() const override { return 0u; }
};
/// No column held ink.
constexpr plot::u32 kNoColumn = 0xFFFFFFFFu;
/// A backend the test bakes by hand: one glyph whose cell is the whole atlas, and
/// a revision it bumps on every bake. Baking two atlases of *different sizes* with
/// the ink on opposite sides of the cell is what makes a stale atlas visible -- the
/// frame after a re-bake must read the new bytes, and a quad that is still the old
/// size cannot even reach the new ink.
class BakeableBackend final : public plot::render::TextBackend {
public:
BakeableBackend() noexcept { bake(8u, false); }
/// Bakes a `size` x `size` atlas with ink on the right half when `ink_right`,
/// on the left half otherwise, and bumps the revision. The glyph's cell is the
/// whole atlas, so the drawn quad is `size * px` pixels across.
void bake(plot::u32 size, bool ink_right) {
size_ = size;
pixels_.assign(static_cast<std::size_t>(size) * size, 0u);
for (plot::u32 y = 0; y < size; ++y) {
for (plot::u32 x = 0; x < size; ++x) {
if ((x >= size / 2u) == ink_right) {
pixels_[static_cast<std::size_t>(y) * size + x] = 255u;
}
}
}
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(size)};
++revision_;
}
/// 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.
void drop_atlas() {
pixels_.clear();
size_ = 0u;
++revision_;
}
/// Moves the revision without touching the artwork, so a test can drive it
/// backwards -- any change is a change, and "smaller" is still a change.
void set_revision(plot::u32 revision) noexcept { revision_ = revision; }
plot::f32 measure(const char*) const override { return 8.0f; }
plot::f32 height() const override { return static_cast<plot::f32>(size_); }
plot::f32 ascent() const override { return static_cast<plot::f32>(size_); }
const plot::render::Glyph* glyph(plot::u32) const override { return &glyph_; }
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_; }
plot::u32 revision() const override { return revision_; }
private:
std::vector<plot::u8> pixels_;
plot::render::Glyph glyph_{};
plot::u32 size_ = 8u;
plot::u32 revision_ = 0u;
};
/// The ink one drawn text run left in the framebuffer: its bounding box in Rect
/// pixels (top-down, like every other Rect here). `any()` is false when nothing
/// was drawn.
struct InkBox {
plot::u32 x0 = kNoColumn;
plot::u32 y0 = 0;
plot::u32 x1 = 0;
plot::u32 y1 = 0;
bool any() const noexcept { return x0 != kNoColumn; }
plot::u32 width() const noexcept { return any() ? x1 - x0 + 1u : 0u; }
plot::u32 height() const noexcept { return any() ? y1 - y0 + 1u : 0u; }
};
/// Scans the whole framebuffer for fully covered `text`-coloured pixels.
InkBox scan_ink_box(plottest::HeadlessContext& ctx, plot::Color text) {
InkBox box;
for (plot::u32 y = 0; y < ctx.height; ++y) {
for (plot::u32 x = 0; x < ctx.width; ++x) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
if (!pixel_is(px, text)) continue;
if (!box.any()) {
box.x0 = x;
box.y0 = y;
}
box.x1 = x;
box.y1 = y;
}
}
return box;
}
/// Draws one staged text run from `backend`'s atlas into the whole context in
/// `text`, and returns the ink's bounding box. The pipeline is driven directly --
/// atlas, batch, program, vertex array -- so a case can rotate a run, or swap the
/// backend underneath a live atlas, without the renderer in between. `atlas` is
/// the caller's, so a case can bake again, sync again and draw again.
InkBox draw_text_run(plottest::HeadlessContext& ctx, plot::render::TextAtlas& atlas,
const plot::render::TextBackend& backend, const char* utf8,
plot::Vec2 pen, plot::f32 px, plot::Vec2 axis) {
plot::Status st;
PLOT_CHECK(atlas.sync(backend, &st));
PLOT_CHECK(!atlas.empty());
plot::render::TextProgram program;
PLOT_CHECK(program.create(&st));
plot::render::TextVertexArray vao;
PLOT_CHECK(vao.create(&st));
plot::render::TextBatch batch;
batch.begin();
batch.add_text(utf8, pen, px, backend, axis);
PLOT_CHECK(batch.glyph_count() != 0u);
vao.upload(batch.data().data(), batch.glyph_count());
const plot::Color text = plot::Color::hex(0xFFFFFF);
plot::gl::glViewport(0, 0, static_cast<plot::gl::GLsizei>(ctx.width),
static_cast<plot::gl::GLsizei>(ctx.height));
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
plot::gl::glEnable(plot::gl::GL_BLEND);
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
program.bind();
program.set_viewport({static_cast<plot::f32>(ctx.width), static_cast<plot::f32>(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();
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});
program.set_texture_size(texels);
program.set_color(text);
atlas.bind();
vao.bind();
plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4,
static_cast<plot::gl::GLsizei>(vao.instance_count()));
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
return scan_ink_box(ctx, text);
}
/// What a text strip's pixels say: the leftmost column holding ink, and the widest
/// run of ink columns any single row holds -- one drawn label's extent, since a
@@ -1075,3 +1215,197 @@ PLOT_TEST(a_dense_frame_uploads_more_than_the_ring_floor_without_waiting) {
plot::render::detail::InstanceStore::kInitialSlots);
ctx.destroy();
}
// Rotated text: the staged basis, not the screen axes, is what a run is laid out
// along. The same six glyphs are drawn twice at the same pen and size, once
// unrotated and once with `axis = (0, -1)` -- the direction a bottom-to-top y
// label advances in -- and the ink has to stand up: the rotated box is taller
// than it is wide, and the run marches *up* from the pen instead of to its right.
// A shader that ignored `a_axis` would draw the second frame exactly like the
// first.
PLOT_TEST(rotated_text_advances_along_its_basis) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::BitmapFont font;
plot::render::TextAtlas atlas;
const plot::Vec2 pen{300.0f, 180.0f};
const plot::f32 px = 2.0f; // 16 px cells: six glyphs is over a hundred pixels
const plot::u32 cell = static_cast<plot::u32>(8.0f * px);
const InkBox flat = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {1.0f, 0.0f});
PLOT_CHECK(flat.any());
PLOT_CHECK(flat.width() > flat.height());
PLOT_CHECK(flat.x0 >= static_cast<plot::u32>(pen.x));
PLOT_CHECK(flat.y0 >= static_cast<plot::u32>(pen.y));
PLOT_CHECK(flat.height() <= cell);
const InkBox up = draw_text_run(ctx, atlas, font, "MWMWMW", pen, px, {0.0f, -1.0f});
PLOT_CHECK(up.any());
PLOT_CHECK(up.height() > up.width()); // the run stands up
PLOT_CHECK(up.height() > flat.height()); // and is as long as the flat one was wide
PLOT_CHECK(up.width() <= cell); // one cell wide, so nothing marched right
PLOT_CHECK(up.x0 >= static_cast<plot::u32>(pen.x));
PLOT_CHECK(up.x1 < static_cast<plot::u32>(pen.x) + cell);
// Ink above the pen: the first cell starts at the pen and every later one is a
// cell further up, so the run's rows run from well above the pen down to the
// pen's own cell -- and the pen's own cell is the lowest row of ink.
PLOT_CHECK(up.y0 + 4u * cell < static_cast<plot::u32>(pen.y));
PLOT_CHECK(up.y1 < static_cast<plot::u32>(pen.y) + cell);
ctx.destroy();
}
// The orientation rule, stated rather than hardcoded: a glyph's texel `(col, row)`
// lands at the pen plus `col` steps along the cell's +v axis and `row` steps along
// its +x axis. That is the mapping kTextVS implements, so with `axis = (0, -1)` the
// 'L' -- whose ink is a stem down its left column and a foot along its bottom row --
// has to come out as a top bar with the foot running up the right-hand side.
//
// The check is two-sided: the basis-derived corner holds ink and the corner a
// mirrored perpendicular would put it in does not, so a glyph flipped about its own
// diagonal fails even though it is the same size and shape.
PLOT_TEST(rotated_glyph_orientation_is_not_mirrored) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::render::BitmapFont font;
plot::render::TextAtlas atlas;
const plot::Vec2 pen{300.0f, 200.0f};
const plot::f32 px = 4.0f; // an 8x8 glyph at 32 px: one texel is a 4x4 pixel block
const plot::Vec2 ux{0.0f, -1.0f}; // a 90 degree counter-clockwise label
const plot::Vec2 uy{-ux.y, ux.x}; // (1, 0): the cell's own +v axis
// Where the centre of the glyph's texel (col, row) is drawn. Every cell is
// `kGlyphPx` texels across, and the cell's top-left is the pen because the
// bitmap font's bearings are zero.
auto texel_centre = [&](plot::f32 col, plot::f32 row, plot::Vec2 v_axis) {
const plot::f32 du = (col + 0.5f) * px;
const plot::f32 dv = (row + 0.5f) * px;
return plot::Vec2{pen.x + ux.x * dv + v_axis.x * du, pen.y + ux.y * dv + v_axis.y * du};
};
auto inked = [&](plot::Vec2 at) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(at.x),
ctx.height - 1u - static_cast<plot::u32>(at.y), pxv));
return pixel_is(pxv, plot::Color::hex(0xFFFFFF));
};
PLOT_CHECK(draw_text_run(ctx, atlas, font, "L", pen, px, ux).any());
// 'L' is inked at texel (0, 0) -- the stem's top -- and at (6, 6), the far end of
// its foot. Both corners are where the basis puts them ...
PLOT_CHECK(inked(texel_centre(0.0f, 0.0f, uy)));
PLOT_CHECK(inked(texel_centre(6.0f, 6.0f, uy)));
// ... and the mirrored basis -- the other perpendicular a shader could build --
// would put the stem's top on the opposite side of the pen, where this frame has
// no ink at all.
const plot::Vec2 mirrored{-uy.x, -uy.y};
PLOT_CHECK(!inked(texel_centre(0.0f, 0.0f, mirrored)));
PLOT_CHECK(!inked(texel_centre(6.0f, 6.0f, mirrored)));
ctx.destroy();
}
// The atlas cache is keyed on the backend's revision, and every change counts. A
// backend re-baked at another size with the ink moved to the other side of its cell
// must reach the screen: the second frame's ink is where only the new atlas has it,
// and a stale texture -- what a cache keyed on a flag set at init would leave in
// place -- cannot produce it, because its quad is the old size and does not even
// reach that far. The last step drives the revision *backwards* to pin the other
// half of the rule: the cache compares revisions, it does not require them to grow.
PLOT_TEST(atlas_is_reuploaded_when_the_backend_revision_changes) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
const plot::Color white = plot::Color::hex(0xFFFFFF);
const plot::Vec2 pen{120.0f, 120.0f};
const plot::f32 px = 4.0f;
BakeableBackend backend; // 8x8 atlas, ink on the left half
plot::render::TextAtlas atlas;
auto inked = [&](plot::f32 x, plot::f32 y) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(x),
ctx.height - 1u - static_cast<plot::u32>(y), pxv));
return pixel_is(pxv, white);
};
// An 8x8 atlas at scale 4: a 32x32 quad, inked on its left half.
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any());
PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6);
PLOT_CHECK(atlas.revision() == 1u);
PLOT_CHECK(inked(pen.x + 8.0f, pen.y + 16.0f));
PLOT_CHECK(!inked(pen.x + 24.0f, pen.y + 16.0f));
// Re-baked at 16x16 with the ink on the right, revision bumped: a 64x64 quad
// whose ink is beyond the old quad's right edge. A stale 8x8 atlas would leave
// the left half inked and could not reach x + 48 at all.
backend.bake(16u, true);
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any());
PLOT_CHECK_NEAR(atlas.texel_size().x, 16.0, 1e-6);
PLOT_CHECK(atlas.revision() == 2u);
PLOT_CHECK(!inked(pen.x + 8.0f, pen.y + 16.0f));
PLOT_CHECK(inked(pen.x + 48.0f, pen.y + 32.0f));
// Back to an 8x8 atlas, but with a revision *below* the one on the GPU: still a
// change, so the upload happens and the quad is 32x32 again with ink on the
// right -- which is where the 16x16 atlas had none.
backend.bake(8u, true);
backend.set_revision(0u);
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, px, {1.0f, 0.0f}).any());
PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6);
PLOT_CHECK(atlas.revision() == 0u);
PLOT_CHECK(!inked(pen.x + 8.0f, pen.y + 16.0f));
PLOT_CHECK(inked(pen.x + 24.0f, pen.y + 16.0f));
ctx.destroy();
}
// "No text" is a legal backend, not a failure: a null atlas, or one with no
// size, must sync cleanly, leave the atlas owner empty, and be *replaced* on the
// GPU as soon as the backend publishes one -- including by releasing the texture
// a draw would otherwise still be sampling.
PLOT_TEST(a_backend_without_an_atlas_uploads_nothing) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
BakeableBackend backend; // its constructor bakes an 8x8 atlas
plot::render::TextAtlas atlas;
PLOT_CHECK(atlas.sync(backend, &st));
PLOT_CHECK(!atlas.empty());
// The backend now publishes no atlas at all. The uploader has to let go of the
// texture it was holding -- merely recording the new revision would leave a
// draw sampling glyphs the backend no longer stands behind, and dividing by a
// zero texture size doing it.
backend.drop_atlas();
PLOT_CHECK(backend.atlas_pixels() == nullptr);
PLOT_CHECK(atlas.sync(backend, &st)); // legal: nothing to upload
PLOT_CHECK(atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, 0.0, 1e-6);
PLOT_CHECK_NEAR(atlas.texel_size().y, 0.0, 1e-6);
// And the same when the backend keeps claiming a size but publishes nothing.
BakeableBackend zero_sized;
plot::render::TextAtlas zero_atlas;
zero_sized.bake(0u, false);
PLOT_CHECK(zero_sized.atlas_width() == 0u);
PLOT_CHECK(zero_atlas.sync(zero_sized, &st));
PLOT_CHECK(zero_atlas.empty());
// A backend that starts publishing an atlas announces it with a revision bump,
// so the upload happens here rather than being skipped as "nothing changed".
backend.bake(8u, true);
PLOT_CHECK(atlas.sync(backend, &st));
PLOT_CHECK(!atlas.empty());
PLOT_CHECK_NEAR(atlas.texel_size().x, 8.0, 1e-6);
PLOT_CHECK(atlas.revision() == backend.revision());
PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR);
ctx.destroy();
}