Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f79a97be9b |
No files matched your search
@@ -59,15 +59,19 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
||||
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
|
||||
threshold;
|
||||
- a **phosphor density** a host can turn on for a plot (`Plot::set_density()`, an image rather
|
||||
than a set of retained traces): **one grid per trace**, in that trace's own colour, one `f32`
|
||||
cell per device pixel of the axes rect, drawn *in place of* the traces -- a host may not also
|
||||
draw them over it -- into which each acquisition is deposited, 1.0 per cell a trace crosses, so
|
||||
brightness is how often it passed. It fades either by a **time constant** in stream seconds or by
|
||||
a **per-acquisition factor**, and the newest acquisition is deposited like every other, so it
|
||||
fades too. Memory is the screen's times the traces on screen (a 640x360 viewport's axes is
|
||||
179 349 cells per trace, 717 kB), the deposit walks the axes' columns rather than
|
||||
the samples, and a frame whose grids did not move costs one draw call per trace
|
||||
(`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
|
||||
than a set of retained traces): **one grid per trace**, in that trace's own colour, the axes
|
||||
rect in device pixels, drawn *in place of* the traces -- a host may not also draw them over it --
|
||||
into which each acquisition is deposited, 1.0 per cell a trace crosses, so brightness is how
|
||||
often it passed. It fades either by a **time constant** in stream seconds or by a
|
||||
**per-acquisition factor**, and the newest acquisition is deposited like every other, so it
|
||||
fades too. The resolution is bounded (`kDensityCellCap`, 262 144 cells -- a mebibyte of counts --
|
||||
a grid, past which the rect is scaled down uniformly and the image magnified back over it with a
|
||||
linear filter), because the per-frame pass is `O(cells)` and the window is not bounded at all:
|
||||
memory is the cap times the traces on screen times 4 bytes, whatever the window (a 640x360
|
||||
viewport's axes is 179 349 cells per trace, 717 kB, under it, while four rows of one trace in a
|
||||
3040x1700 one used to be 4.5 M cells and 18 MB between them). The deposit walks the axes'
|
||||
columns rather than the samples, and a frame whose grids did not move costs one draw call per
|
||||
trace (`FrameStats::density_cells`/`density_upload_bytes`); see `docs/data-and-performance.md`;
|
||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
||||
(`Style::marker_below_samples`, 50 by default, `marker_px` across, in the trace's own colour);
|
||||
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
|
||||
namespace plot {
|
||||
|
||||
/// The most cells one density grid may hold: **262 144**, a mebibyte of `f32` counts.
|
||||
///
|
||||
/// A grid is the axes rect in device pixels -- the screen's resolution and not the data's, which is
|
||||
/// the whole point of accumulating into a grid -- but the screen is not a fixed thing. A window can
|
||||
/// be dragged to any width, and at 2x device scale the axes rects of a 1520x850-logical window are
|
||||
/// 2877 device pixels wide each: four rows of one trace are **4.5 M cells**, 18 MB of counts
|
||||
/// between them, and every one of them is walked per frame. That walk 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. So a resolution tied to the window is a per-frame
|
||||
/// cost tied to the window, with no ceiling -- measured headless on llvmpipe with four rows of one
|
||||
/// trace, a 2 s window and a 3040x1700 framebuffer: **4.5 M cells, 12.9 ms of tick and 12.8 ms of
|
||||
/// draw a frame, 4.9 MB uploaded**.
|
||||
///
|
||||
/// This is the ceiling, in **cells** rather than in pixels: the axes rect is scaled down, uniformly,
|
||||
/// until it fits, so a cell stays square and the picture keeps the axes' aspect (see `DensityGrid`).
|
||||
/// The number is a frame's budget read off that same measurement -- the same four rows at the cap
|
||||
/// are 1.05 M cells and **3.9 ms of tick and 5.6 ms of draw**, a third and a half of the unbounded
|
||||
/// grid's pass -- while the image is still 681 columns across on a 3784-pixel axes rect. It is a
|
||||
/// **cap** and not a resolution: a grid whose axes rect is under it is still exactly that rect in
|
||||
/// device pixels, and only an axes rect of more than a quarter of a megapixel -- a 4K window's, or
|
||||
/// one row of a tall one -- is scaled.
|
||||
inline constexpr usize kDensityCellCap = usize{1} << 18;
|
||||
|
||||
/// Which way a plot's **phosphor density** fades between the acquisitions drawn into it.
|
||||
///
|
||||
/// The two are answers to two different questions, which is why the choice belongs to the
|
||||
@@ -89,17 +112,23 @@ struct DensityConfig {
|
||||
f32 gamma = 1.0f;
|
||||
};
|
||||
|
||||
/// The density's own image: **one `f32` cell per device pixel of the axes rect**, so its
|
||||
/// resolution is the screen's and not the data's -- the whole point of accumulating into a grid
|
||||
/// rather than keeping the acquisitions that were drawn into it. Memory is therefore
|
||||
/// `columns() * rows() * 4` bytes whatever the trigger has done: an 800x480 axes is 1.5 MB and a
|
||||
/// 1920x1080 one is 8.3 MB, and a thousand acquisitions cost the same as one. A plot holds **one
|
||||
/// grid per trace** (see `DensityConfig`), so a display's memory is that figure times the number of
|
||||
/// traces actually drawn -- a plot's unbound slots hold no grid -- and four signals in one 800x480
|
||||
/// row are four grids, 6 MB, while a 1920x1080 row of eight is 66 MB. The renderer adds one byte
|
||||
/// per cell as the luminance it uploads (`DensityGrid::cells()`
|
||||
/// is `f32` because a **count** is what decays and what `ink()`/`peak()` report; the byte is
|
||||
/// derived per changed frame, not stored here).
|
||||
/// The density's own image: **the axes rect in device pixels, up to `kDensityCellCap` cells**, so
|
||||
/// its resolution is the screen's and not the data's -- the whole point of accumulating into a grid
|
||||
/// rather than keeping the acquisitions that were drawn into it -- while still being bounded by
|
||||
/// something other than the window a user has dragged. Under the cap the grid *is* the axes rect and
|
||||
/// a cell is a device pixel; past it the rect is scaled down uniformly (see `kDensityCellCap` for
|
||||
/// the number and the frame budget it comes from), and the renderer magnifies the image back over
|
||||
/// the rect with a linear filter, because a cell is a count of sweeps and not a pixel of a picture.
|
||||
///
|
||||
/// The bound is what makes the memory and the per-frame cost *numbers* rather than functions of the
|
||||
/// window: `columns() * rows() * 4` bytes whatever the trigger has done **and** whatever the
|
||||
/// window is, at most **262 144 cells, 1 048 576 bytes** a grid -- against the 4.5 MB a trace a
|
||||
/// 3040x1700 framebuffer's axes used to ask for, and the 26 ms of tick and draw a frame the pass
|
||||
/// over four of them then cost. A plot holds **one grid per trace** (see `DensityConfig`), so a
|
||||
/// display's memory is that figure times the traces actually drawn -- a plot's unbound slots hold no
|
||||
/// grid -- and four traces hold at most 4 MiB between them. The renderer adds one byte per cell as
|
||||
/// the luminance it uploads (`DensityGrid::cells()` is `f32` because a **count** is what decays and
|
||||
/// what `ink()`/`peak()` report; the byte is derived per changed frame, not stored here).
|
||||
///
|
||||
/// A cell's value is a **number of sweeps**: every visible trace of the plot that covers that cell
|
||||
/// during one `Plot::accumulate_density()` leaves 1.0 in it -- once, however many of that trace's
|
||||
@@ -109,21 +138,25 @@ struct DensityConfig {
|
||||
/// the whole image, so a cell's value is a decaying count of sweeps and not a bounded score.
|
||||
///
|
||||
/// The grid is indexed row-major from the axes rect's **top-left** corner: cell `(c, r)` is at
|
||||
/// `r * columns() + c` and covers the device pixel `(axes.x + c, axes.y + r)`, where `axes` is
|
||||
/// the rect the grid was sized to. `revision()` moves on every change, which is what lets the
|
||||
/// renderer skip the upload -- and the whole conversion -- on a frame the grid did not move.
|
||||
/// `r * columns() + c` and covers the device span `[axes.x + c*w/columns(), axes.x + (c+1)*w/columns())`
|
||||
/// by `[axes.y + r*h/rows(), axes.y + (r+1)*h/rows())` -- the device pixel `(axes.x + c, axes.y + r)`
|
||||
/// while the rect is under the cap, and a block of them when the plot scaled the image down.
|
||||
/// `revision()` moves on every change, which is what lets the renderer skip the upload -- and the
|
||||
/// whole conversion -- on a frame the grid did not move.
|
||||
class DensityGrid {
|
||||
public:
|
||||
/// Grid width in cells: the axes rect's width in device pixels, rounded up. 0 until the first
|
||||
/// accumulation on a laid-out plot.
|
||||
/// Grid width in cells: the axes rect's width in device pixels, rounded up, scaled down when the
|
||||
/// rect is past `kDensityCellCap` (see the class comment). 0 until the first accumulation on a
|
||||
/// laid-out plot.
|
||||
u32 columns() const noexcept { return columns_; }
|
||||
/// Grid height in cells, the axes rect's height in device pixels: `columns() * rows()` cells.
|
||||
/// Grid height in cells: the axes rect's height in device pixels, rounded up, scaled down with
|
||||
/// `columns()`: `columns() * rows()` cells.
|
||||
u32 rows() const noexcept { return rows_; }
|
||||
usize cell_count() const noexcept {
|
||||
return static_cast<usize>(columns_) * static_cast<usize>(rows_);
|
||||
}
|
||||
/// The cells, row-major from the top-left, one per device pixel -- see the class comment for
|
||||
/// the resolution and what one cell's value means.
|
||||
/// The cells, row-major from the top-left, one per cell of the axes rect -- see the class comment
|
||||
/// for the resolution and what one cell's value means.
|
||||
std::span<const f32> cells() const noexcept { return cells_; }
|
||||
/// One cell, or 0.0 outside the grid.
|
||||
f32 at(u32 column, u32 row) const noexcept {
|
||||
|
||||
+18
-14
@@ -590,8 +590,8 @@ class Plot {
|
||||
/// default -- is the plot exactly as it was: no grid is allocated, no geometry is deposited
|
||||
/// and no frame uploads anything.
|
||||
///
|
||||
/// On, the plot keeps a **screen-sized intensity grid behind each of its traces**, in that
|
||||
/// trace's own colour (see `DensityGrid` for the resolution and per-cell meaning, and
|
||||
/// On, the plot keeps an intensity grid behind each of its traces, in that trace's own colour
|
||||
/// (see `DensityGrid` for the resolution, the bound on it and the per-cell meaning, and
|
||||
/// `DensityConfig` for the per-trace colour and the memory the grids cost), and deposits the
|
||||
/// acquisition on screen into it with `accumulate_density()`. While a density is on the
|
||||
/// renderer draws **no solid trace** over it: the trace ink *is* the image, so the newest
|
||||
@@ -603,8 +603,10 @@ class Plot {
|
||||
///
|
||||
/// Like `set_invalid_spans()` it reserves **no layout space** and nothing on it can be picked:
|
||||
/// it is drawn inside the axes it was sized to, and a pointer neither finds it nor is found by
|
||||
/// it. Unlike a band it needs no one-pixel floor on its own size -- a cell *is* a device pixel
|
||||
/// by construction, which is the smallest mark the rule could ask for.
|
||||
/// it. A cell is a device pixel of that axes rect while the rect is under `kDensityCellCap` and
|
||||
/// a block of them past it, which the renderer scales back up with a linear filter -- the image
|
||||
/// reserves no space either way, so the bound changes its resolution and nothing about the
|
||||
/// layout.
|
||||
///
|
||||
/// **Disabling drops what was accumulated at once**, not at the next acquisition: a caller who
|
||||
/// has stopped paying for the display is not left holding a stale image of it, which is the
|
||||
@@ -640,11 +642,12 @@ class Plot {
|
||||
/// a layout: a plot that has never been laid out has no axes rect to size the grid to and
|
||||
/// deposits nothing.
|
||||
///
|
||||
/// The grid is sized to that axes rect, **one `f32` cell per device pixel**, and a grid of the
|
||||
/// wrong size is reallocated -- which drops what it held, because the image is a screen-sized
|
||||
/// one and a new screen is a new image. Under `DensityDecay::PerAcquisition` the fade happens
|
||||
/// here, before the deposit, so the acquisition just drawn is the brightest. A NaN sample is a
|
||||
/// gap: it deposits nothing and joins nothing, the library-wide policy.
|
||||
/// The grid is sized to that axes rect, **the rect in device pixels up to `kDensityCellCap`
|
||||
/// cells** (`DensityGrid`), and a grid of the wrong size is reallocated -- which drops what it
|
||||
/// held, because the image is of the axes rect and a new rect is a new image. Under
|
||||
/// `DensityDecay::PerAcquisition` the fade happens here, before the deposit, so the acquisition
|
||||
/// just drawn is the brightest. A NaN sample is a gap: it deposits nothing and joins nothing,
|
||||
/// the library-wide policy.
|
||||
void accumulate_density();
|
||||
|
||||
/// Fades the grid by `dt_s` of **stream seconds** -- the clock the x axis reads, so a caller
|
||||
@@ -1075,11 +1078,12 @@ class Plot {
|
||||
std::vector<Region> regions_;
|
||||
std::vector<InvalidSpan> invalid_spans_;
|
||||
/// The phosphor density: its configuration, the grids it accumulates into, and the scratch the
|
||||
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is
|
||||
/// screen-sized but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
|
||||
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth. The vector tracks
|
||||
/// `traces_` and outlives the frames its images were drawn in, which is the whole point of
|
||||
/// accumulating into a screen-sized image rather than retaining traces.
|
||||
/// one visible window being deposited is gathered into. **One grid per trace** -- the image is of
|
||||
/// the axes rect but each trace's is tinted with that trace's own colour (see `DensityConfig`), so
|
||||
/// the memory is `traces * columns * rows * 4` bytes and not one plot's worth -- and each is
|
||||
/// bounded by `kDensityCellCap`. The vector tracks `traces_` and outlives the frames its images
|
||||
/// were drawn in, which is the whole point of accumulating into an image rather than retaining
|
||||
/// traces.
|
||||
DensityConfig density_{};
|
||||
std::vector<DensityGrid> density_grids_;
|
||||
/// Scratch for `deposit_density_trace()`: interleaved (x, y) of one gathered window, re-used
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+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;
|
||||
|
||||
+84
-14
@@ -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);
|
||||
|
||||
@@ -7725,3 +7725,127 @@ PLOT_TEST(a_phosphor_density_is_the_trace_in_its_own_colour) {
|
||||
bg_g, cell_g, faded_g, steady_g, quarter_g, before, after);
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
/// A grid **past `kDensityCellCap`** is the axes rect scaled down, and the frame magnifies the image
|
||||
/// back over that rect: this is the screen-side half of the bound --
|
||||
/// `a_density_grid_is_capped_whatever_the_window` reads the counts of a capped grid, and the two
|
||||
/// agree only if the deposit and the draw use the same footprint, the axes rect in *whole* device
|
||||
/// pixels.
|
||||
///
|
||||
/// What is read here is what a wrong scale moves: the image covers the rect's own columns rather
|
||||
/// than the smaller grid's (the assertion a quad drawn at the grid's size fails), it stays inside
|
||||
/// the rect, and the sweep is on the row its own transform maps y = 0 to, in the trace's colour.
|
||||
PLOT_TEST(a_capped_density_is_magnified_over_the_axes_rect) {
|
||||
const plot::u32 w = 900, h = 720;
|
||||
plottest::HeadlessContext ctx(w, h);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
plot::data::ArraySeries s(std::vector<plot::f32>{0.0f, 0.0f}, 0.0, 1.0);
|
||||
plot::Plot p;
|
||||
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||
const plot::Color colour = plot::Color::hex(0x00E070);
|
||||
PLOT_CHECK(p.add_trace({&s, colour, 1.5f, ax, ay, "flat"}) == 0u);
|
||||
p.set_x_range(ax, {0.0, 1.0});
|
||||
p.set_y_range(ay, {-1.0, 1.0});
|
||||
|
||||
const plot::Rect frame{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
|
||||
r.begin_frame(w, h, frame);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const std::vector<plot::u8> without = read_frame(ctx);
|
||||
|
||||
plot::DensityConfig cfg;
|
||||
cfg.enabled = true;
|
||||
p.set_density(cfg);
|
||||
p.accumulate_density();
|
||||
const plot::DensityGrid& grid = p.density_grid(0u);
|
||||
const plot::Rect axes = p.layout().axes;
|
||||
// The rect is past the cap, so the grid is smaller than it and the frame has to scale the image
|
||||
// up -- and the one flat sweep still lights every column, once.
|
||||
PLOT_CHECK(grid.cell_count() > 0u);
|
||||
PLOT_CHECK(grid.cell_count() <= plot::kDensityCellCap);
|
||||
PLOT_CHECK(static_cast<plot::u64>(grid.columns()) < static_cast<plot::u64>(std::ceil(axes.w)));
|
||||
PLOT_CHECK(static_cast<plot::u64>(grid.rows()) < static_cast<plot::u64>(std::ceil(axes.h)));
|
||||
PLOT_CHECK_NEAR(grid.ink(), static_cast<double>(grid.columns()), 1.0);
|
||||
|
||||
r.begin_frame(w, h, frame);
|
||||
r.draw(p);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
const plot::FrameStats stats = r.stats();
|
||||
PLOT_CHECK(stats.density_cells == static_cast<plot::u32>(grid.cell_count()));
|
||||
PLOT_CHECK(stats.density_upload_bytes == static_cast<plot::u32>(grid.cell_count()));
|
||||
const std::vector<plot::u8> capped = read_frame(ctx);
|
||||
|
||||
const auto differs = [&](plot::u32 x, plot::u32 y) {
|
||||
const plot::usize k = (plot::usize(y) * w + x) * 4u;
|
||||
return capped[k] != without[k] || capped[k + 1u] != without[k + 1u] ||
|
||||
capped[k + 2u] != without[k + 2u];
|
||||
};
|
||||
const auto at = [&](const std::vector<plot::u8>& f, plot::u32 x, plot::u32 y, int c) {
|
||||
return static_cast<int>(f[(plot::usize(y) * w + x) * 4u + static_cast<plot::usize>(c)]);
|
||||
};
|
||||
|
||||
// **The image covers the rect's columns, not the grid's.** A quad drawn at the grid's own size --
|
||||
// the temptation once a cell is no longer a pixel -- would paint the left `columns` of the rect
|
||||
// and leave the rest of it blank, which this counts.
|
||||
plot::u32 changed_cols = 0;
|
||||
plot::u32 lo_x = w, hi_x = 0, lo_y = h, hi_y = 0;
|
||||
for (plot::u32 x = 0; x < w; ++x) {
|
||||
bool any = false;
|
||||
for (plot::u32 y = 0; y < h; ++y) {
|
||||
if (!differs(x, y)) continue;
|
||||
any = true;
|
||||
lo_x = std::min(lo_x, x);
|
||||
hi_x = std::max(hi_x, x);
|
||||
lo_y = std::min(lo_y, y);
|
||||
hi_y = std::max(hi_y, y);
|
||||
}
|
||||
if (any) ++changed_cols;
|
||||
}
|
||||
PLOT_CHECK(changed_cols > 0u);
|
||||
PLOT_CHECK(changed_cols + 2u >= static_cast<plot::u32>(axes.w));
|
||||
// ... and it stays inside the rect it is an image of.
|
||||
PLOT_CHECK(static_cast<plot::f32>(lo_x) >= axes.x - 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(hi_x) <= axes.right() + 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(lo_y) >= axes.y - 1.0f);
|
||||
PLOT_CHECK(static_cast<plot::f32>(hi_y) <= axes.bottom() + 1.0f);
|
||||
|
||||
// The sweep is on the row its own transform maps y = 0 to, in the trace's own colour: the cell's
|
||||
// one sweep is spread over the block of device pixels it was magnified into, so what is asserted
|
||||
// is that the ink is *there* -- the brightest row of the rect is that row -- and that its hue is
|
||||
// the trace's rather than the background's.
|
||||
const plot::u32 mid_x = static_cast<plot::u32>(p.x_transform(ax).to_screen_x(0.5));
|
||||
const plot::u32 trace_row = static_cast<plot::u32>(p.y_transform(ay).to_screen_y(0.0));
|
||||
// The background to read the ink against is a row of the *without* frame away from the sweep's
|
||||
// own: the trace itself is drawn there (the density is off in that frame), so the trace's own row
|
||||
// is the trace's colour and not a background at all.
|
||||
const plot::u32 blank_row = trace_row + 40u < h ? trace_row + 40u : trace_row - 40u;
|
||||
const int bg_g = at(without, mid_x, blank_row, 1);
|
||||
int band_g = 0;
|
||||
int band_y = 0;
|
||||
for (plot::u32 y = static_cast<plot::u32>(axes.y); y < static_cast<plot::u32>(axes.bottom()); ++y) {
|
||||
const int g = at(capped, mid_x, y, 1);
|
||||
if (g > band_g) {
|
||||
band_g = g;
|
||||
band_y = static_cast<int>(y);
|
||||
}
|
||||
}
|
||||
PLOT_CHECK(band_g > bg_g + 40); // the sweep is on screen at all
|
||||
PLOT_CHECK(at(capped, mid_x, trace_row, 0) < 40); // ... in the trace's own hue ...
|
||||
PLOT_CHECK(at(capped, mid_x, trace_row, 1) > bg_g + 40);
|
||||
PLOT_CHECK(std::abs(band_y - static_cast<int>(trace_row)) <= 2);
|
||||
|
||||
std::printf("[density gl] capped grid=%ux%u cells=%zu of a %.0fx%.0f axes: changed cols=%u"
|
||||
" brightest row=%d (transform says %u), green=%d against bg=%d\n",
|
||||
grid.columns(), grid.rows(), grid.cell_count(), axes.w, axes.h, changed_cols, band_y,
|
||||
trace_row, band_g, bg_g);
|
||||
ctx.destroy();
|
||||
}
|
||||
Reference in new issue
Block a user