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

+40 -2
View File
@@ -48,6 +48,17 @@ struct FrameStats {
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text
/// The phosphor density's own cells, when one was drawn: `Plot::density_grid()`'s resolution,
/// zero for a plot with the density off. It is what the *frame* drew, so a host reading the
/// density's cost off a frame reads it here rather than off the grid, which says what is
/// accumulated and not what was spent.
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
};
/// Draws a `Plot` into whatever framebuffer is bound.
@@ -93,8 +104,8 @@ class Renderer {
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex
/// arrays and their instance buffers, and the glyph atlas texture. The
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
/// their instance buffers, the glyph atlas texture and the density's own texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object.
///
@@ -513,6 +524,18 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: the accumulated
/// image as one textured quad over the axes rect, in `Style::density`'s monochrome tint. Drawn
/// over the grid and the invalid-span bands and **under the traces**, which is what makes it a
/// record of where the traces have been rather than a second set of them.
///
/// The image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here from the
/// grid's counts -- the scale, the gamma and the tint are the plot's and this stage derives
/// none of them, so the numbers a host reads back and the picture it sees are one derivation.
/// A frame whose grid did not change (nothing accumulated, nothing decayed -- the steady state
/// of a display that has stopped being swept) skips the conversion *and* the upload and costs
/// the one draw call, which `FrameStats::density_upload_bytes` says per frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
@@ -679,6 +702,21 @@ class Renderer {
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture the
/// grid is uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and
/// re-created when that grid's size moves -- a resized plot is a differently sized image, and
/// the plot has already dropped what the old one held.
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid revision the texture currently holds. Compared per frame, so a frame whose grid
/// did not move skips the conversion *and* the upload -- the pause in a persistence display.
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;