`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind its traces, into which each acquisition is deposited and which fades by a time constant or by a per-acquisition factor. It is the library's and not a host's because the library is the side that knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already staged, at a second derivation of the same mapping. It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32` cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a display of a thousand acquisitions costs what one costs and there is no depth to get wrong. Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one byte per cell uploaded on the frame the grid moved. - `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked column by column -- so the deposit is bounded by the axes' columns and not by the samples behind them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a different question the library already answers (`Style::intensity_grading`). - `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are one derivation, and a frame whose grid did not move skips the conversion and the upload entirely (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every row, which one row's assignment used to overwrite). - The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`, `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the axes' clip: it reserves no layout space and nothing on it can be picked, exactly as `Plot::set_invalid_spans()` does. `Style::density` is its tint. - The two fades are the two questions a persistence display can be asked -- "still visible a second later" and "the N-th back is gone" -- so the choice is the caller's. Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver (`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
432 lines
29 KiB
Markdown
432 lines
29 KiB
Markdown
# Data and performance
|
||
|
||
## The sample stores
|
||
|
||
All series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
|
||
describes the sample-indexing contract; a **uniform-grid** series additionally has
|
||
sample `i` at `x = x0() + i * dx()` (`ArraySeries`, `StreamSeries`), while
|
||
**`XYSeries`** carries its own sorted, ascending x array for data that is not on a
|
||
uniform grid.
|
||
|
||
```cpp
|
||
class Series {
|
||
public:
|
||
virtual u64 size() const;
|
||
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
|
||
virtual f64 dx() const; // 0 for XYSeries, which owns its x array
|
||
virtual Range x_bounds() const;
|
||
virtual Range y_bounds() const;
|
||
virtual const MinMaxPyramid& pyramid() const;
|
||
virtual bool streaming() const noexcept; // default false
|
||
virtual f64 x_at(u64 i) const; // x0() + i*dx(), or x()[i]
|
||
virtual bool neighbours(f64 x, u64& i0, u64& i1) const; // bracketing samples
|
||
virtual u64 snap_index(f64 x) const; // nearest sample
|
||
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||
GatherMode mode = GatherMode::Envelope) const;
|
||
|
||
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
|
||
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
|
||
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
|
||
};
|
||
```
|
||
|
||
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
|
||
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
|
||
|
||
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
|
||
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
|
||
is taken by value and owned, so the samples cannot change behind the series' back; the store
|
||
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild), `commit()`,
|
||
which rebuilds the pyramid and cached bounds from the current `y`, or `push(y)`, which
|
||
appends one sample incrementally — the pyramid folds it in amortised O(1) and the bounds
|
||
extend in place, NaN-aware, instead of rebuilding.
|
||
|
||
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
|
||
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`. `capacity` is rounded up to a power of
|
||
two by the ring, `size()` saturates at that rounded `capacity()` (a `StreamSeries(100)`
|
||
retains up to 128 samples), and the pyramid is reserved for the rounded value up front.
|
||
`freeze()` copies the window published by the last `acquire()` into a fresh `ArraySeries`
|
||
(consumer thread only): a stable snapshot that no later production can move.
|
||
|
||
## Non-finite samples
|
||
|
||
The library-wide NaN policy: **a non-finite sample is excluded from every aggregate** —
|
||
min, max, sum, sum of squares and the pyramid's per-bucket finite count — and from the
|
||
cached bounds. A window with no finite samples at all aggregates to a **zero-span
|
||
`Range` / zero sums**, never to NaN. The policy is applied everywhere an aggregate is
|
||
built: `MinMaxPyramid::append` (a NaN occupies its absolute position, so bucket
|
||
boundaries stay positional, but folds nothing), the edge folds in `Series::reduce`,
|
||
`sum` and `sumsq`, `ArraySeries::commit` and `push` bounds, and
|
||
`StreamSeries::acquire` bounds. A bucket whose samples were all non-finite is stored as
|
||
an *empty* aggregate (count 0, zero sums) that every reader skips — it is absent, not
|
||
zero-valued. `gather()` follows suit: Envelope and Mean emit nothing for such a bucket,
|
||
and Nearest draws its centre sample raw. Hosts that want gaps in the data should draw
|
||
them with NaN samples and will get exactly that: excluded from statistics, present in
|
||
raw geometry.
|
||
|
||
## Gather x is relative, and what each mode emits
|
||
|
||
**`Series::gather` emits x relative to `x0()`** — exactly `i * dx` for a uniform
|
||
series, `x_at(i) - x0()` for `XYSeries` — never the absolute coordinate. y is
|
||
unchanged. The renderer reconstructs absolute data x in f64 (`x0() + emitted`) before
|
||
transforming, so no precision is lost at the f32 narrowing: the staged value is
|
||
O(visible pixels), not O(epoch). (Staging an absolute epoch x as f32 would quantise it
|
||
to a grid coarser than the pixel — f32 carries 24 bits, about 1 s at 1.7e9 — while the
|
||
relative value is small and exact.) The emitted geometry always spans
|
||
`[begin, end)` exactly and never reaches outside it: at level ≥ 1 the bucket edges are
|
||
selected ceil/floor (`first` = first bucket starting at or after `begin`, `last` = one
|
||
past the last bucket ending at or before `end`) and the two partial edge spans are
|
||
drawn from raw samples, which is what keeps an unaligned window envelope-exact
|
||
(previously the buckets straddling the range leaked samples from outside it into the
|
||
geometry). `level` is clamped to `kMaxPyramidLevel` (57 — the deepest level whose
|
||
bucket span fits a `u64`), so any `u32` level is safe.
|
||
|
||
The `GatherMode` decides what each bucket contributes, at the same level the renderer
|
||
already picked:
|
||
|
||
- **`Envelope`** (the default): one (min, max) pair per bucket, min drawn at the
|
||
bucket's first sample and max at its last. The dense, extreme-reading geometry a
|
||
time-series trace wants.
|
||
- **`Nearest`**: one raw sample per bucket — the centre sample, the pick
|
||
`pyramid().snap()` would make — drawn at its own x. Thinner geometry for traces
|
||
whose *shape* matters more than their extremes.
|
||
- **`Mean`**: one point per bucket, y = the bucket's finite samples' mean (the exact
|
||
`bucket_sum()` the pyramid stores divided by its finite count), x at the bucket
|
||
centre. A smoothed trace for noisy data.
|
||
|
||
Level 0 is the raw samples for every mode.
|
||
|
||
## The SPSC contract
|
||
|
||
One producer thread, one consumer thread, and the division is strict
|
||
(`stream_series.hpp`):
|
||
|
||
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
|
||
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
|
||
The acquisition thread can therefore never stall the renderer.
|
||
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
|
||
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
|
||
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
|
||
`acquire()`.
|
||
|
||
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
|
||
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
|
||
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
|
||
The one exception is a producer that laps the consumer (more than `capacity()` samples
|
||
between two acquires), where the missing samples cannot be represented and the pyramid
|
||
restarts at the new base, re-reading the whole window.
|
||
|
||
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
|
||
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
|
||
data race and no value tears; but if the producer pushes `k` samples while a read is in
|
||
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
|
||
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
|
||
producer to stop before unread data (drop-on-full) or a claim protocol — a different
|
||
contract from this one.
|
||
|
||
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
|
||
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
|
||
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
|
||
|
||
## The min/max LOD pyramid
|
||
|
||
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
|
||
the visible pixels rather than on the sample count.
|
||
|
||
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
|
||
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
|
||
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
|
||
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
|
||
over the same buckets.
|
||
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
|
||
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
|
||
and, when it completes, pushes it and walks up, stopping at the first level that gains no
|
||
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
|
||
edge, bounding the memory by the retained window rather than by everything ever appended.
|
||
- An entry is 32 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
|
||
over and the finite-sample `count` the mean folds by), so level 0 costs `0.5n` bytes and
|
||
the geometric tail brings the whole pyramid to about **`1n` bytes — roughly 25% of the
|
||
`4n` bytes of raw samples** (`f32`). A min/max-only index would be `0.25n` (~6%); the
|
||
extra words are the price of answering mean/RMS/integral from whole buckets instead of
|
||
raw samples.
|
||
|
||
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
|
||
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
|
||
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
|
||
range queries live on `Series`, which owns the raw samples.
|
||
|
||
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
|
||
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
|
||
levels with more than one bucket, and to levels that give the window at least two buckets).
|
||
|
||
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
|
||
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
|
||
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
|
||
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
|
||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||
sides go through the same instanced anti-aliased quad pipeline.
|
||
|
||
## Dense traces are reduced onto the pixel grid
|
||
|
||
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||
|
||
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||
`GatherMode::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; and each of those lands at a **fractional** x, so with ordinary
|
||
src-alpha compositing a column's brightness is 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, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||
and the snap then puts that one phase on the grid.
|
||
|
||
Four properties of the reduction are load-bearing:
|
||
|
||
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||
would draw a vertical line over x the series says nothing about.
|
||
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||
trace, which is what the polyline's connector prevented. The later column is extended to
|
||
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||
the two bounds are equal, nothing at all.
|
||
|
||
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||
are untouched by the snap.
|
||
|
||
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||
|
||
## A reduced column can be graded by dwell
|
||
|
||
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
|
||
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
|
||
scope reads. It is off by default — a change of look, not a correction of one — and
|
||
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
|
||
grading is a way of drawing the reduction and not a third path.
|
||
|
||
What it puts back is the information the reduction throws away: a column's 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 band. The distribution cannot be recovered from the band, which is why the gather is
|
||
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
|
||
and the dwell read from those sub-buckets.
|
||
|
||
Five things carry it, and each was arrived at by being wrong first:
|
||
|
||
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
|
||
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
|
||
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
|
||
time.
|
||
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
|
||
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
|
||
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
|
||
row and the negation one past its last, so it costs two writes whatever its height, and the
|
||
running sum over the column is the per-row dwell.
|
||
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
|
||
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
|
||
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
|
||
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
|
||
the peak.
|
||
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
|
||
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
|
||
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
|
||
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
|
||
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
|
||
existence — and `intensity_gamma` lifts the faint end. The floor only holds if the riser's rows
|
||
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
|
||
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
|
||
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
|
||
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
|
||
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
|
||
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
|
||
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
|
||
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
|
||
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
|
||
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
|
||
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). A connector between two disjoint columns
|
||
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
|
||
measured to dwell.
|
||
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
|
||
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
|
||
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
|
||
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
|
||
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
|
||
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
|
||
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
|
||
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
|
||
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
|
||
limit, not the number.
|
||
|
||
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
|
||
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
|
||
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
|
||
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
|
||
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
|
||
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
|
||
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
|
||
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
|
||
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
|
||
and the floor is never reached.
|
||
|
||
## A sparse polyline is graded by slope
|
||
|
||
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
|
||
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
|
||
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
|
||
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
|
||
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
|
||
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
|
||
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
|
||
the moment you look at it closely. The thing a reader most needs told apart from a real signal
|
||
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
|
||
|
||
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
|
||
it must:
|
||
|
||
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
|
||
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
|
||
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
|
||
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
|
||
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
|
||
panning does not reshade the trace.
|
||
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
|
||
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
|
||
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
|
||
only `intensity_grading` and `intensity_steps > 1` are.
|
||
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
|
||
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
|
||
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
|
||
reaches too.
|
||
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
|
||
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
|
||
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||
|
||
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
|
||
threshold over the same transition at the same place. The level reads **191 either way** (a flat
|
||
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
|
||
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
|
||
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
|
||
left to see.
|
||
|
||
## A phosphor density accumulates into a screen-sized grid
|
||
|
||
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
|
||
passed, drawn behind them, fading by a time constant or by an acquisition count. It is the
|
||
library's because the library is the side that knows a trace's device geometry; a host that
|
||
built one itself would be re-rasterising traces the renderer has already staged.
|
||
|
||
The split from the grading above is what it costs: grading measures *one* frame's dwell and
|
||
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
|
||
also why it cannot be a per-frame accumulator.
|
||
|
||
- **The grid is the screen's, not the data's.** One `f32` cell per device pixel of the axes rect
|
||
(`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 viewport is a **573x313** grid
|
||
of **179 349 cells** and **717 396 bytes**, and a thousand acquisitions through it cost the
|
||
same. A cell's value is a **number of sweeps**: a trace covering it during one
|
||
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
|
||
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
|
||
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
|
||
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
|
||
kB), keyed by (trace, column), so two sweeps agreeing on a cell still count twice and the two
|
||
segments that share a column boundary do not.
|
||
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
|
||
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
|
||
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
|
||
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
|
||
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
|
||
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
|
||
accumulation affordable at the trigger's own rate.
|
||
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
|
||
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
|
||
only tint that byte. The one derivation is deliberate: a value a host reads back through
|
||
`DensityGrid::ink()`/`peak()` and the picture it sees come from the same numbers.
|
||
- **Per frame, it is one texture upload and one draw call -- or one draw call.** The upload is one
|
||
byte per cell (**179 kB** for the axes above); a frame whose grid did not move skips the
|
||
conversion and the upload entirely and pays the quad alone (`FrameStats::density_upload_bytes`
|
||
reads 0 for it). The decay is what makes the grid move every frame under `TimeConstant`, and
|
||
what makes it stand still between acquisitions under `PerAcquisition`.
|
||
|
||
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
|
||
(**584x458 = 267 472 cells, 1 069 888 bytes**, `ink=584 peak=1` for one flat sweep — the ink of
|
||
one sweep *is* the columns it covers),
|
||
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
|
||
(584 → 876 → 1022 on a 584-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
|
||
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink, and
|
||
`a_phosphor_density_is_drawn_under_the_traces` reads the frame: `cells=179349 uploaded=179349`
|
||
on the frame the grid moved and `uploaded=0` on the identical one after it, with the trace's
|
||
stroke keeping its own colour over the ink (`min_red < 40`, where a density drawn on top would
|
||
put the red at ~190).
|
||
|
||
## One sample or a statistic of many
|
||
|
||
Every number the library reports is one the samples actually hold. What differs between
|
||
these two is *how many* samples answer the question:
|
||
|
||
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
|
||
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
|
||
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
|
||
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
|
||
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
|
||
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
|
||
never on the `*_buckets` forms, which would silently report wrong extremes for an
|
||
unaligned interval.
|
||
- **The crosshair readout is a single sample — the nearest one — by default.**
|
||
`crosshair_span(series, x)` returns the two samples `x` lies between (`begin`, `end`, `t`),
|
||
and `CrosshairValue::NearestSample` takes whichever of the two is nearer: `t <= 0.5` the
|
||
earlier, otherwise the later. It answers "which datum is under the pointer", not "what is
|
||
the signal *at* this instant" — a linearly interpolated y is a number the series does not
|
||
contain, and on a source whose samples are already averaged or decimated at ingest it is a
|
||
claim about a signal nobody measured. That is why it is the default; a host whose x is a
|
||
quantity it is willing to interpolate states `CrosshairValue::Interpolated` and gets
|
||
`crosshair_interpolate(y_begin, y_end, t)` instead, at the crosshair's own x. The lookup is
|
||
O(1) and allocation-free either way, and it reads level 0, so the value is a stored sample
|
||
(or a blend of two) and never an LOD bucket's aggregate. `Plot::crosshair_point(trace, x,
|
||
out_x, out_y)` exposes **both** coordinates to a host and `crosshair_value()` the y alone;
|
||
`CrosshairRow::x`/`y` and its `marker` are placed from that same pair, so in
|
||
`NearestSample` the mark snaps onto the quoted sample and the dot and the number cannot
|
||
disagree. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
|
||
(an em dash, `"—"`), and an x outside the series window yields no value rather than
|
||
clamping to the first or last sample.
|
||
|
||
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
|
||
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
|
||
is part of the result's contract, not an implementation detail.
|
||
|
||
## Measured frame times
|
||
|
||
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
|
||
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
|
||
upper bounds and as a record of the software path, not as hardware numbers.
|
||
|
||
| demo | window | mean ms/frame | work |
|
||
|---|---|---|---|
|
||
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
|
||
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
|
||
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
|
||
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
|
||
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
|
||
|
||
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
|
||
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
|
||
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
|
||
library.
|