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:
1 parent
738205293e
commit
1b8b55d3ee
12 files changed
+534
-299
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user