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:
1 parent
bee318870f
commit
e81e84645f
5 files changed
+301
-3
No files matched your search
+132
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user