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

+106 -6
View File
@@ -1096,6 +1096,9 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
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 --
@@ -1124,10 +1127,25 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
}
}
const f32 inv = peak > 0.0f ? 1.0f / peak : 0.0f;
// What counts as covered at all. The deposits are fractions, so this cannot be an
// integer test: it is a share of the column's own peak, small enough to keep a row a
// single thin segment grazed and large enough not to light a row on rounding alone.
const f32 lit_floor = 0.01f * peak;
// 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;
@@ -1175,6 +1193,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
}
}
open = false;
w_min = std::numeric_limits<f32>::infinity();
if (!any) {
joined = false; // nothing visible here: a hole, like an uncovered column
return;
@@ -1263,6 +1282,7 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
// 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;
}
@@ -1275,6 +1295,61 @@ u32 Renderer::stage_trace_density(const Plot& plot, const PlotLayout& layout, co
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) {
// 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
@@ -1498,8 +1573,33 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
flush_lines(pixel_mapping(), trace.color, style_.device(trace.width), dash, true);
++stats_.column_traces;
} else {
stage_xy(gather_.data(), points, x_range.min, log_x, log_y);
flush_lines(map, trace.color, style_.device(trace.width), dash);
// 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);
}
}
}