fix: a dense trace is drawn one pixel column at a time, so it cannot moiré
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
62df049f53
commit
5f5b87ca60
5 files changed
+94
-8
No files matched your search
@@ -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
|
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
|
||||||
`last event ... ago` are checked there.
|
`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 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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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 —
|
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
|
`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.
|
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
|
- `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
|
drawn-vs-quoted D px over R raw paints` plus a
|
||||||
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
`[ink] paint N <flags> 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
|
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
|
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.
|
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.**
|
- **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
|
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
|
timestamp's is a property of *how long the scope has been running*, because the stamps are
|
||||||
|
|||||||
@@ -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
|
and whether the LOD path was taken at all. A segment count in the millions is this defect
|
||||||
returning.
|
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
|
**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
|
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
|
||||||
side-panel toggle and an overflow menu. Those
|
side-panel toggle and an overflow menu. Those
|
||||||
|
|||||||
Vendored
+1
-1
Submodule deps/plotter updated: bee318870f...e81e84645f.
@@ -35,6 +35,13 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
|||||||
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
|
• 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
|
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.
|
wandered does not drift away from its axis as you zoom.
|
||||||
|
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
|
||||||
|
time</i>: 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 <i>crawl</i> 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.
|
||||||
|
|
||||||
<b>Example</b>
|
<b>Example</b>
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -411,6 +411,12 @@ struct App {
|
|||||||
std::uint64_t dbg_visible_max = 0;
|
std::uint64_t dbg_visible_max = 0;
|
||||||
int dbg_raw_frames = 0;
|
int dbg_raw_frames = 0;
|
||||||
int dbg_lod_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.
|
/// 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`
|
/// 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
|
/// 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;
|
app->dbg_seg_last = s.staged_segments;
|
||||||
if (s.raw_traces > 0) ++app->dbg_raw_frames;
|
if (s.raw_traces > 0) ++app->dbg_raw_frames;
|
||||||
if (s.lod_traces > 0) ++app->dbg_lod_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) check_frame_ink(*app);
|
||||||
if (s.raw_traces == 0 && s.lod_traces == 0) {
|
if (s.raw_traces == 0 && s.lod_traces == 0) {
|
||||||
++app->dbg_notrace_frames;
|
++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_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_n > 0 ? app.dbg_paint_ms_sum / app.dbg_paint_n : 0.0,
|
||||||
app.dbg_paint_ms_max, app.dbg_paint_n);
|
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",
|
"(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<unsigned long long>(app.dbg_paint_n > 0
|
static_cast<unsigned long long>(app.dbg_paint_n > 0
|
||||||
? app.dbg_seg_sum / app.dbg_paint_n
|
? app.dbg_seg_sum / app.dbg_paint_n
|
||||||
: 0),
|
: 0),
|
||||||
|
|||||||
Reference in new issue
Block a user