fix(render): filter the glyph atlas, snap chrome hairlines to pixels

Text was point-sampled (`GL_NEAREST` plus `floor(v_uv * u_tex_size)`), which
at a non-integer scale *deletes* texels instead of blending them. The library's
own default -- a 12 px style over the 13 px embedded bake -- dropped one of
every 8 columns and one of every 17 rows of every glyph, so whole strokes went
missing and which ones depended on where the unsnapped pen fell. `kTextFS` now
does a 2x2 bilinear fetch clamped to the instance's own cell: the clamp keeps
the no-bleed guarantee the sampler mode protects (the atlas is a tight strip of
cells with no padding), and at scale 1 with a whole pen the weights are exactly
0, so it stays as crisp as the point sample.

Grid and axis lines were staged in data space and mapped in the vertex shader,
so a 1 px line's centre landed on an arbitrary fraction -- x = 100.0 lights two
columns at half coverage, x = 100.5 lights one crisply, and a frame mixed both.
`u_snap` rounds an axis-aligned segment's ink *leading edge* onto a pixel
boundary, which reduces to integer+0.5 for odd widths and to an integer for
even ones with no case analysis. It is on for the chrome that frames a plot and
off for anything drawn at a data position, since the rounding moves a line by
up to half a pixel. Diagonals are untouched.

`text_pixel`'s threshold drops to 160 because filtered glyphs legitimately peak
at 0.844 coverage at 12/13; the 180 threshold found text only while the sampler
was also deleting a column and a row of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-02 00:22:08 +02:00
1 parent 98cfc5805b
commit 525759f9a9
8 files changed
+295 -25

No files matched your search

