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.
This commit is contained in:
Martino Ferrari committed 2026-10-16 20:21:38 +02:00
1 parent 738205293e
commit 1b8b55d3ee
12 files changed
+534 -299

No files matched your search

+93 -39
View File
@@ -2852,9 +2852,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
// deposits nothing at all.
PLOT_CHECK(!f.plot.density().enabled);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid().peak() == 0.0f);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
plot::DensityConfig cfg;
cfg.enabled = true;
@@ -2862,7 +2862,7 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
PLOT_CHECK(f.plot.density().enabled);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
@@ -2895,9 +2895,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout();
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().columns() < columns);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(),
static_cast<double>(f.plot.density_grid().columns()), 1.0);
PLOT_CHECK(f.plot.density_grid(0u).columns() < columns);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(),
static_cast<double>(f.plot.density_grid(0u).columns()), 1.0);
}
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
@@ -2915,30 +2915,30 @@ PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
f.plot.accumulate_density();
// The grid is sized by the first accumulation, so the width is read after it -- and the ink
// of one pass *is* the width: one segment across the window, one cell per column.
const double width = static_cast<double>(f.plot.density_grid().columns());
const double one = f.plot.density_grid().ink();
const double width = static_cast<double>(f.plot.density_grid(0u).columns());
const double one = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(one, width, 1.0);
f.plot.accumulate_density();
const double two = f.plot.density_grid().ink();
const double two = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.5f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3);
f.plot.accumulate_density();
const double three = f.plot.density_grid().ink();
const double three = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.75f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.75f, 1e-3);
// The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes.
for (int i = 0; i < 40; ++i) f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 2.0f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 2.0f, 1e-3);
// A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass.
cfg.per_acquisition = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.0f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.0f, 1e-3);
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
three, 2.0);
@@ -2955,40 +2955,40 @@ PLOT_TEST(a_density_fades_by_its_time_constant) {
cfg.time_constant_s = 2.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double full = f.plot.density_grid().ink();
const double full = f.plot.density_grid(0u).ink();
// A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does
// a frame under `PerAcquisition`, which fades in `accumulate_density()` instead.
f.plot.decay_density(0.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
f.plot.decay_density(-1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
// Quarter of a time constant: exp(-0.25).
f.plot.decay_density(0.5);
const double quarter = full * std::exp(-0.25);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), quarter, quarter * 1e-4);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), quarter, quarter * 1e-4);
// Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink.
for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0);
PLOT_CHECK(f.plot.density_grid().ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid().peak() < 1e-3f);
PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid(0u).peak() < 1e-3f);
// A non-positive constant freezes the grid rather than fading it.
cfg.time_constant_s = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double frozen = f.plot.density_grid().ink();
const double frozen = f.plot.density_grid(0u).ink();
f.plot.decay_density(100.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), frozen, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), frozen, 1e-6);
// And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed
// changes nothing.
cfg.decay = plot::DensityDecay::PerAcquisition;
f.plot.set_density(cfg);
const double before = f.plot.density_grid().ink();
const double before = f.plot.density_grid(0u).ink();
f.plot.decay_density(1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), before, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6);
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
frozen);
@@ -3002,26 +3002,26 @@ PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
f.plot.clear_density();
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid().peak() == 0.0f);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
// The clear keeps the allocation -- the grid is still the screen's -- so the next
// accumulation does not have to reallocate it.
PLOT_CHECK(f.plot.density_grid().cell_count() > 0u);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
cfg.enabled = false;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
// ... and an accumulation while it is off deposits nothing (it is not a hidden build-up).
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
}
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
@@ -3034,14 +3034,14 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
cfg.enabled = true;
off.plot.set_density(cfg);
off.plot.accumulate_density();
PLOT_CHECK_NEAR(off.plot.density_grid().ink(), 0.0, 1e-9);
PLOT_CHECK_NEAR(off.plot.density_grid(0u).ink(), 0.0, 1e-9);
// ... and the same series inside the axis deposits one cell per column, in two rows: the
// first half at y = -1 and the second at y = +1, joined by a diagonal.
DensityFixture split(std::vector<f32>{-1.0f, 1.0f});
split.plot.set_density(cfg);
split.plot.accumulate_density();
const plot::DensityGrid& grid = split.plot.density_grid();
const plot::DensityGrid& grid = split.plot.density_grid(0u);
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes;
@@ -3073,8 +3073,8 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
gap.plot.set_density(cfg);
gap.plot.accumulate_density();
const plot::DensityGrid& whole_grid = whole.plot.density_grid();
const plot::DensityGrid& gap_grid = gap.plot.density_grid();
const plot::DensityGrid& whole_grid = whole.plot.density_grid(0u);
const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u);
const plot::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes;
const plot::u32 column = static_cast<plot::u32>(std::clamp(
@@ -3085,5 +3085,59 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
PLOT_CHECK(gap_grid.ink() < whole_grid.ink());
std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n",
off.plot.density_grid().ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
}
/// **One grid per trace.** A plot of two signals holds two images, each deposited from its own
/// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is
/// read): the two traces at two levels leave ink in two different rows, so one grid holding one
/// trace's ink and not the other's is the reading that says the images are not shared.
PLOT_TEST(a_density_holds_one_grid_per_trace) {
plot::data::ArraySeries lo(std::vector<plot::f32>{-0.5f, -0.5f}, 0.0, 1.0);
plot::data::ArraySeries hi(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(p.add_trace({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u);
PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
p.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
p.set_density(cfg);
p.accumulate_density();
PLOT_CHECK(p.trace_count() == 2u);
const plot::DensityGrid& g0 = p.density_grid(0u);
const plot::DensityGrid& g1 = p.density_grid(1u);
// Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink
// is the width in each grid, so neither picked up the other trace's ink (two traces would be two
// widths in a shared grid).
PLOT_CHECK(g0.cell_count() > 0u);
PLOT_CHECK(g1.cell_count() == g0.cell_count());
PLOT_CHECK_NEAR(g0.ink(), static_cast<double>(g0.columns()), 1.0);
PLOT_CHECK_NEAR(g1.ink(), static_cast<double>(g1.columns()), 1.0);
const plot::Transform ty = p.y_transform(ay);
const plot::Rect axes = p.layout().axes;
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::floor(ty.to_screen_y(y) - axes.y));
};
const plot::u32 lo_row = row_of(-0.5);
const plot::u32 hi_row = row_of(0.5);
PLOT_CHECK(lo_row != hi_row);
PLOT_CHECK(g0.at(0u, lo_row) > 0.0f);
PLOT_CHECK(g1.at(0u, hi_row) > 0.0f);
PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid
PLOT_CHECK(g1.at(0u, lo_row) == 0.0f);
// An out-of-range trace reads an empty image rather than anything borrowed.
PLOT_CHECK(p.density_grid(2u).cell_count() == 0u);
PLOT_CHECK(p.density_grid(9u).ink() == 0.0);
std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n",
g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row);
}