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>
88 lines
3.9 KiB
C++
88 lines
3.9 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
#include <plot/core/color.hpp>
|
|
#include <plot/core/status.hpp>
|
|
#include <plot/gl/handle.hpp>
|
|
#include <plot/math/vec2.hpp>
|
|
|
|
namespace plot::render {
|
|
|
|
/// Linked line program and its uniform surface.
|
|
///
|
|
/// It owns the `plot::gl::Program` and knows the GLSL pair (shaders.hpp), so no
|
|
/// caller spells out a uniform name that could drift from the shader. Creating
|
|
/// it propagates what the GL layer reports: `ErrorCode::Unsupported` while
|
|
/// `plot::gl::available()` is false, `GLFailure` plus the driver's log when the
|
|
/// GLSL does not compile or link.
|
|
class LineProgram {
|
|
public:
|
|
bool create(Status* st = nullptr) noexcept;
|
|
bool valid() const noexcept { return program_.id() != 0; }
|
|
void bind() const noexcept { program_.use(); }
|
|
|
|
/// `u_scale`: data space to pixels.
|
|
void set_scale(Vec2 data_to_px) const noexcept;
|
|
/// `u_offset`: pixel offset added after `set_scale`.
|
|
void set_offset(Vec2 px) const noexcept;
|
|
/// `u_viewport`: render target size in pixels.
|
|
void set_viewport(Vec2 px) const noexcept;
|
|
/// `u_width`: stroke width in pixels.
|
|
void set_width(f32 px) const noexcept;
|
|
/// `u_dash`: the dash pattern in pixels -- `x` the ink length, `y` the gap, and a
|
|
/// non-positive `y` (the default a caller passes) means **solid**. It is a fragment-shader
|
|
/// discard on the interpolated `v_along`, so it costs no geometry, and it is the one uniform
|
|
/// that distinguishes the crosshair's stroke from every other line in the library.
|
|
void set_dash(Vec2 on_off) const noexcept;
|
|
/// `u_aa`: the AA fringe half-width in pixels. The style's `line_aa_px` in
|
|
/// device pixels, clamped at 0.25; 1.0 reproduces the historical fringe.
|
|
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_;
|
|
};
|
|
|
|
/// Linked text program and its uniform surface. It fails exactly the way
|
|
/// `LineProgram::create()` does, propagating `Unsupported` and `GLFailure`.
|
|
///
|
|
/// `create()` also points the `u_atlas` sampler at texture unit 0, which is the
|
|
/// unit `plot::gl::Texture::bind()` defaults to, and leaves this program bound.
|
|
/// The atlas itself stays with the caller, who owns its lifetime.
|
|
class TextProgram {
|
|
public:
|
|
bool create(Status* st = nullptr) noexcept;
|
|
bool valid() const noexcept { return program_.id() != 0; }
|
|
void bind() const noexcept { program_.use(); }
|
|
|
|
/// `u_viewport`: render target size in pixels.
|
|
void set_viewport(Vec2 px) const noexcept;
|
|
/// `u_px_range`: the glyph cell's size on screen, in pixels (width and
|
|
/// height -- a cell need not be square).
|
|
void set_px_range(Vec2 px) const noexcept;
|
|
/// `u_glyph_uv`: the glyph cell's size in the atlas, normalised. It is the
|
|
/// rect every staged glyph is read through, so it is one size per draw -- and
|
|
/// it is the *backend's* (`TextBackend::cell_uv`), never a glyph's, because a
|
|
/// draw may stage glyphs whose origins differ even when their size cannot.
|
|
void set_glyph_uv(Vec2 uv) const noexcept;
|
|
/// `u_tex_size`: atlas size in texels.
|
|
void set_texture_size(Vec2 texels) const noexcept;
|
|
/// The tint is per instance (`a_colour`), not a uniform, so a draw can carry
|
|
/// runs of several colours -- there is deliberately no colour setter here.
|
|
|
|
private:
|
|
gl::Program program_;
|
|
};
|
|
|
|
} // namespace plot::render
|