feat(render): style, GLSL line/text pipelines, staging batches, bitmap font
This commit is contained in:
1 parent
9a086b7d69
commit
7cbc1c9955
22 files changed
+1535
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) \
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#include <plot/render/bitmap_font.hpp>
|
||||
|
||||
#include <array>
|
||||
|
||||
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<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> pack_atlas() noexcept {
|
||||
std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> 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<u8>(((bits >> x) & 1u) != 0u ? 0xFFu : 0x00u);
|
||||
}
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
constexpr std::array<u8, BitmapFont::kAtlasWidth * BitmapFont::kAtlasHeight> 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<u32>(static_cast<unsigned char>(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<u32>(static_cast<unsigned char>(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<f32>(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<f32>(kGlyphPx); }
|
||||
|
||||
f32 BitmapFont::ascent() const { return static_cast<f32>(kGlyphPx - 1u); }
|
||||
|
||||
f32 BitmapFont::advance(char c) const noexcept {
|
||||
return advances_[glyph_index(static_cast<u32>(static_cast<unsigned char>(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<u32>(static_cast<unsigned char>(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<f32>((s % kAtlasCols) * kGlyphPx),
|
||||
static_cast<f32>((s / kAtlasCols) * kGlyphPx)};
|
||||
}
|
||||
|
||||
} // namespace plot::render
|
||||
@@ -0,0 +1,32 @@
|
||||
#include <plot/render/buffer.hpp>
|
||||
|
||||
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<usize>(count) * instance_bytes, gl::GL_STREAM_DRAW);
|
||||
capacity_ = count;
|
||||
}
|
||||
buffer_.upload_sub(data, static_cast<usize>(count) * instance_bytes, 0);
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
} // namespace plot::render
|
||||
@@ -0,0 +1,61 @@
|
||||
#include <plot/render/primitives.hpp>
|
||||
|
||||
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<f32>(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
|
||||
@@ -0,0 +1,50 @@
|
||||
#include <plot/render/program.hpp>
|
||||
|
||||
#include <plot/render/shaders.hpp>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,110 @@
|
||||
#include <plot/render/shaders.hpp>
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,76 @@
|
||||
#include <plot/render/vertex_array.hpp>
|
||||
|
||||
#include <plot/gl/api.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
#include <plot/gl/caps.hpp>
|
||||
#include <plot/gl/handle.hpp>
|
||||
#include <plot/gl/loader.hpp>
|
||||
#include <plot/render/bitmap_font.hpp>
|
||||
#include <plot/render/primitives.hpp>
|
||||
#include <plot/render/program.hpp>
|
||||
#include <plot/render/shaders.hpp>
|
||||
#include <plot/render/vertex_array.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -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<plot::gl::GLsizei>(ctx.width),
|
||||
static_cast<plot::gl::GLsizei>(ctx.height));
|
||||
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
|
||||
plot::gl::glEnable(plot::gl::GL_BLEND);
|
||||
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
program.bind();
|
||||
program.set_scale({1.0f, 1.0f});
|
||||
program.set_offset({64.0f, 100.0f});
|
||||
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(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<plot::gl::GLsizei>(ctx.width),
|
||||
static_cast<plot::gl::GLsizei>(ctx.height));
|
||||
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
|
||||
plot::gl::glEnable(plot::gl::GL_BLEND);
|
||||
plot::gl::glBlendFunc(plot::gl::GL_SRC_ALPHA, plot::gl::GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
program.bind();
|
||||
program.set_viewport({static_cast<f32>(ctx.width), static_cast<f32>(ctx.height)});
|
||||
program.set_px_range({static_cast<f32>(BitmapFont::kGlyphPx), static_cast<f32>(BitmapFont::kGlyphPx)});
|
||||
program.set_texture_size({static_cast<f32>(BitmapFont::kAtlasWidth),
|
||||
static_cast<f32>(BitmapFont::kAtlasHeight)});
|
||||
program.set_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();
|
||||
}
|
||||
@@ -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 <plot/render/bitmap_font.hpp>
|
||||
#include <plot/render/primitives.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
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<f32>(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<char>(0x80)) > 0.0f);
|
||||
PLOT_CHECK_NEAR(f.advance(static_cast<char>(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<std::size_t>(o.y) + y;
|
||||
const auto col = static_cast<std::size_t>(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<plot::u32>('.') - 32u);
|
||||
PLOT_CHECK_NEAR(b.data()[10], dot_uv.x, 1e-6);
|
||||
PLOT_CHECK_NEAR(b.data()[11], dot_uv.y, 1e-6);
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user