Compare commits
7
Commits
0.1
...
1fc97d818f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc97d818f | ||
|
|
ebc67bd318 | ||
|
|
1ae72d4903 | ||
|
|
2ebf2be571 | ||
|
|
37e414bcc5 | ||
|
|
cc908efa36 | ||
|
|
e81e84645f |
No files matched your search
@@ -37,6 +37,21 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
|
|||||||
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
window's worth, the scroll offset is bounded by `retained - span`, so a pan or a zoom-in near an
|
||||||
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
edge cannot leave the window's own span over data the scope never had (a window *wider* than the
|
||||||
data keeps the older placement -- right edge on the newest sample);
|
data keeps the older placement -- right edge on the newest sample);
|
||||||
|
- a trace whose gathered window is denser than the axis is reduced **onto the pixel grid** --
|
||||||
|
one snapped vertical segment per integer column, lit from that column's own extremes --
|
||||||
|
because an LOD envelope staged as a polyline puts three or four near-vertical hairlines per
|
||||||
|
column at as many sub-pixel phases, and the beat between the bucket pitch and the pixel pitch
|
||||||
|
is a crawling moiré; one segment per column leaves one phase, so there is nothing to beat
|
||||||
|
(`FrameStats::column_traces`, and `docs/data-and-performance.md` for the four rules it keeps);
|
||||||
|
- and such a column can be drawn **graded by dwell** instead of as one flat band
|
||||||
|
(`Style::intensity_grading`, off by default, `FrameStats::graded_traces`): bright where the
|
||||||
|
signal sits, faint where it only passes through, the way a digital-phosphor scope reads. It is
|
||||||
|
what puts back the information the reduction throws away — a column's min and max are the same
|
||||||
|
whether its samples fill the span or pile at its two ends — so the gather is asked at a level
|
||||||
|
finer than the pixel grid and the distribution read from those sub-buckets. Below the density at
|
||||||
|
which columns are reduced the same switch grades the **polyline** by each segment's slope
|
||||||
|
(`FrameStats::slope_traces`), so the look does not change as a window narrows past the
|
||||||
|
threshold;
|
||||||
- sample **markers** when a window is sparse enough that its samples are the shape
|
- 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);
|
(`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`,
|
- grid with independent major/minor line classes (`Style::grid_major_visible`,
|
||||||
@@ -172,12 +187,12 @@ documentation.
|
|||||||
```sh
|
```sh
|
||||||
cmake --preset linux-debug
|
cmake --preset linux-debug
|
||||||
cmake --build build/debug
|
cmake --build build/debug
|
||||||
ctest --test-dir build/debug --output-on-failure # 350 cases, 0 failed
|
ctest --test-dir build/debug --output-on-failure # 377 cases, 0 failed
|
||||||
|
|
||||||
tools/coverage.sh # configure+build+run+capture+floor check
|
tools/coverage.sh # configure+build+run+capture+floor check
|
||||||
```
|
```
|
||||||
|
|
||||||
The `linux-debug` suite runs **350 cases**; the `linux-coverage` preset builds **333** of
|
The `linux-debug` suite runs **377 cases**; the `linux-coverage` preset builds **360** of
|
||||||
them, because its adapters are off and 17 cases need an adapter target (11 in
|
them, because its adapters are off and 17 cases need an adapter target (11 in
|
||||||
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
|
`test_imgui_widget.cpp`, 6 in `test_glfw_host.cpp`). Coverage is enforced by
|
||||||
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
|
||||||
|
|||||||
@@ -41,11 +41,18 @@ ctest --test-dir build/debug --output-on-failure
|
|||||||
|
|
||||||
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
|
||||||
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
|
||||||
`plot_umbrella_check`. The `plot_tests` binary itself runs **350 cases** in the `linux-debug`
|
`plot_umbrella_check`. The `plot_tests` binary itself runs **377 cases** in the `linux-debug`
|
||||||
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line.
|
preset; its harness prints one line per case and a final `377 cases, 0 failed` summary line.
|
||||||
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
|
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/` — recount them
|
||||||
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
|
(`grep -c '^PLOT_TEST(' tests/test_*.cpp`) after adding or removing cases.
|
||||||
|
|
||||||
|
`PLOT_TEST_FILTER=<substring>` runs only the cases whose name contains that substring, and
|
||||||
|
is for working *on* a case rather than for checking the library: most of the render cases
|
||||||
|
create their own EGL context and read pixels back, so the whole binary is far slower than
|
||||||
|
the one case being edited and its one line of measured output scrolls past 376 others. The
|
||||||
|
summary line still counts the whole suite, so a filtered run says `377 cases, 0 failed`
|
||||||
|
having run one — it is never evidence that the suite passes. Unset, every case runs.
|
||||||
|
|
||||||
## Install layout
|
## Install layout
|
||||||
|
|
||||||
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
|
`cmake --install` follows `GNUInstallDirs`, so every path honours the standard cache
|
||||||
@@ -119,7 +126,7 @@ per-file table and the floor verdict live in [docs/coverage.md](coverage.md), wh
|
|||||||
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
|
`tools/coverage.sh` rewrites from the very run that measured them. Re-run the command for the
|
||||||
live numbers.
|
live numbers.
|
||||||
|
|
||||||
**One thing to know about the coverage preset:** it builds **333 of the 350 cases**. The 17
|
**One thing to know about the coverage preset:** it builds **360 of the 377 cases**. The 17
|
||||||
that are missing are the adapter cases that need an adapter target to exist — 11 in
|
that are missing are the adapter cases that need an adapter target to exist — 11 in
|
||||||
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
|
`test_imgui_widget.cpp` and 6 in `test_glfw_host.cpp` — and the coverage preset turns the
|
||||||
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
|
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (5 cases)
|
||||||
|
|||||||
@@ -166,6 +166,167 @@ series therefore stages O(visible pixels) vertices, not O(samples), and `FrameSt
|
|||||||
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
which path each trace took. Below the threshold the raw samples are drawn instead; both
|
||||||
sides go through the same instanced anti-aliased quad pipeline.
|
sides go through the same instanced anti-aliased quad pipeline.
|
||||||
|
|
||||||
|
## Dense traces are reduced onto the pixel grid
|
||||||
|
|
||||||
|
A gathered window denser than the axis is **not** staged as a polyline. For each integer
|
||||||
|
column of `layout.axes`, `Renderer::draw_traces` emits one exactly vertical segment from the
|
||||||
|
lowest to the highest screen y any gathered point put in that column, flushed in framebuffer
|
||||||
|
pixels with `u_snap` on. `FrameStats::column_traces` counts the traces that took this path.
|
||||||
|
|
||||||
|
It exists because a polyline over such a window **moirés**, and three things compound to make
|
||||||
|
it. `MinMaxPyramid::level_for()` picks the level whose buckets hold about
|
||||||
|
`samples_per_pixel` samples, so a bucket spans **one to two pixel columns**;
|
||||||
|
`GatherMode::Envelope` emits two points per bucket, so each bucket contributes a near-vertical
|
||||||
|
min→max hairline *and* a near-vertical connector to the next — about 3.6 near-vertical
|
||||||
|
hairlines per column; and each of those lands at a **fractional** x, so with ordinary
|
||||||
|
src-alpha compositing a column's brightness is a non-linear function of the sub-pixel phases
|
||||||
|
overlapping it. The phase pattern repeats at the beat between the bucket pitch and the pixel
|
||||||
|
pitch, which is a visible banding, and panning sweeps the beat so it crawls. Reducing to one
|
||||||
|
segment per column leaves the trace **one** sub-pixel phase, so there is nothing left to beat,
|
||||||
|
and the snap then puts that one phase on the grid.
|
||||||
|
|
||||||
|
Four properties of the reduction are load-bearing:
|
||||||
|
|
||||||
|
- **It is gated on the gathered *density*, not on the level.** It runs when the window is
|
||||||
|
`GatherMode::Envelope` and carries at least two gathered points per column of the axes —
|
||||||
|
the raw path at 1.5–64 samples per pixel has the same beat and the same cure. The density
|
||||||
|
term is also what keeps it away from a *sparse* window, whose trace is drawn by the two
|
||||||
|
segments that enter and leave the frame (`a_sparse_window_between_samples_still_inks_both_margins`);
|
||||||
|
`Nearest` and `Mean` are excluded because they are one-point-per-bucket modes a band defeats.
|
||||||
|
- **A hole stays a hole.** A column no gathered point landed in emits nothing. Bridging it
|
||||||
|
would draw a vertical line over x the series says nothing about.
|
||||||
|
- **Disjoint columns are joined.** Two neighbouring columns' extremes need not overlap — a
|
||||||
|
fast edge rises through a whole column — and two disjoint vertical segments read as a broken
|
||||||
|
trace, which is what the polyline's connector prevented. The later column is extended to
|
||||||
|
meet the earlier one's nearest bound, so the join is made inside the column that moved.
|
||||||
|
- **A span shorter than the stroke is drawn *as* the stroke.** The line fragment shader ramps
|
||||||
|
on the distance to the segment's axis and does not cap its ends, so a vertical segment
|
||||||
|
shorter than its own width rasterises a sliver of itself, and a flat trace's column, where
|
||||||
|
the two bounds are equal, nothing at all.
|
||||||
|
|
||||||
|
This is the **one carve-out** in `u_snap`'s rule that chrome may be snapped and data may not
|
||||||
|
(see `LineProgram::set_snap()`): a column *is* a pixel column and its x is already a screen
|
||||||
|
coordinate, so there is no data position to move it off, and the y bounds the segment reports
|
||||||
|
are untouched by the snap.
|
||||||
|
|
||||||
|
`a_dense_trace_is_drawn_as_whole_pixel_columns` is the guard, and what it measures is the
|
||||||
|
**number of distinct coverage levels** in the ink: 4 with the reduction against 126 with the
|
||||||
|
polyline, over one sine period across 60 000 samples. A count of *partially covered pixels* is
|
||||||
|
**not** the reading and was tried first — a 1.5 px stroke with a 1 px anti-aliasing fringe
|
||||||
|
always spills into its neighbour column, so the two renderings measure 577 and 652, which
|
||||||
|
separates nothing. The frame's whole geometry collapses with it: 627 segments against 60 054.
|
||||||
|
|
||||||
|
## A reduced column can be graded by dwell
|
||||||
|
|
||||||
|
`Style::intensity_grading` draws each of those columns **graded** rather than as one flat band:
|
||||||
|
bright where the signal sits, faint where it only passes through, the way a digital-phosphor
|
||||||
|
scope reads. It is off by default — a change of look, not a correction of one — and
|
||||||
|
`FrameStats::graded_traces` counts the traces that took it, a subset of `column_traces`, since
|
||||||
|
grading is a way of drawing the reduction and not a third path.
|
||||||
|
|
||||||
|
What it puts back is the information the reduction throws away: a column's min and max are the
|
||||||
|
**same** whether its samples fill the span evenly or pile at its two ends, so a ramp and a pulse
|
||||||
|
are one band. The distribution cannot be recovered from the band, which is why the gather is
|
||||||
|
asked at a level **finer than the pixel grid** (`intensity_sub_columns` sub-buckets per column)
|
||||||
|
and the dwell read from those sub-buckets.
|
||||||
|
|
||||||
|
Five things carry it, and each was arrived at by being wrong first:
|
||||||
|
|
||||||
|
- **The measure is dwell per row, each segment depositing one unit spread over the rows it
|
||||||
|
spans** (`1 / height`). Counting +1 per row instead grades a sine's fast middle as brightly as
|
||||||
|
its rails, which is the pattern inverted — the middle is where the signal spends the *least*
|
||||||
|
time.
|
||||||
|
- **The accumulator is one column wide.** Gathered x is monotonic, so points arrive column by
|
||||||
|
column: accumulate, read, clear. A full `axes.w × axes.h` density image is 33 MB at 4K; one
|
||||||
|
column of `axes.h` floats is L1-resident. A span is written as a `+1/height` delta at its first
|
||||||
|
row and the negation one past its last, so it costs two writes whatever its height, and the
|
||||||
|
running sum over the column is the per-row dwell.
|
||||||
|
- **Normalisation is per column, against the column's own peak.** A frame-wide scale needs the
|
||||||
|
whole image to exist before anything can be drawn, *and* reads worse: a column the signal flew
|
||||||
|
through uniformly comes out faint at every row, so the trace fades out exactly where it moves
|
||||||
|
fastest. The cost of the local scale is one extra non-clearing walk of the accumulator to find
|
||||||
|
the peak.
|
||||||
|
- **Alpha is quantised into `intensity_steps` tiers, one `LineBatch` and one draw call each.**
|
||||||
|
The line pipeline's colour is a **uniform**, not per-instance (`LineInstance` is four floats of
|
||||||
|
geometry; only `TextInstance` carries `a_colour`), so a per-segment alpha is inexpressible and
|
||||||
|
the tiers are what make the gradient drawable at all. `intensity_floor` keeps the faintest tier
|
||||||
|
visible — a fast riser is one sub-bucket deep everywhere and must not be dimmed out of
|
||||||
|
existence — and `intensity_gamma` lifts the faint end. The floor only holds if the riser's rows
|
||||||
|
**reach** a tier: the "lit at all" cutoff that discards a column's empty rows is
|
||||||
|
`min(0.01 × peak, 0.5 × w_min)` over the smallest deposit any of that column's own segments
|
||||||
|
made, and not the first term alone. A column holding a flat level *and* a riser has its peak set
|
||||||
|
by the level — one unit in one row — while the riser spreads a unit over 300, which is three
|
||||||
|
orders of magnitude under `0.01 × peak`, so the riser was discarded before the floor could apply
|
||||||
|
to it. Measured on a 2098-sample square wave at 105 samples a column: **2 916 of the ungraded
|
||||||
|
path's 35 884 riser rows**, 106 of 116 riser columns gutted entirely, with a 311-row dark run in
|
||||||
|
a 312-row axis; the ten survivors were the phase that put a riser in a column of its own, which
|
||||||
|
is why it read as a riser *flickering* under a pan rather than as a missing edge. With the second
|
||||||
|
term it is 35 572 rows and 115 of 116 columns, for 48 extra segments
|
||||||
|
(`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`). A connector between two disjoint columns
|
||||||
|
is emitted at the **faintest** tier: it is inferred geometry, not a place the signal was
|
||||||
|
measured to dwell.
|
||||||
|
- **Grading may not undo the reduction.** `level_for()` answers 0 — raw samples — as soon as the
|
||||||
|
asked-for fraction of a column falls under one bucket (`kBucket` is 64), so the finer ask is
|
||||||
|
**floored at level 1** whenever the flat path would have used the pyramid. Without the floor
|
||||||
|
every moderately dense frame became an O(visible) gather: measured in udpscope at 1.3 MS/s over
|
||||||
|
a 2 s window, 172 samples a pixel, the flat path sat on the pyramid and grading dropped the
|
||||||
|
whole figure to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 → 6.4 ms of draw
|
||||||
|
mean. The floor degrades the *look* instead — fewer sub-columns than asked for, down to what the
|
||||||
|
coarsest buckets give, which at 64 samples a bucket is still two or three per column. That is
|
||||||
|
also why raising `intensity_sub_columns` past about 8 buys nothing: the bucket size is the
|
||||||
|
limit, not the number.
|
||||||
|
|
||||||
|
`a_dense_trace_can_be_graded_by_dwell` is the guard, and it draws the same frame both ways. It
|
||||||
|
profiles the mean peak coverage over the band's two outer fifths against its middle fifth: the
|
||||||
|
flat band is flat (**191 and 191**, one ink) and the graded one is **191 against 131**, a middle
|
||||||
|
at 0.69 of the edge. That ratio is a *tier* ratio and not the dwell ratio — the default gamma
|
||||||
|
0.45 and floor 0.3 compress it on purpose. The geometry stays bounded by the axis and not by its
|
||||||
|
area: **8303 segments over a 572 px axis**, 14.5 runs a column, against the flat path's 627 at
|
||||||
|
one end and `572 × 156` rows at the other. A fourth frame over a window wide enough that the
|
||||||
|
flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=634
|
||||||
|
visible=200001`); the test's first window sits at 105 samples a pixel, where both paths are raw
|
||||||
|
and the floor is never reached.
|
||||||
|
|
||||||
|
## A sparse polyline is graded by slope
|
||||||
|
|
||||||
|
The column reduction stops at `dense_threshold_spp` (1.5 samples a device pixel), below which the
|
||||||
|
trace is a polyline. Grading used to stop there with it, because it was staged *inside* the
|
||||||
|
reduction — and that coupling was a defect, not a design. The reduction's gate is a density, and
|
||||||
|
its only purpose is removing the moiré beat between the bucket pitch and the pixel pitch. Dwell
|
||||||
|
has nothing to do with that beat, so zooming past the threshold turned the phosphor model off in
|
||||||
|
one step: the same square-wave edge measured **71 of 191** at 3.4 samples a pixel and **191 of
|
||||||
|
191** at 1.0, a 2.7× jump across one pixel of zoom, which reads as a discontinuity *lighting up*
|
||||||
|
the moment you look at it closely. The thing a reader most needs told apart from a real signal
|
||||||
|
excursion is a transition, and it was being drawn at full ink exactly when it filled the screen.
|
||||||
|
|
||||||
|
`Renderer::stage_trace_slopes()` grades the polyline on the same principle, differing only where
|
||||||
|
it must:
|
||||||
|
|
||||||
|
- **The measure is the segment's own slope**, `1 / (1 + |Δy_screen|)`, which is dwell per row for
|
||||||
|
a single segment. The `1 +` is not a guard against a division: a flat segment — span 0, the
|
||||||
|
commonest case in a sparse window — must be the *brightest* tier, and this makes the measure
|
||||||
|
dimensionless and **absolute**. There is no per-column peak to normalise against down here, and
|
||||||
|
that is an improvement: a sparse frame's tiers mean the same thing from frame to frame, so
|
||||||
|
panning does not reshade the trace.
|
||||||
|
- **No gather mode is excluded.** The reduction is `Envelope`-only, because turning a bucket's
|
||||||
|
min/max into a band answers a different question from the one a `Nearest` or `Mean` host asked.
|
||||||
|
A segment's slope is a property of the polyline itself, so `grade` is not reused as the gate —
|
||||||
|
only `intensity_grading` and `intensity_steps > 1` are.
|
||||||
|
- **The geometry is bit-for-bit the ungraded polyline's**, staged exactly as `stage_xy()` stages
|
||||||
|
it, with the same `snap = false`; only the batch a segment lands in differs. A segment with one
|
||||||
|
non-finite end is dropped rather than clamped, which is the conclusion `add_polyline_xy()`
|
||||||
|
reaches too.
|
||||||
|
- **`FrameStats::slope_traces` counts it, and it is disjoint from `column_traces`.** A window is
|
||||||
|
either dense enough to reduce onto the pixel grid or it is drawn as a polyline, never both — so
|
||||||
|
a host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
|
||||||
|
`a_sparse_trace_is_graded_by_slope_like_a_dense_one` is the guard: one window either side of the
|
||||||
|
threshold over the same transition at the same place. The level reads **191 either way** (a flat
|
||||||
|
segment dwells in its row, so it is the top tier and full ink) and the riser reads **71 either
|
||||||
|
way**, against the 191 the sparse frame gave before. Across the whole sweep from 101 down to 0.1
|
||||||
|
samples a pixel the riser holds 71 and the level holds 191, so there is no step at the threshold
|
||||||
|
left to see.
|
||||||
|
|
||||||
## One sample or a statistic of many
|
## One sample or a statistic of many
|
||||||
|
|
||||||
Every number the library reports is one the samples actually hold. What differs between
|
Every number the library reports is one the samples actually hold. What differs between
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ Colours (neither logical nor device — nothing to scale):
|
|||||||
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
|
||||||
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
|
||||||
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
|
||||||
|
| `invalid_fill` | `0x8A94A6` at α 0.18 | a span the host declared invalid (`Plot::set_invalid_spans()`) |
|
||||||
|
| `invalid_repaired_fill` | `0x8A94A6` at α 0.08 | the same span once the host has marked it repaired |
|
||||||
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
|
||||||
|
|
||||||
The scale:
|
The scale:
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ class MinMaxPyramid {
|
|||||||
/// whose samples were all non-finite is stored as an empty aggregate
|
/// whose samples were all non-finite is stored as an empty aggregate
|
||||||
/// (count 0, zero sums) that every reader treats as absent.
|
/// (count 0, zero sums) that every reader treats as absent.
|
||||||
void append(f32 v);
|
void append(f32 v);
|
||||||
|
/// Folds a backfilled sample into every aggregate that covers absolute index
|
||||||
|
/// `absolute_sample`.
|
||||||
|
///
|
||||||
|
/// **Precondition: the sample at that index was appended non-finite.** That is what makes
|
||||||
|
/// this purely additive -- min/max widen, sum/sumsq accumulate and count rises by one, at
|
||||||
|
/// every covering level -- so no bucket is ever recomputed and the cost is O(levels)
|
||||||
|
/// regardless of how wide the repaired span is. Amending a *finite* sample would need the
|
||||||
|
/// old value removed from sum/sumsq and could not restore min/max at all, which is why
|
||||||
|
/// this is a one-way NaN-to-value operation and not a general write.
|
||||||
|
///
|
||||||
|
/// An index outside `[window_base(), window_end())` is a no-op: the window slides on the
|
||||||
|
/// consumer thread, so a caller cannot prove the slot is still held before asking.
|
||||||
|
void amend(u64 absolute_sample, f32 v);
|
||||||
/// Slides the retained window's leading edge to absolute sample index
|
/// Slides the retained window's leading edge to absolute sample index
|
||||||
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
/// `absolute_sample`, dropping every aggregate that covered a sample before it.
|
||||||
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
/// Monotonic, and a no-op only when `absolute_sample <= window_base()`: an empty
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ class RingBuffer {
|
|||||||
for (u64 i = 0; i < n; ++i) push(vs[i]);
|
for (u64 i = 0; i < n; ++i) push(vs[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Producer thread only: rewrites the slot at absolute index `absolute`, without pushing.
|
||||||
|
/// Returns false when that index is not retained -- already overwritten, or not yet
|
||||||
|
/// produced -- in which case nothing is written.
|
||||||
|
///
|
||||||
|
/// This is for *backfill*: a slot pushed as a placeholder and corrected when the data that
|
||||||
|
/// belongs there arrives late. It is the same race class as `push()` -- one relaxed store
|
||||||
|
/// into a slot a consumer may be reading, and T is lock-free by the class's static_assert,
|
||||||
|
/// so a reader sees the old value or the new one and never a torn one. It deliberately does
|
||||||
|
/// not touch `produced_`: the count fixes which indices a snapshot reads, and an amend
|
||||||
|
/// changes a value inside that window rather than extending it.
|
||||||
|
bool amend(u64 absolute, T v) {
|
||||||
|
const u64 p = produced_.load(std::memory_order_relaxed);
|
||||||
|
if (absolute >= p) return false;
|
||||||
|
if (p > cap_ && absolute < p - cap_) return false;
|
||||||
|
buf_[static_cast<usize>(absolute & mask_)].store(v, std::memory_order_relaxed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Consumer: retained element count, saturating at capacity().
|
/// Consumer: retained element count, saturating at capacity().
|
||||||
u64 size() const noexcept {
|
u64 size() const noexcept {
|
||||||
const u64 p = produced_.load(std::memory_order_acquire);
|
const u64 p = produced_.load(std::memory_order_acquire);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
#include <span>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <plot/core/rect.hpp>
|
#include <plot/core/rect.hpp>
|
||||||
@@ -49,6 +50,28 @@ constexpr bool x_strip_has_title(XStrip mode) noexcept {
|
|||||||
return mode == XStrip::Both || mode == XStrip::TitleOnly;
|
return mode == XStrip::Both || mode == XStrip::TitleOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One span of the primary x axis the host has declared invalid, in **data** coordinates --
|
||||||
|
/// `Region`'s convention. In udpscope's case a reserved hole: samples the stream's counter says
|
||||||
|
/// exist and that have not been delivered.
|
||||||
|
///
|
||||||
|
/// `repaired` says the host has since filled the span's samples in. Such a span is **still
|
||||||
|
/// reported**, because the shading is a record of what the window was missing, but it is drawn in
|
||||||
|
/// the fainter of the two tints -- so a reader can distinguish a gap that is still empty from one
|
||||||
|
/// that has since been repaired, and from a span that has left the list entirely.
|
||||||
|
struct InvalidSpan {
|
||||||
|
f64 x0 = 0.0;
|
||||||
|
f64 x1 = 0.0;
|
||||||
|
bool repaired = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One invalid span's measured shading: the rect, clipped to the axes' columns, and the state that
|
||||||
|
/// chooses its tint. A struct and not a bare `Rect` for `RegionLayout`'s reason -- the renderer
|
||||||
|
/// draws exactly what the layout measured and derives nothing of its own.
|
||||||
|
struct InvalidSpanLayout {
|
||||||
|
Rect fill;
|
||||||
|
bool repaired = false;
|
||||||
|
};
|
||||||
|
|
||||||
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
/// Pixel geometry of a plot's chrome, all of it in the same pixel space as
|
||||||
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
|
/// `Rect` (origin top-left) and therefore in **device** pixels: everything here is
|
||||||
/// what the renderer draws with, already converted from the logical metrics in
|
/// what the renderer draws with, already converted from the logical metrics in
|
||||||
@@ -105,6 +128,15 @@ struct PlotLayout {
|
|||||||
/// controller's hit test reads the very same rects, so where a region is drawn and
|
/// controller's hit test reads the very same rects, so where a region is drawn and
|
||||||
/// where a pointer can grab it cannot drift apart.
|
/// where a pointer can grab it cannot drift apart.
|
||||||
std::vector<RegionLayout> regions;
|
std::vector<RegionLayout> regions;
|
||||||
|
/// Every invalid span's shading, in `Plot::invalid_spans()` order and clipped to the axes'
|
||||||
|
/// columns. Measured here for the regions' first reason -- the frame draws exactly these
|
||||||
|
/// numbers -- and reserving nothing, so a hole cannot move the strips the labels were
|
||||||
|
/// measured into. A span entirely outside the window contributes no rect at all, so this is
|
||||||
|
/// not index-aligned with `invalid_spans()` and nothing needs it to be: there is no hit test
|
||||||
|
/// on a band and no label beside it. Each entry carries the span's `repaired` state alongside
|
||||||
|
/// its rect, so the tint is chosen where the geometry is measured rather than looked up again
|
||||||
|
/// at draw time.
|
||||||
|
std::vector<InvalidSpanLayout> invalid_spans;
|
||||||
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
/// The crosshair's geometry, when one is active: the two lines' stroke boxes, the
|
||||||
/// readout panel and one row per visible trace with the value of the sample nearest the
|
/// readout panel and one row per visible trace with the value of the sample nearest the
|
||||||
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
|
/// pointer. Placed here rather than derived by the renderer, exactly as the legend and
|
||||||
@@ -525,6 +557,25 @@ class Plot {
|
|||||||
void set_x_note(std::string text);
|
void set_x_note(std::string text);
|
||||||
const std::string& x_note() const noexcept { return x_note_; }
|
const std::string& x_note() const noexcept { return x_note_; }
|
||||||
|
|
||||||
|
/// Spans of the primary x axis the host has declared invalid -- in udpscope's case a reserved
|
||||||
|
/// hole: samples the stream's counter says exist and that have not been delivered. Each span's
|
||||||
|
/// `x0`/`x1` are in **data** coordinates, `Region`'s convention, and is shaded behind
|
||||||
|
/// the traces and over the grid.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* a `Region`: a region is interactive -- draggable bounds, hot zones, a
|
||||||
|
/// label, a cached statistic, a controller hit test -- and a plot can carry dozens of holes at
|
||||||
|
/// once, which would put a hundred-odd grab targets in the way of every pointer event for
|
||||||
|
/// marks nobody can usefully drag. This is passive shading and nothing else.
|
||||||
|
///
|
||||||
|
/// The span is copied, so the caller's storage need not outlive the call. Spans need not be
|
||||||
|
/// sorted and may overlap; an empty call clears the shading.
|
||||||
|
///
|
||||||
|
/// A span whose `repaired` flag is set is shaded more faintly (`Style::invalid_repaired_fill`
|
||||||
|
/// against `Style::invalid_fill`). Clearing the shading is the host's decision and nothing
|
||||||
|
/// here retires a span on its own: a repaired span is drawn until the host stops sending it.
|
||||||
|
void set_invalid_spans(std::span<const InvalidSpan> spans);
|
||||||
|
std::span<const InvalidSpan> invalid_spans() const noexcept { return invalid_spans_; }
|
||||||
|
|
||||||
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
||||||
/// measure was added, or when the series underneath it changed: a different
|
/// measure was added, or when the series underneath it changed: a different
|
||||||
/// series, a longer one, or a sliding window whose sample count stayed the same
|
/// series, a longer one, or a sliding window whose sample count stayed the same
|
||||||
@@ -852,6 +903,10 @@ class Plot {
|
|||||||
/// region's pixel geometry is derived -- the controller hit-tests what this
|
/// region's pixel geometry is derived -- the controller hit-tests what this
|
||||||
/// measured instead of mapping the bounds through the transform again.
|
/// measured instead of mapping the bounds through the transform again.
|
||||||
void build_regions();
|
void build_regions();
|
||||||
|
/// Measures every invalid span into `layout_.invalid_spans`: its interval clipped to the axes'
|
||||||
|
/// columns, with a one-device-pixel floor, and the state that picks its tint. Here and not in
|
||||||
|
/// the renderer for `build_regions()`' reason -- one x mapping, the frame's own.
|
||||||
|
void build_invalid_spans();
|
||||||
/// Exact statistics of `region` over `series`, or an invalid result when the
|
/// Exact statistics of `region` over `series`, or an invalid result when the
|
||||||
/// interval covers no sample. The one place the region arithmetic lives.
|
/// interval covers no sample. The one place the region arithmetic lives.
|
||||||
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
static RegionStats evaluate_region_stats(const Region& region, const data::Series& series);
|
||||||
@@ -899,6 +954,7 @@ class Plot {
|
|||||||
std::vector<MeasureSlot> measures_;
|
std::vector<MeasureSlot> measures_;
|
||||||
std::vector<Annotation> annotations_;
|
std::vector<Annotation> annotations_;
|
||||||
std::vector<Region> regions_;
|
std::vector<Region> regions_;
|
||||||
|
std::vector<InvalidSpan> invalid_spans_;
|
||||||
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
|
/// The (region, trace) statistics cache, filled on demand by `region_stats()`
|
||||||
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
|
/// and so mutable: a lookup on a const Plot may cache, exactly as `measures_`
|
||||||
/// does. Entries are created for the pairs a caller actually asks about, never
|
/// does. Entries are created for the pairs a caller actually asks about, never
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ namespace plot {
|
|||||||
struct FrameStats {
|
struct FrameStats {
|
||||||
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
u32 raw_traces = 0; ///< traces drawn from raw samples (level 0)
|
||||||
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
u32 lod_traces = 0; ///< traces drawn from a min/max pyramid level
|
||||||
|
/// Traces whose geometry was reduced onto the pixel grid: one vertical segment
|
||||||
|
/// per column, lit from that column's own extremes. Counts a trace from either
|
||||||
|
/// path above -- the reduction is about the *density* of the gathered points,
|
||||||
|
/// not about which level produced them.
|
||||||
|
u32 column_traces = 0;
|
||||||
|
/// Traces whose columns were graded by dwell rather than drawn as one flat band
|
||||||
|
/// (`Style::intensity_grading`). A subset of `column_traces`: grading is a way of
|
||||||
|
/// drawing the reduction, not a third path.
|
||||||
|
u32 graded_traces = 0;
|
||||||
|
/// Traces drawn as a polyline whose *segments* were graded by slope -- the sparse
|
||||||
|
/// half of the same phosphor model (`Renderer::stage_trace_slopes`). **Disjoint from
|
||||||
|
/// `column_traces`**, and therefore from `graded_traces`: a window is either dense
|
||||||
|
/// enough to reduce onto the pixel grid or it is drawn as a polyline, never both. A
|
||||||
|
/// host asserting `graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
u32 slope_traces = 0;
|
||||||
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
u64 visible_samples = 0; ///< samples the gathered windows covered, summed
|
||||||
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
u32 staged_segments = 0; ///< line segments staged, all colour groups
|
||||||
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
u32 staged_markers = 0; ///< marker shapes staged over the traces
|
||||||
@@ -404,6 +419,13 @@ class Renderer {
|
|||||||
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
|
/// axis-aligned line by up to half a pixel. Set on every flush, like `dash`.
|
||||||
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
|
void flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash = Vec2{},
|
||||||
bool snap = false) noexcept;
|
bool snap = false) noexcept;
|
||||||
|
/// The same, for a batch other than the renderer's own `lines_`. The graded trace
|
||||||
|
/// pass stages one batch per alpha step, and a step has to be a draw call of its
|
||||||
|
/// own because the line pipeline takes its colour from a uniform -- so it needs to
|
||||||
|
/// flush a batch it chose rather than the member one. Every other caller flushes
|
||||||
|
/// `lines_` and uses the overload above.
|
||||||
|
void flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
|
||||||
|
Vec2 dash = Vec2{}, bool snap = false) noexcept;
|
||||||
/// Draws every staged glyph in `batch` -- each instance carries its own tint
|
/// Draws every staged glyph in `batch` -- each instance carries its own tint
|
||||||
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
/// (`a_colour`) -- and empties the batch. Text is texture-bound, so this runs
|
||||||
/// once per text batch, after the line geometry it belongs over.
|
/// once per text batch, after the line geometry it belongs over.
|
||||||
@@ -491,6 +513,13 @@ class Renderer {
|
|||||||
void draw_plot(Plot& plot, const PlotLayout& layout);
|
void draw_plot(Plot& plot, const PlotLayout& layout);
|
||||||
|
|
||||||
void draw_grid(const Plot& plot);
|
void draw_grid(const Plot& plot);
|
||||||
|
/// `Plot::invalid_spans()`, as `PlotLayout::invalid_spans` measured them: one fill per span,
|
||||||
|
/// in `style_.invalid_fill` or — for a span the host has marked repaired —
|
||||||
|
/// `style_.invalid_repaired_fill`. Over the grid and under everything else inside the axes, so
|
||||||
|
/// a hole reads as a property of the plot's background -- the grid shows through it and a trace
|
||||||
|
/// that does have data in those columns is drawn on top, which is what distinguishes a reserved
|
||||||
|
/// hole from a trace that has gone quiet.
|
||||||
|
void draw_invalid_spans(const PlotLayout& layout);
|
||||||
void draw_axis_lines(const Plot& plot);
|
void draw_axis_lines(const Plot& plot);
|
||||||
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
/// The chrome helpers take the frame's layout: `Plot::layout()` recomputes the
|
||||||
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
/// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed
|
||||||
@@ -500,6 +529,53 @@ class Renderer {
|
|||||||
/// The data helpers take it too; they read the axes rect and the transforms it
|
/// The data helpers take it too; they read the axes rect and the transforms it
|
||||||
/// pins.
|
/// pins.
|
||||||
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
void draw_traces(const Plot& plot, const PlotLayout& layout);
|
||||||
|
/// One gathered window reduced onto the pixel grid and staged in framebuffer
|
||||||
|
/// pixels: for each integer column of `layout.axes`, one exactly vertical
|
||||||
|
/// segment from the lowest to the highest screen y any gathered point put in
|
||||||
|
/// it. Returns how many columns were staged (0 stages nothing); the caller
|
||||||
|
/// flushes with `pixel_mapping()` and `snap = true`.
|
||||||
|
///
|
||||||
|
/// `gather_` is the input, read with the same origin `draw_traces` asked the
|
||||||
|
/// series for. See `draw_traces` for when this path is taken and why.
|
||||||
|
u32 stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
|
/// The same reduction, graded by **dwell**: a column is drawn as a stack of runs,
|
||||||
|
/// each at the alpha step its own share of the column's samples earns, so a flat
|
||||||
|
/// top is bright and the edge leading to it is faint (`Style::intensity_grading`).
|
||||||
|
///
|
||||||
|
/// Stages into `steps_`, one `LineBatch` per alpha step, which the caller flushes
|
||||||
|
/// in turn -- the line pipeline's colour is a uniform, so a step is a draw call.
|
||||||
|
/// Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
///
|
||||||
|
/// `gather_` is the input and must have been asked for at the *finer* level
|
||||||
|
/// `Style::intensity_sub_columns` implies; a column's dwell is not recoverable
|
||||||
|
/// from its own min and max (see `Style::intensity_grading`).
|
||||||
|
u32 stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points);
|
||||||
|
/// The **sparse** window's polyline, graded by the same measure: each segment is
|
||||||
|
/// staged into the step its own *slope* earns, so a near-vertical segment between two
|
||||||
|
/// adjacent samples -- a discontinuity, which the signal crossed without dwelling
|
||||||
|
/// anywhere in it -- is faint and a near-flat one is bright.
|
||||||
|
///
|
||||||
|
/// It exists because grading used to be gated on the column reduction, and that
|
||||||
|
/// reduction is gated on a *density* (two gathered points per pixel column) whose only
|
||||||
|
/// purpose is removing the moire beat. Dwell has nothing to do with that density, so
|
||||||
|
/// zooming in past it switched the whole phosphor model off and drew an edge as
|
||||||
|
/// brightly as a flat top -- the edge got brighter the further in you zoomed.
|
||||||
|
///
|
||||||
|
/// The measure agrees with `stage_trace_density()`'s at the boundary but is **absolute
|
||||||
|
/// rather than per column**: below two points a column there is no column peak to
|
||||||
|
/// normalise against, so a segment's dwell is `1 / (1 + rows it spans)` outright. A
|
||||||
|
/// flat segment earns the top step and one crossing the whole axis earns
|
||||||
|
/// `Style::intensity_floor`, which is what a riser's column already gets on the dense
|
||||||
|
/// side.
|
||||||
|
///
|
||||||
|
/// Geometry is staged exactly as `stage_xy()` stages it -- in the pre-affine space,
|
||||||
|
/// relative to `x_origin` -- so only *which batch* a segment lands in differs from the
|
||||||
|
/// ungraded polyline, and the caller flushes with the trace's own `Mapping` and
|
||||||
|
/// `snap = false`. Returns the segments staged across all steps (0 stages nothing).
|
||||||
|
u32 stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
|
||||||
|
f64 x_origin, bool log_x, bool log_y);
|
||||||
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
/// The cursors, both orientations: one stroke per cursor, placed at the coordinate its own
|
||||||
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
/// orientation owns (`Plot::cursor_screen_coordinate()`) and spanning the other axis' whole
|
||||||
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
/// range, in its own colour -- or in the accent at handle width while it is hovered or
|
||||||
@@ -611,7 +687,25 @@ class Renderer {
|
|||||||
render::TextBatch glyphs_under_;
|
render::TextBatch glyphs_under_;
|
||||||
render::TextBatch glyphs_over_;
|
render::TextBatch glyphs_over_;
|
||||||
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
std::vector<f32> gather_; ///< scratch for one gathered window, re-used
|
||||||
std::vector<f32> staged_; ///< scratch for one staged window, re-used
|
/// One (lowest y, highest y) pair per pixel column of the axes, re-used:
|
||||||
|
/// `stage_trace_columns()`'s working set. Two floats per column, so an 8K-wide
|
||||||
|
/// axis is 64 kB -- bounded by the *screen*, where `gather_` is bounded by the
|
||||||
|
/// window.
|
||||||
|
std::vector<f32> columns_;
|
||||||
|
/// `stage_trace_density()`'s working set, and deliberately **one column wide**: the
|
||||||
|
/// gathered points arrive in x order, so a column can be accumulated, read and
|
||||||
|
/// cleared before the next one starts. A full-axes density image would be
|
||||||
|
/// `axes.w * axes.h` (33 MB at 4K); this is `axes.h` (a few kB) and stays in L1.
|
||||||
|
///
|
||||||
|
/// Each sub-bucket's span is written as a +1 at its first row and a -1 one past
|
||||||
|
/// its last, so accumulating a span costs two writes whatever its height; the
|
||||||
|
/// running sum over the column is then how many sub-buckets cover each row, which
|
||||||
|
/// is the dwell.
|
||||||
|
std::vector<f32> column_rows_;
|
||||||
|
/// One batch per alpha step of the grading (`Style::intensity_steps`), re-used
|
||||||
|
/// across frames: the line pipeline takes its colour from a uniform, so segments
|
||||||
|
/// at different alphas cannot share a draw call and must be staged apart.
|
||||||
|
std::vector<render::LineBatch> steps_;
|
||||||
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
mutable std::vector<u8> readback_; ///< dump_frame_ppm's row buffer
|
||||||
|
|
||||||
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
/// The host's `GL_COLOR_CLEAR_VALUE`, queried on the first `begin_frame()` (GL
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ struct Style {
|
|||||||
Color axis_text = Color::hex(0xC8D0DC);
|
Color axis_text = Color::hex(0xC8D0DC);
|
||||||
Color cursor = Color::hex(0xFFB020);
|
Color cursor = Color::hex(0xFFB020);
|
||||||
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
Color measure_fill = Color::hex(0xFFB020, 0.15f);
|
||||||
|
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
|
||||||
|
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
|
||||||
|
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
|
||||||
|
/// one in a hue a trace could have would read as a signal rather than as a gap. The alpha is
|
||||||
|
/// low enough that the grid under it stays legible, which is what makes the band read as a
|
||||||
|
/// region of the plot rather than as a panel laid over it.
|
||||||
|
Color invalid_fill = Color::hex(0x8A94A6, 0.18f);
|
||||||
|
/// The same shading for a span whose samples have since arrived and been written in, at well
|
||||||
|
/// under half the alpha. The gap is still reported -- the window was drawn without that data --
|
||||||
|
/// but a reader can tell a hole that is still empty from one that has been filled. Fainter by
|
||||||
|
/// alpha alone and not by hue, so the two read as two depths of one mark rather than as two
|
||||||
|
/// different kinds of thing; and still above the threshold where the difference from *no* band
|
||||||
|
/// is visible, which is the floor this alpha is chosen against.
|
||||||
|
Color invalid_repaired_fill = Color::hex(0x8A94A6, 0.08f);
|
||||||
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
/// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
|
||||||
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
/// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
|
||||||
/// trace and the line says *where*.
|
/// trace and the line says *where*.
|
||||||
@@ -177,6 +191,90 @@ struct Style {
|
|||||||
///< sides draw through the one instanced
|
///< sides draw through the one instanced
|
||||||
///< anti-aliased quad path.
|
///< anti-aliased quad path.
|
||||||
|
|
||||||
|
// --- intensity grading: counts and ratios, never sizes -------------------
|
||||||
|
|
||||||
|
/// Draw a dense trace's pixel columns **graded by how long the signal dwells at
|
||||||
|
/// each height**, the way a digital-phosphor scope does, instead of as one flat
|
||||||
|
/// band per column: bright where samples pile up (a flat top, a peak), faint
|
||||||
|
/// where they only pass through (an edge, a noise excursion).
|
||||||
|
///
|
||||||
|
/// Off by default, because it is a change of *look* and not a correction: the
|
||||||
|
/// flat band is the honest answer to "what did this column reach", and a host
|
||||||
|
/// that wants that -- or that draws few enough samples never to take the column
|
||||||
|
/// path at all -- should not have its traces restyled by an upgrade. A scope-like
|
||||||
|
/// host turns it on.
|
||||||
|
///
|
||||||
|
/// It is not derivable from the flat band. A column's min and max are the same
|
||||||
|
/// whether its samples fill the span or sit piled at its two ends, which is
|
||||||
|
/// exactly the difference between a ramp and a pulse -- so grading needs the
|
||||||
|
/// gather asked at a *finer* level than the pixel grid (`intensity_sub_columns`)
|
||||||
|
/// and reads the distribution from those sub-buckets.
|
||||||
|
///
|
||||||
|
/// The measure is *dwell per row*, and the weighting is the whole of it: each
|
||||||
|
/// sub-column segment deposits **one unit spread over the rows it spans**, so a
|
||||||
|
/// tall crossing leaves a thin smear and a short one at a turning point leaves a
|
||||||
|
/// bright mark. Counting a row at +1 per segment that touches it instead -- the
|
||||||
|
/// obvious thing -- grades a sine's fast middle as brightly as its rails, which
|
||||||
|
/// is the pattern inverted.
|
||||||
|
///
|
||||||
|
/// Below `dense_threshold_spp`, where there are no columns to reduce, this grades
|
||||||
|
/// the **polyline** by each segment's own slope instead (`FrameStats::slope_traces`
|
||||||
|
/// against `graded_traces`) -- the same measure for a sparse window, where a
|
||||||
|
/// segment *is* the sub-column. One switch covers both because the alternative was
|
||||||
|
/// a defect: gated on the reduction, the model switched off as a window narrowed
|
||||||
|
/// past the threshold, and an edge drawn at a third of a flat top's ink jumped to
|
||||||
|
/// all of it in one pixel of zoom.
|
||||||
|
bool intensity_grading = false;
|
||||||
|
|
||||||
|
/// Sub-buckets per pixel column the gather is asked for when grading: the level
|
||||||
|
/// is chosen for `samples_per_pixel / intensity_sub_columns`, so a column's
|
||||||
|
/// density is read from this many envelopes instead of from one.
|
||||||
|
///
|
||||||
|
/// It buys resolution in *dwell*, linearly, for the same factor in gathered
|
||||||
|
/// points -- and the points stay bounded by the screen, which is the property
|
||||||
|
/// that matters (8 of them over a 1000 px axis is 16 000 points whatever the
|
||||||
|
/// window holds). Below about 4 a pulse's flat levels and its edge are too few
|
||||||
|
/// samples apart to separate.
|
||||||
|
///
|
||||||
|
/// It is a *want*, not a promise: the renderer floors the level it asks for at the
|
||||||
|
/// pyramid's coarsest bucket level, so a window too sparse to hold this many buckets
|
||||||
|
/// a column is graded from however many it does hold. Without that floor `level_for`
|
||||||
|
/// answers 0 -- raw samples -- the moment a sub-column falls under one bucket, which
|
||||||
|
/// turns a frame the flat path draws off the pyramid into an O(visible) gather. So
|
||||||
|
/// raising this past about 8 costs resolution nothing and buys it nothing either: the
|
||||||
|
/// bucket size is the limit, not this number.
|
||||||
|
u32 intensity_sub_columns = 8;
|
||||||
|
|
||||||
|
/// Quantisation of the grading, in alpha steps. Each step is one draw call (the
|
||||||
|
/// line pipeline's colour is a uniform, so a per-segment alpha would mean a
|
||||||
|
/// per-instance one), and the segments are bounded by the columns, so the cost
|
||||||
|
/// of a step is a draw call over a few hundred instances.
|
||||||
|
///
|
||||||
|
/// Four is enough to read a waveform's structure and few enough that a step is a
|
||||||
|
/// visible tier rather than a gradient -- the point is to separate "dwelt here"
|
||||||
|
/// from "passed through", not to measure.
|
||||||
|
u32 intensity_steps = 4;
|
||||||
|
|
||||||
|
/// Alpha the faintest graded step is drawn at, as a fraction of the trace's own.
|
||||||
|
///
|
||||||
|
/// Not 0: a column the signal merely passed through is still a column the signal
|
||||||
|
/// *reached*, and a riser dimmed out of existence is a square wave drawn as two
|
||||||
|
/// disconnected lines. This is the floor that keeps the trace continuous.
|
||||||
|
f32 intensity_floor = 0.3f;
|
||||||
|
|
||||||
|
/// Exponent the normalised dwell is raised to before it is quantised. Below 1 it
|
||||||
|
/// lifts the faint end, which is what makes a fast edge visible beside a flat top
|
||||||
|
/// whose dwell is three orders of magnitude greater.
|
||||||
|
///
|
||||||
|
/// The dwell is normalised **against the column's own peak**, not against a scale
|
||||||
|
/// for the whole frame, so the steps spend their range on the structure *inside*
|
||||||
|
/// each column. One scale for the frame needs the whole `w * h` density image
|
||||||
|
/// (33 MB at 4K, which is why the accumulator is one column wide) and reads
|
||||||
|
/// worse: a column the signal flew straight through holds little dwell at any
|
||||||
|
/// height, so it would grade uniformly faint -- a trace that fades out exactly
|
||||||
|
/// where it moves fastest.
|
||||||
|
f32 intensity_gamma = 0.45f;
|
||||||
|
|
||||||
/// `logical` in device pixels: the single conversion point for every logical
|
/// `logical` in device pixels: the single conversion point for every logical
|
||||||
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
/// metric above, so the arithmetic appears once per consumer instead of a `*
|
||||||
/// ui_scale` scattered over the renderer and the layout.
|
/// ui_scale` scattered over the renderer and the layout.
|
||||||
|
|||||||
@@ -179,6 +179,43 @@ void MinMaxPyramid::append(f32 v) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MinMaxPyramid::amend(u64 absolute_sample, f32 v) {
|
||||||
|
if (!std::isfinite(v)) return;
|
||||||
|
if (absolute_sample < base_ || absolute_sample >= count_) return;
|
||||||
|
|
||||||
|
const f64 vd = f64(v);
|
||||||
|
const auto fold = [&](Entry& e) {
|
||||||
|
// An empty bucket's `mm` is meaningless (see the Entry note), so the first finite
|
||||||
|
// sample *sets* the extremes instead of widening from a zero that was never a sample.
|
||||||
|
if (e.count == 0) {
|
||||||
|
e.mm.min = v;
|
||||||
|
e.mm.max = v;
|
||||||
|
} else {
|
||||||
|
e.mm.min = std::min(e.mm.min, v);
|
||||||
|
e.mm.max = std::max(e.mm.max, v);
|
||||||
|
}
|
||||||
|
e.sum += vd;
|
||||||
|
e.sumsq += vd * vd;
|
||||||
|
++e.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The in-progress level-0 bucket, when the index lies inside it. It is not an entry yet,
|
||||||
|
// so the loop below cannot reach it; leaving it out would lose the amendment for a hole
|
||||||
|
// whose tail is still filling, which is exactly the hole a prompt backfill arrives into.
|
||||||
|
if (count_ % kBucket != 0 && absolute_sample >= (count_ / kBucket) * kBucket) {
|
||||||
|
fold(pending_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every level independently: min/max/sum/sumsq/count are each associative, so folding the
|
||||||
|
// value into a parent gives the same aggregate as re-merging its children would.
|
||||||
|
for (u32 l = 0; l < static_cast<u32>(levels_.size()); ++l) {
|
||||||
|
const u64 i = absolute_sample / level_span(l);
|
||||||
|
if (i < levels_[l].base) continue;
|
||||||
|
if (i - levels_[l].base >= static_cast<u64>(levels_[l].entries.size())) continue;
|
||||||
|
fold(levels_[l].entries[static_cast<usize>(i - levels_[l].base)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MinMaxPyramid::drop_before(u64 absolute_sample) {
|
void MinMaxPyramid::drop_before(u64 absolute_sample) {
|
||||||
if (absolute_sample <= base_) return;
|
if (absolute_sample <= base_) return;
|
||||||
if (absolute_sample > count_) {
|
if (absolute_sample > count_) {
|
||||||
|
|||||||
@@ -344,6 +344,10 @@ void Plot::set_y_axis_origin_from_range(u32 axis, bool on) {
|
|||||||
|
|
||||||
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
|
void Plot::set_x_note(std::string text) { x_note_ = std::move(text); }
|
||||||
|
|
||||||
|
void Plot::set_invalid_spans(std::span<const InvalidSpan> spans) {
|
||||||
|
invalid_spans_.assign(spans.begin(), spans.end());
|
||||||
|
}
|
||||||
|
|
||||||
f64 Plot::x_axis_origin(u32 axis) const {
|
f64 Plot::x_axis_origin(u32 axis) const {
|
||||||
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
return axis < x_axes_.size() ? x_axes_[axis].origin : 0.0;
|
||||||
}
|
}
|
||||||
@@ -1091,6 +1095,7 @@ const PlotLayout& Plot::layout() {
|
|||||||
// measured from the *placed* live windows, which is what makes a region's data
|
// measured from the *placed* live windows, which is what makes a region's data
|
||||||
// interval map to the pixel columns the frame will actually use.
|
// interval map to the pixel columns the frame will actually use.
|
||||||
build_regions();
|
build_regions();
|
||||||
|
build_invalid_spans();
|
||||||
build_legend();
|
build_legend();
|
||||||
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
// The crosshair last: its panel is fitted into `axes` and its lines are held inside it, and
|
||||||
// its rows are the sample values nearest a data x -- so it wants the transforms the
|
// its rows are the sample values nearest a data x -- so it wants the transforms the
|
||||||
@@ -1358,6 +1363,41 @@ void Plot::build_regions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Plot::build_invalid_spans() {
|
||||||
|
layout_.invalid_spans.clear();
|
||||||
|
if (invalid_spans_.empty()) return;
|
||||||
|
|
||||||
|
const Rect& axes = layout_.axes;
|
||||||
|
// The primary x axis' transform, taken here for `build_regions()`' reason: after the live
|
||||||
|
// windows have been placed and the strips have settled, so these are the columns the frame
|
||||||
|
// draws with and a band is anchored to the data by construction rather than by a second
|
||||||
|
// mapping that would have to be kept in step with this one.
|
||||||
|
const Transform tx = x_transform(0u);
|
||||||
|
// A hole can be narrower than a column -- one missing sample in a window of hundreds of
|
||||||
|
// thousands -- and a fill rounded to zero width would draw nothing, losing exactly the case
|
||||||
|
// the band is most needed for. One device pixel is the floor.
|
||||||
|
const f32 min_w = std::max(1.0f, style_.device(1.0f));
|
||||||
|
|
||||||
|
for (const InvalidSpan& span : invalid_spans_) {
|
||||||
|
const f64 a = std::min(span.x0, span.x1);
|
||||||
|
const f64 b = std::max(span.x0, span.x1);
|
||||||
|
if (!std::isfinite(a) || !std::isfinite(b)) continue;
|
||||||
|
const f32 lo = static_cast<f32>(tx.to_screen_x(a));
|
||||||
|
const f32 hi = static_cast<f32>(tx.to_screen_x(b));
|
||||||
|
// Scrolled out of the window: nothing to shade. Tested before the floor is applied, so a
|
||||||
|
// span off the left edge is dropped rather than pinned to it as a one-pixel band that
|
||||||
|
// accuses the samples at the window's start.
|
||||||
|
if (hi < axes.x || lo > axes.right()) continue;
|
||||||
|
const f32 first = std::clamp(lo, axes.x, axes.right());
|
||||||
|
const f32 last = std::clamp(hi, axes.x, axes.right());
|
||||||
|
const f32 w = std::min(std::max(last - first, min_w), axes.w);
|
||||||
|
// Widening a band at the right edge would run it over the axis line, so the floor is taken
|
||||||
|
// out of the left instead: the span's own edge is kept wherever there is room for it.
|
||||||
|
const f32 x = std::clamp(first, axes.x, axes.right() - w);
|
||||||
|
layout_.invalid_spans.push_back(InvalidSpanLayout{Rect{x, axes.y, w, axes.h}, span.repaired});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- crosshair (spec §15) --------------------------------------------------
|
// --- crosshair (spec §15) --------------------------------------------------
|
||||||
|
|
||||||
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
CrosshairSpan crosshair_span(const data::Series& series, f64 x) noexcept {
|
||||||
|
|||||||
+505
-8
@@ -516,6 +516,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
|
|||||||
// against the line passes would not change a pixel.)
|
// against the line passes would not change a pixel.)
|
||||||
set_clip(layout.axes);
|
set_clip(layout.axes);
|
||||||
draw_grid(plot);
|
draw_grid(plot);
|
||||||
|
// The holes over the grid and under the data: a band is chrome of the *background*, not an
|
||||||
|
// overlay, so everything a frame draws inside the axes is drawn on top of it.
|
||||||
|
draw_invalid_spans(layout);
|
||||||
clear_clip();
|
clear_clip();
|
||||||
draw_axis_lines(plot);
|
draw_axis_lines(plot);
|
||||||
set_clip(layout.axes);
|
set_clip(layout.axes);
|
||||||
@@ -616,9 +619,14 @@ void Renderer::upload_text_vertices(std::span<const f32> interleaved) noexcept {
|
|||||||
|
|
||||||
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
|
void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash,
|
||||||
bool snap) noexcept {
|
bool snap) noexcept {
|
||||||
const u32 segments = lines_.segment_count();
|
flush_lines(lines_, map, color, width, dash, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Renderer::flush_lines(render::LineBatch& batch, const Mapping& map, Color color, f32 width,
|
||||||
|
Vec2 dash, bool snap) noexcept {
|
||||||
|
const u32 segments = batch.segment_count();
|
||||||
if (segments != 0 && ready_) {
|
if (segments != 0 && ready_) {
|
||||||
upload_vertices(lines_.data());
|
upload_vertices(batch.data());
|
||||||
line_program_.bind();
|
line_program_.bind();
|
||||||
line_program_.set_scale(map.scale);
|
line_program_.set_scale(map.scale);
|
||||||
line_program_.set_offset(map.offset);
|
line_program_.set_offset(map.offset);
|
||||||
@@ -643,7 +651,7 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
|
|||||||
stats_.staged_segments += segments;
|
stats_.staged_segments += segments;
|
||||||
++stats_.draw_calls;
|
++stats_.draw_calls;
|
||||||
}
|
}
|
||||||
lines_.clear(); // keeps the capacity, so the next group re-uses the store
|
batch.clear(); // keeps the capacity, so the next group re-uses the store
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::flush_text(render::TextBatch& batch) noexcept {
|
void Renderer::flush_text(render::TextBatch& batch) noexcept {
|
||||||
@@ -972,6 +980,379 @@ void Renderer::draw_titles(const Plot& plot, const PlotLayout& layout) {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------- data
|
// ------------------------------------------------------------------- data
|
||||||
|
|
||||||
|
u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points) {
|
||||||
|
const Transform xt = plot.x_transform(trace.axis_x);
|
||||||
|
const Transform yt = plot.y_transform(trace.axis_y);
|
||||||
|
const f32 left = layout.axes.x;
|
||||||
|
// One column per whole pixel of the axes, plus the partial one at the right-hand
|
||||||
|
// edge: `axes.w` is fractional, and the samples that land in that last fraction
|
||||||
|
// are inside the frame.
|
||||||
|
const u32 count = static_cast<u32>(layout.axes.w) + 1u;
|
||||||
|
constexpr f32 kInf = std::numeric_limits<f32>::infinity();
|
||||||
|
columns_.resize(2u * static_cast<usize>(count));
|
||||||
|
for (u32 c = 0; c < count; ++c) {
|
||||||
|
columns_[2u * c] = kInf; // the lowest screen y seen in this column
|
||||||
|
columns_[2u * c + 1u] = -kInf; // and the highest; `lo <= hi` is "present"
|
||||||
|
}
|
||||||
|
|
||||||
|
for (u32 k = 0; k < points; ++k) {
|
||||||
|
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
||||||
|
const f64 sx = xt.to_screen_x(x_abs);
|
||||||
|
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
||||||
|
// A NaN sample, and a non-positive one on a log axis, have no position: both
|
||||||
|
// arrive here as a non-finite screen coordinate.
|
||||||
|
if (!std::isfinite(sx) || !std::isfinite(sy)) continue;
|
||||||
|
const f64 column = std::floor(sx - static_cast<f64>(left));
|
||||||
|
// Clamped, not dropped. The gathered window carries one sample of slack on each
|
||||||
|
// side, and that sample's ink belongs to the edge column -- it is what the
|
||||||
|
// polyline drew there, under the same clip. Nothing further out can arrive: the
|
||||||
|
// window is the visible range's.
|
||||||
|
const u32 c = !(column > 0.0) ? 0u
|
||||||
|
: column >= static_cast<f64>(count - 1u) ? count - 1u
|
||||||
|
: static_cast<u32>(column);
|
||||||
|
const f32 y = static_cast<f32>(sy);
|
||||||
|
if (y < columns_[2u * c]) columns_[2u * c] = y;
|
||||||
|
if (y > columns_[2u * c + 1u]) columns_[2u * c + 1u] = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f32 width = style_.device(trace.width);
|
||||||
|
u32 staged = 0;
|
||||||
|
f32 prev_lo = 0.0f;
|
||||||
|
f32 prev_hi = 0.0f;
|
||||||
|
bool joined = false; // the column before this one is present, so there is a join to make
|
||||||
|
for (u32 c = 0; c < count; ++c) {
|
||||||
|
f32 lo = columns_[2u * c];
|
||||||
|
f32 hi = columns_[2u * c + 1u];
|
||||||
|
if (!(lo <= hi)) {
|
||||||
|
// No sample landed here. A hole in the data stays a hole: bridging it would
|
||||||
|
// draw a vertical line over x the series says nothing about, where the
|
||||||
|
// polyline drew a diagonal across the gap at most one pixel wide.
|
||||||
|
joined = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Connectivity. Two columns' extremes need not overlap -- a fast edge rises
|
||||||
|
// through a whole column -- and two disjoint vertical segments read as a broken
|
||||||
|
// trace, which is exactly what the polyline's connecting segment prevented. The
|
||||||
|
// later column is extended to meet the earlier one's nearest bound, so the join
|
||||||
|
// is made inside the column that moved and no ink appears left of it.
|
||||||
|
if (joined) {
|
||||||
|
if (hi < prev_lo) {
|
||||||
|
hi = prev_lo;
|
||||||
|
} else if (lo > prev_hi) {
|
||||||
|
lo = prev_hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_lo = lo;
|
||||||
|
prev_hi = hi;
|
||||||
|
joined = true;
|
||||||
|
// A span shorter than the stroke is drawn *as* the stroke: the fragment shader
|
||||||
|
// ramps on the distance to the segment's axis and does not cap its ends, so a
|
||||||
|
// vertical segment shorter than its own width rasterises a sliver of itself --
|
||||||
|
// and a column of a flat trace, where `lo == hi`, nothing at all.
|
||||||
|
if (hi - lo < width) {
|
||||||
|
const f32 mid = 0.5f * (lo + hi);
|
||||||
|
lo = mid - 0.5f * width;
|
||||||
|
hi = mid + 0.5f * width;
|
||||||
|
}
|
||||||
|
// The column's centre, in framebuffer pixels. Every segment therefore shares one
|
||||||
|
// sub-pixel phase, which is the whole point (see `draw_traces`), and the flush's
|
||||||
|
// snap puts that one phase on the grid.
|
||||||
|
const f32 x = left + static_cast<f32>(c) + 0.5f;
|
||||||
|
lines_.add_segment({x, lo}, {x, hi});
|
||||||
|
++staged;
|
||||||
|
}
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, const Trace& trace,
|
||||||
|
f64 x_origin, u32 points) {
|
||||||
|
if (points < 2u) return 0u;
|
||||||
|
// At least two steps, or there is nothing to grade and the faintest step is also
|
||||||
|
// the brightest -- a one-step grading is the flat band drawn dimmer, which is not
|
||||||
|
// what any host asking for this wants.
|
||||||
|
const u32 steps = std::max(2u, style_.intensity_steps);
|
||||||
|
// Exactly `steps`, never merely at least: the flush loop reads `steps_.size()` as the
|
||||||
|
// step count and divides by it, so a batch left over from a larger setting would be
|
||||||
|
// flushed at the wrong alpha -- or, being uncleared, at all.
|
||||||
|
steps_.resize(steps);
|
||||||
|
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
|
||||||
|
|
||||||
|
const Transform xt = plot.x_transform(trace.axis_x);
|
||||||
|
const Transform yt = plot.y_transform(trace.axis_y);
|
||||||
|
const f32 left = layout.axes.x;
|
||||||
|
const f32 top = layout.axes.y;
|
||||||
|
const u32 columns = static_cast<u32>(layout.axes.w) + 1u;
|
||||||
|
// One accumulator row per whole pixel of the axes, plus the partial one at the
|
||||||
|
// bottom edge, plus one past the end for a span's closing delta.
|
||||||
|
const u32 rows = static_cast<u32>(layout.axes.h) + 1u;
|
||||||
|
column_rows_.assign(static_cast<usize>(rows) + 1u, 0.0f);
|
||||||
|
|
||||||
|
const f32 width = style_.device(trace.width);
|
||||||
|
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
|
||||||
|
const f32 bottom = top + layout.axes.h;
|
||||||
|
|
||||||
|
u32 staged = 0;
|
||||||
|
u32 column = 0; // the column being accumulated
|
||||||
|
bool open = false; // it has at least one segment
|
||||||
|
u32 prev_column = 0; // the last column that emitted anything, for the join below
|
||||||
|
f32 prev_lo = 0.0f;
|
||||||
|
f32 prev_hi = 0.0f;
|
||||||
|
bool joined = false;
|
||||||
|
// The smallest deposit any segment made into the column being accumulated, which is
|
||||||
|
// what bounds the "lit at all" test below. Reset per column by `emit_column()`.
|
||||||
|
f32 w_min = std::numeric_limits<f32>::infinity();
|
||||||
|
|
||||||
|
// Reads one column's accumulator out as graded runs and clears it, so the next column
|
||||||
|
// starts empty. Two walks of it, which is nothing -- it is one column wide and in L1 --
|
||||||
|
// and the first is what makes the second's grading *relative to this column*:
|
||||||
|
//
|
||||||
|
// the brightest row of a column is always the top tier. That is deliberate and it is
|
||||||
|
// the difference between grading and dimming. The alternative, one scale for the whole
|
||||||
|
// frame, needs a pass over every column before any of them can be drawn (an
|
||||||
|
// `axes.w * axes.h` image, 33 MB at 4K) and makes a column the signal flew through
|
||||||
|
// *uniformly* faint -- a trace that fades out where it moves fastest, which is where a
|
||||||
|
// reader is most often looking. Per column, the dwell structure *inside* each one is
|
||||||
|
// what the tiers spend their range on: the rails of a sine are bright and its fast
|
||||||
|
// middle is faint, in every column.
|
||||||
|
//
|
||||||
|
// The runs are maximal stretches of equal tier, so this is O(rows) segments per column
|
||||||
|
// at worst and a handful in practice: a column of a clean waveform is a bright ridge
|
||||||
|
// and a faint tail.
|
||||||
|
const auto emit_column = [&]() {
|
||||||
|
const f32 x = left + static_cast<f32>(column) + 0.5f;
|
||||||
|
f32 peak = 0.0f;
|
||||||
|
{
|
||||||
|
f32 cover = 0.0f;
|
||||||
|
for (u32 r = 0; r < rows; ++r) {
|
||||||
|
cover += column_rows_[r];
|
||||||
|
if (cover > peak) peak = cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
|
||||||
|
// What counts as covered at all: **a row one segment covered is lit**. The deposits
|
||||||
|
// are fractions, so this cannot be an integer test, and it cannot be a share of the
|
||||||
|
// column's peak alone either -- that was this function's one real defect. A square
|
||||||
|
// wave's riser is a single segment spread over the whole axis, so it deposits
|
||||||
|
// `1/rows` per row, while a flat level in the same column is one deposit of 1.0 per
|
||||||
|
// sub-bucket piled on one row: at 105 samples a column the peak is ~50 and the share
|
||||||
|
// floor is ~0.5, eighty times the riser's own 0.006. Every row of the edge was
|
||||||
|
// unlit, and the connector could not bridge it either -- the two levels are both lit,
|
||||||
|
// so the column's band already spans the gap and neither gap test fires. Measured on
|
||||||
|
// a true square at 104.8 spp over a 312-row axis: 106 of 116 riser columns drawn as
|
||||||
|
// two disconnected marks with up to 311 dark rows between them, the other 10 kept by
|
||||||
|
// the phase that put the riser in a column of its own -- which is why it read as an
|
||||||
|
// edge *flickering* rather than as one missing. `intensity_floor` guards a lit row's
|
||||||
|
// alpha and was documented as preventing exactly this; it never got the chance.
|
||||||
|
//
|
||||||
|
// So the share term is kept only for what it is good for -- suppressing residue from
|
||||||
|
// the running-sum reconstruction -- and is capped by half the smallest deposit the
|
||||||
|
// column actually received, which no real segment's contribution can fall under.
|
||||||
|
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
|
||||||
|
f32 cover = 0.0f;
|
||||||
|
bool in_run = false;
|
||||||
|
u32 run_tier = 0;
|
||||||
|
u32 run_start = 0;
|
||||||
|
f32 band_lo = 0.0f;
|
||||||
|
f32 band_hi = 0.0f;
|
||||||
|
bool any = false;
|
||||||
|
// One past the last row too: it closes a run that reaches the bottom edge.
|
||||||
|
for (u32 r = 0; r <= rows; ++r) {
|
||||||
|
cover += column_rows_[r];
|
||||||
|
column_rows_[r] = 0.0f;
|
||||||
|
const bool lit = r < rows && cover > lit_floor;
|
||||||
|
u32 tier = 0;
|
||||||
|
if (lit) {
|
||||||
|
const f32 share = std::min(1.0f, cover * inv);
|
||||||
|
const u32 t = static_cast<u32>(std::pow(share, gamma) * static_cast<f32>(steps));
|
||||||
|
tier = std::min(steps - 1u, t);
|
||||||
|
}
|
||||||
|
if (in_run && (!lit || tier != run_tier)) {
|
||||||
|
f32 lo = top + static_cast<f32>(run_start);
|
||||||
|
f32 hi = top + static_cast<f32>(r);
|
||||||
|
// A run shorter than the stroke is drawn *as* the stroke, for the reason
|
||||||
|
// `stage_trace_columns()` gives: the fragment shader ramps on the distance to
|
||||||
|
// the segment's axis and does not cap its ends.
|
||||||
|
if (hi - lo < width) {
|
||||||
|
const f32 mid = 0.5f * (lo + hi);
|
||||||
|
lo = mid - 0.5f * width;
|
||||||
|
hi = mid + 0.5f * width;
|
||||||
|
}
|
||||||
|
steps_[run_tier].add_segment({x, lo}, {x, hi});
|
||||||
|
++staged;
|
||||||
|
in_run = false;
|
||||||
|
}
|
||||||
|
if (lit && !in_run) {
|
||||||
|
run_start = r;
|
||||||
|
run_tier = tier;
|
||||||
|
in_run = true;
|
||||||
|
}
|
||||||
|
if (lit) {
|
||||||
|
if (!any) {
|
||||||
|
band_lo = top + static_cast<f32>(r);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
band_hi = top + static_cast<f32>(r) + 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
w_min = std::numeric_limits<f32>::infinity();
|
||||||
|
if (!any) {
|
||||||
|
joined = false; // nothing visible here: a hole, like an uncovered column
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Connectivity, the same rule as the flat path: two columns' bands need not
|
||||||
|
// overlap -- a fast edge rises through a whole column -- and two disjoint
|
||||||
|
// verticals read as a broken trace. The gap is bridged by a segment of its own at
|
||||||
|
// the **faintest** tier rather than by extending a run, because a connector is
|
||||||
|
// inferred geometry and not a place the signal was measured to dwell: grading it
|
||||||
|
// bright would claim the opposite of what it is.
|
||||||
|
if (joined && column == prev_column + 1u) {
|
||||||
|
if (band_hi < prev_lo) {
|
||||||
|
steps_[0].add_segment({x, band_hi}, {x, prev_lo});
|
||||||
|
++staged;
|
||||||
|
} else if (band_lo > prev_hi) {
|
||||||
|
steps_[0].add_segment({x, prev_hi}, {x, band_lo});
|
||||||
|
++staged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_lo = band_lo;
|
||||||
|
prev_hi = band_hi;
|
||||||
|
prev_column = column;
|
||||||
|
joined = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
f64 px = 0.0;
|
||||||
|
f64 py = 0.0;
|
||||||
|
bool have_prev = false;
|
||||||
|
for (u32 k = 0; k < points; ++k) {
|
||||||
|
const f64 x_abs = x_origin + static_cast<f64>(gather_[2u * k]);
|
||||||
|
const f64 sx = xt.to_screen_x(x_abs);
|
||||||
|
const f64 sy = yt.to_screen_y(static_cast<f64>(gather_[2u * k + 1u]));
|
||||||
|
// A NaN sample, and a non-positive one on a log axis, have no position -- and it
|
||||||
|
// breaks the chain as well as skipping the point: a segment from the sample
|
||||||
|
// before it to the one after would cross x the series says nothing about.
|
||||||
|
if (!std::isfinite(sx) || !std::isfinite(sy)) {
|
||||||
|
have_prev = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (have_prev) {
|
||||||
|
// The *segment*, not the point, is what carries dwell: it is a stretch of the
|
||||||
|
// waveform, and where it went slowly it covers few rows and where it went fast it
|
||||||
|
// covers many. This is where a height-based heuristic cannot reach -- a column's
|
||||||
|
// min and max cannot tell a ramp from a pulse, and a column's segments can (see
|
||||||
|
// `Style::intensity_grading`).
|
||||||
|
//
|
||||||
|
// It is charged to the column its earlier end falls in. The gather is asked at
|
||||||
|
// `intensity_sub_columns` buckets per column, so a segment is a fraction of a
|
||||||
|
// column wide and splitting one across a boundary would buy a fraction of one
|
||||||
|
// sub-bucket's dwell.
|
||||||
|
const f64 c = std::floor(px - static_cast<f64>(left));
|
||||||
|
// Clamped, not dropped, for the reason `stage_trace_columns()` gives: the
|
||||||
|
// gathered window carries one sample of slack on each side and that sample's
|
||||||
|
// ink belongs to the edge column.
|
||||||
|
const u32 ci = !(c > 0.0) ? 0u
|
||||||
|
: c >= static_cast<f64>(columns - 1u) ? columns - 1u
|
||||||
|
: static_cast<u32>(c);
|
||||||
|
if (open && ci != column) emit_column(); // x is increasing, so ci > column
|
||||||
|
if (!open) {
|
||||||
|
column = ci;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
const f64 lo = std::min(py, sy);
|
||||||
|
const f64 hi = std::max(py, sy);
|
||||||
|
// Wholly outside the axes is wholly clipped, and a clamp would move its ink to
|
||||||
|
// the edge and invent a ridge there. A segment that *crosses* the edge is
|
||||||
|
// clamped instead, which is the coverage the clip leaves -- so a column's shares
|
||||||
|
// are shares of what is visible in it, which is what is being graded.
|
||||||
|
if (hi >= static_cast<f64>(top) && lo <= static_cast<f64>(bottom)) {
|
||||||
|
const f64 ra = std::floor(lo - static_cast<f64>(top));
|
||||||
|
const f64 rb = std::floor(hi - static_cast<f64>(top));
|
||||||
|
const u32 r0 = !(ra > 0.0) ? 0u
|
||||||
|
: ra >= static_cast<f64>(rows - 1u) ? rows - 1u
|
||||||
|
: static_cast<u32>(ra);
|
||||||
|
const u32 r1 = !(rb > 0.0) ? 0u
|
||||||
|
: rb >= static_cast<f64>(rows - 1u) ? rows - 1u
|
||||||
|
: static_cast<u32>(rb);
|
||||||
|
// A segment deposits one unit of dwell, **spread over the rows it covers** --
|
||||||
|
// which is the whole measurement, and counting the rows instead was wrong in a
|
||||||
|
// way worth recording: a sub-bucket holds about as many samples as any other, so
|
||||||
|
// a segment crossing a hundred rows is those samples *passing through* each of
|
||||||
|
// them, while one covering two rows is the same samples *sitting* in them. Giving
|
||||||
|
// each crossed row a full unit makes a sine's fast middle as bright as its rails,
|
||||||
|
// which is the pattern grading exists to show.
|
||||||
|
//
|
||||||
|
// Two writes whatever the height: the running sum over the column is then the
|
||||||
|
// dwell at each row.
|
||||||
|
const f32 w = 1.0f / static_cast<f32>(r1 - r0 + 1u);
|
||||||
|
if (w < w_min) w_min = w;
|
||||||
|
column_rows_[r0] += w;
|
||||||
|
column_rows_[r1 + 1u] -= w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
px = sx;
|
||||||
|
py = sy;
|
||||||
|
have_prev = true;
|
||||||
|
}
|
||||||
|
if (open) emit_column();
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Renderer::stage_trace_slopes(const Plot& plot, const Trace& trace, const f32* xy, u32 points,
|
||||||
|
f64 x_origin, bool log_x, bool log_y) {
|
||||||
|
if (points < 2u) return 0u;
|
||||||
|
// At least two, for the reason `stage_trace_density()` gives: a one-step grading is
|
||||||
|
// the whole trace drawn dimmer, which is not what a host asking for this wants.
|
||||||
|
const u32 steps = std::max(2u, style_.intensity_steps);
|
||||||
|
steps_.resize(steps);
|
||||||
|
for (u32 t = 0; t < steps; ++t) steps_[t].clear();
|
||||||
|
|
||||||
|
const Transform yt = plot.y_transform(trace.axis_y);
|
||||||
|
const f32 gamma = style_.intensity_gamma > 0.0f ? style_.intensity_gamma : 1.0f;
|
||||||
|
const f32 fsteps = static_cast<f32>(steps);
|
||||||
|
|
||||||
|
u32 staged = 0;
|
||||||
|
for (u32 i = 0; i + 1u < points; ++i) {
|
||||||
|
const f64 y0 = static_cast<f64>(xy[2u * i + 1u]);
|
||||||
|
const f64 y1 = static_cast<f64>(xy[2u * i + 3u]);
|
||||||
|
const f64 s0 = yt.to_screen_y(y0);
|
||||||
|
const f64 s1 = yt.to_screen_y(y1);
|
||||||
|
// A NaN sample, and a non-positive one on a log axis, have no position, and the
|
||||||
|
// *segment* is what is being staged -- so one unplaceable end drops it entirely
|
||||||
|
// rather than being clamped to somewhere the series says nothing about. The
|
||||||
|
// ungraded polyline reaches the same conclusion through `add_polyline_xy()`, which
|
||||||
|
// a non-finite vertex excludes from both of its segments.
|
||||||
|
if (!std::isfinite(s0) || !std::isfinite(s1)) continue;
|
||||||
|
|
||||||
|
// Dwell, the same quantity `stage_trace_density()` deposits and for the same
|
||||||
|
// reason: a segment is a stretch of the waveform, and the rows it spans are the
|
||||||
|
// rows the signal crossed *on its way* rather than sat in. `1 + span` so a flat
|
||||||
|
// segment -- span 0, the commonest case in a sparse window -- is 1 and not a
|
||||||
|
// division by zero, which also makes the measure dimensionless and absolute: no
|
||||||
|
// per-column peak exists to normalise against down here.
|
||||||
|
const f32 span = static_cast<f32>(std::fabs(s1 - s0));
|
||||||
|
const f32 dwell = 1.0f / (1.0f + span);
|
||||||
|
const u32 t = std::min(steps - 1u, static_cast<u32>(std::pow(dwell, gamma) * fsteps));
|
||||||
|
|
||||||
|
// Staged exactly as `stage_xy()` does, so the geometry is bit-for-bit the ungraded
|
||||||
|
// polyline's and only the batch differs. The linear case needs no mapping at all --
|
||||||
|
// the gather already emitted x relative to this origin.
|
||||||
|
if (!log_x && !log_y) {
|
||||||
|
steps_[t].add_segment(Vec2{xy[2u * i], xy[2u * i + 1u]},
|
||||||
|
Vec2{xy[2u * i + 2u], xy[2u * i + 3u]});
|
||||||
|
} else {
|
||||||
|
const f64 ox = space(x_origin, log_x);
|
||||||
|
steps_[t].add_segment(
|
||||||
|
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i]) + x_origin, log_x) - ox),
|
||||||
|
static_cast<f32>(space(y0, log_y))},
|
||||||
|
Vec2{static_cast<f32>(space(static_cast<f64>(xy[2u * i + 2u]) + x_origin, log_x) - ox),
|
||||||
|
static_cast<f32>(space(y1, log_y))});
|
||||||
|
}
|
||||||
|
++staged;
|
||||||
|
}
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
||||||
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
// Both dimensions: a zero-width rect selects no level, and a zero-height one
|
||||||
// would flatten every trace onto a single row -- a frame that looks like a
|
// would flatten every trace onto a single row -- a frame that looks like a
|
||||||
@@ -1013,9 +1394,30 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
// This is the whole performance contract: the window, never the series.
|
// This is the whole performance contract: the window, never the series.
|
||||||
const u64 visible = last - first;
|
const u64 visible = last - first;
|
||||||
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
|
const f64 samples_per_pixel = static_cast<f64>(visible) / static_cast<f64>(layout.axes.w);
|
||||||
const u32 level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
// Grading reads a column's *distribution*, which one envelope per column cannot
|
||||||
? series.pyramid().level_for(n, samples_per_pixel)
|
// carry (`Style::intensity_grading`), so the gather is asked for the level a
|
||||||
: 0u;
|
// fraction of a column implies. The *gate* is still the pixel grid's density: a
|
||||||
|
// window sparse enough to be drawn as a polyline is drawn as one, graded or not.
|
||||||
|
const bool grade = style_.intensity_grading && trace.mode == data::GatherMode::Envelope &&
|
||||||
|
style_.intensity_sub_columns > 1u && style_.intensity_steps > 1u;
|
||||||
|
const u32 flat_level = samples_per_pixel >= static_cast<f64>(style_.dense_threshold_spp)
|
||||||
|
? series.pyramid().level_for(n, samples_per_pixel)
|
||||||
|
: 0u;
|
||||||
|
// The finer level, floored at the pyramid's coarsest bucket level. Grading asks for a
|
||||||
|
// fraction of a column and `level_for` answers 0 -- raw samples -- as soon as that
|
||||||
|
// fraction falls under one bucket, so an unfloored ask turns every moderately dense
|
||||||
|
// frame into an O(visible) gather: measured at 1.3 MS/s over a 2 s window, 172 samples
|
||||||
|
// a pixel, where the flat path sits on the pyramid and grading dropped the whole figure
|
||||||
|
// to raw (`raw=1154 lod=0` against `raw=710 lod=453`) and cost 2.3 -> 6.4 ms a frame.
|
||||||
|
// The floor degrades the *look* instead: fewer sub-columns than asked for, down to
|
||||||
|
// whatever the coarsest buckets give, which at 64 samples a bucket is still two or three
|
||||||
|
// per column. Grading is a way of drawing a reduction and may not undo it.
|
||||||
|
const u32 level =
|
||||||
|
grade && flat_level > 0u
|
||||||
|
? std::max(1u, series.pyramid().level_for(
|
||||||
|
n, samples_per_pixel /
|
||||||
|
static_cast<f64>(style_.intensity_sub_columns)))
|
||||||
|
: flat_level;
|
||||||
|
|
||||||
gather_.clear();
|
gather_.clear();
|
||||||
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
|
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
|
||||||
@@ -1111,8 +1513,97 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
|
|||||||
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
|
// trace/axis hierarchy on a high-DPI panel. `width == 0` is scatter: the
|
||||||
// markers below are the whole trace.
|
// markers below are the whole trace.
|
||||||
if (trace.width > 0.0f) {
|
if (trace.width > 0.0f) {
|
||||||
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
|
// A window denser than two gathered points per pixel column is drawn as *columns*
|
||||||
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
// rather than as a polyline: one exactly vertical, pixel-snapped segment per column,
|
||||||
|
// spanning what that column's own points reach.
|
||||||
|
//
|
||||||
|
// The polyline's geometry at this density is a stack of near-vertical hairlines at
|
||||||
|
// *fractional* x -- an envelope bucket spans (spp/2, spp] samples, so there are one to
|
||||||
|
// two buckets per column, each emitting its own min->max hairline plus a connector to
|
||||||
|
// the next, about three and a half per column. Each is composited with its own
|
||||||
|
// sub-pixel coverage, so a column's brightness is a non-linear function of those
|
||||||
|
// phases; the phases repeat at the beat between the bucket pitch and the pixel pitch,
|
||||||
|
// which is a **moire** across the trace, and panning sweeps the beat so the pattern
|
||||||
|
// crawls. It is strongest exactly where a scope is used hardest: a high sample rate, a
|
||||||
|
// high signal frequency, or a noisy signal, all of which make the ribbon thin enough
|
||||||
|
// for its per-column coverage to be visible as texture.
|
||||||
|
//
|
||||||
|
// Reducing to one segment per column removes the cause rather than the symptom: there
|
||||||
|
// is one phase on the whole trace (and the snap puts it on the grid), so there is
|
||||||
|
// nothing left to beat. It is also O(axes.w) geometry instead of O(buckets), and it
|
||||||
|
// loses nothing a reader could have seen -- a column cannot show more than the extremes
|
||||||
|
// of what it covers, which is what the band is.
|
||||||
|
//
|
||||||
|
// Two conditions, and each refuses a case this would damage.
|
||||||
|
// - `Envelope` only. `Nearest` and `Mean` are one-point-per-bucket modes chosen so the
|
||||||
|
// trace reads as *samples* or as a smoothed line; a min/max band would answer a
|
||||||
|
// different question from the one the host asked.
|
||||||
|
// - Two points per column. Below that the gather is sparser than the grid, and the
|
||||||
|
// polyline's segments are the shape: `Series::gather()` also falls back to raw samples
|
||||||
|
// when no complete bucket fits the range, and that window's trace is drawn by the two
|
||||||
|
// segments that enter and leave the frame (see
|
||||||
|
// `a_sparse_window_between_samples_still_inks_both_margins`). The real envelope path
|
||||||
|
// emits two to four points per column by construction, so the test holds exactly when
|
||||||
|
// it ran.
|
||||||
|
const bool columns = trace.mode == data::GatherMode::Envelope &&
|
||||||
|
static_cast<f64>(points) >= 2.0 * static_cast<f64>(layout.axes.w);
|
||||||
|
// `snap = true` on geometry that is not chrome, which is the one carve-out in that
|
||||||
|
// uniform's rule ("a line that reports a data position may not be moved off it"): a
|
||||||
|
// column *is* a pixel column, and its x is already a screen coordinate derived from
|
||||||
|
// one -- there is no data position to move it off. The y bounds, which are what the
|
||||||
|
// segment reports, are untouched by the snap. It holds for both staging paths.
|
||||||
|
const u32 staged =
|
||||||
|
!columns ? 0u
|
||||||
|
: grade ? stage_trace_density(plot, layout, trace, x_range.min, points)
|
||||||
|
: stage_trace_columns(plot, layout, trace, x_range.min, points);
|
||||||
|
if (staged != 0u && grade) {
|
||||||
|
// One draw call per alpha step, faintest first: the line pipeline's colour is a
|
||||||
|
// uniform, so a per-segment alpha is not expressible and a step is a batch. They
|
||||||
|
// are flushed in order so the bright ridge composites over the faint tail rather
|
||||||
|
// than under it -- the runs within a column do not overlap, but their AA fringes
|
||||||
|
// do, and that is the order a phosphor display puts them in.
|
||||||
|
for (u32 t = 0; t < steps_.size(); ++t) {
|
||||||
|
if (steps_[t].segment_count() == 0u) continue;
|
||||||
|
Color color = trace.color;
|
||||||
|
color.a *= style_.intensity_floor +
|
||||||
|
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
|
||||||
|
static_cast<f32>(steps_.size() - 1u);
|
||||||
|
flush_lines(steps_[t], pixel_mapping(), color, style_.device(trace.width), dash, true);
|
||||||
|
}
|
||||||
|
++stats_.column_traces;
|
||||||
|
++stats_.graded_traces;
|
||||||
|
} else if (staged != 0u) {
|
||||||
|
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
|
||||||
|
++stats_.column_traces;
|
||||||
|
} else {
|
||||||
|
// The sparse half of the phosphor model. `grade` is not reused as the gate: it
|
||||||
|
// requires `Envelope`, because the *column reduction* turns a bucket's min/max
|
||||||
|
// into a band and that answers a different question from the one a `Nearest` or
|
||||||
|
// `Mean` host asked. A segment's slope is a property of the polyline itself, so
|
||||||
|
// no gather mode is excluded here.
|
||||||
|
const u32 sparse =
|
||||||
|
style_.intensity_grading && style_.intensity_steps > 1u
|
||||||
|
? stage_trace_slopes(plot, trace, gather_.data(), points, x_range.min, log_x,
|
||||||
|
log_y)
|
||||||
|
: 0u;
|
||||||
|
if (sparse != 0u) {
|
||||||
|
// Faintest first, and `snap = false` -- the same order as the graded columns
|
||||||
|
// for the same compositing reason, but these segments sit at *data* positions
|
||||||
|
// and the snap uniform's rule applies to them in full.
|
||||||
|
for (u32 t = 0; t < steps_.size(); ++t) {
|
||||||
|
if (steps_[t].segment_count() == 0u) continue;
|
||||||
|
Color color = trace.color;
|
||||||
|
color.a *= style_.intensity_floor +
|
||||||
|
(1.0f - style_.intensity_floor) * static_cast<f32>(t) /
|
||||||
|
static_cast<f32>(steps_.size() - 1u);
|
||||||
|
flush_lines(steps_[t], map, color, style_.device(trace.width), dash, false);
|
||||||
|
}
|
||||||
|
++stats_.slope_traces;
|
||||||
|
} else {
|
||||||
|
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
|
||||||
|
flush_lines(map, trace.color, style_.device(trace.width), dash);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markers, staged as line segments through this same batch in *pixel* space
|
// Markers, staged as line segments through this same batch in *pixel* space
|
||||||
@@ -1240,6 +1731,12 @@ void Renderer::draw_cursor_label(const Plot& plot, const PlotLayout& layout, con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Renderer::draw_invalid_spans(const PlotLayout& layout) {
|
||||||
|
for (const InvalidSpanLayout& band : layout.invalid_spans) {
|
||||||
|
fill_pixels(band.fill, band.repaired ? style_.invalid_repaired_fill : style_.invalid_fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
|
void Renderer::draw_regions(const Plot& plot, const PlotLayout& layout) {
|
||||||
const HotHandles hot = plot.hot_handles();
|
const HotHandles hot = plot.hot_handles();
|
||||||
const f32 edge_w = style_.device(style_.line_width_px);
|
const f32 edge_w = style_.device(style_.line_width_px);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -57,7 +58,9 @@ inline void check_near(double a, double b, const char* file, int line, const cha
|
|||||||
|
|
||||||
inline int run_all() {
|
inline int run_all() {
|
||||||
int failed = 0;
|
int failed = 0;
|
||||||
|
const char* only = std::getenv("PLOT_TEST_FILTER");
|
||||||
for (auto& c : cases()) {
|
for (auto& c : cases()) {
|
||||||
|
if (only != nullptr && std::string(c.name).find(only) == std::string::npos) continue;
|
||||||
try {
|
try {
|
||||||
c.fn();
|
c.fn();
|
||||||
std::printf("[ ok ] %s\n", c.name);
|
std::printf("[ ok ] %s\n", c.name);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <plot/data/detail/merge_count.hpp>
|
#include <plot/data/detail/merge_count.hpp>
|
||||||
#include <plot/data/minmax_pyramid.hpp>
|
#include <plot/data/minmax_pyramid.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
#include <random>
|
#include <random>
|
||||||
|
|
||||||
using plot::data::kBucket;
|
using plot::data::kBucket;
|
||||||
@@ -342,3 +343,69 @@ PLOT_TEST(pyramid_level_cap_keeps_the_span_shift_defined) {
|
|||||||
p.append(1.0f);
|
p.append(1.0f);
|
||||||
PLOT_CHECK(p.level_count() >= 1);
|
PLOT_CHECK(p.level_count() >= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(amend_folds_a_backfilled_sample_into_every_covering_level) {
|
||||||
|
// A hole is appended as NaN -- which the pyramid already stores as an empty aggregate --
|
||||||
|
// and amended later when the packet that owned it finally arrives. The amendment has to
|
||||||
|
// reach every level that covers the index, because the renderer reads whichever level the
|
||||||
|
// zoom picked: repairing level 0 alone would make a trace whole when zoomed in and still
|
||||||
|
// broken when zoomed out.
|
||||||
|
MinMaxPyramid p;
|
||||||
|
p.reserve(8 * kBucket);
|
||||||
|
for (u64 i = 0; i < 8 * kBucket; ++i) {
|
||||||
|
// One NaN, at an index comfortably inside the first bucket of every level.
|
||||||
|
p.append(i == 5 ? std::numeric_limits<f32>::quiet_NaN() : 1.0f);
|
||||||
|
}
|
||||||
|
// Before: every covering bucket aggregates kBucket-1 ones and knows nothing of index 5.
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == kBucket - 1);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 1.0);
|
||||||
|
|
||||||
|
p.amend(5, 7.0f);
|
||||||
|
|
||||||
|
// Level 0 gained the sample.
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == kBucket);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).max, 7.0);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket_sum(0, 0), f64(kBucket - 1) + 7.0);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket_sumsq(0, 0), f64(kBucket - 1) + 49.0);
|
||||||
|
// And so did every coarser level that covers it.
|
||||||
|
for (u32 l = 1; l < p.level_count(); ++l) {
|
||||||
|
if (p.level_size(l) == 0) continue;
|
||||||
|
const u64 span = u64{kBucket} << l;
|
||||||
|
const u64 b = 5 / span;
|
||||||
|
if (b < p.level_base(l) || b - p.level_base(l) >= p.level_size(l)) continue;
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(l, b).max, 7.0);
|
||||||
|
PLOT_CHECK(p.bucket_count(l, b) == span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(amend_makes_an_all_nan_bucket_present_again) {
|
||||||
|
// A bucket whose every sample was non-finite is stored with count 0, which every reader
|
||||||
|
// treats as absent. One amendment has to make it present -- min and max become the value
|
||||||
|
// rather than widening from a meaningless zero, which is the case a plain fold gets wrong.
|
||||||
|
MinMaxPyramid p;
|
||||||
|
p.reserve(4 * kBucket);
|
||||||
|
const f32 nan = std::numeric_limits<f32>::quiet_NaN();
|
||||||
|
for (u64 i = 0; i < 2 * kBucket; ++i) p.append(i < kBucket ? nan : 1.0f);
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == 0);
|
||||||
|
|
||||||
|
p.amend(3, -2.0f);
|
||||||
|
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 0) == 1);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).min, -2.0);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 0).max, -2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(amend_outside_the_window_is_a_no_op) {
|
||||||
|
// An index the window has already dropped, or one not yet appended, has nowhere to go.
|
||||||
|
// Silently ignoring it is what lets the caller amend without first proving the ring still
|
||||||
|
// holds the slot -- a race it cannot win, because the window slides on the other thread.
|
||||||
|
MinMaxPyramid p;
|
||||||
|
p.reserve(4 * kBucket);
|
||||||
|
for (u64 i = 0; i < 4 * kBucket; ++i) p.append(1.0f);
|
||||||
|
p.drop_before(2 * kBucket);
|
||||||
|
const u64 before = p.bucket_count(0, 2);
|
||||||
|
p.amend(5, 99.0f); // dropped
|
||||||
|
p.amend(100 * kBucket, 99.0f); // never appended
|
||||||
|
PLOT_CHECK(p.bucket_count(0, 2) == before);
|
||||||
|
PLOT_CHECK_NEAR(p.bucket(0, 2).max, 1.0);
|
||||||
|
}
|
||||||
@@ -38,6 +38,23 @@ constexpr plot::u32 kH = 360;
|
|||||||
/// 1M-wide window holds ~16 of them (the dense envelope spans the full height in
|
/// 1M-wide window holds ~16 of them (the dense envelope spans the full height in
|
||||||
/// every column) while a 640-wide one is a nearly flat line (the sparse path's
|
/// every column) while a 640-wide one is a nearly flat line (the sparse path's
|
||||||
/// polyline crosses every column exactly once).
|
/// polyline crosses every column exactly once).
|
||||||
|
/// A sine of **one period per pixel column** for the window
|
||||||
|
/// `a_dense_trace_can_be_graded_by_dwell` draws (60 000 x-units over a 572 px axis, so
|
||||||
|
/// 104.9 samples a column). That is the one density at which a column *has* a dwell
|
||||||
|
/// structure to grade: its envelope spans the full amplitude, so the band is tall, and
|
||||||
|
/// the sub-buckets inside it are a fraction of a period each, so the ones at the sine's
|
||||||
|
/// turning points are short and the ones crossing its middle are long. A period of many
|
||||||
|
/// columns leaves every band a thin ribbon with nothing to distinguish, and a period of a
|
||||||
|
/// fraction of a column fills every sub-bucket to the full amplitude -- also nothing.
|
||||||
|
std::vector<plot::f32> fast_sine_samples() {
|
||||||
|
std::vector<plot::f32> y(1'000'000);
|
||||||
|
const double w = 2.0 * 3.14159265358979323846 / 104.9;
|
||||||
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
||||||
|
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * w));
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<plot::f32> sine_samples() {
|
std::vector<plot::f32> sine_samples() {
|
||||||
std::vector<plot::f32> y(1'000'000);
|
std::vector<plot::f32> y(1'000'000);
|
||||||
for (plot::u64 i = 0; i < y.size(); ++i) {
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
||||||
@@ -868,6 +885,260 @@ PLOT_TEST(dense_and_sparse_paths_agree_on_extent) {
|
|||||||
ctx.destroy();
|
ctx.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A window denser than the pixel grid inks **whole columns**, which is what kills the moire.
|
||||||
|
///
|
||||||
|
/// Reported as "strong moire patterns due to a large number of vertical lines" on a high rate,
|
||||||
|
/// a high frequency or a noisy signal. The polyline at this density is a stack of near-vertical
|
||||||
|
/// hairlines at *fractional* x -- one to two envelope buckets per column, each a min->max
|
||||||
|
/// hairline plus a connector -- so a column's brightness is a non-linear function of those
|
||||||
|
/// sub-pixel phases, and the phases repeat at the beat between the bucket pitch and the pixel
|
||||||
|
/// pitch. That beat is the pattern, and panning sweeps it, so it crawls.
|
||||||
|
///
|
||||||
|
/// The observable is the beat's own cause, measured: how many **distinct coverage levels** the
|
||||||
|
/// trace's ink takes. One segment per column puts every column's ink at one sub-pixel phase, so
|
||||||
|
/// the AA ramp is evaluated at the same few distances everywhere and the ink lands on **4**
|
||||||
|
/// levels; the polyline's hairlines sit at arbitrary phases and its ink spreads over **126** of
|
||||||
|
/// them. That spread *is* the texture, and it is what beats against the pixel pitch.
|
||||||
|
///
|
||||||
|
/// A count of partially covered pixels is *not* the reading, which is worth knowing before
|
||||||
|
/// adding one: both renderings put about the same amount of ink in the fringe (577 against 652
|
||||||
|
/// here, of 1400-odd inked pixels), because a 1.5 px stroke with a 1 px fringe always spills
|
||||||
|
/// into its neighbour. The fix is not that the fringe is gone, it is that it is the *same*
|
||||||
|
/// fringe in every column.
|
||||||
|
///
|
||||||
|
/// The window is one period of the sine over 107 samples per pixel: dense enough for the
|
||||||
|
/// reduction and slow enough that the band is a *thin ribbon*, which is where the beat shows.
|
||||||
|
/// A window whose buckets each span several periods paints a solid block and hides it.
|
||||||
|
PLOT_TEST(a_dense_trace_is_drawn_as_whole_pixel_columns) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
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(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||||
|
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
dump_if_requested(r, "columns.ppm");
|
||||||
|
|
||||||
|
const plot::Rect a = p.layout().axes;
|
||||||
|
// Blue over the background minus red is the trace's coverage, linearly: 8 at no coverage
|
||||||
|
// and 199 at full (0x40C0FF over 0x101418). Nothing else in this frame is blue-dominant --
|
||||||
|
// the grid and the axis line are grey -- so the two bands below are the trace's own ink,
|
||||||
|
// split at three quarters covered.
|
||||||
|
plot::u32 inked = 0;
|
||||||
|
plot::u32 histogram[256] = {};
|
||||||
|
for (plot::u32 row = 0; row < static_cast<plot::u32>(a.h); ++row) {
|
||||||
|
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
|
||||||
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||||
|
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
||||||
|
if (cover > 40) { // the trace's ink, at a quarter covered or better
|
||||||
|
++inked;
|
||||||
|
++histogram[cover];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// How many *distinct* coverages the ink takes is the beat, measured. A column's ink is
|
||||||
|
// placed at one sub-pixel phase, so the AA ramp is evaluated at the same few distances in
|
||||||
|
// every column and the ink lands on a handful of levels; a polyline's hairlines sit at
|
||||||
|
// arbitrary phases, so its ink spreads over the whole range of them. The floor of 4 pixels
|
||||||
|
// per level ignores the stragglers at the ribbon's turning points.
|
||||||
|
plot::u32 levels = 0;
|
||||||
|
for (plot::u32 c = 0; c < 256u; ++c) {
|
||||||
|
if (histogram[c] >= 4u) ++levels;
|
||||||
|
}
|
||||||
|
std::printf("[columns] axes=[%.1f %.1f %.1f %.1f] raw=%u lod=%u columns=%u segments=%u "
|
||||||
|
"visible=%llu inked=%u levels=%u\n",
|
||||||
|
a.x, a.y, a.w, a.h, r.stats().raw_traces, r.stats().lod_traces,
|
||||||
|
r.stats().column_traces, r.stats().staged_segments,
|
||||||
|
static_cast<unsigned long long>(r.stats().visible_samples), inked, levels);
|
||||||
|
|
||||||
|
// The reduction ran, and on the *raw* path: the gate is the gathered density, not the level.
|
||||||
|
PLOT_CHECK(r.stats().column_traces == 1);
|
||||||
|
PLOT_CHECK(r.stats().visible_samples > 50'000u);
|
||||||
|
// One segment per column, so the frame's whole geometry -- grid and axis lines included --
|
||||||
|
// is bounded by the axis' width: 627 against the polyline's 60 054.
|
||||||
|
PLOT_CHECK(r.stats().staged_segments < static_cast<plot::u32>(a.w) + 64u);
|
||||||
|
// The ribbon is there, and its ink sits on a handful of coverage levels (4) rather than on a
|
||||||
|
// continuum of them (126).
|
||||||
|
PLOT_CHECK(inked > static_cast<plot::u32>(a.w));
|
||||||
|
PLOT_CHECK(levels <= 8u);
|
||||||
|
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Style::intensity_grading`: a reduced column is drawn **graded by dwell** -- bright
|
||||||
|
/// where the signal sits, faint where it only passes through -- which is the second half
|
||||||
|
/// of the moire answer. The columns alone removed the beat; this is what puts back the
|
||||||
|
/// information a flat band throws away, the way a phosphor display does.
|
||||||
|
///
|
||||||
|
/// The observable is the thing the feature claims and nothing weaker: within one column,
|
||||||
|
/// the rows at the **edges** of the band are brighter than the rows in its **middle**.
|
||||||
|
/// For a sine of one period per column that is exactly right -- it dwells at its turning
|
||||||
|
/// points and crosses its middle at full speed -- and it is a property of the *rendering*
|
||||||
|
/// rather than of the waveform, because the same frame drawn ungraded puts the band's
|
||||||
|
/// edge and its middle at the same coverage. Both frames are measured here, so what is
|
||||||
|
/// asserted is the difference between them.
|
||||||
|
///
|
||||||
|
/// It also pins what grading may *not* cost. The reduction still runs (`column_traces`),
|
||||||
|
/// so the beat is still gone; the geometry is still bounded by the axis' width times a
|
||||||
|
/// small factor, because a run is a stretch of equal tier and not a row; and the draw
|
||||||
|
/// calls are bounded by the steps, since the line pipeline's colour is a uniform.
|
||||||
|
PLOT_TEST(a_dense_trace_can_be_graded_by_dwell) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
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(fast_sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||||
|
|
||||||
|
// The band's edges against its middle, averaged over columns across the axis. The
|
||||||
|
// shares are of the band's own height, so the reading does not depend on where the
|
||||||
|
// band happens to sit or on how tall it is: the outer fifth at each end against the
|
||||||
|
// middle fifth.
|
||||||
|
struct Profile {
|
||||||
|
plot::u32 edge = 0; ///< mean peak coverage over the band's two outer fifths
|
||||||
|
plot::u32 middle = 0; ///< mean peak coverage over its middle fifth
|
||||||
|
plot::u32 columns = 0; ///< columns that held a band at all
|
||||||
|
};
|
||||||
|
auto profile = [&](const plot::Rect& a) -> Profile {
|
||||||
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||||
|
std::vector<int> cover(rows, 0);
|
||||||
|
plot::u64 edge_sum = 0;
|
||||||
|
plot::u64 middle_sum = 0;
|
||||||
|
Profile out;
|
||||||
|
for (plot::u32 col = 8; col + 8 < static_cast<plot::u32>(a.w); col += 16) {
|
||||||
|
plot::u32 lo = rows;
|
||||||
|
plot::u32 hi = 0;
|
||||||
|
for (plot::u32 row = 0; row < rows; ++row) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
const plot::u32 x = static_cast<plot::u32>(a.x) + col;
|
||||||
|
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||||
|
cover[row] = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
||||||
|
if (cover[row] > 20) { // the faintest step is 0.3 of 191, so this is under it
|
||||||
|
if (row < lo) lo = row;
|
||||||
|
hi = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A band of fewer than 15 rows has no middle fifth to speak of; at this density
|
||||||
|
// there is none, but the reading would be meaningless if there were.
|
||||||
|
if (lo >= hi || hi - lo < 15u) continue;
|
||||||
|
const plot::u32 h = hi - lo + 1u;
|
||||||
|
const plot::u32 fifth = h / 5u;
|
||||||
|
int edge = 0;
|
||||||
|
int middle = 0;
|
||||||
|
for (plot::u32 row = lo; row <= hi; ++row) {
|
||||||
|
const plot::u32 d = row - lo;
|
||||||
|
if (d < fifth || d >= h - fifth) {
|
||||||
|
if (cover[row] > edge) edge = cover[row];
|
||||||
|
} else if (d >= 2u * fifth && d < 3u * fifth) {
|
||||||
|
if (cover[row] > middle) middle = cover[row];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edge_sum += static_cast<plot::u64>(edge);
|
||||||
|
middle_sum += static_cast<plot::u64>(middle);
|
||||||
|
++out.columns;
|
||||||
|
}
|
||||||
|
if (out.columns != 0) {
|
||||||
|
out.edge = static_cast<plot::u32>(edge_sum / out.columns);
|
||||||
|
out.middle = static_cast<plot::u32>(middle_sum / out.columns);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ungraded first: the reference the grading is read against.
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
dump_if_requested(r, "ungraded.ppm");
|
||||||
|
const plot::Rect a = p.layout().axes;
|
||||||
|
const Profile flat = profile(a);
|
||||||
|
const plot::u32 flat_segments = r.stats().staged_segments;
|
||||||
|
const plot::u32 flat_draws = r.stats().draw_calls;
|
||||||
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
|
||||||
|
r.style().intensity_grading = true;
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
dump_if_requested(r, "graded.ppm");
|
||||||
|
const Profile lit = profile(a);
|
||||||
|
|
||||||
|
std::printf("[graded] axes=[%.1f %.1f %.1f %.1f] flat edge=%u middle=%u cols=%u seg=%u "
|
||||||
|
"draws=%u | graded edge=%u middle=%u cols=%u seg=%u draws=%u columns=%u "
|
||||||
|
"graded_traces=%u\n",
|
||||||
|
a.x, a.y, a.w, a.h, flat.edge, flat.middle, flat.columns, flat_segments,
|
||||||
|
flat_draws, lit.edge, lit.middle, lit.columns, r.stats().staged_segments,
|
||||||
|
r.stats().draw_calls, r.stats().column_traces, r.stats().graded_traces);
|
||||||
|
|
||||||
|
// Both frames measured a band in most of the columns they sampled, or the two
|
||||||
|
// profiles are not comparable and everything below is vacuous.
|
||||||
|
PLOT_CHECK(flat.columns > 20u && lit.columns == flat.columns);
|
||||||
|
// The flat band is flat: its edge and its middle are one ink (191 and 191).
|
||||||
|
PLOT_CHECK(flat.middle > 0u && flat.edge * 100u < flat.middle * 115u);
|
||||||
|
// The graded one is not, and in the direction a sine dwells in: 191 against 131, a
|
||||||
|
// middle at 0.69 of the edge. The figure is a *tier* ratio and not the dwell ratio --
|
||||||
|
// the per-row dwell at the rails is several times the crossing's, and
|
||||||
|
// `intensity_gamma` (0.45) and `intensity_floor` (0.3) are there to compress exactly
|
||||||
|
// that into a range an eye reads as shading rather than as a gap. So the threshold is
|
||||||
|
// what the quantised ink does, with room for a step either way.
|
||||||
|
PLOT_CHECK(lit.middle > 0u && lit.middle * 100u < lit.edge * 80u);
|
||||||
|
// The reduction still ran, so the beat the columns removed is still gone.
|
||||||
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
||||||
|
// A run is a stretch of equal tier, not a row: the geometry stays bounded by the
|
||||||
|
// axis' width times a small factor, nothing like `w * h`. Measured 8303 over a 572 px
|
||||||
|
// axis, 14.5 runs a column -- the dwell profile's own structure (four tiers from the
|
||||||
|
// dim middle out to each rail) plus the quantiser's dither at each boundary. The flat
|
||||||
|
// path's 627 is the other end of the scale, and 572 * 156 rows is what this is not.
|
||||||
|
PLOT_CHECK(r.stats().staged_segments < 24u * static_cast<plot::u32>(a.w));
|
||||||
|
// And the draw calls by the steps: one per tier that staged anything, over the flat
|
||||||
|
// frame's single trace draw.
|
||||||
|
PLOT_CHECK(r.stats().draw_calls <= flat_draws + r.style().intensity_steps);
|
||||||
|
|
||||||
|
// And the level floor, which the window above cannot see: at 105 samples a pixel the
|
||||||
|
// flat path is already on raw samples, so grading asks for nothing finer. A wider
|
||||||
|
// window puts the flat path on the pyramid, and there a sub-column is `spp / 8` -- which
|
||||||
|
// falls under one bucket long before the column does, so an unfloored ask answers level
|
||||||
|
// 0 and gathers the whole visible window. That is a real cost and it was measured: at
|
||||||
|
// 1.3 MS/s over a 2 s window udpscope's own frame report went from `raw=710 lod=453` to
|
||||||
|
// `raw=1154 lod=0` and 2.3 -> 6.4 ms a frame. The floor is one bucket level, so the
|
||||||
|
// reading is simply that a graded frame stays on the pyramid.
|
||||||
|
p.set_x_range(ax, {0.0, 200'000.0});
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
std::printf("[graded] wide: raw=%u lod=%u columns=%u graded=%u seg=%u visible=%llu\n",
|
||||||
|
r.stats().raw_traces, r.stats().lod_traces, r.stats().column_traces,
|
||||||
|
r.stats().graded_traces, r.stats().staged_segments,
|
||||||
|
static_cast<unsigned long long>(r.stats().visible_samples));
|
||||||
|
PLOT_CHECK(r.stats().visible_samples > 100'000u);
|
||||||
|
PLOT_CHECK(r.stats().lod_traces == 1 && r.stats().raw_traces == 0);
|
||||||
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
||||||
|
PLOT_CHECK(r.stats().staged_segments < 24u * static_cast<plot::u32>(a.w));
|
||||||
|
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// Resolution #1 of the brief: the renderer draws into whatever framebuffer is
|
// Resolution #1 of the brief: the renderer draws into whatever framebuffer is
|
||||||
// bound and clears only its viewport, so a host that embeds the plot in a
|
// bound and clears only its viewport, so a host that embeds the plot in a
|
||||||
// sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given
|
// sub-rect (Qt/GTK/ImGui) keeps the rest of its surface. Coordinates are given
|
||||||
@@ -6714,3 +6985,479 @@ PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
|
|||||||
}
|
}
|
||||||
(void)layout;
|
(void)layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A true square wave: `period` samples a cycle, half at +1 and half at -1, so every
|
||||||
|
/// transition is a single-sample discontinuity. That is the waveform the phosphor model
|
||||||
|
/// is hardest on -- a riser has no intermediate samples to deposit ink in, so whatever
|
||||||
|
/// the grading does to it is the whole of what a reader sees.
|
||||||
|
std::vector<plot::f32> square_samples(plot::u64 period) {
|
||||||
|
std::vector<plot::f32> y(1'000'000);
|
||||||
|
for (plot::u64 i = 0; i < y.size(); ++i) {
|
||||||
|
y[i] = (i % period) < period / 2u ? 1.0f : -1.0f;
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// A column the signal only *passed* through must still be a column it reached:
|
||||||
|
/// `intensity_floor` exists so that "a riser dimmed out of existence is a square wave
|
||||||
|
/// drawn as two disconnected lines", and the per-column grading used to break that
|
||||||
|
/// promise on exactly the waveform it names.
|
||||||
|
///
|
||||||
|
/// The cause was the "lit at all" cutoff inside the column reduction. Grading normalises
|
||||||
|
/// a column against its own peak and then discards sub-buckets under `0.01 * peak`. A
|
||||||
|
/// column holding both a flat level and a riser has a peak set by the level -- the level
|
||||||
|
/// deposits its whole unit into one row -- while the riser spreads a unit over 300 rows,
|
||||||
|
/// so every riser row lands three orders of magnitude under the cutoff and is dropped
|
||||||
|
/// before the floor can be applied to it. The floor never sees the riser; there is no
|
||||||
|
/// tier 0 to fall back to.
|
||||||
|
///
|
||||||
|
/// Measured over this frame's 116 riser columns: the graded path inked 2 916 of the
|
||||||
|
/// ungraded path's 35 884 rows (8%), gutting 106 of the 116 and leaving a 311-row dark
|
||||||
|
/// run in a 312-row axis. The ten survivors are the phase that puts a riser in a column
|
||||||
|
/// of its own, which is what made the defect read as a *flicker* under panning rather
|
||||||
|
/// than as a missing edge. The cutoff is now `min(0.01 * peak, 0.5 * w_min)`, so it can
|
||||||
|
/// never exclude a deposit some segment actually made, and the reading is 35 572 rows
|
||||||
|
/// (99.1%) with 115 of 116 columns intact.
|
||||||
|
PLOT_TEST(a_graded_column_keeps_a_riser_it_shares_with_a_flat_level) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
plot::Status st;
|
||||||
|
PLOT_CHECK(ctx.create(&st));
|
||||||
|
PLOT_CHECK(plot::gl::load(nullptr));
|
||||||
|
plot::Renderer r;
|
||||||
|
PLOT_CHECK(r.init(&st));
|
||||||
|
|
||||||
|
// 2098 samples a cycle over 104.8 samples a column is about 20 columns a cycle, so a
|
||||||
|
// riser's column also holds a long stretch of the level it leaves -- the sharing this
|
||||||
|
// test is about -- rather than being a column of pure transition.
|
||||||
|
plot::data::ArraySeries s(square_samples(2098), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||||
|
|
||||||
|
// Columns whose band spans more than half the axis: on a square wave those are the
|
||||||
|
// transitions and nothing else, since a level's own band is a ribbon a few rows deep.
|
||||||
|
// "Tall" is the property at stake -- a riser drawn as a stub is as wrong as one not
|
||||||
|
// drawn at all -- so it is both how the columns are chosen and what is asserted of
|
||||||
|
// them, read from the ungraded frame and then re-read from the graded one.
|
||||||
|
const auto tall_columns = [&](const plot::Rect& a) {
|
||||||
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||||
|
std::vector<plot::u32> out;
|
||||||
|
for (plot::u32 col = 0; col < static_cast<plot::u32>(a.w); ++col) {
|
||||||
|
plot::u32 lo = rows;
|
||||||
|
plot::u32 hi = 0;
|
||||||
|
for (plot::u32 row = 0; row < rows; ++row) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
|
||||||
|
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||||
|
// 20 of the 191 a full stroke inks, which is under the faintest tier
|
||||||
|
// (`intensity_floor` 0.3) -- so a row this rejects is a row no tier drew.
|
||||||
|
if (static_cast<int>(px[2]) - static_cast<int>(px[0]) > 20) {
|
||||||
|
if (row < lo) lo = row;
|
||||||
|
hi = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lo < hi && hi - lo > rows / 2u) out.push_back(col);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The axes rect is read *after* a draw: `Plot::layout()` sizes its label strips from
|
||||||
|
// the tick text at the style's font, so before the first frame it answers with the
|
||||||
|
// unmeasured estimate and the window would be off by 11 columns in x.
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
dump_if_requested(r, "riser-ungraded.ppm");
|
||||||
|
const plot::Rect a = p.layout().axes;
|
||||||
|
const std::vector<plot::u32> flat = tall_columns(a);
|
||||||
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 0);
|
||||||
|
|
||||||
|
r.style().intensity_grading = true;
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
dump_if_requested(r, "riser-graded.ppm");
|
||||||
|
const std::vector<plot::u32> lit = tall_columns(a);
|
||||||
|
PLOT_CHECK(r.stats().column_traces == 1 && r.stats().graded_traces == 1);
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
|
||||||
|
// How many of the ungraded frame's own riser columns survived the grading, which is
|
||||||
|
// the reading the ratio below is taken from. Both lists are ascending, so this is one
|
||||||
|
// pass.
|
||||||
|
plot::u32 kept = 0;
|
||||||
|
for (plot::usize i = 0, j = 0; i < flat.size() && j < lit.size();) {
|
||||||
|
if (flat[i] == lit[j]) {
|
||||||
|
++kept;
|
||||||
|
++i;
|
||||||
|
++j;
|
||||||
|
} else if (flat[i] < lit[j]) {
|
||||||
|
++i;
|
||||||
|
} else {
|
||||||
|
++j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::printf("[riser] axes=[%.1f %.1f %.1f %.1f] flat=%zu graded=%zu kept=%u\n", a.x, a.y, a.w,
|
||||||
|
a.h, flat.size(), lit.size(), kept);
|
||||||
|
|
||||||
|
// Enough columns to make the ratio mean something; 116 were measured.
|
||||||
|
PLOT_CHECK(flat.size() > 50u);
|
||||||
|
// And the grading kept all but one of them. The one is real and is not the defect: the
|
||||||
|
// ungraded path inks *two* full-height columns per transition -- a column's own min/max,
|
||||||
|
// the connectivity extension to its neighbour, and the gathered window's slack sample
|
||||||
|
// all land in x -- where the reduction inks one, which is also why 116 columns appear
|
||||||
|
// for 58 transitions. Before the fix this read 10 of 116.
|
||||||
|
PLOT_CHECK(kept * 100u >= static_cast<plot::u32>(flat.size()) * 95u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dwell dimming must not switch off when the window gets narrow. It used to, because
|
||||||
|
/// the grading was gated on the *column reduction*, and that reduction is gated on two
|
||||||
|
/// gathered points per pixel column -- a density whose only purpose is removing the
|
||||||
|
/// moire beat. Zooming past `dense_threshold_spp` therefore turned the whole phosphor
|
||||||
|
/// model off in one step, and a discontinuity that had been drawn at a third of the
|
||||||
|
/// level's ink was suddenly drawn at all of it: measured 71 of 191 at 3.4 samples a
|
||||||
|
/// pixel and 191 of 191 at 1.0, which is what a user reported as an edge that "lights
|
||||||
|
/// up" on zoom.
|
||||||
|
///
|
||||||
|
/// `Renderer::stage_trace_slopes()` grades the polyline by each segment's own slope, so
|
||||||
|
/// the model now spans both rasterisation paths. The riser reads 71 either side of the
|
||||||
|
/// threshold, and this test is that pair of readings.
|
||||||
|
PLOT_TEST(a_sparse_trace_is_graded_by_slope_like_a_dense_one) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
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(square_samples(2098), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", {0.0, 60'000.0}, false, plot::AxisPlace::Bottom, false});
|
||||||
|
// -2..2 so the two levels sit a quarter in from each edge rather than on it: a level
|
||||||
|
// on the boundary row is half clipped away, and its ink would then read low for a
|
||||||
|
// reason that has nothing to do with grading.
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-2.0, 2.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
|
||||||
|
r.style().intensity_grading = true;
|
||||||
|
|
||||||
|
struct Reading {
|
||||||
|
int riser = 0; ///< peak ink in the riser's own columns, at mid height
|
||||||
|
int flat = 0; ///< peak ink in a level's columns, well clear of the riser
|
||||||
|
};
|
||||||
|
// Both windows are centred on the transition at sample 1049, so the riser's column is
|
||||||
|
// known by construction rather than searched for -- a search would have to decide what
|
||||||
|
// a riser looks like, which is the very thing in question.
|
||||||
|
const auto measure = [&](double window) {
|
||||||
|
p.set_x_range(ax, {1049.0 - window / 2.0, 1049.0 + window / 2.0});
|
||||||
|
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
const plot::Rect a = p.layout().axes;
|
||||||
|
const plot::u32 rows = static_cast<plot::u32>(a.h);
|
||||||
|
const plot::u32 cols = static_cast<plot::u32>(a.w);
|
||||||
|
const plot::u32 mid = cols / 2u;
|
||||||
|
Reading out;
|
||||||
|
for (plot::u32 col = 0; col < cols; ++col) {
|
||||||
|
for (plot::u32 row = 0; row < rows; ++row) {
|
||||||
|
plot::u8 px[4] = {};
|
||||||
|
PLOT_CHECK(ctx.read_pixel(static_cast<plot::u32>(a.x) + col,
|
||||||
|
ctx.height - 1u - (static_cast<plot::u32>(a.y) + row), px));
|
||||||
|
const int cover = static_cast<int>(px[2]) - static_cast<int>(px[0]);
|
||||||
|
if (col + 6u < mid || col > mid + 6u) {
|
||||||
|
// A level's own ink. The six-column guard is for the near-vertical stroke's
|
||||||
|
// own width plus the connector either side of it.
|
||||||
|
if (cover > out.flat) out.flat = cover;
|
||||||
|
} else if (row >= 3u * rows / 8u && row <= 5u * rows / 8u) {
|
||||||
|
// The riser's own ink: the middle quarter of the axis, which neither level
|
||||||
|
// reaches. Reading the full height instead answers 191 at every zoom, because
|
||||||
|
// the riser's column contains a level too.
|
||||||
|
if (cover > out.riser) out.riser = cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3.4 samples a pixel and 1.0, so one window either side of `dense_threshold_spp`
|
||||||
|
// (1.5) over the same waveform at the same place.
|
||||||
|
const Reading dense = measure(2000.0);
|
||||||
|
const plot::FrameStats dense_stats = r.stats();
|
||||||
|
const Reading sparse = measure(600.0);
|
||||||
|
const plot::FrameStats sparse_stats = r.stats();
|
||||||
|
std::printf("[slope] dense riser=%d flat=%d graded=%u slopes=%u | sparse riser=%d flat=%d "
|
||||||
|
"graded=%u slopes=%u\n",
|
||||||
|
dense.riser, dense.flat, dense_stats.graded_traces, dense_stats.slope_traces,
|
||||||
|
sparse.riser, sparse.flat, sparse_stats.graded_traces, sparse_stats.slope_traces);
|
||||||
|
|
||||||
|
// The two frames took the two paths, or the comparison is between a frame and itself.
|
||||||
|
PLOT_CHECK(dense_stats.graded_traces == 1 && dense_stats.slope_traces == 0);
|
||||||
|
PLOT_CHECK(sparse_stats.slope_traces == 1 && sparse_stats.graded_traces == 0);
|
||||||
|
// Neither path dims the level: a flat segment dwells in its row, so it is the top tier
|
||||||
|
// and full ink. Both read 191.
|
||||||
|
PLOT_CHECK(dense.flat > 180 && sparse.flat > 180);
|
||||||
|
// And both dim the discontinuity, to about a third -- `intensity_floor` is 0.3 and a
|
||||||
|
// riser spanning the axis is the longest span there is, so it sits on the floor. Both
|
||||||
|
// read 71. Before the slope grading the sparse frame read 191 here.
|
||||||
|
PLOT_CHECK(dense.riser * 100 < dense.flat * 55);
|
||||||
|
PLOT_CHECK(sparse.riser * 100 < sparse.flat * 55);
|
||||||
|
// The point of the exercise: the same edge at the two zooms is the same ink, so there
|
||||||
|
// is no step at the threshold for a reader to see. A tier either way is 23 of 191, and
|
||||||
|
// the measured pair is exact.
|
||||||
|
PLOT_CHECK(std::abs(dense.riser - sparse.riser) < 24);
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invalid span is the opposite of the x note: the note is chrome that a pan must not move,
|
||||||
|
/// and a band marks a *region of the data*, so it must pan and zoom with it. A band that stayed
|
||||||
|
/// put under a pan would accuse the wrong samples of being missing, which is worse than drawing
|
||||||
|
/// nothing. It also must reserve no layout space -- the label strips were measured before any
|
||||||
|
/// band existed, and a hole must not be able to move them.
|
||||||
|
///
|
||||||
|
/// Measured as a footprint for the x note's reason: the band's rect is deliberately not readable
|
||||||
|
/// back, so the only way to see it is the pixels a frame *with* it differs from the same frame
|
||||||
|
/// without one, at the same view. Diffing at a fixed view is also what makes the measurement
|
||||||
|
/// robust: whatever the trace does under a pan cancels between the two frames, so the bounding
|
||||||
|
/// box is the band and nothing else.
|
||||||
|
PLOT_TEST(an_invalid_span_is_shaded_and_moves_with_the_data) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
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(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
||||||
|
|
||||||
|
// The bounding box of the pixels the band, and only the band, is responsible for, plus the
|
||||||
|
// axes rect the frame was laid out into so the caller can check it did not move.
|
||||||
|
const auto footprint = [&](const plot::Range& view, plot::InvalidSpan span,
|
||||||
|
plot::Rect& axes_out, const char* dump) {
|
||||||
|
p.set_x_range(ax, view);
|
||||||
|
const plot::InvalidSpan one[1] = {span};
|
||||||
|
p.set_invalid_spans(one);
|
||||||
|
r.begin_frame(kW, kH, kFrameRect);
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
dump_if_requested(r, dump);
|
||||||
|
const std::vector<plot::u8> with = read_frame(ctx);
|
||||||
|
axes_out = p.layout().axes;
|
||||||
|
p.set_invalid_spans({});
|
||||||
|
r.begin_frame(kW, kH, kFrameRect);
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
const std::vector<plot::u8> without = read_frame(ctx);
|
||||||
|
plot::u32 lo_x = kW, lo_y = kH, hi_x = 0, hi_y = 0, differing = 0;
|
||||||
|
for (plot::u32 y = 0; y < kH; ++y) {
|
||||||
|
for (plot::u32 x = 0; x < kW; ++x) {
|
||||||
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
||||||
|
if (with[at] == without[at] && with[at + 1u] == without[at + 1u] &&
|
||||||
|
with[at + 2u] == without[at + 2u] && with[at + 3u] == without[at + 3u]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++differing;
|
||||||
|
lo_x = std::min(lo_x, x);
|
||||||
|
lo_y = std::min(lo_y, y);
|
||||||
|
hi_x = std::max(hi_x, x);
|
||||||
|
hi_y = std::max(hi_y, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A comparison of two frames is only evidence if the band was in one of them.
|
||||||
|
PLOT_CHECK(differing > 0u);
|
||||||
|
return plot::Rect{static_cast<plot::f32>(lo_x), static_cast<plot::f32>(lo_y),
|
||||||
|
static_cast<plot::f32>(hi_x - lo_x + 1u),
|
||||||
|
static_cast<plot::f32>(hi_y - lo_y + 1u)};
|
||||||
|
};
|
||||||
|
|
||||||
|
// A band over the middle third of a 640-wide window.
|
||||||
|
plot::Rect axes_a{0.0f, 0.0f, 0.0f, 0.0f};
|
||||||
|
const plot::Rect none_box = footprint({0.0, 640.0}, {640.0 / 3.0, 1280.0 / 3.0, false}, axes_a,
|
||||||
|
"invalid_span.ppm");
|
||||||
|
// Inside the axes on every side -- a hole is not allowed to paint over a label strip -- and
|
||||||
|
// the full height of the data area, because the whole column has no data.
|
||||||
|
PLOT_CHECK(none_box.x >= axes_a.x && none_box.right() <= axes_a.right() + 1.0f);
|
||||||
|
PLOT_CHECK(none_box.y >= axes_a.y && none_box.bottom() <= axes_a.bottom() + 1.0f);
|
||||||
|
PLOT_CHECK_NEAR(none_box.h, axes_a.h, 2.0);
|
||||||
|
// The middle third: a third of the width, starting a third of the way across. Two pixels of
|
||||||
|
// slack for the fill's fractional edges.
|
||||||
|
PLOT_CHECK_NEAR(none_box.x - axes_a.x, axes_a.w / 3.0f, 2.0);
|
||||||
|
PLOT_CHECK_NEAR(none_box.w, axes_a.w / 3.0f, 2.0);
|
||||||
|
|
||||||
|
// The same span under a pan of exactly 64 device pixels' worth of data. The band is anchored
|
||||||
|
// in the data, so it must move by 64 pixels -- the x note, measured the same way, does not
|
||||||
|
// move at all, which is the distinction this pair of cases draws.
|
||||||
|
const plot::f64 per_px = 640.0 / plot::f64(axes_a.w);
|
||||||
|
const plot::f64 dx = 64.0 * per_px;
|
||||||
|
plot::Rect axes_b{0.0f, 0.0f, 0.0f, 0.0f};
|
||||||
|
const plot::Rect panned = footprint({dx, 640.0 + dx}, {640.0 / 3.0, 1280.0 / 3.0, false},
|
||||||
|
axes_b, "invalid_span_panned.ppm");
|
||||||
|
PLOT_CHECK_NEAR(panned.x, none_box.x - 64.0f, 2.0);
|
||||||
|
PLOT_CHECK_NEAR(panned.w, none_box.w, 2.0);
|
||||||
|
// The layout is identical across both: the band reserved nothing, so the strips the labels
|
||||||
|
// were measured into are where they were before any band existed.
|
||||||
|
PLOT_CHECK_NEAR(axes_b.x, axes_a.x, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(axes_b.y, axes_a.y, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(axes_b.w, axes_a.w, 1e-4);
|
||||||
|
PLOT_CHECK_NEAR(axes_b.h, axes_a.h, 1e-4);
|
||||||
|
|
||||||
|
std::printf("[invalid span] band %.0f,%.0f %.0fx%.0f panned %.0f,%.0f %.0fx%.0f "
|
||||||
|
"(shift %.1f px) in axes %.0f,%.0f %.0fx%.0f\n",
|
||||||
|
none_box.x, none_box.y, none_box.w, none_box.h, panned.x, panned.y, panned.w,
|
||||||
|
panned.h, none_box.x - panned.x, axes_a.x, axes_a.y, axes_a.w, axes_a.h);
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hole narrower than a pixel is still a report. A reserved span of one sample at a window
|
||||||
|
/// width of hundreds of thousands maps to a fraction of a column, and a fill rounded to zero
|
||||||
|
/// width would draw nothing -- the one case where the feature is most needed is the one where
|
||||||
|
/// naive rounding silently drops it. The band therefore has a one-device-pixel floor.
|
||||||
|
PLOT_TEST(a_sub_pixel_invalid_span_is_still_shaded) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
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(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
||||||
|
|
||||||
|
// One sample's worth of hole in a 200 000-wide window: well under a hundredth of a column.
|
||||||
|
p.set_x_range(ax, {0.0, 200000.0});
|
||||||
|
const plot::InvalidSpan one[1] = {{100000.0, 100000.0, false}};
|
||||||
|
p.set_invalid_spans(one);
|
||||||
|
r.begin_frame(kW, kH, kFrameRect);
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
dump_if_requested(r, "invalid_span_subpixel.ppm");
|
||||||
|
const std::vector<plot::u8> with = read_frame(ctx);
|
||||||
|
p.set_invalid_spans({});
|
||||||
|
r.begin_frame(kW, kH, kFrameRect);
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
const std::vector<plot::u8> without = read_frame(ctx);
|
||||||
|
|
||||||
|
plot::u32 columns = 0, differing = 0;
|
||||||
|
for (plot::u32 x = 0; x < kW; ++x) {
|
||||||
|
bool any = false;
|
||||||
|
for (plot::u32 y = 0; y < kH; ++y) {
|
||||||
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
||||||
|
if (with[at] != without[at] || with[at + 1u] != without[at + 1u] ||
|
||||||
|
with[at + 2u] != without[at + 2u] || with[at + 3u] != without[at + 3u]) {
|
||||||
|
any = true;
|
||||||
|
++differing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (any) ++columns;
|
||||||
|
}
|
||||||
|
// Drawn, and narrow: the floor is one device pixel, not a visible band widened to be seen.
|
||||||
|
PLOT_CHECK(differing > 0u);
|
||||||
|
PLOT_CHECK(columns >= 1u && columns <= 2u);
|
||||||
|
std::printf("[invalid span] sub-pixel hole lit %u column(s), %u pixels\n", columns, differing);
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The band carries three states and not two: a hole still missing its samples is shaded solidly,
|
||||||
|
/// a hole whose late packets have all arrived is shaded faintly, and a hole whose slots have aged
|
||||||
|
/// out of the host's buffer is not shaded at all. The faint tier is what keeps the report honest
|
||||||
|
/// -- the window *was* drawn without that data -- while telling a reader it is no longer missing.
|
||||||
|
///
|
||||||
|
/// Measured as ink rather than as a sampled pixel: at a fixed view the trace, the grid and the
|
||||||
|
/// labels are byte-identical across all three frames, so every differing byte is the band's own
|
||||||
|
/// and a sum of absolute differences measures the tint and nothing else. The alternative -- read
|
||||||
|
/// one pixel inside the band -- would be measuring whatever the trace happens to do there.
|
||||||
|
PLOT_TEST(a_repaired_invalid_span_is_fainter_than_a_missing_one) {
|
||||||
|
plottest::HeadlessContext ctx(kW, kH);
|
||||||
|
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(sine_samples(), 0.0, 1.0);
|
||||||
|
plot::Plot p;
|
||||||
|
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||||
|
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
|
||||||
|
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
|
||||||
|
PLOT_CHECK(p.add_trace({&s, plot::Color::hex(0x00E070, 1.0f), 1.5f, ax, ay, "signal"}) == 0u);
|
||||||
|
p.set_x_range(ax, {0.0, 640.0});
|
||||||
|
|
||||||
|
// One band over the middle third, at one fixed view, in each of the three states.
|
||||||
|
const auto frame = [&](bool shaded, bool repaired, const char* dump) {
|
||||||
|
const plot::InvalidSpan one[1] = {{640.0 / 3.0, 1280.0 / 3.0, repaired}};
|
||||||
|
if (shaded) {
|
||||||
|
p.set_invalid_spans(one);
|
||||||
|
} else {
|
||||||
|
p.set_invalid_spans({});
|
||||||
|
}
|
||||||
|
r.begin_frame(kW, kH, kFrameRect);
|
||||||
|
r.draw(p);
|
||||||
|
r.end_frame();
|
||||||
|
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||||
|
if (dump != nullptr) dump_if_requested(r, dump);
|
||||||
|
return read_frame(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::vector<plot::u8> none = frame(false, false, nullptr);
|
||||||
|
const std::vector<plot::u8> missing = frame(true, false, "invalid_span_missing.ppm");
|
||||||
|
const std::vector<plot::u8> repaired = frame(true, true, "invalid_span_repaired.ppm");
|
||||||
|
|
||||||
|
// Total ink a frame laid down over the unshaded one, and the columns it covers.
|
||||||
|
const auto ink = [&](const std::vector<plot::u8>& with, plot::u32& lo_x, plot::u32& hi_x) {
|
||||||
|
unsigned long long total = 0;
|
||||||
|
lo_x = kW;
|
||||||
|
hi_x = 0;
|
||||||
|
for (plot::u32 y = 0; y < kH; ++y) {
|
||||||
|
for (plot::u32 x = 0; x < kW; ++x) {
|
||||||
|
const plot::usize at = (plot::usize(y) * kW + x) * 4u;
|
||||||
|
unsigned d = 0;
|
||||||
|
for (plot::usize c = 0; c < 4u; ++c) {
|
||||||
|
const unsigned a = with[at + c];
|
||||||
|
const unsigned b = none[at + c];
|
||||||
|
d += a > b ? a - b : b - a;
|
||||||
|
}
|
||||||
|
if (d == 0u) continue;
|
||||||
|
total += d;
|
||||||
|
lo_x = std::min(lo_x, x);
|
||||||
|
hi_x = std::max(hi_x, x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
plot::u32 m_lo = 0, m_hi = 0, r_lo = 0, r_hi = 0;
|
||||||
|
const unsigned long long missing_ink = ink(missing, m_lo, m_hi);
|
||||||
|
const unsigned long long repaired_ink = ink(repaired, r_lo, r_hi);
|
||||||
|
|
||||||
|
// A repaired hole is still drawn. A tint that vanished would be the "retire the band on repair"
|
||||||
|
// behaviour this feature deliberately does not have: the window was missing that data when it
|
||||||
|
// was drawn, and the band is the record of it.
|
||||||
|
PLOT_CHECK(repaired_ink > 0ull);
|
||||||
|
// And the solid one is unmistakably heavier. The defaults are alpha 0.18 against 0.08, better
|
||||||
|
// than two to one, so half again is a wide margin and not a tuned threshold.
|
||||||
|
PLOT_CHECK(missing_ink > repaired_ink + repaired_ink / 2ull);
|
||||||
|
// Same pixels either way: the state chooses the tint and nothing about the geometry.
|
||||||
|
PLOT_CHECK(m_lo == r_lo && m_hi == r_hi);
|
||||||
|
std::printf("[invalid span] missing ink %llu over columns %u..%u, repaired ink %llu over "
|
||||||
|
"%u..%u\n",
|
||||||
|
missing_ink, m_lo, m_hi, repaired_ink, r_lo, r_hi);
|
||||||
|
ctx.destroy();
|
||||||
|
}
|
||||||
@@ -35,3 +35,27 @@ PLOT_TEST(ring_capacity_rounds_up_to_a_power_of_two) {
|
|||||||
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
|
PLOT_CHECK_NEAR(r[0], 872.0); // oldest retained
|
||||||
PLOT_CHECK_NEAR(r[127], 999.0); // newest
|
PLOT_CHECK_NEAR(r[127], 999.0); // newest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ring_amend_rewrites_a_retained_slot) {
|
||||||
|
// Backfill: a slot pushed as a placeholder is rewritten once the data that belongs there
|
||||||
|
// arrives. The index is absolute, because that is the only coordinate a late packet can
|
||||||
|
// compute -- a retained-window index would have moved by the time it got here.
|
||||||
|
plot::data::RingBuffer<f32> r(8);
|
||||||
|
for (int i = 0; i < 6; ++i) r.push(f32(i));
|
||||||
|
PLOT_CHECK(r.amend(3, 42.0f));
|
||||||
|
PLOT_CHECK_NEAR(r[3], 42.0);
|
||||||
|
PLOT_CHECK(r.produced() == 6); // an amend is not a push
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(ring_amend_refuses_an_index_the_ring_no_longer_holds) {
|
||||||
|
// The window slides on the producer, so a late amend may arrive after its slot was
|
||||||
|
// overwritten. Writing it anyway would corrupt whatever sample now owns that slot, so the
|
||||||
|
// bounds test is the whole safety of the operation and the caller reads the result.
|
||||||
|
plot::data::RingBuffer<f32> r(8);
|
||||||
|
for (int i = 0; i < 20; ++i) r.push(f32(i));
|
||||||
|
PLOT_CHECK(r.base() == 12);
|
||||||
|
PLOT_CHECK(!r.amend(3, 42.0f)); // aged out
|
||||||
|
PLOT_CHECK(!r.amend(20, 42.0f)); // never produced
|
||||||
|
PLOT_CHECK_NEAR(r[0], 12.0); // and nothing moved
|
||||||
|
PLOT_CHECK_NEAR(r[7], 19.0);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user