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
@@ -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