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

+1
View File
@@ -54,6 +54,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/figure.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
+139
View File
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <span>
#include <vector>
#include <plot/core/types.hpp>
namespace plot {
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
///
/// The two are answers to two different questions, which is why the choice belongs to the
/// caller rather than to a default: a bench scope's persistence is set by *time* -- "the trace
/// is still visible a second after it was drawn" -- while a sweep-to-sweep accumulation is set
/// by *count* -- "the tenth acquisition back is gone". The two agree only while the trigger
/// rate is constant, and a rate that varies between runs is exactly when a caller has to pick.
enum class DensityDecay {
/// `intensity *= exp(-dt / time_constant_s)` every time the grid is advanced
/// (`Plot::decay_density()`), so the image fades while you watch it whatever the trigger
/// rate -- a slow stream draws faint sweeps, a fast one bright ones, and both are still
/// legible a time constant later. A `time_constant_s <= 0` freezes the grid instead of
/// fading it.
TimeConstant,
/// `intensity *= per_acquisition` once per accumulated acquisition, applied by
/// `Plot::accumulate_density()` *before* the new acquisition is deposited, so the sweep just
/// drawn is the brightest and each older one is the factor down: the N-th back is gone
/// whatever the rate. It scales with the trigger rate by construction -- the record of the
/// last few sweeps is what the display holds -- which is what a caller wants when the
/// interesting comparison is between successive acquisitions and not between seconds.
PerAcquisition,
};
/// A plot's phosphor density: whether the grid is on, how it fades, and how a cell's count is
/// turned into brightness. Plain data, replaced wholesale by `Plot::set_density()`.
///
/// Its reason to exist as a *plot* feature rather than a host's overlay is the same one
/// `Plot::set_invalid_spans()` has: the plot is what knows a trace's device geometry -- the
/// visible window about to be drawn, on the axes rect the layout settled -- so the host would
/// otherwise have to rasterise traces the library has already staged, at a second derivation of
/// the same mapping that could disagree with the first.
struct DensityConfig {
/// Whether the grid is drawn *and* accumulated. Off is the default and costs nothing: no
/// grid is allocated, no frame uploads one, and the traces are drawn exactly as they were.
/// Turning it off drops what was accumulated --- a caller that has stopped paying is not
/// holding a stale image -- see `Plot::set_density()`.
bool enabled = false;
/// How the grid fades (see `DensityDecay`).
DensityDecay decay = DensityDecay::PerAcquisition;
/// `TimeConstant`'s constant, in **stream seconds** -- the same clock the x axis reads, so a
/// caller advancing the density by its frame's own `dt` gets a fade that does not depend on
/// the frame rate. `<= 0` freezes the grid.
f32 time_constant_s = 1.0f;
/// `PerAcquisition`'s factor, clamped to `[0, 1]` at use. 0.5 -- half per acquisition -- is
/// what a phosphor's afterglow looks like sweep to sweep: 1, 0.5, 0.25, 0.125 and by the
/// seventh the layer is under 1 %.
f32 per_acquisition = 0.5f;
/// The cell value that reads as **full brightness**, in accumulated sweeps: one accumulation
/// deposits 1.0 into every cell its traces cover, so the default of 1.0 draws a single
/// acquisition at full ink and lets the ones behind it add on top (up to saturation). A caller
/// that wants the newest sweep dimmer -- so that agreement between acquisitions is the only
/// thing that reaches white -- passes 2.0 or more.
f32 full_scale = 1.0f;
/// The gamma applied to the normalised cell value before it becomes a luminance. Under 1 --
/// 0.45, the same default `Style::intensity_gamma` uses -- lifts the faint end, which is what
/// makes a ghost *readable* rather than merely present: without it a cell covered by one in
/// eight sweeps is an eighth of the ink and looks like a smudge of the background.
f32 gamma = 0.45f;
};
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its
/// resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it. Memory is therefore
/// `columns() * rows() * 4` bytes whatever the trigger has done: an 800x480 axes is 1.5 MB and a
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. The renderer adds
/// one byte per cell as the luminance it uploads (`DensityGrid::cells()` is `f32` because a
/// **count** is what decays and what `ink()`/`peak()` report; the byte is derived per changed
/// frame, not stored here).
///
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
/// own samples crossed it -- so brightness means how many acquisitions were here, and two traces
/// agreeing in one cell is twice the ink. (What the *beam* did inside one column is a different
/// question, and the library answers it elsewhere: `Style::intensity_grading`.) Decay multiplies
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
///
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device pixel `(axes.x + c, axes.y + r)`, where `axes` is
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move.
class DensityGrid {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first
/// accumulation on a laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per device pixel -- see the class comment for
/// the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
if (column >= columns_ || row >= rows_) return 0.0f;
return cells_[static_cast<usize>(row) * columns_ + column];
}
/// The **total ink**: every cell's value summed. The reading that says how much has been
/// drawn into the grid at all -- it grows with each accumulation and falls with each decay,
/// and is 0 for a grid that is off or was just cleared.
f64 ink() const noexcept {
f64 total = 0.0;
for (const f32 v : cells_) total += static_cast<f64>(v);
return total;
}
/// The **peak**: the brightest cell's value, 0 for an empty grid. It is the reading that
/// says whether the accumulation is saturating (at or past `DensityConfig::full_scale`, which
/// reads as full brightness) and whether the decay has reached the sweep behind the newest.
f32 peak() const noexcept {
f32 top = 0.0f;
for (const f32 v : cells_) top = v > top ? v : top;
return top;
}
/// How many times the image changed: bumped by every accumulation, decay, clear and resize.
/// The renderer compares it with the revision it last uploaded, so a frame that changed
/// nothing uploads nothing.
u64 revision() const noexcept { return revision_; }
private:
friend class Plot;
std::vector<f32> cells_;
u32 columns_ = 0;
u32 rows_ = 0;
u64 revision_ = 0;
};
} // namespace plot
+103
View File
@@ -12,6 +12,7 @@
#include <plot/plot/axis.hpp>
#include <plot/plot/crosshair.hpp>
#include <plot/plot/cursor.hpp>
#include <plot/plot/density.hpp>
#include <plot/plot/grid.hpp>
#include <plot/plot/legend.hpp>
#include <plot/plot/measure.hpp>
@@ -583,6 +584,73 @@ class Plot {
void set_invalid_spans(std::span<const InvalidSpan> spans);
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
// --- phosphor density --------------------------------------------------
/// Turns this plot's **phosphor density** on or off, and states how it fades. Off -- the
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything.
///
/// On, the plot keeps a **screen-sized intensity grid** behind its traces (see `DensityGrid`
/// for the resolution, the per-cell meaning and the memory cost) and deposits the acquisition
/// on screen into it with `accumulate_density()`: brightness means how often a trace passed,
/// monochrome, and every cell fades as the display's own clock moves on. It is deliberately
/// *not* a set of retained traces -- the library has the trace geometry in hand, so a density
/// costs one grid whatever the trigger has done, where retaining the acquisitions would cost
/// a window copy each.
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. Unlike a band it needs no one-pixel floor on its own size -- a cell *is* a device pixel
/// by construction, which is the smallest mark the rule could ask for.
///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
/// has stopped paying for the display is not left holding a stale image of it, which is the
/// rule the app's own `set_persist_depth()` follows for the very same reason. Re-enabling
/// starts from an empty grid -- the image is sized to the screen, and a screen it has not been
/// sized against yet is a screen it has nothing to say about.
void set_density(const DensityConfig& config);
/// The configuration, as set.
const DensityConfig& density() const noexcept { return density_; }
/// The accumulated image, for a host that wants to draw it or read it back. Empty (0x0) until
/// the first `accumulate_density()` on a laid-out plot.
const DensityGrid& density_grid() const noexcept { return density_grid_; }
/// Deposits **the acquisition on screen** into the grid: every visible trace of this plot,
/// over the x window its own axis is showing, 1.0 into every cell it covers -- **once per cell
/// per sweep**, however many of that trace's own samples crossed it, so a cell's value is how
/// many traces have covered it since the last decay and not how much the beam moved (see
/// `DensityGrid`; the dwell question is `Style::intensity_grading`'s). Called once per
/// *acquisition*, which is the cadence the grid counts in: a
/// caller that deposited every frame would add one sweep sixty times a second, and the
/// per-acquisition fade would have nothing to measure itself against.
///
/// The geometry is the frame's own. The visible x range is mapped onto sample indices through
/// the series' own x (`Series::neighbours()`), never the `dx()` grid, and never the whole
/// series -- exactly as `Renderer::draw_traces()` maps it -- and the gathered points are taken
/// to pixels through the transforms the plot's last `layout()` pinned. So this belongs *after*
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing.
///
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a
/// gap: it deposits nothing and joins nothing, the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
/// advancing the display by its frame's own `dt` gets a fade that does not depend on the frame
/// rate. Only `DensityDecay::TimeConstant` fades here: `PerAcquisition`'s factor is applied by
/// `accumulate_density()`, because there is no acquisition in a frame that merely passed.
/// A non-positive `dt_s` and a non-positive `time_constant_s` are both no-ops, so a caller that
/// has not measured a frame yet cannot wipe the grid by accident.
void decay_density(f64 dt_s);
/// Empties the grid (and leaves the configuration alone). What a caller does when the data
/// under the image is replaced: the screen-relative image of another stream's sweeps says
/// nothing about this one.
void clear_density() noexcept;
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
/// measure was added, or when the series underneath it changed: a different
/// series, a longer one, or a sliding window whose sample count stayed the same
@@ -929,6 +997,23 @@ class Plot {
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
void build_invalid_spans();
/// Deposits one trace's visible window into the density grid: the same index window
/// `Renderer::draw_traces()` maps -- `neighbours()` for the ends, the 64-spp gate for the LOD
/// level -- gathered into `density_points_`, mapped to pixels through the transforms of the
/// plot's last layout, and left with `deposit_density_segment()`. A trace with no series, no
/// visible window or non-positive `dx()` deposits nothing.
void deposit_density_trace(const Trace& trace);
/// Deposits one already-mapped segment of a trace polyline: for every grid column the segment
/// crosses, the rows it covers in that column, +1 each. The columns it also *clips*: a column
/// whose slice of the segment lies entirely outside the axes is skipped, so a trace off the
/// top of the plot deposits nothing rather than a band along the edge, and a segment with both
/// ends past an edge keeps the part that is inside. A degenerate segment (`x0 == x1`, or a
/// non-finite coordinate) fills the single column it is in.
void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1) noexcept;
/// Grows `density_grid_` to the last layout's axes rect in device pixels, clearing it when the
/// size moved (a *different* screen is a different image), and returns whether the grid is
/// usable. The one place the grid is allocated.
bool size_density_grid();
/// Exact statistics of `region` over `series`, or an invalid result when the
/// interval covers no sample. The one place the region arithmetic lives.
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
@@ -977,6 +1062,24 @@ class Plot {
std::vector<Annotation> annotations_;
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grid it accumulates into, and the scratch the
/// one visible window being deposited is gathered into. The grid is a member and not a local
/// because it is the *display's* memory -- it outlives the frames it was drawn in, which is the
/// whole point of accumulating into a screen-sized image rather than retaining traces.
DensityConfig density_{};
DensityGrid density_grid_{};
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
/// across traces and frames so a steady-state accumulation allocates nothing. Bounded by the
/// window, not the series, as `Renderer::gather_` is.
std::vector<f32> density_points_;
/// The per-sweep dedupe's stamps, one per grid **row**: the key of the last (trace, column) to
/// deposit at that row, so one acquisition's own samples cannot count a cell twice (see
/// `deposit_density_segment()`). One `u64` a row -- a few kB at any window -- and not one per
/// cell, which would double the grid's memory for a comparison.
std::vector<u64> density_stamp_;
/// The sweep's own number, bumped once per trace deposited: the high half of every stamp key, so
/// every trace of a plot, and every acquisition, deposits exactly once per cell it covers.
u32 density_trace_ = 0;
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
/// does. Entries are created for the pairs a caller actually asks about, never
+32
View File
@@ -84,4 +84,36 @@ class TextProgram {
gl::Program program_;
};
/// Linked phosphor-density program and its uniform surface: one textured quad over a plot's
/// axes rect. It fails exactly the way the two above do, propagating `Unsupported` and
/// `GLFailure`.
///
/// It is the one pipeline with **no vertex data**: the quad's four corners come from
/// `gl_VertexID`, so a draw is one four-vertex strip whatever the grid's size and there is no
/// buffer to stage or upload. The density itself reaches the GPU as a texture -- one texel per
/// cell -- which is the one thing this pipeline has that the others do not.
///
/// `create()` also points the `u_density` sampler at texture unit 0, which is the unit
/// `plot::gl::Texture::bind()` defaults to, and leaves this program bound. The texture stays
/// with the caller, who owns its lifetime.
class DensityProgram {
public:
bool create(Status* st = nullptr) noexcept;
bool valid() const noexcept { return program_.id() != 0; }
void bind() const noexcept { program_.use(); }
/// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept;
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and
/// the grid's own size, which is the axes rect in whole device pixels. They are two uniforms
/// rather than a rect because a `vec4` uniform would need a fourth setter shape for one
/// caller; a corner and a size are what the vertex shader builds the quad from anyway.
void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept;
private:
gl::Program program_;
};
} // namespace plot::render
+40 -2
View File
@@ -48,6 +48,17 @@ struct FrameStats {
u32 staged_segments = 0; ///< line segments staged, all colour groups
u32 staged_markers = 0; ///< marker shapes staged over the traces
u32 draw_calls = 0; ///< draw calls issued, line and text
/// The phosphor density's own cells, when one was drawn: `Plot::density_grid()`'s resolution,
/// zero for a plot with the density off. It is what the *frame* drew, so a host reading the
/// density's cost off a frame reads it here rather than off the grid, which says what is
/// accumulated and not what was spent.
u32 density_cells = 0;
/// The bytes of that image the frame **uploaded**: `columns * rows` when the grid had changed
/// since the last frame and 0 when it had not. It is the one field that separates the two
/// costs of a density -- the conversion and upload on the frame that moved, the single draw
/// call on the frames that did not -- which is exactly the pair a host comparing frame times
/// with the display on and off needs to tell apart.
u32 density_upload_bytes = 0;
};
/// Draws a `Plot` into whatever framebuffer is bound.
@@ -93,8 +104,8 @@ class Renderer {
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
/// Destroys every GL object this renderer created: both programs, both vertex
/// arrays and their instance buffers, and the glyph atlas texture. The
/// Destroys every GL object this renderer created: all three programs, the vertex arrays and
/// their instance buffers, the glyph atlas texture and the density's own texture. The
/// renderer is left uninitialised (`ready() == false`) and `init()` can be
/// called again on the same object.
///
@@ -513,6 +524,18 @@ class Renderer {
void draw_plot(Plot& plot, const PlotLayout& layout);
void draw_grid(const Plot& plot);
/// The plot's **phosphor density** (`Plot::set_density()`), when one is on: the accumulated
/// image as one textured quad over the axes rect, in `Style::density`'s monochrome tint. Drawn
/// over the grid and the invalid-span bands and **under the traces**, which is what makes it a
/// record of where the traces have been rather than a second set of them.
///
/// The image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here from the
/// grid's counts -- the scale, the gamma and the tint are the plot's and this stage derives
/// none of them, so the numbers a host reads back and the picture it sees are one derivation.
/// A frame whose grid did not change (nothing accumulated, nothing decayed -- the steady state
/// of a display that has stopped being swept) skips the conversion *and* the upload and costs
/// the one draw call, which `FrameStats::density_upload_bytes` says per frame.
void draw_density(const Plot& plot, const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
@@ -679,6 +702,21 @@ class Renderer {
render::TextAtlas atlas_;
render::LineProgram line_program_;
render::TextProgram text_program_;
/// The phosphor density's pipeline, its one attribute-less vertex array and the texture the
/// grid is uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and
/// re-created when that grid's size moves -- a resized plot is a differently sized image, and
/// the plot has already dropped what the old one held.
render::DensityProgram density_program_;
gl::VertexArray density_vao_;
gl::Texture density_texture_;
/// The luminance image as uploaded, re-used across frames so a steady-state frame reallocates
/// nothing: one byte per cell.
std::vector<u8> density_pixels_;
u32 density_w_ = 0;
u32 density_h_ = 0;
/// The grid revision the texture currently holds. Compared per frame, so a frame whose grid
/// did not move skips the conversion *and* the upload -- the pause in a persistence display.
u64 density_uploaded_revision_ = 0;
render::LineVertexArray line_vao_;
render::TextVertexArray text_vao_;
+22
View File
@@ -68,4 +68,26 @@ extern const char* const kLineFS;
extern const char* const kTextVS;
extern const char* const kTextFS;
/// GLSL source of the phosphor-density pipeline: one textured quad over the axes rect.
///
/// The vertex shader builds the quad from `gl_VertexID` -- a four-vertex `GL_TRIANGLE_STRIP`
/// like the other two pipelines, and the only one with **no vertex attributes at all**: the
/// image is one rectangle whose corners are uniform state, so there is no geometry to stage and
/// nothing to upload (the density's own bytes go to a texture, not to a buffer).
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
/// pixels), `u_size` (vec2, its size in pixels -- the grid's own columns and rows, so one texel
/// is one pixel on screen and no filtering is needed), `u_color` (vec4), `u_density` (sampler2D,
/// unit 0).
///
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
/// CPU (`Plot`), where the count, the decay and the gamma live, so this stage has no scale of its
/// own to get out of step with the numbers a host reads back.
///
/// `Rect` is top-left origin while GL is bottom-left, so the y axis is flipped exactly once,
/// here, as in the line pipeline.
extern const char* const kDensityVS;
extern const char* const kDensityFS;
} // namespace plot::render
+8
View File
@@ -77,6 +77,14 @@ struct Style {
/// 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*.