feat(render): style, GLSL line/text pipelines, staging batches, bitmap font

This commit is contained in:
Martino Ferrari committed 2026-09-27 22:03:56 +02:00
1 parent 9a086b7d69
commit 7cbc1c9955
22 files changed
+1535

No files matched your search

+1
View File
@@ -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;
+2
View File
@@ -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)) \
+8
View File
@@ -1,4 +1,5 @@
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/api.hpp>
@@ -103,6 +104,13 @@ class Program final : public Handle<Program> {
/// 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;
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/text.hpp>
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
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
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
+87
View File
@@ -0,0 +1,87 @@
#pragma once
#include <plot/core/types.hpp>
#include <plot/math/vec2.hpp>
#include <plot/render/bitmap_font.hpp>
#include <vector>
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<u32>(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<f32>& data() const noexcept { return v_; }
private:
std::vector<f32> 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<f32>(BitmapFont::kGlyphPx));
/// Glyphs staged so far, one instance each.
u32 glyph_count() const noexcept { return static_cast<u32>(v_.size() / 4u); }
/// Interleaved `x,y,u,v`, four floats per staged glyph.
const std::vector<f32>& data() const noexcept { return v_; }
private:
std::vector<f32> v_;
};
} // namespace plot::render
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <plot/core/color.hpp>
#include <plot/core/status.hpp>
#include <plot/gl/handle.hpp>
#include <plot/math/vec2.hpp>
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
+41
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <plot/core/color.hpp>
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
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <plot/core/types.hpp>
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
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <plot/core/status.hpp>
#include <plot/core/types.hpp>
#include <plot/gl/handle.hpp>
#include <plot/render/buffer.hpp>
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