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:
1 parent
3ad02e5120
commit
6a6915e64e
17 files changed
+1199
-13
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user