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
+295
-25
No files matched your search
+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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user