`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.
94 lines
5.1 KiB
C++
94 lines
5.1 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
namespace plot::render {
|
|
|
|
/// GLSL source of the instanced antialiased line pipeline.
|
|
///
|
|
/// The vertex shader takes one segment per instance (`a_p0` at location 0,
|
|
/// `a_p1` at location 1, both with a vertex attribute divisor of 1), expands it
|
|
/// in pixel space along its normal by `u_width * 0.5 + 1.0` pixels of AA fringe,
|
|
/// and emits a four-vertex `GL_TRIANGLE_STRIP` quad whose corners come from
|
|
/// `gl_VertexID` (0 and 1 are the two sides of `a_p0`, 2 and 3 of `a_p1`). It
|
|
/// outputs `v_dist`, the signed distance from the segment axis in pixels, and
|
|
/// `v_along`, the distance travelled from `a_p0` in pixels.
|
|
///
|
|
/// Uniforms: `u_scale` (vec2, data space to pixels), `u_offset` (vec2, pixels),
|
|
/// `u_viewport` (vec2, pixels), `u_width` (float, pixels), `u_aa` (float, the AA
|
|
/// fringe half-width in pixels -- the style's `line_aa_px` in device pixels,
|
|
/// clamped at 0.25), `u_color` (vec4).
|
|
/// `plot::Rect` is top-left origin while GL is bottom-left, so the y axis is
|
|
/// flipped exactly once, here.
|
|
///
|
|
/// A degenerate segment (`a_p0 == a_p1`) keeps a finite normal and is stretched
|
|
/// into a square mark one stroke width and fringe across, rather than
|
|
/// collapsing to zero area and rasterising nothing — the dense path stages
|
|
/// `min == max` buckets as exactly such segments.
|
|
///
|
|
/// The fragment shader discards outside the fringe and ramps alpha over the last
|
|
/// pixel of the half width with `smoothstep`, which is what keeps a 1px trace
|
|
/// from aliasing into dashes.
|
|
extern const char* const kLineVS;
|
|
extern const char* const kLineFS;
|
|
|
|
/// GLSL source of the instanced text pipeline.
|
|
///
|
|
/// One glyph cell per instance, every attribute with a vertex attribute divisor
|
|
/// of 1: `a_pos` at location 0 is the cell's top-left corner in Rect pixels,
|
|
/// `a_uv` at location 1 is the glyph rect's normalised top-left in the atlas and
|
|
/// `a_axis` at location 2 is the cell's local +x axis, a unit vector in pixels.
|
|
///
|
|
/// The vertex shader builds the cell from that basis and its perpendicular --
|
|
/// the cell's own +v direction -- so `a_axis` of `(0, -1)` draws a run that
|
|
/// advances up the screen, and it maps `a_uv` over the cell with the same
|
|
/// handedness, so a glyph's top row stays its top row however the cell is
|
|
/// turned. It also passes the cell's origin through `flat`, which is the bound
|
|
/// the fragment shader filters against.
|
|
///
|
|
/// The fragment shader converts back to texels and blends the four texels the
|
|
/// fragment sits between, **clamped to the instance's own cell**. Filtering is
|
|
/// the shader's, not the sampler's: every fetch is an explicit texel centre, so
|
|
/// `GL_NEAREST` stays correct, and the clamp keeps a footprint reaching past a
|
|
/// cell edge from blending in the neighbouring glyph of what is a tight,
|
|
/// unpadded strip of cells. A point sample would have been enough only at an
|
|
/// integer scale: at the library's own default of a 12 px style on the 13 px
|
|
/// embedded bake it *deleted* one of every 8 columns and one of every 17 rows of
|
|
/// every glyph. At scale 1 with a whole pen the two taps coincide and the result
|
|
/// is exactly as crisp as a point sample. The instance's own `a_colour` tints the
|
|
/// straight alpha -- per vertex, so one draw can carry runs of several colours.
|
|
///
|
|
/// Uniforms: `u_viewport` (vec2, pixels), `u_px_range` (vec2, the glyph cell's
|
|
/// size on screen in pixels), `u_glyph_uv` (vec2, the glyph cell's size in
|
|
/// normalised atlas coordinates -- one size for the whole draw, since every
|
|
/// glyph of a frame comes from one atlas; read in *both* stages, the fragment
|
|
/// one needing it for the clamp), `u_tex_size` (vec2, atlas size in texels),
|
|
/// `u_atlas` (sampler2D, unit 0).
|
|
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
|