diff --git a/README.md b/README.md index a10b9de..d5524ca 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index 601405c..a6af8e0 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -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 diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 46b9671..f03e58b 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 gather_; ///< scratch for one gathered window, re-used - std::vector 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 columns_; mutable std::vector readback_; ///< dump_frame_ppm's row buffer /// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 44b142f..b844b42 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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(layout.axes.w) + 1u; + constexpr f32 kInf = std::numeric_limits::infinity(); + columns_.resize(2u * static_cast(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(gather_[2u * k]); + const f64 sx = xt.to_screen_x(x_abs); + const f64 sy = yt.to_screen_y(static_cast(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(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(count - 1u) ? count - 1u + : static_cast(column); + const f32 y = static_cast(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(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,8 +1196,53 @@ 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) { - stage_xy(gather_.data(), points, x_range.min, log_x, log_y); - flush_lines(map, trace.color, style_.device(trace.width), dash); + // 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(points) >= 2.0 * static_cast(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 diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index e142ae3..8916904 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(kW), static_cast(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(kW), static_cast(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(a.h); ++row) { + for (plot::u32 col = 0; col < static_cast(a.w); ++col) { + plot::u8 px[4] = {}; + const plot::u32 x = static_cast(a.x) + col; + PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast(a.y) + row), px)); + const int cover = static_cast(px[2]) - static_cast(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(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(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(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