fix(render): a dense trace is drawn one column at a time, not as a polyline

An LOD envelope staged as a polyline moirés. level_for() sizes a bucket at one to
two pixel columns and Envelope emits two points per bucket, so a column carries
three or four near-vertical hairlines at as many sub-pixel phases; under src-alpha
compositing its brightness is then a non-linear function of those phases, the
pattern repeats at the beat between the bucket pitch and the pixel pitch, and a pan
sweeps the beat so the banding crawls.

draw_traces now reduces such a window onto the pixel grid: one exactly vertical
segment per integer column of the axes, from the lowest to the highest screen y any
gathered point put in it, flushed in framebuffer pixels with the snap on. One phase
for the whole trace, so there is nothing left to beat -- and the geometry becomes
O(axes.w) rather than O(buckets).

Gated on the gathered density rather than on the level (the raw path beats the same
way), and on Envelope alone. A hole stays a hole, disjoint neighbours are joined so
a fast edge keeps it, and a span under the stroke is drawn as the stroke since the
shader caps nothing. It is the one carve-out in u_snap's data/chrome rule: a column
is a pixel column, and the y bounds it reports are untouched.

Measured by the new test as the number of distinct coverage levels in the ink --
4 against the polyline's 126, with 627 segments against 60 054. A count of
partially covered pixels was tried first and separates nothing (577 against 652):
a 1.5 px stroke's fringe always spills into the next column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 02:00:25 +02:00
1 parent bee318870f
commit e81e84645f
5 files changed
+301 -3

No files matched your search

+132 -2
View File
@@ -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,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<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