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
+33
-8
@@ -404,9 +404,25 @@ void Plot::clear_density() noexcept {
|
||||
bool Plot::size_density_grids() {
|
||||
const Rect& axes = layout_.axes;
|
||||
if (!(axes.w >= 1.0f) || !(axes.h >= 1.0f)) return false;
|
||||
const u32 columns = static_cast<u32>(std::ceil(axes.w));
|
||||
const u32 rows = static_cast<u32>(std::ceil(axes.h));
|
||||
u32 columns = static_cast<u32>(std::ceil(axes.w));
|
||||
u32 rows = static_cast<u32>(std::ceil(axes.h));
|
||||
if (columns == 0u || rows == 0u) return false;
|
||||
// **The grid is the axes rect in device pixels, up to `kDensityCellCap`.** Beyond it the rect is
|
||||
// scaled down *uniformly*, so the cells stay square in device pixels and the picture keeps the
|
||||
// axes' aspect: a cap on one axis alone would stretch a cell, and a density smeared in one
|
||||
// direction only reads as a bug in the data rather than as a coarse image. The cells are then no
|
||||
// longer one device pixel across, which is why the renderer magnifies the image with a linear
|
||||
// filter instead of a point sample -- see `Renderer::draw_density_grid()`.
|
||||
//
|
||||
// The floor of the product is what makes the cap hold: `floor(w*s) * floor(h*s) <= w*s * h*s
|
||||
// <= cap` for the `s` chosen, and the two `max(1, ...)` keep a very thin axes rect from
|
||||
// rounding to nothing.
|
||||
const u64 pixels = static_cast<u64>(columns) * static_cast<u64>(rows);
|
||||
if (pixels > static_cast<u64>(kDensityCellCap)) {
|
||||
const f64 scale = std::sqrt(static_cast<f64>(kDensityCellCap) / static_cast<f64>(pixels));
|
||||
columns = std::max(1u, static_cast<u32>(static_cast<f64>(columns) * scale));
|
||||
rows = std::max(1u, static_cast<u32>(static_cast<f64>(rows) * scale));
|
||||
}
|
||||
// **One grid per trace**, because a density is drawn in the colour of the trace that laid it
|
||||
// down (see `DensityConfig`). The vector tracks the trace list, so a trace added after the first
|
||||
// accumulation gets its own empty grid here while the grids already sized keep what they held.
|
||||
@@ -557,10 +573,19 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
|
||||
if (density_stamp_.size() < rows) return; // sized with the grid; nothing deposits before that
|
||||
if (!std::isfinite(x0) || !std::isfinite(y0) || !std::isfinite(x1) || !std::isfinite(y1)) return;
|
||||
const Rect& axes = layout_.axes;
|
||||
// **The image's footprint is the axes rect rounded up to whole device pixels** -- the rect
|
||||
// `size_density_grids()` counted the cells on -- so `sx`/`sy` are exactly **1** while that rect is
|
||||
// under `kDensityCellCap`: a cell *is* a device pixel and this is the arithmetic the density has
|
||||
// always used. Past the cap they are a fraction of one, and the cell index is read through the
|
||||
// scale rather than straight off the pixel. (The rounding is why the footprint, not the
|
||||
// fractional rect, is the reference: a cell is defined on whole pixels, and a grid of `ceil(w)` of
|
||||
// them covers the rect the axes was laid out with.)
|
||||
const f32 sx = axes.w > 0.0f ? static_cast<f32>(columns) / std::ceil(axes.w) : 1.0f;
|
||||
const f32 sy = axes.h > 0.0f ? static_cast<f32>(rows) / std::ceil(axes.h) : 1.0f;
|
||||
const f32 lo_x = std::min(x0, x1);
|
||||
const f32 hi_x = std::max(x0, x1);
|
||||
i32 c0 = static_cast<i32>(std::floor(lo_x - axes.x));
|
||||
i32 c1 = static_cast<i32>(std::floor(hi_x - axes.x));
|
||||
i32 c0 = static_cast<i32>(std::floor((lo_x - axes.x) * sx));
|
||||
i32 c1 = static_cast<i32>(std::floor((hi_x - axes.x) * sx));
|
||||
// Clipped to the axes' columns *and* to the grid: a segment reaching past the right edge keeps
|
||||
// the part that is inside, and one entirely outside deposits nothing at all. The clamp is what
|
||||
// keeps ink off the edge column when a trace leaves the frame sideways.
|
||||
@@ -575,12 +600,12 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
|
||||
// along the segment. Every column's span meets its neighbour's end exactly (the y is linear in
|
||||
// x), so the deposit is a continuous trace and not a dotted one.
|
||||
const auto column_y_span = [&](i32 column, f32& out_lo, f32& out_hi) {
|
||||
const f32 ca = axes.x + static_cast<f32>(column);
|
||||
const f32 ca = axes.x + static_cast<f32>(column) / sx;
|
||||
f32 ya = y0;
|
||||
f32 yb = y1;
|
||||
if (span > 0.0f && denom != 0.0f) {
|
||||
const f32 a = std::max(ca, lo_x);
|
||||
const f32 b = std::min(ca + 1.0f, hi_x);
|
||||
const f32 b = std::min(ca + 1.0f / sx, hi_x);
|
||||
ya = y0 + (a - x0) / denom * (y1 - y0);
|
||||
yb = y0 + (b - x0) / denom * (y1 - y0);
|
||||
}
|
||||
@@ -599,8 +624,8 @@ void Plot::deposit_density_segment(f32 x0, f32 y0, f32 x1, f32 y1, DensityGrid&
|
||||
// One ULP inside the bottom edge, so a segment ending exactly on it lands in the last row
|
||||
// rather than one past the end.
|
||||
const f32 bottom = std::min(y_hi, std::nextafter(axes.bottom(), axes.y));
|
||||
i32 r0 = std::clamp(static_cast<i32>(std::floor(top - axes.y)), 0, last_row);
|
||||
i32 r1 = std::clamp(static_cast<i32>(std::floor(bottom - axes.y)), 0, last_row);
|
||||
i32 r0 = std::clamp(static_cast<i32>(std::floor((top - axes.y) * sy)), 0, last_row);
|
||||
i32 r1 = std::clamp(static_cast<i32>(std::floor((bottom - axes.y) * sy)), 0, last_row);
|
||||
// **One pass per cell per sweep, not per hit.** A column of a dense window is crossed hundreds
|
||||
// of times by one acquisition's own samples, and counting each crossing would put a single
|
||||
// sweep at the ceiling of the brightness scale: the newer sweeps behind it would be invisible,
|
||||
|
||||
+16
-8
@@ -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);
|
||||
|
||||
@@ -241,7 +241,7 @@ const char* const kDensityVS = R"(#version 330 core
|
||||
// fill. The density's own bytes go to a texture (see kDensityFS), never to a buffer.
|
||||
uniform vec2 u_viewport; // render target size, pixels
|
||||
uniform vec2 u_origin; // the quad's top-left corner in Rect pixels
|
||||
uniform vec2 u_size; // the quad's size in pixels -- the grid's columns and rows
|
||||
uniform vec2 u_size; // the quad's size in pixels -- the axes rect, which the image fills
|
||||
|
||||
out vec2 v_uv;
|
||||
|
||||
@@ -253,9 +253,9 @@ void main() {
|
||||
// Rect is top-left origin, GL is bottom-left: the one and only y flip.
|
||||
vec2 ndc = vec2(px.x / u_viewport.x * 2.0 - 1.0, 1.0 - px.y / u_viewport.y * 2.0);
|
||||
gl_Position = vec4(ndc, 0.0, 1.0);
|
||||
// u_size is the grid's own size in pixels and the texture is one texel per cell, so one
|
||||
// unit of uv is exactly one cell and a GL_NEAREST fetch lands on the cell the fragment is
|
||||
// inside -- there is nothing to filter and nothing to round.
|
||||
// uv runs 0..1 across the whole image, so a fragment lands wherever the axes rect puts it
|
||||
// between the grid's cells: one texel a cell while the rect is under the cap (a cell a device
|
||||
// pixel, the sampling all but a point sample) and a linear magnification of the image past it.
|
||||
v_uv = corner;
|
||||
}
|
||||
)";
|
||||
@@ -263,7 +263,7 @@ void main() {
|
||||
const char* const kDensityFS = R"(#version 330 core
|
||||
in vec2 v_uv; // cell coordinate in [0,1] across the quad
|
||||
|
||||
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_NEAREST
|
||||
uniform sampler2D u_density; // GL_R8 luminance, one texel per cell, GL_LINEAR
|
||||
uniform vec4 u_color;
|
||||
|
||||
out vec4 o_color;
|
||||
|
||||
Reference in new issue
Block a user