From 5f5b87ca60290c4e711d6b1ec0aacdbf2e79e3be Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 6 Oct 2026 02:02:52 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20a=20dense=20trace=20is=20drawn=20one=20p?= =?UTF-8?q?ixel=20column=20at=20a=20time,=20so=20it=20cannot=20moir=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At a high rate, a high frequency or on noise the plots banded visibly and the bands crawled under a pan. Three things compound to produce it, none a bug alone: a pyramid bucket spans one to two pixel columns, Envelope emits two points per bucket so each contributes a near-vertical hairline and a near-vertical connector, and every one lands at a fractional x because a trace is deliberately not snapped -- so a column's brightness beats against the pixel grid, and a pan sweeps the beat. The cure is upstream (deps/plotter, pin moved): draw_traces reduces such a window onto the pixel grid, one snapped vertical segment per column from that column's lowest to its highest value. One sub-pixel phase for the whole trace, so there is nothing left to beat, and the geometry is bounded by axes.w rather than by the buckets. frame path: gains a columns= field, which is what separates "the segment count collapsed because the reduction ran" from "the window got narrower". On the fast mock at 3 kHz over 20 s: columns=1200, segments 2041/2237 over 1201 painted frames, against columns=0, segments 10960/216093 over 263 before -- the same run at 4.6x the frame rate, with trace ink: clean either way. Also corrects the documented mock invocation: --batch 20000 exceeds the datagram limit and the mock dies with Errno 90. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 50 ++++++++++++++++++++++++++++++++++++---- README.md | 32 +++++++++++++++++++++++++ deps/plotter | 2 +- src/help/06-navigate.txt | 7 ++++++ src/ui.cpp | 11 +++++++-- 5 files changed, 94 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ddeae5..07cba98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -671,22 +671,34 @@ Two lines of the self-check output are assertions in disguise: blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and `last event ... ago` are checked there. - `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and - `frame path: raw=R lod=L frames, segments S/T (mean/max), visible max V samples` — the + `frame path: raw=R lod=L columns=C frames, segments S/T (mean/max), visible max V samples` + — the drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame clock's `before-paint` to the end of the host's `render` handler) and the whole GTK - paint. **`segments` max must stay in the tens of thousands and `lod` must dominate `raw` + paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw` on any run holding a large capture**: a max in the *millions* with `lod=0` is the empty-pyramid defect (see the `MinMaxPyramid` invariant below) — the renderer drawing every raw sample of a held event on every frame, which measured 6,000,073 segments and 100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per - pixel, which is where `level_for()`'s own gate puts the boundary. **`draw`'s max must sit + pixel, which is where `level_for()`'s own gate puts the boundary. + **`columns` must equal the frames that drew a trace at all on any run at a high rate**, and + it is the reading that says *why* `segments` is small: a dense gathered window is reduced + onto the pixel grid upstream (one snapped vertical segment per column — see the moiré + invariant below), so the geometry is bounded by `axes.w`, and a `columns=0` beside a large + `segments` is that reduction gone rather than a narrower window. Measured on + `--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames, + segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments + 10960/216093` over 263 before it existed — the same run at 4.6× the frame rate. + **`draw`'s max must sit at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything — - `tools/mock_udpstreamer.py --rate 1300000 --batch 20000` — since at the default 10 kS/s a - whole window fits in the raw path and the publish is too short to lose a race. + `tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a + whole window fits in the raw path and the publish is too short to lose a race. (Do not + raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock + dies with `OSError: [Errno 90] Message too long`.) - `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst drawn-vs-quoted D px over R raw paints` plus a `[ink] paint N mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=.. @@ -1111,6 +1123,34 @@ far the largest file. draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is 0 and a window index *is* the absolute one. +- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps + the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled* + under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or + noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a + bucket at one to two pixel columns, `Envelope` emits two points per bucket so each + contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per + column per trace), and every one lands at a **fractional** x because a trace is deliberately + not snapped. Under src-alpha compositing a column's brightness is then a non-linear function + of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket + pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s + `Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column + instead, so the trace has **one** phase and there is nothing left to beat. Four things to + know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the + gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw + path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction + off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole + stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one + carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is* + a pixel column. + **It needs a thin ribbon to be visible at all** — a window whose buckets span several periods + paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window + view says nothing either way. The guards are upstream's + `a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels** + in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates + nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next + column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this + path: it recomputes the **gathered** points, which the reduction is downstream of, so its + `drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone. - **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.** A value's precision is a property of the signal (24 bits is plenty for a sample); a timestamp's is a property of *how long the scope has been running*, because the stamps are diff --git a/README.md b/README.md index 08a18f7..cde4f72 100644 --- a/README.md +++ b/README.md @@ -939,6 +939,38 @@ into the tick, the plot's own draw and the whole GTK paint, and report the segme and whether the LOD path was taken at all. A segment count in the millions is this defect returning. +**A dense trace is drawn one pixel column at a time, which is what cured the moiré.** At a +high sample rate, a high signal frequency or on a noisy signal the plots showed strong, +*crawling* banding — and three things compounded to produce it, none of which is a bug on its +own. `MinMaxPyramid::level_for()` sizes a bucket at one to two pixel columns; `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 per +trace; and every one of them lands at a *fractional* x, because a trace is deliberately not +snapped. With src-alpha compositing a column's brightness is then 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, and a pan sweeps the beat. It needs a *thin ribbon* to be visible: +a window whose buckets each span several periods paints a saturated block and hides it +entirely, which is why it showed up on fast and noisy signals and not on a slow sine at a +whole-window view. + +`deps/plotter`'s `Renderer::draw_traces` now reduces such a window onto the pixel grid: one +exactly vertical segment per integer column, from the lowest to the highest screen y any +gathered point put in it, flushed in framebuffer pixels with the snap on. The whole trace +then has **one** sub-pixel phase, so there is nothing left to beat. See the upstream +`docs/data-and-performance.md` for the four rules it keeps (a hole stays a hole, disjoint +neighbours are joined so a fast edge keeps its join, a span under the stroke is drawn *as* +the stroke, and the gate is the gathered density rather than the LOD level) and for why this +is the one carve-out in `u_snap`'s rule that chrome may be snapped and data may not. + +It is also a large speed-up, because the geometry becomes O(axes.w) instead of O(buckets). +Measured on the fast mock (`--rate 1300000 --batch 2000 --freq 3000`, a 20 s run): `frame +path: raw=1 lod=262 columns=0 frames, segments 10960/216093` and **263 painted frames** +before, `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` and **1201 painted frames** +after — the same run at 4.6× the frame rate, with `trace ink:` reading `0 collapsed, 0 +staircase, 0 short, worst drawn-vs-quoted 0.0000 px` either way. `frame path:`'s +`columns=` field is the reading that separates "the segment count collapsed because the +reduction ran" from "the window got narrower". + **The panels and the toolbar.** The header bar carries only what you reach for *while watching a signal*: the time window, pause/resume, the trigger toggle, the side-panel toggle and an overflow menu. Those diff --git a/deps/plotter b/deps/plotter index bee3188..e81e846 160000 --- a/deps/plotter +++ b/deps/plotter @@ -1 +1 @@ -Subproject commit bee318870fd68c6fafbda8cb0201b73f921e4e4e +Subproject commit e81e84645f57a364ceb8469dc4989d8df9a41693 diff --git a/src/help/06-navigate.txt b/src/help/06-navigate.txt index 81f8f0b..68f41cd 100644 --- a/src/help/06-navigate.txt +++ b/src/help/06-navigate.txt @@ -35,6 +35,13 @@ The full table is in the shortcut overview (Ctrl+/). • Zooming hard into a held capture is exact. The renderer finds the visible samples by binary search over the real timestamps, never over a nominal sample grid, so a capture whose clock wandered does not drift away from its axis as you zoom. +• A trace holding far more samples than the plot has pixels is drawn one pixel column at a + time: each column is a single vertical line from the lowest to the highest value that falls + in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in + every column at slightly different sub-pixel positions, and how they overlap beats against the + pixel grid — which reads as bands of light and dark that crawl as you pan. One line per + column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand + line segments a frame instead of a couple of hundred thousand. Example diff --git a/src/ui.cpp b/src/ui.cpp index 450136c..d710504 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -411,6 +411,12 @@ struct App { std::uint64_t dbg_visible_max = 0; int dbg_raw_frames = 0; int dbg_lod_frames = 0; + /// Frames on which at least one trace's geometry was reduced onto the pixel grid (one + /// vertical segment per column). It is orthogonal to `raw`/`lod` on purpose -- the + /// reduction is gated on the *density* of the gathered points, not on which level + /// produced them -- and it is the only reading that distinguishes "the segment count + /// collapsed because the reduction ran" from "the window got narrower". + int dbg_column_frames = 0; /// Painted frames on which `draw_traces` drew **no trace at all**, and the last one's index. /// This is the observable for "the plot flickers as if there were no data": `staged_segments` /// cannot see it, because the grid and the instruments stage geometry of their own, so a frame @@ -1247,6 +1253,7 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) { app->dbg_seg_last = s.staged_segments; if (s.raw_traces > 0) ++app->dbg_raw_frames; if (s.lod_traces > 0) ++app->dbg_lod_frames; + if (s.column_traces > 0) ++app->dbg_column_frames; if (s.raw_traces > 0 || s.lod_traces > 0) check_frame_ink(*app); if (s.raw_traces == 0 && s.lod_traces == 0) { ++app->dbg_notrace_frames; @@ -8212,9 +8219,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_draw_ms_max, app.dbg_draw_max_frame, app.dbg_paint_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0, app.dbg_paint_ms_max, app.dbg_paint_n); - std::printf("[udpscope] frame path: raw=%d lod=%d frames, segments %llu/%u " + std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d frames, segments %llu/%u " "(mean/max), visible max %llu samples\n", - app.dbg_raw_frames, app.dbg_lod_frames, + app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames, static_cast(app.dbg_paint_n > 0 ? app.dbg_seg_sum / app.dbg_paint_n : 0),