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
+94
-37
@@ -355,13 +355,26 @@ void Plot::set_density(const DensityConfig& config) {
|
||||
// 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_;
|
||||
for (DensityGrid& grid : density_grids_) {
|
||||
grid.cells_.clear();
|
||||
grid.cells_.shrink_to_fit();
|
||||
grid.columns_ = 0u;
|
||||
grid.rows_ = 0u;
|
||||
++grid.revision_;
|
||||
}
|
||||
density_grids_.clear();
|
||||
density_grids_.shrink_to_fit();
|
||||
}
|
||||
// The two fields the renderer turns a cell's count into a luminance with: a change to either is
|
||||
// a change to every cell's *byte*, and a grid's own revision does not see it. A host that states
|
||||
// its configuration every frame (udpscope does) must not force a re-upload per frame, so this
|
||||
// bumps only when one of the two actually moved -- and then a re-upload is owed.
|
||||
const bool rescale = density_.enabled && config.enabled &&
|
||||
(density_.full_scale != config.full_scale || density_.gamma != config.gamma);
|
||||
density_ = config;
|
||||
if (rescale) {
|
||||
for (DensityGrid& grid : density_grids_) ++grid.revision_;
|
||||
}
|
||||
}
|
||||
|
||||
void Plot::decay_density(f64 dt_s) {
|
||||
@@ -373,56 +386,94 @@ void Plot::decay_density(f64 dt_s) {
|
||||
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_;
|
||||
for (DensityGrid& grid : density_grids_) {
|
||||
if (grid.cells_.empty()) continue;
|
||||
for (f32& cell : grid.cells_) cell *= factor;
|
||||
++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_;
|
||||
for (DensityGrid& grid : density_grids_) {
|
||||
if (grid.cells_.empty()) continue;
|
||||
std::fill(grid.cells_.begin(), grid.cells_.end(), 0.0f);
|
||||
++grid.revision_;
|
||||
}
|
||||
}
|
||||
|
||||
bool Plot::size_density_grid() {
|
||||
bool Plot::size_density_grids() {
|
||||
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_;
|
||||
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it
|
||||
// down (see `DensityConfig`). The vector tracks the trace list, so a trace added after the first
|
||||
// accumulation gets its own empty grid here while the grids already sized keep what they held.
|
||||
if (density_grids_.size() != traces_.size()) density_grids_.resize(traces_.size());
|
||||
// The per-sweep dedupe's stamps are one per grid **row** and shared by every trace's deposit:
|
||||
// the key a cell is deduped against carries the trace's number (see
|
||||
// `deposit_density_segment()`), so a stamp left by one trace's sweep never matches another's, and
|
||||
// a zeroed stamp -- every key has a non-zero trace number in its high half -- matches nothing.
|
||||
if (density_stamp_.size() != rows) density_stamp_.assign(rows, 0u);
|
||||
bool usable = false;
|
||||
for (usize i = 0u; i < traces_.size(); ++i) {
|
||||
DensityGrid& grid = density_grids_[i];
|
||||
if (!traces_[i].visible()) {
|
||||
// A trace with nothing to draw gets no image: the grid is per *trace*, and a slot with no
|
||||
// series is not one. Dropping it here is also what keeps the display's memory proportional
|
||||
// to the traces actually on screen rather than to a plot's fixed trace slots -- a row of four
|
||||
// signals out of eight slots holds four grids, not eight.
|
||||
if (!grid.cells_.empty()) {
|
||||
grid.cells_.clear();
|
||||
grid.cells_.shrink_to_fit();
|
||||
grid.columns_ = 0u;
|
||||
grid.rows_ = 0u;
|
||||
++grid.revision_;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (columns != grid.columns_ || rows != 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.
|
||||
grid.cells_.assign(static_cast<usize>(columns) * rows, 0.0f);
|
||||
grid.columns_ = columns;
|
||||
grid.rows_ = rows;
|
||||
++grid.revision_;
|
||||
}
|
||||
usable = usable || !grid.cells_.empty();
|
||||
}
|
||||
return !density_grid_.cells_.empty();
|
||||
return usable;
|
||||
}
|
||||
|
||||
void Plot::accumulate_density() {
|
||||
if (!density_.enabled) return;
|
||||
if (!size_density_grid()) return;
|
||||
if (!size_density_grids()) 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 (DensityGrid& grid : density_grids_) {
|
||||
if (grid.cells_.empty()) continue;
|
||||
for (f32& cell : grid.cells_) cell *= factor;
|
||||
++grid.revision_;
|
||||
}
|
||||
}
|
||||
for (const Trace& trace : traces_) deposit_density_trace(trace);
|
||||
++density_grid_.revision_;
|
||||
// **Every visible trace of the acquisition on screen, the newest included.** The current
|
||||
// acquisition is deposited here like any other, so it is inside the image and fades with it --
|
||||
// there is no second, solid trace drawn over the density (see `Renderer::draw_traces()`), which
|
||||
// is what a report of "the last acquisition never fades" turned out to be. One grid each, so two
|
||||
// traces that agree read as two colours at full strength where they overlap and not as one
|
||||
// blended one.
|
||||
const usize n = std::min(density_grids_.size(), traces_.size());
|
||||
for (usize i = 0u; i < n; ++i) deposit_density_trace(traces_[i], density_grids_[i]);
|
||||
}
|
||||
|
||||
void Plot::deposit_density_trace(const Trace& trace) {
|
||||
void Plot::deposit_density_trace(const Trace& trace, DensityGrid& grid) {
|
||||
if (!trace.visible()) return;
|
||||
const data::Series& series = *trace.series;
|
||||
const u64 n = series.size();
|
||||
@@ -470,7 +521,7 @@ void Plot::deposit_density_trace(const Trace& trace) {
|
||||
// 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);
|
||||
if (run == 1u) deposit_density_segment(px, py, px, py, grid);
|
||||
};
|
||||
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
|
||||
@@ -490,17 +541,18 @@ void Plot::deposit_density_trace(const Trace& trace) {
|
||||
run = 0u;
|
||||
continue;
|
||||
}
|
||||
if (run > 0u) deposit_density_segment(px, py, sx, sy);
|
||||
if (run > 0u) deposit_density_segment(px, py, sx, sy, grid);
|
||||
px = sx;
|
||||
py = sy;
|
||||
++run;
|
||||
}
|
||||
flush_run();
|
||||
++grid.revision_;
|
||||
}
|
||||
|
||||
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_;
|
||||
void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept {
|
||||
const u32 columns = grid.columns_;
|
||||
const u32 rows = 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;
|
||||
@@ -557,7 +609,7 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept {
|
||||
// *(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);
|
||||
f32* column_cells = 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
|
||||
@@ -567,6 +619,11 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept {
|
||||
}
|
||||
}
|
||||
|
||||
const DensityGrid& Plot::empty_density_grid() noexcept {
|
||||
static const DensityGrid empty{};
|
||||
return empty;
|
||||
}
|
||||
|
||||
f64 Plot::x_axis_origin(u32 axis) const {
|
||||
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||
}
|
||||
|
||||
+50
-11
@@ -208,6 +208,7 @@ void Renderer::release_gl() noexcept {
|
||||
density_pixels_.clear();
|
||||
density_w_ = 0;
|
||||
density_h_ = 0;
|
||||
density_uploaded_grid_ = nullptr;
|
||||
density_uploaded_revision_ = 0;
|
||||
lines_.clear();
|
||||
glyphs_under_.clear();
|
||||
@@ -528,7 +529,8 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
||||
// 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.
|
||||
// where the traces did -- and, while it is on, *instead* of them (`draw_traces()` returns) -- and
|
||||
// under them for the same reason they are under the data.
|
||||
draw_density(plot, layout);
|
||||
clear_clip();
|
||||
draw_axis_lines(plot);
|
||||
@@ -1368,6 +1370,14 @@ u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32
|
||||
}
|
||||
|
||||
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||
// **No solid trace while the density is on.** The trace ink *is* the density image (see
|
||||
// `Plot::set_density()` and `draw_density()`): every visible trace was deposited, the newest
|
||||
// acquisition included, so drawing the polyline as well would put a solid, unfading stroke over
|
||||
// its own faint image -- the "persistent image + last acquisition" a report described, where the
|
||||
// newest acquisition never faded because it was never only the image. The traces still exist --
|
||||
// the legend, the crosshair readout and the measurements read them -- they are simply not drawn
|
||||
// as lines.
|
||||
if (plot.density().enabled) return;
|
||||
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
||||
// would flatten every trace onto a single row -- a frame that looks like a
|
||||
// rendering choice rather than the collapsed plot it is.
|
||||
@@ -1753,17 +1763,43 @@ 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 (!config.enabled) {
|
||||
// A plot that has stopped drawing a density is not holding a stale one: forget what the shared
|
||||
// texture holds, so a re-enable -- which drops the grids and rebuilds them, possibly at the same
|
||||
// addresses and with fresh revisions -- re-uploads rather than trusting a cache entry that
|
||||
// happens to match.
|
||||
density_uploaded_grid_ = nullptr;
|
||||
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
|
||||
// One quad per trace, in that trace's own colour: the plot holds one grid per trace (see
|
||||
// `DensityConfig`), so where two traces agree the viewer sees both colours, and a saturated cell
|
||||
// is the colour of the trace whose ink filled it and not a blend. A trace with no visible series
|
||||
// was deposited into nothing (its grid is empty), so it is skipped.
|
||||
for (u32 i = 0u; i < plot.trace_count(); ++i) {
|
||||
const Trace& trace = plot.trace(i);
|
||||
if (!trace.visible()) continue;
|
||||
draw_density_grid(config, plot.density_grid(i), trace.color, layout);
|
||||
}
|
||||
}
|
||||
|
||||
void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
|
||||
const PlotLayout& layout) {
|
||||
const u32 columns = grid.columns();
|
||||
const u32 rows = grid.rows();
|
||||
if (columns == 0u || rows == 0u) return;
|
||||
|
||||
// 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.
|
||||
//
|
||||
// **One texture, re-filled per trace.** The cache is keyed by the grid's own address as well as
|
||||
// its revision, so a plot drawn twice with nothing changed (the steady state of a display that
|
||||
// has stopped being swept) skips the conversion *and* the upload -- while a row of several traces
|
||||
// re-fills the one texture once per trace, which is what a figure of several rows already cost
|
||||
// before the density was per trace.
|
||||
const bool resized = columns != density_w_ || rows != density_h_;
|
||||
if (resized) {
|
||||
if (!density_texture_.create_r8(columns, rows, nullptr)) {
|
||||
@@ -1776,9 +1812,9 @@ void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
|
||||
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
|
||||
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
|
||||
}
|
||||
if (resized || grid.revision() != density_uploaded_revision_) {
|
||||
if (resized || density_uploaded_grid_ != &grid || 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
|
||||
@@ -1804,9 +1840,10 @@ void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
|
||||
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_grid_ = &grid;
|
||||
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.
|
||||
// += and not =: a figure draws a density per trace per row, and what this field reports is the
|
||||
// frame's upload, not the last one's.
|
||||
stats_.density_upload_bytes += columns * rows;
|
||||
}
|
||||
|
||||
@@ -1817,11 +1854,13 @@ void Renderer::draw_density(const Plot& plot, const PlotLayout& layout) {
|
||||
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);
|
||||
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the
|
||||
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
|
||||
density_program_.set_color(tint);
|
||||
density_texture_.bind();
|
||||
density_vao_.bind();
|
||||
gl::glDrawArrays(gl::GL_TRIANGLE_STRIP, 0, 4);
|
||||
stats_.density_cells = columns * rows;
|
||||
stats_.density_cells += columns * rows;
|
||||
++stats_.draw_calls;
|
||||
}
|
||||
|
||||
|
||||
@@ -271,8 +271,9 @@ 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.
|
||||
// grid's numbers back could disagree with. All that is left is the tint: the density is drawn
|
||||
// once per trace, in that trace's own colour (the caller's `u_color`), so a saturated cell is
|
||||
// the trace at its own brightness and a faint one is the same hue at a lower alpha.
|
||||
float ink = texture(u_density, v_uv).r;
|
||||
o_color = vec4(u_color.rgb, u_color.a * ink);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user