feat: a plot can carry a phosphor density, one image for every acquisition

`Plot::set_density()` gives a plot a **phosphor density**: a screen-sized intensity image behind
its traces, into which each acquisition is deposited and which fades by a time constant or by a
per-acquisition factor. It is the library's and not a host's because the library is the side that
knows a trace's device geometry -- a host would be re-rasterising traces the renderer has already
staged, at a second derivation of the same mapping.

It is an **image and not a set of retained traces**, and that is the whole of its cost: one `f32`
cell per device pixel of the axes rect (`DensityGrid`), a cell's value a *number of sweeps*, so a
display of a thousand acquisitions costs what one costs and there is no depth to get wrong.
Measured on a 640x360 viewport's axes: **573x313 = 179 349 cells, 717 396 bytes** of grid, and one
byte per cell uploaded on the frame the grid moved.

- `Plot::accumulate_density()` deposits the acquisition on screen: every visible trace's window is
  mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it, then walked
  column by column -- so the deposit is bounded by the axes' columns and not by the samples behind
  them -- and a cell takes **1.0 once per trace per sweep**, however many of that trace's own
  samples crossed it. Counting the hits instead put a single dense sweep at the ceiling of the
  brightness scale and hid the sweeps behind it, and it made a cell mean "how much the beam moved
  here" rather than "how many acquisitions were here"; the dedupe is a stamp per grid *row* (one
  `u64`, keyed by trace and column) so two sweeps agreeing on a cell still count twice. Dwell is a
  different question the library already answers (`Style::intensity_grading`).
