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:
Martino FerrariandClaude Opus 4.6 committed 2026-10-08 16:56:10 +02:00
1 parent cc908efa36
commit 37e414bcc5
8 files changed
+442 -13

No files matched your search

+52 -1
View File
@@ -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