diff --git a/README.md b/README.md index eccade3..aa45756 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,14 @@ run touched a GPU, so read it as an upper bound and a record of the software pat (`FrameStats::slope_traces`), so the look does not change as a window narrows past the threshold; - a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather - than a set of retained traces): one `f32` cell per device pixel of the axes rect, behind the - traces, into which each acquisition is deposited -- 1.0 per cell a trace crosses, so brightness - is how often it passed -- and which fades either by a **time constant** in stream seconds or by - a **per-acquisition factor**. Memory is the screen's (a 640x360 viewport's axes is 179 349 - cells, 717 kB) whatever the trigger has done, the deposit walks the axes' columns rather than - the samples, and a frame whose grid did not move costs one draw call + than a set of retained traces): **one grid per trace**, in that trace's own colour, one `f32` + cell per device pixel of the axes rect, drawn *in place of* the traces -- a host may not also + draw them over it -- into which each acquisition is deposited, 1.0 per cell a trace crosses, so + brightness is how often it passed. It fades either by a **time constant** in stream seconds or by + a **per-acquisition factor**, and the newest acquisition is deposited like every other, so it + fades too. Memory is the screen's times the traces on screen (a 640x360 viewport's axes is + 179 349 cells per trace, 717 kB), the deposit walks the axes' columns rather than + the samples, and a frame whose grids did not move costs one draw call per trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`; - sample **markers** when a window is sparse enough that its samples are the shape (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour); diff --git a/docs/data-and-performance.md b/docs/data-and-performance.md index 59e4bb2..7948684 100644 --- a/docs/data-and-performance.md +++ b/docs/data-and-performance.md @@ -327,27 +327,34 @@ way**, against the 191 the sparse frame gave before. Across the whole sweep from samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold left to see. -## A phosphor density accumulates into a screen-sized grid +## A phosphor density is the trace, one grid per trace `Plot::set_density()` gives a plot a **phosphor density**: an image of how often its traces have -passed, drawn behind them, fading by a time constant or by an acquisition count. It is the -library's because the library is the side that knows a trace's device geometry; a host that -built one itself would be re-rasterising traces the renderer has already staged. +passed, drawn *in place of them* -- one grid per trace, in that trace's own colour -- fading by a +time constant or by an acquisition count. It is the library's because the library is the side that +knows a trace's device geometry; a host that built one itself would be re-rasterising traces the +renderer has already staged. The split from the grading above is what it costs: grading measures *one* frame's dwell and throws it away, while the density **keeps** what it measured — that is the whole point of it, and also why it cannot be a per-frame accumulator. -- **The grid is the screen's, not the data's.** One `f32` cell per device pixel of the axes rect - (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 viewport is a **573x313** grid - of **179 349 cells** and **717 396 bytes**, and a thousand acquisitions through it cost the - same. A cell's value is a **number of sweeps**: a trace covering it during one +- **The grid is the screen's, not the data's, and there is one per trace.** One `f32` cell per + device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 + viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand + acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so + the display's memory is that figure times the traces on screen — four signals in one 800x480 row + are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density + that had one hue for the plot could not say *which* trace put the ink there, and two signals + agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's + image; a trace with no series (an unbound slot) holds no grid at all. +- **A cell's value is a number of sweeps**, and a trace covering it during one `Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**. Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and hides the sweeps behind it, and a cell would then mean "how much the beam moved here" rather than "how many acquisitions were here". The dedupe is a stamp per grid *row* (one `u64`, a few - kB), keyed by (trace, column), so two sweeps agreeing on a cell still count twice and the two - segments that share a column boundary do not. + kB, shared by every trace's deposit), keyed by (trace, column), so two sweeps agreeing on a cell + still count twice and the two segments that share a column boundary do not. - **The deposit is one pass per cell, not one per sample.** A trace's visible window is mapped to sample indices and gathered exactly as `Renderer::draw_traces()` maps it (`neighbours()`, the same 64-spp gate for the LOD level), then walked as a polyline: for each grid column the @@ -355,38 +362,47 @@ also why it cannot be a per-frame accumulator. over hundreds of columns therefore costs *columns*, plus one row of ink per column the trace crossed — the deposit is bounded by the screen, not by the data, which is what makes an accumulation affordable at the trigger's own rate. -- **The brightness ceiling is the fade's own steady state, and the ramp is linear in sweeps.** - `DensityConfig::full_scale` defaults to `1/(1 - 0.5)` -- the geometric sum of the default - per-acquisition fade -- so one sweep reads half ink and the display reaches white only where - acquisitions agree, and `gamma` defaults to 1.0, because a cell's value is a count and half as - many sweeps must read as half the ink. Both defaults were wrong first and the *eye* found it: with - the ceiling at a single sweep and `gamma` at 0.45 (the value `Style::intensity_gamma` wants for a - *dwell fraction*) every cell one sweep touched was already full white, so a display of one sweep - and a display of five looked the same. The case that would have caught it is a **framebuffer** - reading, not the grid's numbers -- `a_phosphor_density_is_drawn_under_the_traces` now measures a - cell at a quarter of the scale against one at the ceiling and against the background - (`empty=16 full=83 quarter=54` at the current defaults, where the old ones read `199` for the - first two alike). +- **The newest acquisition is deposited like every other, and no 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 acquisition just drawn is a sweep *inside* it and fades with the rest. Drawing the polyline + on top as well would put a solid, unfading stroke over its own faint ghost — the "persistent + image + last acquisition" a user reported, where the newest acquisition never faded. +- **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** + `DensityConfig::full_scale` defaults to **one sweep**, so 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 *exactly* at the colour its trace would have drawn — the settlement is + a clamp, not a gain. `gamma` defaults to 1.0, because a cell's value is a count and half as many + sweeps must read as half the ink. A ceiling at the *fade's steady state* (`1/(1-0.5)=2`) was the + first default and is a legitimate alternative: it makes the newest sweep a fraction of the + settled brightness so the fade's ramp is visible, at the cost of a gain offset between the newest + sweep and the image behind it. The case that reads either one off the *screen* rather than the + grid is `a_phosphor_density_is_the_trace_in_its_own_colour`. - **The scale is applied once, on the CPU.** `DensityConfig::full_scale` and `gamma` map a cell's count to a luminance byte when the grid changed, and the shaders (`kDensityVS`/`kDensityFS`) - only tint that byte. The one derivation is deliberate: a value a host reads back through - `DensityGrid::ink()`/`peak()` and the picture it sees come from the same numbers. -- **Per frame, it is one texture upload and one draw call -- or one draw call.** The upload is one - byte per cell (**179 kB** for the axes above); a frame whose grid did not move skips the - conversion and the upload entirely and pays the quad alone (`FrameStats::density_upload_bytes` - reads 0 for it). The decay is what makes the grid move every frame under `TimeConstant`, and - what makes it stand still between acquisitions under `PerAcquisition`. + only tint that byte with the trace's colour. The one derivation is deliberate: a value a host + reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same + numbers. +- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.** + The upload is one byte per cell (**179 kB** for the axes above); the one texture is re-filled per + trace, and a frame whose grids did not move skips the conversion and the uploads entirely and + pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes + the grids move every frame under `TimeConstant`, and what makes them stand still between + acquisitions under `PerAcquisition`. `a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back (**584x458 = 267 472 cells, 1 069 888 bytes**, `ink=584 peak=1` for one flat sweep — the ink of one sweep *is* the columns it covers), `a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule (584 → 876 → 1022 on a 584-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`), -`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink, and -`a_phosphor_density_is_drawn_under_the_traces` reads the frame: `cells=179349 uploaded=179349` -on the frame the grid moved and `uploaded=0` on the identical one after it, with the trace's -stroke keeping its own colour over the ink (`min_red < 40`, where a density drawn on top would -put the red at ~190). +`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink, +`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**267 472 cells each, +ink 584/584**, in two different rows), and +`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349` +on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the +trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old +near-white tint read ~190 red); one time constant dims it (`faded=115`); and a steady state +(`steady=224`) against a quarter of it (`quarter=138`) keeps the trace's hue, red near the +background's. ## One sample or a statistic of many diff --git a/docs/styling.md b/docs/styling.md index 6115e3b..913a796 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -44,7 +44,6 @@ Colours (neither logical nor device — nothing to scale): | `measure_fill` | `0xFFB020` at α 0.15 | the measurement band | | `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) | | `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired | -| `density` | `0xDFF2FF` at α 0.85 | the tint a plot's phosphor density is drawn in (`Plot::set_density()`); the alpha is the ceiling of the image's brightness scale | | `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) | The scale: diff --git a/include/plot/plot/density.hpp b/include/plot/plot/density.hpp index a800afa..979afe9 100644 --- a/include/plot/plot/density.hpp +++ b/include/plot/plot/density.hpp @@ -35,6 +35,14 @@ enum class DensityDecay { /// 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()`. /// +/// The image is **one grid per trace**, drawn in that trace's own colour (`Trace::color`), because +/// a density that had one hue for the whole plot could not say *which* trace put the ink there -- +/// two signals agreeing in a cell would be one blended pixel, and a display of several traces +/// could not be read back to the trace it came from. A cell's brightness is a count of sweeps in +/// *that trace's* colour: `full_scale` (default one sweep, the trace's own brightness) is where it +/// saturates, and a signal crossing the same place every acquisition settles exactly at the colour +/// its trace would have drawn. +/// /// 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 @@ -56,15 +64,21 @@ struct DensityConfig { /// 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. The default is the - /// ink the **default fade settles at** -- `1/(1 - 0.5)`, the geometric sum of - /// `PerAcquisition`'s factor -- and that is the rule worth keeping: a ceiling at *one* sweep - /// puts the newest acquisition at full white and everything above it clamps to the same pixel, - /// so a display of one sweep and a display of five read alike and the fade is invisible - /// (measured: a cell at a quarter of the scale rendered 104 of 255 while one at the ceiling - /// rendered 156, but with the ceiling at one sweep both were 199). A caller whose fade settles - /// elsewhere wants its own sum here. - f32 full_scale = 2.0f; + /// The cell value that reads as **full brightness**, in accumulated sweeps -- and the one field + /// a caller reads *off its own trace* rather than off the fade. A cell at this value is drawn in + /// its trace's own colour at that colour's own alpha; the default of **one sweep** is therefore + /// the ink the newest acquisition leaves, so the acquisition just drawn reads exactly as the + /// trace it is a picture of (a signal crossing the same place every acquisition settles at the + /// trace's brightness, where a ceiling above one sweep would settle *brighter* than the trace and + /// leave a gain offset between the newest sweep and the image behind it). Everything above the + /// ceiling clamps to the same pixel: where several acquisitions agree, the cell is the trace's + /// colour at full strength and no brighter. + /// + /// A caller that wants the fade's own ramp on screen instead -- the newest sweep a *fraction* of + /// the steady state, so the eye can rank the sweeps -- sets this to the sum the fade settles at + /// (for `PerAcquisition`'s factor `f` that is `1/(1-f)`) and accepts the gain offset; that was + /// this field's first default, and it is a legitimate choice, not the one a phosphor makes. + f32 full_scale = 1.0f; /// The gamma applied to the normalised cell value before it becomes a luminance. **1.0 by /// default: linear in sweeps**, because that is what a count means -- a cell covered by half the /// acquisitions is half the ink. Under 1 lifts the faint end, and that is what @@ -79,10 +93,13 @@ struct DensityConfig { /// 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). +/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. A plot holds **one +/// grid per trace** (see `DensityConfig`), so a display's memory is that figure times the number of +/// traces actually drawn -- a plot's unbound slots hold no grid -- and four signals in one 800x480 +/// row are four grids, 6 MB, while a 1920x1080 row of eight is 66 MB. 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 diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 50599b9..e9cfadf 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -590,13 +590,16 @@ class Plot { /// 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. + /// On, the plot keeps a **screen-sized intensity grid behind each of its traces**, in that + /// trace's own colour (see `DensityGrid` for the resolution and per-cell meaning, and + /// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the + /// acquisition on screen into it with `accumulate_density()`. While a density is on the + /// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest + /// acquisition is deposited like every other and fades with the rest -- a trace left drawn on + /// top of its own density could never fade, which is what a report of "the last acquisition + /// never fades" was. It is deliberately *not* a set of retained traces -- the library has the + /// trace geometry in hand, so a density costs one grid per trace 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 @@ -611,18 +614,24 @@ class Plot { 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_; } + /// The accumulated image **of a trace**, for a host that wants to draw it or read it back. There + /// is one per trace, in trace order, because each is drawn in that trace's own colour (see + /// `DensityConfig`); an out-of-range trace reads an empty (0x0) image. Empty until the first + /// `accumulate_density()` on a laid-out plot, and dropped by `set_density()` off. + const DensityGrid& density_grid(u32 trace) const noexcept { + return trace < density_grids_.size() ? density_grids_[trace] : empty_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. + /// Deposits **the acquisition on screen** into each trace's grid: every visible trace of this + /// plot, the newest acquisition included, 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 grids + /// count 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 renderer draws no + /// polyline for a trace whose density is on (see `set_density()`), so this deposit is the whole + /// of what the newest acquisition looks like -- and it fades with the rest. /// /// 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 @@ -997,23 +1006,26 @@ 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 + /// Deposits one trace's visible window into its own 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 + void deposit_density_trace(const Trace& trace, DensityGrid& grid); + /// Deposits one already-mapped segment of a trace polyline into `grid`: 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(); + void deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid& grid) noexcept; + /// Grows `density_grids_` -- one grid per trace -- to the last layout's axes rect in device + /// pixels, clearing a grid when the size moved (a *different* screen is a different image), and + /// returns whether any grid is usable. The one place the grids are allocated. + bool size_density_grids(); + /// The image an out-of-range trace reads (see `density_grid()`): a shared empty grid, so the + /// accessor need not hand back a reference into nothing. + static const DensityGrid& empty_density_grid() noexcept; /// 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); @@ -1062,20 +1074,22 @@ class Plot { std::vector annotations_; std::vector regions_; std::vector 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. + /// The phosphor density: its configuration, the grids it accumulates into, and the scratch the + /// one visible window being deposited is gathered into. **One grid per trace** -- the image is + /// screen-sized but each trace's is tinted with that trace's own colour (see `DensityConfig`), so + /// the memory is `traces * columns * rows * 4` bytes and not one plot's worth. The vector tracks + /// `traces_` and outlives the frames its images were drawn in, which is the whole point of + /// accumulating into a screen-sized image rather than retaining traces. DensityConfig density_{}; - DensityGrid density_grid_{}; + std::vector density_grids_; /// 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 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. + /// The per-sweep dedupe's stamps, one per grid **row** and shared by every trace's deposit: 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 a grid's memory for a comparison. std::vector 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. diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 8952211..93b42f9 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -48,10 +48,11 @@ 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. + /// The phosphor density's own cells, when one was drawn: the **sum** of every trace's + /// `Plot::density_grid()` 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 grids, which say what is accumulated and not what was spent -- and it is the number that + /// scales with the trace count, since the image is one grid per trace (see `DensityConfig`). 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 @@ -524,18 +525,23 @@ 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 plot's **phosphor density** (`Plot::set_density()`), when one is on: one textured quad per + /// trace over the axes rect, each in **that trace's own colour** -- a saturated cell is the trace + /// at its own brightness -- drawn over the grid and the invalid-span bands. The traces themselves + /// are not drawn while it is on (`draw_traces()` returns), so this is the whole of the data ink + /// and the newest acquisition is inside it. /// - /// 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. + /// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here + /// from the grid's counts -- the scale and the gamma are the plot's and this stage derives + /// neither, so the numbers a host reads back and the picture it sees are one derivation. A frame + /// whose grids did not change skips the conversion *and* the uploads and costs the draw calls, + /// which `FrameStats::density_upload_bytes` says per frame. void draw_density(const Plot& plot, const PlotLayout& layout); + /// One grid's quad: fills the shared `GL_R8` texture from `grid` (skipping when its revision and + /// address are the ones already uploaded) and draws it over the axes rect in `tint`. The one + /// place the density conversion lives, shared by every trace of `draw_density()`. + void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint, + 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 @@ -702,10 +708,12 @@ 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. + /// The phosphor density's pipeline, its one attribute-less vertex array and the texture a grid is + /// uploaded into. The texture is `GL_R8`, one texel per cell of the plot's grid, and is re-filled + /// for each trace in turn -- a trace's grid is drawn before the next trace's replaces it -- so a + /// row of several traces still costs the one texture. It is re-created when the 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_; @@ -714,8 +722,12 @@ class Renderer { std::vector 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. + /// The grid the texture currently holds, by address, and its revision. Compared per frame and per + /// trace, so a frame whose grids did not move skips the conversion *and* the upload -- the pause + /// in a persistence display. The address is part of the key because the texture is shared: two + /// traces of one plot have two grids with two revisions, and without the address a skip would + /// draw one trace's image under another's colour. + const DensityGrid* density_uploaded_grid_ = nullptr; u64 density_uploaded_revision_ = 0; render::LineVertexArray line_vao_; render::TextVertexArray text_vao_; diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index b35efb2..a62c643 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -77,14 +77,6 @@ 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*. diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index bc0d483..f30743e 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -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(std::exp(-dt_s / static_cast(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(std::ceil(axes.w)); const u32 rows = static_cast(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(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(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(density_trace_) << 32) | static_cast(static_cast(c)); - f32* column_cells = density_grid_.cells_.data() + static_cast(c); + f32* column_cells = grid.cells_.data() + static_cast(c); for (i32 r = r0; r <= r1; ++r) { const usize row = static_cast(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; } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 389cd5a..a4c9ec9 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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(columns), static_cast(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; } diff --git a/src/render/shaders.cpp b/src/render/shaders.cpp index a42e63f..a4e9ba8 100644 --- a/src/render/shaders.cpp +++ b/src/render/shaders.cpp @@ -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); } diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index 2a13a0b..7f5645b 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -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(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(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(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(f.plot.density_grid().columns()); - const double one = f.plot.density_grid().ink(); + const double width = static_cast(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{-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(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(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{-0.5f, -0.5f}, 0.0, 1.0); + plot::data::ArraySeries hi(std::vector{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(g0.columns()), 1.0); + PLOT_CHECK_NEAR(g1.ink(), static_cast(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(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); } diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 6f30f09..c76a191 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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 without = read_frame(ctx); + const auto channel = [&](const std::vector& f, plot::u32 x, plot::u32 y, int c) { + return static_cast(f[(plot::usize(y) * kW + x) * 4u + static_cast(c)]); + }; + const plot::Transform tx = p.x_transform(ax); + const plot::Transform ty = p.y_transform(ay); + const plot::u32 mid_x = static_cast(tx.to_screen_x(0.5)); + const plot::u32 trace_row = static_cast(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{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 with = read_frame(ctx); + const std::vector 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(lo_y) >= axes.y - 1.0f); PLOT_CHECK(static_cast(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(grid.cell_count())); PLOT_CHECK(first.density_upload_bytes == static_cast(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(tx.to_screen_x(0.5)); - const plot::u32 trace_row = static_cast(ty.to_screen_y(0.5)); - const plot::u32 ghost_row = static_cast(ty.to_screen_y(0.0)); - const auto frame_pixel = [&](const std::vector& 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(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(trace_row) + dy, 0, static_cast(kH) - 1); - frame_pixel(with, mid_x, static_cast(y), px); - min_red = std::min(min_red, static_cast(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& f, plot::u32 x, plot::u32 y, int c) { - return static_cast(f[(plot::usize(y) * kW + x) * 4u + static_cast(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 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{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 moved = read_frame(ctx); + const plot::u32 moved_row = static_cast(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{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 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 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 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(); }