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.
146 lines
8.3 KiB
C++
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
|