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

+16 -8
View File
@@ -1806,10 +1806,15 @@ void Renderer::draw_density_grid(const DensityConfig& config, const DensityGrid&
gl_error_ = true;
return;
}
// One texel per cell and a quad exactly that many pixels across, so every fragment lands
// inside its own cell: a point sample is the whole of the filtering this needs, and any
// other mode would blend two sweeps' counts at the cell walls.
density_texture_.set_filter(gl::GL_NEAREST, gl::GL_NEAREST);
// **Linear, and it used to be a point sample.** The filter is the thing that makes a cell
// bigger than a pixel acceptable: a cell is a *count*, a continuous quantity, so a fragment
// between two of them is between their two ink levels -- which is exactly what a linear tap
// 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_h_ = rows;
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;
}
// The quad covers the cells, not the axes rect's own fractional size: `columns`/`rows` are that
// rect rounded up, so mapping uv over them puts texel i exactly on device pixel `axes.x + i`.
// Drawing the un-rounded rect would stretch the last row and column of cells over the fraction.
// The quad covers the image's own **footprint**: the axes rect rounded up to whole device pixels,
// the rect `Plot::size_density_grids()` counted the cells on. While that rect is under
// `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_.set_viewport({viewport_.w, viewport_.h});
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
// trace at its own brightness, and the shader multiplies it by the cell's luminance.
density_program_.set_color(tint);