feat: a phosphor density is the trace, one grid per trace in its own colour
A user looked at the display and reported three things at once: "there is clearly a gain offset between latest acquisition and persistent image", "persistent instead of being brighter + opaque is just gray/white", and "last acquisition does not seem to be affected, it looks like persistent image + last acquisition (so for example last acquisition never fade in time mode)". What they add up to is that this was not a phosphor yet: it was a **density with a solid trace drawn over it**. Four changes, and the first is the one the others follow from: - **The current acquisition is deposited like every other, and no solid trace is drawn over the image.** `Renderer::draw_traces()` returns at once while a density is on: the trace ink *is* the image, so the newest acquisition is a sweep inside it and fades with it. Drawing the polyline as well put a solid, unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition", where the last acquisition never faded in time mode. - **One grid per trace, in that trace's own colour.** `Plot::density_grid(trace)` is one screen-sized `f32` grid per trace, deposited from that trace and drawn in `Trace::color`, because a density that had one hue for the plot could not say *which* trace put the ink there -- two signals agreeing in a cell would be one blended pixel. The display's memory is therefore `traces * columns * rows * 4` bytes rather than one plot's worth (a trace with no series, an unbound slot, holds no grid), which is the one cost this change adds: four signals in one 800x480 row are four grids, 6 MB. - **The ceiling is the trace's own brightness, not the fade's steady state.** `full_scale` now defaults to **one sweep**: a cell with one acquisition's ink is drawn in its trace's colour at that colour's own alpha, and a signal crossing the same place every acquisition settles *at* the colour its trace would have drawn (the ceiling clamps) rather than a fraction of a brighter steady state -- the gain offset the user reported. `Style::density`'s near-white 0.85 tint is gone with it, because there is no one tint any more. A caller that wants the fade's ramp on screen instead still sets `full_scale` to the sum the fade settles at and accepts the offset; that is a legitimate choice, just not the one a phosphor makes. The reading is a **framebuffer** one, because neither `ink`/`peak` nor a tint can make a colour or a fade: `a_phosphor_density_is_the_trace_in_its_own_colour` deposits one sweep, checks the saturated cell is *exactly* the trace's colour (`full=224`, the trace's `0xE0` green, against `bg=25` -- the old monochrome tint read ~190 red), then fades it one time constant and checks the same pixel is dimmer (`faded=115`) and still the trace's hue -- which a stroke over the density could not do. It moves the trace and draws without depositing to prove no polyline is drawn (`moved` equals the no-density frame at the moved row), and reads a steady state against a quarter of it (`steady=224`, `quarter=138`), in the signal's colour throughout. `a_density_holds_one_grid_per_trace` reads two traces' grids apart (`267 472 cells each, ink 584/584`, in two rows). The grid's own cases are unchanged where they should be (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` `peak_steady=2.000`, `a_density_fades_by_its_time_constant` `exp(-dt/tau)`). Suite: **397 cases, 0 failed**, 4/4 ctest entries.
This commit is contained in:
1 parent
738205293e
commit
1b8b55d3ee
12 files changed
+534
-299
No files matched your search
+93
-39
@@ -2852,9 +2852,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
|
||||
// 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_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).peak() == 0.0f);
|
||||
|
||||
plot::DensityConfig cfg;
|
||||
cfg.enabled = true;
|
||||
@@ -2862,7 +2862,7 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
|
||||
PLOT_CHECK(f.plot.density().enabled);
|
||||
f.plot.accumulate_density();
|
||||
|
||||
const plot::DensityGrid& grid = f.plot.density_grid();
|
||||
const plot::DensityGrid& grid = f.plot.density_grid(0u);
|
||||
PLOT_CHECK(grid.columns() == columns);
|
||||
PLOT_CHECK(grid.rows() == rows);
|
||||
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
|
||||
@@ -2895,9 +2895,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
|
||||
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);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).columns() < columns);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(),
|
||||
static_cast<double>(f.plot.density_grid(0u).columns()), 1.0);
|
||||
}
|
||||
|
||||
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is
|
||||
@@ -2915,30 +2915,30 @@ PLOT_TEST(a_density_halves_per_acquisition_and_keeps_the_newest_brightest) {
|
||||
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();
|
||||
const double width = static_cast<double>(f.plot.density_grid(0u).columns());
|
||||
const double one = f.plot.density_grid(0u).ink();
|
||||
PLOT_CHECK_NEAR(one, width, 1.0);
|
||||
|
||||
f.plot.accumulate_density();
|
||||
const double two = f.plot.density_grid().ink();
|
||||
const double two = f.plot.density_grid(0u).ink();
|
||||
PLOT_CHECK_NEAR(two, width * 1.5, 2.0);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.5f, 1e-3);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).peak(), 1.5f, 1e-3);
|
||||
|
||||
f.plot.accumulate_density();
|
||||
const double three = f.plot.density_grid().ink();
|
||||
const double three = f.plot.density_grid(0u).ink();
|
||||
PLOT_CHECK_NEAR(three, width * 1.75, 2.0);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid().peak(), 1.75f, 1e-3);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), width, 1.0);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).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);
|
||||
@@ -2955,40 +2955,40 @@ PLOT_TEST(a_density_fades_by_its_time_constant) {
|
||||
cfg.time_constant_s = 2.0f;
|
||||
f.plot.set_density(cfg);
|
||||
f.plot.accumulate_density();
|
||||
const double full = f.plot.density_grid().ink();
|
||||
const double full = f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), full, 1e-6);
|
||||
f.plot.decay_density(-1.0);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), full, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).ink() < quarter * 1e-3);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).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();
|
||||
const double frozen = f.plot.density_grid(0u).ink();
|
||||
f.plot.decay_density(100.0);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), frozen, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).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();
|
||||
const double before = f.plot.density_grid(0u).ink();
|
||||
f.plot.decay_density(1.0);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid().ink(), before, 1e-6);
|
||||
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), before, 1e-6);
|
||||
|
||||
std::printf("[density] time-constant ink: full=%.0f quarter=%.1f frozen=%.0f\n", full, quarter,
|
||||
frozen);
|
||||
@@ -3002,26 +3002,26 @@ PLOT_TEST(switching_a_density_off_drops_the_image_at_once) {
|
||||
cfg.enabled = true;
|
||||
f.plot.set_density(cfg);
|
||||
f.plot.accumulate_density();
|
||||
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).ink() == 0.0);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).cell_count() > 0u);
|
||||
|
||||
f.plot.accumulate_density();
|
||||
PLOT_CHECK(f.plot.density_grid().ink() > 0.0);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).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);
|
||||
PLOT_CHECK(f.plot.density_grid(0u).cell_count() == 0u);
|
||||
}
|
||||
|
||||
/// What is deposited is where the *trace* is, and nowhere else: a trace off the visible y range
|
||||
@@ -3034,14 +3034,14 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
|
||||
cfg.enabled = true;
|
||||
off.plot.set_density(cfg);
|
||||
off.plot.accumulate_density();
|
||||
PLOT_CHECK_NEAR(off.plot.density_grid().ink(), 0.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(off.plot.density_grid(0u).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();
|
||||
const plot::DensityGrid& grid = split.plot.density_grid(0u);
|
||||
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;
|
||||
@@ -3073,8 +3073,8 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
|
||||
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::DensityGrid& whole_grid = whole.plot.density_grid(0u);
|
||||
const plot::DensityGrid& gap_grid = gap.plot.density_grid(0u);
|
||||
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(
|
||||
@@ -3085,5 +3085,59 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
|
||||
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());
|
||||
off.plot.density_grid(0u).ink(), grid.ink(), gap_grid.ink(), whole_grid.ink());
|
||||
}
|
||||
|
||||
/// **One grid per trace.** A plot of two signals holds two images, each deposited from its own
|
||||
/// trace and drawn in its own colour (the colour is the renderer's, and here the *count* is what is
|
||||
/// read): the two traces at two levels leave ink in two different rows, so one grid holding one
|
||||
/// trace's ink and not the other's is the reading that says the images are not shared.
|
||||
PLOT_TEST(a_density_holds_one_grid_per_trace) {
|
||||
plot::data::ArraySeries lo(std::vector<plot::f32>{-0.5f, -0.5f}, 0.0, 1.0);
|
||||
plot::data::ArraySeries hi(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, 640.0f, 480.0f});
|
||||
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});
|
||||
PLOT_CHECK(p.add_trace({&lo, plot::Color::hex(0x4FA3FF), 1.5f, ax, ay, "lo"}) == 0u);
|
||||
PLOT_CHECK(p.add_trace({&hi, plot::Color::hex(0xFF8A4F), 1.5f, ax, ay, "hi"}) == 1u);
|
||||
p.set_x_range(ax, {0.0, 1.0});
|
||||
p.set_y_range(ay, {-1.0, 1.0});
|
||||
p.layout();
|
||||
|
||||
plot::DensityConfig cfg;
|
||||
cfg.enabled = true;
|
||||
p.set_density(cfg);
|
||||
p.accumulate_density();
|
||||
|
||||
PLOT_CHECK(p.trace_count() == 2u);
|
||||
const plot::DensityGrid& g0 = p.density_grid(0u);
|
||||
const plot::DensityGrid& g1 = p.density_grid(1u);
|
||||
// Both are sized to the one axes rect, and each holds exactly its own trace's one pass: the ink
|
||||
// is the width in each grid, so neither picked up the other trace's ink (two traces would be two
|
||||
// widths in a shared grid).
|
||||
PLOT_CHECK(g0.cell_count() > 0u);
|
||||
PLOT_CHECK(g1.cell_count() == g0.cell_count());
|
||||
PLOT_CHECK_NEAR(g0.ink(), static_cast<double>(g0.columns()), 1.0);
|
||||
PLOT_CHECK_NEAR(g1.ink(), static_cast<double>(g1.columns()), 1.0);
|
||||
|
||||
const plot::Transform ty = p.y_transform(ay);
|
||||
const plot::Rect axes = p.layout().axes;
|
||||
const auto row_of = [&](plot::f64 y) {
|
||||
return static_cast<plot::u32>(std::floor(ty.to_screen_y(y) - axes.y));
|
||||
};
|
||||
const plot::u32 lo_row = row_of(-0.5);
|
||||
const plot::u32 hi_row = row_of(0.5);
|
||||
PLOT_CHECK(lo_row != hi_row);
|
||||
PLOT_CHECK(g0.at(0u, lo_row) > 0.0f);
|
||||
PLOT_CHECK(g1.at(0u, hi_row) > 0.0f);
|
||||
PLOT_CHECK(g0.at(0u, hi_row) == 0.0f); // no trace but `lo` is in the lower grid
|
||||
PLOT_CHECK(g1.at(0u, lo_row) == 0.0f);
|
||||
|
||||
// An out-of-range trace reads an empty image rather than anything borrowed.
|
||||
PLOT_CHECK(p.density_grid(2u).cell_count() == 0u);
|
||||
PLOT_CHECK(p.density_grid(9u).ink() == 0.0);
|
||||
|
||||
std::printf("[density] per-trace grids: cells=%zu each ink=%.0f/%.0f rows lo=%u hi=%u\n",
|
||||
g0.cell_count(), g0.ink(), g1.ink(), lo_row, hi_row);
|
||||
}
|
||||
+121
-89
@@ -7520,17 +7520,17 @@ PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
|
||||
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 phosphor density **is the trace**, in the trace's own colour: it covers the axes rect in
|
||||
/// `Trace::color`, the newest acquisition is deposited into it like every other -- so it fades, and
|
||||
/// no solid polyline is drawn over the ink -- and it costs an upload on the frame a 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) {
|
||||
/// Three things are read off the *screen*, because neither the grid's counts nor a tint alone can
|
||||
/// make them: that a saturated cell is the trace's own colour and not a monochrome wash; that a
|
||||
/// cell a quarter of the way up the scale is dimmer *and the same hue*; and that the acquisition
|
||||
/// just deposited fades -- which is only true if it is *inside* the image and not a stroke drawn
|
||||
/// on top of its own faint ghost (the "persistent image + last acquisition" report).
|
||||
PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
@@ -7557,14 +7557,25 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
|
||||
PLOT_CHECK(r.stats().density_upload_bytes == 0u);
|
||||
const std::vector<plot::u8> without = read_frame(ctx);
|
||||
|
||||
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
||||
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
|
||||
};
|
||||
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.0));
|
||||
// A row well away from the sweep, and away from the trace in the no-density frame: the
|
||||
// background to read every cell against, rather than a guessed one.
|
||||
const plot::u32 blank_row = trace_row + 40u < kH ? trace_row + 40u : trace_row - 40u;
|
||||
|
||||
// **The newest acquisition is in the image, and it fades.** A time fade from the first deposit:
|
||||
// one time constant must dim the same pixel, which a stroke drawn over the density could not do.
|
||||
plot::DensityConfig cfg;
|
||||
cfg.enabled = true;
|
||||
cfg.decay = plot::DensityDecay::PerAcquisition;
|
||||
cfg.per_acquisition = 0.5f;
|
||||
cfg.decay = plot::DensityDecay::TimeConstant;
|
||||
cfg.time_constant_s = 1.0f; // the ceiling stays the library's one sweep -- the trace's own
|
||||
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
|
||||
p.accumulate_density(); // the acquisition on screen, deposited like any other
|
||||
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
@@ -7572,7 +7583,7 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
|
||||
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);
|
||||
const std::vector<plot::u8> fresh = 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.
|
||||
@@ -7581,8 +7592,8 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
|
||||
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]) {
|
||||
if (fresh[at] == without[at] && fresh[at + 1u] == without[at + 1u] &&
|
||||
fresh[at + 2u] == without[at + 2u]) {
|
||||
continue;
|
||||
}
|
||||
++differing;
|
||||
@@ -7598,81 +7609,101 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
|
||||
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();
|
||||
// One trace, so one grid and one upload of one byte a cell -- and the *same* grid the plot
|
||||
// reports, so the picture and the numbers are one derivation.
|
||||
const plot::DensityGrid& grid = p.density_grid(0u);
|
||||
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));
|
||||
// **The colour**: the cell the sweep is in is the trace's own colour at full strength -- one
|
||||
// sweep is the ceiling, so a saturated cell *is* the trace -- and not the near-white monochrome
|
||||
// the density used to draw in. The trace has no red and plenty of green and blue; a monochrome
|
||||
// wash at full ink had all three high and near equal.
|
||||
const int bg_g = channel(fresh, mid_x, blank_row, 1);
|
||||
const int cell_r = channel(fresh, mid_x, trace_row, 0);
|
||||
const int cell_g = channel(fresh, mid_x, trace_row, 1);
|
||||
const int cell_b = channel(fresh, mid_x, trace_row, 2);
|
||||
PLOT_CHECK(cell_r < 40); // the trace's colour has no red in it
|
||||
PLOT_CHECK(cell_g > 150); // ... and its green is the ink
|
||||
PLOT_CHECK(cell_g > cell_b && cell_b > cell_r); // the hue is the trace's, not a neutral
|
||||
PLOT_CHECK(cell_g > bg_g + 40);
|
||||
// A saturated cell **is** the trace's colour: one sweep at the ceiling draws the trace's own
|
||||
// rgba, so the pixel matches it exactly rather than merely resembling it.
|
||||
const plot::u8* cell_px = fresh.data() + (plot::usize(trace_row) * kW + mid_x) * 4u;
|
||||
PLOT_CHECK(pixel_is(cell_px, trace_color));
|
||||
|
||||
// **The screen half**: a cell at a quarter of what a full one holds must render measurably
|
||||
// fainter, and a full cell must not be the background. That is the assertion the *eye* makes --
|
||||
// it is what "the colour fading is not applied, it looks white" turned out to be -- and nothing
|
||||
// in the grid's own numbers can make it: they were right while the picture was flat.
|
||||
//
|
||||
// The row measured is the one the *ghost* is in (the sweep before the last), not the trace's own:
|
||||
// a trace drawn over a cell hides the density in it by construction.
|
||||
const auto channel = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
||||
return static_cast<int>(f[(plot::usize(y) * kW + x) * 4u + static_cast<plot::usize>(c)]);
|
||||
};
|
||||
const int ghost_full = channel(with, mid_x, ghost_row, 0);
|
||||
// The background to compare against is a row *away* from both: the ghost's own row holds the
|
||||
// trace in the frame drawn without a density (that is where the trace was before it moved), so
|
||||
// the difference between the two frames there is the trace, not the ink.
|
||||
const plot::u32 blank_row = ghost_row + 40u < kH ? ghost_row + 40u : ghost_row - 40u;
|
||||
const int empty_red = channel(without, mid_x, blank_row, 0);
|
||||
// A quarter of the ink: `exp(-dt/tau)` is the time fade's own rule, so `dt = tau*ln(4)` is one
|
||||
// step to a quarter whatever one sweep deposited.
|
||||
plot::DensityConfig fading = cfg;
|
||||
fading.decay = plot::DensityDecay::TimeConstant;
|
||||
fading.time_constant_s = 1.0f;
|
||||
p.set_density(fading);
|
||||
const double before = p.density_grid().ink();
|
||||
p.decay_density(std::log(4.0));
|
||||
const double after = p.density_grid().ink();
|
||||
PLOT_CHECK_NEAR(after / before, 0.25, 1e-6);
|
||||
// One time constant of the same pixel: dimmer, and still the trace's hue. This is the assertion
|
||||
// that the current acquisition is inside the density -- the fade `exp(-dt/tau)`.
|
||||
const double before = grid.ink();
|
||||
p.decay_density(1.0); // dt = tau, so the whole image is e^-1 of what it was
|
||||
const double after = grid.ink();
|
||||
PLOT_CHECK_NEAR(after / before, std::exp(-1.0), 1e-4);
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> faded = read_frame(ctx);
|
||||
const int ghost_quarter = channel(faded, mid_x, ghost_row, 0);
|
||||
PLOT_CHECK(ghost_full > empty_red + 20);
|
||||
PLOT_CHECK(ghost_full > ghost_quarter + 20);
|
||||
PLOT_CHECK(ghost_quarter > empty_red);
|
||||
const int faded_g = channel(faded, mid_x, trace_row, 1);
|
||||
const int faded_r = channel(faded, mid_x, trace_row, 0);
|
||||
PLOT_CHECK(faded_g < cell_g - 20); // the newest acquisition faded
|
||||
PLOT_CHECK(faded_r < 40); // and it faded *as the trace's colour*, not toward a neutral
|
||||
PLOT_CHECK(faded_g > bg_g); // ... to more than the background: still on screen
|
||||
|
||||
// **No solid trace over the image.** Move the series to y = +0.5 and draw *without* depositing:
|
||||
// the plot draws no polyline while the density is on, so the +0.5 row is background and the only
|
||||
// ink on screen is the sweep already in the image. A trace drawn over its own ghost -- the
|
||||
// reported "persistent image + last acquisition" -- would put its colour at +0.5 here.
|
||||
s.reset(std::vector<plot::f32>{0.5f, 0.5f}, 0.0, 1.0);
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> moved = read_frame(ctx);
|
||||
const plot::u32 moved_row = static_cast<plot::u32>(ty.to_screen_y(0.5));
|
||||
PLOT_CHECK(moved_row != trace_row);
|
||||
// At +0.5 there is only the grid: the frame with the density is *identical* to the frame without
|
||||
// one there, so nothing was drawn at the moved trace's own row -- no stroke over the image. (The
|
||||
// row the sweep is in still holds its ink, so the image itself did not go away.)
|
||||
PLOT_CHECK(channel(moved, mid_x, moved_row, 1) == channel(without, mid_x, moved_row, 1));
|
||||
PLOT_CHECK(channel(moved, mid_x, moved_row, 0) == channel(without, mid_x, moved_row, 0));
|
||||
PLOT_CHECK(channel(moved, mid_x, trace_row, 1) > bg_g + 20); // the image is still there
|
||||
s.reset(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
||||
|
||||
// **The steady state and a quarter of it, in the signal's colour.** Many acquisitions of a
|
||||
// per-acquisition fade settle at `1/(1-f)` sweeps, which the one-sweep ceiling clamps to the
|
||||
// trace's colour; a decay to a quarter of that ink is dimmer and keeps the hue.
|
||||
plot::DensityConfig steady_cfg = cfg;
|
||||
steady_cfg.decay = plot::DensityDecay::PerAcquisition;
|
||||
steady_cfg.per_acquisition = 0.5f;
|
||||
p.set_density(steady_cfg);
|
||||
for (int i = 0; i < 24; ++i) p.accumulate_density();
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> steady = read_frame(ctx);
|
||||
const int steady_g = channel(steady, mid_x, trace_row, 1);
|
||||
const int steady_r = channel(steady, mid_x, trace_row, 0);
|
||||
PLOT_CHECK(steady_g > bg_g + 60);
|
||||
PLOT_CHECK(steady_r < 40);
|
||||
// A quarter of the ink: `dt = tau*ln(4)` is one step of the time fade to a quarter.
|
||||
plot::DensityConfig quarter_cfg = steady_cfg;
|
||||
quarter_cfg.decay = plot::DensityDecay::TimeConstant;
|
||||
quarter_cfg.time_constant_s = 1.0f;
|
||||
p.set_density(quarter_cfg);
|
||||
const double steady_ink = grid.ink();
|
||||
p.decay_density(std::log(4.0));
|
||||
PLOT_CHECK_NEAR(grid.ink() / steady_ink, 0.25, 1e-6);
|
||||
r.begin_frame(kW, kH, kFrameRect);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> quarter = read_frame(ctx);
|
||||
const int quarter_g = channel(quarter, mid_x, trace_row, 1);
|
||||
const int quarter_r = channel(quarter, mid_x, trace_row, 0);
|
||||
PLOT_CHECK(steady_g > quarter_g + 20);
|
||||
PLOT_CHECK(quarter_g > bg_g + 10);
|
||||
PLOT_CHECK(quarter_r < 40); // still the signal's colour, not a neutral wash
|
||||
|
||||
// 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.
|
||||
@@ -7683,13 +7714,14 @@ PLOT_TEST(a_phosphor_density_is_drawn_under_the_traces) {
|
||||
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 == faded);
|
||||
PLOT_CHECK(again == quarter);
|
||||
|
||||
std::printf("[density gl] cells=%u uploaded=%u differing=%u ghost row=%u trace row=%u"
|
||||
std::printf("[density gl] cells=%u uploaded=%u differing=%u trace row=%u blank row=%u"
|
||||
" axes=%.0f,%.0f %.0fx%.0f\n",
|
||||
first.density_cells, first.density_upload_bytes, differing, ghost_row, trace_row,
|
||||
first.density_cells, first.density_upload_bytes, differing, trace_row, blank_row,
|
||||
axes.x, axes.y, axes.w, axes.h);
|
||||
std::printf("[density gl] luminance red: empty=%d full=%d quarter=%d (ink %.0f -> %.0f)\n",
|
||||
empty_red, ghost_full, ghost_quarter, before, after);
|
||||
std::printf("[density gl] green: bg=%d full=%d faded=%d steady=%d quarter=%d"
|
||||
" (fresh ink %.0f -> %.0f)\n",
|
||||
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
|
||||
ctx.destroy();
|
||||
}
|
||||
Reference in new issue
Block a user