feat(render): a reduced column can be graded by dwell, not drawn as one flat band
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s

A pixel column reduced from a dense envelope is honest about where the signal
went and silent about how long it was there: its min and max are identical
whether its samples fill the span or pile at its two ends, so a ramp and a pulse
are one band. Style::intensity_grading grades it instead -- bright where the
signal sits, faint where it only passes through -- by asking the gather for a
level finer than the pixel grid and reading the distribution from those
sub-buckets. Off by default: a change of look, not a correction of one.

The measure is dwell per row, each segment depositing one unit spread over the
rows it spans; counting +1 per row instead grades a sine's fast middle as
brightly as its rails, which is the pattern inverted. The accumulator is one
column wide (a full w*h density image is 33 MB at 4K) with each span written as
two delta writes, and normalisation is against the column's own peak -- a
frame-wide scale fades the trace out exactly where it moves fastest. Alpha is
quantised into tiers, one LineBatch and one draw call each, because the line
pipeline's colour is a uniform and a per-segment alpha is inexpressible.

The finer ask is floored at level 1 whenever the flat path would have used the
pyramid: level_for() answers 0 -- raw samples -- as soon as a sub-column falls
under one bucket, so an unfloored ask turns a moderately dense frame into an
O(visible) gather (measured at 172 samples a pixel: raw=1154 lod=0 and 6.4 ms a
frame against raw=710 lod=453 and 2.3 ms). Grading is a way of drawing a
reduction and may not undo it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 03:02:36 +02:00
1 parent e81e84645f
commit cc908efa36
6 files changed
+629 -13

No files matched your search

+271 -13
View File
@@ -616,9 +616,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,
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_) {
upload_vertices(lines_.data());
upload_vertices(batch.data());
line_program_.bind();
line_program_.set_scale(map.scale);
line_program_.set_offset(map.offset);
@@ -643,7 +648,7 @@ void Renderer::flush_lines(const Mapping& map, Color color, f32 width, Vec2 dash
stats_.staged_segments += segments;
++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 {
@@ -1057,6 +1062,219 @@ u32 Renderer::stage_trace_columns(const Plot& plot, const PlotLayout& layout, co
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;
// 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. 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;
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;
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);
column_rows_[r0] += w;
column_rows_[r1 + 1u] -= w;
}
}
px = sx;
py = sy;
have_prev = true;
}
if (open) emit_column();
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
@@ -1098,9 +1316,30 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// This is the whole performance contract: the window, never the series.
const u64 visible = last - first;
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)
? series.pyramid().level_for(n, samples_per_pixel)
: 0u;
// Grading reads a column's *distribution*, which one envelope per column cannot
// carry (`Style::intensity_grading`), so the gather is asked for the level a
// 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();
// The visible range's minimum is the gather origin: the emitted f32 is then O(the span on
@@ -1230,13 +1469,32 @@ void Renderer::draw_traces(const Plot& plot, const PlotLayout& layout) {
// it ran.
const bool columns = trace.mode == data::GatherMode::Envelope &&
static_cast<f64>(points) >= 2.0 * static_cast<f64>(layout.axes.w);
const u32 staged = columns ? stage_trace_columns(plot, layout, trace, x_range.min, points) : 0u;
if (staged != 0u) {
// `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.
// `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 {