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
@@ -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