diff --git a/CMakeLists.txt b/CMakeLists.txt index e3ae60c..bf097a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ add_library(plot_core src/gl/loader.cpp src/gl/handle.cpp src/render/shaders.cpp + src/render/text.cpp + src/render/text_atlas.cpp src/render/bitmap_font.cpp src/render/primitives.cpp src/render/buffer.cpp diff --git a/include/plot/gl/handle.hpp b/include/plot/gl/handle.hpp index 6deef26..04ddf78 100644 --- a/include/plot/gl/handle.hpp +++ b/include/plot/gl/handle.hpp @@ -175,7 +175,8 @@ class Texture final : public Handle { /// data (row padding is disabled, so any width is valid). It sets /// `GL_UNPACK_ALIGNMENT` to 1 to do so and leaves it there -- a caller that /// shares the context with other uploaders saves and restores it, as - /// `Renderer` does around its atlas and its frames. + /// `render::TextAtlas` does around every glyph-atlas upload, whenever in a + /// frame that upload happens to run. void upload_r8(u32 width, u32 height, const void* pixels) const noexcept; /// Binds to `unit` (e.g. `GL_TEXTURE0`). diff --git a/include/plot/plot.hpp b/include/plot/plot.hpp index f4a268e..252f468 100644 --- a/include/plot/plot.hpp +++ b/include/plot/plot.hpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include diff --git a/include/plot/render/bitmap_font.hpp b/include/plot/render/bitmap_font.hpp index c43e157..7ec0386 100644 --- a/include/plot/render/bitmap_font.hpp +++ b/include/plot/render/bitmap_font.hpp @@ -12,22 +12,22 @@ namespace plot::render { /// font file, FreeType or stb_truetype: `plot_core` stays dependency-free. /// /// Metrics are proportional, not fixed width: an advance is the glyph's ink -/// width plus one pixel of spacing, floored at two pixels, computed once when -/// the font is constructed from the same bitmaps the atlas uploads. Whitespace -/// is a blank glyph with a nonzero advance, and every codepoint without a glyph -/// draws a visible box, so a missing glyph is obvious rather than silently -/// blank. +/// width plus one pixel of spacing, floored at two pixels, derived from the +/// same bitmaps the atlas uploads, so a second width table cannot drift from the +/// artwork. Whitespace is a blank glyph with a nonzero advance, and every +/// codepoint without a glyph draws a visible box, so a missing glyph is obvious +/// rather than silently blank. /// -/// Metrics are in unscaled font pixels, one per glyph texel. The text pipeline -/// draws a cell `u_px_range` pixels across, so a run at another size measures -/// `measure(utf8) * u_px_range.x / kGlyphPx`; `TextBatch::add_text` takes that -/// cell size and applies the same scale to the pen it advances. +/// Metrics are in unscaled font pixels, one per glyph texel, and the atlas is a +/// grid of `kGlyphPx` cells: every glyph's rect is its own cell, so a run drawn +/// at `s` pixels per font pixel measures `measure(utf8) * s` and sits in a cell +/// of `s * kGlyphPx` pixels. `TextBatch::add_text` takes that factor and applies +/// it to the pen it advances. class BitmapFont final : public TextBackend { public: - // Atlas geometry. It is part of the render pipeline's contract: the text - // vertex shader expands a glyph cell by `u_px_range` pixels and reads the cell - // at the instance's texel origin, so the atlas is a grid of 8x8 cells whose - // width must stay a whole number of cells. + // Atlas geometry: a grid of `kGlyphPx` cells whose width is a whole number of + // them. A glyph's rect is its own cell, normalised by these sizes, so the grid + // is all the text pipeline has to know about the atlas. static constexpr u32 kGlyphPx = 8; ///< glyph cell edge, texels static constexpr u32 kFirstChar = 32; ///< U+0020 static constexpr u32 kAsciiGlyphs = 95; ///< U+0020..U+007E @@ -42,17 +42,7 @@ class BitmapFont final : public TextBackend { static constexpr u32 kAtlasHeight = kAtlasRows * kGlyphPx; ///< 56 texels static_assert(kAtlasCols * kAtlasRows >= kGlyphCount, "atlas too small for the glyph set"); - /// Reported by `next_codepoint` for a byte sequence that is not valid UTF-8. - /// It is not a Unicode scalar value, so it can only map to the fallback box. - static constexpr u32 kInvalidCodepoint = 0xFFFFFFFFu; - - /// 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. The - /// text batch and `measure` share it, which is what keeps a staged run exactly - /// `measure`-wide. - static u32 next_codepoint(const char*& p) noexcept; - - BitmapFont() noexcept; + BitmapFont() noexcept = default; /// Width of `utf8`, the sum of the glyph advances: zero for the empty string /// and for null. The ASCII path allocates nothing. @@ -75,6 +65,21 @@ class BitmapFont final : public TextBackend { /// codepoints `format_si` emits map to real glyphs, and anything else maps to /// the visible missing-glyph box. static u32 glyph_index(u32 codepoint) noexcept; + /// The glyph for `codepoint`: its atlas cell as a normalised rect (see + /// `Glyph`), a zero bearing and its ink-derived advance. Never null -- a + /// codepoint with no artwork maps to the visible missing-glyph box, which is + /// what a label outside the coverage set should show. + const Glyph* glyph(u32 codepoint) const override; + + /// 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; + u32 atlas_width() const override; + u32 atlas_height() const override; + + /// Always zero: the atlas is a compile-time constant, so it never changes and + /// the renderer uploads it once. + u32 revision() const override; /// Rows in a glyph bitmap, always `kGlyphPx`. u32 glyph_rows(char c) const noexcept; @@ -90,12 +95,16 @@ class BitmapFont final : public TextBackend { /// (straight alpha, no dilation or gamma applied). static const u8* atlas_image() noexcept; - /// Texel origin of a glyph slot in the atlas; this is the per-instance `a_uv` - /// the text pipeline reads. + /// Texel origin of a glyph slot in the atlas, in texels; `glyph()` gives the + /// same rectangle normalised, which is what the text pipeline stages. static Vec2 atlas_origin(u32 slot) noexcept; private: - f32 advances_[kGlyphCount]; + /// Every slot's rect, bearing and advance, derived from the bitmaps above at + /// compile time. This replaced a per-instance `advances_` array holding the + /// same numbers, so the advances the pen uses and the ones `glyph()` publishes + /// cannot drift apart. + static const Glyph* glyph_table() noexcept; }; } // namespace plot::render diff --git a/include/plot/render/buffer.hpp b/include/plot/render/buffer.hpp index 22d99e5..6b65de2 100644 --- a/include/plot/render/buffer.hpp +++ b/include/plot/render/buffer.hpp @@ -16,17 +16,37 @@ struct LineInstance { f32 y1; }; -/// Per-instance payload of the text pipeline: the glyph quad's top-left corner -/// in Rect pixels plus the glyph's atlas texel origin. +/// Per-instance payload of the text pipeline: one glyph cell. +/// +/// - `(x, y)` is the cell's top-left corner in Rect pixels, top-left origin like +/// `plot::Rect`. `TextBatch` already applied the glyph's bearing along the +/// basis, so this is the corner the shader grows the cell from. +/// - `(u, v)` is the glyph rect's **normalised top-left** in the atlas, with `v` +/// increasing *downward* -- the convention `Glyph` states, so `(u, v)` is the +/// rect's top-left texel and the cell spans `(u, v)..(u + w, v + h)`, where +/// `(w, h)` is the glyph cell's normalised size (a per-draw uniform: every +/// glyph a batch stages comes from one atlas and occupies a cell of one size). +/// - `(ax, ay)` is the glyph's local +x axis -- the direction the run advances +/// in -- as a **unit vector in pixels**. It is what rotates the cell: the +/// shader builds it from `(ax, ay)` and its perpendicular, and maps the glyph's +/// rect onto the two the same way round, so a cell is never mirrored. `(1, 0)` +/// is unrotated text, and `(0, -1)` is a run advancing up the screen, i.e. a +/// 90 degree counter-clockwise label such as a y-axis title. +/// +/// Six floats, so the whole text pipeline still fits one instanced draw call per +/// frame with rotation free: a matrix per glyph would be three times the bytes +/// for the same pixels. struct TextInstance { f32 x; f32 y; f32 u; f32 v; + f32 ax; + f32 ay; }; static_assert(sizeof(LineInstance) == 4 * sizeof(f32), "line instance must stay 4 floats"); -static_assert(sizeof(TextInstance) == 4 * sizeof(f32), "text instance must stay 4 floats"); +static_assert(sizeof(TextInstance) == 6 * sizeof(f32), "text instance must stay 6 floats"); /// How a store's staged instances reach the GPU. Semantics are identical either /// way -- only the transfer differs, and `Renderer::init()` picks one from diff --git a/include/plot/render/primitives.hpp b/include/plot/render/primitives.hpp index 410a636..5eefdca 100644 --- a/include/plot/render/primitives.hpp +++ b/include/plot/render/primitives.hpp @@ -1,7 +1,7 @@ #pragma once #include #include -#include +#include #include @@ -55,41 +55,64 @@ class LineBatch { /// CPU-side staging for one instanced text draw call. /// /// Like `LineBatch` it keeps its capacity across frames. Each staged glyph is -/// one instance: the glyph quad's top-left corner in pixels, top-left origin -/// like `plot::Rect`, plus the glyph's atlas texel origin, interleaved as -/// `x,y,u,v` — the layout the text shader declares (see buffer.hpp). +/// one instance: the glyph cell's top-left corner in pixels, the glyph rect's +/// normalised top-left in the atlas and the cell's rotation basis, interleaved as +/// `x,y,u,v,ax,ay` — the layout the text shader declares (see `TextInstance` in +/// 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. class TextBatch { public: - /// Starts a frame: empties the batch, keeps its capacity. + /// Starts a frame: empties the batch and the staged cell, keeps the capacity. void begin() noexcept; /// Same as `begin()`. void clear() noexcept; - /// Stages one glyph cell at `pos_px` (its top-left corner), reading the cell - /// from `slot` in the font atlas (see `BitmapFont::atlas_origin`). `slot` - /// indexes the atlas, not a codepoint. - void add_glyph(Vec2 pos_px, u32 slot); - - /// Stages every codepoint of `utf8` and returns the pen past the last glyph, - /// so the run is exactly `font.measure(utf8) * px_per_cell / - /// BitmapFont::kGlyphPx` pixels wide — the width the draw covers when it is - /// told the same cell size as `u_px_range`. + /// 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. /// - /// `px_per_cell` is the on-screen size of one glyph cell: pass - /// `BitmapFont::kGlyphPx` for 1:1 text, and anything else to scale the run. - /// The glyph quad itself is sized by the shader's `u_px_range`, which must be - /// the same value. A null or empty string stages nothing and returns `pen_px`. - Vec2 add_text(Vec2 pen_px, const char* utf8, const BitmapFont& font, - f32 px_per_cell = static_cast(BitmapFont::kGlyphPx)); + /// `px` is the scale: on-screen pixels per font pixel, the factor `measure()` + /// is multiplied by — `1.0` draws the glyphs at the size the backend + /// rasterised them. `axis` is the direction the run advances in, in pixel + /// space; it rotates the cell (see `TextInstance`), and is normalised here, so + /// any non-zero direction works and a zero or non-finite one means "unrotated". + void add_glyph(Vec2 pen_px, const Glyph& glyph, f32 px = 1.0f, + Vec2 axis = {1.0f, 0.0f}); + + /// Stages every codepoint of `utf8` from `pen_px` and returns the pen past the + /// last glyph, so the run is exactly `font.measure(utf8) * px` long measured + /// along `axis` — the length the plot layer reserved for the label, at the size + /// the text pipeline then draws it. + /// + /// The codepoints and the advances come from the backend itself, through + /// `next_codepoint()` and `glyph()`, so a run stages exactly what `measure()` + /// summed. A codepoint the backend has no glyph for stages nothing and moves + /// the pen nowhere — a run is never wider than the glyphs it can draw. A null + /// or empty string stages nothing and returns `pen_px`. + Vec2 add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font, + Vec2 axis = {1.0f, 0.0f}); /// Glyphs staged so far, one instance each. - u32 glyph_count() const noexcept { return static_cast(v_.size() / 4u); } + u32 glyph_count() const noexcept { return static_cast(v_.size() / 6u); } - /// Interleaved `x,y,u,v`, four floats per staged glyph. + /// 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. + Vec2 glyph_cell_uv() const noexcept { return cell_uv_; } + private: + /// Stages one cell whose basis the caller has already normalised, so a run and + /// the pen it advances share one rounding of the axis. + void add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux); + std::vector v_; + Vec2 cell_uv_{}; }; } // namespace plot::render diff --git a/include/plot/render/program.hpp b/include/plot/render/program.hpp index 88c2951..c29b847 100644 --- a/include/plot/render/program.hpp +++ b/include/plot/render/program.hpp @@ -48,8 +48,14 @@ class TextProgram { /// `u_viewport`: render target size in pixels. void set_viewport(Vec2 px) const noexcept; - /// `u_px_range`: pixels per glyph cell, i.e. the on-screen size of one glyph. + /// `u_px_range`: the glyph cell's size on screen, in pixels (width and + /// 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. + void set_glyph_uv(Vec2 uv) const noexcept; /// `u_tex_size`: atlas size in texels. void set_texture_size(Vec2 texels) const noexcept; /// `u_color`: tint colour, alpha included. diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index cf39d40..ea5c608 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -14,6 +13,7 @@ #include #include #include +#include #include namespace plot { @@ -70,7 +70,7 @@ class Renderer { Renderer(const Renderer&) = delete; Renderer& operator=(const Renderer&) = delete; - /// Compiles both programs, builds the glyph atlas and allocates the vertex + /// Compiles both programs, uploads the font's atlas and allocates the vertex /// buffers. The calling thread must have the context current and /// `plot::gl::load()` must have succeeded, otherwise the returned `Status` is /// `Unsupported`; anything the driver refuses is `GLFailure` plus its log. @@ -114,13 +114,17 @@ class Renderer { /// vertex array 0, program 0, `GL_ARRAY_BUFFER` 0, texture unit 0 active and /// nothing bound to `GL_TEXTURE_2D` on it, blending off, scissoring off, and the /// viewport left at the frame's rect (the caller sets its own for the next pass). - /// Two pieces of global state the frame *changed* are handed back rather than - /// left where the frame wanted them: `GL_UNPACK_ALIGNMENT`, which `init()` set - /// to 1 for the glyph atlas' single-channel rows, and the clear colour, which is + /// The one piece of global state the frame *changed* is handed back rather than + /// left where the frame wanted it: the clear colour, which is /// `style().background` only for the duration of the frame. GL keeps no history - /// for the clear colour, so the host's value is queried on the first - /// `begin_frame()`; that first value is what comes back here, so a colour the - /// host changes after the first frame is restored over by it. + /// for it, so the host's value is queried on the first `begin_frame()`; that + /// first value is what comes back here, so a colour the host changes after the + /// first frame is restored over by it. + /// + /// `GL_UNPACK_ALIGNMENT` is deliberately not in that list any more: the atlas + /// upload needs it at 1 for single-channel rows, and `render::TextAtlas` saves + /// and restores it around every upload it performs, so no frame -- initial or + /// mid-run -- leaves it moved for the host's own uploads to inherit. /// The framebuffer binding is never touched -- it is the caller's, and so is the /// pixel-pack alignment if `dump_frame_ppm()` ran. void end_frame() noexcept; @@ -176,19 +180,40 @@ class Renderer { /// by whole multiples of its cell only -- anything else lands a 1-bit glyph's /// strokes unevenly -- so `reset()` picks the multiple nearest the requested /// size. + /// + /// It is also the backend the renderer uploads the atlas from, so it forwards + /// the whole interface: the metrics and the glyph bearings and advances are + /// scaled, while the artwork itself -- the atlas bytes, its size and its + /// revision -- is the font's own and passes through untouched. class AxisFont final : public render::TextBackend { public: void reset(const render::BitmapFont& font, f32 requested_px) noexcept; f32 measure(const char* utf8) const override; f32 height() const override; f32 ascent() const override; + /// The font's glyph at this wrapper's scale: the atlas rect is unchanged (it + /// is scale-free) and the bearing and advance are multiplied by it, so one + /// `Glyph` describes the glyph this backend draws. The result lives in the + /// 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; + const u8* atlas_pixels() const override; + u32 atlas_width() const override; + u32 atlas_height() const override; + u32 revision() const override; /// On-screen size of one glyph cell, in pixels. f32 cell_px() const noexcept { return cell_px_; } + /// On-screen pixels per font pixel: the factor `measure()` and `glyph()` are + /// scaled by, and what `TextBatch::add_text` is handed as its size. + f32 scale() const noexcept { return scale_; } private: const render::BitmapFont* font_ = nullptr; f32 cell_px_ = 1.0f; f32 scale_ = 1.0f; + /// `glyph()`'s scaled result, returned by pointer because the interface is + /// `const Glyph*` and the scale is the one thing this wrapper adds. + mutable render::Glyph scaled_{}; }; /// One axis pair's data -> pixel mapping, already shifted so the viewport's @@ -254,7 +279,7 @@ class Renderer { AxisFont font_backend_; render::Style style_; - gl::Texture atlas_; + render::TextAtlas atlas_; render::LineProgram line_program_; render::TextProgram text_program_; render::LineVertexArray line_vao_; @@ -265,9 +290,6 @@ class Renderer { std::vector gather_; ///< scratch for one gathered window, re-used mutable std::vector readback_; ///< dump_frame_ppm's row buffer - /// The host's `GL_UNPACK_ALIGNMENT`, saved by `init()` before the atlas upload - /// set it to 1 and handed back by `end_frame()`. GL's default is 4. - gl::GLint host_unpack_alignment_ = 4; /// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL /// has no other way to recover it once the frame has set it) and handed back by /// `end_frame()`. diff --git a/include/plot/render/shaders.hpp b/include/plot/render/shaders.hpp index 9d8055c..d130cc0 100644 --- a/include/plot/render/shaders.hpp +++ b/include/plot/render/shaders.hpp @@ -30,16 +30,24 @@ extern const char* const kLineFS; /// GLSL source of the instanced text pipeline. /// -/// One glyph cell per instance: `a_pos` at location 0 is the quad's top-left -/// corner in Rect pixels and `a_uv` at location 1 is the glyph's atlas texel -/// origin, both with a vertex attribute divisor of 1. The quad grows by -/// `u_px_range` pixels per cell and the fragment shader samples the +/// One glyph cell per instance, every attribute with a vertex attribute divisor +/// of 1: `a_pos` at location 0 is the cell's top-left corner in Rect pixels, +/// `a_uv` at location 1 is the glyph rect's normalised top-left in the atlas and +/// `a_axis` at location 2 is the cell's local +x axis, a unit vector in pixels. +/// +/// The vertex shader builds the cell from that basis and its perpendicular -- +/// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that +/// advances up the screen, and it maps `a_uv` over the cell with the same +/// handedness, so a glyph's top row stays its top row however the cell is +/// turned. The fragment shader converts back to texels and samples the /// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST` /// never lands on a cell boundary. `u_color` tints the straight alpha. /// -/// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, pixels per glyph -/// cell), `u_tex_size` (vec2, atlas texels), `u_atlas` (sampler2D, unit 0), -/// `u_color` (vec4). +/// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, the glyph cell's +/// size on screen in pixels), `u_glyph_uv` (vec2, the glyph cell's size in +/// normalised atlas coordinates -- one size for the whole draw, since every +/// glyph of a frame comes from one atlas), `u_tex_size` (vec2, atlas size in +/// texels), `u_atlas` (sampler2D, unit 0), `u_color` (vec4). extern const char* const kTextVS; extern const char* const kTextFS; diff --git a/include/plot/render/text.hpp b/include/plot/render/text.hpp index e529d31..d1f0edc 100644 --- a/include/plot/render/text.hpp +++ b/include/plot/render/text.hpp @@ -3,13 +3,70 @@ namespace plot::render { -/// Text metrics the plot layer lays axes and labels out with. It is a pure -/// interface so the layout code never depends on the bitmap font, and so a -/// caller can measure with a host toolkit's font instead. +/// One glyph's atlas rectangle and metrics, in font pixels. +/// +/// **The rectangle is normalised, v grows downward.** `(u0, v0)` is the glyph's +/// top-left atlas texel and `(u1, v1)` its bottom-right, each divided by the +/// atlas' size -- so the atlas is stored with its top row first, exactly the row +/// order `atlas_pixels()` hands over, and a larger `v` is a row further down. +/// Text is laid out with the same handedness: a glyph's own +y runs from `v0` +/// toward `v1`, which on screen is downward when the glyph is unrotated. A +/// backend that rasterised its atlas bottom row first would draw every glyph +/// mirrored unless it divided `v` the other way, so the convention is stated +/// once, here, and every consumer -- the text shader's UVs, the vertex shader's +/// second cell axis, `TextBatch` -- uses it and nothing else. +/// +/// `(xoff, yoff)` is the bearing from the pen to the glyph's top-left corner +/// (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. +struct Glyph { + f32 u0 = 0.0f; + f32 v0 = 0.0f; + f32 u1 = 0.0f; + f32 v1 = 0.0f; + f32 xoff = 0.0f; + f32 yoff = 0.0f; + f32 advance = 0.0f; +}; + +/// A byte sequence that is not valid UTF-8, as reported by `next_codepoint`. It +/// is deliberately not a Unicode scalar value, so it can only map to whatever a +/// backend draws for "no glyph" rather than colliding with a real codepoint. +inline constexpr u32 kInvalidCodepoint = 0xFFFFFFFFu; + +/// 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. +/// +/// It belongs to this interface rather than to a font because it is what keeps a +/// backend's `measure()` and the glyphs the pipeline stages for the same string +/// in step: both walk the string with this decoder, so neither can disagree with +/// the other about where a codepoint starts. +u32 next_codepoint(const char*& p) noexcept; + +/// The font a `Plot` measures with and the renderer draws with: metrics, plus the +/// coverage atlas the text pipeline samples. /// /// Text is passed as UTF-8, not as a byte-per-character string: `plot::format_si` /// emits U+00B5, U+00B0 and U+03A9 for micro, degree and ohm, and an /// implementation that ignores that prints mojibake. +/// +/// The atlas is the backend's own: it owns the pixels, the glyph table that +/// describes them and the `revision()` that says when they changed. The renderer +/// uploads whatever the backend exposes and re-uploads when the revision moves, +/// so a backend can be swapped -- or re-baked at another size -- without the +/// 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. class TextBackend { public: TextBackend() noexcept = default; @@ -17,8 +74,8 @@ class TextBackend { TextBackend& operator=(const TextBackend&) = default; virtual ~TextBackend() = default; - /// Width of `utf8` in the same units as `height()` (pixels for the bitmap - /// font). Zero for the empty string. + /// Width of `utf8` in the same units as `height()` (font pixels for a font + /// atlas). Zero for the empty string. virtual f32 measure(const char* utf8) const = 0; /// Line height: the cell a single line of text occupies. @@ -27,6 +84,29 @@ class TextBackend { /// Distance from the top of the cell to the baseline, so a caller can align /// text bottoms instead of cell bottoms. virtual f32 ascent() const = 0; + + /// The glyph for `codepoint`, or null when this backend has none -- the caller + /// then draws nothing for it (a backend that wants a visible missing-glyph box + /// 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. + virtual const Glyph* glyph(u32 codepoint) 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". + virtual const u8* atlas_pixels() const = 0; + + /// Atlas size in texels. Zero width or height means "no text", like a null + /// `atlas_pixels()`. + virtual u32 atlas_width() const = 0; + virtual u32 atlas_height() const = 0; + + /// Bumped whenever `atlas_pixels()` content changes, so the renderer knows to + /// re-upload instead of trusting its cache. Must not change otherwise -- a + /// revision that moves on its own would re-upload the same bytes every frame -- + /// and any change counts, so a backend may re-bake at a new size and keep + /// counting up. + virtual u32 revision() const = 0; }; } // namespace plot::render diff --git a/include/plot/render/text_atlas.hpp b/include/plot/render/text_atlas.hpp new file mode 100644 index 0000000..f15ed4b --- /dev/null +++ b/include/plot/render/text_atlas.hpp @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace plot::render { + +/// The GPU side of a `TextBackend`'s atlas. +/// +/// The atlas belongs to the backend, so the renderer does not decide what it +/// contains or how big it is: `sync()` uploads whatever `atlas_pixels()` points +/// at, as a single-channel `GL_R8` image of `atlas_width()` by `atlas_height()`, +/// and re-uploads exactly when the backend's `revision()` changes. Keying that +/// cache on the revision rather than on a flag set once is what makes a font +/// swap, or a re-bake at another size, visible -- a cached atlas left in place +/// draws the previous font's glyphs and nothing in the pixels says why. +/// +/// It is also where the atlas upload's `GL_UNPACK_ALIGNMENT` excursion is +/// contained: `Texture::upload_r8` needs 1 for single-channel rows and leaves it +/// there, so every upload here saves the caller's value and puts it back -- +/// whether it runs in `Renderer::init()` or in the middle of a frame, which is +/// why the renderer's frame no longer has to carry that value around one. +/// +/// Nearest filtering, always: the shader reads the texel centre of a glyph cell, +/// and linear filtering would smear one cell into its neighbour. +class TextAtlas { + public: + TextAtlas() noexcept = default; + TextAtlas(const TextAtlas&) = delete; + TextAtlas& operator=(const TextAtlas&) = delete; + TextAtlas(TextAtlas&&) noexcept = default; + TextAtlas& operator=(TextAtlas&&) noexcept = default; + ~TextAtlas() = default; + + /// Brings the texture up to date with `backend`, and does nothing at all when + /// it already is: the same revision as the last successful upload means the + /// bytes on the GPU are the ones that revision describes. Any change counts, + /// including one that moves the revision backwards, so a backend that + /// re-bakes is never mistaken for one that did not. + /// + /// A null atlas, or one with a zero width or height, is legal and means "no + /// text": nothing is uploaded, `empty()` stays true and the resulting draw + /// stages nothing. The revision is recorded even then, so a backend whose + /// atlas never appears is not re-checked every frame -- the contract is that a + /// backend which *starts* providing one bumps its revision. + /// + /// False, with `st` filled in, when the driver refuses the texture; the last + /// successful upload is left in place and the next call retries. + bool sync(const TextBackend& backend, Status* st = nullptr) noexcept; + + /// Binds to `unit` (texture unit 0 by default, which is where the text + /// program's sampler points). + void bind(gl::GLenum unit = gl::GL_TEXTURE0) const noexcept; + + /// Atlas size in texels; `(0, 0)` while nothing has been uploaded. + Vec2 texel_size() const noexcept { + return Vec2{static_cast(width_), static_cast(height_)}; + } + + /// True while no atlas is on the GPU: there is no text to draw, and `bind()` + /// would bind nothing. + bool empty() const noexcept { return texture_.id() == 0; } + + /// The revision of the atlas currently on the GPU. Meaningless before the + /// first `sync()`, which is the one call that always uploads. + u32 revision() const noexcept { return revision_; } + + private: + gl::Texture texture_; + u32 revision_ = 0; + u32 width_ = 0; + u32 height_ = 0; + /// False until a revision has been recorded, so a backend whose `revision()` + /// is 0 is still uploaded once instead of being mistaken for "already there". + bool synced_ = false; +}; + +} // namespace plot::render diff --git a/include/plot/render/vertex_array.hpp b/include/plot/render/vertex_array.hpp index 336477a..dd8f651 100644 --- a/include/plot/render/vertex_array.hpp +++ b/include/plot/render/vertex_array.hpp @@ -55,13 +55,16 @@ class LineVertexArray { }; /// Vertex array plus instance buffer of the text pipeline: `a_pos` at location 0 -/// and the glyph's atlas texel origin `a_uv` at location 1, two floats each, -/// advancing once per instance. +/// (the cell's top-left corner in pixels), the glyph's atlas rect `a_uv` at +/// location 1 and its rotation basis `a_axis` at location 2, two floats each, +/// every one advancing once per instance. The layout is the one `TextInstance` +/// declares and shaders.hpp consumes; a caller never writes a location, stride or +/// offset, it stages a `TextBatch` and uploads its `data()`. class TextVertexArray { public: bool create(Status* st = nullptr, UploadMode mode = UploadMode::Orphan) noexcept; - /// Uploads `glyphs` instances from interleaved `x,y,u,v` floats. + /// Uploads `glyphs` instances from interleaved `x,y,u,v,ax,ay` floats. void upload(const f32* interleaved, u32 glyphs) noexcept; void bind() const noexcept { vao_.bind(); } diff --git a/src/render/bitmap_font.cpp b/src/render/bitmap_font.cpp index b12180a..1ea87ed 100644 --- a/src/render/bitmap_font.cpp +++ b/src/render/bitmap_font.cpp @@ -166,56 +166,32 @@ 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. +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 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)}; + } + return glyphs; +} + +constexpr std::array kGlyphs = build_glyphs(); + } // namespace -// The decoder is public because the text batch stages glyphs from the same -// codepoints `measure` sums: one implementation keeps "what a string is worth" -// and "what a string draws" the same thing. -u32 BitmapFont::next_codepoint(const char*& p) noexcept { - const auto b0 = static_cast(static_cast(p[0])); - if (b0 < 0x80u) { - ++p; - return b0; - } - u32 codepoint = 0; - u32 extra = 0; - if ((b0 & 0xE0u) == 0xC0u) { - codepoint = b0 & 0x1Fu; - extra = 1u; - } else if ((b0 & 0xF0u) == 0xE0u) { - codepoint = b0 & 0x0Fu; - extra = 2u; - } else if ((b0 & 0xF8u) == 0xF0u) { - codepoint = b0 & 0x07u; - extra = 3u; - } else { - ++p; - return kInvalidCodepoint; - } - for (u32 i = 1; i <= extra; ++i) { - const auto b = static_cast(static_cast(p[i])); - if ((b & 0xC0u) != 0x80u) { // truncated: stop before the terminator - ++p; - return kInvalidCodepoint; - } - codepoint = (codepoint << 6) | (b & 0x3Fu); - } - p += extra + 1u; - constexpr u32 kMinForLength[4] = {0u, 0x80u, 0x800u, 0x10000u}; - if (codepoint < kMinForLength[extra] || codepoint > 0x10FFFFu || - (codepoint >= 0xD800u && codepoint <= 0xDFFFu)) { - return kInvalidCodepoint; // overlong, out of range or a surrogate - } - return codepoint; -} - -BitmapFont::BitmapFont() noexcept { - for (u32 slot = 0; slot < kGlyphCount; ++slot) { - const u32 ink = ink_width(kGlyphBits[slot]); - const u32 with_spacing = ink + 1u; - advances_[slot] = static_cast(with_spacing < 2u ? 2u : with_spacing); - } -} +const Glyph* BitmapFont::glyph_table() noexcept { return kGlyphs.data(); } f32 BitmapFont::measure(const char* utf8) const { if (utf8 == nullptr) { @@ -223,21 +199,35 @@ f32 BitmapFont::measure(const char* utf8) const { } f32 width = 0.0f; for (const char* p = utf8; *p != '\0';) { - width += advances_[glyph_index(next_codepoint(p))]; + width += advance_codepoint(next_codepoint(p)); } return width; } +const Glyph* BitmapFont::glyph(u32 codepoint) const { + // Every codepoint has a glyph: `glyph_index` maps whatever the font cannot + // draw -- including `kInvalidCodepoint` -- to the visible box. + return &glyph_table()[glyph_index(codepoint)]; +} + +const u8* BitmapFont::atlas_pixels() const { return atlas_image(); } + +u32 BitmapFont::atlas_width() const { return kAtlasWidth; } + +u32 BitmapFont::atlas_height() const { return kAtlasHeight; } + +u32 BitmapFont::revision() const { return 0u; } + f32 BitmapFont::height() const { return static_cast(kGlyphPx); } f32 BitmapFont::ascent() const { return static_cast(kGlyphPx - 1u); } f32 BitmapFont::advance(char c) const noexcept { - return advances_[glyph_index(static_cast(static_cast(c)))]; + return glyph_table()[glyph_index(static_cast(static_cast(c)))].advance; } f32 BitmapFont::advance_codepoint(u32 codepoint) const noexcept { - return advances_[glyph_index(codepoint)]; + return glyph_table()[glyph_index(codepoint)].advance; } u32 BitmapFont::glyph_index(u32 codepoint) noexcept { diff --git a/src/render/primitives.cpp b/src/render/primitives.cpp index f2f1501..07f537e 100644 --- a/src/render/primitives.cpp +++ b/src/render/primitives.cpp @@ -1,6 +1,23 @@ #include +#include + namespace plot::render { +namespace { + +/// `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 +/// geometry, and a basis the shader trusts to be unit must be one. +Vec2 unit_axis(Vec2 axis) noexcept { + const f32 len = axis.length(); + if (std::isfinite(len) && len > 0.0f) { + return axis / len; + } + return Vec2{1.0f, 0.0f}; +} + +} // namespace // ----------------------------------------------------------------- LineBatch @@ -42,31 +59,56 @@ void LineBatch::add_polyline_xy(const f32* xy, u32 n) { // ----------------------------------------------------------------- TextBatch -void TextBatch::begin() noexcept { v_.clear(); } - -void TextBatch::clear() noexcept { v_.clear(); } - -void TextBatch::add_glyph(Vec2 pos_px, u32 slot) { - const Vec2 origin = BitmapFont::atlas_origin(slot); - v_.push_back(pos_px.x); - v_.push_back(pos_px.y); - v_.push_back(origin.x); - v_.push_back(origin.y); +void TextBatch::begin() noexcept { + v_.clear(); + cell_uv_ = Vec2{}; } -Vec2 TextBatch::add_text(Vec2 pen_px, const char* utf8, const BitmapFont& font, f32 px_per_cell) { +void TextBatch::clear() noexcept { + v_.clear(); + cell_uv_ = Vec2{}; +} + +void TextBatch::add_cell(Vec2 pen_px, const Glyph& glyph, f32 px, Vec2 unit_ux) { + // 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 + // keeps its place inside a rotated run instead of drifting along screen +y. + const Vec2 uy{-unit_ux.y, unit_ux.x}; + const Vec2 corner = pen_px + unit_ux * (glyph.xoff * px) + uy * (glyph.yoff * px); + v_.push_back(corner.x); + v_.push_back(corner.y); + v_.push_back(glyph.u0); + 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) { + add_cell(pen_px, glyph, px, unit_axis(axis)); +} + +Vec2 TextBatch::add_text(const char* utf8, Vec2 pen_px, f32 px, const TextBackend& font, + Vec2 axis) { if (utf8 == nullptr) { return pen_px; } - const f32 scale = px_per_cell / static_cast(BitmapFont::kGlyphPx); + const Vec2 ux = unit_axis(axis); for (const char* p = utf8; *p != '\0';) { - // Same codepoints `measure` sums and the same advances, scaled by the - // on-screen cell size: a run staged here is exactly what the plot layer - // measured, times the scale it draws at, so a label never overruns the - // space that was reserved for it. - const u32 codepoint = BitmapFont::next_codepoint(p); - add_glyph(pen_px, BitmapFont::glyph_index(codepoint)); - pen_px.x += font.advance_codepoint(codepoint) * scale; + // The backend's own codepoints and the backend's own advances -- the same + // numbers `measure()` sums -- scaled by `px` and measured along the basis, + // so a run staged here is exactly as long as the plot layer measured it and + // ends where the space reserved for the label ends. + const Glyph* glyph = font.glyph(next_codepoint(p)); + if (glyph == nullptr) { + continue; // no artwork for this codepoint: nothing to draw, nothing to advance + } + add_cell(pen_px, *glyph, px, ux); + pen_px = pen_px + ux * (glyph->advance * px); } return pen_px; } diff --git a/src/render/program.cpp b/src/render/program.cpp index f8bc7d3..1bb2745 100644 --- a/src/render/program.cpp +++ b/src/render/program.cpp @@ -41,6 +41,8 @@ void TextProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u void TextProgram::set_px_range(Vec2 px) const noexcept { program_.set_uniform("u_px_range", px); } +void TextProgram::set_glyph_uv(Vec2 uv) const noexcept { program_.set_uniform("u_glyph_uv", uv); } + void TextProgram::set_texture_size(Vec2 texels) const noexcept { program_.set_uniform("u_tex_size", texels); } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index dcf5085..30ed291 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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 -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(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 interleaved) noexcept { } void Renderer::upload_text_vertices(std::span interleaved) noexcept { - text_vao_.upload(interleaved.data(), static_cast(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(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(render::BitmapFont::kAtlasWidth), - static_cast(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 diff --git a/src/render/shaders.cpp b/src/render/shaders.cpp index c04be5c..63b0fa5 100644 --- a/src/render/shaders.cpp +++ b/src/render/shaders.cpp @@ -73,28 +73,38 @@ void main() { )"; const char* const kTextVS = R"(#version 330 core -layout(location = 0) in vec2 a_pos; // per-instance glyph quad top-left, pixels -layout(location = 1) in vec2 a_uv; // per-instance glyph atlas texel origin +layout(location = 0) in vec2 a_pos; // per-instance glyph cell top-left, pixels +layout(location = 1) in vec2 a_uv; // per-instance glyph rect top-left, normalised +layout(location = 2) in vec2 a_axis; // per-instance cell +x axis, unit, pixels uniform vec2 u_viewport; // render target size, pixels -uniform vec2 u_px_range; // pixels per glyph cell on screen +uniform vec2 u_px_range; // glyph cell on screen, pixels (width, height) +uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height) -out vec2 v_texel; // atlas texel coordinate of this fragment - -const vec2 kGlyphTexels = vec2(8.0); // BitmapFont::kGlyphPx +out vec2 v_uv; // normalised atlas coordinate of this fragment void main() { + // The cell's two axes: the run direction the instance carries and its + // perpendicular, so the cell follows the basis instead of the screen axes. The + // perpendicular is the cell's own +v axis -- with an unrotated basis + // (1.0, 0.0) it is (0.0, 1.0), i.e. down the screen, which is the direction v + // grows in the atlas -- and building it this way keeps the quad's orientation: + // a mirrored perpendicular would draw every glyph flipped about its diagonal. + vec2 ux = a_axis; + vec2 uy = vec2(-ux.y, ux.x); vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1)); - vec2 px = a_pos + corner * u_px_range; + vec2 px = a_pos + corner.x * u_px_range.x * ux + corner.y * u_px_range.y * uy; // Rect is top-left origin, GL is bottom-left: the one and only y flip. vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0); gl_Position = vec4(ndc, 0.0, 1.0); - v_texel = a_uv + corner * kGlyphTexels; + // The same handedness as the cell, so the glyph's rect lands on it the right + // way round: u runs along the basis, v down the glyph. + v_uv = a_uv + corner * u_glyph_uv; } )"; const char* const kTextFS = R"(#version 330 core -in vec2 v_texel; // atlas texel coordinate of this fragment +in vec2 v_uv; // normalised atlas coordinate of this fragment uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST uniform vec2 u_tex_size; // atlas size in texels @@ -106,7 +116,7 @@ void main() { // Sample the centre of the texel the fragment lands in. GL_NEAREST alone // would tie at a texel boundary, where the driver may pick the neighbouring // glyph cell; rounding to a centre removes the tie by construction. - vec2 texel = clamp(floor(v_texel), vec2(0.0), u_tex_size - vec2(1.0)); + vec2 texel = clamp(floor(v_uv * u_tex_size), vec2(0.0), u_tex_size - vec2(1.0)); float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r; o_color = vec4(u_color.rgb, u_color.a * coverage); } diff --git a/src/render/text.cpp b/src/render/text.cpp new file mode 100644 index 0000000..b2a9dc1 --- /dev/null +++ b/src/render/text.cpp @@ -0,0 +1,48 @@ +#include + +namespace plot::render { + +// The decoder lives with the interface, not with a font, because it is what keeps +// "what a string is worth" (`TextBackend::measure`) and "what a string draws" +// (`TextBatch::add_text`) the same thing: both walk a label with this function, so +// neither can disagree with the other about where a codepoint starts or how many +// bytes a malformed sequence costs. +u32 next_codepoint(const char*& p) noexcept { + const auto b0 = static_cast(static_cast(p[0])); + if (b0 < 0x80u) { + ++p; + return b0; + } + u32 codepoint = 0; + u32 extra = 0; + if ((b0 & 0xE0u) == 0xC0u) { + codepoint = b0 & 0x1Fu; + extra = 1u; + } else if ((b0 & 0xF0u) == 0xE0u) { + codepoint = b0 & 0x0Fu; + extra = 2u; + } else if ((b0 & 0xF8u) == 0xF0u) { + codepoint = b0 & 0x07u; + extra = 3u; + } else { + ++p; + return kInvalidCodepoint; + } + for (u32 i = 1; i <= extra; ++i) { + const auto b = static_cast(static_cast(p[i])); + if ((b & 0xC0u) != 0x80u) { // truncated: stop before the terminator + ++p; + return kInvalidCodepoint; + } + codepoint = (codepoint << 6) | (b & 0x3Fu); + } + p += extra + 1u; + constexpr u32 kMinForLength[4] = {0u, 0x80u, 0x800u, 0x10000u}; + if (codepoint < kMinForLength[extra] || codepoint > 0x10FFFFu || + (codepoint >= 0xD800u && codepoint <= 0xDFFFu)) { + return kInvalidCodepoint; // overlong, out of range or a surrogate + } + return codepoint; +} + +} // namespace plot::render diff --git a/src/render/text_atlas.cpp b/src/render/text_atlas.cpp new file mode 100644 index 0000000..ba1ade4 --- /dev/null +++ b/src/render/text_atlas.cpp @@ -0,0 +1,54 @@ +#include + +#include + +namespace plot::render { + +bool TextAtlas::sync(const TextBackend& backend, Status* st) noexcept { + if (synced_ && revision_ == backend.revision()) { + return true; // the GPU holds exactly the bytes this revision describes + } + const u8* pixels = backend.atlas_pixels(); + const u32 width = backend.atlas_width(); + const u32 height = backend.atlas_height(); + if (pixels == nullptr || width == 0u || height == 0u) { + // "No text", not a failure: record the revision so the check above settles, + // and release the texture as well as the size -- an atlas a backend has + // stopped publishing must not stay bound, or a draw would sample glyphs that + // are no longer its and divide by a zero texture size doing it. + revision_ = backend.revision(); + synced_ = true; + width_ = 0; + height_ = 0; + texture_.reset(); + return true; + } + + // `upload_r8` needs GL_UNPACK_ALIGNMENT 1 for single-channel rows and leaves it + // there; the caller's value is this call's to put back, wherever it runs. + gl::GLint host_alignment = 4; + gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &host_alignment); + const bool ready = texture_.id() != 0 || texture_.create_r8(width, height, st); + if (ready) { + // `glTexImage2D` re-specifies the image, so a re-upload at another size needs + // no second create -- and it keeps the filter, which is why the filter is set + // on every upload rather than once at creation. + texture_.upload_r8(width, height, pixels); + texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST); + } + gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment); + if (!ready) { + // Leave the last good upload and its revision in place: the next sync tries + // again, and a frame in between draws the text it already had. + return false; + } + revision_ = backend.revision(); + synced_ = true; + width_ = width; + height_ = height; + return true; +} + +void TextAtlas::bind(gl::GLenum unit) const noexcept { texture_.bind(unit); } + +} // namespace plot::render diff --git a/src/render/vertex_array.cpp b/src/render/vertex_array.cpp index 09ab18f..c72515b 100644 --- a/src/render/vertex_array.cpp +++ b/src/render/vertex_array.cpp @@ -69,10 +69,15 @@ bool TextVertexArray::create(Status* st, UploadMode mode) noexcept { } vao_.bind(); buffer_.bind(); + // Three attributes now: the cell's corner, its atlas rect and the basis that + // rotates it (see TextInstance). All of them advance once per instance, like + // the line pipeline's, because the corners come from gl_VertexID. vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x)); vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u)); + vao_.attrib(2u, 2, sizeof(TextInstance), offsetof(TextInstance, ax)); gl::glVertexAttribDivisor(0u, kPerInstance); gl::glVertexAttribDivisor(1u, kPerInstance); + gl::glVertexAttribDivisor(2u, kPerInstance); gl::glBindVertexArray(0); epoch_ = buffer_.epoch(); return true; @@ -90,6 +95,7 @@ void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept { buffer_.bind(); vao_.attrib(0u, 2, sizeof(TextInstance), base + offsetof(TextInstance, x)); vao_.attrib(1u, 2, sizeof(TextInstance), base + offsetof(TextInstance, u)); + vao_.attrib(2u, 2, sizeof(TextInstance), base + offsetof(TextInstance, ax)); epoch_ = buffer_.epoch(); } instances_ = glyphs; diff --git a/tests/test_gl_context.cpp b/tests/test_gl_context.cpp index d9cd250..a1963ec 100644 --- a/tests/test_gl_context.cpp +++ b/tests/test_gl_context.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -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(BitmapFont::kAtlasWidth), 1e-6); + PLOT_CHECK_NEAR(atlas.texel_size().y, static_cast(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(ctx.width), @@ -376,8 +384,8 @@ 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_texture_size({static_cast(BitmapFont::kAtlasWidth), - static_cast(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(); diff --git a/tests/test_primitives.cpp b/tests/test_primitives.cpp index 266aad6..a358b35 100644 --- a/tests/test_primitives.cpp +++ b/tests/test_primitives.cpp @@ -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('.') - 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_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); } diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 23fb1d2..24f2321 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(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(y) * size + x] = 255u; + } + } + } + glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast(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(size_); } + plot::f32 ascent() const override { return static_cast(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 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(ctx.width), + static_cast(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(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(); + 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(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(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(pen.x)); + PLOT_CHECK(flat.y0 >= static_cast(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(pen.x)); + PLOT_CHECK(up.x1 < static_cast(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(pen.y)); + PLOT_CHECK(up.y1 < static_cast(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(at.x), + ctx.height - 1u - static_cast(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(x), + ctx.height - 1u - static_cast(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(); +}