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

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

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

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

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

No files matched your search

+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_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
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));
}
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);
}
}
+89 -7
View File
@@ -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);
}
)";
+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
// 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);