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

+50 -11
View File
@@ -208,6 +208,7 @@ void Renderer::release_gl() noexcept {
density_pixels_.clear();
density_w_ = 0;
density_h_ = 0;
density_uploaded_grid_ = nullptr;
density_uploaded_revision_ = 0;
lines_.clear();
glyphs_under_.clear();
@@ -528,7 +529,8 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// 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.
// where the traces did -- and, while it is on, *instead* of them (`draw_traces()` returns) -- and
// under them for the same reason they are under the data.
draw_density(plot, layout);
clear_clip();
draw_axis_lines(plot);
@@ -1368,6 +1370,14 @@ u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32
}
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// **No solid trace while the density is on.** The trace ink *is* the density image (see
// `Plot::set_density()` and `draw_density()`): every visible trace was deposited, the newest
// acquisition included, so drawing the polyline as well would put a solid, unfading stroke over
// its own faint image -- the "persistent image + last acquisition" a report described, where the
// newest acquisition never faded because it was never only the image. The traces still exist --
// the legend, the crosshair readout and the measurements read them -- they are simply not drawn
// as lines.
if (plot.density().enabled) return;
// Both dimensions: a zero-width rect selects no level, and a zero-height one
// would flatten every trace onto a single row -- a frame that looks like a
// rendering choice rather than the collapsed plot it is.
@@ -1753,17 +1763,43 @@ 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 (!config.enabled) {
// A plot that has stopped drawing a density is not holding a stale one: forget what the shared
// texture holds, so a re-enable -- which drops the grids and rebuilds them, possibly at the same
// addresses and with fresh revisions -- re-uploads rather than trusting a cache entry that
// happens to match.
density_uploaded_grid_ = nullptr;
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
// One quad per trace, in that trace's own colour: the plot holds one grid per trace (see
// `DensityConfig`), so where two traces agree the viewer sees both colours, and a saturated cell
// is the colour of the trace whose ink filled it and not a blend. A trace with no visible series
// was deposited into nothing (its grid is empty), so it is skipped.
for (u32 i = 0u; i < plot.trace_count(); ++i) {
const Trace& trace = plot.trace(i);
if (!trace.visible()) continue;
draw_density_grid(config, plot.density_grid(i), trace.color, layout);
}
}
void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout) {
const u32 columns = grid.columns();
const u32 rows = grid.rows();
if (columns == 0u || rows == 0u) return;
// 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.
//
// **One texture, re-filled per trace.** The cache is keyed by the grid's own address as well as
// its revision, so a plot drawn twice with nothing changed (the steady state of a display that
// has stopped being swept) skips the conversion *and* the upload -- while a row of several traces
// re-fills the one texture once per trace, which is what a figure of several rows already cost
// before the density was per trace.
const bool resized = columns != density_w_ || rows != density_h_;
if (resized) {
if (!density_texture_.create_r8(columns, rows, nullptr)) {
@@ -1776,9 +1812,9 @@ void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
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
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
}
if (resized || grid.revision() != density_uploaded_revision_) {
if (resized || density_uploaded_grid_ != &grid || 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
@@ -1804,9 +1840,10 @@ void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
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_grid_ = &grid;
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.
// += and not =: a figure draws a density per trace per row, and what this field reports is the
// frame's upload, not the last one's.
stats_.density_upload_bytes += columns * rows;
}
@@ -1817,11 +1854,13 @@ void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
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);
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
density_program_.set_color(tint);
density_texture_.bind();
density_vao_.bind();
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
stats_.density_cells = columns * rows;
stats_.density_cells += columns * rows;
++stats_.draw_calls;
}
+3 -2
View File
@@ -271,8 +271,9 @@ 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.
// grid's numbers back could disagree with. All that is left is the tint: the density is drawn
// once per trace, in that trace's own colour (the caller's `u_color`), so a saturated cell is
// the trace at its own brightness and a faint one is the same hue at a lower alpha.
float ink = texture(u_density, v_uv).r;
o_color = vec4(u_color.rgb, u_color.a * ink);
}