fix: a density grid is bounded in cells, not by the window's pixels

A grid was the axes rect in device pixels, so dragging a window wide made the
image wider with it -- and the per-frame pass over it is O(cells): the fade
multiplies every cell, a host's ink()/peak() read every cell, and a frame the
fade moved converts and uploads every cell as one byte. At 2x device scale a
1520x850 window's axes rect is 2877 device pixels wide, so four rows of one
trace are 4.5 M cells and 4.9 MB uploaded a frame; measured headless on
llvmpipe with a 2 s window, 12.9 ms of tick and 12.8 ms of draw a frame, every
frame under a time fade -- a cost that follows the window and has no ceiling.

The grid is now the axes rect up to kDensityCellCap (2^18 cells, a mebibyte of
counts), scaled down uniformly past it so cells stay square and the picture
keeps the axes' aspect, and magnified back over the rect with a linear filter:
a cell is a count, so a fragment between two of them is between their two ink
levels, which a point sample cannot give once a cell is several pixels wide.
The same four rows at the cap are 1.05 M cells, 3.9 ms of tick and 5.6 ms of
draw and 1.0 MB uploaded, while a grid under the cap is untouched -- a 640x360
viewport's 573x313 axes is still 179 349 cells, one a device pixel.

The image's footprint is the axes rect rounded up to whole device pixels on
both sides of the pipe, so a grid under the cap keeps the exact
one-texel-a-pixel mapping it has always had (sx/sy come out exactly 1) and only
a capped grid reads its cell index through a scale. Moving the decay itself to
the GPU was considered and left out: the CPU grid is what ink()/peak() read,
what the deposit adds to and what the per-sweep dedupe keys on, so a GPU image
would have to be reconciled with it per frame -- a larger change than this
bound, and not one to half-do.

