feat: a dense trace's columns are graded by dwell, so density reads as brightness

Stage 1 cured the moire by collapsing a dense gathered window onto one vertical
segment per pixel column; this is the information that collapse throws away. A
column's min and max are the same whether its samples are spread evenly or piled
at its two ends, so the flat parts of a square wave and the edges between them
drew as one ink. Style::intensity_grading, which the library leaves off and this
app turns on at the first tick (the renderer does not exist until the area is
realized), grades each column by the time the signal spent at each row -- the
digital-phosphor look.

frame path: gains a graded= field, which must equal columns=: grading is a way of
drawing the reduction, not a third path. UDPSCOPE_NO_PHOSPHOR=1 puts the style
back to the library's default and is the A/B, since a frame mean cannot attribute
its own cost -- measured on the fast mock, draw 3.24 ms graded against 2.30 ms,
with segments 1340/4157 against 131/4306 over the same lod=453 frames. That A/B
is also what found the level-floor regression now fixed upstream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 03:02:48 +02:00
1 parent 5f5b87ca60
commit b4b26d3e31
5 files changed
+112 -4

No files matched your search

+44 -1
View File
@@ -671,7 +671,7 @@ 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 columns=C frames, segments S/T (mean/max), visible max V samples` `frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples`
— the — 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
@@ -689,6 +689,16 @@ Two lines of the self-check output are assertions in disguise:
`--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames, `--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 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. 10960/216093` over 263 before it existed — the same run at 4.6× the frame rate.
**`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is
a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the
style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read
either of these two lines against the ungraded path, since a frame mean cannot attribute its own
cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with
`draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and
`segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is
quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour
is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the
level-floor invariant below gone, which measured `raw=1154 lod=0` and 6.36 ms.
**`draw`'s max must sit **`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
@@ -1151,6 +1161,39 @@ far the largest file.
column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this 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 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. `drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone.
- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The
column above is honest about *where* the signal went and silent about *how long it was there*
— a column's min and max are identical whether its samples fill the span or pile at its two
ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead
(bright where the signal sits, faint where it only passes through, the digital-phosphor look),
and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by
an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in
the window build: `PlotArea::renderer()` is null until the area is realized, so there is no
earlier place to state a style. Three things to know here, the rest upstream in
`docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the
per-column-peak normalisation, the quantised alpha steps):
* **The finer gather level is floored at 1 whenever the flat path would have used the
pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and
`MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one
bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible)
gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat
path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look*
(fewer sub-columns than asked for, down to what the coarsest buckets give — still two or
three per column at 64 samples a bucket) and never the path. It is also why
`intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit.
* **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B
that makes either line readable against the ungraded path (see Verification).
* **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of
geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the
gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the
segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra
geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the
library's own guard, 8303 segments over a 572 px axis).
The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways
and profiles the band's outer fifths against its middle: flat reads **191/191**, graded
**191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and
`intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one
sub-bucket deep everywhere, is never dimmed out of existence.
- **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
+33
View File
@@ -971,6 +971,39 @@ 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 `columns=` field is the reading that separates "the segment count collapsed because the
reduction ran" from "the window got narrower". reduction ran" from "the window got narrower".
**Those columns are then graded by dwell, which is the rest of the answer.** A reduced column
is honest about *where* the signal went and silent about *how long it was there*: its min and
max are the same whether its samples fill the span evenly or pile at its two ends, so a ramp
and a pulse are one flat band. `Style::intensity_grading` — off in the library, which udpscope
turns on at the first tick, since the renderer does not exist until the area is realized —
draws each column graded instead: bright where the signal sits, faint where it only passes
through, the way a digital-phosphor scope reads. The distribution cannot be recovered from the
band, so the gather is asked at a level finer than the pixel grid and the dwell read from those
sub-buckets; the measure is dwell *per row*, each segment depositing one unit spread over the
rows it spans, and normalisation is against the column's own peak. See the upstream
`docs/data-and-performance.md` for the five things that carry it and for the two that were
wrong first (counting rows at +1 each inverts the pattern, grading a sine's fast middle as
brightly as its rails; a frame-wide scale needs a 33 MB density image *and* fades the trace out
exactly where it moves fastest).
It costs about a millisecond a frame and the A/B is the only way to read that, because a frame
mean cannot attribute its own cost: **`UDPSCOPE_NO_PHOSPHOR=1` puts the style back to the
library's default**, and the same probe run on the fast mock (`--rate 1300000 --batch 2000`)
reads `draw 3.24/21.43 ms` with the grading against `draw 2.30/25.76 ms` without, over the same
`lod=453` frames. **`frame path:`'s `graded=` must equal `columns=`** — grading is a way of
drawing the reduction and not a third path, so the two counts agreeing is what says the look
reaches every reduced frame (`columns=1166 graded=1166` in that run, `graded=0` under the env
var). The segment count rises with it, from a mean of 131 to 1340, which is the gradient's
tiers: alpha is quantised into steps because the line pipeline's colour is a uniform and a
per-segment alpha is inexpressible, and each step is its own draw call.
The one performance trap here is worth stating because only the A/B found it. Asking for a
finer level drops straight to **raw samples** once a sub-column falls under one pyramid bucket
(64 samples), which at the run's 172 samples a pixel it did: `raw=1154 lod=0` and 6.4 ms a
frame, i.e. grading undoing the very reduction it draws. The finer ask is floored at level 1
whenever the flat path would have used the pyramid, so what degrades is the *look* — fewer
sub-columns than asked for — and never the path.
**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
+1 -1
+6
View File
@@ -42,6 +42,12 @@ The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per 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 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. line segments a frame instead of a couple of hundred thousand.
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
where the signal spent its time, faint where it only passed through. A column's highest and
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
the edges between them faint. It is read from a finer division of each column rather than from
the line that is drawn, and it costs about a millisecond a frame.
<b>Example</b> <b>Example</b>
+28 -2
View File
@@ -311,6 +311,9 @@ struct App {
unsigned signal_generation = ~0u; unsigned signal_generation = ~0u;
bool updating_panels = false; ///< guards signal handlers during a rebuild bool updating_panels = false; ///< guards signal handlers during a rebuild
bool relayout_pending = false; bool relayout_pending = false;
/// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not
/// exist until the area is realized, so the one-shot has to be a tick's job.
bool style_stated = false;
bool signals_panel_initialised = false; bool signals_panel_initialised = false;
/// Set once the user edits the trigger level, so the panel stops defaulting it /// Set once the user edits the trigger level, so the panel stops defaulting it
/// into the middle of the declared range. /// into the middle of the declared range.
@@ -417,6 +420,11 @@ struct App {
/// produced them -- and it is the only reading that distinguishes "the segment count /// produced them -- and it is the only reading that distinguishes "the segment count
/// collapsed because the reduction ran" from "the window got narrower". /// collapsed because the reduction ran" from "the window got narrower".
int dbg_column_frames = 0; int dbg_column_frames = 0;
/// Frames on which at least one reduced column was drawn **graded by dwell** rather than
/// as one flat band (`Style::intensity_grading`, which this app turns on). A subset of
/// `columns`: grading is a way of drawing the reduction, not a third path, so the two
/// counts being equal is what says the look is reaching every reduced frame.
int dbg_graded_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
@@ -1254,6 +1262,7 @@ int on_render_probe(GtkGLArea*, GdkGLContext*, gpointer user) {
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.column_traces > 0) ++app->dbg_column_frames;
if (s.graded_traces > 0) ++app->dbg_graded_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;
@@ -4872,6 +4881,22 @@ gboolean tick(gpointer user) {
} }
} }
// The trace look, stated on the first tick that finds a renderer -- there is none until
// the area is realized, and this is the same "claim it when it exists" shape as the frame
// clock above. `intensity_grading` is off in the library by default (it is a change of
// look, not a correction, and other hosts must not be restyled by an upgrade); an
// oscilloscope is exactly the host it is for, so udpscope turns it on. A one-shot rather
// than a per-frame write because `Style` is plain data and nothing else touches it.
if (!app->style_stated && app->area != nullptr) {
if (plot::Renderer* r = app->area->renderer()) {
// `UDPSCOPE_NO_PHOSPHOR` puts it back to the library's default, which is the only way
// to read `frame cost:` and `frame path:` against the ungraded path -- a frame mean
// cannot attribute its own cost, so the A/B is the measurement.
r->style().intensity_grading = g_getenv("UDPSCOPE_NO_PHOSPHOR") == nullptr;
app->style_stated = true;
}
}
if (app->relayout_pending) { if (app->relayout_pending) {
app->relayout_pending = false; app->relayout_pending = false;
relayout_areas(*app); relayout_areas(*app);
@@ -8219,9 +8244,10 @@ 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 columns=%d frames, segments %llu/%u " std::printf("[udpscope] frame path: raw=%d lod=%d columns=%d graded=%d frames, segments "
"(mean/max), visible max %llu samples\n", "%llu/%u (mean/max), visible max %llu samples\n",
app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames, app.dbg_raw_frames, app.dbg_lod_frames, app.dbg_column_frames,
app.dbg_graded_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),