- `decay_density(dt_s)` fades by the clock, `clear_density()` and switching the density off drop
  the image at once. `DensityConfig` carries `full_scale` and `gamma`, applied on the CPU when the
  grid changed: the value a host reads back through `ink()`/`peak()` and the picture it sees are
  one derivation, and a frame whose grid did not move skips the conversion and the upload entirely
  (`FrameStats::density_cells`/`density_upload_bytes`, the latter the *frame's* total over every
  row, which one row's assignment used to overwrite).
- The renderer draws it as one attribute-less quad over the axes rect (`kDensityVS`/`kDensityFS`,
  `DensityProgram`), over the grid and the invalid-span bands and **under the traces**, inside the
  axes' clip: it reserves no layout space and nothing on it can be picked, exactly as
  `Plot::set_invalid_spans()` does. `Style::density` is its tint.
- The two fades are the two questions a persistence display can be asked -- "still visible a second
  later" and "the N-th back is gone" -- so the choice is the caller's.

Six cases, all in the suite: four plot-level ones (the grid's resolution and memory, the
per-acquisition schedule **584 -> 876 -> 1022** ink with `peak_steady=2.000`, `exp(-dt/tau)` off
the ink, and the deposit's own geometry including a NaN as a gap) and one on the real driver
(`a_phosphor_density_is_drawn_under_the_traces`: `cells=179349 uploaded=179349` on the moved frame
and `uploaded=0` on the identical one after it, with the trace's stroke keeping its own colour over
the ink). The suite is **396 cases, 0 failed** and all four ctest entries pass.
This commit is contained in:
Martino Ferrari committed 2026-10-16 17:25:39 +02:00
1 parent 3ad02e5120
commit 6a6915e64e
17 files changed
+1199 -13

No files matched your search

+10 -2
View File
@@ -58,6 +58,14 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
which columns are reduced the same switch grades the **polyline** by each segment's slope which columns are reduced the same switch grades the **polyline** by each segment's slope
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the (`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold; 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
(`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 - 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); (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
- grid with independent major/minor line classes (`Style::grid_major_visible`, - grid with independent major/minor line classes (`Style::grid_major_visible`,
@@ -196,12 +204,12 @@ documentation.
```sh ```sh
cmake --preset linux-debug cmake --preset linux-debug
cmake --build build/debug cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 377 cases, 0 failed ctest --test-dir build/debug --output-on-failure # 396 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check tools/coverage.sh # configure+build+run+capture+floor check
``` ```
The `linux-debug` suite runs **377 cases**; the `linux-coverage` preset builds **360** of The `linux-debug` suite runs **396 cases**; the `linux-coverage` preset builds **379** of
them, because its adapters are off and 17 cases need an adapter target (11 in them, because its adapters are off and 17 cases need an adapter target (11 in
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by `test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and `tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
+4 -4
View File
@@ -41,8 +41,8 @@ ctest --test-dir build/debug --output-on-failure
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **377 cases** in the `linux-debug` `plot_umbrella_check`. The `plot_tests` binary itself runs **396 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `377 cases, 0 failed` summary line. preset; its harness prints one line per case and a final `396 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases. (`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
@@ -50,7 +50,7 @@ The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`
is for working *on* a case rather than for checking the library: most of the render cases is for working *on* a case rather than for checking the library: most of the render cases
create their own EGL context and read pixels back, so the whole binary is far slower than create their own EGL context and read pixels back, so the whole binary is far slower than
the one case being edited and its one line of measured output scrolls past 376 others. The the one case being edited and its one line of measured output scrolls past 376 others. The
summary line still counts the whole suite, so a filtered run says `377 cases, 0 failed` summary line still counts the whole suite, so a filtered run says `396 cases, 0 failed`
having run one — it is never evidence that the suite passes. Unset, every case runs. having run one — it is never evidence that the suite passes. Unset, every case runs.
## Install layout ## Install layout
@@ -126,7 +126,7 @@ per-file table and the floor verdict live in [docs/coverage.md](coverage.md), wh
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the `tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
live numbers. live numbers.
**One thing to know about the coverage preset:** it builds **360 of the 377 cases**. The 17 **One thing to know about the coverage preset:** it builds **379 of the 396 cases**. The 17
that are missing are the adapter cases that need an adapter target to exist — 11 in that are missing are the adapter cases that need an adapter target to exist — 11 in
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the `test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases) adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
+49
View File
@@ -327,6 +327,55 @@ 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 samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
left to see. left to see.
## A phosphor density accumulates into a screen-sized grid
`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.
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
`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.
- **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
segment covers, the rows it spans in that column get +1 each. A window of millions of samples
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 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`.
`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).
## One sample or a statistic of many ## One sample or a statistic of many
Every number the library reports is one the samples actually hold. What differs between Every number the library reports is one the samples actually hold. What differs between
+1
View File
@@ -44,6 +44,7 @@ Colours (neither logical nor device — nothing to scale):
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band | | `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_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 | | `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) | | `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale: The scale:
+1
View File
@@ -54,6 +54,7 @@
#include <plot/plot/axis.hpp> #include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp> #include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp> #include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/figure.hpp> #include <plot/plot/figure.hpp>
#include <plot/plot/grid.hpp> #include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp> #include <plot/plot/legend.hpp>
+139
View File
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
///
/// The two are answers to two different questions, which is why the choice belongs to the
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
enum class DensityDecay {
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
/// fading it.
TimeConstant,
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
/// last few sweeps is what the display holds -- which is what a caller wants when the
/// interesting comparison is between successive acquisitions and not between seconds.
PerAcquisition,
};
/// 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()`.
///
/// 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
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
/// the same mapping that could disagree with the first.
struct DensityConfig {
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
/// holding a stale image -- see `Plot::set_density()`.
bool enabled = false;
/// How the grid fades (see `DensityDecay`).
DensityDecay decay = DensityDecay::PerAcquisition;
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
/// the frame rate. `<= 0` freezes the grid.
f32 time_constant_s = 1.0f;
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
/// 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: one accumulation
/// deposits 1.0 into every cell its traces cover, so the default of 1.0 draws a single
/// acquisition at full ink and lets the ones behind it add on top (up to saturation). A caller
/// that wants the newest sweep dimmer -- so that agreement between acquisitions is the only
/// thing that reaches white -- passes 2.0 or more.
f32 full_scale = 1.0f;
/// The gamma applied to the normalised cell value before it becomes a luminance. Under 1 --
/// 0.45, the same default `Style::intensity_gamma` uses -- lifts the faint end, which is what
/// makes a ghost *readable* rather than merely present: without it a cell covered by one in
/// eight sweeps is an eighth of the ink and looks like a smudge of the background.
f32 gamma = 0.45f;
};
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its
/// 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).
///
/// 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
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
///
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device pixel `(axes.x + c, axes.y + r)`, where `axes` is
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move.
class DensityGrid {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first
/// accumulation on a laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per device pixel -- see the class comment for
/// the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
if (column >= columns_ || row >= rows_) return 0.0f;
return cells_[static_cast<usize>(row) * columns_ + column];
}
/// The **total ink**: every cell's value summed. The reading that says how much has been
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
/// and is 0 for a grid that is off or was just cleared.
f64 ink() const noexcept {
f64 total = 0.0;
for (const f32 v : cells_) total += static_cast<f64>(v);
return total;
}
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
f32 peak() const noexcept {
f32 top = 0.0f;
for (const f32 v : cells_) top = v > top ? v : top;
return top;
}
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
/// The renderer compares it with the revision it last uploaded, so a frame that changed
/// nothing uploads nothing.
u64 revision() const noexcept { return revision_; }
private:
friend class Plot;
std::vector<f32> cells_;
u32 columns_ = 0;
u32 rows_ = 0;
u64 revision_ = 0;
};
} // namespace plot
+103
View File
@@ -12,6 +12,7 @@
#include <plot/plot/axis.hpp> #include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp> #include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp> #include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp> #include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp> #include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp> #include <plot/plot/measure.hpp>
@@ -583,6 +584,73 @@ class Plot {
void set_invalid_spans(std::span<const InvalidSpan> spans); void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; } std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// 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.
///
/// 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
/// it. Unlike a band it needs no one-pixel floor on its own size -- a cell *is* a device pixel
/// by construction, which is the smallest mark the rule could ask for.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
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_; }
/// 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.
///
/// 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
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a
/// gap: it deposits nothing and joins nothing, the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the /// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different /// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same /// series, a longer one, or a sliding window whose sample count stayed the same
@@ -929,6 +997,23 @@ class Plot {
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in /// 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. /// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans(); void build_invalid_spans();
/// Deposits one trace's visible window into the 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
/// 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();
/// Exact statistics of `region` over `series`, or an invalid result when the /// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives. /// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series); static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -977,6 +1062,24 @@ class Plot {
std::vector<Annotation> annotations_; std::vector<Annotation> annotations_;
std::vector<Region> regions_; std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_; 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.
DensityConfig density_{};
DensityGrid density_grid_{};
/// 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.
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.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()` /// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_` /// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never /// does. Entries are created for the pairs a caller actually asks about, never
+32
View File
@@ -84,4 +84,36 @@ class TextProgram {
gl::Program program_; gl::Program program_;
}; };
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
/// `GLFailure`.
///
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
/// cell -- which is the one thing this pipeline has that the others do not.
///
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
/// with the caller, who owns its lifetime.
class DensityProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and
/// the grid's own size, which is the axes rect in whole device pixels. They are two uniforms
/// rather than a rect because a `vec4` uniform would need a fourth setter shape for one
/// caller; a corner and a size are what the vertex shader builds the quad from anyway.
void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept;
private:
gl::Program program_;
};
} // namespace plot::render } // namespace plot::render
+40 -2
View File
@@ -48,6 +48,17 @@ struct FrameStats {
u32 staged_segments = 0; ///< line segments staged, all colour groups u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text 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.
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
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
}; };
/// Draws a `Plot` into whatever framebuffer is bound. /// Draws a `Plot` into whatever framebuffer is bound.
@@ -93,8 +104,8 @@ class Renderer {
Renderer(const Renderer&) = delete; Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete; Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex /// Destroys every GL object this renderer created: all three programs, the vertex arrays and
/// arrays and their instance buffers, and the glyph atlas texture. The /// their instance buffers, the glyph atlas texture and the density's own texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be /// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object. /// called again on the same object.
/// ///
@@ -513,6 +524,18 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout); void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot); 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 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.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span, /// `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 — /// 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 /// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
@@ -679,6 +702,21 @@ class Renderer {
render::TextAtlas atlas_; render::TextAtlas atlas_;
render::LineProgram line_program_; render::LineProgram line_program_;
render::TextProgram text_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.
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
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.
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_; render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_; render::TextVertexArray text_vao_;
+22
View File
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
extern const char* const kTextVS; extern const char* const kTextVS;
extern const char* const kTextFS; extern const char* const kTextFS;
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
///
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
/// pixels), `u_size` (vec2, its size in pixels -- the grid's own columns and rows, so one texel
/// is one pixel on screen and no filtering is needed), `u_color` (vec4), `u_density` (sampler2D,
/// unit 0).
///
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
/// own to get out of step with the numbers a host reads back.
///
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
/// here, as in the line pipeline.
extern const char* const kDensityVS;
extern const char* const kDensityFS;
} // namespace plot::render } // namespace plot::render
+8
View File
@@ -77,6 +77,14 @@ struct Style {
/// different kinds of thing; and still above the threshold where the difference from *no* band /// 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. /// is visible, which is the floor this alpha is chosen against.
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f); 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 /// 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* /// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// trace and the line says *where*. /// trace and the line says *where*.
+219
View File
@@ -348,6 +348,225 @@ void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
invalid_spans_.assign(spans.begin(), spans.end()); invalid_spans_.assign(spans.begin(), spans.end());
} }
// --- phosphor density ---------------------------------------------------------
void Plot::set_density(const DensityConfig& config) {
// Disabling is a caller asking to stop paying for the display, and a trigger that has stopped
// 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_;
}
density_ = config;
}
void Plot::decay_density(f64 dt_s) {
if (!density_.enabled || density_.decay != DensityDecay::TimeConstant) return;
// A non-positive frame and a non-positive time constant are both "do not fade": the first is a
// caller that has not measured a frame yet, and the second is a caller asking for a grid that
// is drawn and never fades.
if (!(dt_s > 0.0) || !(density_.time_constant_s > 0.0f)) return;
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_;
}
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_;
}
bool Plot::size_density_grid() {
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_;
}
return !density_grid_.cells_.empty();
}
void Plot::accumulate_density() {
if (!density_.enabled) return;
if (!size_density_grid()) 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 (const Trace& trace : traces_) deposit_density_trace(trace);
++density_grid_.revision_;
}
void Plot::deposit_density_trace(const Trace& trace) {
if (!trace.visible()) return;
const data::Series& series = *trace.series;
const u64 n = series.size();
if (!(series.dx() > 0.0) || n == 0u) return;
// The same index window `Renderer::draw_traces()` maps, by the same calls: `neighbours()` on
// the series' own stamps -- never the `(x - x0()) / dx()` grid, which is a nominal spacing and
// would lose the segments entering and leaving the frame -- with one sample of slack on each
// side and clamped to the series.
const Range window = x_range(trace.axis_x);
u64 first = 0u;
u64 last = n;
u64 i0 = 0u;
u64 i1 = 0u;
if (series.neighbours(window.min, i0, i1)) first = i0 > 0u ? i0 - 1u : 0u;
if (series.neighbours(window.max, i0, i1)) last = std::min(n, i1 + 1u);
if (last <= first) return;
// The same level gate the renderer uses, and for the same reason: a window denser than the
// pixel grid is a *reduction*, and the density is a reduction of it too -- one pass per cell
// the column's own envelope crosses. The levels agree because the ask is the same ask.
const f64 samples_per_pixel =
static_cast<f64>(last - first) / static_cast<f64>(layout_.axes.w);
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
density_points_.clear();
series.gather(level, first, last, window.min, density_points_, trace.mode);
if (density_points_.size() < 2u) return;
// A fresh stamp for this trace's part of this acquisition: the key a cell is deduped against is
// `(this, column)`, so every trace of a plot deposits once per cell it covers even where they
// overlap, and a cell one trace crosses twice in one sweep deposits once.
++density_trace_;
const Transform tx = x_transform(trace.axis_x);
const Transform ty = y_transform(trace.axis_y);
f32 px = 0.0f;
f32 py = 0.0f;
u32 run = 0u; // points in the current unbroken run
// A run's *first* point is not deposited on its own: the segment that leaves it already puts
// ink in that cell, and depositing both would count the pen's arrival twice -- a single pass
// would read as two in the cell every trace starts in, which is exactly the cell a peak
// reading is taken from. A run of one point -- a lone visible sample, or one isolated by a
// 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);
};
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
// absolute x is reconstructed in f64 before it is mapped -- the same reconstruction, and the
// same reason, as the staging paths.
const f64 x_abs = window.min + static_cast<f64>(density_points_[k]);
const f64 y_abs = static_cast<f64>(density_points_[k + 1u]);
if (!std::isfinite(x_abs) || !std::isfinite(y_abs)) {
flush_run(); // a NaN is a gap: it joins nothing, so the trace is deposited in pieces
run = 0u;
continue;
}
const f32 sx = static_cast<f32>(tx.to_screen_x(x_abs));
const f32 sy = static_cast<f32>(ty.to_screen_y(y_abs));
if (!std::isfinite(sx) || !std::isfinite(sy)) {
flush_run();
run = 0u;
continue;
}
if (run > 0u) deposit_density_segment(px, py, sx, sy);
px = sx;
py = sy;
++run;
}
flush_run();
}
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_;
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;
const Rect& axes = layout_.axes;
const f32 lo_x = std::min(x0, x1);
const f32 hi_x = std::max(x0, x1);
i32 c0 = static_cast<i32>(std::floor(lo_x - axes.x));
i32 c1 = static_cast<i32>(std::floor(hi_x - axes.x));
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
// keeps ink off the edge column when a trace leaves the frame sideways.
c0 = std::max(c0, 0);
c1 = std::min(c1, static_cast<i32>(columns) - 1);
if (c1 < c0) return;
const f32 span = hi_x - lo_x;
const f32 denom = x1 - x0;
const i32 last_row = static_cast<i32>(rows) - 1;
// The segment's y span inside one column's own slice of x: the two ends of the slice taken
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
// x), so the deposit is a continuous trace and not a dotted one.
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
const f32 ca = axes.x + static_cast<f32>(column);
f32 ya = y0;
f32 yb = y1;
if (span > 0.0f && denom != 0.0f) {
const f32 a = std::max(ca, lo_x);
const f32 b = std::min(ca + 1.0f, hi_x);
ya = y0 + (a - x0) / denom * (y1 - y0);
yb = y0 + (b - x0) / denom * (y1 - y0);
}
out_lo = std::min(ya, yb);
out_hi = std::max(ya, yb);
};
for (i32 c = c0; c <= c1; ++c) {
f32 y_lo = 0.0f;
f32 y_hi = 0.0f;
column_y_span(c, y_lo, y_hi);
// A column whose slice of the segment lies wholly outside the axes deposits nothing: without
// this, a trace above the top of the plot would paint a band along the top row, which is the
// one thing a density must not do -- it would say the trace was there.
if (!(y_hi >= axes.y) || !(y_lo <= axes.bottom())) continue;
const f32 top = std::max(y_lo, axes.y);
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
// rather than one past the end.
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
i32 r0 = std::clamp(static_cast<i32>(std::floor(top - axes.y)), 0, last_row);
i32 r1 = std::clamp(static_cast<i32>(std::floor(bottom - axes.y)), 0, last_row);
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
// of times by one acquisition's own samples, and counting each crossing would put a single
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
// and a cell's value would mean "how much the beam moved here" instead of "how many
// acquisitions were here" -- which is what a persistence display is. The stamp is per
// *(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);
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
density_stamp_[row] = key;
column_cells[row * static_cast<usize>(columns)] += 1.0f;
}
}
}
f64 Plot::x_axis_origin(u32 axis) const { f64 Plot::x_axis_origin(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0; return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
} }
+23
View File
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
program_.set_uniform("u_tex_size", texels); program_.set_uniform("u_tex_size", texels);
} }
bool DensityProgram::create(Status* st) noexcept {
program_ = gl::Program::from_source(kDensityVS, kDensityFS, st);
if (program_.id() == 0) {
return false;
}
// The grid texture is bound to texture unit 0 by plot::gl::Texture::bind(), so the sampler
// has to point there. Set once, here, not per frame -- the same shape as the text program's.
program_.use();
program_.set_uniform_int("u_density", 0);
return true;
}
void DensityProgram::set_viewport(Vec2 px) const noexcept { program_.set_uniform("u_viewport", px); }
void DensityProgram::set_rect(Vec2 origin, Vec2 size) const noexcept {
program_.set_uniform("u_origin", origin);
program_.set_uniform("u_size", size);
}
void DensityProgram::set_color(Color color) const noexcept {
program_.set_uniform("u_color", color);
}
} // namespace plot::render } // namespace plot::render
+90 -5
View File
@@ -193,14 +193,22 @@ Renderer::~Renderer() {
void Renderer::release_gl() noexcept { void Renderer::release_gl() noexcept {
ready_ = false; ready_ = false;
// Assigning fresh objects releases every GL name through the handles' // Assigning fresh objects releases every GL name through the handles'
// destructors: both programs, both vertex arrays and their instance buffers, // destructors: all three programs, the vertex arrays and their instance
// and the atlas texture. Safe to call more than once; every assignment is a // buffers, the glyph atlas texture and the density's own texture. Safe to call
// no-op while the renderer is uninitialised. // more than once; every assignment is a no-op while the renderer is
// uninitialised.
atlas_ = render::TextAtlas{}; atlas_ = render::TextAtlas{};
line_program_ = render::LineProgram{}; line_program_ = render::LineProgram{};
text_program_ = render::TextProgram{}; text_program_ = render::TextProgram{};
density_program_ = render::DensityProgram{};
line_vao_ = render::LineVertexArray{}; line_vao_ = render::LineVertexArray{};
text_vao_ = render::TextVertexArray{}; text_vao_ = render::TextVertexArray{};
density_vao_ = gl::VertexArray{};
density_texture_ = gl::Texture{};
density_pixels_.clear();
density_w_ = 0;
density_h_ = 0;
density_uploaded_revision_ = 0;
lines_.clear(); lines_.clear();
glyphs_under_.clear(); glyphs_under_.clear();
glyphs_over_.clear(); glyphs_over_.clear();
@@ -252,10 +260,10 @@ bool Renderer::init(Status* st) noexcept {
return false; return false;
} }
if (!line_program_.create(st) || !text_program_.create(st)) { if (!line_program_.create(st) || !text_program_.create(st) || !density_program_.create(st)) {
return false; return false;
} }
if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode)) { if (!line_vao_.create(st, mode) || !text_vao_.create(st, mode) || !density_vao_.create(st)) {
return false; return false;
} }
ready_ = true; ready_ = true;
@@ -519,6 +527,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// The holes over the grid and under the data: a band is chrome of the *background*, not an // The holes over the grid and under the data: a band is chrome of the *background*, not an
// overlay, so everything a frame draws inside the axes is drawn on top of it. // overlay, so everything a frame draws inside the axes is drawn on top of it.
draw_invalid_spans(layout); draw_invalid_spans(layout);
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
// as the traces do, and under them for the same reason they are under the data.
draw_density(plot, layout);
clear_clip(); clear_clip();
draw_axis_lines(plot); draw_axis_lines(plot);
set_clip(layout.axes); set_clip(layout.axes);
@@ -1740,6 +1751,80 @@ void Renderer::draw_invalid_spans(const PlotLayout& layout) {
} }
} }
void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
const DensityConfig& config = plot.density();
const DensityGrid& grid = plot.density_grid();
const u32 columns = grid.columns();
const u32 rows = grid.rows();
if (!config.enabled || columns == 0u || rows == 0u) return;
if (!(layout.axes.w > 0.0f) || !(layout.axes.h > 0.0f)) return;
if (!density_program_.valid()) return; // a pipeline that never linked draws nothing
// The image as the shader reads it: one byte per cell, `full_scale` and `gamma` already
// applied. The conversion lives here rather than in the fragment shader so that a cell's
// *count* has exactly one reading -- the plot's, which is also what a host reads back through
// `DensityGrid::ink()`/`peak()`, and which decays once per frame on the CPU anyway.
const bool resized = columns != density_w_ || rows != density_h_;
if (resized) {
if (!density_texture_.create_r8(columns, rows, nullptr)) {
gl_error_ = true;
return;
}
// One texel per cell and a quad exactly that many pixels across, so every fragment lands
// inside its own cell: a point sample is the whole of the filtering this needs, and any
// other mode would blend two sweeps' counts at the cell walls.
density_texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
density_w_ = columns;
density_h_ = rows;
density_uploaded_revision_ = 0u; // a fresh texture holds nothing
}
if (resized || grid.revision() != density_uploaded_revision_) {
const f32 full = config.full_scale > 0.0f ? config.full_scale : 1.0f;
const f32 gamma = config.gamma > 0.0f ? config.gamma : 1.0f;
// The gamma through a 256-entry table rather than a `pow` per cell: the output *is* eight
// bits, so quantising the count first and lifting the quantised value costs nothing a
// per-cell `pow` would have bought, and it keeps the per-frame pass a loop of multiplies.
u8 lut[256];
for (u32 i = 0u; i < 256u; ++i) {
const f32 v = static_cast<f32>(i) / 255.0f;
lut[i] = static_cast<u8>(std::clamp(std::pow(v, gamma), 0.0f, 1.0f) * 255.0f + 0.5f);
}
density_pixels_.resize(grid.cell_count());
const f32* cells = grid.cells().data();
for (usize i = 0; i < density_pixels_.size(); ++i) {
const f32 v = cells[i] / full;
const u32 quantised =
static_cast<u32>(std::clamp(v, 0.0f, 1.0f) * 255.0f + 0.5f);
density_pixels_[i] = lut[std::min(quantised, 255u)];
}
// `upload_r8` sets GL_UNPACK_ALIGNMENT to 1 and leaves it there (see gl::Texture), so the
// host's own value is saved and put back -- the same care `TextAtlas::sync()` takes around
// its own upload, since the two share a context and a frame.
gl::GLint alignment = 4;
gl::glGetIntegerv(gl::GL_UNPACK_ALIGNMENT, &alignment);
density_texture_.upload_r8(columns, rows, density_pixels_.data());
gl::glPixelStorei(gl::GL_UNPACK_ALIGNMENT, alignment);
density_uploaded_revision_ = grid.revision();
// += and not =: a figure draws a density per row, and what this field reports is the frame's
// upload, not the last row's.
stats_.density_upload_bytes += columns * rows;
}
// The quad covers the cells, not the axes rect's own fractional size: `columns`/`rows` are that
// rect rounded up, so mapping uv over them puts texel i exactly on device pixel `axes.x + i`.
// Drawing the un-rounded rect would stretch the last row and column of cells over the fraction.
density_program_.bind();
density_program_.set_viewport({viewport_.w, viewport_.h});
density_program_.set_rect({layout.axes.x, layout.axes.y},
{static_cast<f32>(columns), static_cast<f32>(rows)});
density_program_.set_color(style_.density);
density_texture_.bind();
density_vao_.bind();
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
stats_.density_cells = columns * rows;
++stats_.draw_calls;
}
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
const HotHandles hot = plot.hot_handles(); const HotHandles hot = plot.hot_handles();
const f32 edge_w = style_.device(style_.line_width_px); const f32 edge_w = style_.device(style_.line_width_px);
+43
View File
@@ -235,4 +235,47 @@ void main() {
} }
)"; )";
const char* const kDensityVS = R"(#version 330 core
// No vertex attributes at all: the image is one axis-aligned rectangle whose corners are
// uniform state, so the quad comes from gl_VertexID alone and there is no vertex buffer to
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
uniform vec2 u_size; // the quad's size in pixels -- the grid's columns and rows
out vec2 v_uv;
void main() {
// A four-vertex triangle strip, like the line and text pipelines: the corner index is the
// vertex id's two bits, so 0,1,2,3 are (0,0), (1,0), (0,1), (1,1).
vec2 corner = vec2(float(gl_VertexID & 1), float((gl_VertexID >> 1) & 1));
vec2 px = u_origin + corner * u_size;
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
// u_size is the grid's own size in pixels and the texture is one texel per cell, so one
// unit of uv is exactly one cell and a GL_NEAREST fetch lands on the cell the fragment is
// inside -- there is nothing to filter and nothing to round.
v_uv = corner;
}
)";
const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_NEAREST
uniform vec4 u_color;
out vec4 o_color;
void main() {
// The cell's count was already turned into a luminance on the CPU, where the count, the
// decay and the gamma live -- so this stage has no scale of its own that a host reading the
// grid's numbers back could disagree with. All that is left is the tint: the density says how
// often a trace passed, never which one, so it is one hue for the whole image.
float ink = texture(u_density, v_uv).r;
o_color = vec4(u_color.rgb, u_color.a * ink);
}
)";
} // namespace plot::render } // namespace plot::render
+279
View File
@@ -2808,3 +2808,282 @@ PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6); PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6); PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
} }
// --- phosphor density -------------------------------------------------------
namespace {
/// A two-sample flat series: one segment across the whole window at y = 0, which is the
/// simplest geometry a density can be read against -- one cell per column and nothing else.
std::vector<f32> flat_pair() { return std::vector<f32>{0.0f, 0.0f}; }
/// A plot with one flat trace over a 640x480 viewport, laid out. The axes rect it returns is
/// the one `accumulate_density()` sizes the grid to, so a test reads both together.
struct DensityFixture {
plot::data::ArraySeries series;
plot::Plot plot;
plot::u32 ax = 0;
plot::u32 ay = 0;
explicit DensityFixture(std::vector<f32> y = flat_pair(), plot::f64 dx = 1.0)
: series(std::move(y), 0.0, dx) {
plot.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
ax = plot.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "flat"}) == 0u);
plot.set_x_range(ax, {0.0, 1.0});
plot.set_y_range(ay, {-1.0, 1.0});
plot.layout();
}
};
} // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect, and
/// nothing at all until the density is switched on. The figures here are the ones the API's
/// memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has done.
PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
DensityFixture f;
const plot::Rect axes = f.plot.layout().axes;
const plot::u32 columns = static_cast<plot::u32>(std::ceil(axes.w));
const plot::u32 rows = static_cast<plot::u32>(std::ceil(axes.h));
// Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
// 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::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density().enabled);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid();
PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
// A 640x480 viewport, minus the chrome: `4 bytes * cells`.
const plot::usize bytes = grid.cell_count() * sizeof(plot::f32);
std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n",
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
static_cast<double>(grid.peak()));
// One flat trace over the whole window lights every column exactly once: one segment, no
// column reached twice, so the ink *is* the width and the peak is one pass.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(columns), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
// ... and all of it in one row, the one y = 0 maps to.
const plot::Transform ty = f.plot.y_transform(f.ay);
const int row = static_cast<int>(std::floor(ty.to_screen_y(0.0) - axes.y));
plot::u32 lit = 0;
for (plot::u32 r = 0; r < grid.rows(); ++r) {
for (plot::u32 c = 0; c < grid.columns(); ++c) {
if (grid.at(c, r) <= 0.0f) continue;
++lit;
PLOT_CHECK(static_cast<int>(r) == row);
}
}
PLOT_CHECK(lit == columns);
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a
// grid of that size and nothing carried over from the old one.
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);
}
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
/// deposited, so the sweep on screen is the brightest and the one behind it is the factor
/// down: the schedule a phosphor's afterglow has, and the reading that says which way round
/// the halving goes.
PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::PerAcquisition;
cfg.per_acquisition = 0.5f;
f.plot.set_density(cfg);
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();
PLOT_CHECK_NEAR(one, width, 1.0);
f.plot.accumulate_density();
const double two = f.plot.density_grid().ink();
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.5f, 1e-3);
f.plot.accumulate_density();
const double three = f.plot.density_grid().ink();
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().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);
// 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);
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
three, 2.0);
}
/// The time constant is the other question: the image fades while you watch, by the frame's own
/// `dt` against a constant stated in seconds, and it is the *caller* who advances it -- there is
/// no clock in the library, which is what makes the reading reproducible.
PLOT_TEST(a_density_fades_by_its_time_constant) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::TimeConstant;
cfg.time_constant_s = 2.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double full = f.plot.density_grid().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);
f.plot.decay_density(-1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().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);
// 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);
// 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();
f.plot.decay_density(100.0);
PLOT_CHECK_NEAR(f.plot.density_grid().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();
f.plot.decay_density(1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), before, 1e-6);
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
frozen);
}
/// Switching off drops the image at once -- not at the next acquisition, which a trigger that
/// has stopped firing never sends -- and turning it back on starts from an empty grid.
PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().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);
// 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);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().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);
// ... 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);
}
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
/// leaves no ink rather than a band along the edge it left by, and a NaN sample is a gap that
/// joins nothing.
PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
// y = 5 against a [-1, 1] axis: the segment's whole column span is above the axes.
DensityFixture off(std::vector<f32>{5.0f, 5.0f});
plot::DensityConfig cfg;
cfg.enabled = true;
off.plot.set_density(cfg);
off.plot.accumulate_density();
PLOT_CHECK_NEAR(off.plot.density_grid().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();
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;
// The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto*
// the bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(ty.to_screen_y(y) - axes.y)), 0,
static_cast<int>(grid.rows()) - 1));
};
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
PLOT_CHECK(grid.at(grid.columns() - 1u, row_of(1.0)) > 0.0f);
// Inverted on screen: y = +1 is the *lower* row index, which is the one y flip of the mapping.
PLOT_CHECK(row_of(1.0) < row_of(-1.0));
// A NaN in the middle is a gap: it joins nothing, so the trace is deposited as two runs and
// the columns where the gap is stay empty -- where the same series with the sample filled in
// lights every one of them. Five samples at x = 0, 0.25, 0.5, 0.75, 1.0.
const auto column_ink = [](const plot::DensityGrid& g, plot::u32 column) {
double sum = 0.0;
for (plot::u32 r = 0; r < g.rows(); ++r) sum += static_cast<double>(g.at(column, r));
return sum;
};
DensityFixture whole(std::vector<f32>{0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, 0.25);
whole.plot.set_density(cfg);
whole.plot.accumulate_density();
DensityFixture gap(
std::vector<f32>{0.0f, 0.0f, std::numeric_limits<f32>::quiet_NaN(), 0.0f, 0.0f}, 0.25);
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::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(
static_cast<int>(std::floor(tx.to_screen_x(0.5) - gap_axes.x)), 0,
static_cast<int>(gap_grid.columns()) - 1));
PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit
PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
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());
}
+136
View File
@@ -7519,3 +7519,139 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
PLOT_CHECK(outside_after == outside_before); PLOT_CHECK(outside_after == outside_before);
p.clear_selection_rect(); 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 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) {
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
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});
const plot::Color trace_color = plot::Color::hex(0x00E070);
PLOT_CHECK(p.add_trace({&s, trace_color, 1.5f, ax, ay, "flat"}) == 0u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
// The baseline: no density at all, and no grid to draw one from.
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
PLOT_CHECK(r.stats().density_cells == 0u);
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
const std::vector<plot::u8> without = read_frame(ctx);
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::PerAcquisition;
cfg.per_acquisition = 0.5f;
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
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
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);
// 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.
const plot::Rect axes = p.layout().axes;
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0u, hi_y = 0u, differing = 0u;
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]) {
continue;
}
++differing;
lo_x = std::min(lo_x, x);
lo_y = std::min(lo_y, y);
hi_x = std::max(hi_x, x);
hi_y = std::max(hi_y, y);
}
}
PLOT_CHECK(differing > 0u);
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
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();
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));
// 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.
r.begin_frame(kW, kH, kFrameRect);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
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 == with);
std::printf("[density gl] cells=%u uploaded=%u differing=%u ghost row=%u trace row=%u"
" axes=%.0f,%.0f %.0fx%.0f\n",
first.density_cells, first.density_upload_bytes, differing, ghost_row, trace_row,
axes.x, axes.y, axes.w, axes.h);
ctx.destroy();
}