Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e337c6a60c | ||
|
|
525759f9a9 |
No files matched your search
@@ -73,6 +73,15 @@ inline constexpr usize kMaxTicks = 256;
|
|||||||
/// its labelled majors -- every major at or below `hi` is emitted, so the far
|
/// its labelled majors -- every major at or below `hi` is emitted, so the far
|
||||||
/// end of the axis keeps its numbers.
|
/// end of the axis keeps its numbers.
|
||||||
///
|
///
|
||||||
|
/// A range that straddles the origin gets a tick of **exactly** `0.0` there, labelled
|
||||||
|
/// `0`. The walk cannot land on the origin by itself -- it starts at
|
||||||
|
/// `ceil(min / stride) * stride`, which is not representable whenever the stride is
|
||||||
|
/// not a power of two -- so the tick that means zero arrives a few ULP off and would
|
||||||
|
/// otherwise be labelled from the far end of the SI table (`-139a` between `-500m` and
|
||||||
|
/// `500m`). Every tick is an integer multiple of the stride, so anything within a
|
||||||
|
/// millionth of one *is* the origin and nothing else; a range whose ticks are
|
||||||
|
/// legitimately that small keeps them.
|
||||||
|
///
|
||||||
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
|
/// Hostile input clears `out` and returns: a non-finite endpoint, `max <= min`
|
||||||
/// (a zero or negative span, which includes the collapsed range of a
|
/// (a zero or negative span, which includes the collapsed range of a
|
||||||
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
/// single-sample series), a non-positive `px` or `min_px`, and a span whose step
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ class LineProgram {
|
|||||||
void set_aa(f32 px) const noexcept;
|
void set_aa(f32 px) const noexcept;
|
||||||
/// `u_color`: stroke colour, alpha included.
|
/// `u_color`: stroke colour, alpha included.
|
||||||
void set_color(Color color) const noexcept;
|
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:
|
private:
|
||||||
gl::Program program_;
|
gl::Program program_;
|
||||||
|
|||||||
@@ -397,7 +397,13 @@ class Renderer {
|
|||||||
/// crosshair passes and a dashed trace -- means **solid**. It is set on every flush,
|
/// 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
|
/// 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.
|
/// 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
|
/// 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
|
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
||||||
/// once per text batch, after the line geometry it belongs over.
|
/// once per text batch, after the line geometry it belongs over.
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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
|
/// 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
|
/// turned. It also passes the cell's origin through `flat`, which is the bound
|
||||||
/// single-channel `GL_R8` atlas at the nearest texel centre, so `GL_NEAREST`
|
/// the fragment shader filters against.
|
||||||
/// never lands on a cell boundary. The instance's own `a_colour` tints the
|
///
|
||||||
|
/// 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.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// glyph of a frame comes from one atlas; read in *both* stages, the fragment
|
||||||
/// texels), `u_atlas` (sampler2D, unit 0).
|
/// 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 kTextVS;
|
||||||
extern const char* const kTextFS;
|
extern const char* const kTextFS;
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -189,6 +189,23 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
|||||||
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
|
const f64 first = include_minors ? std::ceil(lo / minor_step) * minor_step : first_major;
|
||||||
if (!std::isfinite(first)) return;
|
if (!std::isfinite(first)) return;
|
||||||
|
|
||||||
|
// The walk starts at `ceil(lo / stride) * stride`, which is not representable
|
||||||
|
// whenever the stride is not a power of two -- 0.1 and 0.2 are the common ones --
|
||||||
|
// so a range that straddles the origin never lands *on* it: [-1, 1] stepped by 0.1
|
||||||
|
// reaches -1.39e-16 where it means zero, and that renders as `-139a`, a label from
|
||||||
|
// the far end of the SI table sitting between `-500m` and `500m`. Deriving `v` by
|
||||||
|
// multiplication instead of accumulation does not help, because the error is
|
||||||
|
// already in `first`.
|
||||||
|
//
|
||||||
|
// Every tick is an integer multiple of the stride, so the smallest magnitude a
|
||||||
|
// *non-zero* tick can have is the stride itself, while the residue is a few ULP of
|
||||||
|
// the range -- a relative gap of some ten orders of magnitude. Anything inside a
|
||||||
|
// millionth of the stride is therefore the zero tick and nothing else, and it is
|
||||||
|
// stored as exactly `0.0` rather than merely labelled as such: `Axis::refresh()`
|
||||||
|
// re-formats a time axis from `tick.value`, where the same residue reads
|
||||||
|
// `3.19e-13ms`.
|
||||||
|
const f64 zero_tol = (include_minors ? minor_step : step) * 1e-6;
|
||||||
|
|
||||||
char label[kLabelBuf];
|
char label[kLabelBuf];
|
||||||
usize minors = 0;
|
usize minors = 0;
|
||||||
for (f64 v = first; v <= hi + stride * 1e-9;) {
|
for (f64 v = first; v <= hi + stride * 1e-9;) {
|
||||||
@@ -213,12 +230,14 @@ void generate_ticks(Range range, f32 px, f32 min_px, std::vector<Tick>& out, boo
|
|||||||
}
|
}
|
||||||
if (!major) ++minors;
|
if (!major) ++minors;
|
||||||
Tick tick;
|
Tick tick;
|
||||||
tick.value = v;
|
// The walk variable itself is left alone: `minor_index_is_major` counts indices
|
||||||
|
// from `first_major` and both operands have to stay on the walk's own grid.
|
||||||
|
tick.value = std::fabs(v) < zero_tol ? 0.0 : v;
|
||||||
tick.major = major;
|
tick.major = major;
|
||||||
if (major) {
|
if (major) {
|
||||||
// format_si owns both label guards: it never writes past the buffer, and it
|
// format_si owns both label guards: it never writes past the buffer, and it
|
||||||
// falls back to %.3g for a non-finite value or a label over 12 characters.
|
// falls back to %.3g for a non-finite value or a label over 12 characters.
|
||||||
format_si(v, label, sizeof label);
|
format_si(tick.value, label, sizeof label);
|
||||||
tick.label = label;
|
tick.label = label;
|
||||||
}
|
}
|
||||||
out.push_back(std::move(tick));
|
out.push_back(std::move(tick));
|
||||||
|
|||||||
@@ -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_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 {
|
bool TextProgram::create(Status* st) noexcept {
|
||||||
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
|
program_ = gl::Program::from_source(kTextVS, kTextFS, st);
|
||||||
if (program_.id() == 0) {
|
if (program_.id() == 0) {
|
||||||
|
|||||||
+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));
|
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();
|
const u32 segments = lines_.segment_count();
|
||||||
if (segments != 0 && ready_) {
|
if (segments != 0 && ready_) {
|
||||||
upload_vertices(lines_.data());
|
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
|
// 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()).
|
// grid, a trace or a cursor drawn after it (see draw_crosshair_lines()).
|
||||||
line_program_.set_dash(dash);
|
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);
|
line_program_.set_color(color);
|
||||||
// The AA fringe, too: the style's logical knob in device pixels, clamped so
|
// 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
|
// 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
|
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);
|
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,
|
draw_class(true, style_.grid_major_visible, style_.grid_major,
|
||||||
style_.device(style_.grid_major_width));
|
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 Axis& axis = plot.x_axis(i);
|
||||||
const f64 y = axis.place == AxisPlace::Top ? y_range.max : y_range.min;
|
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);
|
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,
|
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) {
|
for (u32 i = 0; i < plot.y_axis_count(); ++i) {
|
||||||
const Axis& axis = plot.y_axis(i);
|
const Axis& axis = plot.y_axis(i);
|
||||||
const f64 x = axis.place == AxisPlace::Right ? x_range.max : x_range.min;
|
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);
|
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
@@ -20,6 +20,7 @@ uniform vec2 u_offset; // pixels
|
|||||||
uniform vec2 u_viewport; // render target size, pixels
|
uniform vec2 u_viewport; // render target size, pixels
|
||||||
uniform float u_width; // stroke width, pixels
|
uniform float u_width; // stroke width, pixels
|
||||||
uniform float u_aa; // AA fringe half-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
|
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
|
// 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() {
|
void main() {
|
||||||
vec2 p0 = a_p0 * u_scale + u_offset;
|
vec2 p0 = a_p0 * u_scale + u_offset;
|
||||||
vec2 p1 = a_p1 * 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;
|
vec2 d = p1 - p0;
|
||||||
float len = length(d);
|
float len = length(d);
|
||||||
float half_w = u_width * 0.5 + u_aa; // + the AA fringe
|
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 vec2 v_uv; // normalised atlas coordinate of this fragment
|
||||||
out vec4 v_colour; // the instance's tint
|
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() {
|
void main() {
|
||||||
// The cell's two axes: the run direction the instance carries and its
|
// 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.
|
// way round: u runs along the basis, v down the glyph.
|
||||||
v_uv = a_uv + corner * u_glyph_uv;
|
v_uv = a_uv + corner * u_glyph_uv;
|
||||||
v_colour = a_colour;
|
v_colour = a_colour;
|
||||||
|
v_cell0 = a_uv;
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|
||||||
const char* const kTextFS = R"(#version 330 core
|
const char* const kTextFS = R"(#version 330 core
|
||||||
in vec2 v_uv; // normalised atlas coordinate of this fragment
|
in vec2 v_uv; // normalised atlas coordinate of this fragment
|
||||||
in vec4 v_colour; // the instance's tint
|
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 sampler2D u_atlas; // GL_R8 glyph atlas, GL_NEAREST
|
||||||
uniform vec2 u_tex_size; // atlas size in texels
|
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;
|
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() {
|
void main() {
|
||||||
// Sample the centre of the texel the fragment lands in. GL_NEAREST alone
|
// Bilinear over the four texels the fragment sits between, **clamped to this
|
||||||
// would tie at a texel boundary, where the driver may pick the neighbouring
|
// glyph's own cell**.
|
||||||
// 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));
|
// It used to be a single point sample of the texel the fragment landed in, and
|
||||||
float coverage = texture(u_atlas, (texel + vec2(0.5)) / u_tex_size).r;
|
// 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);
|
o_color = vec4(v_colour.rgb, v_colour.a * coverage);
|
||||||
}
|
}
|
||||||
)";
|
)";
|
||||||
|
|||||||
@@ -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
|
// no second create -- and it keeps the filter, which is why the filter is set
|
||||||
// on every upload rather than once at creation.
|
// on every upload rather than once at creation.
|
||||||
texture_.upload_r8(width, height, pixels);
|
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);
|
texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
|
||||||
}
|
}
|
||||||
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
|
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, host_alignment);
|
||||||
|
|||||||
+179
-17
@@ -70,7 +70,16 @@ bool magenta_pixel(const plot::u8 px[4]) { return px[0] > px[1] + 60 && px[2] >
|
|||||||
bool band_pixel(const plot::u8 px[4]) {
|
bool band_pixel(const plot::u8 px[4]) {
|
||||||
return px[0] > 35 && px[0] > px[1] && px[1] > px[2];
|
return px[0] > 35 && px[0] > px[1] && px[1] > px[2];
|
||||||
}
|
}
|
||||||
bool text_pixel(const plot::u8 px[4]) { return px[0] > 180 && px[1] > 180 && px[2] > 180; }
|
/// A chalk pixel: `axis_text` (0xC8D0DC) at close to full coverage, which nothing
|
||||||
|
/// else in these frames reaches -- the brightest other element is `axis_line`
|
||||||
|
/// (0x8A94A6), whose dimmest channel is 138.
|
||||||
|
///
|
||||||
|
/// The threshold is 160 and not the colour's own 200-220 because the text pipeline
|
||||||
|
/// filters the atlas: at the default 12 px style on the 13 px bake the brightest
|
||||||
|
/// fragment of a glyph is 0.844 covered, which puts `axis_text`'s green at 179. A
|
||||||
|
/// 180 threshold found text only while the sampler was a point fetch, i.e. only
|
||||||
|
/// while it was also deleting a column and a row of every glyph.
|
||||||
|
bool text_pixel(const plot::u8 px[4]) { return px[0] > 160 && px[1] > 160 && px[2] > 160; }
|
||||||
/// A grey marker: the annotation colour at any coverage, which no data or chrome
|
/// A grey marker: the annotation colour at any coverage, which no data or chrome
|
||||||
/// colour here matches (they are either dark or strongly coloured).
|
/// colour here matches (they are either dark or strongly coloured).
|
||||||
bool marker_pixel(const plot::u8 px[4]) {
|
bool marker_pixel(const plot::u8 px[4]) {
|
||||||
@@ -199,6 +208,25 @@ class BakeableBackend final : public plot::render::TextBackend {
|
|||||||
++revision_;
|
++revision_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bakes a `size` x `size` atlas of alternating ink and blank texels, and bumps
|
||||||
|
/// the revision. It is the artwork that separates a *filtered* sampler from a
|
||||||
|
/// point one without depending on any face's shapes: halve the scale and a
|
||||||
|
/// filter must average every 2x2 to mid grey, while a point fetch lands on one
|
||||||
|
/// parity for every fragment and returns a solid block.
|
||||||
|
void bake_checkerboard(plot::u32 size) {
|
||||||
|
size_ = size;
|
||||||
|
pixels_.assign(static_cast<std::size_t>(size) * size, 0u);
|
||||||
|
for (plot::u32 y = 0; y < size; ++y) {
|
||||||
|
for (plot::u32 x = 0; x < size; ++x) {
|
||||||
|
if (((x + y) & 1u) == 0u) {
|
||||||
|
pixels_[static_cast<std::size_t>(y) * size + x] = 255u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glyph_ = plot::render::Glyph{0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, static_cast<plot::f32>(size)};
|
||||||
|
++revision_;
|
||||||
|
}
|
||||||
|
|
||||||
/// Trims the published glyph's rect to an ink box, which is what breaks the
|
/// Trims the published glyph's rect to an ink box, which is what breaks the
|
||||||
/// fixed-cell grid (see `TextBackend::cell_uv`): the rect is no longer the cell
|
/// fixed-cell grid (see `TextBackend::cell_uv`): the rect is no longer the cell
|
||||||
/// `cell_uv()` declares, so the staging path's check fails. Used by the case
|
/// `cell_uv()` declares, so the staging path's check fails. Used by the case
|
||||||
@@ -990,6 +1018,56 @@ PLOT_TEST(draws_cursors_measure_band_and_annotations) {
|
|||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The grid is on the pixel grid. Its lines are staged in data space like every
|
||||||
|
// other line and mapped by the vertex shader, so a 1 px stroke's centre used to
|
||||||
|
// land wherever the view put it: a line at an x that maps to 100.0 spans
|
||||||
|
// [99.5, 100.5] and lights two columns at half coverage, while one that maps to
|
||||||
|
// 100.5 spans [100, 101] and lights one fully. A frame's gridlines were a mix of
|
||||||
|
// both -- a grey smear beside a crisp hairline -- and panning made each shimmer
|
||||||
|
// between the two. `LineProgram::set_snap()` rounds an axis-aligned segment's ink
|
||||||
|
// onto whole pixels, so the observable is sharp: **every lit pixel inside the axes
|
||||||
|
// rect is the grid colour exactly**, with no half-coverage blend anywhere.
|
||||||
|
//
|
||||||
|
// No trace and no minor grid, so the only thing drawn inside the rect is the major
|
||||||
|
// grid and the one colour it is drawn in; the rect is inset so the axis lines on
|
||||||
|
// its own edges, which are a different colour, stay out of the scan.
|
||||||
|
PLOT_TEST(grid_hairlines_land_on_whole_pixels) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
r.style().grid_minor_visible = false;
|
||||||
|
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
p.add_x_axis({"seconds", {0.0, 10.0}, true, plot::AxisPlace::Bottom, false});
|
||||||
|
p.add_y_axis({"volts", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
|
||||||
|
const plot::Rect& a = p.layout().axes;
|
||||||
|
const plot::Color grid = r.style().grid_major;
|
||||||
|
const plot::Color background = r.style().background;
|
||||||
|
plot::u32 lit = 0;
|
||||||
|
for (plot::u32 row = 3u; row + 3u < static_cast<plot::u32>(a.h); ++row) {
|
||||||
|
for (plot::u32 column = 3u; column + 3u < static_cast<plot::u32>(a.w); ++column) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + column,
|
||||||
|
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||||
|
if (pixel_is(px, background)) continue;
|
||||||
|
PLOT_CHECK(pixel_is(px, grid)); // full coverage, not a blend of grid and background
|
||||||
|
++lit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PLOT_CHECK(lit > static_cast<plot::u32>(a.h)); // a grid was drawn at all
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// Finding 2 of the review: `draw_titles()` placed both titles inside
|
// Finding 2 of the review: `draw_titles()` placed both titles inside
|
||||||
// `layout.axes`, because the layout reserved no room outside it for them, so in
|
// `layout.axes`, because the layout reserved no room outside it for them, so in
|
||||||
// the dense frame -- whose envelope spans every column's full height -- a title
|
// the dense frame -- whose envelope spans every column's full height -- a title
|
||||||
@@ -1027,31 +1105,39 @@ PLOT_TEST(titles_stay_out_of_the_axes_band) {
|
|||||||
const plot::Color title = r.style().axis_text;
|
const plot::Color title = r.style().axis_text;
|
||||||
|
|
||||||
// Reads a Rect -- whose rows run top-down, like every Rect here -- and reports
|
// Reads a Rect -- whose rows run top-down, like every Rect here -- and reports
|
||||||
// whether any pixel in it is the title colour.
|
// whether any pixel in it holds the title colour by `pred`'s reading of it. The
|
||||||
auto area_has_title = [&](const plot::Rect& area) {
|
// two directions of this test need different readings, and for the reason
|
||||||
|
// `rect_has_exact` below is written for: inside `axes` only the *exact* colour
|
||||||
|
// means "a title is here", because a third of a chalk tone is under the axis
|
||||||
|
// stroke's own grey, so an ink scan there would find the axis; in the reserved
|
||||||
|
// strips, where nothing but the title draws, the ink scan is the one that can
|
||||||
|
// see a filtered glyph at all (see `text_ink`).
|
||||||
|
auto area_has_title = [&](const plot::Rect& area, auto pred) {
|
||||||
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(area.h); ++row) {
|
||||||
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
|
for (plot::u32 column = 0; column < static_cast<plot::u32>(area.w); ++column) {
|
||||||
plot::u8 px[4] = {};
|
plot::u8 px[4] = {};
|
||||||
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(area.x) + column,
|
||||||
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
|
ctx.height - 1u - (static_cast<plot::u32>(area.y) + row), px));
|
||||||
if (pixel_is(px, title)) return true;
|
if (pred(px, title)) return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
const auto exact = [](const plot::u8* px, plot::Color c) { return pixel_is(px, c); };
|
||||||
|
const auto ink = [](const plot::u8* px, plot::Color c) { return text_ink(px, c); };
|
||||||
|
|
||||||
// One text line plus the two pixels the old placement inset itself by: tall
|
// One text line plus the two pixels the old placement inset itself by: tall
|
||||||
// enough for the whole title line that used to be drawn there.
|
// enough for the whole title line that used to be drawn there.
|
||||||
const plot::f32 band = r.axis_font_px() + 2.0f;
|
const plot::f32 band = r.axis_font_px() + 2.0f;
|
||||||
PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band}));
|
PLOT_CHECK(!area_has_title({a.x, a.y, a.w, band}, exact));
|
||||||
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}));
|
PLOT_CHECK(!area_has_title({a.x, a.bottom() - band, a.w, band}, exact));
|
||||||
|
|
||||||
// ... while the strips the layout reserved hold both titles.
|
// ... while the strips the layout reserved hold both titles.
|
||||||
PLOT_CHECK(layout.x_title_area.h > 0.0f);
|
PLOT_CHECK(layout.x_title_area.h > 0.0f);
|
||||||
PLOT_CHECK(layout.y_title_area.w > 0.0f);
|
PLOT_CHECK(layout.y_title_area.w > 0.0f);
|
||||||
PLOT_CHECK(layout.axes.bottom() <= layout.x_title_area.y); // outside the data
|
PLOT_CHECK(layout.axes.bottom() <= layout.x_title_area.y); // outside the data
|
||||||
PLOT_CHECK(area_has_title(layout.x_title_area));
|
PLOT_CHECK(area_has_title(layout.x_title_area, ink));
|
||||||
PLOT_CHECK(area_has_title(layout.y_title_area));
|
PLOT_CHECK(area_has_title(layout.y_title_area, ink));
|
||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1486,9 +1572,17 @@ PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
|
|||||||
plot::Plot plot;
|
plot::Plot plot;
|
||||||
plot.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
plot.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
||||||
plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
plot.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
// A milli-scale range, for the *width of its labels*: a symmetric span ticks at
|
||||||
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
|
// +/-step and 0, so the labels here are `-200m 0 200m` -- five characters, wide
|
||||||
plot.add_y_axis({title, {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
// enough that the label strip alone spends the whole chrome allowance and the
|
||||||
|
// title strip is left zero wide, which is the regime asserted below. A `[-1, 1]`
|
||||||
|
// axis labels `-1 0 1` instead and does not fill the allowance: the titled scene
|
||||||
|
// then keeps a 9.6 px title column -- still too narrow to stage a glyph, so the
|
||||||
|
// property holds -- but the column also pushes the label strip 9.6 px right, and
|
||||||
|
// the frame comparison would be reading that shift rather than title ink.
|
||||||
|
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
||||||
|
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
|
||||||
|
plot.add_y_axis({title, {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
||||||
return plot;
|
return plot;
|
||||||
};
|
};
|
||||||
const auto render = [&](plot::Plot& plot) {
|
const auto render = [&](plot::Plot& plot) {
|
||||||
@@ -1509,6 +1603,11 @@ PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
|
|||||||
// than the one column its three titled axes ask for, so no column can be staged.
|
// than the one column its three titled axes ask for, so no column can be staged.
|
||||||
PLOT_CHECK(titled.titled_y_axis_count() == 3u);
|
PLOT_CHECK(titled.titled_y_axis_count() == 3u);
|
||||||
PLOT_CHECK(layout.y_title_area.w < r.axis_font_px() + r.style().grid_label_pad);
|
PLOT_CHECK(layout.y_title_area.w < r.axis_font_px() + r.style().grid_label_pad);
|
||||||
|
// Zero wide, not merely under one column -- the labels spend the allowance whole.
|
||||||
|
// That is this comparison's own premise as much as the clamp's: a strip with any
|
||||||
|
// width left in it would move the label strip right in the titled scene alone, and
|
||||||
|
// the two frames would then differ over the labels rather than over title ink.
|
||||||
|
PLOT_CHECK(layout.y_title_area.w == 0.0f);
|
||||||
// ... and the plot kept its floor in a viewport this narrow, so nothing here is
|
// ... and the plot kept its floor in a viewport this narrow, so nothing here is
|
||||||
// degenerate for some other reason.
|
// degenerate for some other reason.
|
||||||
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kNarrowW));
|
PLOT_CHECK(layout.axes.w >= 0.5f * static_cast<plot::f32>(kNarrowW));
|
||||||
@@ -1547,9 +1646,12 @@ PLOT_TEST(narrow_viewport_draws_no_y_title_it_cannot_fit) {
|
|||||||
// that survive are the leading ones -- which is what "keep the leading columns"
|
// that survive are the leading ones -- which is what "keep the leading columns"
|
||||||
// means, since the renderer lays them out left to right from the strip's padding.
|
// means, since the renderer lays them out left to right from the strip's padding.
|
||||||
PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) {
|
PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) {
|
||||||
// 170 px is where the clamp leaves room for exactly one column (the label strip
|
// 170 px is where the clamp leaves room for some of the three columns and not all
|
||||||
// takes 51.2 px of the 85 px allowance, leaving 33.8 px against the 19.7 px a
|
// of them: the label strip takes 44 px of the 85 px allowance and a column needs
|
||||||
// column needs); the assertions below re-derive that rather than assume it.
|
// 19.7, so the 41 px left over hold two. The assertions below re-derive the count
|
||||||
|
// from the measured strip rather than assume it, which is what lets the regime be
|
||||||
|
// "at least one and fewer than three" instead of a pixel figure that drifts with
|
||||||
|
// the font, the device scale or the tick labels.
|
||||||
constexpr plot::u32 kNarrowW = 170;
|
constexpr plot::u32 kNarrowW = 170;
|
||||||
constexpr plot::u32 kNarrowH = 200;
|
constexpr plot::u32 kNarrowH = 200;
|
||||||
plottest::HeadlessContext ctx(kNarrowW, kNarrowH);
|
plottest::HeadlessContext ctx(kNarrowW, kNarrowH);
|
||||||
@@ -1562,9 +1664,12 @@ PLOT_TEST(narrow_viewport_keeps_only_the_y_title_columns_that_fit) {
|
|||||||
plot::Plot p;
|
plot::Plot p;
|
||||||
p.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
p.set_viewport({0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
||||||
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
p.add_x_axis({"", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
// Milli-scale for the label width, as in the zero-wide-strip case above: the strip
|
||||||
p.add_y_axis({"amps", {-1.0, 1.0}, false, plot::AxisPlace::Right, false});
|
// this test wants is the one that holds *some* columns and not all three, and what
|
||||||
p.add_y_axis({"watts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
// it has left to hold them in is the allowance minus the label strip.
|
||||||
|
p.add_y_axis({"volts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_y_axis({"amps", {-0.2, 0.2}, false, plot::AxisPlace::Right, false});
|
||||||
|
p.add_y_axis({"watts", {-0.2, 0.2}, false, plot::AxisPlace::Left, false});
|
||||||
r.begin_frame(kNarrowW, kNarrowH,
|
r.begin_frame(kNarrowW, kNarrowH,
|
||||||
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
{0, 0, static_cast<plot::f32>(kNarrowW), static_cast<plot::f32>(kNarrowH)});
|
||||||
r.draw(p);
|
r.draw(p);
|
||||||
@@ -2097,6 +2202,63 @@ PLOT_TEST(atlas_is_reuploaded_when_the_backend_revision_changes) {
|
|||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The text pipeline filters the atlas; it does not point-sample it. The two halves
|
||||||
|
// of the old behaviour -- `GL_NEAREST` plus one `floor(v_uv * u_tex_size)` fetch,
|
||||||
|
// and `AxisFont` drawing the atlas at `requested_px / em_px` -- were each
|
||||||
|
// reasonable and jointly wrong: a point fetch at a non-integer scale *deletes*
|
||||||
|
// texels instead of blending them, and the library's own default is a 12 px style
|
||||||
|
// on the 13 px embedded bake, which dropped one of every 8 columns and one of every
|
||||||
|
// 17 rows of every glyph. Whole strokes, and which stroke went depended on where the
|
||||||
|
// unsnapped pen fell.
|
||||||
|
//
|
||||||
|
// A checkerboard is the artwork that pins it without depending on a face's shapes.
|
||||||
|
// At half scale every fragment's footprint spans a 2x2 of it, so a filter must
|
||||||
|
// return mid grey everywhere, while a point fetch lands on one parity for every
|
||||||
|
// fragment and returns a solid block -- a 255 or a 0, not a 128. And at scale 1 with
|
||||||
|
// a whole pen the two taps coincide (`f == 0` exactly), so the second half of the
|
||||||
|
// case is that filtering cost nothing there: the checkerboard comes back as a
|
||||||
|
// checkerboard, every pixel either full ink or none.
|
||||||
|
PLOT_TEST(the_atlas_is_filtered_not_point_sampled) {
|
||||||
|
plottest::HeadlessContext ctx(64, 64);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
|
||||||
|
const plot::Vec2 pen{8.0f, 8.0f}; // whole pixels: the best case for a point fetch
|
||||||
|
BakeableBackend backend;
|
||||||
|
backend.bake_checkerboard(8u);
|
||||||
|
plot::render::TextAtlas atlas;
|
||||||
|
|
||||||
|
auto red = [&](plot::u32 x, plot::u32 y) {
|
||||||
|
plot::u8 pxv[4] = {};
|
||||||
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, pxv));
|
||||||
|
return static_cast<int>(pxv[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scale 1: an 8x8 quad, and the checkerboard survives as itself.
|
||||||
|
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 1.0f, {1.0f, 0.0f}).any());
|
||||||
|
int crisp = 0;
|
||||||
|
for (plot::u32 row = 0; row < 8u; ++row) {
|
||||||
|
for (plot::u32 column = 0; column < 8u; ++column) {
|
||||||
|
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
|
||||||
|
PLOT_CHECK(v < 8 || v > 247); // one texel or the other, never a blend of both
|
||||||
|
if (v > 247) ++crisp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PLOT_CHECK(crisp == 32); // half the cell, which is what a checkerboard is
|
||||||
|
|
||||||
|
// Scale 0.5: a 4x4 quad, and every pixel of it is the average of a 2x2 of the
|
||||||
|
// checkerboard. A point fetch gives 255 or 0 here, so the band is the assertion.
|
||||||
|
PLOT_CHECK(draw_text_run(ctx, atlas, backend, "X", pen, 0.5f, {1.0f, 0.0f}).any());
|
||||||
|
for (plot::u32 row = 0; row < 4u; ++row) {
|
||||||
|
for (plot::u32 column = 0; column < 4u; ++column) {
|
||||||
|
const int v = red(static_cast<plot::u32>(pen.x) + column, static_cast<plot::u32>(pen.y) + row);
|
||||||
|
PLOT_CHECK(v > 96 && v < 160);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// "No text" is a legal backend, not a failure: a null atlas, or one with no
|
// "No text" is a legal backend, not a failure: a null atlas, or one with no
|
||||||
// size, must sync cleanly, leave the atlas owner empty, and be *replaced* on the
|
// size, must sync cleanly, leave the atlas owner empty, and be *replaced* on the
|
||||||
// GPU as soon as the backend publishes one -- including by releasing the texture
|
// GPU as soon as the backend publishes one -- including by releasing the texture
|
||||||
|
|||||||
@@ -158,6 +158,52 @@ PLOT_TEST(epoch_window_yields_labelled_majors) {
|
|||||||
PLOT_CHECK(deep_majors >= 9);
|
PLOT_CHECK(deep_majors >= 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tick that *means* zero reads `0`. The walk starts at `ceil(lo/stride)*stride`,
|
||||||
|
/// inexact for every stride that is not a power of two, so a range straddling the
|
||||||
|
/// origin used to reach a few e-16 there and label it from the far end of the SI
|
||||||
|
/// table: `-1 -500m -139a 500m 1`. The tolerance is relative to the stride, which is
|
||||||
|
/// also the smallest magnitude a non-zero tick can have, so a range whose ticks are
|
||||||
|
/// *legitimately* that small keeps them.
|
||||||
|
PLOT_TEST(the_tick_at_the_origin_reads_zero) {
|
||||||
|
std::vector<plot::Tick> t;
|
||||||
|
const float widths[] = {80.0f, 40.0f, 10.0f};
|
||||||
|
for (const float min_px : widths) {
|
||||||
|
for (const bool minors : {false, true}) {
|
||||||
|
plot::generate_ticks({-1.0, 1.0}, 400.0f, min_px, t, minors);
|
||||||
|
plot::usize zeros = 0;
|
||||||
|
for (const plot::Tick& tick : t) {
|
||||||
|
if (!tick.major) continue;
|
||||||
|
PLOT_CHECK(!tick.label.empty());
|
||||||
|
// Nothing between -500m and 500m but the origin itself, so no major may
|
||||||
|
// carry a prefix from below `m`.
|
||||||
|
PLOT_CHECK(tick.label.find('a') == std::string::npos);
|
||||||
|
PLOT_CHECK(tick.label.find('f') == std::string::npos);
|
||||||
|
if (tick.label == "0") {
|
||||||
|
++zeros;
|
||||||
|
PLOT_CHECK(tick.value == 0.0); // exact, so a Time axis relabels it as 0 too
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PLOT_CHECK(zeros == 1u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A range whose ticks really are atto-scale is untouched: only the origin folds.
|
||||||
|
plot::generate_ticks({-2e-16, 2e-16}, 400.0f, 80.0f, t, /*include_minors=*/true);
|
||||||
|
plot::usize tiny = 0;
|
||||||
|
plot::usize tiny_zeros = 0;
|
||||||
|
for (const plot::Tick& tick : t) {
|
||||||
|
if (!tick.major) continue;
|
||||||
|
if (tick.value == 0.0) {
|
||||||
|
++tiny_zeros;
|
||||||
|
} else {
|
||||||
|
++tiny;
|
||||||
|
PLOT_CHECK(tick.label.find('a') != std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PLOT_CHECK(tiny_zeros == 1u);
|
||||||
|
PLOT_CHECK(tiny >= 2u);
|
||||||
|
}
|
||||||
|
|
||||||
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
PLOT_TEST(si_labels_are_utf8_and_never_overrun) {
|
||||||
char b[32];
|
char b[32];
|
||||||
plot::format_si(0.0000015, b, sizeof b);
|
plot::format_si(0.0000015, b, sizeof b);
|
||||||
|
|||||||
Reference in new issue
Block a user