diff --git a/include/plot/render/program.hpp b/include/plot/render/program.hpp index 28e3444..ac0f96f 100644 --- a/include/plot/render/program.hpp +++ b/include/plot/render/program.hpp @@ -40,6 +40,14 @@ class LineProgram { void set_aa(f32 px) const noexcept; /// `u_color`: stroke colour, alpha included. void set_color(Color color) const noexcept; + /// `u_snap`: with it on, an **axis-aligned** segment's ink is rounded onto whole + /// pixels along the axis it is constant on, so a hairline lights one column (or + /// row) fully instead of two at half coverage. It is for the chrome that frames a + /// plot -- grid lines, axis lines, panel and legend borders. Anything that reports + /// a data position -- a trace, a cursor, a region edge, the crosshair -- leaves it + /// off, because the rounding moves a line by up to half a pixel. A diagonal segment + /// is untouched whatever this says. + void set_snap(bool on) const noexcept; private: gl::Program program_; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 7fe7f5d..fdbe4fc 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -397,7 +397,13 @@ class Renderer { /// crosshair passes and a dashed trace -- means **solid**. It is set on every flush, /// so one draw's dash can never leak into the next. The AA fringe (`u_aa`) is /// likewise set on every flush, from `style().line_aa_px` in device pixels. - void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}) noexcept; + /// + /// `snap` is `LineProgram::set_snap()`'s pixel-grid rounding: on for the chrome + /// that frames a plot -- the grid, the axis lines, a panel or legend border -- + /// and off for everything drawn at a data position, since it moves an + /// axis-aligned line by up to half a pixel. Set on every flush, like `dash`. + void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{}, + bool snap = false) noexcept; /// Draws every staged glyph in `batch` -- each instance carries its own tint /// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs /// once per text batch, after the line geometry it belongs over. diff --git a/include/plot/render/shaders.hpp b/include/plot/render/shaders.hpp index 7ec1f9c..763999a 100644 --- a/include/plot/render/shaders.hpp +++ b/include/plot/render/shaders.hpp @@ -44,16 +44,27 @@ extern const char* const kLineFS; /// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that /// advances up the screen, and it maps `a_uv` over the cell with the same /// handedness, so a glyph's top row stays its top row however the cell is -/// turned. The fragment shader converts back to texels and samples the -/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST` -/// never lands on a cell boundary. The instance's own `a_colour` tints the +/// turned. It also passes the cell's origin through `flat`, which is the bound +/// the fragment shader filters against. +/// +/// The fragment shader converts back to texels and blends the four texels the +/// fragment sits between, **clamped to the instance's own cell**. Filtering is +/// the shader's, not the sampler's: every fetch is an explicit texel centre, so +/// `GL_NEAREST` stays correct, and the clamp keeps a footprint reaching past a +/// cell edge from blending in the neighbouring glyph of what is a tight, +/// unpadded strip of cells. A point sample would have been enough only at an +/// integer scale: at the library's own default of a 12 px style on the 13 px +/// embedded bake it *deleted* one of every 8 columns and one of every 17 rows of +/// every glyph. At scale 1 with a whole pen the two taps coincide and the result +/// is exactly as crisp as a point sample. The instance's own `a_colour` tints the /// straight alpha -- per vertex, so one draw can carry runs of several colours. /// /// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, the glyph cell's /// size on screen in pixels), `u_glyph_uv` (vec2, the glyph cell's size in /// normalised atlas coordinates -- one size for the whole draw, since every -/// glyph of a frame comes from one atlas), `u_tex_size` (vec2, atlas size in -/// texels), `u_atlas` (sampler2D, unit 0). +/// glyph of a frame comes from one atlas; read in *both* stages, the fragment +/// one needing it for the clamp), `u_tex_size` (vec2, atlas size in texels), +/// `u_atlas` (sampler2D, unit 0). extern const char* const kTextVS; extern const char* const kTextFS; diff --git a/src/render/program.cpp b/src/render/program.cpp index e0f3cd5..05bb266 100644 --- a/src/render/program.cpp +++ b/src/render/program.cpp @@ -34,6 +34,10 @@ void LineProgram::set_color(Color color) const noexcept { program_.set_uniform(" void LineProgram::set_aa(f32 px) const noexcept { program_.set_uniform("u_aa", px); } +void LineProgram::set_snap(bool on) const noexcept { + program_.set_uniform("u_snap", on ? 1.0f : 0.0f); +} + bool TextProgram::create(Status* st) noexcept { program_ = gl::Program::from_source(kTextVS, kTextFS, st); if (program_.id() == 0) { diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index f44769e..f35e52f 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -621,7 +621,8 @@ void Renderer::upload_text_vertices(std::span interleaved) noexcept { text_vao_.upload(interleaved.data(), static_cast(interleaved.size() / 10u)); } -void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash) noexcept { +void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash, + bool snap) noexcept { const u32 segments = lines_.segment_count(); if (segments != 0 && ready_) { upload_vertices(lines_.data()); @@ -633,6 +634,9 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash // Set on *every* flush, solid included, so the crosshair's dash cannot leak into the // grid, a trace or a cursor drawn after it (see draw_crosshair_lines()). line_program_.set_dash(dash); + // Likewise set on every flush: the grid's snap must not follow the trace drawn + // after it onto the pixel grid. + line_program_.set_snap(snap); line_program_.set_color(color); // The AA fringe, too: the style's logical knob in device pixels, clamped so // a 0 style still keeps a quarter pixel of ramp. 1.0 -- the default -- is @@ -811,7 +815,9 @@ void Renderer::draw_grid(const Plot& plot) { if (line.major != major) continue; // grid.build() already dropped non-finite ones stage_segment(x_range.min, line.x0, line.y0, line.x1, line.y1, log_x, log_y); } - flush_lines(map, color, width); + // Snapped: a grid line is chrome at a round *data* value, and a hairline that + // straddles two columns reads as a grey smear and shimmers as the view pans. + flush_lines(map, color, width, Vec2{}, true); }; draw_class(true, style_.grid_major_visible, style_.grid_major, style_.device(style_.grid_major_width)); @@ -835,14 +841,18 @@ void Renderer::draw_axis_lines(const Plot& plot) { const Axis& axis = plot.x_axis(i); const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min; stage_segment(axis.range.min, axis.range.min, y, axis.range.max, y, axis.log, log_y0); + // Snapped, like the grid: an axis line is the frame of the plot, and it is the + // one line a reader compares every other against, so a smeared one is the worst + // place for the half-coverage pair. flush_lines(mapping(plot, i, 0, axis.range.min), style_.axis_line, - style_.device(style_.line_width_px)); + style_.device(style_.line_width_px), Vec2{}, true); } for (u32 i = 0; i < plot.y_axis_count(); ++i) { const Axis& axis = plot.y_axis(i); const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min; stage_segment(x, x, axis.range.min, x, axis.range.max, log_x0, axis.log); - flush_lines(mapping(plot, 0, i, x), style_.axis_line, style_.device(style_.line_width_px)); + flush_lines(mapping(plot, 0, i, x), style_.axis_line, + style_.device(style_.line_width_px), Vec2{}, true); } } diff --git a/src/render/shaders.cpp b/src/render/shaders.cpp index d585aca..9702f3a 100644 --- a/src/render/shaders.cpp +++ b/src/render/shaders.cpp @@ -20,6 +20,7 @@ uniform vec2 u_offset; // pixels uniform vec2 u_viewport; // render target size, pixels uniform float u_width; // stroke width, pixels uniform float u_aa; // AA fringe half-width, pixels +uniform float u_snap; // non-zero: put an axis-aligned segment's ink on whole pixels out float v_dist; // signed distance to the segment axis, pixels // Distance travelled from a_p0, pixels -- and the dash's phase anchor. Both of the @@ -32,6 +33,40 @@ out float v_along; void main() { vec2 p0 = a_p0 * u_scale + u_offset; vec2 p1 = a_p1 * u_scale + u_offset; + + // The pixel-grid snap, for the chrome that *frames* the plot: a grid line, an + // axis line, a panel or legend border. Those are staged in data space like + // everything else, so a 1 px stroke's centre lands wherever the mapping puts it + // -- at x = 100.0 the ink spans [99.5, 100.5] and lights two columns at half + // coverage each (a grey smear), at x = 100.5 it spans [100, 101] and lights one + // crisply. Which of the two a given line got depended on the view, so a frame's + // gridlines were a mix of both and panning made each shimmer between them. + // + // Snapping the ink's *leading edge* to a pixel boundary -- rather than the + // centre to a half pixel -- is the form that holds for any width: it reduces to + // integer + 0.5 for an odd stroke and to an integer for an even one, with no + // case analysis. `u_aa` is deliberately not in `ink`: the fringe is symmetric + // about the ink and snapping with it folded in would bias the line by half a + // fringe. + // + // Only for an axis-aligned segment, and only on the axis it is constant along: + // a diagonal has no single perpendicular to round, and moving either end of one + // by a part of a pixel would tilt it. That restriction is also why this is safe + // to leave on for a whole draw -- the grid and the axis lines are nothing *but* + // axis-aligned segments. Traces, cursors, regions and the crosshair pass 0: a + // line that reports a data position may not be moved off it, however little. + if (u_snap != 0.0) { + float ink = u_width * 0.5; + vec2 snapped = floor(p0 - vec2(ink) + vec2(0.5)) + vec2(ink); + if (abs(p1.x - p0.x) < 1e-4) { // vertical: round x, leave y + p0.x = snapped.x; + p1.x = snapped.x; + } else if (abs(p1.y - p0.y) < 1e-4) { // horizontal: round y, leave x + p0.y = snapped.y; + p1.y = snapped.y; + } + } + vec2 d = p1 - p0; float len = length(d); float half_w = u_width * 0.5 + u_aa; // + the AA fringe @@ -112,6 +147,10 @@ uniform vec2 u_glyph_uv; // glyph cell in the atlas, normalised (width, height) out vec2 v_uv; // normalised atlas coordinate of this fragment out vec4 v_colour; // the instance's tint +// This glyph's cell origin, flat: the fragment shader filters inside the cell and +// has to know where it starts. Interpolating it would make the bound it clamps +// against drift across the quad, which is the one thing the clamp is there to stop. +flat out vec2 v_cell0; void main() { // The cell's two axes: the run direction the instance carries and its @@ -131,24 +170,67 @@ void main() { // way round: u runs along the basis, v down the glyph. v_uv = a_uv + corner * u_glyph_uv; v_colour = a_colour; + v_cell0 = a_uv; } )"; const char* const kTextFS = R"(#version 330 core in vec2 v_uv; // normalised atlas coordinate of this fragment in vec4 v_colour; // the instance's tint +flat in vec2 v_cell0; // this glyph's cell origin, normalised -uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST -uniform vec2 u_tex_size; // atlas size in texels +uniform sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST +uniform vec2 u_tex_size; // atlas size in texels +uniform vec2 u_glyph_uv; // the cell's size in normalised atlas coordinates out vec4 o_color; +/// Coverage of one texel, addressed by its integer index. Every fetch in here is +/// an explicit texel centre, which is why the sampler can stay `GL_NEAREST`: the +/// filtering below is this shader's, not the sampler's, so no fetch can tie at a +/// texel boundary and no fetch can be steered outside the cell clamped for it. +float tap(vec2 texel) { + return texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r; +} + 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_uv * u_tex_size), vec2(0.0), u_tex_size - vec2(1.0)); - float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r; + // Bilinear over the four texels the fragment sits between, **clamped to this + // glyph's own cell**. + // + // It used to be a single point sample of the texel the fragment landed in, and + // the two halves of that -- a point sample, and `AxisFont` drawing the atlas at + // `requested_px / em_px` -- are each reasonable and jointly wrong: a point + // sample at a non-integer scale *deletes* texels rather than blending them. The + // library's own default is a 12 px style on the 13 px embedded bake, i.e. 12/13, + // which dropped one of every 8 columns and one of every 17 rows of every glyph + // -- whole strokes, not softened edges, and which stroke went depended on where + // the unsnapped pen happened to fall. Filtering costs four fetches of a texture + // that is one row of cells and is in cache for the whole pass. + // + // The clamp is what keeps the old guarantee: the atlas is a tight strip of cells + // with no padding between them, so a footprint reaching past a cell edge would + // blend in the neighbouring glyph. Clamping to the cell's own texel range + // replicates its edge instead, and a cell's border texels are blank (the cell is + // the face's widest advance by its full ascent-to-descent extent), so the + // replication is of nothing. + // + // `floor(x + 0.5)` on both bounds, not a raw product: they come back from a + // normalised coordinate times a size, so an exact texel index arrives a few ULP + // either side of itself, and a `floor` of 191.99999 is off by a whole texel. + vec2 lo = floor(v_cell0 * u_tex_size + vec2(0.5)); + vec2 hi = lo + floor(u_glyph_uv * u_tex_size + vec2(0.5)) - vec2(1.0); + // Texel *centres* are at integer + 0.5 in normalised-times-size space, so + // subtracting the half puts the sample on a grid whose integers are the centres: + // `f` is then the distance between the two taps, and a fragment that lands on a + // centre -- every fragment, when the scale is 1 and the pen is whole -- gets + // `f == 0` and exactly one texel, as crisp as the point sample ever was. + vec2 t = v_uv * u_tex_size - vec2(0.5); + vec2 t0 = floor(t); + vec2 f = t - t0; + vec2 a = clamp(t0, lo, hi); + vec2 b = clamp(t0 + vec2(1.0), lo, hi); + float coverage = mix(mix(tap(vec2(a.x, a.y)), tap(vec2(b.x, a.y)), f.x), + mix(tap(vec2(a.x, b.y)), tap(vec2(b.x, b.y)), f.x), f.y); o_color = vec4(v_colour.rgb, v_colour.a * coverage); } )"; diff --git a/src/render/text_atlas.cpp b/src/render/text_atlas.cpp index 848836d..9afd14f 100644 --- a/src/render/text_atlas.cpp +++ b/src/render/text_atlas.cpp @@ -71,6 +71,12 @@ bool TextAtlas::sync(const TextBackend& backend, Status* st) noexcept { // no second create -- and it keeps the filter, which is why the filter is set // on every upload rather than once at creation. texture_.upload_r8(width, height, pixels); + // `GL_NEAREST` on purpose, and it is not the absence of filtering: `kTextFS` + // filters, with four fetches it aims at explicit texel centres and clamps to + // the instance's own cell. `GL_LINEAR` here would do the blending with the + // *sampler's* footprint instead, which knows nothing of cell bounds -- and the + // atlas is a tight strip of cells with no padding, so a glyph at a cell edge + // would pick up its neighbour. texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST); } gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment); diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index c4f2d58..74b6f7e 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(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(y) * size + x] = 255u; + } + } + } + glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast(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(kW), static_cast(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(kW), static_cast(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(a.h); ++row) { + for (plot::u32 column = 3u; column + 3u < static_cast(a.w); ++column) { + plot::u8 px[4] = {}; + PLOT_CHECK(ctx.read_pixel(static_cast(a.x) + column, + ctx.height - 1u - (static_cast(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(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(area.h); ++row) { for (plot::u32 column = 0; column < static_cast(area.w); ++column) { plot::u8 px[4] = {}; PLOT_CHECK(ctx.read_pixel(static_cast(area.x) + column, ctx.height - 1u - (static_cast(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(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(pen.x) + column, static_cast(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(pen.x) + column, static_cast(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