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

+32 -20
View File
@@ -48,10 +48,11 @@ struct FrameStats {
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text
/// The phosphor density's own cells, when one was drawn: `Plot::density_grid()`'s resolution,
/// zero for a plot with the density off. It is what the *frame* drew, so a host reading the
/// density's cost off a frame reads it here rather than off the grid, which says what is
/// accumulated and not what was spent.
/// The phosphor density's own cells, when one was drawn: the **sum** of every trace's
/// `Plot::density_grid()` resolution, zero for a plot with the density off. It is what the
/// *frame* drew, so a host reading the density's cost off a frame reads it here rather than off
/// the grids, which say what is accumulated and not what was spent -- and it is the number that
/// scales with the trace count, since the image is one grid per trace (see `DensityConfig`).
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
@@ -524,18 +525,23 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: the accumulated
/// image as one textured quad over the axes rect, in `Style::density`'s monochrome tint. Drawn
/// over the grid and the invalid-span bands and **under the traces**, which is what makes it a
/// record of where the traces have been rather than a second set of them.
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per
/// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace
/// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves
/// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink
/// and the newest acquisition is inside it.
///
/// The image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here from the
/// grid's counts -- the scale, the gamma and the tint are the plot's and this stage derives
/// none of them, so the numbers a host reads back and the picture it sees are one derivation.
/// A frame whose grid did not change (nothing accumulated, nothing decayed -- the steady state
/// of a display that has stopped being swept) skips the conversion *and* the upload and costs
/// the one draw call, which `FrameStats::density_upload_bytes` says per frame.
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here
/// from the grid's counts -- the scale and the gamma are the plot's and this stage derives
/// neither, so the numbers a host reads back and the picture it sees are one derivation. A frame
/// whose grids did not change skips the conversion *and* the uploads and costs the draw calls,
/// which `FrameStats::density_upload_bytes` says per frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and
/// address are the ones already uploaded) and draws it over the axes rect in `tint`. The one
/// place the density conversion lives, shared by every trace of `draw_density()`.
void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
@@ -702,10 +708,12 @@ class Renderer {
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture the
/// grid is uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and
/// re-created when that grid's size moves -- a resized plot is a differently sized image, and
/// the plot has already dropped what the old one held.
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is
/// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled
/// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a
/// row of several traces still costs the one texture. It is re-created when the grid's size moves
/// (a resized plot is a differently sized image, and the plot has already dropped what the old one
/// held).
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
@@ -714,8 +722,12 @@ class Renderer {
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid revision the texture currently holds. Compared per frame, so a frame whose grid
/// did not move skips the conversion *and* the upload -- the pause in a persistence display.
/// The grid the texture currently holds, by address, and its revision. Compared per frame and per
/// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause
/// in a persistence display. The address is part of the key because the texture is shared: two
/// traces of one plot have two grids with two revisions, and without the address a skip would
/// draw one trace's image under another's colour.
const DensityGrid* density_uploaded_grid_ = nullptr;
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;