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

+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