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

+279
View File
@@ -2808,3 +2808,282 @@ PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
}
// --- phosphor density -------------------------------------------------------
namespace {
/// A two-sample flat series: one segment across the whole window at y = 0, which is the
/// simplest geometry a density can be read against -- one cell per column and nothing else.
std::vector<f32> flat_pair() { return std::vector<f32>{0.0f, 0.0f}; }
/// A plot with one flat trace over a 640x480 viewport, laid out. The axes rect it returns is
/// the one `accumulate_density()` sizes the grid to, so a test reads both together.
struct DensityFixture {
plot::data::ArraySeries series;
plot::Plot plot;
plot::u32 ax = 0;
plot::u32 ay = 0;
explicit DensityFixture(std::vector<f32> y = flat_pair(), plot::f64 dx = 1.0)
: series(std::move(y), 0.0, dx) {
plot.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
ax = plot.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
ay = plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
PLOT_CHECK(plot.add_trace({&series, plot::Color::hex(0x00E070), 1.5f, ax, ay, "flat"}) == 0u);
plot.set_x_range(ax, {0.0, 1.0});
plot.set_y_range(ay, {-1.0, 1.0});
plot.layout();
}
};
} // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect, and
/// nothing at all until the density is switched on. The figures here are the ones the API's
/// memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has done.
PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
DensityFixture f;
const plot::Rect axes = f.plot.layout().axes;
const plot::u32 columns = static_cast<plot::u32>(std::ceil(axes.w));
const plot::u32 rows = static_cast<plot::u32>(std::ceil(axes.h));
// Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
// deposits nothing at all.
PLOT_CHECK(!f.plot.density().enabled);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid().peak() == 0.0f);
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density().enabled);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid();
PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
// A 640x480 viewport, minus the chrome: `4 bytes * cells`.
const plot::usize bytes = grid.cell_count() * sizeof(plot::f32);
std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n",
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
static_cast<double>(grid.peak()));
// One flat trace over the whole window lights every column exactly once: one segment, no
// column reached twice, so the ink *is* the width and the peak is one pass.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(columns), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
// ... and all of it in one row, the one y = 0 maps to.
const plot::Transform ty = f.plot.y_transform(f.ay);
const int row = static_cast<int>(std::floor(ty.to_screen_y(0.0) - axes.y));
plot::u32 lit = 0;
for (plot::u32 r = 0; r < grid.rows(); ++r) {
for (plot::u32 c = 0; c < grid.columns(); ++c) {
if (grid.at(c, r) <= 0.0f) continue;
++lit;
PLOT_CHECK(static_cast<int>(r) == row);
}
}
PLOT_CHECK(lit == columns);
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a
// grid of that size and nothing carried over from the old one.
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout();
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().columns() < columns);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(),
static_cast<double>(f.plot.density_grid().columns()), 1.0);
}
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
/// deposited, so the sweep on screen is the brightest and the one behind it is the factor
/// down: the schedule a phosphor's afterglow has, and the reading that says which way round
/// the halving goes.
PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::PerAcquisition;
cfg.per_acquisition = 0.5f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
// The grid is sized by the first accumulation, so the width is read after it -- and the ink
// of one pass *is* the width: one segment across the window, one cell per column.
const double width = static_cast<double>(f.plot.density_grid().columns());
const double one = f.plot.density_grid().ink();
PLOT_CHECK_NEAR(one, width, 1.0);
f.plot.accumulate_density();
const double two = f.plot.density_grid().ink();
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.5f, 1e-3);
f.plot.accumulate_density();
const double three = f.plot.density_grid().ink();
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.75f, 1e-3);
// The steady state of a stream that keeps sweeping: 1 + 1/2 + 1/4 + ... = 2 passes.
for (int i = 0; i < 40; ++i) f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 2.0f, 1e-3);
// A factor of 0 is "only the newest acquisition is on screen": the grid is the last pass.
cfg.per_acquisition = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), width, 1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.0f, 1e-3);
std::printf("[density] per-acquisition ink: 1=%.0f 2=%.0f 3=%.0f peak_steady=%.3f\n", one, two,
three, 2.0);
}
/// The time constant is the other question: the image fades while you watch, by the frame's own
/// `dt` against a constant stated in seconds, and it is the *caller* who advances it -- there is
/// no clock in the library, which is what makes the reading reproducible.
PLOT_TEST(a_density_fades_by_its_time_constant) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
cfg.decay = plot::DensityDecay::TimeConstant;
cfg.time_constant_s = 2.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double full = f.plot.density_grid().ink();
// A frame that has not been measured yet (dt <= 0) does not wipe the grid, and neither does
// a frame under `PerAcquisition`, which fades in `accumulate_density()` instead.
f.plot.decay_density(0.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
f.plot.decay_density(-1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
// Quarter of a time constant: exp(-0.25).
f.plot.decay_density(0.5);
const double quarter = full * std::exp(-0.25);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), quarter, quarter * 1e-4);
// Ten more constants and the display is gone to within a rounding: 4.5e-5 of the ink.
for (int i = 0; i < 10; ++i) f.plot.decay_density(2.0);
PLOT_CHECK(f.plot.density_grid().ink() < quarter * 1e-3);
PLOT_CHECK(f.plot.density_grid().peak() < 1e-3f);
// A non-positive constant freezes the grid rather than fading it.
cfg.time_constant_s = 0.0f;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const double frozen = f.plot.density_grid().ink();
f.plot.decay_density(100.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), frozen, 1e-6);
// And `PerAcquisition` fades in `accumulate_density()` alone: a frame that only passed
// changes nothing.
cfg.decay = plot::DensityDecay::PerAcquisition;
f.plot.set_density(cfg);
const double before = f.plot.density_grid().ink();
f.plot.decay_density(1.0);
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), before, 1e-6);
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
frozen);
}
/// Switching off drops the image at once -- not at the next acquisition, which a trigger that
/// has stopped firing never sends -- and turning it back on starts from an empty grid.
PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
DensityFixture f;
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
f.plot.clear_density();
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
PLOT_CHECK(f.plot.density_grid().peak() == 0.0f);
// The clear keeps the allocation -- the grid is still the screen's -- so the next
// accumulation does not have to reallocate it.
PLOT_CHECK(f.plot.density_grid().cell_count() > 0u);
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
cfg.enabled = false;
f.plot.set_density(cfg);
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
PLOT_CHECK(f.plot.density_grid().ink() == 0.0);
// ... and an accumulation while it is off deposits nothing (it is not a hidden build-up).
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid().cell_count() == 0u);
}
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
/// leaves no ink rather than a band along the edge it left by, and a NaN sample is a gap that
/// joins nothing.
PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
// y = 5 against a [-1, 1] axis: the segment's whole column span is above the axes.
DensityFixture off(std::vector<f32>{5.0f, 5.0f});
plot::DensityConfig cfg;
cfg.enabled = true;
off.plot.set_density(cfg);
off.plot.accumulate_density();
PLOT_CHECK_NEAR(off.plot.density_grid().ink(), 0.0, 1e-9);
// ... and the same series inside the axis deposits one cell per column, in two rows: the
// first half at y = -1 and the second at y = +1, joined by a diagonal.
DensityFixture split(std::vector<f32>{-1.0f, 1.0f});
split.plot.set_density(cfg);
split.plot.accumulate_density();
const plot::DensityGrid& grid = split.plot.density_grid();
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes;
// The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto*
// the bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(ty.to_screen_y(y) - axes.y)), 0,
static_cast<int>(grid.rows()) - 1));
};
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
PLOT_CHECK(grid.at(grid.columns() - 1u, row_of(1.0)) > 0.0f);
// Inverted on screen: y = +1 is the *lower* row index, which is the one y flip of the mapping.
PLOT_CHECK(row_of(1.0) < row_of(-1.0));
// A NaN in the middle is a gap: it joins nothing, so the trace is deposited as two runs and
// the columns where the gap is stay empty -- where the same series with the sample filled in
// lights every one of them. Five samples at x = 0, 0.25, 0.5, 0.75, 1.0.
const auto column_ink = [](const plot::DensityGrid& g, plot::u32 column) {
double sum = 0.0;
for (plot::u32 r = 0; r < g.rows(); ++r) sum += static_cast<double>(g.at(column, r));
return sum;
};
DensityFixture whole(std::vector<f32>{0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, 0.25);
whole.plot.set_density(cfg);
whole.plot.accumulate_density();
DensityFixture gap(
std::vector<f32>{0.0f, 0.0f, std::numeric_limits<f32>::quiet_NaN(), 0.0f, 0.0f}, 0.25);
gap.plot.set_density(cfg);
gap.plot.accumulate_density();
const plot::DensityGrid& whole_grid = whole.plot.density_grid();
const plot::DensityGrid& gap_grid = gap.plot.density_grid();
const plot::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes;
const plot::u32 column = static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(tx.to_screen_x(0.5) - gap_axes.x)), 0,
static_cast<int>(gap_grid.columns()) - 1));
PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit
PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
PLOT_CHECK(gap_grid.ink() < whole_grid.ink());
std::printf("[density] off-range ink=%.0f split ink=%.0f gap ink=%.0f whole ink=%.0f\n",
off.plot.density_grid().ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
}
+136
View File
@@ -7519,3 +7519,139 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
PLOT_CHECK(outside_after == outside_before);
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();
}