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

+8
View File
@@ -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_;
+7 -1
View File
@@ -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.
+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
/// 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;