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:
1 parent
98cfc5805b
commit
525759f9a9
8 files changed
+293
-23
No files matched your search
@@ -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_;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+14
-4
@@ -621,7 +621,8 @@ void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
|
||||
text_vao_.upload(interleaved.data(), static_cast<u32>(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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+87
-5
@@ -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 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);
|
||||
}
|
||||
)";
|
||||
|
||||
@@ -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);
|
||||
|
||||
+151
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user