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

+83 -37
View File
@@ -51,25 +51,28 @@ constexpr usize kNumberChars = 24;
constexpr usize kReadoutChars = 160;
/// Walks the longest prefix of `utf8` that fits `budget_px`, calling `emit(pen,
/// slot)` for every glyph it keeps -- `pen` in viewport pixels, `slot` in the font
/// atlas -- and returning the width that prefix occupies. Both the measuring and
/// the staging of a clipped run walk this, so the cut lands on the same pixel
/// either way; the codepoints, the advances and the scale are the ones
/// `TextBatch::add_text()` uses, so a clipped run still ends where the plot layer
/// measured its title to end.
/// glyph)` for every glyph it keeps -- `pen` in viewport pixels, `glyph` the
/// backend's own entry for that codepoint -- and returning the width that prefix
/// occupies. Both the measuring and the staging of a clipped run walk this, so the
/// cut lands on the same pixel either way; the codepoints, the advances and the
/// scale are the ones `TextBatch::add_text()` uses, so a clipped run still ends
/// where the plot layer measured its title to end.
///
/// A codepoint the backend has no glyph for is skipped, exactly as `add_text`
/// skips it, so what is measured here is what is drawn.
template <class Emit>
f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::BitmapFont& font,
f32 px_per_cell, Emit emit) {
f32 walk_text_clipped(Vec2 pen, const char* utf8, f32 budget_px, const render::TextBackend& font,
f32 scale, Emit emit) {
if (utf8 == nullptr || !(budget_px > 0.0f)) {
return 0.0f; // no room at all: a strip clipped away stages nothing
}
const f32 scale = px_per_cell / static_cast<f32>(render::BitmapFont::kGlyphPx);
f32 used = 0.0f;
for (const char* p = utf8; *p != '\0';) {
const u32 codepoint = render::BitmapFont::next_codepoint(p);
const f32 advance = font.advance_codepoint(codepoint) * scale;
const render::Glyph* glyph = font.glyph(render::next_codepoint(p));
if (glyph == nullptr) continue; // nothing to draw, nothing to reserve
const f32 advance = glyph->advance * scale;
if (used + advance > budget_px) break; // the next glyph does not fit: cut here
emit(pen, render::BitmapFont::glyph_index(codepoint));
emit(pen, *glyph);
pen.x += advance;
used += advance;
}
@@ -116,6 +119,35 @@ f32 Renderer::AxisFont::ascent() const {
return font_ != nullptr ? font_->ascent() * scale_ : 0.0f;
}
const render::Glyph* Renderer::AxisFont::glyph(u32 codepoint) const {
if (font_ == nullptr) {
return nullptr; // nothing to draw with, so nothing to describe
}
const render::Glyph* glyph = font_->glyph(codepoint);
if (glyph == nullptr) {
return nullptr;
}
// The rect is the atlas', and the atlas is not scaled; the bearing and the
// advance are font pixels, and this backend's caller works in screen pixels.
scaled_ = *glyph;
scaled_.xoff *= scale_;
scaled_.yoff *= scale_;
scaled_.advance *= scale_;
return &scaled_;
}
const u8* Renderer::AxisFont::atlas_pixels() const {
return font_ != nullptr ? font_->atlas_pixels() : nullptr;
}
u32 Renderer::AxisFont::atlas_width() const { return font_ != nullptr ? font_->atlas_width() : 0u; }
u32 Renderer::AxisFont::atlas_height() const {
return font_ != nullptr ? font_->atlas_height() : 0u;
}
u32 Renderer::AxisFont::revision() const { return font_ != nullptr ? font_->revision() : 0u; }
// ------------------------------------------------------------------ init
bool Renderer::init(Status* st) noexcept {
@@ -137,20 +169,13 @@ bool Renderer::init(Status* st) noexcept {
font_backend_.reset(font_, style_.axis_font_px);
// The atlas upload needs GL_UNPACK_ALIGNMENT 1 (single-channel rows have no
// padding), which is the only global state `init()` changes; the host's value is
// read first so `end_frame()` can hand it back rather than leave the host's own
// uploads to the alignment this renderer wanted.
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &host_unpack_alignment_);
if (!atlas_.create_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight, st)) {
// The atlas is the backend's: `sync()` uploads whatever it exposes, at whatever
// size it says, and restores the host's unpack alignment around the upload
// itself -- so this is the only place in the renderer that has to know a font
// atlas exists at all.
if (!atlas_.sync(font_backend_, st)) {
return false;
}
atlas_.upload_r8(render::BitmapFont::kAtlasWidth, render::BitmapFont::kAtlasHeight,
render::BitmapFont::atlas_image());
// Texel-for-texel: the text shader samples the nearest texel centre of the
// glyph cell, and linear filtering would smear one cell into its neighbour.
atlas_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
if (!line_program_.create(st) || !text_program_.create(st)) {
return false;
@@ -237,6 +262,16 @@ 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);
// 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
// `init()` and a steady-state frame only compares one counter, so a font that
// never changes costs a comparison a frame. A driver that refuses the re-upload
// is reported the way the stores report a refused fence: the frame draws on (it
// has the previous atlas), and `last_frame_had_gl_error()` says so.
if (!atlas_.sync(font_backend_)) {
gl_error_ = true;
}
#ifndef NDEBUG
// The renderer owns the text of the frames it draws: every label goes through
// the text pipeline in `font_`, so the plot is handed this font's metrics
@@ -296,7 +331,6 @@ void Renderer::end_frame() noexcept {
gl::glActiveTexture(gl::GL_TEXTURE0);
gl::glBindTexture(gl::GL_TEXTURE_2D, 0);
gl::glDisable(gl::GL_BLEND);
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_unpack_alignment_);
if (host_clear_saved_) {
gl::glClearColor(host_clear_.r, host_clear_.g, host_clear_.b, host_clear_.a);
}
@@ -318,7 +352,9 @@ void Renderer::upload_vertices(std::span<const f32> interleaved) noexcept {
}
void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 4u));
// Six floats per instance is the text pipeline's layout (see TextInstance):
// the cell's corner, its atlas rect and the basis that rotates it.
text_vao_.upload(interleaved.data(), static_cast<u32>(interleaved.size() / 6u));
}
void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept {
@@ -343,15 +379,22 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width) noexcept
void Renderer::flush_text() noexcept {
const u32 glyphs = glyphs_.glyph_count();
if (glyphs != 0 && ready_) {
// No atlas is no text: a backend that publishes none has nothing to sample, and
// drawing its instances would read whatever texture happened to be bound.
if (glyphs != 0 && ready_ && !atlas_.empty()) {
upload_text_vertices(glyphs_.data());
text_program_.bind();
text_program_.set_viewport({viewport_.w, viewport_.h});
const f32 cell = font_backend_.cell_px();
text_program_.set_px_range({cell, cell});
text_program_.set_texture_size(
{static_cast<f32>(render::BitmapFont::kAtlasWidth),
static_cast<f32>(render::BitmapFont::kAtlasHeight)});
// 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();
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(),
cell_uv.y * atlas_texels.y * font_backend_.scale()});
text_program_.set_texture_size(atlas_texels);
text_program_.set_color(style_.axis_text);
atlas_.bind();
text_vao_.bind();
@@ -404,17 +447,20 @@ void Renderer::stage_xy(const f32* xy, u32 n, bool log_x, bool log_y) {
Vec2 Renderer::stage_text(Vec2 pos_px, const char* utf8) {
// Text is staged in viewport pixels: the text program has no offset uniform.
return glyphs_.add_text(to_viewport(pos_px), utf8, font_, font_backend_.cell_px());
return glyphs_.add_text(utf8, to_viewport(pos_px), font_backend_.scale(), font_);
}
f32 Renderer::clipped_text_width(const char* utf8, f32 budget_px) const {
return walk_text_clipped(Vec2{}, utf8, budget_px, font_, font_backend_.cell_px(),
[](Vec2, u32) {});
return walk_text_clipped(Vec2{}, utf8, budget_px, font_, font_backend_.scale(),
[](Vec2, const render::Glyph&) {});
}
f32 Renderer::stage_text_clipped(Vec2 pos_px, const char* utf8, f32 budget_px) {
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, font_, font_backend_.cell_px(),
[this](Vec2 pen, u32 slot) { glyphs_.add_glyph(pen, slot); });
const f32 scale = font_backend_.scale();
return walk_text_clipped(to_viewport(pos_px), utf8, budget_px, font_, scale,
[this, scale](Vec2 pen, const render::Glyph& glyph) {
glyphs_.add_glyph(pen, glyph, scale);
});
}
// -------------------------------------------------------------------- grid