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

+84 -14
View File
@@ -2839,14 +2839,34 @@ struct DensityFixture {
} // namespace
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect, and
/// nothing at all until the density is switched on. The figures here are the ones the API's
/// memory claim rests on -- `cell_count * 4` bytes, whatever the trigger has done.
/// The grid is the screen's, not the data's: one cell per device pixel of the axes rect **up to
/// `kDensityCellCap`**, and nothing at all until the density is switched on. The figures here are
/// 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) {
DensityFixture f;
const plot::Rect axes = f.plot.layout().axes;
const plot::u32 columns = static_cast<plot::u32>(std::ceil(axes.w));
const plot::u32 rows = static_cast<plot::u32>(std::ceil(axes.h));
// The 640x480 viewport this fixture asks for is past the cap, so its grid is the axes rect
// *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
// 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.rows() == 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);
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",
grid.columns(), grid.rows(), grid.cell_count(), bytes, grid.ink(),
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.
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;
for (plot::u32 r = 0; r < grid.rows(); ++r) {
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);
// 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.layout();
f.plot.accumulate_density();
PLOT_CHECK(f.plot.density_grid(0u).columns() < columns);
PLOT_CHECK_NEAR(f.plot.density_grid(0u).ink(),
static_cast<double>(f.plot.density_grid(0u).columns()), 1.0);
const plot::DensityGrid& small = f.plot.density_grid(0u);
const plot::Rect small_axes = f.plot.layout().axes;
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
@@ -3045,11 +3106,15 @@ PLOT_TEST(a_density_deposits_only_where_the_trace_is) {
PLOT_CHECK(grid.ink() >= static_cast<double>(grid.columns()));
const plot::Transform ty = split.plot.y_transform(split.ay);
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 bottom edge -- lands in the last row rather than one past the end.
const auto row_of = [&](plot::f64 y) {
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));
};
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::Transform tx = gap.plot.x_transform(gap.ax);
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(
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));
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
@@ -3123,8 +3191,10 @@ PLOT_TEST(a_density_holds_one_grid_per_trace) {
const plot::Transform ty = p.y_transform(ay);
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) {
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 hi_row = row_of(0.5);