From 7cbc1c99556ad2f4e2f65151f75ef211063dcac5 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 27 Sep 2026 22:03:56 +0200 Subject: [PATCH] feat(render): style, GLSL line/text pipelines, staging batches, bitmap font --- CMakeLists.txt | 6 + include/plot/core/types.hpp | 1 + include/plot/gl/api.hpp | 2 + include/plot/gl/handle.hpp | 8 + include/plot/render/bitmap_font.hpp | 101 ++++++++++ include/plot/render/buffer.hpp | 81 ++++++++ include/plot/render/primitives.hpp | 87 +++++++++ include/plot/render/program.hpp | 62 ++++++ include/plot/render/shaders.hpp | 41 ++++ include/plot/render/style.hpp | 25 +++ include/plot/render/text.hpp | 32 ++++ include/plot/render/vertex_array.hpp | 54 ++++++ src/gl/handle.cpp | 20 ++ src/render/bitmap_font.cpp | 277 +++++++++++++++++++++++++++ src/render/buffer.cpp | 32 ++++ src/render/primitives.cpp | 61 ++++++ src/render/program.cpp | 50 +++++ src/render/shaders.cpp | 110 +++++++++++ src/render/vertex_array.cpp | 76 ++++++++ tests/CMakeLists.txt | 1 + tests/test_gl_context.cpp | 187 ++++++++++++++++++ tests/test_primitives.cpp | 221 +++++++++++++++++++++ 22 files changed, 1535 insertions(+) create mode 100644 include/plot/render/bitmap_font.hpp create mode 100644 include/plot/render/buffer.hpp create mode 100644 include/plot/render/primitives.hpp create mode 100644 include/plot/render/program.hpp create mode 100644 include/plot/render/shaders.hpp create mode 100644 include/plot/render/style.hpp create mode 100644 include/plot/render/text.hpp create mode 100644 include/plot/render/vertex_array.hpp create mode 100644 src/render/bitmap_font.cpp create mode 100644 src/render/buffer.cpp create mode 100644 src/render/primitives.cpp create mode 100644 src/render/program.cpp create mode 100644 src/render/shaders.cpp create mode 100644 src/render/vertex_array.cpp create mode 100644 tests/test_primitives.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b42a364..83c2bc0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,12 @@ add_library(plot_core src/data/stream_series.cpp src/gl/loader.cpp src/gl/handle.cpp + src/render/shaders.cpp + src/render/bitmap_font.cpp + src/render/primitives.cpp + src/render/buffer.cpp + src/render/vertex_array.cpp + src/render/program.cpp ) add_library(plot::core ALIAS plot_core) diff --git a/include/plot/core/types.hpp b/include/plot/core/types.hpp index 321011c..abf3972 100644 --- a/include/plot/core/types.hpp +++ b/include/plot/core/types.hpp @@ -6,6 +6,7 @@ namespace plot { using f32 = float; using f64 = double; +using u8 = std::uint8_t; using i32 = std::int32_t; using u32 = std::uint32_t; using u64 = std::uint64_t; diff --git a/include/plot/gl/api.hpp b/include/plot/gl/api.hpp index 8c8fe88..fcc83d8 100644 --- a/include/plot/gl/api.hpp +++ b/include/plot/gl/api.hpp @@ -139,6 +139,7 @@ enum : GLenum { X(void, glUniform1i, (GLint, GLint)) \ X(void, glUniform1f, (GLint, float)) \ X(void, glUniform2f, (GLint, float, float)) \ + X(void, glUniform4f, (GLint, float, float, float, float)) \ X(void, glUniform1fv, (GLint, GLsizei, const float*)) \ X(void, glUniform2fv, (GLint, GLsizei, const float*)) \ /* buffers */ \ @@ -153,6 +154,7 @@ enum : GLenum { X(void, glBindVertexArray, (GLuint)) \ X(void, glEnableVertexAttribArray, (GLuint)) \ X(void, glVertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*)) \ + X(void, glVertexAttribDivisor, (GLuint, GLuint)) \ /* drawing */ \ X(void, glDrawArrays, (GLenum, GLint, GLsizei)) \ X(void, glDrawArraysInstanced, (GLenum, GLint, GLsizei, GLsizei)) \ diff --git a/include/plot/gl/handle.hpp b/include/plot/gl/handle.hpp index 8a9cde2..bdaef97 100644 --- a/include/plot/gl/handle.hpp +++ b/include/plot/gl/handle.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -103,6 +104,13 @@ class Program final : public Handle { /// Sets a two-component float uniform. void set_uniform(const char* name, Vec2 value) const noexcept; + /// Sets a four-component float uniform, e.g. the colour of a pipeline. + void set_uniform(const char* name, Color value) const noexcept; + + /// Sets an integer uniform, e.g. a sampler's texture unit. Deliberately not + /// an overload of the float setter: an untyped literal such as `0` must not + /// turn a float uniform into a `glUniform1i` call. + void set_uniform_int(const char* name, i32 value) const noexcept; /// Exposed for the CRTP base. static void destroy(GLuint id) noexcept; diff --git a/include/plot/render/bitmap_font.hpp b/include/plot/render/bitmap_font.hpp new file mode 100644 index 0000000..c43e157 --- /dev/null +++ b/include/plot/render/bitmap_font.hpp @@ -0,0 +1,101 @@ +#pragma once +#include +#include +#include + +namespace plot::render { + +/// 8x8 bitmap font, the text backend of the renderer. +/// +/// The glyphs are 1-bit, one byte per row, bit 0 the leftmost pixel; the table +/// and its provenance are in `src/render/bitmap_font.cpp`. Nothing here needs a +/// 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. +/// +/// 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. +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. + 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 + static constexpr u32 kDegreeSlot = kAsciiGlyphs; ///< U+00B0 DEGREE SIGN + static constexpr u32 kMicroSlot = kAsciiGlyphs + 1u; ///< U+00B5 MICRO SIGN + static constexpr u32 kOhmSlot = kAsciiGlyphs + 2u; ///< U+03A9 OHM + static constexpr u32 kFallbackSlot = kAsciiGlyphs + 3u; ///< missing-glyph box + static constexpr u32 kGlyphCount = kAsciiGlyphs + 4u; + static constexpr u32 kAtlasCols = 16; + static constexpr u32 kAtlasRows = 7; + static constexpr u32 kAtlasWidth = kAtlasCols * kGlyphPx; ///< 128 texels + 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; + + /// Width of `utf8`, the sum of the glyph advances: zero for the empty string + /// and for null. The ASCII path allocates nothing. + f32 measure(const char* utf8) const override; + + /// Cell height in pixels (one glyph row of text occupies `height()` pixels). + f32 height() const override; + /// Top of the cell to the baseline, in pixels. + f32 ascent() const override; + + /// Advance of one ASCII character in pixels. A byte that is not printable + /// ASCII reports the missing-glyph box, matching what `measure` gives that + /// byte on its own. + f32 advance(char c) const noexcept; + + /// Advance of a decoded Unicode codepoint, in pixels. + f32 advance_codepoint(u32 codepoint) const noexcept; + + /// Glyph slot for a codepoint: printable ASCII is its own slot, the three SI + /// 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; + + /// Rows in a glyph bitmap, always `kGlyphPx`. + u32 glyph_rows(char c) const noexcept; + + /// The `kGlyphPx` rows of one ASCII character's bitmap, top row first. + const u8* glyph_bitmap(char c) const noexcept; + + /// The `kGlyphPx` rows of a glyph slot. + static const u8* glyph_bitmap_of(u32 slot) noexcept; + + /// The packed atlas image, ready for `plot::gl::Texture::upload_r8`: rows of + /// `kAtlasWidth` bytes, top row first, 255 for ink and 0 for background + /// (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. + static Vec2 atlas_origin(u32 slot) noexcept; + + private: + f32 advances_[kGlyphCount]; +}; + +} // namespace plot::render diff --git a/include/plot/render/buffer.hpp b/include/plot/render/buffer.hpp new file mode 100644 index 0000000..b24e41e --- /dev/null +++ b/include/plot/render/buffer.hpp @@ -0,0 +1,81 @@ +#pragma once +#include +#include +#include + +namespace plot::render { + +/// Per-instance payload of the line pipeline: one segment in data space. It +/// mirrors the interleaved floats `LineBatch::data()` produces, which is why the +/// attribute offsets in vertex_array.cpp come from `offsetof` rather than from +/// hand-written numbers. +struct LineInstance { + f32 x0; + f32 y0; + f32 x1; + 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. +struct TextInstance { + f32 x; + f32 y; + f32 u; + f32 v; +}; + +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"); + +namespace detail { + +/// The growth half of an instance buffer, shared by both pipelines so neither +/// duplicates the GL ownership or the "grow only when the frame needs it" rule. +class InstanceStore { + public: + /// Creates the vertex buffer. Reports `ErrorCode::Unsupported` while the GL + /// entry points are unresolved, `GLFailure` if the driver hands out no name. + bool create(Status* st = nullptr) noexcept; + + /// Uploads `count` instances of `instance_bytes`. The store only reallocates + /// when the frame's workload outgrows it, so steady-state frames write into + /// the existing allocation. + void upload(const void* data, u32 count, usize instance_bytes) noexcept; + + void bind() const noexcept { buffer_.bind(); } + + private: + gl::Buffer buffer_; + u32 capacity_ = 0; ///< instances the store can hold +}; + +} // namespace detail + +/// Instance buffer of the line pipeline: one `LineInstance` per segment. +class LineBuffer { + public: + bool create(Status* st = nullptr) noexcept { return store_.create(st); } + void upload(const f32* interleaved, u32 count) noexcept { + store_.upload(interleaved, count, sizeof(LineInstance)); + } + void bind() const noexcept { store_.bind(); } + + private: + detail::InstanceStore store_; +}; + +/// Instance buffer of the text pipeline: one `TextInstance` per glyph. +class TextBuffer { + public: + bool create(Status* st = nullptr) noexcept { return store_.create(st); } + void upload(const f32* interleaved, u32 count) noexcept { + store_.upload(interleaved, count, sizeof(TextInstance)); + } + void bind() const noexcept { store_.bind(); } + + private: + detail::InstanceStore store_; +}; + +} // namespace plot::render diff --git a/include/plot/render/primitives.hpp b/include/plot/render/primitives.hpp new file mode 100644 index 0000000..c1fe5f1 --- /dev/null +++ b/include/plot/render/primitives.hpp @@ -0,0 +1,87 @@ +#pragma once +#include +#include +#include + +#include + +namespace plot::render { + +/// CPU-side staging for one instanced-AA line draw call. +/// +/// `begin()` starts a frame and `clear()` empties the batch; neither releases +/// the store, and both leave capacity alone so a steady-state frame re-uses the +/// previous allocation instead of reallocating per frame. The renderer re-uses +/// one batch for the whole run and uploads `data()` once per frame. +/// +/// The payload is interleaved `x0,y0,x1,y1` per segment — exactly the +/// per-instance attribute layout the line shader declares (see buffer.hpp and +/// shaders.hpp), so the intersection of `data()` with an `f32` span can be +/// uploaded verbatim. +class LineBatch { + public: + /// Starts a frame: empties the batch, keeps its capacity. + void begin() noexcept; + /// Same as `begin()`, so the render loop can read as begin/stage/draw. + void clear() noexcept; + + /// Stages one segment: one instance, two quad corners per end. + void add_segment(Vec2 a, Vec2 b); + + /// Stages `n - 1` segments joining consecutive points. A null pointer or + /// fewer than two points stages nothing. + void add_polyline(const Vec2* pts, u32 n); + + /// Segments staged so far, one instance each. + u32 segment_count() const noexcept { return static_cast(v_.size() / 4u); } + /// Vertices the pipeline will rasterise: two per staged segment. + u32 vertex_count() const noexcept { return segment_count() * 2u; } + + /// Interleaved `x0,y0,x1,y1`, four floats per staged segment. + const std::vector& data() const noexcept { return v_; } + + private: + std::vector v_; +}; + +/// 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). +class TextBatch { + public: + /// Starts a frame: empties the batch, keeps its 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`. + /// + /// `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)); + + /// Glyphs staged so far, one instance each. + u32 glyph_count() const noexcept { return static_cast(v_.size() / 4u); } + + /// Interleaved `x,y,u,v`, four floats per staged glyph. + const std::vector& data() const noexcept { return v_; } + + private: + std::vector v_; +}; + +} // namespace plot::render diff --git a/include/plot/render/program.hpp b/include/plot/render/program.hpp new file mode 100644 index 0000000..88c2951 --- /dev/null +++ b/include/plot/render/program.hpp @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include +#include + +namespace plot::render { + +/// Linked line program and its uniform surface. +/// +/// It owns the `plot::gl::Program` and knows the GLSL pair (shaders.hpp), so no +/// caller spells out a uniform name that could drift from the shader. Creating +/// it propagates what the GL layer reports: `ErrorCode::Unsupported` while +/// `plot::gl::available()` is false, `GLFailure` plus the driver's log when the +/// GLSL does not compile or link. +class LineProgram { + public: + bool create(Status* st = nullptr) noexcept; + bool valid() const noexcept { return program_.id() != 0; } + void bind() const noexcept { program_.use(); } + + /// `u_scale`: data space to pixels. + void set_scale(Vec2 data_to_px) const noexcept; + /// `u_offset`: pixel offset added after `set_scale`. + void set_offset(Vec2 px) const noexcept; + /// `u_viewport`: render target size in pixels. + void set_viewport(Vec2 px) const noexcept; + /// `u_width`: stroke width in pixels. + void set_width(f32 px) const noexcept; + /// `u_color`: stroke colour, alpha included. + void set_color(Color color) const noexcept; + + private: + gl::Program program_; +}; + +/// Linked text program and its uniform surface. It fails exactly the way +/// `LineProgram::create()` does, propagating `Unsupported` and `GLFailure`. +/// +/// `create()` also points the `u_atlas` sampler at texture unit 0, which is the +/// unit `plot::gl::Texture::bind()` defaults to, and leaves this program bound. +/// The atlas itself stays with the caller, who owns its lifetime. +class TextProgram { + public: + bool create(Status* st = nullptr) noexcept; + bool valid() const noexcept { return program_.id() != 0; } + void bind() const noexcept { program_.use(); } + + /// `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. + void set_px_range(Vec2 px) const noexcept; + /// `u_tex_size`: atlas size in texels. + void set_texture_size(Vec2 texels) const noexcept; + /// `u_color`: tint colour, alpha included. + void set_color(Color color) const noexcept; + + private: + gl::Program program_; +}; + +} // namespace plot::render diff --git a/include/plot/render/shaders.hpp b/include/plot/render/shaders.hpp new file mode 100644 index 0000000..363845f --- /dev/null +++ b/include/plot/render/shaders.hpp @@ -0,0 +1,41 @@ +#pragma once + +namespace plot::render { + +/// GLSL source of the instanced antialiased line pipeline. +/// +/// The vertex shader takes one segment per instance (`a_p0` at location 0, +/// `a_p1` at location 1, both with a vertex attribute divisor of 1), expands it +/// in pixel space along its normal by `u_width * 0.5 + 1.0` pixels of AA fringe, +/// and emits a four-vertex `GL_TRIANGLE_STRIP` quad whose corners come from +/// `gl_VertexID` (0 and 1 are the two sides of `a_p0`, 2 and 3 of `a_p1`). It +/// outputs `v_dist`, the signed distance from the segment axis in pixels, and +/// `v_along`, the distance travelled from `a_p0` in pixels. +/// +/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels), +/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_color` (vec4). +/// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is +/// flipped exactly once, here. +/// +/// The fragment shader discards outside the fringe and ramps alpha over the last +/// pixel of the half width with `smoothstep`, which is what keeps a 1px trace +/// from aliasing into dashes. +extern const char* const kLineVS; +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 +/// 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). +extern const char* const kTextVS; +extern const char* const kTextFS; + +} // namespace plot::render diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp new file mode 100644 index 0000000..78e7854 --- /dev/null +++ b/include/plot/render/style.hpp @@ -0,0 +1,25 @@ +#pragma once +#include + +namespace plot::render { + +/// Every colour and metric the renderer draws with. Defaults are the dark theme +/// the plan's render smoke test asserts against; a caller swaps a field and the +/// next frame picks it up, so nothing here is baked into a shader. +struct Style { + Color background = Color::hex(0x101418); + Color grid_major = Color::hex(0x2A3038); + Color grid_minor = Color::hex(0x1C2126); + Color axis_line = Color::hex(0x8A94A6); + Color axis_text = Color::hex(0xC8D0DC); + Color cursor = Color::hex(0xFFB020); + Color measure_fill = Color::hex(0xFFB020, 0.15f); + f32 axis_font_px = 12.0f; + f32 grid_label_pad = 4.0f; + f32 line_width_px = 1.5f; + f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per pixel above which + ///< a trace is drawn from the LOD pyramid. + f32 tick_min_px = 60.0f; ///< minimum spacing between major ticks. +}; + +} // namespace plot::render diff --git a/include/plot/render/text.hpp b/include/plot/render/text.hpp new file mode 100644 index 0000000..e529d31 --- /dev/null +++ b/include/plot/render/text.hpp @@ -0,0 +1,32 @@ +#pragma once +#include + +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. +/// +/// 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. +class TextBackend { + public: + TextBackend() noexcept = default; + TextBackend(const TextBackend&) = default; + 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. + virtual f32 measure(const char* utf8) const = 0; + + /// Line height: the cell a single line of text occupies. + virtual f32 height() const = 0; + + /// 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; +}; + +} // namespace plot::render diff --git a/include/plot/render/vertex_array.hpp b/include/plot/render/vertex_array.hpp new file mode 100644 index 0000000..7f984df --- /dev/null +++ b/include/plot/render/vertex_array.hpp @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include +#include + +namespace plot::render { + +/// Vertex array plus instance buffer of the line pipeline. +/// +/// The attribute layout is fixed by the line shader (see shaders.hpp): `a_p0` at +/// location 0 and `a_p1` at location 1, two floats each, advancing once per +/// instance. A caller never writes a location, stride or offset; it stages a +/// `LineBatch` and uploads its `data()`. +class LineVertexArray { + public: + /// Creates the vertex array, the instance buffer and the attribute layout. + bool create(Status* st = nullptr) noexcept; + + /// Uploads `segments` instances from interleaved `x0,y0,x1,y1` floats and + /// records how many the next draw should rasterise. + void upload(const f32* interleaved, u32 segments) noexcept; + + void bind() const noexcept { vao_.bind(); } + bool valid() const noexcept { return vao_.id() != 0; } + u32 instance_count() const noexcept { return instances_; } + + private: + LineBuffer buffer_; + gl::VertexArray vao_; + u32 instances_ = 0; +}; + +/// 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. +class TextVertexArray { + public: + bool create(Status* st = nullptr) noexcept; + + /// Uploads `glyphs` instances from interleaved `x,y,u,v` floats. + void upload(const f32* interleaved, u32 glyphs) noexcept; + + void bind() const noexcept { vao_.bind(); } + bool valid() const noexcept { return vao_.id() != 0; } + u32 instance_count() const noexcept { return instances_; } + + private: + TextBuffer buffer_; + gl::VertexArray vao_; + u32 instances_ = 0; +}; + +} // namespace plot::render diff --git a/src/gl/handle.cpp b/src/gl/handle.cpp index 5f5fdc4..2f4e6e3 100644 --- a/src/gl/handle.cpp +++ b/src/gl/handle.cpp @@ -226,6 +226,26 @@ void Program::set_uniform(const char* name, Vec2 value) const noexcept { } } +void Program::set_uniform(const char* name, Color value) const noexcept { + if (id_ == 0 || name == nullptr) { + return; + } + const GLint location = glGetUniformLocation(id_, name); + if (location >= 0) { + glUniform4f(location, value.r, value.g, value.b, value.a); + } +} + +void Program::set_uniform_int(const char* name, i32 value) const noexcept { + if (id_ == 0 || name == nullptr) { + return; + } + const GLint location = glGetUniformLocation(id_, name); + if (location >= 0) { + glUniform1i(location, value); + } +} + void Program::destroy(GLuint id) noexcept { glDeleteProgram(id); } // ------------------------------------------------------------------ Texture diff --git a/src/render/bitmap_font.cpp b/src/render/bitmap_font.cpp new file mode 100644 index 0000000..b12180a --- /dev/null +++ b/src/render/bitmap_font.cpp @@ -0,0 +1,277 @@ +#include + +#include + +namespace plot::render { +namespace { + +// Glyph data and its provenance +// ----------------------------- +// The 95 printable ASCII glyphs (slots 0..94, U+0020..U+007E) are the +// `font8x8_basic` table of the font8x8 project by Daniel Hepper, released into +// the public domain. It is itself based on Marcel Sondaar's public-domain 8x8 +// VGA font (via the IBM public-domain VGA fonts). Fetched on 2026-09-27 from +// https://raw.githubusercontent.com/dhepper/font8x8/master/font8x8_basic.h +// The glyphs the plot layer's SI labels need come from the same project: +// U+00B0 DEGREE SIGN and U+00B5 MICRO SIGN from font8x8_ext_latin.h (which +// covers U+00A0..U+00FF), and U+03A9 GREEK CAPITAL LETTER OMEGA from +// font8x8_greek.h (which covers U+0390..U+03C9). +// The missing-glyph box in the last slot is authored here: a hollow 8x8 square. +// Nothing else in this table is hand-drawn, and none of it is a library: it is +// public-domain data, so `plot_core` still links nothing third-party. +// +// Row 0 is the top row and bit 0 is the leftmost pixel, one byte per row. + +constexpr u8 kGlyphBits[BitmapFont::kGlyphCount][BitmapFont::kGlyphPx] = { + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 0] U+0020 space + {0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // [ 1] U+0021 '!' + {0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 2] U+0022 '"' + {0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // [ 3] U+0023 '#' + {0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // [ 4] U+0024 '$' + {0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // [ 5] U+0025 '%' + {0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // [ 6] U+0026 '&' + {0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // [ 7] U+0027 ''' + {0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // [ 8] U+0028 '(' + {0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // [ 9] U+0029 ')' + {0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // [10] U+002A '*' + {0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // [11] U+002B '+' + {0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // [12] U+002C ',' + {0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // [13] U+002D '-' + {0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // [14] U+002E '.' + {0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // [15] U+002F '/' + {0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // [16] U+0030 '0' + {0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // [17] U+0031 '1' + {0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // [18] U+0032 '2' + {0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // [19] U+0033 '3' + {0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // [20] U+0034 '4' + {0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // [21] U+0035 '5' + {0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // [22] U+0036 '6' + {0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // [23] U+0037 '7' + {0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // [24] U+0038 '8' + {0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // [25] U+0039 '9' + {0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // [26] U+003A ':' + {0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // [27] U+003B ';' + {0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // [28] U+003C '<' + {0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // [29] U+003D '=' + {0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // [30] U+003E '>' + {0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // [31] U+003F '?' + {0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // [32] U+0040 '@' + {0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // [33] U+0041 'A' + {0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // [34] U+0042 'B' + {0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // [35] U+0043 'C' + {0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // [36] U+0044 'D' + {0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // [37] U+0045 'E' + {0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // [38] U+0046 'F' + {0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // [39] U+0047 'G' + {0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // [40] U+0048 'H' + {0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [41] U+0049 'I' + {0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // [42] U+004A 'J' + {0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // [43] U+004B 'K' + {0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // [44] U+004C 'L' + {0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // [45] U+004D 'M' + {0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // [46] U+004E 'N' + {0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // [47] U+004F 'O' + {0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // [48] U+0050 'P' + {0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // [49] U+0051 'Q' + {0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // [50] U+0052 'R' + {0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // [51] U+0053 'S' + {0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [52] U+0054 'T' + {0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // [53] U+0055 'U' + {0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // [54] U+0056 'V' + {0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // [55] U+0057 'W' + {0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // [56] U+0058 'X' + {0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // [57] U+0059 'Y' + {0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // [58] U+005A 'Z' + {0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // [59] U+005B '[' + {0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // [60] U+005C '\' + {0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // [61] U+005D ']' + {0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // [62] U+005E '^' + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // [63] U+005F '_' + {0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // [64] U+0060 '`' + {0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // [65] U+0061 'a' + {0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // [66] U+0062 'b' + {0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // [67] U+0063 'c' + {0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00}, // [68] U+0064 'd' + {0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00}, // [69] U+0065 'e' + {0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00}, // [70] U+0066 'f' + {0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // [71] U+0067 'g' + {0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // [72] U+0068 'h' + {0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [73] U+0069 'i' + {0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // [74] U+006A 'j' + {0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // [75] U+006B 'k' + {0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // [76] U+006C 'l' + {0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // [77] U+006D 'm' + {0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // [78] U+006E 'n' + {0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // [79] U+006F 'o' + {0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // [80] U+0070 'p' + {0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // [81] U+0071 'q' + {0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // [82] U+0072 'r' + {0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // [83] U+0073 's' + {0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // [84] U+0074 't' + {0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // [85] U+0075 'u' + {0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // [86] U+0076 'v' + {0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // [87] U+0077 'w' + {0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // [88] U+0078 'x' + {0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // [89] U+0079 'y' + {0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // [90] U+007A 'z' + {0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // [91] U+007B '{' + {0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // [92] U+007C '|' + {0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // [93] U+007D '}' + {0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // [94] U+007E '~' + {0x1C, 0x36, 0x36, 0x1C, 0x00, 0x00, 0x00, 0x00}, // [95] U+00B0 degree + {0x00, 0x00, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x03}, // [96] U+00B5 micro + {0x3E, 0x63, 0x63, 0x63, 0x36, 0x36, 0x77, 0x00}, // [97] U+03A9 ohm + {0x7E, 0x42, 0x42, 0x42, 0x42, 0x42, 0x7E, 0x00}, // [98] missing-glyph box, authored here +}; + +/// Repacks the glyph-major table into the atlas image the text pipeline +/// uploads: one 8x8 cell per slot, 255 for ink, straight alpha, no dilation. +constexpr std::array pack_atlas() noexcept { + std::array image{}; + for (u32 slot = 0; slot < BitmapFont::kGlyphCount; ++slot) { + const u32 cell_x = (slot % BitmapFont::kAtlasCols) * BitmapFont::kGlyphPx; + const u32 cell_y = (slot / BitmapFont::kAtlasCols) * BitmapFont::kGlyphPx; + for (u32 y = 0; y < BitmapFont::kGlyphPx; ++y) { + const u8 bits = kGlyphBits[slot][y]; + for (u32 x = 0; x < BitmapFont::kGlyphPx; ++x) { + image[(cell_y + y) * BitmapFont::kAtlasWidth + cell_x + x] = + static_cast(((bits >> x) & 1u) != 0u ? 0xFFu : 0x00u); + } + } + } + return image; +} + +constexpr std::array kAtlasImage = + pack_atlas(); + +/// Span of set bits in a glyph's rows: 0 for a blank glyph such as space. +constexpr u32 ink_width(const u8* rows) noexcept { + u32 lo = BitmapFont::kGlyphPx; + u32 hi = 0; + bool any = false; + for (u32 y = 0; y < BitmapFont::kGlyphPx; ++y) { + for (u32 x = 0; x < BitmapFont::kGlyphPx; ++x) { + if (((rows[y] >> x) & 1u) != 0u) { + if (x < lo) { + lo = x; + } + if (x > hi) { + hi = x; + } + any = true; + } + } + } + return any ? hi - lo + 1u : 0u; +} + +} // 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); + } +} + +f32 BitmapFont::measure(const char* utf8) const { + if (utf8 == nullptr) { + return 0.0f; + } + f32 width = 0.0f; + for (const char* p = utf8; *p != '\0';) { + width += advances_[glyph_index(next_codepoint(p))]; + } + return width; +} + +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)))]; +} + +f32 BitmapFont::advance_codepoint(u32 codepoint) const noexcept { + return advances_[glyph_index(codepoint)]; +} + +u32 BitmapFont::glyph_index(u32 codepoint) noexcept { + if (codepoint >= kFirstChar && codepoint < kFirstChar + kAsciiGlyphs) { + return codepoint - kFirstChar; + } + switch (codepoint) { + case 0x00B0u: + return kDegreeSlot; + case 0x00B5u: + return kMicroSlot; + case 0x03A9u: + return kOhmSlot; + default: + return kFallbackSlot; + } +} + +u32 BitmapFont::glyph_rows(char) const noexcept { return kGlyphPx; } + +const u8* BitmapFont::glyph_bitmap(char c) const noexcept { + return glyph_bitmap_of(glyph_index(static_cast(static_cast(c)))); +} + +const u8* BitmapFont::glyph_bitmap_of(u32 slot) noexcept { + return kGlyphBits[slot < kGlyphCount ? slot : kFallbackSlot]; +} + +const u8* BitmapFont::atlas_image() noexcept { return kAtlasImage.data(); } + +Vec2 BitmapFont::atlas_origin(u32 slot) noexcept { + const u32 s = slot < kGlyphCount ? slot : kFallbackSlot; + return Vec2{static_cast((s % kAtlasCols) * kGlyphPx), + static_cast((s / kAtlasCols) * kGlyphPx)}; +} + +} // namespace plot::render diff --git a/src/render/buffer.cpp b/src/render/buffer.cpp new file mode 100644 index 0000000..dedecbc --- /dev/null +++ b/src/render/buffer.cpp @@ -0,0 +1,32 @@ +#include + +namespace plot::render { +namespace detail { + +bool InstanceStore::create(Status* st) noexcept { + // plot::gl::Buffer::create() already reports Unsupported while the entry + // points are unresolved and GLFailure when the driver hands out no name, so + // the wrapper only has to propagate its answer. + if (!buffer_.create(st)) { + return false; + } + capacity_ = 0; + return true; +} + +void InstanceStore::upload(const void* data, u32 count, usize instance_bytes) noexcept { + if (buffer_.id() == 0 || count == 0 || instance_bytes == 0) { + return; + } + if (count > capacity_) { + // Only a workload larger than anything staged so far pays for an + // allocation; a frame that repeats the previous one writes into the store + // that is already there. + buffer_.upload(nullptr, static_cast(count) * instance_bytes, gl::GL_STREAM_DRAW); + capacity_ = count; + } + buffer_.upload_sub(data, static_cast(count) * instance_bytes, 0); +} + +} // namespace detail +} // namespace plot::render diff --git a/src/render/primitives.cpp b/src/render/primitives.cpp new file mode 100644 index 0000000..51c4756 --- /dev/null +++ b/src/render/primitives.cpp @@ -0,0 +1,61 @@ +#include + +namespace plot::render { + +// ----------------------------------------------------------------- LineBatch + +void LineBatch::begin() noexcept { v_.clear(); } + +void LineBatch::clear() noexcept { v_.clear(); } + +void LineBatch::add_segment(Vec2 a, Vec2 b) { + v_.push_back(a.x); + v_.push_back(a.y); + v_.push_back(b.x); + v_.push_back(b.y); +} + +void LineBatch::add_polyline(const Vec2* pts, u32 n) { + if (pts == nullptr || n < 2u) { + return; // no pair of points, no segment + } + // Grow once for the whole polyline rather than per segment, so a frame that + // stages the same workload as the last one never reallocates. + v_.reserve(v_.size() + 4u * (n - 1u)); + for (u32 i = 1; i < n; ++i) { + add_segment(pts[i - 1u], pts[i]); + } +} + +// ----------------------------------------------------------------- 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); +} + +Vec2 TextBatch::add_text(Vec2 pen_px, const char* utf8, const BitmapFont& font, f32 px_per_cell) { + if (utf8 == nullptr) { + return pen_px; + } + const f32 scale = px_per_cell / static_cast(BitmapFont::kGlyphPx); + 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; + } + return pen_px; +} + +} // namespace plot::render diff --git a/src/render/program.cpp b/src/render/program.cpp new file mode 100644 index 0000000..f8bc7d3 --- /dev/null +++ b/src/render/program.cpp @@ -0,0 +1,50 @@ +#include + +#include + +namespace plot::render { + +bool LineProgram::create(Status* st) noexcept { + // plot::gl::Program::from_source() reports Unsupported while + // plot::gl::available() is false and GLFailure when the driver rejects the + // GLSL. Propagating it is the whole point of returning a bool here: the + // caller must not end up drawing with a program that never linked. + program_ = gl::Program::from_source(kLineVS, kLineFS, st); + return program_.id() != 0; +} + +void LineProgram::set_scale(Vec2 data_to_px) const noexcept { + program_.set_uniform("u_scale", data_to_px); +} + +void LineProgram::set_offset(Vec2 px) const noexcept { program_.set_uniform("u_offset", px); } + +void LineProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); } + +void LineProgram::set_width(f32 px) const noexcept { program_.set_uniform("u_width", px); } + +void LineProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); } + +bool TextProgram::create(Status* st) noexcept { + program_ = gl::Program::from_source(kTextVS, kTextFS, st); + if (program_.id() == 0) { + return false; + } + // The atlas is bound to texture unit 0 by plot::gl::Texture::bind(), so the + // sampler has to point there. It is set once, here, not per frame. + program_.use(); + program_.set_uniform_int("u_atlas", 0); + return true; +} + +void TextProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); } + +void TextProgram::set_px_range(Vec2 px) const noexcept { program_.set_uniform("u_px_range", px); } + +void TextProgram::set_texture_size(Vec2 texels) const noexcept { + program_.set_uniform("u_tex_size", texels); +} + +void TextProgram::set_color(Color color) const noexcept { program_.set_uniform("u_color", color); } + +} // namespace plot::render diff --git a/src/render/shaders.cpp b/src/render/shaders.cpp new file mode 100644 index 0000000..5f77848 --- /dev/null +++ b/src/render/shaders.cpp @@ -0,0 +1,110 @@ +#include + +// Both pipelines are `#version 330 core` and stay untouched by the upload +// strategy: the 4.3+ path changes how vertex data reaches the GPU, never the +// GLSL, so one program pair serves every context the library supports. The +// declarations here are the contract the render layer's attribute layouts and +// uniform setters mirror (see program.hpp, vertex_array.hpp, shaders.hpp). + +namespace plot::render { + +const char* const kLineVS = R"(#version 330 core +layout(location = 0) in vec2 a_p0; // per-instance segment start, data space +layout(location = 1) in vec2 a_p1; // per-instance segment end + +uniform vec2 u_scale; // data -> pixels +uniform vec2 u_offset; // pixels +uniform vec2 u_viewport; // render target size, pixels +uniform float u_width; // stroke width, pixels + +out float v_dist; // signed distance to the segment axis, pixels +out float v_along; // distance travelled from a_p0, pixels + +void main() { + vec2 p0 = a_p0 * u_scale + u_offset; + vec2 p1 = a_p1 * u_scale + u_offset; + vec2 d = p1 - p0; + float len = length(d); + // A degenerate segment has no direction: pin the normal so the quad stays + // finite and the instance draws as a dot instead of NaN geometry. + vec2 n = len > 1e-6 ? vec2(-d.y, d.x) / len : vec2(0.0, 1.0); + float half_w = u_width * 0.5 + 1.0; // + one pixel of AA fringe + + // gl_VertexID picks the corner: 0,1 are the sides of a_p0 and 2,3 of a_p1, + // which is the order GL_TRIANGLE_STRIP expects. + float side = (gl_VertexID & 1) == 0 ? -1.0 : 1.0; + vec2 base = (gl_VertexID & 2) == 0 ? p0 : p1; + vec2 px = base + n * (side * half_w); + + // Rect is top-left origin, GL is bottom-left: flip y once, here, and never + // again in the fragment shader or in a caller. + 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_dist = side * half_w; + v_along = (gl_VertexID & 2) == 0 ? 0.0 : len; +} +)"; + +const char* const kLineFS = R"(#version 330 core +in float v_dist; // signed distance to the segment axis, pixels + +uniform float u_width; // stroke width, pixels +uniform vec4 u_color; + +out vec4 o_color; + +void main() { + float half_w = u_width * 0.5; + float d = abs(v_dist); + if (d > half_w + 1.0) { + discard; // outside the fringe; nothing to blend + } + // Ramp over the last pixel of the half width so a thin stroke keeps its + // weight instead of aliasing into a dashed line. + float alpha = 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, d); + o_color = vec4(u_color.rgb, u_color.a * alpha); +} +)"; + +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 + +uniform vec2 u_viewport; // render target size, pixels +uniform vec2 u_px_range; // pixels per glyph cell on screen + +out vec2 v_texel; // atlas texel coordinate of this fragment + +const vec2 kGlyphTexels = vec2(8.0); // BitmapFont::kGlyphPx + +void main() { + vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1)); + vec2 px = a_pos + corner * u_px_range; + // 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; +} +)"; + +const char* const kTextFS = R"(#version 330 core +in vec2 v_texel; // atlas texel coordinate of this fragment + +uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST +uniform vec2 u_tex_size; // atlas size in texels +uniform vec4 u_color; + +out vec4 o_color; + +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)); + float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r; + o_color = vec4(u_color.rgb, u_color.a * coverage); +} +)"; + +} // namespace plot::render diff --git a/src/render/vertex_array.cpp b/src/render/vertex_array.cpp new file mode 100644 index 0000000..c645956 --- /dev/null +++ b/src/render/vertex_array.cpp @@ -0,0 +1,76 @@ +#include + +#include + +#include + +namespace plot::render { +namespace { + +/// Instance data advances one step per instance, never per vertex: both +/// pipelines draw one quad per instance with corners synthesised from +/// `gl_VertexID`, so every attribute has a divisor of 1 (core since GL 3.3). +constexpr gl::GLuint kPerInstance = 1; + +} // namespace + +bool LineVertexArray::create(Status* st) noexcept { + if (!vao_.create(st)) { + return false; + } + if (!buffer_.create(st)) { + vao_.reset(); // do not leave a vertex array behind on the failure path + return false; + } + // Attribute pointers capture the bound GL_ARRAY_BUFFER, so bind both first. + vao_.bind(); + buffer_.bind(); + // Offsets come from the struct, not from counting floats: the layout is the + // one shaders.hpp declares, and it stays in step with LineInstance. + vao_.attrib(0u, 2, sizeof(LineInstance), offsetof(LineInstance, x0)); + vao_.attrib(1u, 2, sizeof(LineInstance), offsetof(LineInstance, x1)); + gl::glVertexAttribDivisor(0u, kPerInstance); + gl::glVertexAttribDivisor(1u, kPerInstance); + gl::glBindVertexArray(0); // leave the binding as we found it + return true; +} + +void LineVertexArray::upload(const f32* interleaved, u32 segments) noexcept { + // An array that was never created reports nothing to draw, so a caller that + // ignored create()'s status rasterises nothing instead of stale memory. + if (interleaved == nullptr || vao_.id() == 0) { + instances_ = 0; + return; + } + buffer_.upload(interleaved, segments); + instances_ = segments; +} + +bool TextVertexArray::create(Status* st) noexcept { + if (!vao_.create(st)) { + return false; + } + if (!buffer_.create(st)) { + vao_.reset(); + return false; + } + vao_.bind(); + buffer_.bind(); + vao_.attrib(0u, 2, sizeof(TextInstance), offsetof(TextInstance, x)); + vao_.attrib(1u, 2, sizeof(TextInstance), offsetof(TextInstance, u)); + gl::glVertexAttribDivisor(0u, kPerInstance); + gl::glVertexAttribDivisor(1u, kPerInstance); + gl::glBindVertexArray(0); + return true; +} + +void TextVertexArray::upload(const f32* interleaved, u32 glyphs) noexcept { + if (interleaved == nullptr || vao_.id() == 0) { + instances_ = 0; + return; + } + buffer_.upload(interleaved, glyphs); + instances_ = glyphs; +} + +} // namespace plot::render diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1217f6e..44f743b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,7 @@ add_executable(plot_tests main.cpp test_transform.cpp test_gl_context.cpp + test_primitives.cpp test_ring_buffer.cpp test_minmax_pyramid.cpp test_series.cpp diff --git a/tests/test_gl_context.cpp b/tests/test_gl_context.cpp index aea067c..0ea1038 100644 --- a/tests/test_gl_context.cpp +++ b/tests/test_gl_context.cpp @@ -6,6 +6,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include @@ -201,3 +206,185 @@ PLOT_TEST(program_compiles_trivial_shaders) { PLOT_CHECK(broken.id() == 0); ctx.destroy(); } + +// The GLSL of both pipelines has to be accepted by the real driver: a typo that +// no CPU test can catch (a missing varying, a reserved word) fails here. +PLOT_TEST(shaders_compile_and_link) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + plot::Status s0; + plot::Status s1; + plot::gl::Program line = + plot::gl::Program::from_source(plot::render::kLineVS, plot::render::kLineFS, &s0); + plot::gl::Program text = + plot::gl::Program::from_source(plot::render::kTextVS, plot::render::kTextFS, &s1); + PLOT_CHECK(bool(s0)); + PLOT_CHECK(bool(s1)); + PLOT_CHECK(line.id() != 0); + PLOT_CHECK(text.id() != 0); + ctx.destroy(); +} + +// A renderer wrapper that was never successfully created must be a harmless +// no-op: Task 7 checks `create()`'s bool and then keeps drawing with whatever +// else it has, so the setters must not reach an empty program. +PLOT_TEST(render_programs_are_inert_until_created) { + plottest::HeadlessContext ctx; + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + + plot::render::LineProgram line; + plot::render::TextProgram text; + PLOT_CHECK(!line.valid()); + PLOT_CHECK(!text.valid()); + line.bind(); + line.set_scale({1.0f, 1.0f}); + line.set_offset({0.0f, 0.0f}); + line.set_viewport({256.0f, 256.0f}); + line.set_width(2.0f); + line.set_color(plot::Color::hex(0xFFFFFF)); + text.bind(); + text.set_viewport({256.0f, 256.0f}); + text.set_px_range({8.0f, 8.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); + ctx.destroy(); +} + +// End to end on the real driver: the instanced attribute layout, the +// triangle-strip expansion, the single y flip between Rect pixels and GL, and +// the AA ramp that makes a thin trace legible. +PLOT_TEST(line_pipeline_draws_an_aa_strip) { + plottest::HeadlessContext ctx(256, 256); + plot::Status st; + PLOT_CHECK(ctx.create(&st)); + PLOT_CHECK(plot::gl::load(nullptr)); + + plot::render::LineProgram program; + PLOT_CHECK(program.create(&st)); + plot::render::LineVertexArray lines; + PLOT_CHECK(lines.create(&st)); + + // Two instances, so a wrong attribute divisor cannot pass: with a divisor of + // zero the second quad would be assembled from the first instance's leftover + // attribute values instead of its own segment. Data space is pixels here, and + // u_offset lands the two axes at Rect y = 100 and Rect y = 120. + const f32 segments[8] = {0.0f, 0.0f, 40.0f, 0.0f, 0.0f, 20.0f, 40.0f, 20.0f}; + lines.upload(segments, 2); + PLOT_CHECK(lines.instance_count() == 2); + + 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_scale({1.0f, 1.0f}); + program.set_offset({64.0f, 100.0f}); + program.set_viewport({static_cast(ctx.width), static_cast(ctx.height)}); + program.set_width(3.0f); + program.set_color(plot::Color::hex(0xFF00FF)); + lines.bind(); + plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 2); + + plot::u8 px[4] = {}; + // Rect y = 100 is GL row 155.5, so rows 155 and 156 straddle the axis + // (d = 0.5), rows 154 and 157 are the half-covered AA fringe (d = 1.5), and + // row 152 is outside the quad. Getting the flip wrong, or applying it twice, + // moves the whole strip to the other half of the target. + PLOT_CHECK(ctx.read_pixel(84, 155, px)); + PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200); + PLOT_CHECK(ctx.read_pixel(84, 156, px)); + PLOT_CHECK(px[0] > 200 && px[2] > 200); + PLOT_CHECK(ctx.read_pixel(84, 157, px)); + PLOT_CHECK(px[0] > 60 && px[0] < 200); + PLOT_CHECK(ctx.read_pixel(84, 152, px)); + PLOT_CHECK(px[0] + px[2] < 40); + + // The second instance is where its own segment says: Rect y = 120 is rows + // 135/136, and the gap between the two strips stays background. + PLOT_CHECK(ctx.read_pixel(84, 135, px)); + PLOT_CHECK(px[0] > 200 && px[2] > 200); + PLOT_CHECK(ctx.read_pixel(84, 136, px)); + PLOT_CHECK(px[0] > 200 && px[2] > 200); + PLOT_CHECK(ctx.read_pixel(84, 145, px)); + PLOT_CHECK(px[0] + px[2] < 40); + // Past the end of the segment (flat cap) and far from both strips. + PLOT_CHECK(ctx.read_pixel(110, 155, px)); + PLOT_CHECK(px[0] + px[2] < 40); + PLOT_CHECK(ctx.read_pixel(128, 0, px)); + PLOT_CHECK(px[0] + px[2] < 40); + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + ctx.destroy(); +} + +// 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. +PLOT_TEST(text_pipeline_draws_a_glyph) { + using plot::render::BitmapFont; + plottest::HeadlessContext ctx(256, 256); + plot::Status st; + 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); + + 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) + PLOT_CHECK(batch.glyph_count() == 1); + glyphs.upload(batch.data().data(), batch.glyph_count()); + + 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)}); + 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_color(plot::Color::hex(0xFF00FF)); + atlas.bind(); + glyphs.bind(); + plot::gl::glDrawArraysInstanced(plot::gl::GL_TRIANGLE_STRIP, 0, 4, 1); + + // One cell is 8x8 texels and u_px_range is 8, so texel (gx, gy) lands on one + // target pixel: Rect x = 64 + gx, and Rect y = 100 + gy is GL row 155 - gy. + const plot::u8* rows = font.glyph_bitmap('A'); + plot::u8 px[4] = {}; + for (plot::u32 gy = 0; gy < BitmapFont::kGlyphPx; ++gy) { + for (plot::u32 gx = 0; gx < BitmapFont::kGlyphPx; ++gx) { + const bool ink = ((rows[gy] >> gx) & 1u) != 0u; + PLOT_CHECK(ctx.read_pixel(64u + gx, 155u - gy, px)); + if (ink) { + PLOT_CHECK(px[0] > 200 && px[1] < 60 && px[2] > 200); + } else { + PLOT_CHECK(px[0] < 40 && px[2] < 40); + } + } + } + PLOT_CHECK(plot::gl::glGetError() == plot::gl::GL_NO_ERROR); + ctx.destroy(); +} diff --git a/tests/test_primitives.cpp b/tests/test_primitives.cpp new file mode 100644 index 0000000..c0c1df8 --- /dev/null +++ b/tests/test_primitives.cpp @@ -0,0 +1,221 @@ +// CPU-side contract of the render layer: the staging batches that feed the +// instanced line and text pipelines, and the bitmap font's metrics and atlas. +// Nothing here touches GL, so it runs on any machine that can build the tests. +#include "harness.hpp" + +#include +#include + +#include + +PLOT_TEST(line_batch_emits_two_verts_per_segment) { + plot::render::LineBatch b; + b.begin(); + const plot::Vec2 pts[3] = {{0, 0}, {1, 1}, {2, 0}}; + b.add_polyline(pts, 3); // 2 segments + PLOT_CHECK(b.vertex_count() == 4); + b.clear(); + PLOT_CHECK(b.vertex_count() == 0); + PLOT_CHECK(b.data().capacity() >= 4); // clear() must not free capacity + b.add_segment({0, 0}, {1, 1}); + PLOT_CHECK(b.vertex_count() == 2); +} + +PLOT_TEST(line_batch_stages_segment_endpoints) { + plot::render::LineBatch b; + b.begin(); + b.add_segment({1.0f, 2.0f}, {3.0f, 4.0f}); + PLOT_CHECK(b.data().size() == 4); // x0,y0,x1,y1 for the one instance + PLOT_CHECK_NEAR(b.data()[0], 1.0, 0.0); + PLOT_CHECK_NEAR(b.data()[1], 2.0, 0.0); + PLOT_CHECK_NEAR(b.data()[2], 3.0, 0.0); + PLOT_CHECK_NEAR(b.data()[3], 4.0, 0.0); + + // A polyline stages one instance per consecutive pair, in order. + const plot::Vec2 pts[3] = {{0.0f, 0.0f}, {1.0f, 0.0f}, {1.0f, 1.0f}}; + b.begin(); + b.add_polyline(pts, 3); + PLOT_CHECK(b.data().size() == 8); + PLOT_CHECK_NEAR(b.data()[2], 1.0, 0.0); + PLOT_CHECK_NEAR(b.data()[4], 1.0, 0.0); + PLOT_CHECK_NEAR(b.data()[5], 0.0, 0.0); + PLOT_CHECK_NEAR(b.data()[6], 1.0, 0.0); + PLOT_CHECK_NEAR(b.data()[7], 1.0, 0.0); +} + +PLOT_TEST(line_batch_ignores_degenerate_polylines) { + plot::render::LineBatch b; + b.begin(); + const plot::Vec2 one[1] = {{5.0f, 5.0f}}; + b.add_polyline(one, 1); // no pair -> no instance + b.add_polyline(nullptr, 0); + PLOT_CHECK(b.vertex_count() == 0); + PLOT_CHECK(b.data().empty()); + b.add_polyline(nullptr, 3); // null with a count: still no deref + PLOT_CHECK(b.vertex_count() == 0); +} + +PLOT_TEST(line_batch_reuses_capacity_across_frames) { + plot::render::LineBatch b; + const plot::Vec2 pts[5] = {{0, 0}, {1, 1}, {2, 0}, {3, 1}, {4, 0}}; + b.begin(); + b.add_polyline(pts, 5); // 4 segments -> 16 floats + const std::size_t cap = b.data().capacity(); + const f32* store = b.data().data(); + PLOT_CHECK(cap >= 16); + + b.clear(); + PLOT_CHECK(b.vertex_count() == 0); + PLOT_CHECK(b.data().capacity() == cap); // clear() keeps the store + + b.begin(); + b.add_polyline(pts, 5); // same workload: must not reallocate + PLOT_CHECK(b.data().size() == 16); + PLOT_CHECK(b.data().capacity() == cap); + PLOT_CHECK(b.data().data() == store); +} + +PLOT_TEST(text_batch_measures_ascii) { + plot::render::BitmapFont f; + const f32 w = f.measure("12.5 ms"); + PLOT_CHECK(w > 0.0f); + PLOT_CHECK_NEAR(f.measure(""), 0.0, 1e-6); + PLOT_CHECK(f.measure("ab") < f.measure("abc")); // monotonic in length + PLOT_CHECK(f.measure("iiii") < f.measure("WWWW")); // proportional-ish, not fixed-width-blank +} + +PLOT_TEST(bitmap_font_advance_follows_ink_extent) { + plot::render::BitmapFont f; + PLOT_CHECK(f.advance('i') < f.advance('W')); + // Whitespace is a blank glyph with a nonzero advance, never zero. + PLOT_CHECK(f.advance(' ') >= 2.0f); + // Advance is ink width + 1px of spacing, floored at 2px: derived here from + // the same bitmaps the atlas uploads, so a second width table cannot drift. + auto ink_width = [&f](char c) { + const plot::u8* rows = f.glyph_bitmap(c); + int lo = 8; + int hi = -1; + for (plot::u32 y = 0; y < f.glyph_rows(c); ++y) { + for (int x = 0; x < 8; ++x) { + if ((rows[y] >> x) & 1u) { + if (x < lo) lo = x; + if (x > hi) hi = x; + } + } + } + return hi < 0 ? 0 : hi - lo + 1; + }; + const char* samples = "iW.1_m"; + for (const char* c = samples; *c != '\0'; ++c) { + const int ink = ink_width(*c); + const f32 want = ink + 1 < 2 ? 2.0f : static_cast(ink + 1); + PLOT_CHECK_NEAR(f.advance(*c), want, 1e-6); + } + // measure() is exactly the sum of the glyph advances it stages. + f32 sum = 0.0f; + for (const char* c = "12.5 ms"; *c != '\0'; ++c) sum += f.advance(*c); + PLOT_CHECK_NEAR(f.measure("12.5 ms"), sum, 1e-6); +} + +PLOT_TEST(bitmap_font_decodes_si_units_and_falls_back_visibly) { + plot::render::BitmapFont f; + // plot::format_si emits U+00B5 MICRO SIGN, U+00B0 DEGREE SIGN and U+03A9 OHM. + const f32 micro = f.measure("\xC2\xB5s"); + const f32 degree = f.measure("\xC2\xB0"); + const f32 ohm = f.measure("\xCE\xA9"); + PLOT_CHECK(micro > 0.0f); + PLOT_CHECK(degree > 0.0f); + PLOT_CHECK(ohm > 0.0f); + PLOT_CHECK_NEAR(micro, f.advance_codepoint(0x00B5u) + f.advance('s'), 1e-6); + PLOT_CHECK_NEAR(degree, f.advance_codepoint(0x00B0u), 1e-6); + PLOT_CHECK_NEAR(ohm, f.advance_codepoint(0x03A9u), 1e-6); + // Those three map to their own renderable glyph, not to the missing-glyph box. + const plot::u32 fallback = f.glyph_index(0x4E00u); // CJK, deliberately absent + PLOT_CHECK(f.glyph_index(0x00B5u) != fallback); + PLOT_CHECK(f.glyph_index(0x00B0u) != fallback); + PLOT_CHECK(f.glyph_index(0x03A9u) != fallback); + PLOT_CHECK(f.glyph_index(0x00B5u) != f.glyph_index(0x00B0u)); + PLOT_CHECK(f.glyph_index(0x00B0u) != f.glyph_index(0x03A9u)); + // Anything else non-ASCII is a visible box with a nonzero advance, and a + // malformed UTF-8 byte is treated the same way rather than measured as zero. + PLOT_CHECK_NEAR(f.measure("\xE4\xB8\x80"), f.advance_codepoint(0x4E00u), 1e-6); + PLOT_CHECK(f.advance_codepoint(0x4E00u) > 0.0f); + PLOT_CHECK(f.advance(static_cast(0x80)) > 0.0f); + PLOT_CHECK_NEAR(f.advance(static_cast(0x80)), f.advance_codepoint(0x4E00u), 1e-6); + // The fallback box glyph has ink; a blank bitmap would hide the problem. + bool any_ink = false; + const plot::u8* box = plot::render::BitmapFont::glyph_bitmap_of(fallback); + for (plot::u32 y = 0; y < plot::render::BitmapFont::kGlyphPx; ++y) { + any_ink = any_ink || box[y] != 0; + } + PLOT_CHECK(any_ink); +} + +PLOT_TEST(bitmap_font_atlas_is_uploadable) { + using plot::render::BitmapFont; + PLOT_CHECK(BitmapFont::kAtlasWidth == BitmapFont::kAtlasCols * BitmapFont::kGlyphPx); + PLOT_CHECK(BitmapFont::kAtlasHeight % BitmapFont::kGlyphPx == 0); + PLOT_CHECK(BitmapFont::kAtlasCols * BitmapFont::kAtlasRows >= BitmapFont::kGlyphCount); + PLOT_CHECK(BitmapFont::kAsciiGlyphs == 95); + + plot::render::BitmapFont f; + PLOT_CHECK(f.glyph_rows('A') == 8); + PLOT_CHECK(f.height() > 0.0f); + PLOT_CHECK(f.ascent() > 0.0f); + PLOT_CHECK(f.ascent() <= f.height()); + + // Each glyph occupies its own atlas cell, and that cell is the bitmap byte + // for byte: 255 where a row bit is set, 0 elsewhere (straight alpha). + const plot::u8* atlas = BitmapFont::atlas_image(); + const plot::u32 slots[] = {'A' - 32, 'g' - 32, 'i' - 32, 'W' - 32, 0, '.' - 32, + BitmapFont::kFallbackSlot, BitmapFont::kMicroSlot}; + for (const plot::u32 slot : slots) { + const plot::Vec2 o = BitmapFont::atlas_origin(slot); + const plot::u8* rows = BitmapFont::glyph_bitmap_of(slot); + for (plot::u32 y = 0; y < BitmapFont::kGlyphPx; ++y) { + for (plot::u32 x = 0; x < BitmapFont::kGlyphPx; ++x) { + const plot::u8 want = ((rows[y] >> x) & 1u) != 0u ? 0xFFu : 0x00u; + const auto row = static_cast(o.y) + y; + const auto col = static_cast(o.x) + x; + PLOT_CHECK(atlas[row * BitmapFont::kAtlasWidth + col] == want); + } + } + } + // The atlas is a whole number of cells and no glyph leaves its cell. + PLOT_CHECK(BitmapFont::kAtlasWidth == 16u * BitmapFont::kGlyphPx); +} + +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); + PLOT_CHECK(b.glyph_count() == 7); + PLOT_CHECK(b.data().size() == 28); // x,y,u,v 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()[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); + + // 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(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. + b.begin(); + const plot::Vec2 scaled = b.add_text({10.0f, 4.0f}, "AW", f, 16.0f); + 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 +}