feat(render): a reduced column can be graded by dwell, not drawn as one flat band
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:
1 parent
e81e84645f
commit
cc908efa36
6 files changed
+629
-13
No files matched your search
+271
-13
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user