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

+93
View File
@@ -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