Compare commits

..
2 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 e337c6a60c fix(ticks): label the tick at the origin 0, not from the atto end of the table
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
A range straddling zero never lands *on* zero: the walk starts at
`ceil(min / stride) * stride`, which is not representable whenever the stride is
not a power of two -- 0.1 and 0.2 are the everyday ones -- so the tick that means
zero arrives a few ULP off and `format_si` labels it from six prefixes down.
`[-1, 1]` read `-1 -500m -139a 500m 1`. Deriving the tick by multiplication
rather than accumulation does not help, because the error is already in the
first term.

Every tick is an integer multiple of the stride, so the smallest magnitude a
non-zero tick can have is the stride itself, while the residue is a few ULP of
the range -- ten orders of magnitude apart. Anything inside a millionth of the
stride is therefore the origin and nothing else. It is stored as exactly `0.0`
rather than merely labelled so: `Axis::refresh()` re-formats a time axis from
`tick.value`, where the same residue reads `3.19e-13ms`. The walk variable is
left alone, since `minor_index_is_major` counts indices from `first_major` and
both operands have to stay on the walk's own grid.

The two narrow-y-title cases were calibrated on the defect -- the bogus `-55.5a`
was a six-character label, and its width is what spent the chrome allowance and
made the titled and untitled frames comparable. They are re-seated on a
milli-scale range, whose `-200m` is wide for a real reason, and the zero-width
strip the frame comparison depends on is now asserted rather than assumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-02 00:52:20 +02:00
Martino FerrariandClaude Opus 4.6 525759f9a9 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>
2026-10-02 00:22:08 +02:00
11 changed files with 399 additions and 36 deletions

No files matched your search

