feat: grade a sparse window's polyline by slope, so phosphor does not switch off on zoom
Intensity grading was gated on the column reduction, which is gated on a density whose only job is killing the moire beat. Dwell has nothing to do with that, so narrowing a window past the threshold turned the whole phosphor model off and a discontinuity went from a third of a flat top's ink to all of it in one pixel of zoom. stage_trace_slopes() grades each segment by its own slope instead, with a measure that agrees with the dense path at the boundary, and reports itself as FrameStats::slope_traces -- disjoint from column_traces, so a host asserting graded_traces == column_traces reads the same as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc908efa36
commit
37e414bcc5
8 files changed
+442
-13
No files matched your search
@@ -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
|
||||
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`
|
||||
preset; its harness prints one line per case and a final `350 cases, 0 failed` summary line.
|
||||
`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 `377 cases, 0 failed` summary line.
|
||||
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.
|
||||
|
||||
`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
|
||||
|
||||
`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
|
||||
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
|
||||
`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)
|
||||
|
||||
@@ -251,7 +251,18 @@ Five things carry it, and each was arrived at by being wrong first:
|
||||
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. A connector between two disjoint columns
|
||||
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
|
||||
@@ -276,6 +287,46 @@ flat path is on the pyramid is what exercises the floor (`raw=0 lod=1 … seg=63
|
||||
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
|
||||
|
||||
Every number the library reports is one the samples actually hold. What differs between
|
||||
|
||||
Reference in new issue
Block a user