feat: a plot can carry a phosphor density, one image for every acquisition

`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.
This commit is contained in:
Martino Ferrari committed 2026-10-16 17:25:39 +02:00
1 parent 3ad02e5120
commit 6a6915e64e
17 files changed
+1199 -13

No files matched your search

+103
View File
@@ -12,6 +12,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
@@ -583,6 +584,73 @@ class Plot {
void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps a **screen-sized intensity grid** behind its traces (see `DensityGrid`
/// for the resolution, the per-cell meaning and the memory cost) and deposits the acquisition
/// on screen into it with `accumulate_density()`: brightness means how often a trace passed,
/// monochrome, and every cell fades as the display's own clock moves on. It is deliberately
/// *not* a set of retained traces -- the library has the trace geometry in hand, so a density
/// costs one grid whatever the trigger has done, where retaining the acquisitions would cost
/// a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. Unlike a band it needs no one-pixel floor on its own size -- a cell *is* a device pixel
/// by construction, which is the smallest mark the rule could ask for.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image, for a host that wants to draw it or read it back. Empty (0x0) until
/// the first `accumulate_density()` on a laid-out plot.
const DensityGrid& density_grid() const noexcept { return density_grid_; }
/// Deposits **the acquisition on screen** into the grid: every visible trace of this plot,
/// over the x window its own axis is showing, 1.0 into every cell it covers -- **once per cell
/// per sweep**, however many of that trace's own samples crossed it, so a cell's value is how
/// many traces have covered it since the last decay and not how much the beam moved (see
/// `DensityGrid`; the dwell question is `Style::intensity_grading`'s). Called once per
/// *acquisition*, which is the cadence the grid counts in: a
/// caller that deposited every frame would add one sweep sixty times a second, and the
/// per-acquisition fade would have nothing to measure itself against.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a
/// gap: it deposits nothing and joins nothing, the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
@@ -929,6 +997,23 @@ class Plot {
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans();
/// Deposits one trace's visible window into the density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace);
/// Deposits one already-mapped segment of a trace polyline: for every grid column the segment
/// crosses, the rows it covers in that column, +1 each. The columns it also *clips*: a column
/// whose slice of the segment lies entirely outside the axes is skipped, so a trace off the
/// top of the plot deposits nothing rather than a band along the edge, and a segment with both
/// ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept;
/// Grows `density_grid_` to the last layout's axes rect in device pixels, clearing it when the
/// size moved (a *different* screen is a different image), and returns whether the grid is
/// usable. The one place the grid is allocated.
bool size_density_grid();
/// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -977,6 +1062,24 @@ class Plot {
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grid it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. The grid is a member and not a local
/// because it is the *display's* memory -- it outlives the frames it was drawn in, which is the
/// whole point of accumulating into a screen-sized image rather than retaining traces.
DensityConfig density_{};
DensityGrid density_grid_{};
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row**: the key of the last (trace, column) to
/// deposit at that row, so one acquisition's own samples cannot count a cell twice (see
/// `deposit_density_segment()`). One `u64` a row -- a few kB at any window -- and not one per
/// cell, which would double the grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never