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

+8 -6
View File
@@ -59,12 +59,14 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
than a set of retained traces): one `f32` cell per device pixel of the axes rect, behind the
traces, into which each acquisition is deposited -- 1.0 per cell a trace crosses, so brightness
is how often it passed -- and which fades either by a **time constant** in stream seconds or by
a **per-acquisition factor**. Memory is the screen's (a 640x360 viewport's axes is 179 349
cells, 717 kB) whatever the trigger has done, the deposit walks the axes' columns rather than
the samples, and a frame whose grid did not move costs one draw call
than a set of retained traces): **one grid per trace**, in that trace's own colour, one `f32`
cell per device pixel of the axes rect, drawn *in place of* the traces -- a host may not also
draw them over it -- into which each acquisition is deposited, 1.0 per cell a trace crosses, so
brightness is how often it passed. It fades either by a **time constant** in stream seconds or by
a **per-acquisition factor**, and the newest acquisition is deposited like every other, so it
fades too. Memory is the screen's times the traces on screen (a 640x360 viewport's axes is
179 349 cells per trace, 717 kB), the deposit walks the axes' columns rather than
the samples, and a frame whose grids did not move costs one draw call per trace
(`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
- sample **markers** when a window is sparse enough that its samples are the shape
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
+50 -34
View File
@@ -327,27 +327,34 @@ way**, against the 191 the sparse frame gave before. Across the whole sweep from
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
left to see.
## A phosphor density accumulates into a screen-sized grid
## A phosphor density is the trace, one grid per trace
`Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have
passed, drawn behind them, fading by a time constant or by an acquisition count. It is the
library's because the library is the side that knows a trace's device geometry; a host that
built one itself would be re-rasterising traces the renderer has already staged.
passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a
time constant or by an acquisition count. It is the library's because the library is the side that
knows a trace's device geometry; a host that built one itself would be re-rasterising traces the
renderer has already staged.
The split from the grading above is what it costs: grading measures *one* frame's dwell and
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's.** One `f32` cell per device pixel of the axes rect
(`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 viewport is a **573x313** grid
of **179 349 cells** and **717 396 bytes**, and a thousand acquisitions through it cost the
same. A cell's value is a **number of sweeps**: a trace covering it during one
- **The grid is the screen's, not the data's, and there is one per trace.** One `f32` cell per
device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360
viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand
acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so
the display's memory is that figure times the traces on screen — four signals in one 800x480 row
are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density
that had one hue for the plot could not say *which* trace put the ink there, and two signals
agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's
image; a trace with no series (an unbound slot) holds no grid at all.
- **A cell's value is a number of sweeps**, and a trace covering it during one
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather
than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few
kB), keyed by (trace, column), so two sweeps agreeing on a cell still count twice and the two
segments that share a column boundary do not.
kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell
still count twice and the two segments that share a column boundary do not.
- **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to
sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the
same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the
@@ -355,38 +362,47 @@ also why it cannot be a per-frame accumulator.
over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace
crossed — the deposit is bounded by the screen, not by the data, which is what makes an
accumulation affordable at the trigger's own rate.
- **The brightness ceiling is the fade's own steady state, and the ramp is linear in sweeps.**
`DensityConfig::full_scale` defaults to `1/(1 - 0.5)` -- the geometric sum of the default
per-acquisition fade -- so one sweep reads half ink and the display reaches white only where
acquisitions agree, and `gamma` defaults to 1.0, because a cell's value is a count and half as
many sweeps must read as half the ink. Both defaults were wrong first and the *eye* found it: with
the ceiling at a single sweep and `gamma` at 0.45 (the value `Style::intensity_gamma` wants for a
*dwell fraction*) every cell one sweep touched was already full white, so a display of one sweep
and a display of five looked the same. The case that would have caught it is a **framebuffer**
reading, not the grid's numbers -- `a_phosphor_density_is_drawn_under_the_traces` now measures a
cell at a quarter of the scale against one at the ceiling and against the background
(`empty=16 full=83 quarter=54` at the current defaults, where the old ones read `199` for the
first two alike).
- **The newest acquisition is deposited like every other, and no 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 acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline
on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent
image + last acquisition" a user reported, where the newest acquisition never faded.
- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.**
`DensityConfig::full_scale` defaults to **one sweep**, so 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 *exactly* at the colour its trace would have drawn — the settlement is
a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many
sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the
first default and is a legitimate alternative: it makes the newest sweep a fraction of the
settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest
sweep and the image behind it. The case that reads either one off the *screen* rather than the
grid is `a_phosphor_density_is_the_trace_in_its_own_colour`.
- **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's
count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`)
only tint that byte. The one derivation is deliberate: a value a host reads back through
`DensityGrid::ink()`/`peak()` and the picture it sees come from the same numbers.
- **Per frame, it is one texture upload and one draw call -- or one draw call.** The upload is one
byte per cell (**179 kB** for the axes above); a frame whose grid did not move skips the
conversion and the upload entirely and pays the quad alone (`FrameStats::density_upload_bytes`
reads 0 for it). The decay is what makes the grid move every frame under `TimeConstant`, and
what makes it stand still between acquisitions under `PerAcquisition`.
only tint that byte with the trace's colour. The one derivation is deliberate: a value a host
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
The upload is one byte per cell (**179 kB** for the axes above); the one texture is re-filled per
trace, and a frame whose grids did not move skips the conversion and the uploads entirely and
pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes
the grids move every frame under `TimeConstant`, and what makes them stand still between
acquisitions under `PerAcquisition`.
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
(**584x458 = 267 472 cells, 1 069 888 bytes**, `ink=584 peak=1` for one flat sweep — the ink of
one sweep *is* the columns it covers),
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
(584 → 876 → 1022 on a 584-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink, and
`a_phosphor_density_is_drawn_under_the_traces` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it, with the trace's
stroke keeping its own colour over the ink (`min_red < 40`, where a density drawn on top would
put the red at ~190).
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**267 472 cells each,
ink 584/584**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state
(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the
background's.
## One sample or a statistic of many
-1
View File
@@ -44,7 +44,6 @@ Colours (neither logical nor device — nothing to scale):
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) |
| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired |
| `density` | `0xDFF2FF` at α 0.85 | the tint a plot's phosphor density is drawn in (`Plot::set_density()`); the alpha is the ceiling of the image's brightness scale |
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale:
+30 -13
View File
@@ -35,6 +35,14 @@ enum class DensityDecay {
/// 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()`.
///
/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because
/// a density that had one hue for the whole plot could not say *which* trace put the ink there --
/// two signals agreeing in a cell would be one blended pixel, and a display of several traces
/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in
/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it
/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour
/// its trace would have drawn.
///
/// 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
@@ -56,15 +64,21 @@ struct DensityConfig {
/// 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 cell value that reads as **full brightness**, in accumulated sweeps -- and the one field
/// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in
/// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore
/// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the
/// trace it is a picture of (a signal crossing the same place every acquisition settles at the
/// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and
/// leave a gain offset between the newest sweep and the image behind it). Everything above the
/// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's
/// colour at full strength and no brighter.
///
/// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of
/// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at
/// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was
/// this field's first default, and it is a legitimate choice, not the one a phosphor makes.
f32 full_scale = 1.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
@@ -79,10 +93,13 @@ struct DensityConfig {
/// 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).
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. A plot holds **one
/// grid per trace** (see `DensityConfig`), so a display's memory is that figure times the number of
/// traces actually drawn -- a plot's unbound slots hold no grid -- and four signals in one 800x480
/// row are four grids, 6 MB, while a 1920x1080 row of eight is 66 MB. 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
+53 -39
View File
@@ -590,13 +590,16 @@ class Plot {
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps a **screen-sized intensity grid** behind its traces (see `DensityGrid`
/// for the resolution, the per-cell meaning and the memory cost) and deposits the acquisition
/// on screen into it with `accumulate_density()`: brightness means how often a trace passed,
/// monochrome, and every cell fades as the display's own clock moves on. It is deliberately
/// *not* a set of retained traces -- the library has the trace geometry in hand, so a density
/// costs one grid whatever the trigger has done, where retaining the acquisitions would cost
/// a window copy each.
/// On, the plot keeps a **screen-sized intensity grid behind each of its traces**, in that
/// trace's own colour (see `DensityGrid` for the resolution and per-cell meaning, and
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
/// acquisition is deposited like every other and fades with the rest -- a trace left drawn on
/// top of its own density could never fade, which is what a report of "the last acquisition
/// never fades" was. It is deliberately *not* a set of retained traces -- the library has the
/// trace geometry in hand, so a density costs one grid per trace whatever the trigger has done,
/// where retaining the acquisitions would cost a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
@@ -611,18 +614,24 @@ class Plot {
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image, for a host that wants to draw it or read it back. Empty (0x0) until
/// the first `accumulate_density()` on a laid-out plot.
const DensityGrid& density_grid() const noexcept { return density_grid_; }
/// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There
/// is one per trace, in trace order, because each is drawn in that trace's own colour (see
/// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first
/// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off.
const DensityGrid& density_grid(u32 trace) const noexcept {
return trace < density_grids_.size() ? density_grids_[trace] : empty_density_grid();
}
/// Deposits **the acquisition on screen** into the grid: every visible trace of this plot,
/// over the x window its own axis is showing, 1.0 into every cell it covers -- **once per cell
/// per sweep**, however many of that trace's own samples crossed it, so a cell's value is how
/// many traces have covered it since the last decay and not how much the beam moved (see
/// `DensityGrid`; the dwell question is `Style::intensity_grading`'s). Called once per
/// *acquisition*, which is the cadence the grid counts in: a
/// caller that deposited every frame would add one sweep sixty times a second, and the
/// per-acquisition fade would have nothing to measure itself against.
/// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this
/// plot, the newest acquisition included, over the x window its own axis is showing, 1.0 into
/// every cell it covers -- **once per cell per sweep**, however many of that trace's own samples
/// crossed it, so a cell's value is how many traces have covered it since the last decay and not
/// how much the beam moved (see `DensityGrid`; the dwell question is
/// `Style::intensity_grading`'s). Called once per *acquisition*, which is the cadence the grids
/// count in: a caller that deposited every frame would add one sweep sixty times a second, and
/// the per-acquisition fade would have nothing to measure itself against. The renderer draws no
/// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole
/// of what the newest acquisition looks like -- and it fades with the rest.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
@@ -997,23 +1006,26 @@ class Plot {
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans();
/// Deposits one trace's visible window into the density grid: the same index window
/// Deposits one trace's visible window into its own density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace);
/// Deposits one already-mapped segment of a trace polyline: for every grid column the segment
/// crosses, the rows it covers in that column, +1 each. The columns it also *clips*: a column
/// whose slice of the segment lies entirely outside the axes is skipped, so a trace off the
/// top of the plot deposits nothing rather than a band along the edge, and a segment with both
/// ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
void deposit_density_trace(const Trace& trace, DensityGrid& grid);
/// Deposits one already-mapped segment of a trace polyline into `grid`: for every grid column
/// the segment crosses, the rows it covers in that column, +1 each. The columns it also *clips*:
/// a column whose slice of the segment lies entirely outside the axes is skipped, so a trace off
/// the top of the plot deposits nothing rather than a band along the edge, and a segment with
/// both ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept;
/// Grows `density_grid_` to the last layout's axes rect in device pixels, clearing it when the
/// size moved (a *different* screen is a different image), and returns whether the grid is
/// usable. The one place the grid is allocated.
bool size_density_grid();
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept;
/// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device
/// pixels, clearing a grid when the size moved (a *different* screen is a different image), and
/// returns whether any grid is usable. The one place the grids are allocated.
bool size_density_grids();
/// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the
/// accessor need not hand back a reference into nothing.
static const DensityGrid& empty_density_grid() noexcept;
/// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -1062,20 +1074,22 @@ class Plot {
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grid it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. The grid is a member and not a local
/// because it is the *display's* memory -- it outlives the frames it was drawn in, which is the
/// whole point of accumulating into a screen-sized image rather than retaining traces.
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is
/// screen-sized but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth. The vector tracks
/// `traces_` and outlives the frames its images were drawn in, which is the whole point of
/// accumulating into a screen-sized image rather than retaining traces.
DensityConfig density_{};
DensityGrid density_grid_{};
std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row**: the key of the last (trace, column) to
/// deposit at that row, so one acquisition's own samples cannot count a cell twice (see
/// `deposit_density_segment()`). One `u64` a row -- a few kB at any window -- and not one per
/// cell, which would double the grid's memory for a comparison.
/// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: the
/// key of the last (trace, column) to deposit at that row, so one acquisition's own samples
/// cannot count a cell twice (see `deposit_density_segment()`). One `u64` a row -- a few kB at
/// any window -- and not one per cell, which would double a grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
+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_;
-8
View File
@@ -77,14 +77,6 @@ struct Style {
/// different kinds of thing; and still above the threshold where the difference from *no* band
/// is visible, which is the floor this alpha is chosen against.
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
/// The tint a plot's **phosphor density** is drawn in (`Plot::set_density()`): the grid's
/// luminance comes from the accumulated counts, and this is the monochrome hue it is multiplied
/// into -- the density says *how often*, so it must not also say *which signal*, and a trace
/// colour here would make a ghost read as a trace that is not there. A near-white neutral, one
/// step brighter than `invalid_fill`'s grey, because this is ink the plot *has* and a hole is
/// ink it is missing. The alpha is the whole brightness scale's ceiling -- the shader multiplies
/// it by each cell's own value -- so under 1 it puts a lid on how bright the display can get.
Color density = Color::hex(0xDFF2FF, 0.85f);
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// trace and the line says *where*.
+94 -37
View File
@@ -355,13 +355,26 @@ void Plot::set_density(const DensityConfig& config) {
// 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_;
for (DensityGrid& grid : density_grids_) {
grid.cells_.clear();
grid.cells_.shrink_to_fit();
grid.columns_ = 0u;
grid.rows_ = 0u;
++grid.revision_;
}
density_grids_.clear();
density_grids_.shrink_to_fit();
}
// The two fields the renderer turns a cell's count into a luminance with: a change to either is
// a change to every cell's *byte*, and a grid's own revision does not see it. A host that states
// its configuration every frame (udpscope does) must not force a re-upload per frame, so this
// bumps only when one of the two actually moved -- and then a re-upload is owed.
const bool rescale = density_.enabled && config.enabled &&
(density_.full_scale != config.full_scale || density_.gamma != config.gamma);
density_ = config;
if (rescale) {
for (DensityGrid& grid : density_grids_) ++grid.revision_;
}
}
void Plot::decay_density(f64 dt_s) {
@@ -373,56 +386,94 @@ void Plot::decay_density(f64 dt_s) {
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_;
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
for (f32& cell : grid.cells_) cell *= factor;
++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_;
for (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
std::fill(grid.cells_.begin(), grid.cells_.end(), 0.0f);
++grid.revision_;
}
}
bool Plot::size_density_grid() {
bool Plot::size_density_grids() {
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_;
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it
// down (see `DensityConfig`). The vector tracks the trace list, so a trace added after the first
// accumulation gets its own empty grid here while the grids already sized keep what they held.
if (density_grids_.size() != traces_.size()) density_grids_.resize(traces_.size());
// The per-sweep dedupe's stamps are one per grid **row** and shared by every trace's deposit:
// the key a cell is deduped against carries the trace's number (see
// `deposit_density_segment()`), so a stamp left by one trace's sweep never matches another's, and
// a zeroed stamp -- every key has a non-zero trace number in its high half -- matches nothing.
if (density_stamp_.size() != rows) density_stamp_.assign(rows, 0u);
bool usable = false;
for (usize i = 0u; i < traces_.size(); ++i) {
DensityGrid& grid = density_grids_[i];
if (!traces_[i].visible()) {
// A trace with nothing to draw gets no image: the grid is per *trace*, and a slot with no
// series is not one. Dropping it here is also what keeps the display's memory proportional
// to the traces actually on screen rather than to a plot's fixed trace slots -- a row of four
// signals out of eight slots holds four grids, not eight.
if (!grid.cells_.empty()) {
grid.cells_.clear();
grid.cells_.shrink_to_fit();
grid.columns_ = 0u;
grid.rows_ = 0u;
++grid.revision_;
}
continue;
}
if (columns != grid.columns_ || rows != 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.
grid.cells_.assign(static_cast<usize>(columns) * rows, 0.0f);
grid.columns_ = columns;
grid.rows_ = rows;
++grid.revision_;
}
usable = usable || !grid.cells_.empty();
}
return !density_grid_.cells_.empty();
return usable;
}
void Plot::accumulate_density() {
if (!density_.enabled) return;
if (!size_density_grid()) return;
if (!size_density_grids()) 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 (DensityGrid& grid : density_grids_) {
if (grid.cells_.empty()) continue;
for (f32& cell : grid.cells_) cell *= factor;
++grid.revision_;
}
}
for (const Trace& trace : traces_) deposit_density_trace(trace);
++density_grid_.revision_;
// **Every visible trace of the acquisition on screen, the newest included.** The current
// acquisition is deposited here like any other, so it is inside the image and fades with it --
// there is no second, solid trace drawn over the density (see `Renderer::draw_traces()`), which
// is what a report of "the last acquisition never fades" turned out to be. One grid each, so two
// traces that agree read as two colours at full strength where they overlap and not as one
// blended one.
const usize n = std::min(density_grids_.size(), traces_.size());
for (usize i = 0u; i < n; ++i) deposit_density_trace(traces_[i], density_grids_[i]);
}
void Plot::deposit_density_trace(const Trace& trace) {
void Plot::deposit_density_trace(const Trace& trace, DensityGrid& grid) {
if (!trace.visible()) return;
const data::Series& series = *trace.series;
const u64 n = series.size();
@@ -470,7 +521,7 @@ void Plot::deposit_density_trace(const Trace& trace) {
// 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);
if (run == 1u) deposit_density_segment(px, py, px, py, grid);
};
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
@@ -490,17 +541,18 @@ void Plot::deposit_density_trace(const Trace& trace) {
run = 0u;
continue;
}
if (run > 0u) deposit_density_segment(px, py, sx, sy);
if (run > 0u) deposit_density_segment(px, py, sx, sy, grid);
px = sx;
py = sy;
++run;
}
flush_run();
++grid.revision_;
}
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_;
void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept {
const u32 columns = grid.columns_;
const u32 rows = 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;
@@ -557,7 +609,7 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept {
// *(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);
f32* column_cells = 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
@@ -567,6 +619,11 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept {
}
}
const DensityGrid& Plot::empty_density_grid() noexcept {
static const DensityGrid empty{};
return empty;
}
f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
}
+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);
}
+93 -39
View File
@@ -2852,9 +2852,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
// deposits nothing at all.
PLOT_CHECK(!f.plot.density().enabled);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid().peak() == 0.0f);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
plot::DensityConfig cfg;
cfg.enabled = true;
@@ -2862,7 +2862,7 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
PLOT_CHECK(f.plot.density().enabled);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
@@ -2895,9 +2895,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout();
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().columns() < columns);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(),
static_cast<double>(f.plot.density_grid().columns()), 1.0);
PLOT_CHECK(f.plot.density_grid(0u).columns() < columns);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(),
static_cast<double>(f.plot.density_grid(0u).columns()), 1.0);
}
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
@@ -2915,30 +2915,30 @@ PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
f.plot.accumulate_density();
// The grid is sized by the first accumulation, so the width is read after it -- and the ink
// of one pass *is* the width: one segment across the window, one cell per column.
const double width = static_cast<double>(f.plot.density_grid().columns());
const double one = f.plot.density_grid().ink();
const double width = static_cast<double>(f.plot.density_grid(0u).columns());
const double one = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(one, width, 1.0);
f.plot.accumulate_density();
const double two = f.plot.density_grid().ink();
const double two = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.5f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3);
f.plot.accumulate_density();
const double three = f.plot.density_grid().ink();
const double three = f.plot.density_grid(0u).ink();
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.75f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.75f, 1e-3);
// The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes.
for (int i = 0; i < 40; ++i) f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 2.0f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 2.0f, 1e-3);
// A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass.
cfg.per_acquisition = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.0f, 1e-3);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.0f, 1e-3);
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
three, 2.0);
@@ -2955,40 +2955,40 @@ PLOT_TEST(a_density_fades_by_its_time_constant) {
cfg.time_constant_s = 2.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double full = f.plot.density_grid().ink();
const double full = f.plot.density_grid(0u).ink();
// A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does
// a frame under `PerAcquisition`, which fades in `accumulate_density()` instead.
f.plot.decay_density(0.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
f.plot.decay_density(-1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
// Quarter of a time constant: exp(-0.25).
f.plot.decay_density(0.5);
const double quarter = full * std::exp(-0.25);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), quarter, quarter * 1e-4);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), quarter, quarter * 1e-4);
// Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink.
for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0);
PLOT_CHECK(f.plot.density_grid().ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid().peak() < 1e-3f);
PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid(0u).peak() < 1e-3f);
// A non-positive constant freezes the grid rather than fading it.
cfg.time_constant_s = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double frozen = f.plot.density_grid().ink();
const double frozen = f.plot.density_grid(0u).ink();
f.plot.decay_density(100.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), frozen, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), frozen, 1e-6);
// And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed
// changes nothing.
cfg.decay = plot::DensityDecay::PerAcquisition;
f.plot.set_density(cfg);
const double before = f.plot.density_grid().ink();
const double before = f.plot.density_grid(0u).ink();
f.plot.decay_density(1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), before, 1e-6);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6);
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
frozen);
@@ -3002,26 +3002,26 @@ PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
f.plot.clear_density();
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid().peak() == 0.0f);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
// The clear keeps the allocation -- the grid is still the screen's -- so the next
// accumulation does not have to reallocate it.
PLOT_CHECK(f.plot.density_grid().cell_count() > 0u);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
PLOT_CHECK(f.plot.density_grid(0u).ink() > 0.0);
cfg.enabled = false;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
// ... and an accumulation while it is off deposits nothing (it is not a hidden build-up).
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
}
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
@@ -3034,14 +3034,14 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
cfg.enabled = true;
off.plot.set_density(cfg);
off.plot.accumulate_density();
PLOT_CHECK_NEAR(off.plot.density_grid().ink(), 0.0, 1e-9);
PLOT_CHECK_NEAR(off.plot.density_grid(0u).ink(), 0.0, 1e-9);
// ... and the same series inside the axis deposits one cell per column, in two rows: the
// first half at y = -1 and the second at y = +1, joined by a diagonal.
DensityFixture split(std::vector<f32>{-1.0f, 1.0f});
split.plot.set_density(cfg);
split.plot.accumulate_density();
const plot::DensityGrid& grid = split.plot.density_grid();
const plot::DensityGrid& grid = split.plot.density_grid(0u);
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes;
@@ -3073,8 +3073,8 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
gap.plot.set_density(cfg);
gap.plot.accumulate_density();
const plot::DensityGrid& whole_grid = whole.plot.density_grid();
const plot::DensityGrid& gap_grid = gap.plot.density_grid();
const plot::DensityGrid& whole_grid = whole.plot.density_grid(0u);
const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u);
const plot::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes;
const plot::u32 column = static_cast<plot::u32>(std::clamp(
@@ -3085,5 +3085,59 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
PLOT_CHECK(gap_grid.ink() < whole_grid.ink());
std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n",
off.plot.density_grid().ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
}
/// **One grid per trace.** A plot of two signals holds two images, each deposited from its own
/// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is
/// read): the two traces at two levels leave ink in two different rows, so one grid holding one
/// trace's ink and not the other's is the reading that says the images are not shared.
PLOT_TEST(a_density_holds_one_grid_per_trace) {
plot::data::ArraySeries lo(std::vector<plot::f32>{-0.5f, -0.5f}, 0.0, 1.0);
plot::data::ArraySeries hi(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(p.add_trace({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u);
PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
p.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
p.set_density(cfg);
p.accumulate_density();
PLOT_CHECK(p.trace_count() == 2u);
const plot::DensityGrid& g0 = p.density_grid(0u);
const plot::DensityGrid& g1 = p.density_grid(1u);
// Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink
// is the width in each grid, so neither picked up the other trace's ink (two traces would be two
// widths in a shared grid).
PLOT_CHECK(g0.cell_count() > 0u);
PLOT_CHECK(g1.cell_count() == g0.cell_count());
PLOT_CHECK_NEAR(g0.ink(), static_cast<double>(g0.columns()), 1.0);
PLOT_CHECK_NEAR(g1.ink(), static_cast<double>(g1.columns()), 1.0);
const plot::Transform ty = p.y_transform(ay);
const plot::Rect axes = p.layout().axes;
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::floor(ty.to_screen_y(y) - axes.y));
};
const plot::u32 lo_row = row_of(-0.5);
const plot::u32 hi_row = row_of(0.5);
PLOT_CHECK(lo_row != hi_row);
PLOT_CHECK(g0.at(0u, lo_row) > 0.0f);
PLOT_CHECK(g1.at(0u, hi_row) > 0.0f);
PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid
PLOT_CHECK(g1.at(0u, lo_row) == 0.0f);
// An out-of-range trace reads an empty image rather than anything borrowed.
PLOT_CHECK(p.density_grid(2u).cell_count() == 0u);
PLOT_CHECK(p.density_grid(9u).ink() == 0.0);
std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n",
g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row);
}
+121 -89
View File
@@ -7520,17 +7520,17 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
p.clear_selection_rect();
}
/// The phosphor density is a *background* image drawn by the plot: it covers the axes rect in
/// the style's one tint, it is behind the traces -- a trace drawn over the ink is the trace's
/// own colour and not a blend -- and it costs an upload on the frame the grid moved and nothing
/// on the frame it did not.
/// The phosphor density **is the trace**, in the trace's own colour: it covers the axes rect in
/// `Trace::color`, the newest acquisition is deposited into it like every other -- so it fades, and
/// no solid polyline is drawn over the ink -- and it costs an upload on the frame a grid moved and
/// nothing on the frame it did not.
///
/// The trace is moved between the two accumulations (the same `ArraySeries`, committed to new
/// values, which is how a host re-points a trace it cannot retarget) so that the image holds a
/// sweep at y = 0 *and* the trace is drawn at y = +0.5. Without that, a trace and its own ghost
/// would be in the same row and the density would be entirely hidden by the ink over it -- which
/// is exactly the ordering being asserted, so the two have to be told apart by position.
PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
/// Three things are read off the *screen*, because neither the grid's counts nor a tint alone can
/// make them: that a saturated cell is the trace's own colour and not a monochrome wash; that a
/// cell a quarter of the way up the scale is dimmer *and the same hue*; and that the acquisition
/// just deposited fades -- which is only true if it is *inside* the image and not a stroke drawn
/// on top of its own faint ghost (the "persistent image + last acquisition" report).
PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
@@ -7557,14 +7557,25 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
const std::vector<plot::u8> without = read_frame(ctx);
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
};
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
const plot::u32 mid_x = static_cast<plot::u32>(tx.to_screen_x(0.5));
const plot::u32 trace_row = static_cast<plot::u32>(ty.to_screen_y(0.0));
// A row well away from the sweep, and away from the trace in the no-density frame: the
// background to read every cell against, rather than a guessed one.
const plot::u32 blank_row = trace_row + 40u < kH ? trace_row + 40u : trace_row - 40u;
// **The newest acquisition is in the image, and it fades.** A time fade from the first deposit:
// one time constant must dim the same pixel, which a stroke drawn over the density could not do.
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::PerAcquisition;
cfg.per_acquisition = 0.5f;
cfg.decay = plot::DensityDecay::TimeConstant;
cfg.time_constant_s = 1.0f; // the ceiling stays the library's one sweep -- the trace's own
p.set_density(cfg);
p.accumulate_density(); // a sweep at y = 0
s.reset(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
p.accumulate_density(); // and the next one at y = +0.5, the trace's own row
p.accumulate_density(); // the acquisition on screen, deposited like any other
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
@@ -7572,7 +7583,7 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
PLOT_CHECK(!r.last_frame_had_gl_error());
dump_if_requested(r, "density.ppm");
const plot::FrameStats first = r.stats();
const std::vector<plot::u8> with = read_frame(ctx);
const std::vector<plot::u8> fresh = read_frame(ctx);
// The image is drawn -- it changed pixels -- and every pixel it changed is inside the axes:
// a density reserves no layout space and paints nothing over a label strip.
@@ -7581,8 +7592,8 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
for (plot::u32 y = 0u; y < kH; ++y) {
for (plot::u32 x = 0u; x < kW; ++x) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
with[at + 2u] == without[at + 2u]) {
if (fresh[at] == without[at] && fresh[at + 1u] == without[at + 1u] &&
fresh[at + 2u] == without[at + 2u]) {
continue;
}
++differing;
@@ -7598,81 +7609,101 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
// The grid reached the GPU: one byte per cell, once, on the frame that moved.
const plot::DensityGrid& grid = p.density_grid();
// One trace, so one grid and one upload of one byte a cell -- and the *same* grid the plot
// reports, so the picture and the numbers are one derivation.
const plot::DensityGrid& grid = p.density_grid(0u);
PLOT_CHECK(first.density_cells == static_cast<plot::u32>(grid.cell_count()));
PLOT_CHECK(first.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
// The trace is *over* the density: on the row it is drawn in, the pixel is the trace's own
// colour and not that colour blended with the image behind it.
const plot::Transform tx = p.x_transform(ax);
const plot::Transform ty = p.y_transform(ay);
const plot::u32 mid_x = static_cast<plot::u32>(tx.to_screen_x(0.5));
const plot::u32 trace_row = static_cast<plot::u32>(ty.to_screen_y(0.5));
const plot::u32 ghost_row = static_cast<plot::u32>(ty.to_screen_y(0.0));
const auto frame_pixel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y,
plot::u8 out[4]) {
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
for (int i = 0; i < 4; ++i) out[i] = f[at + static_cast<plot::usize>(i)];
};
plot::u8 px[4] = {};
// The trace is drawn *over* the density, and the red channel is what says so: the trace's own
// colour is 0x00E070 and carries none, while the density's tint (Style::density, a near-white)
// at full ink over it would put the red at roughly 190. So a red near zero on the rows the
// stroke covers is the ordering, and a red near 190 would be the image painted on top. The
// stroke's centre lands between two pixel centres as often as on one and its edge is
// anti-aliased, so the *smallest* red over the rows it covers is the one to read.
int min_red = 255;
for (int dy = -2; dy <= 2; ++dy) {
const int y = std::clamp(static_cast<int>(trace_row) + dy, 0, static_cast<int>(kH) - 1);
frame_pixel(with, mid_x, static_cast<plot::u32>(y), px);
min_red = std::min(min_red, static_cast<int>(px[0]));
}
PLOT_CHECK(min_red < 40);
// ... and the row *above* it holds the ghost of the sweep before: ink that was not in the
// frame drawn without a density, and ink that is not the trace's colour.
frame_pixel(without, mid_x, ghost_row, px);
const plot::u8 ghost_without[4] = {px[0], px[1], px[2], px[3]};
frame_pixel(with, mid_x, ghost_row, px);
PLOT_CHECK(px[0] != ghost_without[0] || px[1] != ghost_without[1] ||
px[2] != ghost_without[2]);
PLOT_CHECK(!pixel_is(px, trace_color));
// **The colour**: the cell the sweep is in is the trace's own colour at full strength -- one
// sweep is the ceiling, so a saturated cell *is* the trace -- and not the near-white monochrome
// the density used to draw in. The trace has no red and plenty of green and blue; a monochrome
// wash at full ink had all three high and near equal.
const int bg_g = channel(fresh, mid_x, blank_row, 1);
const int cell_r = channel(fresh, mid_x, trace_row, 0);
const int cell_g = channel(fresh, mid_x, trace_row, 1);
const int cell_b = channel(fresh, mid_x, trace_row, 2);
PLOT_CHECK(cell_r < 40); // the trace's colour has no red in it
PLOT_CHECK(cell_g > 150); // ... and its green is the ink
PLOT_CHECK(cell_g > cell_b && cell_b > cell_r); // the hue is the trace's, not a neutral
PLOT_CHECK(cell_g > bg_g + 40);
// A saturated cell **is** the trace's colour: one sweep at the ceiling draws the trace's own
// rgba, so the pixel matches it exactly rather than merely resembling it.
const plot::u8* cell_px = fresh.data() + (plot::usize(trace_row) * kW + mid_x) * 4u;
PLOT_CHECK(pixel_is(cell_px, trace_color));
// **The screen half**: a cell at a quarter of what a full one holds must render measurably
// fainter, and a full cell must not be the background. That is the assertion the *eye* makes --
// it is what "the colour fading is not applied, it looks white" turned out to be -- and nothing
// in the grid's own numbers can make it: they were right while the picture was flat.
//
// The row measured is the one the *ghost* is in (the sweep before the last), not the trace's own:
// a trace drawn over a cell hides the density in it by construction.
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
};
const int ghost_full = channel(with, mid_x, ghost_row, 0);
// The background to compare against is a row *away* from both: the ghost's own row holds the
// trace in the frame drawn without a density (that is where the trace was before it moved), so
// the difference between the two frames there is the trace, not the ink.
const plot::u32 blank_row = ghost_row + 40u < kH ? ghost_row + 40u : ghost_row - 40u;
const int empty_red = channel(without, mid_x, blank_row, 0);
// A quarter of the ink: `exp(-dt/tau)` is the time fade's own rule, so `dt = tau*ln(4)` is one
// step to a quarter whatever one sweep deposited.
plot::DensityConfig fading = cfg;
fading.decay = plot::DensityDecay::TimeConstant;
fading.time_constant_s = 1.0f;
p.set_density(fading);
const double before = p.density_grid().ink();
p.decay_density(std::log(4.0));
const double after = p.density_grid().ink();
PLOT_CHECK_NEAR(after / before, 0.25, 1e-6);
// One time constant of the same pixel: dimmer, and still the trace's hue. This is the assertion
// that the current acquisition is inside the density -- the fade `exp(-dt/tau)`.
const double before = grid.ink();
p.decay_density(1.0); // dt = tau, so the whole image is e^-1 of what it was
const double after = grid.ink();
PLOT_CHECK_NEAR(after / before, std::exp(-1.0), 1e-4);
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> faded = read_frame(ctx);
const int ghost_quarter = channel(faded, mid_x, ghost_row, 0);
PLOT_CHECK(ghost_full > empty_red + 20);
PLOT_CHECK(ghost_full > ghost_quarter + 20);
PLOT_CHECK(ghost_quarter > empty_red);
const int faded_g = channel(faded, mid_x, trace_row, 1);
const int faded_r = channel(faded, mid_x, trace_row, 0);
PLOT_CHECK(faded_g < cell_g - 20); // the newest acquisition faded
PLOT_CHECK(faded_r < 40); // and it faded *as the trace's colour*, not toward a neutral
PLOT_CHECK(faded_g > bg_g); // ... to more than the background: still on screen
// **No solid trace over the image.** Move the series to y = +0.5 and draw *without* depositing:
// the plot draws no polyline while the density is on, so the +0.5 row is background and the only
// ink on screen is the sweep already in the image. A trace drawn over its own ghost -- the
// reported "persistent image + last acquisition" -- would put its colour at +0.5 here.
s.reset(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> moved = read_frame(ctx);
const plot::u32 moved_row = static_cast<plot::u32>(ty.to_screen_y(0.5));
PLOT_CHECK(moved_row != trace_row);
// At +0.5 there is only the grid: the frame with the density is *identical* to the frame without
// one there, so nothing was drawn at the moved trace's own row -- no stroke over the image. (The
// row the sweep is in still holds its ink, so the image itself did not go away.)
PLOT_CHECK(channel(moved, mid_x, moved_row, 1) == channel(without, mid_x, moved_row, 1));
PLOT_CHECK(channel(moved, mid_x, moved_row, 0) == channel(without, mid_x, moved_row, 0));
PLOT_CHECK(channel(moved, mid_x, trace_row, 1) > bg_g + 20); // the image is still there
s.reset(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
// **The steady state and a quarter of it, in the signal's colour.** Many acquisitions of a
// per-acquisition fade settle at `1/(1-f)` sweeps, which the one-sweep ceiling clamps to the
// trace's colour; a decay to a quarter of that ink is dimmer and keeps the hue.
plot::DensityConfig steady_cfg = cfg;
steady_cfg.decay = plot::DensityDecay::PerAcquisition;
steady_cfg.per_acquisition = 0.5f;
p.set_density(steady_cfg);
for (int i = 0; i < 24; ++i) p.accumulate_density();
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> steady = read_frame(ctx);
const int steady_g = channel(steady, mid_x, trace_row, 1);
const int steady_r = channel(steady, mid_x, trace_row, 0);
PLOT_CHECK(steady_g > bg_g + 60);
PLOT_CHECK(steady_r < 40);
// A quarter of the ink: `dt = tau*ln(4)` is one step of the time fade to a quarter.
plot::DensityConfig quarter_cfg = steady_cfg;
quarter_cfg.decay = plot::DensityDecay::TimeConstant;
quarter_cfg.time_constant_s = 1.0f;
p.set_density(quarter_cfg);
const double steady_ink = grid.ink();
p.decay_density(std::log(4.0));
PLOT_CHECK_NEAR(grid.ink() / steady_ink, 0.25, 1e-6);
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> quarter = read_frame(ctx);
const int quarter_g = channel(quarter, mid_x, trace_row, 1);
const int quarter_r = channel(quarter, mid_x, trace_row, 0);
PLOT_CHECK(steady_g > quarter_g + 20);
PLOT_CHECK(quarter_g > bg_g + 10);
PLOT_CHECK(quarter_r < 40); // still the signal's colour, not a neutral wash
// A frame with nothing new to deposit: the grid did not move, so the conversion and the upload
// are skipped -- the frame costs the one draw call -- and the picture is bit-identical.
@@ -7683,13 +7714,14 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
PLOT_CHECK(r.stats().density_cells > 0u);
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
const std::vector<plot::u8> again = read_frame(ctx);
PLOT_CHECK(again == faded);
PLOT_CHECK(again == quarter);
std::printf("[density gl] cells=%u uploaded=%u differing=%u ghost row=%u trace row=%u"
std::printf("[density gl] cells=%u uploaded=%u differing=%u trace row=%u blank row=%u"
" axes=%.0f,%.0f %.0fx%.0f\n",
first.density_cells, first.density_upload_bytes, differing, ghost_row, trace_row,
first.density_cells, first.density_upload_bytes, differing, trace_row, blank_row,
axes.x, axes.y, axes.w, axes.h);
std::printf("[density gl] luminance red: empty=%d full=%d quarter=%d (ink %.0f -> %.0f)\n",
empty_red, ghost_full, ghost_quarter, before, after);
std::printf("[density gl] green: bg=%d full=%d faded=%d steady=%d quarter=%d"
" (fresh ink %.0f -> %.0f)\n",
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
ctx.destroy();
}