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

+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);
}
}