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

+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
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`
preset; its harness prints one line per case and a final `377 cases, 0 failed` summary line.
`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 `396 cases, 0 failed` summary line.
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.
@@ -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
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
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.
## 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
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
`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)
+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
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
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 |
| `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: