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:
1 parent
1b8b55d3ee
commit
f79a97be9b
12 files changed
+414
-108
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user