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:
1 parent
3ad02e5120
commit
6a6915e64e
17 files changed
+1199
-13
No files matched your search
@@ -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
|
||||
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
|
||||
threshold;
|
||||
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
|
||||
than a set of retained traces): one `f32` cell per device pixel of the axes rect, behind the
|
||||
traces, into which each acquisition is deposited -- 1.0 per cell a trace crosses, so brightness
|
||||
is how often it passed -- and which fades either by a **time constant** in stream seconds or by
|
||||
a **per-acquisition factor**. Memory is the screen's (a 640x360 viewport's axes is 179 349
|
||||
cells, 717 kB) whatever the trigger has done, the deposit walks the axes' columns rather than
|
||||
the samples, and a frame whose grid did not move costs one draw call
|
||||
(`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
|
||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||
@@ -196,12 +204,12 @@ documentation.
|
||||
```sh
|
||||
cmake --preset linux-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
|
||||
```
|
||||
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/crosshair.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/density.hpp>
|
||||
#include <plot/plot/figure.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
#include <plot/plot/legend.hpp>
|
||||
|
||||
@@ -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
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <plot/plot/axis.hpp>
|
||||
#include <plot/plot/crosshair.hpp>
|
||||
#include <plot/plot/cursor.hpp>
|
||||
#include <plot/plot/density.hpp>
|
||||
#include <plot/plot/grid.hpp>
|
||||
#include <plot/plot/legend.hpp>
|
||||
#include <plot/plot/measure.hpp>
|
||||
@@ -583,6 +584,73 @@ class Plot {
|
||||
void set_invalid_spans(std::span<const InvalidSpan> 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
|
||||
/// 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
|
||||
@@ -929,6 +997,23 @@ class Plot {
|
||||
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
|
||||
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
|
||||
void build_invalid_spans();
|
||||
/// Deposits one trace's visible window into the density grid: the same index window
|
||||
/// `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
|
||||
/// interval covers no sample. The one place the region arithmetic lives.
|
||||
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
||||
@@ -977,6 +1062,24 @@ class Plot {
|
||||
std::vector<Annotation> annotations_;
|
||||
std::vector<Region> regions_;
|
||||
std::vector<InvalidSpan> invalid_spans_;
|
||||
/// The phosphor density: its configuration, the grid it accumulates into, and the scratch the
|
||||
/// one visible window being deposited is gathered into. The grid is a member and not a local
|
||||
/// because it is the *display's* memory -- it outlives the frames it was drawn in, which is the
|
||||
/// whole point of accumulating into a screen-sized image rather than retaining traces.
|
||||
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()`
|
||||
/// 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
|
||||
|
||||
@@ -84,4 +84,36 @@ class TextProgram {
|
||||
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
|
||||
@@ -48,6 +48,17 @@ struct FrameStats {
|
||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||
u32 draw_calls = 0; ///< draw calls issued, line and text
|
||||
/// The phosphor density's own cells, when one was drawn: `Plot::density_grid()`'s resolution,
|
||||
/// zero for a plot with the density off. It is what the *frame* drew, so a host reading the
|
||||
/// density's cost off a frame reads it here rather than off the grid, which says what is
|
||||
/// accumulated and not what was spent.
|
||||
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.
|
||||
@@ -93,8 +104,8 @@ class Renderer {
|
||||
Renderer(const Renderer&) = delete;
|
||||
Renderer& operator=(const Renderer&) = delete;
|
||||
|
||||
/// Destroys every GL object this renderer created: both programs, both vertex
|
||||
/// arrays and their instance buffers, and the glyph atlas texture. The
|
||||
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
|
||||
/// their instance buffers, the glyph atlas texture and the density's own texture. The
|
||||
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
|
||||
/// called again on the same object.
|
||||
///
|
||||
@@ -513,6 +524,18 @@ class Renderer {
|
||||
void draw_plot(Plot& plot, const PlotLayout& layout);
|
||||
|
||||
void draw_grid(const Plot& plot);
|
||||
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: the accumulated
|
||||
/// image as one textured quad over the axes rect, in `Style::density`'s monochrome tint. Drawn
|
||||
/// over the grid and the invalid-span bands and **under the traces**, which is what makes it a
|
||||
/// record of where the traces have been rather than a second set of them.
|
||||
///
|
||||
/// The 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,
|
||||
/// 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
|
||||
@@ -679,6 +702,21 @@ class Renderer {
|
||||
render::TextAtlas atlas_;
|
||||
render::LineProgram line_program_;
|
||||
render::TextProgram text_program_;
|
||||
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture the
|
||||
/// grid is uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and
|
||||
/// re-created when that grid's size moves -- a resized plot is a differently sized image, and
|
||||
/// the plot has already dropped what the old one held.
|
||||
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::TextVertexArray text_vao_;
|
||||
|
||||
|
||||
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
|
||||
extern const char* const kTextVS;
|
||||
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
|
||||
@@ -77,6 +77,14 @@ struct Style {
|
||||
/// different kinds of thing; and still above the threshold where the difference from *no* band
|
||||
/// is visible, which is the floor this alpha is chosen against.
|
||||
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
|
||||
/// The tint a plot's **phosphor density** is drawn in (`Plot::set_density()`): the grid's
|
||||
/// luminance comes from the accumulated counts, and this is the monochrome hue it is multiplied
|
||||
/// into -- the density says *how often*, so it must not also say *which signal*, and a trace
|
||||
/// colour here would make a ghost read as a trace that is not there. A near-white neutral, one
|
||||
/// step brighter than `invalid_fill`'s grey, because this is ink the plot *has* and a hole is
|
||||
/// ink it is missing. The alpha is the whole brightness scale's ceiling -- the shader multiplies
|
||||
/// it by each cell's own value -- so under 1 it puts a lid on how bright the display can get.
|
||||
Color density = Color::hex(0xDFF2FF, 0.85f);
|
||||
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
||||
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
||||
/// trace and the line says *where*.
|
||||
|
||||
@@ -348,6 +348,225 @@ void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
|
||||
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 {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||
}
|
||||
|
||||
@@ -60,4 +60,27 @@ void TextProgram::set_texture_size(Vec2 texels) const noexcept {
|
||||
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
|
||||
+90
-5
@@ -193,14 +193,22 @@ Renderer::~Renderer() {
|
||||
void Renderer::release_gl() noexcept {
|
||||
ready_ = false;
|
||||
// Assigning fresh objects releases every GL name through the handles'
|
||||
// destructors: both programs, both vertex arrays and their instance buffers,
|
||||
// and the atlas texture. Safe to call more than once; every assignment is a
|
||||
// no-op while the renderer is uninitialised.
|
||||
// destructors: all three programs, the vertex arrays and their instance
|
||||
// buffers, the glyph atlas texture and the density's own texture. Safe to call
|
||||
// more than once; every assignment is a no-op while the renderer is
|
||||
// uninitialised.
|
||||
atlas_ = render::TextAtlas{};
|
||||
line_program_ = render::LineProgram{};
|
||||
text_program_ = render::TextProgram{};
|
||||
density_program_ = render::DensityProgram{};
|
||||
line_vao_ = render::LineVertexArray{};
|
||||
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();
|
||||
glyphs_under_.clear();
|
||||
glyphs_over_.clear();
|
||||
@@ -252,10 +260,10 @@ bool Renderer::init(Status* st) noexcept {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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
|
||||
// overlay, so everything a frame draws inside the axes is drawn on top of it.
|
||||
draw_invalid_spans(layout);
|
||||
// ... the density included: it is trace ink and not chrome, so it goes over the bands exactly
|
||||
// as the traces do, and under them for the same reason they are under the data.
|
||||
draw_density(plot, layout);
|
||||
clear_clip();
|
||||
draw_axis_lines(plot);
|
||||
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) {
|
||||
const HotHandles hot = plot.hot_handles();
|
||||
const f32 edge_w = style_.device(style_.line_width_px);
|
||||
|
||||
@@ -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
|
||||
@@ -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.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());
|
||||
}
|
||||
@@ -7519,3 +7519,139 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
|
||||
PLOT_CHECK(outside_after == outside_before);
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user