Compare commits
1
Commits
0.1
...
e81e84645f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81e84645f |
No files matched your search
@@ -37,6 +37,12 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
||||
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
||||
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
||||
data keeps the older placement -- right edge on the newest sample);
|
||||
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
|
||||
one snapped vertical segment per integer column, lit from that column's own extremes --
|
||||
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
|
||||
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
|
||||
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
|
||||
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
|
||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||
|
||||
@@ -166,6 +166,56 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
|
||||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||
sides go through the same instanced anti-aliased quad pipeline.
|
||||
|
||||
## Dense traces are reduced onto the pixel grid
|
||||
|
||||
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||||
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||||
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||||
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||||
|
||||
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||||
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||||
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||||
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
|
||||
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
|
||||
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
|
||||
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
|
||||
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
|
||||
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||||
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||||
and the snap then puts that one phase on the grid.
|
||||
|
||||
Four properties of the reduction are load-bearing:
|
||||
|
||||
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||||
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||||
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||||
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||||
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||||
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||||
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||||
would draw a vertical line over x the series says nothing about.
|
||||
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||||
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||||
trace, which is what the polyline's connector prevented. The later column is extended to
|
||||
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||||
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||||
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||||
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||||
the two bounds are equal, nothing at all.
|
||||
|
||||
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||||
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||||
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||||
are untouched by the snap.
|
||||
|
||||
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||||
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||||
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||||
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||||
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||||
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||||
|
||||
## One sample or a statistic of many
|
||||
|
||||
Every number the library reports is one the samples actually hold. What differs between
|
||||
|
||||
@@ -29,6 +29,11 @@ namespace plot {
|
||||
struct FrameStats {
|
||||
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
||||
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
|
||||
/// per column, lit from that column's own extremes. Counts a trace from either
|
||||
/// path above -- the reduction is about the *density* of the gathered points,
|
||||
/// not about which level produced them.
|
||||
u32 column_traces = 0;
|
||||
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||
@@ -500,6 +505,16 @@ class Renderer {
|
||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||
/// pins.
|
||||
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
||||
/// One gathered window reduced onto the pixel grid and staged in framebuffer
|
||||
/// pixels: for each integer column of `layout.axes`, one exactly vertical
|
||||
/// segment from the lowest to the highest screen y any gathered point put in
|
||||
/// it. Returns how many columns were staged (0 stages nothing); the caller
|
||||
/// flushes with `pixel_mapping()` and `snap = true`.
|
||||
///
|
||||
/// `gather_` is the input, read with the same origin `draw_traces` asked the
|
||||
/// series for. See `draw_traces` for when this path is taken and why.
|
||||
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||
f64 x_origin, u32 points);
|
||||
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
||||
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
||||
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
||||
@@ -611,7 +626,11 @@ class Renderer {
|
||||
render::TextBatch glyphs_under_;
|
||||
render::TextBatch glyphs_over_;
|
||||
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||
std::vector<f32> staged_; ///< scratch for one staged window, re-used
|
||||
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
|
||||
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
|
||||
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
|
||||
/// window.
|
||||
std::vector<f32> columns_;
|
||||
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
||||
|
||||
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
||||
|
||||
@@ -972,6 +972,91 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
|
||||
|
||||
// ------------------------------------------------------------------- data
|
||||
|
||||
u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||
f64 x_origin, u32 points) {
|
||||
const Transform xt = plot.x_transform(trace.axis_x);
|
||||
const Transform yt = plot.y_transform(trace.axis_y);
|
||||
const f32 left = layout.axes.x;
|
||||
// One column per whole pixel of the axes, plus the partial one at the right-hand
|
||||
// edge: `axes.w` is fractional, and the samples that land in that last fraction
|
||||
// are inside the frame.
|
||||
const u32 count = static_cast<u32>(layout.axes.w) + 1u;
|
||||
constexpr f32 kInf = std::numeric_limits<f32>::infinity();
|
||||
columns_.resize(2u * static_cast<usize>(count));
|
||||
for (u32 c = 0; c < count; ++c) {
|
||||
columns_[2u * c] = kInf; // the lowest screen y seen in this column
|
||||
columns_[2u * c + 1u] = -kInf; // and the highest; `lo <= hi` is "present"
|
||||
}
|
||||
|
||||
for (u32 k = 0; k < points; ++k) {
|
||||
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
||||
const f64 sx = xt.to_screen_x(x_abs);
|
||||
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
||||
// A NaN sample, and a non-positive one on a log axis, have no position: both
|
||||
// arrive here as a non-finite screen coordinate.
|
||||
if (!std::isfinite(sx) || !std::isfinite(sy)) continue;
|
||||
const f64 column = std::floor(sx - static_cast<f64>(left));
|
||||
// Clamped, not dropped. The gathered window carries one sample of slack on each
|
||||
// side, and that sample's ink belongs to the edge column -- it is what the
|
||||
// polyline drew there, under the same clip. Nothing further out can arrive: the
|
||||
// window is the visible range's.
|
||||
const u32 c = !(column > 0.0) ? 0u
|
||||
: column >= static_cast<f64>(count - 1u) ? count - 1u
|
||||
: static_cast<u32>(column);
|
||||
const f32 y = static_cast<f32>(sy);
|
||||
if (y < columns_[2u * c]) columns_[2u * c] = y;
|
||||
if (y > columns_[2u * c + 1u]) columns_[2u * c + 1u] = y;
|
||||
}
|
||||
|
||||
const f32 width = style_.device(trace.width);
|
||||
u32 staged = 0;
|
||||
f32 prev_lo = 0.0f;
|
||||
f32 prev_hi = 0.0f;
|
||||
bool joined = false; // the column before this one is present, so there is a join to make
|
||||
for (u32 c = 0; c < count; ++c) {
|
||||
f32 lo = columns_[2u * c];
|
||||
f32 hi = columns_[2u * c + 1u];
|
||||
if (!(lo <= hi)) {
|
||||
// No sample landed here. A hole in the data stays a hole: bridging it would
|
||||
// draw a vertical line over x the series says nothing about, where the
|
||||
// polyline drew a diagonal across the gap at most one pixel wide.
|
||||
joined = false;
|
||||
continue;
|
||||
}
|
||||
// Connectivity. Two columns' extremes need not overlap -- a fast edge rises
|
||||
// through a whole column -- and two disjoint vertical segments read as a broken
|
||||
// trace, which is exactly what the polyline's connecting segment prevented. The
|
||||
// later column is extended to meet the earlier one's nearest bound, so the join
|
||||
// is made inside the column that moved and no ink appears left of it.
|
||||
if (joined) {
|
||||
if (hi < prev_lo) {
|
||||
hi = prev_lo;
|
||||
} else if (lo > prev_hi) {
|
||||
lo = prev_hi;
|
||||
}
|
||||
}
|
||||
prev_lo = lo;
|
||||
prev_hi = hi;
|
||||
joined = true;
|
||||
// A span shorter than the stroke is drawn *as* the stroke: the fragment shader
|
||||
// ramps on the distance to the segment's axis and does not cap its ends, so a
|
||||
// vertical segment shorter than its own width rasterises a sliver of itself --
|
||||
// and a column of a flat trace, where `lo == hi`, nothing at all.
|
||||
if (hi - lo < width) {
|
||||
const f32 mid = 0.5f * (lo + hi);
|
||||
lo = mid - 0.5f * width;
|
||||
hi = mid + 0.5f * width;
|
||||
}
|
||||
// The column's centre, in framebuffer pixels. Every segment therefore shares one
|
||||
// sub-pixel phase, which is the whole point (see `draw_traces`), and the flush's
|
||||
// snap puts that one phase on the grid.
|
||||
const f32 x = left + static_cast<f32>(c) + 0.5f;
|
||||
lines_.add_segment({x, lo}, {x, hi});
|
||||
++staged;
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
||||
// would flatten every trace onto a single row -- a frame that looks like a
|
||||
@@ -1111,9 +1196,54 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
|
||||
// markers below are the whole trace.
|
||||
if (trace.width > 0.0f) {
|
||||
// A window denser than two gathered points per pixel column is drawn as *columns*
|
||||
// rather than as a polyline: one exactly vertical, pixel-snapped segment per column,
|
||||
// spanning what that column's own points reach.
|
||||
//
|
||||
// The polyline's geometry at this density is a stack of near-vertical hairlines at
|
||||
// *fractional* x -- an envelope bucket spans (spp/2, spp] samples, so there are one to
|
||||
// two buckets per column, each emitting its own min->max hairline plus a connector to
|
||||
// the next, about three and a half per column. Each is composited with its own
|
||||
// sub-pixel coverage, so a column's brightness is a non-linear function of those
|
||||
// phases; the phases repeat at the beat between the bucket pitch and the pixel pitch,
|
||||
// which is a **moire** across the trace, and panning sweeps the beat so the pattern
|
||||
// crawls. It is strongest exactly where a scope is used hardest: a high sample rate, a
|
||||
// high signal frequency, or a noisy signal, all of which make the ribbon thin enough
|
||||
// for its per-column coverage to be visible as texture.
|
||||
//
|
||||
// Reducing to one segment per column removes the cause rather than the symptom: there
|
||||
// is one phase on the whole trace (and the snap puts it on the grid), so there is
|
||||
// nothing left to beat. It is also O(axes.w) geometry instead of O(buckets), and it
|
||||
// loses nothing a reader could have seen -- a column cannot show more than the extremes
|
||||
// of what it covers, which is what the band is.
|
||||
//
|
||||
// Two conditions, and each refuses a case this would damage.
|
||||
// - `Envelope` only. `Nearest` and `Mean` are one-point-per-bucket modes chosen so the
|
||||
// trace reads as *samples* or as a smoothed line; a min/max band would answer a
|
||||
// different question from the one the host asked.
|
||||
// - Two points per column. Below that the gather is sparser than the grid, and the
|
||||
// polyline's segments are the shape: `Series::gather()` also falls back to raw samples
|
||||
// when no complete bucket fits the range, and that window's trace is drawn by the two
|
||||
// segments that enter and leave the frame (see
|
||||
// `a_sparse_window_between_samples_still_inks_both_margins`). The real envelope path
|
||||
// emits two to four points per column by construction, so the test holds exactly when
|
||||
// it ran.
|
||||
const bool columns = trace.mode == data::GatherMode::Envelope &&
|
||||
static_cast<f64>(points) >= 2.0 * static_cast<f64>(layout.axes.w);
|
||||
const u32 staged = columns ? stage_trace_columns(plot, layout, trace, x_range.min, points) : 0u;
|
||||
if (staged != 0u) {
|
||||
// `snap = true` on geometry that is not chrome, which is the one carve-out in that
|
||||
// uniform's rule ("a line that reports a data position may not be moved off it"): a
|
||||
// column *is* a pixel column, and its x is already a screen coordinate derived from
|
||||
// one -- there is no data position to move it off. The y bounds, which are what the
|
||||
// segment reports, are untouched by the snap.
|
||||
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
|
||||
++stats_.column_traces;
|
||||
} else {
|
||||
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
|
||||
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
||||
}
|
||||
}
|
||||
|
||||
// Markers, staged as line segments through this same batch in *pixel* space
|
||||
// (so their size is device pixels whatever the data scale is). The stride is
|
||||
|
||||
@@ -868,6 +868,99 @@ PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A window denser than the pixel grid inks **whole columns**, which is what kills the moire.
|
||||
///
|
||||
/// Reported as "strong moire patterns due to a large number of vertical lines" on a high rate,
|
||||
/// a high frequency or a noisy signal. The polyline at this density is a stack of near-vertical
|
||||
/// hairlines at *fractional* x -- one to two envelope buckets per column, each a min->max
|
||||
/// hairline plus a connector -- so a column's brightness is a non-linear function of those
|
||||
/// sub-pixel phases, and the phases repeat at the beat between the bucket pitch and the pixel
|
||||
/// pitch. That beat is the pattern, and panning sweeps it, so it crawls.
|
||||
///
|
||||
/// The observable is the beat's own cause, measured: how many **distinct coverage levels** the
|
||||
/// trace's ink takes. One segment per column puts every column's ink at one sub-pixel phase, so
|
||||
/// the AA ramp is evaluated at the same few distances everywhere and the ink lands on **4**
|
||||
/// levels; the polyline's hairlines sit at arbitrary phases and its ink spreads over **126** of
|
||||
/// them. That spread *is* the texture, and it is what beats against the pixel pitch.
|
||||
///
|
||||
/// A count of partially covered pixels is *not* the reading, which is worth knowing before
|
||||
/// adding one: both renderings put about the same amount of ink in the fringe (577 against 652
|
||||
/// here, of 1400-odd inked pixels), because a 1.5 px stroke with a 1 px fringe always spills
|
||||
/// into its neighbour. The fix is not that the fringe is gone, it is that it is the *same*
|
||||
/// fringe in every column.
|
||||
///
|
||||
/// The window is one period of the sine over 107 samples per pixel: dense enough for the
|
||||
/// reduction and slow enough that the band is a *thin ribbon*, which is where the beat shows.
|
||||
/// A window whose buckets each span several periods paints a solid block and hides it.
|
||||
PLOT_TEST(a_dense_trace_is_drawn_as_whole_pixel_columns) {
|
||||
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));
|
||||
|
||||
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
dump_if_requested(r, "columns.ppm");
|
||||
|
||||
const plot::Rect a = p.layout().axes;
|
||||
// Blue over the background minus red is the trace's coverage, linearly: 8 at no coverage
|
||||
// and 199 at full (0x40C0FF over 0x101418). Nothing else in this frame is blue-dominant --
|
||||
// the grid and the axis line are grey -- so the two bands below are the trace's own ink,
|
||||
// split at three quarters covered.
|
||||
plot::u32 inked = 0;
|
||||
plot::u32 histogram[256] = {};
|
||||
for (plot::u32 row = 0; row < static_cast<plot::u32>(a.h); ++row) {
|
||||
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
|
||||
plot::u8 px[4] = {};
|
||||
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
|
||||
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
||||
if (cover > 40) { // the trace's ink, at a quarter covered or better
|
||||
++inked;
|
||||
++histogram[cover];
|
||||
}
|
||||
}
|
||||
}
|
||||
// How many *distinct* coverages the ink takes is the beat, measured. A column's ink is
|
||||
// placed at one sub-pixel phase, so the AA ramp is evaluated at the same few distances in
|
||||
// every column and the ink lands on a handful of levels; a polyline's hairlines sit at
|
||||
// arbitrary phases, so its ink spreads over the whole range of them. The floor of 4 pixels
|
||||
// per level ignores the stragglers at the ribbon's turning points.
|
||||
plot::u32 levels = 0;
|
||||
for (plot::u32 c = 0; c < 256u; ++c) {
|
||||
if (histogram[c] >= 4u) ++levels;
|
||||
}
|
||||
std::printf("[columns] axes=[%.1f %.1f %.1f %.1f] raw=%u lod=%u columns=%u segments=%u "
|
||||
"visible=%llu inked=%u levels=%u\n",
|
||||
a.x, a.y, a.w, a.h, r.stats().raw_traces, r.stats().lod_traces,
|
||||
r.stats().column_traces, r.stats().staged_segments,
|
||||
static_cast<unsigned long long>(r.stats().visible_samples), inked, levels);
|
||||
|
||||
// The reduction ran, and on the *raw* path: the gate is the gathered density, not the level.
|
||||
PLOT_CHECK(r.stats().column_traces == 1);
|
||||
PLOT_CHECK(r.stats().visible_samples > 50'000u);
|
||||
// One segment per column, so the frame's whole geometry -- grid and axis lines included --
|
||||
// is bounded by the axis' width: 627 against the polyline's 60 054.
|
||||
PLOT_CHECK(r.stats().staged_segments < static_cast<plot::u32>(a.w) + 64u);
|
||||
// The ribbon is there, and its ink sits on a handful of coverage levels (4) rather than on a
|
||||
// continuum of them (126).
|
||||
PLOT_CHECK(inked > static_cast<plot::u32>(a.w));
|
||||
PLOT_CHECK(levels <= 8u);
|
||||
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// Resolution #1 of the brief: the renderer draws into whatever framebuffer is
|
||||
// bound and clears only its viewport, so a host that embeds the plot in a
|
||||
// sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given
|
||||
|
||||
Reference in new issue
Block a user