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
@@ -339,15 +339,31 @@ The split from the grading above is what it costs: grading measures *one* frame'
throws it away, while the density **keeps** what it measured — that is the whole point of it, and
also why it cannot be a per-frame accumulator.
- **The grid is the screen's, not the data's, and there is one per trace.** One `f32` cell per
device pixel of the axes rect (`DensityGrid`, `plot/plot/density.hpp`): the axes of a 640x360
viewport is a **584x458** grid of **267 472 cells** and **1 069 888 bytes**, and a thousand
acquisitions through it cost the same. A plot holds one of these for **each trace it draws**, so
the display's memory is that figure times the traces on screen — four signals in one 800x480 row
are four grids, 6 MB; a row of eight at 1920x1080 is 66 MB. The colour is the reason: a density
that had one hue for the plot could not say *which* trace put the ink there, and two signals
agreeing in a cell would read as one blended pixel. `Plot::density_grid(trace)` returns a trace's
image; a trace with no series (an unbound slot) holds no grid at all.
- **The grid is the screen's, not the data's — bounded — and there is one per trace.** The axes
rect in device pixels (`DensityGrid`, `plot/plot/density.hpp`), one `f32` cell each, **up to
`kDensityCellCap` = 262 144 cells** (2^18, a mebibyte of counts): under the cap the grid *is* the
rect — the axes of a 640x360 viewport is a **573x313** grid of **179 349 cells** and **717 396
bytes**, and a thousand acquisitions through it cost the same — and past it the rect is scaled
down *uniformly*, so a cell stays square and the picture keeps the axes' aspect. A plot holds one
of these for **each trace it draws**, so the display's memory is that figure times the traces on
screen; the bound is per *grid*, so four traces hold at most 1 MiB each whatever the window is.
The colour is the reason for the per-trace split: a density that had one hue for the plot could
not say *which* trace put the ink there, and two signals agreeing in a cell would read as one
blended pixel. `Plot::density_grid(trace)` returns a trace's image; a trace with no series (an
unbound slot) holds no grid at all.
The cap is what keeps the resolution from following the window, and the window is not a fixed
thing: a 1520x850-logical window at 2x device scale has a 2877-pixel-wide axes rect, and four
rows of one trace are **4.5 M cells** — 18 MB of counts, every one of them walked per frame. The
pass is `O(cells)` in all three places it happens, so an unbounded image makes the *frame* cost a
function of the window: measured on llvmpipe (four traces, one a row, a 2 s window, a 3040x1700
framebuffer) the grid pass alone was **12.9 ms of tick and 12.8 ms of draw a frame** and 4.9 MB
uploaded, against **3.9 ms / 5.6 ms** and 1.0 MB at the cap. The renderer magnifies the capped
image back over the rect with a **linear** filter, because a cell is a *count* — a continuous
quantity — and a fragment between two of them is between their two ink levels, which is what a
linear tap returns and a point sample cannot (it would show the cell grid as blocks once a cell
is several pixels wide). A one-cell-tall feature is thereby spread over its neighbours, which is
what any magnification of an image does.
- **A cell's value is a number of sweeps**, and a trace covering it during one
`Plot::accumulate_density()` leaves 1.0, **once however many of its own samples crossed it**.
Counting the hits instead puts a single dense sweep at the ceiling of the brightness scale and
@@ -383,20 +399,37 @@ also why it cannot be a per-frame accumulator.
reads back through `DensityGrid::ink()`/`peak()` and the picture it sees come from the same
numbers.
- **Per frame, it is one texture upload and one draw call *per trace* — or one draw call each.**
The upload is one byte per cell (**179 kB** for the axes above); the one texture is re-filled per
trace, and a frame whose grids did not move skips the conversion and the uploads entirely and
pays the quads alone (`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes
the grids move every frame under `TimeConstant`, and what makes them stand still between
acquisitions under `PerAcquisition`.
The upload is one byte per cell (**179 kB** for the axes above, and at most 256 kB a trace at
`kDensityCellCap`, whatever the window); the one texture is re-filled per trace, and a frame whose
grids did not move skips the conversion and the uploads entirely and pays the quads alone
(`FrameStats::density_upload_bytes` reads 0 for it). The decay is what makes the grids move every
frame under `TimeConstant`, and what makes them stand still between acquisitions under
`PerAcquisition`.
A `TimeConstant` fade therefore does pay the conversion and the upload every frame, whatever the
bound — a *bounded* bill rather than a free one. Applying the fade on the GPU instead was
considered and is a **larger** change than this bound and was deliberately not bundled with it:
the image would have to live in a ping-pong target the decay multiplies in place, while the
*count* stays on the CPU, because that is what a host reads back through `ink()`/`peak()`, what
the deposit adds whole swaths to, and what the per-sweep dedupe keys on — so the two would have
to be reconciled per frame anyway. The bound is what makes that reconciliation a fixed cost.
`a_density_grid_is_the_axes_in_device_pixels` reads the resolution and the memory back
(**584x458 = 267 472 cells, 1 069 888 bytes**, `ink=584 peak=1` for one flat sweep — the ink of
one sweep *is* the columns it covers),
(**578x453 = 261 834 cells, 1 047 336 bytes** on its 640x480 viewport, which is past the cap, and
the 320x240 leg it ends on is the axes rect *exactly*, one cell a device pixel —
`ink=578 peak=1` for one flat sweep, the ink of one sweep *is* the columns it covers),
`a_density_grid_is_capped_whatever_the_window` reads the bound itself off a 3840x2160 viewport
(**681x384 = 261 504 cells = 1 046 016 bytes of a 3784x2138 axes**: the cells never exceed
`kDensityCellCap`, the grid is within 1 % of the whole budget and its aspect is the axes' — the
uniform scaling), and `a_capped_density_is_magnified_over_the_axes_rect` reads the *screen* side of
it (**569x460 = 261 740 cells of an 832x673 axes**: the image covers all **832** of the rect's
columns rather than the 569 cells it holds — the assertion a quad drawn at the grid's own size
fails — the sweep stays on the row its transform maps it to, and its ink is most of the trace's own
colour, `green=195` against a background of `65`, the rest being the magnification's blend),
`a_density_halves_per_acquisition_and_keeps_the_newest_brightest` reads the count schedule
(584 → 876 → 1022 on a 584-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
(578 → 867 → 1012 on a 578-column grid, i.e. `1 + 1/2 + 1/4` of the width, `peak_steady=2.000`),
`a_density_fades_by_its_time_constant` reads `exp(-dt/tau)` off the ink,
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**267 472 cells each,
ink 584/584**, in two different rows), and
`a_density_holds_one_grid_per_trace` reads two traces' grids apart (**261 834 cells each,
ink 578/578**, in two different rows), and
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the frame: `cells=179349 uploaded=179349`
on the frame the grid moved and `uploaded=0` on the identical one after it; a saturated cell is the
trace's own colour exactly (`full=224`, the trace's `0xE0` green, against `bg=25`, where the old