+151 -8
View File
@@ -70,7 +70,16 @@ bool magenta_pixel(const plot::u8 px[4]) { return px[0] > px[1] + 60 && px[2] >
bool band_pixel(const plot::u8 px[4]) {
return px[0] > 35 && px[0] > px[1] && px[1] > px[2];
}
bool text_pixel(const plot::u8 px[4]) { return px[0] > 180 && px[1] > 180 && px[2] > 180; }
/// A chalk pixel: `axis_text` (0xC8D0DC) at close to full coverage, which nothing
/// else in these frames reaches -- the brightest other element is `axis_line`
/// (0x8A94A6), whose dimmest channel is 138.
///
/// The threshold is 160 and not the colour's own 200-220 because the text pipeline
/// filters the atlas: at the default 12 px style on the 13 px bake the brightest
/// fragment of a glyph is 0.844 covered, which puts `axis_text`'s green at 179. A
/// 180 threshold found text only while the sampler was a point fetch, i.e. only
/// while it was also deleting a column and a row of every glyph.
bool text_pixel(const plot::u8 px[4]) { return px[0] > 160 && px[1] > 160 && px[2] > 160; }
/// A grey marker: the annotation colour at any coverage, which no data or chrome
/// colour here matches (they are either dark or strongly coloured).
bool marker_pixel(const plot::u8 px[4]) {
@@ -199,6 +208,25 @@ class BakeableBackend final : public plot::render::TextBackend {
++revision_;
}
/// Bakes a `size` x `size` atlas of alternating ink and blank texels, and bumps
/// the revision. It is the artwork that separates a *filtered* sampler from a
/// point one without depending on any face's shapes: halve the scale and a
/// filter must average every 2x2 to mid grey, while a point fetch lands on one
/// parity for every fragment and returns a solid block.
void bake_checkerboard(plot::u32 size) {
size_ = size;
pixels_.assign(static_cast<std::size_t>(size) * size, 0u);
for (plot::u32 y = 0; y < size; ++y) {
for (plot::u32 x = 0; x < size; ++x) {
if (((x + y) & 1u) == 0u) {
pixels_[static_cast<std::size_t>(y) * size + x] = 255u;
}
}
}
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(size)};
++revision_;
}
/// Trims the published glyph's rect to an ink box, which is what breaks the
/// fixed-cell grid (see `TextBackend::cell_uv`): the rect is no longer the cell
/// `cell_uv()` declares, so the staging path's check fails. Used by the case
@@ -990,6 +1018,56 @@ PLOT_TEST(draws_cursors_measure_band_and_annotations) {
ctx.destroy();
}
// The grid is on the pixel grid. Its lines are staged in data space like every
// other line and mapped by the vertex shader, so a 1 px stroke's centre used to
// land wherever the view put it: a line at an x that maps to 100.0 spans
// [99.5, 100.5] and lights two columns at half coverage, while one that maps to
// 100.5 spans [100, 101] and lights one fully. A frame's gridlines were a mix of
// both -- a grey smear beside a crisp hairline -- and panning made each shimmer
// between the two. `LineProgram::set_snap()` rounds an axis-aligned segment's ink
// onto whole pixels, so the observable is sharp: **every lit pixel inside the axes
// rect is the grid colour exactly**, with no half-coverage blend anywhere.
//
// No trace and no minor grid, so the only thing drawn inside the rect is the major
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
// its own edges, which are a different colour, stay out of the scan.
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
r.style().grid_minor_visible = false;
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
p.add_x_axis({"seconds", {0.0, 10.0}, true, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::Rect& a = p.layout().axes;
const plot::Color grid = r.style().grid_major;
const plot::Color background = r.style().background;
plot::u32 lit = 0;
for (plot::u32 row = 3u; row + 3u < static_cast<plot::u32>(a.h); ++row) {
for (plot::u32 column = 3u; column + 3u < static_cast<plot::u32>(a.w); ++column) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
if (pixel_is(px, background)) continue;
PLOT_CHECK(pixel_is(px, grid)); // full coverage, not a blend of grid and background
++lit;
}
}
PLOT_CHECK(lit > static_cast<plot::u32>(a.h)); // a grid was drawn at all
ctx.destroy();
}
// Finding 2 of the review: `draw_titles()` placed both titles inside
// `layout.axes`, because the layout reserved no room outside it for them, so in
// the dense frame -- whose envelope spans every column's full height -- a title
@@ -1027,31 +1105,39 @@ PLOT_TEST(titles_stay_out_of_the_axes_band) {
const plot::Color title = r.style().axis_text;
// Reads a Rect -- whose rows run top-down, like every Rect here -- and reports
// whether any pixel in it is the title colour.
auto area_has_title = [&](const plot::Rect& area) {
// whether any pixel in it holds the title colour by `pred`'s reading of it. The
// two directions of this test need different readings, and for the reason
// `rect_has_exact` below is written for: inside `axes` only the *exact* colour
// means "a title is here", because a third of a chalk tone is under the axis
// stroke's own grey, so an ink scan there would find the axis; in the reserved
// strips, where nothing but the title draws, the ink scan is the one that can
// see a filtered glyph at all (see `text_ink`).
auto area_has_title = [&](const plot::Rect& area, auto pred) {
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
if (pixel_is(px, title)) return true;
if (pred(px, title)) return true;
}
}
return false;
};
const auto exact = [](const plot::u8* px, plot::Color c) { return pixel_is(px, c); };
const auto ink = [](const plot::u8* px, plot::Color c) { return text_ink(px, c); };
// One text line plus the two pixels the old placement inset itself by: tall
// enough for the whole title line that used to be drawn there.
const plot::f32 band = r.axis_font_px() + 2.0f;
PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band}));
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}));
PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band}, exact));
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}, exact));
// ... while the strips the layout reserved hold both titles.
PLOT_CHECK(layout.x_title_area.h > 0.0f);
PLOT_CHECK(layout.y_title_area.w > 0.0f);
PLOT_CHECK(layout.axes.bottom() <= layout.x_title_area.y); // outside the data
PLOT_CHECK(area_has_title(layout.x_title_area));
PLOT_CHECK(area_has_title(layout.y_title_area));
PLOT_CHECK(area_has_title(layout.x_title_area, ink));
PLOT_CHECK(area_has_title(layout.y_title_area, ink));
ctx.destroy();
}
@@ -2097,6 +2183,63 @@ PLOT_TEST(atlas_is_reuploaded_when_the_backend_revision_changes) {
ctx.destroy();
}
// The text pipeline filters the atlas; it does not point-sample it. The two halves
// of the old behaviour -- `GL_NEAREST` plus one `floor(v_uv * u_tex_size)` fetch,
// and `AxisFont` drawing the atlas at `requested_px / em_px` -- were each
// reasonable and jointly wrong: a point fetch at a non-integer scale *deletes*
// texels instead of blending them, and the library's own default is a 12 px style
// on the 13 px embedded bake, which dropped one of every 8 columns and one of every
// 17 rows of every glyph. Whole strokes, and which stroke went depended on where the
// unsnapped pen fell.
//
// A checkerboard is the artwork that pins it without depending on a face's shapes.
// At half scale every fragment's footprint spans a 2x2 of it, so a filter must
// return mid grey everywhere, while a point fetch lands on one parity for every
// fragment and returns a solid block -- a 255 or a 0, not a 128. And at scale 1 with
// a whole pen the two taps coincide (`f == 0` exactly), so the second half of the
// case is that filtering cost nothing there: the checkerboard comes back as a
// checkerboard, every pixel either full ink or none.
PLOT_TEST(the_atlas_is_filtered_not_point_sampled) {
plottest::HeadlessContext ctx(64, 64);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
const plot::Vec2 pen{8.0f, 8.0f}; // whole pixels: the best case for a point fetch
BakeableBackend backend;
backend.bake_checkerboard(8u);
plot::render::TextAtlas atlas;
auto red = [&](plot::u32 x, plot::u32 y) {
plot::u8 pxv[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, pxv));
return static_cast<int>(pxv[0]);
};
// Scale 1: an 8x8 quad, and the checkerboard survives as itself.
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 1.0f, {1.0f, 0.0f}).any());
int crisp = 0;
for (plot::u32 row = 0; row < 8u; ++row) {
for (plot::u32 column = 0; column < 8u; ++column) {
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
PLOT_CHECK(v < 8 || v > 247); // one texel or the other, never a blend of both
if (v > 247) ++crisp;
}
}
PLOT_CHECK(crisp == 32); // half the cell, which is what a checkerboard is
// Scale 0.5: a 4x4 quad, and every pixel of it is the average of a 2x2 of the
// checkerboard. A point fetch gives 255 or 0 here, so the band is the assertion.
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 0.5f, {1.0f, 0.0f}).any());
for (plot::u32 row = 0; row < 4u; ++row) {
for (plot::u32 column = 0; column < 4u; ++column) {
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
PLOT_CHECK(v > 96 && v < 160);
}
}
ctx.destroy();
}
// "No text" is a legal backend, not a failure: a null atlas, or one with no
// size, must sync cleanly, leave the atlas owner empty, and be *replaced* on the
// GPU as soon as the backend publishes one -- including by releasing the texture