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

+219
View File
@@ -348,6 +348,225 @@ void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
invalid_spans_.assign(spans.begin(), spans.end());
}
// --- phosphor density ---------------------------------------------------------
void Plot::set_density(const DensityConfig& config) {
// Disabling is a caller asking to stop paying for the display, and a trigger that has stopped
// firing never reaches another accumulation -- so the image goes now rather than at an event
// that may not come. The same rule, for the same reason, as the app's `set_persist_depth()`.
if (density_.enabled && !config.enabled) {
density_grid_.cells_.clear();
density_grid_.cells_.shrink_to_fit();
density_grid_.columns_ = 0u;
density_grid_.rows_ = 0u;
++density_grid_.revision_;
}
density_ = config;
}
void Plot::decay_density(f64 dt_s) {
if (!density_.enabled || density_.decay != DensityDecay::TimeConstant) return;
// A non-positive frame and a non-positive time constant are both "do not fade": the first is a
// caller that has not measured a frame yet, and the second is a caller asking for a grid that
// is drawn and never fades.
if (!(dt_s > 0.0) || !(density_.time_constant_s > 0.0f)) return;
const f32 factor =
static_cast<f32>(std::exp(-dt_s / static_cast<f64>(density_.time_constant_s)));
if (!(factor < 1.0f)) return;
for (f32& cell : density_grid_.cells_) cell *= factor;
++density_grid_.revision_;
}
void Plot::clear_density() noexcept {
if (density_grid_.cells_.empty()) return;
std::fill(density_grid_.cells_.begin(), density_grid_.cells_.end(), 0.0f);
++density_grid_.revision_;
}
bool Plot::size_density_grid() {
const Rect& axes = layout_.axes;
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
const u32 columns = static_cast<u32>(std::ceil(axes.w));
const u32 rows = static_cast<u32>(std::ceil(axes.h));
if (columns == 0u || rows == 0u) return false;
if (columns != density_grid_.columns_ || rows != density_grid_.rows_) {
// A grid of another size is another image, and there is nothing honest to carry over from
// one to the other: the cells are device pixels, so a resize moves every one of them. Drop
// what it held rather than resample it -- the display restarts, which is what a scope does
// when its screen changes.
density_grid_.cells_.assign(static_cast<usize>(columns) * rows, 0.0f);
// The per-sweep dedupe's stamps are sized with the grid and cleared with it: a stamp is only
// ever compared for equality against the key of the sweep being deposited, and every key has a
// non-zero trace number in its high half (`density_trace_` starts at 0 and is pre-incremented),
// so a zeroed stamp matches nothing.
density_stamp_.assign(rows, 0u);
density_grid_.columns_ = columns;
density_grid_.rows_ = rows;
++density_grid_.revision_;
}
return !density_grid_.cells_.empty();
}
void Plot::accumulate_density() {
if (!density_.enabled) return;
if (!size_density_grid()) return;
// The fade before the deposit, never after: under `PerAcquisition` the acquisition just drawn
// is the brightest one and each older one is the factor down, which is what "the N-th back is
// gone" means. TimeConstant fades in `decay_density()` instead, because its fade is the clock's
// and not the acquisition's.
if (density_.decay == DensityDecay::PerAcquisition) {
const f32 factor = std::clamp(density_.per_acquisition, 0.0f, 1.0f);
for (f32& cell : density_grid_.cells_) cell *= factor;
}
for (const Trace& trace : traces_) deposit_density_trace(trace);
++density_grid_.revision_;
}
void Plot::deposit_density_trace(const Trace& trace) {
if (!trace.visible()) return;
const data::Series& series = *trace.series;
const u64 n = series.size();
if (!(series.dx() > 0.0) || n == 0u) return;
// The same index window `Renderer::draw_traces()` maps, by the same calls: `neighbours()` on
// the series' own stamps -- never the `(x - x0()) / dx()` grid, which is a nominal spacing and
// would lose the segments entering and leaving the frame -- with one sample of slack on each
// side and clamped to the series.
const Range window = x_range(trace.axis_x);
u64 first = 0u;
u64 last = n;
u64 i0 = 0u;
u64 i1 = 0u;
if (series.neighbours(window.min, i0, i1)) first = i0 > 0u ? i0 - 1u : 0u;
if (series.neighbours(window.max, i0, i1)) last = std::min(n, i1 + 1u);
if (last <= first) return;
// The same level gate the renderer uses, and for the same reason: a window denser than the
// pixel grid is a *reduction*, and the density is a reduction of it too -- one pass per cell
// the column's own envelope crosses. The levels agree because the ask is the same ask.
const f64 samples_per_pixel =
static_cast<f64>(last - first) / static_cast<f64>(layout_.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
density_points_.clear();
series.gather(level, first, last, window.min, density_points_, trace.mode);
if (density_points_.size() < 2u) return;
// A fresh stamp for this trace's part of this acquisition: the key a cell is deduped against is
// `(this, column)`, so every trace of a plot deposits once per cell it covers even where they
// overlap, and a cell one trace crosses twice in one sweep deposits once.
++density_trace_;
const Transform tx = x_transform(trace.axis_x);
const Transform ty = y_transform(trace.axis_y);
f32 px = 0.0f;
f32 py = 0.0f;
u32 run = 0u; // points in the current unbroken run
// A run's *first* point is not deposited on its own: the segment that leaves it already puts
// ink in that cell, and depositing both would count the pen's arrival twice -- a single pass
// would read as two in the cell every trace starts in, which is exactly the cell a peak
// reading is taken from. A run of one point -- a lone visible sample, or one isolated by a
// gap -- is deposited as a degenerate segment instead, because a place a trace *was* is ink
// however short its stay.
const auto flush_run = [&]() {
if (run == 1u) deposit_density_segment(px, py, px, py);
};
for (usize k = 0; k + 1u < density_points_.size(); k += 2u) {
// x is gathered relative to the visible range's minimum (see `Series::gather()`), so the
// absolute x is reconstructed in f64 before it is mapped -- the same reconstruction, and the
// same reason, as the staging paths.
const f64 x_abs = window.min + static_cast<f64>(density_points_[k]);
const f64 y_abs = static_cast<f64>(density_points_[k + 1u]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) {
flush_run(); // a NaN is a gap: it joins nothing, so the trace is deposited in pieces
run = 0u;
continue;
}
const f32 sx = static_cast<f32>(tx.to_screen_x(x_abs));
const f32 sy = static_cast<f32>(ty.to_screen_y(y_abs));
if (!std::isfinite(sx) || !std::isfinite(sy)) {
flush_run();
run = 0u;
continue;
}
if (run > 0u) deposit_density_segment(px, py, sx, sy);
px = sx;
py = sy;
++run;
}
flush_run();
}
void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept {
const u32 columns = density_grid_.columns_;
const u32 rows = density_grid_.rows_;
if (columns == 0u || rows == 0u) return;
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that
if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
const Rect& axes = layout_.axes;
const f32 lo_x = std::min(x0, x1);
const f32 hi_x = std::max(x0, x1);
i32 c0 = static_cast<i32>(std::floor(lo_x - axes.x));
i32 c1 = static_cast<i32>(std::floor(hi_x - axes.x));
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
// keeps ink off the edge column when a trace leaves the frame sideways.
c0 = std::max(c0, 0);
c1 = std::min(c1, static_cast<i32>(columns) - 1);
if (c1 < c0) return;
const f32 span = hi_x - lo_x;
const f32 denom = x1 - x0;
const i32 last_row = static_cast<i32>(rows) - 1;
// The segment's y span inside one column's own slice of x: the two ends of the slice taken
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
// x), so the deposit is a continuous trace and not a dotted one.
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
const f32 ca = axes.x + static_cast<f32>(column);
f32 ya = y0;
f32 yb = y1;
if (span > 0.0f && denom != 0.0f) {
const f32 a = std::max(ca, lo_x);
const f32 b = std::min(ca + 1.0f, hi_x);
ya = y0 + (a - x0) / denom * (y1 - y0);
yb = y0 + (b - x0) / denom * (y1 - y0);
}
out_lo = std::min(ya, yb);
out_hi = std::max(ya, yb);
};
for (i32 c = c0; c <= c1; ++c) {
f32 y_lo = 0.0f;
f32 y_hi = 0.0f;
column_y_span(c, y_lo, y_hi);
// A column whose slice of the segment lies wholly outside the axes deposits nothing: without
// this, a trace above the top of the plot would paint a band along the top row, which is the
// one thing a density must not do -- it would say the trace was there.
if (!(y_hi >= axes.y) || !(y_lo <= axes.bottom())) continue;
const f32 top = std::max(y_lo, axes.y);
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
// rather than one past the end.
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
i32 r0 = std::clamp(static_cast<i32>(std::floor(top - axes.y)), 0, last_row);
i32 r1 = std::clamp(static_cast<i32>(std::floor(bottom - axes.y)), 0, last_row);
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
// of times by one acquisition's own samples, and counting each crossing would put a single
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
// and a cell's value would mean "how much the beam moved here" instead of "how many
// acquisitions were here" -- which is what a persistence display is. The stamp is per
// *(trace, column)*, so two sweeps that agree on a cell still count twice, and the two segments
// that share a column's boundary do not.
const u64 key = (static_cast<u64>(density_trace_) << 32) | static_cast<u64>(static_cast<u32>(c));
f32* column_cells = density_grid_.cells_.data() + static_cast<usize>(c);
for (i32 r = r0; r <= r1; ++r) {
const usize row = static_cast<usize>(r);
if (density_stamp_[row] == key) continue; // this sweep has already put its ink here
density_stamp_[row] = key;
column_cells[row * static_cast<usize>(columns)] += 1.0f;
}
}
}
f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
}
+23
View File
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels);
}
bool DensityProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
if (program_.id() == 0) {
return false;
}
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
program_.use();
program_.set_uniform_int("u_density", 0);
return true;
}
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
program_.set_uniform("u_origin", origin);
program_.set_uniform("u_size", size);
}
void DensityProgram::set_color(Color color) const noexcept {
program_.set_uniform("u_color", color);
}
} // namespace plot::render
+90 -5
View File
@@ -193,14 +193,22 @@ Renderer::~Renderer() {
void Renderer::release_gl() noexcept {
ready_ = false;
// Assigning fresh objects releases every GL name through the handles'
// destructors: both programs, both vertex arrays and their instance buffers,
// and the atlas texture. Safe to call more than once; every assignment is a
// no-op while the renderer is uninitialised.
// destructors: all three programs, the vertex arrays and their instance
// buffers, the glyph atlas texture and the density's own texture. Safe to call
// more than once; every assignment is a no-op while the renderer is
// uninitialised.
atlas_ = render::TextAtlas{};
line_program_ = render::LineProgram{};
text_program_ = render::TextProgram{};
density_program_ = render::DensityProgram{};
line_vao_ = render::LineVertexArray{};
text_vao_ = render::TextVertexArray{};
density_vao_ = gl::VertexArray{};
density_texture_ = gl::Texture{};
density_pixels_.clear();
density_w_ = 0;
density_h_ = 0;
density_uploaded_revision_ = 0;
lines_.clear();
glyphs_under_.clear();
glyphs_over_.clear();
@@ -252,10 +260,10 @@ bool Renderer::init(Status* st) noexcept {
return false;
}
if (!line_program_.create(st) || !text_program_.create(st)) {
if (!line_program_.create(st) || !text_program_.create(st) || !density_program_.create(st)) {
return false;
}
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) {
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
return false;
}
ready_ = true;
@@ -519,6 +527,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// The holes over the grid and under the data: a band is chrome of the *background*, not an
// overlay, so everything a frame draws inside the axes is drawn on top of it.
draw_invalid_spans(layout);
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
// as the traces do, and under them for the same reason they are under the data.
draw_density(plot, layout);
clear_clip();
draw_axis_lines(plot);
set_clip(layout.axes);
@@ -1740,6 +1751,80 @@ void Renderer::draw_invalid_spans(const PlotLayout& layout) {
}
}
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
const DensityConfig& config = plot.density();
const DensityGrid& grid = plot.density_grid();
const u32 columns = grid.columns();
const u32 rows = grid.rows();
if (!config.enabled || columns == 0u || rows == 0u) return;
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
// applied. The conversion lives here rather than in the fragment shader so that a cell's
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
const bool resized = columns != density_w_ || rows != density_h_;
if (resized) {
if (!density_texture_.create_r8(columns, rows, nullptr)) {
gl_error_ = true;
return;
}
// One texel per cell and a quad exactly that many pixels across, so every fragment lands
// inside its own cell: a point sample is the whole of the filtering this needs, and any
// other mode would blend two sweeps' counts at the cell walls.
density_texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
density_w_ = columns;
density_h_ = rows;
density_uploaded_revision_ = 0u; // a fresh texture holds nothing
}
if (resized || grid.revision() != density_uploaded_revision_) {
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
// bits, so quantising the count first and lifting the quantised value costs nothing a
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
u8 lut[256];
for (u32 i = 0u; i < 256u; ++i) {
const f32 v = static_cast<f32>(i) / 255.0f;
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
}
density_pixels_.resize(grid.cell_count());
const f32* cells = grid.cells().data();
for (usize i = 0; i < density_pixels_.size(); ++i) {
const f32 v = cells[i] / full;
const u32 quantised =
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
density_pixels_[i] = lut[std::min(quantised, 255u)];
}
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
// its own upload, since the two share a context and a frame.
gl::GLint alignment = 4;
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
density_texture_.upload_r8(columns, rows, density_pixels_.data());
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
density_uploaded_revision_ = grid.revision();
// += and not =: a figure draws a density per row, and what this field reports is the frame's
// upload, not the last row's.
stats_.density_upload_bytes += columns * rows;
}
// The quad covers the cells, not the axes rect's own fractional size: `columns`/`rows` are that
// rect rounded up, so mapping uv over them puts texel i exactly on device pixel `axes.x + i`.
// Drawing the un-rounded rect would stretch the last row and column of cells over the fraction.
density_program_.bind();
density_program_.set_viewport({viewport_.w, viewport_.h});
density_program_.set_rect({layout.axes.x, layout.axes.y},
{static_cast<f32>(columns), static_cast<f32>(rows)});
density_program_.set_color(style_.density);
density_texture_.bind();
density_vao_.bind();
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
stats_.density_cells = columns * rows;
++stats_.draw_calls;
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px);
+43
View File
@@ -235,4 +235,47 @@ void main() {
}
)";
const char* const kDensityVS = R"(#version 330 core
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
uniform vec2 u_size; // the quad's size in pixels -- the grid's columns and rows
out vec2 v_uv;
void main() {
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vec2 px = u_origin + corner * u_size;
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
// u_size is the grid's own size in pixels and the texture is one texel per cell, so one
// unit of uv is exactly one cell and a GL_NEAREST fetch lands on the cell the fragment is
// inside -- there is nothing to filter and nothing to round.
v_uv = corner;
}
)";
const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_NEAREST
uniform vec4 u_color;
out vec4 o_color;
void main() {
// The cell's count was already turned into a luminance on the CPU, where the count, the
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
// grid's numbers back could disagree with. All that is left is the tint: the density says how
// often a trace passed, never which one, so it is one hue for the whole image.
float ink = texture(u_density, v_uv).r;
o_color = vec4(u_color.rgb, u_color.a * ink);
}
)";
} // namespace plot::render