`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.
307 lines
19 KiB
C++
307 lines
19 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
#include <plot/core/color.hpp>
|
|
|
|
namespace plot::render {
|
|
|
|
/// Every colour and metric the renderer draws with. Defaults are the dark theme
|
|
/// the plan's render smoke test asserts against; a caller swaps a field and the
|
|
/// next frame picks it up, so nothing here is baked into a shader.
|
|
///
|
|
/// ## Units: every metric below is **logical**, and `device()` converts it
|
|
///
|
|
/// A metric a user perceives as a *size* -- a font, a stroke width, a padding, a
|
|
/// marker, a swatch, the minimum spacing between ticks -- is written in logical
|
|
/// units, so a 12 px label is the same physical size on a 1x panel and a 2x one
|
|
/// and a 1 px grid is the same hairline on both. The host states what its display
|
|
/// is with `ui_scale` (device pixels per logical unit) and every consumer converts
|
|
/// exactly once, through `device()`: `Plot::layout()` when it sizes the label
|
|
/// strips, the title strips and the legend's chrome, the renderer when it converts
|
|
/// widths and font px.
|
|
///
|
|
/// The one exception is `dense_threshold_spp`, which is a **density** rather than a
|
|
/// size: samples per *rasterised* pixel. It is compared against a samples-per-pixel
|
|
/// ratio computed from the framebuffer's own width, so scaling it would make the
|
|
/// chosen LOD level depend on the display and not on the data. It is used raw.
|
|
///
|
|
/// The same rule classifies the metrics that live outside this struct, which are
|
|
/// logical too and converted by the same call:
|
|
///
|
|
/// * `Legend`'s pad, swatch, gap and margin;
|
|
/// * `Trace`'s (and `TraceDesc`'s) `width` -- a stroke like `line_width_px`, and
|
|
/// the one a plot draws most of, so leaving it raw would invert the
|
|
/// trace/axis stroke hierarchy at any scale above 1;
|
|
/// * the renderer's marker arm lengths and text offsets.
|
|
struct Style {
|
|
// --- colours: neither logical nor device, so nothing to scale ------------
|
|
|
|
Color background = Color::hex(0x101418);
|
|
/// The labelled grid. Brighter than the subdivisions and still well under `axis_line`
|
|
/// (0x8A94A6): the grid is structure the eye can read, the axis is the frame, and the
|
|
/// hierarchy between the three is what keeps a dense plot legible. It is already fully
|
|
/// opaque, so "more prominent" means a brighter colour and nothing else.
|
|
Color grid_major = Color::hex(0x39414C);
|
|
/// The minor grid's colour, **with an alpha** so it blends into the background rather than
|
|
/// sitting on it: a subdivision is a reading aid, and at full opacity a dense minor grid
|
|
/// reads as a second set of ticks competing with the labelled one. The line shader applies
|
|
/// `u_color.a` (see `src/render/shaders.cpp`), so this one blends and the major -- opaque at
|
|
/// 0x39414C -- stays the structure the eye picks out. Together with `grid_minor_width` and
|
|
/// the dash below, the defaults put a minor line at an order of magnitude less ink than a
|
|
/// major one.
|
|
Color grid_minor = Color::hex(0x1C2126, 0.5f);
|
|
Color axis_line = Color::hex(0x8A94A6);
|
|
Color axis_text = Color::hex(0xC8D0DC);
|
|
Color cursor = Color::hex(0xFFB020);
|
|
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
|
/// The box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
|
|
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
|
|
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
|
|
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
|
|
/// low enough that what is under the frame stays readable, because the point is to show what is
|
|
/// being selected, not to hide it.
|
|
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
|
|
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
|
|
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
|
|
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
|
|
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
|
|
/// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is
|
|
/// low enough that the grid under it stays legible, which is what makes the band read as a
|
|
/// region of the plot rather than as a panel laid over it.
|
|
Color invalid_fill = Color::hex(0x8A94A6, 0.18f);
|
|
/// The same shading for a span whose samples have since arrived and been written in, at well
|
|
/// under half the alpha. The gap is still reported -- the window was drawn without that data --
|
|
/// but a reader can tell a hole that is still empty from one that has been filled. Fainter by
|
|
/// alpha alone and not by hue, so the two read as two depths of one mark rather than as two
|
|
/// 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*.
|
|
///
|
|
/// **Hue-neutral by design**, and that is the whole choice: a saturated colour here would be
|
|
/// confusable with a trace, and the library's own default trace colour is a saturated cyan
|
|
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
|
|
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
|
|
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
|
|
/// the spec asks for.
|
|
///
|
|
/// The stroke is thin and dashed by default -- see `crosshair_width`, `crosshair_dash_on` and
|
|
/// `crosshair_dash_off` below for the values: thinner than a trace so the instrument reads as
|
|
/// chrome rather than data, and dashed so it is distinguishable by *form* as well as by
|
|
/// lightness.
|
|
Color crosshair = Color::hex(0xE6EBF2);
|
|
|
|
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
|
|
/// *passive* hover instrument drawn over the data, so its default **recedes**: a
|
|
/// thinner stroke than a trace's (`Trace::width`'s 1.5) so it reads as chrome rather
|
|
/// than as data, and a dash so it is distinguishable from the grid and the traces at
|
|
/// a glance.
|
|
///
|
|
/// `crosshair_dash_on` is the ink length and `crosshair_dash_off` the gap; **`off == 0`
|
|
/// means solid**, which is the value every *other* line in the library is drawn at, so
|
|
/// the one pipeline serves them unchanged (the dash is a fragment-shader discard on
|
|
/// `v_along`, so no geometry is added). A `Crosshair` may override all three for one
|
|
/// plot without touching this global style.
|
|
f32 crosshair_width = 0.75f;
|
|
f32 crosshair_dash_on = 4.0f;
|
|
f32 crosshair_dash_off = 4.0f;
|
|
/// The full extent, in logical pixels, of the little cross drawn at each trace's readout
|
|
/// point (`CrosshairRow::marker`), in that trace's own colour.
|
|
///
|
|
/// **Deliberately larger than `marker_px`**, which is what a trace's own samples are dotted
|
|
/// with at a deep zoom. In the default `CrosshairValue::NearestSample` the readout's mark is
|
|
/// *snapped to a sample*, so it lands among those dots -- at the same size it would read as
|
|
/// one more sample instead of as "this is the one the panel is quoting". The lines are chrome
|
|
/// and recede; this mark is the answer to the question the hover asks, and it is the one
|
|
/// crosshair element that should not.
|
|
///
|
|
/// Its stroke is `crosshair_marker_width`, thicker than the lines' for the same reason. `0`
|
|
/// across draws no mark at all, which is how a caller that wants the panel without the marks
|
|
/// says so.
|
|
f32 crosshair_marker_px = 9.0f;
|
|
f32 crosshair_marker_width = 1.5f;
|
|
|
|
// --- the scale ----------------------------------------------------------
|
|
|
|
/// Device pixels per logical unit. 1.0 is the 1x display every default here was
|
|
/// chosen on. Supplied by the host (`Renderer::style()` is what an adapter
|
|
/// writes), and handed to the plot by `Renderer::draw()` before it lays out, so
|
|
/// layout and drawing cannot disagree about it.
|
|
f32 ui_scale = 1.0f;
|
|
|
|
// --- logical metrics: a size a user perceives, scaled by `device()` ------
|
|
|
|
f32 axis_font_px = 12.0f; ///< logical em the axis text is drawn at.
|
|
f32 grid_label_pad = 4.0f; ///< logical gap between a tick label and the axes,
|
|
///< and the inset inside a title strip.
|
|
/// The two grid classes, each with its own width and its own switch. The grid's
|
|
/// *positions* come from the tick set alone -- `Grid::build()` tags every segment
|
|
/// with the class of the tick it came from -- so these four decide only how a
|
|
/// class is drawn. A class switched off stages no geometry at all: the renderer
|
|
/// skips it before the first segment is staged, so a dense plot's minor grid
|
|
/// costs nothing rather than being drawn transparent (spec §11). The colours
|
|
/// above are already per class.
|
|
/// When a trace's window holds at most this many raw samples, each one is also drawn as a dot
|
|
/// `marker_px` logical pixels across, in the trace's own colour. `0` draws none. Zoomed in far
|
|
/// enough to see individual samples -- a handful of them across a whole panel -- the polyline
|
|
/// *between* them stops being the shape, and the samples themselves are what a reader is looking
|
|
/// at; a marker makes each one a place rather than a bend in a line.
|
|
f32 marker_below_samples = 50.0f;
|
|
f32 marker_px = 3.0f;
|
|
f32 grid_major_width = 1.0f; ///< logical width of a major (labelled-tick) line.
|
|
/// Logical width of a minor (subdivision) line: **thinner than the major hairline**, and
|
|
/// sub-pixel on purpose. At 1x that is expressed as *coverage* -- the anti-aliased ramp
|
|
/// (`line_aa_px`) lights one column at roughly three quarters of the ink -- and on a HiDPI
|
|
/// display as a line that is literally narrower. The point is hierarchy: the labelled grid is
|
|
/// structure, the subdivisions are texture.
|
|
f32 grid_minor_width = 0.75f;
|
|
bool grid_major_visible = true; ///< stage the major grid at all.
|
|
bool grid_minor_visible = true; ///< stage the minor grid at all.
|
|
/// The minor grid's dash, in logical pixels: ink length and gap. **`off == 0` draws it
|
|
/// solid** -- the same convention as the crosshair's and a trace's dash -- which is what a
|
|
/// caller that wants the old solid subdivision states. Dashed is the default because an
|
|
/// unlabelled line at an unlabelled position should never be mistaken for a labelled one, and
|
|
/// because a broken line carries less weight over data than a continuous one. The dash is a
|
|
/// fragment discard, never extra geometry, so a dense minor grid costs no more vertices for
|
|
/// being dashed.
|
|
f32 grid_minor_dash_on = 3.0f;
|
|
f32 grid_minor_dash_off = 3.0f;
|
|
f32 line_width_px = 1.5f; ///< logical width of the strokes framing the data
|
|
///< (axis lines, cursor markers).
|
|
/// Logical half-width of the anti-aliased fringe the line shader blends at
|
|
/// every stroke edge (the smoothstep ramp runs over `|dist| in [w - aa,
|
|
/// w + aa]`). Clamped to at least 0.25 device px at use: below that the
|
|
/// ramp is thinner than a fragment and edges alias again. 1.0 is the value
|
|
/// every default was chosen on.
|
|
f32 line_aa_px = 1.0f;
|
|
/// Logical width of a *draggable handle* drawn highlighted: the tab a hovered or
|
|
/// grabbed cursor marker is widened to, and the tab a region's hot bound is drawn
|
|
/// as -- the latter sized by `Plot::layout()` with this same metric, so the two
|
|
/// kinds of handle are one width. Wider than `line_width_px` on purpose: the cue
|
|
/// for "this marker will follow your drag" has to be visible on a marker whose
|
|
/// own colour may already be the accent.
|
|
f32 handle_width_px = 5.0f;
|
|
f32 tick_min_px = 60.0f; ///< logical minimum spacing between major ticks, so
|
|
///< a 2x display gets the same tick *count*.
|
|
|
|
// --- device metric: a density, never scaled ------------------------------
|
|
|
|
f32 dense_threshold_spp = 1.5f; ///< spec §4.3: samples per **device** pixel at or
|
|
///< above which a trace is gathered from the LOD
|
|
///< pyramid instead of from its raw samples. A
|
|
///< density, not a size: it is deliberately not
|
|
///< scaled by `ui_scale`, or the chosen level
|
|
///< would move with the display's DPI. It selects
|
|
///< the gather level, never a primitive: both
|
|
///< sides draw through the one instanced
|
|
///< anti-aliased quad path.
|
|
|
|
// --- intensity grading: counts and ratios, never sizes -------------------
|
|
|
|
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
|
|
/// each height**, the way a digital-phosphor scope does, instead of as one flat
|
|
/// band per column: bright where samples pile up (a flat top, a peak), faint
|
|
/// where they only pass through (an edge, a noise excursion).
|
|
///
|
|
/// Off by default, because it is a change of *look* and not a correction: the
|
|
/// flat band is the honest answer to "what did this column reach", and a host
|
|
/// that wants that -- or that draws few enough samples never to take the column
|
|
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
|
|
/// host turns it on.
|
|
///
|
|
/// It is not derivable from the flat band. A column's min and max are the same
|
|
/// whether its samples fill the span or sit piled at its two ends, which is
|
|
/// exactly the difference between a ramp and a pulse -- so grading needs the
|
|
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
|
|
/// and reads the distribution from those sub-buckets.
|
|
///
|
|
/// The measure is *dwell per row*, and the weighting is the whole of it: each
|
|
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
|
|
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
|
|
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
|
|
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
|
|
/// is the pattern inverted.
|
|
///
|
|
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
|
|
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
|
|
/// against `graded_traces`) -- the same measure for a sparse window, where a
|
|
/// segment *is* the sub-column. One switch covers both because the alternative was
|
|
/// a defect: gated on the reduction, the model switched off as a window narrowed
|
|
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
|
|
/// all of it in one pixel of zoom.
|
|
bool intensity_grading = false;
|
|
|
|
/// Sub-buckets per pixel column the gather is asked for when grading: the level
|
|
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
|
|
/// density is read from this many envelopes instead of from one.
|
|
///
|
|
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
|
|
/// points -- and the points stay bounded by the screen, which is the property
|
|
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
|
|
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
|
|
/// samples apart to separate.
|
|
///
|
|
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
|
|
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
|
|
/// a column is graded from however many it does hold. Without that floor `level_for`
|
|
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
|
|
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
|
|
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
|
|
/// bucket size is the limit, not this number.
|
|
u32 intensity_sub_columns = 8;
|
|
|
|
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
|
|
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
|
|
/// per-instance one), and the segments are bounded by the columns, so the cost
|
|
/// of a step is a draw call over a few hundred instances.
|
|
///
|
|
/// Four is enough to read a waveform's structure and few enough that a step is a
|
|
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
|
|
/// from "passed through", not to measure.
|
|
u32 intensity_steps = 4;
|
|
|
|
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
|
|
///
|
|
/// Not 0: a column the signal merely passed through is still a column the signal
|
|
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
|
|
/// disconnected lines. This is the floor that keeps the trace continuous.
|
|
f32 intensity_floor = 0.3f;
|
|
|
|
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
|
|
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
|
|
/// whose dwell is three orders of magnitude greater.
|
|
///
|
|
/// The dwell is normalised **against the column's own peak**, not against a scale
|
|
/// for the whole frame, so the steps spend their range on the structure *inside*
|
|
/// each column. One scale for the frame needs the whole `w * h` density image
|
|
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
|
|
/// worse: a column the signal flew straight through holds little dwell at any
|
|
/// height, so it would grade uniformly faint -- a trace that fades out exactly
|
|
/// where it moves fastest.
|
|
f32 intensity_gamma = 0.45f;
|
|
|
|
/// `logical` in device pixels: the single conversion point for every logical
|
|
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
|
/// ui_scale` scattered over the renderer and the layout.
|
|
///
|
|
/// A scale that is not positive is not a scale -- a host that has not measured
|
|
/// its display, a field nobody filled in -- and converts 1:1 rather than
|
|
/// collapsing every strip, font and stroke to nothing.
|
|
f32 device(f32 logical) const noexcept {
|
|
return ui_scale > 0.0f ? logical * ui_scale : logical;
|
|
}
|
|
};
|
|
|
|
} // namespace plot::render
|