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

+52 -19
View File
@@ -9,6 +9,29 @@
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.
///
/// 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;
};
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its
/// resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it. Memory is therefore
/// `columns() * rows() * 4` bytes whatever the trigger has done: an 800x480 axes is 1.5 MB and a
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. 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).
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
///
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
/// 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
/// 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 grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
/// `r * columns() + c` and covers the device pixel `(axes.x + c, axes.y + r)`, where `axes` is
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move.
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
/// 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 {
public:
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first
/// accumulation on a laid-out plot.
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
/// laid-out plot.
u32 columns() const noexcept { return columns_; }
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells.
/// 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_; }
usize cell_count() const noexcept {
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
}
/// The cells, row-major from the top-left, one per device pixel -- see the class comment for
/// the resolution and what one cell's value means.
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
/// for the resolution and what one cell's value means.
std::span<const f32> cells() const noexcept { return cells_; }
/// One cell, or 0.0 outside the grid.
f32 at(u32 column, u32 row) const noexcept {
+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
/// and no frame uploads anything.
///
/// 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
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
/// (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
/// 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
@@ -603,8 +603,10 @@ class Plot {
///
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
/// it. Unlike a band it needs no one-pixel floor on its own size -- a cell *is* a device pixel
/// by construction, which is the smallest mark the rule could ask for.
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
/// 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
/// 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
/// deposits nothing.
///
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a
/// gap: it deposits nothing and joins nothing, the library-wide policy.
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
/// held, because the image is of the axes rect and a new rect is a new image. Under
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
/// the library-wide policy.
void accumulate_density();
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
@@ -1075,11 +1078,12 @@ class Plot {
std::vector<Region> regions_;
std::vector<InvalidSpan> invalid_spans_;
/// 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.
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
/// 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 -- and each is
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
/// traces.
DensityConfig density_{};
std::vector<DensityGrid> density_grids_;
/// 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.
void set_viewport(Vec2 px) const noexcept;
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and
/// the grid's own size, which is the axes rect in whole device pixels. They are two uniforms
/// rather than a rect because a `vec4` uniform would need a fourth setter shape for one
/// caller; a corner and a size are what the vertex shader builds the quad from anyway.
/// `u_origin` and `u_size`: the quad in `Rect` pixels -- the axes rect's top-left corner and its
/// size, which is the area the image is drawn over (its own grid may hold fewer cells than the
/// rect has pixels: see `kDensityCellCap`). They are two uniforms rather than a rect because a
/// `vec4` uniform would need a fourth setter shape for one caller; a corner and a size are what
/// the vertex shader builds the quad from anyway.
void set_rect(Vec2 origin, Vec2 size) const noexcept;
/// `u_color`: the tint the cells' luminances are multiplied into.
void set_color(Color color) const noexcept;
+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
/// 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.
/// neither, so the numbers a host reads back and the picture it sees are one derivation. The
/// texture is magnified over the axes rect with a linear filter, because past `kDensityCellCap` a
/// 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);
/// 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()`.
/// address are the ones already uploaded) and draws it over the axes rect in `tint` -- the rect,
/// 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,
const PlotLayout& layout);
/// `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).
///
/// Uniforms: `u_viewport` (vec2, pixels), `u_origin` (vec2, the quad's top-left corner in `Rect`
/// pixels), `u_size` (vec2, its size in pixels -- the grid's own columns and rows, so one texel
/// is one pixel on screen and no filtering is needed), `u_color` (vec4), `u_density` (sampler2D,
/// unit 0).
/// pixels), `u_size` (vec2, its size in pixels -- the axes rect the image is drawn over, which the
/// texture's cells are spread across: one texel a cell while the rect is under `kDensityCellCap`,
/// 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
/// is the whole of the brightness mapping: a cell's count was turned into a luminance byte on the