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

+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