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
@@ -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
+187
View File
@@ -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();
}
+221
View File
@@ -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
}