+9
View File
@@ -73,6 +73,15 @@ inline constexpr usize kMaxTicks = 256;
/// its labelled majors -- every major at or below `hi` is emitted, so the far /// its labelled majors -- every major at or below `hi` is emitted, so the far
/// end of the axis keeps its numbers. /// end of the axis keeps its numbers.
/// ///
/// A range that straddles the origin gets a tick of **exactly** `0.0` there, labelled
/// `0`. The walk cannot land on the origin by itself -- it starts at
/// `ceil(min / stride) * stride`, which is not representable whenever the stride is
/// not a power of two -- so the tick that means zero arrives a few ULP off and would
/// otherwise be labelled from the far end of the SI table (`-139a` between `-500m` and
/// `500m`). Every tick is an integer multiple of the stride, so anything within a
/// millionth of one *is* the origin and nothing else; a range whose ticks are
/// legitimately that small keeps them.
///
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min` /// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
/// (a zero or negative span, which includes the collapsed range of a /// (a zero or negative span, which includes the collapsed range of a
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step /// single-sample series), a non-positive `px` or `min_px`, and a span whose step
+8
View File
@@ -40,6 +40,14 @@ class LineProgram {
void set_aa(f32 px) const noexcept; void set_aa(f32 px) const noexcept;
/// `u_color`: stroke colour, alpha included. /// `u_color`: stroke colour, alpha included.
void set_color(Color color) const noexcept; 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: private:
gl::Program program_; gl::Program program_;
+7 -1
View File
@@ -397,7 +397,13 @@ class Renderer {
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush, /// 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 /// 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. /// 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 /// 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 /// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
/// once per text batch, after the line geometry it belongs over. /// once per text batch, after the line geometry it belongs over.
+16 -5
View File
@@ -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 /// 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 /// 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 /// 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 /// turned. It also passes the cell's origin through `flat`, which is the bound
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST` /// the fragment shader filters against.
/// never lands on a cell boundary. The instance's own `a_colour` tints the ///
/// 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. /// 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 /// 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 /// 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 /// 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 /// glyph of a frame comes from one atlas; read in *both* stages, the fragment
/// texels), `u_atlas` (sampler2D, unit 0). /// 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 kTextVS;
extern const char* const kTextFS; extern const char* const kTextFS;
+21 -2
View File
@@ -189,6 +189,23 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major; const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
if (!std::isfinite(first)) return; if (!std::isfinite(first)) return;
// The walk starts at `ceil(lo / stride) * stride`, which is not representable
// whenever the stride is not a power of two -- 0.1 and 0.2 are the common ones --
// so a range that straddles the origin never lands *on* it: [-1, 1] stepped by 0.1
// reaches -1.39e-16 where it means zero, and that renders as `-139a`, a label from
// the far end of the SI table sitting between `-500m` and `500m`. Deriving `v` by
// multiplication instead of accumulation does not help, because the error is
// already in `first`.
//
// Every tick is an integer multiple of the stride, so the smallest magnitude a
// *non-zero* tick can have is the stride itself, while the residue is a few ULP of
// the range -- a relative gap of some ten orders of magnitude. Anything inside a
// millionth of the stride is therefore the zero tick and nothing else, and it is
// stored as exactly `0.0` rather than merely labelled as such: `Axis::refresh()`
// re-formats a time axis from `tick.value`, where the same residue reads
// `3.19e-13ms`.
const f64 zero_tol = (include_minors ? minor_step : step) * 1e-6;
char label[kLabelBuf]; char label[kLabelBuf];
usize minors = 0; usize minors = 0;
for (f64 v = first; v <= hi + stride * 1e-9;) { for (f64 v = first; v <= hi + stride * 1e-9;) {
@@ -213,12 +230,14 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
} }
if (!major) ++minors; if (!major) ++minors;
Tick tick; Tick tick;
tick.value = v; // The walk variable itself is left alone: `minor_index_is_major` counts indices
// from `first_major` and both operands have to stay on the walk's own grid.
tick.value = std::fabs(v) < zero_tol ? 0.0 : v;
tick.major = major; tick.major = major;
if (major) { if (major) {
// format_si owns both label guards: it never writes past the buffer, and it // format_si owns both label guards: it never writes past the buffer, and it
// falls back to %.3g for a non-finite value or a label over 12 characters. // falls back to %.3g for a non-finite value or a label over 12 characters.
format_si(v, label, sizeof label); format_si(tick.value, label, sizeof label);
tick.label = label; tick.label = label;
} }
out.push_back(std::move(tick)); out.push_back(std::move(tick));
+4
View File
@@ -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_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 { bool TextProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kTextVS, kTextFS, st); program_ = gl::Program::from_source(kTextVS, kTextFS, st);
if (program_.id() == 0) { if (program_.id() == 0) {
+14 -4
View File
@@ -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)); 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(); const u32 segments = lines_.segment_count();
if (segments != 0 && ready_) { if (segments != 0 && ready_) {
upload_vertices(lines_.data()); 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 // 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()). // grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
line_program_.set_dash(dash); 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); line_program_.set_color(color);
// The AA fringe, too: the style's logical knob in device pixels, clamped so // 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 // 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 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); 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, draw_class(true, style_.grid_major_visible, style_.grid_major,
style_.device(style_.grid_major_width)); 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 Axis& axis = plot.x_axis(i);
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min; 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); 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, 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) { for (u32 i = 0; i < plot.y_axis_count(); ++i) {
const Axis& axis = plot.y_axis(i); const Axis& axis = plot.y_axis(i);
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min; 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); 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);
} }
} }
+89 -7
View File
@@ -20,6 +20,7 @@ uniform vec2 u_offset; // pixels
uniform vec2 u_viewport; // render target size, pixels uniform vec2 u_viewport; // render target size, pixels
uniform float u_width; // stroke width, pixels uniform float u_width; // stroke width, pixels
uniform float u_aa; // AA fringe half-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 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 // 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() { void main() {
vec2 p0 = a_p0 * u_scale + u_offset; vec2 p0 = a_p0 * u_scale + u_offset;
vec2 p1 = a_p1 * 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; vec2 d = p1 - p0;
float len = length(d); float len = length(d);
float half_w = u_width * 0.5 + u_aa; // + the AA fringe 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 vec2 v_uv; // normalised atlas coordinate of this fragment
out vec4 v_colour; // the instance's tint 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() { void main() {
// The cell's two axes: the run direction the instance carries and its // 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. // way round: u runs along the basis, v down the glyph.
v_uv = a_uv + corner * u_glyph_uv; v_uv = a_uv + corner * u_glyph_uv;
v_colour = a_colour; v_colour = a_colour;
v_cell0 = a_uv;
} }
)"; )";
const char* const kTextFS = R"(#version 330 core const char* const kTextFS = R"(#version 330 core
in vec2 v_uv; // normalised atlas coordinate of this fragment in vec2 v_uv; // normalised atlas coordinate of this fragment
in vec4 v_colour; // the instance's tint 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 sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
uniform vec2 u_tex_size; // atlas size in texels 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; 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() { void main() {
// Sample the centre of the texel the fragment lands in. GL_NEAREST alone // Bilinear over the four texels the fragment sits between, **clamped to this
// would tie at a texel boundary, where the driver may pick the neighbouring // glyph's own cell**.
// 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)); // It used to be a single point sample of the texel the fragment landed in, and
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r; // 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); o_color = vec4(v_colour.rgb, v_colour.a * coverage);
} }
)"; )";
+6
View File
@@ -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 // no second create -- and it keeps the filter, which is why the filter is set
// on every upload rather than once at creation. // on every upload rather than once at creation.
texture_.upload_r8(width, height, pixels); 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); texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
} }
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment); gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
+179 -17
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]) { bool band_pixel(const plot::u8 px[4]) {
return px[0] > 35 && px[0] > px[1] && px[1] > px[2]; 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 /// A grey marker: the annotation colour at any coverage, which no data or chrome
/// colour here matches (they are either dark or strongly coloured). /// colour here matches (they are either dark or strongly coloured).
bool marker_pixel(const plot::u8 px[4]) { bool marker_pixel(const plot::u8 px[4]) {
@@ -199,6 +208,25 @@ class BakeableBackend final : public plot::render::TextBackend {
++revision_; ++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 /// 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 /// 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 /// `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(); 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 // 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 // `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 // 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; const plot::Color title = r.style().axis_text;
// Reads a Rect -- whose rows run top-down, like every Rect here -- and reports // Reads a Rect -- whose rows run top-down, like every Rect here -- and reports
// whether any pixel in it is the title colour. // whether any pixel in it holds the title colour by `pred`'s reading of it. The
auto area_has_title = [&](const plot::Rect& area) { // 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 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) { for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
plot::u8 px[4] = {}; plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column, PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px)); 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; 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 // 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. // enough for the whole title line that used to be drawn there.
const plot::f32 band = r.axis_font_px() + 2.0f; 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.y, a.w, band}, exact));
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band})); PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}, exact));
// ... while the strips the layout reserved hold both titles. // ... while the strips the layout reserved hold both titles.
PLOT_CHECK(layout.x_title_area.h > 0.0f); PLOT_CHECK(layout.x_title_area.h > 0.0f);
PLOT_CHECK(layout.y_title_area.w > 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(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.x_title_area, ink));
PLOT_CHECK(area_has_title(layout.y_title_area)); PLOT_CHECK(area_has_title(layout.y_title_area, ink));
ctx.destroy(); ctx.destroy();
} }
@@ -1486,9 +1572,17 @@ PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
plot::Plot plot; plot::Plot plot;
plot.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)}); plot.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // A milli-scale range, for the *width of its labels*: a symmetric span ticks at
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Right, false}); // +/-step and 0, so the labels here are `-200m 0 200m` -- five characters, wide
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // enough that the label strip alone spends the whole chrome allowance and the
// title strip is left zero wide, which is the regime asserted below. A `[-1, 1]`
// axis labels `-1 0 1` instead and does not fill the allowance: the titled scene
// then keeps a 9.6 px title column -- still too narrow to stage a glyph, so the
// property holds -- but the column also pushes the label strip 9.6 px right, and
// the frame comparison would be reading that shift rather than title ink.
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
return plot; return plot;
}; };
const auto render = [&](plot::Plot& plot) { const auto render = [&](plot::Plot& plot) {
@@ -1509,6 +1603,11 @@ PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
// than the one column its three titled axes ask for, so no column can be staged. // than the one column its three titled axes ask for, so no column can be staged.
PLOT_CHECK(titled.titled_y_axis_count() == 3u); PLOT_CHECK(titled.titled_y_axis_count() == 3u);
PLOT_CHECK(layout.y_title_area.w < r.axis_font_px() + r.style().grid_label_pad); PLOT_CHECK(layout.y_title_area.w < r.axis_font_px() + r.style().grid_label_pad);
// Zero wide, not merely under one column -- the labels spend the allowance whole.
// That is this comparison's own premise as much as the clamp's: a strip with any
// width left in it would move the label strip right in the titled scene alone, and
// the two frames would then differ over the labels rather than over title ink.
PLOT_CHECK(layout.y_title_area.w == 0.0f);
// ... and the plot kept its floor in a viewport this narrow, so nothing here is // ... and the plot kept its floor in a viewport this narrow, so nothing here is
// degenerate for some other reason. // degenerate for some other reason.
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kNarrowW)); PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kNarrowW));
@@ -1547,9 +1646,12 @@ PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
// that survive are the leading ones -- which is what "keep the leading columns" // that survive are the leading ones -- which is what "keep the leading columns"
// means, since the renderer lays them out left to right from the strip's padding. // means, since the renderer lays them out left to right from the strip's padding.
PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) { PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) {
// 170 px is where the clamp leaves room for exactly one column (the label strip // 170 px is where the clamp leaves room for some of the three columns and not all
// takes 51.2 px of the 85 px allowance, leaving 33.8 px against the 19.7 px a // of them: the label strip takes 44 px of the 85 px allowance and a column needs
// column needs); the assertions below re-derive that rather than assume it. // 19.7, so the 41 px left over hold two. The assertions below re-derive the count
// from the measured strip rather than assume it, which is what lets the regime be
// "at least one and fewer than three" instead of a pixel figure that drifts with
// the font, the device scale or the tick labels.
constexpr plot::u32 kNarrowW = 170; constexpr plot::u32 kNarrowW = 170;
constexpr plot::u32 kNarrowH = 200; constexpr plot::u32 kNarrowH = 200;
plottest::HeadlessContext ctx(kNarrowW, kNarrowH); plottest::HeadlessContext ctx(kNarrowW, kNarrowH);
@@ -1562,9 +1664,12 @@ PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) {
plot::Plot p; plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)}); p.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // Milli-scale for the label width, as in the zero-wide-strip case above: the strip
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false}); // this test wants is the one that holds *some* columns and not all three, and what
p.add_y_axis({"watts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); // it has left to hold them in is the allowance minus the label strip.
p.add_y_axis({"volts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
p.add_y_axis({"amps", {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
p.add_y_axis({"watts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
r.begin_frame(kNarrowW, kNarrowH, r.begin_frame(kNarrowW, kNarrowH,
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)}); {0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
r.draw(p); r.draw(p);
@@ -2097,6 +2202,63 @@ PLOT_TEST(atlas_is_reuploaded_when_the_backend_revision_changes) {
ctx.destroy(); 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 // "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 // 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 // GPU as soon as the backend publishes one -- including by releasing the texture
+46
View File
@@ -158,6 +158,52 @@ PLOT_TEST(epoch_window_yields_labelled_majors) {
PLOT_CHECK(deep_majors >= 9); PLOT_CHECK(deep_majors >= 9);
} }
/// The tick that *means* zero reads `0`. The walk starts at `ceil(lo/stride)*stride`,
/// inexact for every stride that is not a power of two, so a range straddling the
/// origin used to reach a few e-16 there and label it from the far end of the SI
/// table: `-1 -500m -139a 500m 1`. The tolerance is relative to the stride, which is
/// also the smallest magnitude a non-zero tick can have, so a range whose ticks are
/// *legitimately* that small keeps them.
PLOT_TEST(the_tick_at_the_origin_reads_zero) {
std::vector<plot::Tick> t;
const float widths[] = {80.0f, 40.0f, 10.0f};
for (const float min_px : widths) {
for (const bool minors : {false, true}) {
plot::generate_ticks({-1.0, 1.0}, 400.0f, min_px, t, minors);
plot::usize zeros = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
PLOT_CHECK(!tick.label.empty());
// Nothing between -500m and 500m but the origin itself, so no major may
// carry a prefix from below `m`.
PLOT_CHECK(tick.label.find('a') == std::string::npos);
PLOT_CHECK(tick.label.find('f') == std::string::npos);
if (tick.label == "0") {
++zeros;
PLOT_CHECK(tick.value == 0.0); // exact, so a Time axis relabels it as 0 too
}
}
PLOT_CHECK(zeros == 1u);
}
}
// A range whose ticks really are atto-scale is untouched: only the origin folds.
plot::generate_ticks({-2e-16, 2e-16}, 400.0f, 80.0f, t, /*include_minors=*/true);
plot::usize tiny = 0;
plot::usize tiny_zeros = 0;
for (const plot::Tick& tick : t) {
if (!tick.major) continue;
if (tick.value == 0.0) {
++tiny_zeros;
} else {
++tiny;
PLOT_CHECK(tick.label.find('a') != std::string::npos);
}
}
PLOT_CHECK(tiny_zeros == 1u);
PLOT_CHECK(tiny >= 2u);
}
PLOT_TEST(si_labels_are_utf8_and_never_overrun) { PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
char b[32]; char b[32];
plot::format_si(0.0000015, b, sizeof b); plot::format_si(0.0000015, b, sizeof b);