Tests: a_density_grid_is_capped_whatever_the_window reads the bound off a
3840x2160 viewport (261 504 cells of a 3784x2138 axes, aspect kept),
a_capped_density_is_magnified_over_the_axes_rect reads the screen side of it
(569x460 cells covering all 832 of the rect's columns), and the density cases
now assert their capped figures. 399 cases, 0 failed.
This commit is contained in:
Martino Ferrari committed 2026-10-16 21:09:07 +02:00
1 parent 1b8b55d3ee
commit f79a97be9b
12 files changed
+414 -108

No files matched your search

+13 -9
View File
@@ -59,15 +59,19 @@ 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 (`FrameStats::slope_traces`), so the look does not change as a window narrows past the
threshold; threshold;
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather - a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
than a set of retained traces): **one grid per trace**, in that trace's own colour, one `f32` than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
cell per device pixel of the axes rect, drawn *in place of* the traces -- a host may not also rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
draw them over it -- into which each acquisition is deposited, 1.0 per cell a trace crosses, so into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
brightness is how often it passed. It fades either by a **time constant** in stream seconds or by often it passed. It fades either by a **time constant** in stream seconds or by a
a **per-acquisition factor**, and the newest acquisition is deposited like every other, so it **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 fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
179 349 cells per trace, 717 kB), the deposit walks the axes' columns rather than a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
the samples, and a frame whose grids did not move costs one draw call per trace linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
(`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`; memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
3040x1700 one used to be 4.5 M cells and 18 MB between them). 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 - 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); (`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
- grid with independent major/minor line classes (`Style::grid_major_visible`, - grid with independent major/minor line classes (`Style::grid_major_visible`,
+52 -19
View File
@@ -339,15 +339,31 @@ The split from the grading above is what it costs: grading measures *one* frame'
throws it away, while the density **keeps** what it measured — that is the whole point of it, 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. also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's, and there is one per trace.** One `f32` cell per - **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360 rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand `kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
the display's memory is that figure times the traces on screen — four signals in one 800x480 row bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
that had one hue for the plot could not say *which* trace put the ink there, and two signals of these for **each trace it draws**, so the display's memory is that figure times the traces on
agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
image; a trace with no series (an unbound slot) holds no grid at all. The colour is the reason for the per-trace split: 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.
The cap is what keeps the resolution from following the window, and the window is not a fixed
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
quantity — and a fragment between two of them is between their two ink levels, which is what a
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
what any magnification of an image does.
- **A cell's value is a number of sweeps**, and a trace covering it during one - **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**. `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 Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
@@ -383,20 +399,37 @@ also why it cannot be a per-frame accumulator.
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers. numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.** - **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 The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
trace, and a frame whose grids did not move skips the conversion and the uploads entirely and `kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes grids did not move skips the conversion and the uploads entirely and pays the quads alone
the grids move every frame under `TimeConstant`, and what makes them stand still between (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
acquisitions under `PerAcquisition`. frame under `TimeConstant`, and what makes them stand still between acquisitions under
`PerAcquisition`.
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
considered and is a **larger** change than this bound and was deliberately not bundled with it:
the image would have to live in a ping-pong target the decay multiplies in place, while the
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back `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 (**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
one sweep *is* the columns it covers), the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule `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`), (578 → 867 → 1012 on a 578-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, `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, `a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
ink 584/584**, in two different rows), and ink 578/578**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349` `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 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 trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old
+52 -19
View File
@@ -9,6 +9,29 @@
namespace plot { namespace plot {
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
///
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
/// between them, and every one of them is walked per frame. That walk is `O(cells)`: the fade
/// multiplies every cell, a host's `ink()`/`peak()` read every cell, and a frame the fade moved
/// converts and uploads every cell as one byte. So a resolution tied to the window is a per-frame
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
/// draw a frame, 4.9 MB uploaded**.
///
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
/// one row of a tall one -- is scaled.
inline constexpr usize kDensityCellCap = usize{1} << 18;
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it. /// 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 /// The two are answers to two different questions, which is why the choice belongs to the
@@ -89,17 +112,23 @@ struct DensityConfig {
f32 gamma = 1.0f; f32 gamma = 1.0f;
}; };
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its /// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
/// resolution is the screen's and not the data's -- the whole point of accumulating into a grid /// 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 /// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
/// `columns() * rows() * 4` bytes whatever the trigger has done: an 800x480 axes is 1.5 MB and a /// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. A plot holds **one /// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
/// grid per trace** (see `DensityConfig`), so a display's memory is that figure times the number of /// the number and the frame budget it comes from), and the renderer magnifies the image back over
/// traces actually drawn -- a plot's unbound slots hold no grid -- and four signals in one 800x480 /// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
/// 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()` /// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
/// is `f32` because a **count** is what decays and what `ink()`/`peak()` report; the byte is /// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
/// derived per changed frame, not stored here). /// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
/// grid -- and four traces hold at most 4 MiB between them. 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 /// 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 /// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
@@ -109,21 +138,25 @@ struct DensityConfig {
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score. /// 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 /// 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 /// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the /// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move. /// while the rect is under the cap, and a block of them when the plot scaled the image down.
/// `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 { class DensityGrid {
public: public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first /// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
/// accumulation on a laid-out plot. /// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
/// laid-out plot.
u32 columns() const noexcept { return columns_; } u32 columns() const noexcept { return columns_; }
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells. /// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
/// `columns()`: `columns() * rows()` cells.
u32 rows() const noexcept { return rows_; } u32 rows() const noexcept { return rows_; }
usize cell_count() const noexcept { usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_); 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 cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
/// the resolution and what one cell's value means. /// for the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; } std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid. /// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept { f32 at(u32 column, u32 row) const noexcept {
+18 -14
View File
@@ -590,8 +590,8 @@ class Plot {
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited /// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
/// and no frame uploads anything. /// and no frame uploads anything.
/// ///
/// On, the plot keeps a **screen-sized intensity grid behind each of its traces**, in that /// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// trace's own colour (see `DensityGrid` for the resolution and per-cell meaning, and /// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the /// `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 /// 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 /// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
@@ -603,8 +603,10 @@ class Plot {
/// ///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked: /// 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 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 /// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// by construction, which is the smallest mark the rule could ask for. /// a block of them past it, which the renderer scales back up with a linear filter -- the image
/// reserves no space either way, so the bound changes its resolution and nothing about the
/// layout.
/// ///
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who /// **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 /// has stopped paying for the display is not left holding a stale image of it, which is the
@@ -640,11 +642,12 @@ class Plot {
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and /// a layout: a plot that has never been laid out has no axes rect to size the grid to and
/// deposits nothing. /// deposits nothing.
/// ///
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the /// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized /// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens /// held, because the image is of the axes rect and a new rect is a new image. Under
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a /// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// gap: it deposits nothing and joins nothing, the library-wide policy. /// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density(); void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller /// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
@@ -1075,11 +1078,12 @@ class Plot {
std::vector<Region> regions_; std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_; std::vector<InvalidSpan> invalid_spans_;
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the /// 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 /// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// screen-sized but each trace's is tinted with that trace's own colour (see `DensityConfig`), so /// the axes rect 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 /// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
/// `traces_` and outlives the frames its images were drawn in, which is the whole point of /// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// accumulating into a screen-sized image rather than retaining traces. /// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{}; DensityConfig density_{};
std::vector<DensityGrid> density_grids_; std::vector<DensityGrid> density_grids_;
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used /// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
+5 -4
View File
@@ -104,10 +104,11 @@ class DensityProgram {
/// `u_viewport`: render target size in pixels. /// `u_viewport`: render target size in pixels.
void set_viewport(Vec2 px) const noexcept; 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 /// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
/// the grid's own size, which is the axes rect in whole device pixels. They are two uniforms /// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
/// rather than a rect because a `vec4` uniform would need a fourth setter shape for one /// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
/// caller; a corner and a size are what the vertex shader builds the quad from anyway. /// `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; void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into. /// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept; void set_color(Color color) const noexcept;
+9 -5
View File
@@ -533,13 +533,17 @@ class Renderer {
/// ///
/// Each trace's image reaches the GPU as an `GL_R8` texture, one texel per cell, converted here /// 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 /// 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 /// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// whose grids did not change skips the conversion *and* the uploads and costs the draw calls, /// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// which `FrameStats::density_upload_bytes` says per frame. /// cell is several device pixels across. 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); 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 /// 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 /// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// place the density conversion lives, shared by every trace of `draw_density()`. /// and not the grid's own cell count, so a capped grid is scaled back over the area it is an
/// image of. 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, void draw_density_grid(const DensityConfig& config, const DensityGrid& grid, Color tint,
const PlotLayout& layout); const PlotLayout& layout);
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span, /// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
+3 -3
View File
@@ -76,9 +76,9 @@ extern const char* const kTextFS;
/// nothing to upload (the density's own bytes go to a texture, not to a buffer). /// 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` /// 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 /// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
/// is one pixel on screen and no filtering is needed), `u_color` (vec4), `u_density` (sampler2D, /// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
/// unit 0). /// and a filtered magnification past it), `u_color` (vec4), `u_density` (sampler2D, unit 0).
/// ///
/// The fragment shader multiplies the sampled single-channel image into `u_color`'s alpha, which /// 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 /// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the
+33 -8
View File
@@ -404,9 +404,25 @@ void Plot::clear_density() noexcept {
bool Plot::size_density_grids() { bool Plot::size_density_grids() {
const Rect& axes = layout_.axes; const Rect& axes = layout_.axes;
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false; if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
const u32 columns = static_cast<u32>(std::ceil(axes.w)); u32 columns = static_cast<u32>(std::ceil(axes.w));
const u32 rows = static_cast<u32>(std::ceil(axes.h)); u32 rows = static_cast<u32>(std::ceil(axes.h));
if (columns == 0u || rows == 0u) return false; if (columns == 0u || rows == 0u) return false;
// **The grid is the axes rect in device pixels, up to `kDensityCellCap`.** Beyond it the rect is
// scaled down *uniformly*, so the cells stay square in device pixels and the picture keeps the
// axes' aspect: a cap on one axis alone would stretch a cell, and a density smeared in one
// direction only reads as a bug in the data rather than as a coarse image. The cells are then no
// longer one device pixel across, which is why the renderer magnifies the image with a linear
// filter instead of a point sample -- see `Renderer::draw_density_grid()`.
//
// The floor of the product is what makes the cap hold: `floor(w*s) * floor(h*s) <= w*s * h*s
// <= cap` for the `s` chosen, and the two `max(1, ...)` keep a very thin axes rect from
// rounding to nothing.
const u64 pixels = static_cast<u64>(columns) * static_cast<u64>(rows);
if (pixels > static_cast<u64>(kDensityCellCap)) {
const f64 scale = std::sqrt(static_cast<f64>(kDensityCellCap) / static_cast<f64>(pixels));
columns = std::max(1u, static_cast<u32>(static_cast<f64>(columns) * scale));
rows = std::max(1u, static_cast<u32>(static_cast<f64>(rows) * scale));
}
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it // **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 // 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. // accumulation gets its own empty grid here while the grids already sized keep what they held.
@@ -557,10 +573,19 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that 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; if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
const Rect& axes = layout_.axes; const Rect& axes = layout_.axes;
// **The image's footprint is the axes rect rounded up to whole device pixels** -- the rect
// `size_density_grids()` counted the cells on -- so `sx`/`sy` are exactly **1** while that rect is
// under `kDensityCellCap`: a cell *is* a device pixel and this is the arithmetic the density has
// always used. Past the cap they are a fraction of one, and the cell index is read through the
// scale rather than straight off the pixel. (The rounding is why the footprint, not the
// fractional rect, is the reference: a cell is defined on whole pixels, and a grid of `ceil(w)` of
// them covers the rect the axes was laid out with.)
const f32 sx = axes.w > 0.0f ? static_cast<f32>(columns) / std::ceil(axes.w) : 1.0f;
const f32 sy = axes.h > 0.0f ? static_cast<f32>(rows) / std::ceil(axes.h) : 1.0f;
const f32 lo_x = std::min(x0, x1); const f32 lo_x = std::min(x0, x1);
const f32 hi_x = std::max(x0, x1); const f32 hi_x = std::max(x0, x1);
i32 c0 = static_cast<i32>(std::floor(lo_x - axes.x)); i32 c0 = static_cast<i32>(std::floor((lo_x - axes.x) * sx));
i32 c1 = static_cast<i32>(std::floor(hi_x - axes.x)); i32 c1 = static_cast<i32>(std::floor((hi_x - axes.x) * sx));
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps // Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what // the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
// keeps ink off the edge column when a trace leaves the frame sideways. // keeps ink off the edge column when a trace leaves the frame sideways.
@@ -575,12 +600,12 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in // along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
// x), so the deposit is a continuous trace and not a dotted one. // x), so the deposit is a continuous trace and not a dotted one.
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) { const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
const f32 ca = axes.x + static_cast<f32>(column); const f32 ca = axes.x + static_cast<f32>(column) / sx;
f32 ya = y0; f32 ya = y0;
f32 yb = y1; f32 yb = y1;
if (span > 0.0f && denom != 0.0f) { if (span > 0.0f && denom != 0.0f) {
const f32 a = std::max(ca, lo_x); const f32 a = std::max(ca, lo_x);
const f32 b = std::min(ca + 1.0f, hi_x); const f32 b = std::min(ca + 1.0f / sx, hi_x);
ya = y0 + (a - x0) / denom * (y1 - y0); ya = y0 + (a - x0) / denom * (y1 - y0);
yb = y0 + (b - x0) / denom * (y1 - y0); yb = y0 + (b - x0) / denom * (y1 - y0);
} }
@@ -599,8 +624,8 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row // One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
// rather than one past the end. // rather than one past the end.
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y)); const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
i32 r0 = std::clamp(static_cast<i32>(std::floor(top - axes.y)), 0, last_row); i32 r0 = std::clamp(static_cast<i32>(std::floor((top - axes.y) * sy)), 0, last_row);
i32 r1 = std::clamp(static_cast<i32>(std::floor(bottom - axes.y)), 0, last_row); i32 r1 = std::clamp(static_cast<i32>(std::floor((bottom - axes.y) * sy)), 0, last_row);
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds // **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
// of times by one acquisition's own samples, and counting each crossing would put a single // of times by one acquisition's own samples, and counting each crossing would put a single
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible, // sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
+16 -8
View File
@@ -1806,10 +1806,15 @@ void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid&
gl_error_ = true; gl_error_ = true;
return; return;
} }
// One texel per cell and a quad exactly that many pixels across, so every fragment lands // **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
// inside its own cell: a point sample is the whole of the filtering this needs, and any // bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
// other mode would blend two sweeps' counts at the cell walls. // between two of them is between their two ink levels -- which is exactly what a linear tap
density_texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST); // returns, and what a `GL_NEAREST` fetch cannot (it would show the grid's own steps as blocks
// once the image is scaled up, `kDensityCellCap` cells spread across a wider axes rect). The
// nearest tap was right only while a cell *was* a device pixel and every fragment landed in
// its own; minification interpolates for the same reason, so a plot whose rect is under the
// cap (cell and pixel one and the same, up to the rounding of `ceil`) reads as it always did.
density_texture_.set_filter(gl::GL_LINEAR, gl::GL_LINEAR);
density_w_ = columns; density_w_ = columns;
density_h_ = rows; density_h_ = rows;
density_uploaded_grid_ = nullptr; // a fresh texture holds nothing density_uploaded_grid_ = nullptr; // a fresh texture holds nothing
@@ -1847,13 +1852,16 @@ void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid&
stats_.density_upload_bytes += columns * rows; stats_.density_upload_bytes += columns * rows;
} }
// The quad covers the cells, not the axes rect's own fractional size: `columns`/`rows` are that // The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
// rect rounded up, so mapping uv over them puts texel i exactly on device pixel `axes.x + i`. // the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
// Drawing the un-rounded rect would stretch the last row and column of cells over the fraction. // `kDensityCellCap` it is exactly `columns` by `rows` pixels -- one texel a pixel, the mapping
// this has always had -- and past the cap the grid is smaller and the image is magnified back
// over it. The uv still runs 0..1 over the quad, so a fragment between two cells is a linear
// blend of them: the filter the texture was created with.
density_program_.bind(); density_program_.bind();
density_program_.set_viewport({viewport_.w, viewport_.h}); density_program_.set_viewport({viewport_.w, viewport_.h});
density_program_.set_rect({layout.axes.x, layout.axes.y}, density_program_.set_rect({layout.axes.x, layout.axes.y},
{static_cast<f32>(columns), static_cast<f32>(rows)}); {std::ceil(layout.axes.w), std::ceil(layout.axes.h)});
// The tint is the trace's own colour: its alpha is the trace's, so a saturated cell reads as the // 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. // trace at its own brightness, and the shader multiplies it by the cell's luminance.
density_program_.set_color(tint); density_program_.set_color(tint);
+5 -5
View File
@@ -241,7 +241,7 @@ const char* const kDensityVS = R"(#version 330 core
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer. // fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
uniform vec2 u_viewport; // render target size, pixels uniform vec2 u_viewport; // render target size, pixels
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
uniform vec2 u_size; // the quad's size in pixels -- the grid's columns and rows uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
out vec2 v_uv; out vec2 v_uv;
@@ -253,9 +253,9 @@ void main() {
// Rect is top-left origin, GL is bottom-left: the one and only y flip. // Rect is top-left origin, GL is bottom-left: the one and only y flip.
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0); vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0); gl_Position = vec4(ndc, 0.0, 1.0);
// u_size is the grid's own size in pixels and the texture is one texel per cell, so one // uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
// unit of uv is exactly one cell and a GL_NEAREST fetch lands on the cell the fragment is // between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
// inside -- there is nothing to filter and nothing to round. // pixel, the sampling all but a point sample) and a linear magnification of the image past it.
v_uv = corner; v_uv = corner;
} }
)"; )";
@@ -263,7 +263,7 @@ void main() {
const char* const kDensityFS = R"(#version 330 core const char* const kDensityFS = R"(#version 330 core
in vec2 v_uv; // cell coordinate in [0,1] across the quad in vec2 v_uv; // cell coordinate in [0,1] across the quad
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_NEAREST uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
uniform vec4 u_color; uniform vec4 u_color;
out vec4 o_color; out vec4 o_color;
+84 -14
View File
@@ -2839,14 +2839,34 @@ struct DensityFixture {
} // namespace } // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect, and /// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to
/// nothing at all until the density is switched on. The figures here are the ones the API's /// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are
/// memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has done. /// the ones the API's memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has
/// done and whatever the window is.
PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) { PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
DensityFixture f; DensityFixture f;
const plot::Rect axes = f.plot.layout().axes; const plot::Rect axes = f.plot.layout().axes;
const plot::u32 columns = static_cast<plot::u32>(std::ceil(axes.w)); // The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect
const plot::u32 rows = static_cast<plot::u32>(std::ceil(axes.h)); // *scaled down* -- the rule the library states, written out here so the assertion is arithmetic
// rather than a recorded number. A grid under the cap is the rect itself, which the 320x240 leg
// at the end reads.
const auto capped = [](plot::u32 w, plot::u32 h) {
std::pair<plot::u32, plot::u32> out{w, h};
const plot::u64 pixels = static_cast<plot::u64>(w) * static_cast<plot::u64>(h);
if (pixels > static_cast<plot::u64>(plot::kDensityCellCap)) {
const plot::f64 s = std::sqrt(static_cast<plot::f64>(plot::kDensityCellCap) /
static_cast<plot::f64>(pixels));
out.first = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(w) * s));
out.second = std::max(1u, static_cast<plot::u32>(static_cast<plot::f64>(h) * s));
}
return out;
};
const auto [columns, rows] =
capped(static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
// The row a device y lands in is read through the grid's cells-per-*whole*-pixel, which is what
// the deposit uses (see `deposit_density_segment()`): 1 while the grid is the rect.
const plot::f32 sy = static_cast<plot::f32>(rows) / std::ceil(axes.h);
PLOT_CHECK(static_cast<plot::u64>(columns) * rows <= plot::kDensityCellCap);
// Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that // Off is the default, and off is *nothing*: no grid, no ink, and an accumulation that
// deposits nothing at all. // deposits nothing at all.
@@ -2866,8 +2886,9 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
PLOT_CHECK(grid.columns() == columns); PLOT_CHECK(grid.columns() == columns);
PLOT_CHECK(grid.rows() == rows); PLOT_CHECK(grid.rows() == rows);
PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows); PLOT_CHECK(grid.cell_count() == static_cast<plot::usize>(columns) * rows);
// A 640x480 viewport, minus the chrome: `4 bytes * cells`. // `4 bytes * cells`, and never more than the cap: the whole point of the bound.
const plot::usize bytes = grid.cell_count() * sizeof(plot::f32); const plot::usize bytes = grid.cell_count() * sizeof(plot::f32);
PLOT_CHECK(bytes <= static_cast<plot::usize>(plot::kDensityCellCap) * sizeof(plot::f32));
std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n", std::printf("[density] grid=%ux%u cells=%zu grid_bytes=%zu ink=%.0f peak=%.0f\n",
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(), grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
static_cast<double>(grid.peak())); static_cast<double>(grid.peak()));
@@ -2879,7 +2900,7 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
// ... and all of it in one row, the one y = 0 maps to. // ... and all of it in one row, the one y = 0 maps to.
const plot::Transform ty = f.plot.y_transform(f.ay); const plot::Transform ty = f.plot.y_transform(f.ay);
const int row = static_cast<int>(std::floor(ty.to_screen_y(0.0) - axes.y)); const int row = static_cast<int>(std::floor((ty.to_screen_y(0.0) - axes.y) * sy));
plot::u32 lit = 0; plot::u32 lit = 0;
for (plot::u32 r = 0; r < grid.rows(); ++r) { for (plot::u32 r = 0; r < grid.rows(); ++r) {
for (plot::u32 c = 0; c < grid.columns(); ++c) { for (plot::u32 c = 0; c < grid.columns(); ++c) {
@@ -2891,13 +2912,53 @@ PLOT_TEST(a_density_grid_is_the_axes_in_device_pixels) {
PLOT_CHECK(lit == columns); PLOT_CHECK(lit == columns);
// The accumulation is a *screen* image: a plot that has moved to another axes rect gets a // The accumulation is a *screen* image: a plot that has moved to another axes rect gets a
// grid of that size and nothing carried over from the old one. // grid of that size and nothing carried over from the old one -- and that rect is under the
// cap, so the grid is it *exactly*: one cell a device pixel, no scaling at all.
f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f}); f.plot.set_viewport({0.0f, 0.0f, 320.0f, 240.0f});
f.plot.layout(); f.plot.layout();
f.plot.accumulate_density(); f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).columns() < columns); const plot::DensityGrid& small = f.plot.density_grid(0u);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(), const plot::Rect small_axes = f.plot.layout().axes;
static_cast<double>(f.plot.density_grid(0u).columns()), 1.0); PLOT_CHECK(small.columns() == static_cast<plot::u32>(std::ceil(small_axes.w)));
PLOT_CHECK(small.rows() == static_cast<plot::u32>(std::ceil(small_axes.h)));
PLOT_CHECK(small.cell_count() <= plot::kDensityCellCap);
PLOT_CHECK(small.columns() < columns);
PLOT_CHECK_NEAR(small.ink(), static_cast<double>(small.columns()), 1.0);
}
/// The cap is what keeps the resolution from following the window: a grid is never more than
/// `kDensityCellCap` cells, whatever the axes rect is, so the memory a display holds and the frame
/// pass over it are numbers and not functions of how wide the window was dragged. The scaling is
/// **uniform**, so a cell stays square and the picture keeps the axes' aspect -- the reading that
/// separates this from a cap on one axis, which would stretch every cell by the other one's ratio.
PLOT_TEST(a_density_grid_is_capped_whatever_the_window) {
DensityFixture f;
f.plot.set_viewport({0.0f, 0.0f, 3840.0f, 2160.0f});
f.plot.layout();
plot::DensityConfig cfg;
cfg.enabled = true;
f.plot.set_density(cfg);
f.plot.accumulate_density();
const plot::DensityGrid& grid = f.plot.density_grid(0u);
const plot::Rect axes = f.plot.layout().axes;
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) * grid.rows() <= plot::kDensityCellCap);
PLOT_CHECK(grid.cell_count() * sizeof(plot::f32) <= 1'048'576u);
// ... and it is as large as the cap allows: flooring a uniform scale costs at most the two
// columns and rows the truncation drops, so the grid is within a hair of the whole budget.
PLOT_CHECK(static_cast<plot::f64>(grid.cell_count()) >
0.99 * static_cast<plot::f64>(plot::kDensityCellCap));
// Uniform: the grid's aspect is the axes rect's, to the rounding of the two floors.
PLOT_CHECK_NEAR(static_cast<plot::f64>(grid.columns()) / grid.rows(),
static_cast<plot::f64>(axes.w) / axes.h, 0.01);
// A cap on the *cells* also returns a full sweep's worth of ink: one flat pass over the window
// is one per column however fine or coarse the grid is.
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
PLOT_CHECK_NEAR(grid.peak(), 1.0, 1e-4);
std::printf("[density] capped grid=%ux%u cells=%zu bytes=%zu of a %ux%u axes\n", grid.columns(),
grid.rows(), grid.cell_count(), grid.cell_count() * sizeof(plot::f32),
static_cast<plot::u32>(std::ceil(axes.w)), static_cast<plot::u32>(std::ceil(axes.h)));
} }
/// Per acquisition the image is multiplied by the factor *before* the new acquisition is /// Per acquisition the image is multiplied by the factor *before* the new acquisition is
@@ -3045,11 +3106,15 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns())); PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay); const plot::Transform ty = split.plot.y_transform(split.ay);
const plot::Rect axes = split.plot.layout().axes; const plot::Rect axes = split.plot.layout().axes;
// The row a device y lands in is the grid's own mapping: the axis rect scaled by the grid's
// cells-per-pixel (1 while the rect is under `kDensityCellCap`, less past it).
const plot::f32 sy =
axes.h > 0.0f ? static_cast<plot::f32>(grid.rows()) / std::ceil(axes.h) : 1.0f;
// The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto* // The deposit keeps one ULP of the bottom edge off its row, so `y = -1` -- which maps *onto*
// the bottom edge -- lands in the last row rather than one past the end. // the bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) { const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::clamp( return static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(ty.to_screen_y(y) - axes.y)), 0, static_cast<int>(std::floor((ty.to_screen_y(y) - axes.y) * sy)), 0,
static_cast<int>(grid.rows()) - 1)); static_cast<int>(grid.rows()) - 1));
}; };
PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f); PLOT_CHECK(grid.at(0u, row_of(-1.0)) > 0.0f);
@@ -3077,8 +3142,11 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
const plot::DensityGrid& gap_grid = gap.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::Transform tx = gap.plot.x_transform(gap.ax);
const plot::Rect gap_axes = gap.plot.layout().axes; const plot::Rect gap_axes = gap.plot.layout().axes;
const plot::f32 gsx = gap_axes.w > 0.0f
? static_cast<plot::f32>(gap_grid.columns()) / std::ceil(gap_axes.w)
: 1.0f;
const plot::u32 column = static_cast<plot::u32>(std::clamp( const plot::u32 column = static_cast<plot::u32>(std::clamp(
static_cast<int>(std::floor(tx.to_screen_x(0.5) - gap_axes.x)), 0, static_cast<int>(std::floor((tx.to_screen_x(0.5) - gap_axes.x) * gsx)), 0,
static_cast<int>(gap_grid.columns()) - 1)); static_cast<int>(gap_grid.columns()) - 1));
PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit PLOT_CHECK(column_ink(whole_grid, column) > 0.0); // the sample is there: the column is lit
PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it PLOT_CHECK(column_ink(gap_grid, column) == 0.0); // the NaN is a gap: nothing joins across it
@@ -3123,8 +3191,10 @@ PLOT_TEST(a_density_holds_one_grid_per_trace) {
const plot::Transform ty = p.y_transform(ay); const plot::Transform ty = p.y_transform(ay);
const plot::Rect axes = p.layout().axes; const plot::Rect axes = p.layout().axes;
const plot::f32 sy =
axes.h > 0.0f ? static_cast<plot::f32>(g0.rows()) / std::ceil(axes.h) : 1.0f;
const auto row_of = [&](plot::f64 y) { const auto row_of = [&](plot::f64 y) {
return static_cast<plot::u32>(std::floor(ty.to_screen_y(y) - axes.y)); return static_cast<plot::u32>(std::floor((ty.to_screen_y(y) - axes.y) * sy));
}; };
const plot::u32 lo_row = row_of(-0.5); const plot::u32 lo_row = row_of(-0.5);
const plot::u32 hi_row = row_of(0.5); const plot::u32 hi_row = row_of(0.5);
+124
View File
@@ -7725,3 +7725,127 @@ PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after); bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
ctx.destroy(); ctx.destroy();
} }
/// A grid **past `kDensityCellCap`** is the axes rect scaled down, and the frame magnifies the image
/// back over that rect: this is the screen-side half of the bound --
/// `a_density_grid_is_capped_whatever_the_window` reads the counts of a capped grid, and the two
/// agree only if the deposit and the draw use the same footprint, the axes rect in *whole* device
/// pixels.
///
/// What is read here is what a wrong scale moves: the image covers the rect's own columns rather
/// than the smaller grid's (the assertion a quad drawn at the grid's size fails), it stays inside
/// the rect, and the sweep is on the row its own transform maps y = 0 to, in the trace's colour.
PLOT_TEST(a_capped_density_is_magnified_over_the_axes_rect) {
const plot::u32 w = 900, h = 720;
plottest::HeadlessContext ctx(w, h);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
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});
const plot::Color colour = plot::Color::hex(0x00E070);
PLOT_CHECK(p.add_trace({&s, colour, 1.5f, ax, ay, "flat"}) == 0u);
p.set_x_range(ax, {0.0, 1.0});
p.set_y_range(ay, {-1.0, 1.0});
const plot::Rect frame{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
r.begin_frame(w, h, frame);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const std::vector<plot::u8> without = read_frame(ctx);
plot::DensityConfig cfg;
cfg.enabled = true;
p.set_density(cfg);
p.accumulate_density();
const plot::DensityGrid& grid = p.density_grid(0u);
const plot::Rect axes = p.layout().axes;
// The rect is past the cap, so the grid is smaller than it and the frame has to scale the image
// up -- and the one flat sweep still lights every column, once.
PLOT_CHECK(grid.cell_count() > 0u);
PLOT_CHECK(grid.cell_count() <= plot::kDensityCellCap);
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) < static_cast<plot::u64>(std::ceil(axes.w)));
PLOT_CHECK(static_cast<plot::u64>(grid.rows()) < static_cast<plot::u64>(std::ceil(axes.h)));
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
r.begin_frame(w, h, frame);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
const plot::FrameStats stats = r.stats();
PLOT_CHECK(stats.density_cells == static_cast<plot::u32>(grid.cell_count()));
PLOT_CHECK(stats.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
const std::vector<plot::u8> capped = read_frame(ctx);
const auto differs = [&](plot::u32 x, plot::u32 y) {
const plot::usize k = (plot::usize(y) * w + x) * 4u;
return capped[k] != without[k] || capped[k + 1u] != without[k + 1u] ||
capped[k + 2u] != without[k + 2u];
};
const auto at = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
return static_cast<int>(f[(plot::usize(y) * w + x) * 4u + static_cast<plot::usize>(c)]);
};
// **The image covers the rect's columns, not the grid's.** A quad drawn at the grid's own size --
// the temptation once a cell is no longer a pixel -- would paint the left `columns` of the rect
// and leave the rest of it blank, which this counts.
plot::u32 changed_cols = 0;
plot::u32 lo_x = w, hi_x = 0, lo_y = h, hi_y = 0;
for (plot::u32 x = 0; x < w; ++x) {
bool any = false;
for (plot::u32 y = 0; y < h; ++y) {
if (!differs(x, y)) continue;
any = true;
lo_x = std::min(lo_x, x);
hi_x = std::max(hi_x, x);
lo_y = std::min(lo_y, y);
hi_y = std::max(hi_y, y);
}
if (any) ++changed_cols;
}
PLOT_CHECK(changed_cols > 0u);
PLOT_CHECK(changed_cols + 2u >= static_cast<plot::u32>(axes.w));
// ... and it stays inside the rect it is an image of.
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
// The sweep is on the row its own transform maps y = 0 to, in the trace's own colour: the cell's
// one sweep is spread over the block of device pixels it was magnified into, so what is asserted
// is that the ink is *there* -- the brightest row of the rect is that row -- and that its hue is
// the trace's rather than the background's.
const plot::u32 mid_x = static_cast<plot::u32>(p.x_transform(ax).to_screen_x(0.5));
const plot::u32 trace_row = static_cast<plot::u32>(p.y_transform(ay).to_screen_y(0.0));
// The background to read the ink against is a row of the *without* frame away from the sweep's
// own: the trace itself is drawn there (the density is off in that frame), so the trace's own row
// is the trace's colour and not a background at all.
const plot::u32 blank_row = trace_row + 40u < h ? trace_row + 40u : trace_row - 40u;
const int bg_g = at(without, mid_x, blank_row, 1);
int band_g = 0;
int band_y = 0;
for (plot::u32 y = static_cast<plot::u32>(axes.y); y < static_cast<plot::u32>(axes.bottom()); ++y) {
const int g = at(capped, mid_x, y, 1);
if (g > band_g) {
band_g = g;
band_y = static_cast<int>(y);
}
}
PLOT_CHECK(band_g > bg_g + 40); // the sweep is on screen at all
PLOT_CHECK(at(capped, mid_x, trace_row, 0) < 40); // ... in the trace's own hue ...
PLOT_CHECK(at(capped, mid_x, trace_row, 1) > bg_g + 40);
PLOT_CHECK(std::abs(band_y - static_cast<int>(trace_row)) <= 2);
std::printf("[density gl] capped grid=%ux%u cells=%zu of a %.0fx%.0f axes: changed cols=%u"
" brightest row=%d (transform says %u), green=%d against bg=%d\n",
grid.columns(), grid.rows(), grid.cell_count(), axes.w, axes.h, changed_cols, band_y,
trace_row, band_g, bg_g);
ctx.destroy();
}