Files
plotter/include/plot/plot/density.hpp
T
Martino Ferrari 738205293e fix: the density's brightness ceiling is the fade's steady state, and the ramp is linear
A user looked at the display and reported "the colour fading is not applied, it looks white or
full color, no opacity gradient" -- and the *numbers* were right: ink decayed, `step-time` was
1/e, `step-acquisitions` was 1 + 0.5 x 1. It was the mapping from a cell's value to a pixel.

**Neither the shader nor the blend was wrong** (the answer to the question that was asked): the
fragment write is already the weighted one (`vec4(u_color.rgb, u_color.a * ink)`) with the frame's
straight-alpha blend, and a cell at half ink over a grid line rendered 160 where the arithmetic
predicts 160. What was wrong was the **scale**:

- `full_scale` defaulted to **1.0**, so the *newest* acquisition already sat at the ceiling and
  everything above it clamped to the same pixel -- a display of one sweep and a display of five
  were one white. It is now **1/(1 - 0.5) = 2.0**, the steady state of the default per-acquisition
  fade, so one sweep reads half ink and white is reached only where acquisitions agree. A caller
  whose fade settles elsewhere states its own sum (udpscope's is `1/(1 - factor)`, in its commit).
- `gamma` defaulted to **0.45**, the value `Style::intensity_gamma` wants where the measure is a
  *dwell fraction*; on a *count* it spends most of the ramp on the last sweep (a quarter of the
  scale mapped to 0.54). It is now **1.0 -- linear in sweeps**, because half as many sweeps must
  read as half the ink.

The case that would have caught this is a **framebuffer** reading, not the grid's, and
`a_phosphor_density_is_drawn_under_the_traces` now makes it: it fades the grid to a quarter
through the time rule (`dt = tau ln 4`, asserted at 0.25 off the ink) and reads the same pixel
back, so the ramp is asserted where the eye sees it -- **`empty=21 full=83 quarter=54`** (red
channel), against the 199 / 199 the ceiling and a single sweep both read at the old defaults. A
full cell must be measurably above the background and above a quarter cell, and a quarter cell
above the background: three assertions on pixels, none of which the grid's own numbers can make.

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

146 lines
8.3 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
///
/// The two are answers to two different questions, which is why the choice belongs to the
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
enum class DensityDecay {
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
/// fading it.
TimeConstant,
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
/// last few sweeps is what the display holds -- which is what a caller wants when the
/// interesting comparison is between successive acquisitions and not between seconds.
PerAcquisition,
};
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
///
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
/// the same mapping that could disagree with the first.
struct DensityConfig {
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
/// holding a stale image -- see `Plot::set_density()`.
bool enabled = false;
/// How the grid fades (see `DensityDecay`).
DensityDecay decay = DensityDecay::PerAcquisition;
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
/// the frame rate. `<= 0` freezes the grid.
f32 time_constant_s = 1.0f;
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
/// seventh the layer is under 1 %.
f32 per_acquisition = 0.5f;
/// The cell value that reads as **full brightness**, in accumulated sweeps. The default is the
/// ink the **default fade settles at** -- `1/(1 - 0.5)`, the geometric sum of
/// `PerAcquisition`'s factor -- and that is the rule worth keeping: a ceiling at *one* sweep
/// puts the newest acquisition at full white and everything above it clamps to the same pixel,
/// so a display of one sweep and a display of five read alike and the fade is invisible
/// (measured: a cell at a quarter of the scale rendered 104 of 255 while one at the ceiling
/// rendered 156, but with the ceiling at one sweep both were 199). A caller whose fade settles
/// elsewhere wants its own sum here.
f32 full_scale = 2.0f;
/// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by
/// default: linear in sweeps**, because that is what a count means -- a cell covered by half the
/// acquisitions is half the ink. Under 1 lifts the faint end, and that is what
/// `Style::intensity_gamma` is for where the value is a *dwell fraction* rather than a count;
/// here it flattens the top instead (0.45 maps a quarter of the scale to 0.54 and the ceiling to
/// 1.0, which is most of the ramp spent on the last sweep). Over 1 darkens the faint end for a
/// caller that wants only the strong agreement visible.
f32 gamma = 1.0f;
};
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its
/// resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it. Memory is therefore
/// `columns() * rows() * 4` bytes whatever the trigger has done: an 800x480 axes is 1.5 MB and a
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. The renderer adds
/// one byte per cell as the luminance it uploads (`DensityGrid::cells()` is `f32` because a
/// **count** is what decays and what `ink()`/`peak()` report; the byte is derived per changed
/// frame, not stored here).
///
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
///
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device pixel `(axes.x + c, axes.y + r)`, where `axes` is
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move.
class DensityGrid {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first
/// accumulation on a laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per device pixel -- see the class comment for
/// the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
if (column >= columns_ || row >= rows_) return 0.0f;
return cells_[static_cast<usize>(row) * columns_ + column];
}
/// The **total ink**: every cell's value summed. The reading that says how much has been
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
/// and is 0 for a grid that is off or was just cleared.
f64 ink() const noexcept {
f64 total = 0.0;
for (const f32 v : cells_) total += static_cast<f64>(v);
return total;
}
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
f32 peak() const noexcept {
f32 top = 0.0f;
for (const f32 v : cells_) top = v > top ? v : top;
return top;
}
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
/// The renderer compares it with the revision it last uploaded, so a frame that changed
/// nothing uploads nothing.
u64 revision() const noexcept { return revision_; }
private:
friend class Plot;
std::vector<f32> cells_;
u32 columns_ = 0;
u32 rows_ = 0;
u64 revision_ = 0;
};
} // namespace plot