Files
plotter/docs/data-and-performance.md
T
Martino Ferrari 1b8b55d3ee feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset
between latest acquisition and persistent image", "persistent instead of being brighter + opaque is
just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image
+ last acquisition (so for example last acquisition never fade in time mode)". What they add up to is
that this was not a phosphor yet: it was a **density with a solid trace drawn over it**.

Four changes, and the first is the one the others follow from:

- **The current acquisition is deposited like every other, and no solid trace is drawn over the
  image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the
  image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as
  well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last
  acquisition", where the last acquisition never faded in time mode.
- **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized
  `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that
  had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a
  cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4`
  bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which
  is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB.
- **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now
  defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that
  colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour
  its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state --
  the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because
  there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets
  `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just
  not the one a phosphor makes.

The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a
fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated
cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old
monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer
(`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the
trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame
at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in
the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart
(`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should
be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`,
`a_density_fades_by_its_time_constant` `exp(-dt/tau)`).

Suite: **397 cases, 0 failed**, 4/4 ctest entries.
2026-10-16 20:21:38 +02:00

460 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 is the trace, one grid per trace
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- 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, and there is one per trace.** One `f32` cell per
device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360
viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand
acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so
the display's memory is that figure times the traces on screen — four signals in one 800x480 row
are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density
that had one hue for the plot could not say *which* trace put the ink there, and two signals
agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's
image; a trace with no series (an unbound slot) holds no grid at all.
- **A cell's value is a number of sweeps**, and 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, shared by every trace's deposit), 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 newest acquisition is deposited like every other, and no trace is drawn over the image.**
`Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so
the acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
image + last acquisition" a user reported, where the newest acquisition never faded.
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
`DensityConfig::full_scale` defaults to **one sweep**, so a cell with one acquisition's ink is
drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place
every acquisition settles *exactly* at the colour its trace would have drawn — the settlement is
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
sweep and the image behind it. The case that reads either one off the *screen* rather than the
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
- **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 with the trace's colour. 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 *per trace* — or one draw call each.**
The upload is one byte per cell (**179 kB** for the axes above); the one texture is re-filled per
trace, and a frame whose grids did not move skips the conversion and the uploads entirely and
pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes
the grids move every frame under `TimeConstant`, and what makes them 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,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**267 472 cells each,
ink 584/584**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
background's.
## 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.