feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size

This commit is contained in:
Martino Ferrari committed 2026-09-28 12:17:53 +02:00
1 parent 1f03631e98
commit 7500fc90d2
21 files changed
+708 -82

No files matched your search

+72
View File
@@ -358,3 +358,75 @@ PLOT_TEST(key_f_auto_fits_traces) {
PLOT_CHECK(p.x_axis(ax).auto_scale); // a fit re-arms auto scale
PLOT_CHECK(p.y_axis(ay).auto_scale);
}
// --- tolerances are logical, coordinates are device pixels -------------------
/// The grab radius is a distance the user aims with, so it must be the same
/// *physical* distance at any DPI: the controller compares device-pixel coordinates
/// against `logical * Plot::ui_scale()`. The press positions below are the same
/// logical distance (0.9 and 1.1 of the 5 px radius) from the cursor at both scales,
/// so the same press grabs -- and the same press just outside does not.
PLOT_TEST(grab_radius_scales_with_dpi) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
const u32 cid = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
plot::input::Controller c(p);
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
const f32 sx = screen_x_of(p, ax, 50.0);
const f32 inside = 0.9f * 5.0f * scale; // logical: within the grab radius
const f32 outside = 1.1f * 5.0f * scale; // logical: just past it
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == cid);
c.on_pointer({sx + outside, 200.0f * scale, 0, 0, plot::input::Button::Left, 0, false, false,
false});
PLOT_CHECK(c.hovered_cursor() == plot::kInvalid);
// ...and a ctrl-drag at that same logical distance opens a cursor move, which is
// the gesture the radius actually gates.
c.on_pointer({sx + inside, 200.0f * scale, 0, 0, plot::input::Button::Left, kCtrl, true, true,
false});
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::MoveCursor);
}
}
/// The box-zoom click threshold is a hand movement too: a selection the user
/// perceives as a few pixels is a click at any DPI, so both dimensions are compared
/// against `logical * Plot::ui_scale()`.
PLOT_TEST(box_zoom_click_threshold_scales_with_dpi) {
for (const f32 scale : {1.0f, 2.0f}) {
plot::Plot p;
p.set_ui_scale(scale);
p.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const u32 ax = p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
plot::input::Controller c(p);
c.set_viewport({0, 0, 800.0f * scale, 400.0f * scale});
const plot::Range before = p.x_range(ax);
// 3 logical px on each side: a click, at both scales.
const f32 x0 = 100.0f * scale;
const f32 y0 = 50.0f * scale;
const f32 small = 3.0f * scale;
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({x0 + small, y0 + small, small, small, plot::input::Button::Left, kShift, true,
false, false});
c.on_pointer({x0 + small, y0 + small, 0, 0, plot::input::Button::Left, kShift, false, false,
true});
PLOT_CHECK_NEAR(p.x_range(ax).min, before.min, 1e-9);
PLOT_CHECK_NEAR(p.x_range(ax).max, before.max, 1e-9);
// 5 logical px on each side: a box, at both scales.
const f32 large = 5.0f * scale;
c.on_pointer({x0, y0, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({x0 + large, y0 + large, large, large, plot::input::Button::Left, kShift, true,
false, false});
c.on_pointer({x0 + large, y0 + large, 0, 0, plot::input::Button::Left, kShift, false, false,
true});
PLOT_CHECK(p.x_range(ax).min > before.min);
PLOT_CHECK(p.x_range(ax).max < before.max);
}
}
+37
View File
@@ -452,3 +452,40 @@ PLOT_TEST(imgui_widget_paints_only_the_visible_part_of_a_clipped_child) {
f.pixel(f.paint_min_x + 40u, f.paint_min_y + 40u, out);
PLOT_CHECK(!f.is_host(out));
}
/// The widget states the display's scale to the renderer, which is what makes the
/// chrome one physical size on any DPI: `io.DisplayFramebufferScale` is the same
/// factor the widget already maps points to framebuffer pixels by, so a host on a 2x
/// surface gets a 2x *device* font and a 2x device strip -- the same logical layout
/// as on a 1x one.
PLOT_TEST(imgui_widget_states_the_display_scale_to_the_renderer) {
Fixture f;
PLOT_CHECK(f.init());
ImGuiIO& io = ImGui::GetIO();
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6);
const plot::f32 cell_1x = f.renderer.axis_font_px();
const plot::f32 strip_1x = f.plot.layout().y_axis_width;
PLOT_CHECK(cell_1x > 1.0f);
PLOT_CHECK(strip_1x > 1.0f);
// The same widget and the same logical size, on a 2x surface.
io.DisplayFramebufferScale = ImVec2(2.0f, 2.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 2.0f, 1e-6);
// The renderer hands it to the plot before laying out, so the *device* chrome
// doubles while the logical figure (device / scale) is what the 1x run measured.
PLOT_CHECK_NEAR(f.plot.ui_scale(), 2.0f, 1e-6);
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), 2.0f * cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, 2.0f * strip_1x, 0.05f);
// Back to 1x: the scale is re-read every frame rather than latched at the first
// one, so a window moved to another display follows.
io.DisplayFramebufferScale = ImVec2(1.0f, 1.0f);
f.frame(ImVec2(10.0f, 10.0f), false);
PLOT_CHECK_NEAR(f.renderer.style().ui_scale, 1.0f, 1e-6);
PLOT_CHECK_NEAR(f.renderer.axis_font_px(), cell_1x, 1e-3);
PLOT_CHECK_NEAR(f.plot.layout().y_axis_width, strip_1x, 1e-3);
}
+95
View File
@@ -25,6 +25,16 @@ f32 widest_major_label(const plot::Axis& a, const plot::render::TextBackend& t)
return w;
}
/// Every major tick's value, in order: what a tick-density assertion compares, so it
/// can compare the *grid* the two scales produce rather than only a count.
std::vector<f64> major_tick_values(const plot::Axis& a) {
std::vector<f64> v;
for (const plot::Tick& tick : a.ticks) {
if (tick.major) v.push_back(tick.value);
}
return v;
}
/// A log axis must never be left inverted or non-positive by an auto-fit.
void check_log_axis_range(const plot::Range& r) {
PLOT_CHECK(std::isfinite(r.min) && std::isfinite(r.max));
@@ -839,3 +849,88 @@ PLOT_TEST(legend_hidden_by_visible_false_draws_nothing) {
scene.plot.set_legend(cfg);
PLOT_CHECK(scene.plot.layout().legend.rows.size() == 3u);
}
// --- the UI scale: logical metrics, one physical size at any DPI -------------
/// A minimum tick spacing is a physical gap between labels, so the same *logical*
/// plot width must get the same major ticks at any scale. This is the failure mode
/// on the under-eager side: an unscaled `tick_min_px` measured against a 2x-wide
/// framebuffer packs twice as many ticks onto the same panel, and the grid's density
/// becomes a property of the display instead of the data.
PLOT_TEST(tick_spacing_is_physical) {
plot::render::EmbeddedMonoFont font;
// No y axis, and so no y label strip: with a text backend present the axes width
// is then the viewport width *exactly* (`layout()` reserves a left strip only for
// the axes that have labels), and the two runs differ in the display's scale and
// nothing else. The strips' own scaling is the render smoke test's subject; this
// case isolates the tick rule.
plot::Plot one;
one.set_text_backend(&font);
one.set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax1 = one.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
const plot::PlotLayout& lay1 = one.layout();
PLOT_CHECK_NEAR(lay1.axes.w, 800.0f, 1e-6);
plot::Plot two;
two.set_text_backend(&font);
two.set_ui_scale(2.0f);
two.set_viewport({0.0f, 0.0f, 1600.0f, 800.0f}); // the same plot, a 2x display
const u32 ax2 = two.add_x_axis({"t", {0.0, 1000.0}, false, plot::AxisPlace::Bottom, false});
const plot::PlotLayout& lay2 = two.layout();
PLOT_CHECK_NEAR(lay2.axes.w, 1600.0f, 1e-6);
// Both axes are 800 logical units wide, so both must carry the same major ticks.
PLOT_CHECK_NEAR(lay2.axes.w / 2.0f, lay1.axes.w, 1e-6);
const std::vector<f64> majors1 = major_tick_values(one.x_axis(ax1));
const std::vector<f64> majors2 = major_tick_values(two.x_axis(ax2));
PLOT_CHECK(majors1.size() > 2u); // not vacuous: the case has ticks to lose
PLOT_CHECK(majors2 == majors1);
// The label strip is the same *logical* height too: its padding scaled even though
// this backend's own metrics are fixed. (A frame drawn by a renderer also gets
// metrics scaled with the display -- see the smoke test's two-scale case.)
PLOT_CHECK(lay2.x_label_area.h > lay1.x_label_area.h);
}
/// The scale is an opt-in: nothing changes for a caller that never sets one, which
/// is what keeps this a unit change and not a behaviour change for existing code.
PLOT_TEST(ui_scale_defaults_to_one) {
plot::render::Style s;
PLOT_CHECK(s.ui_scale == 1.0f);
// At 1.0 the conversion is the identity, exactly -- no rounding, no reordering.
PLOT_CHECK(s.device(0.0f) == 0.0f);
PLOT_CHECK(s.device(1.5f) == 1.5f);
PLOT_CHECK(s.device(12.0f) == 12.0f);
PLOT_CHECK(s.device(1234.5f) == 1234.5f);
// A scale that is not a scale -- a host that has not measured its display -- is
// 1:1 rather than zero, which would collapse every strip and stroke to nothing.
s.ui_scale = 0.0f;
PLOT_CHECK(s.device(12.0f) == 12.0f);
s.ui_scale = -2.0f;
PLOT_CHECK(s.device(12.0f) == 12.0f);
plot::render::EmbeddedMonoFont font;
// One plot left at the default and one told the scale is 1.0 lay out identically,
// measured strips and legend included: the default *is* 1.0.
plot::Plot plain;
plot::Plot one;
one.set_ui_scale(1.0f);
plot::data::ArraySeries series(ramp(8), 0.0, 1.0);
for (plot::Plot* p : {&plain, &one}) {
p->set_text_backend(&font);
p->set_viewport({0.0f, 0.0f, 800.0f, 400.0f});
const u32 ax = p->add_x_axis({"t", {0.0, 7.0}, false, plot::AxisPlace::Bottom, false});
const u32 ay = p->add_y_axis({"v", {0.0, 7.0}, false, plot::AxisPlace::Left, false});
p->add_trace({&series, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
}
const plot::PlotLayout& a = plain.layout();
const plot::PlotLayout& b = one.layout();
PLOT_CHECK(plain.ui_scale() == 1.0f);
PLOT_CHECK(b.y_axis_width == a.y_axis_width);
PLOT_CHECK(b.x_axis_height == a.x_axis_height);
PLOT_CHECK(b.y_title_width == a.y_title_width);
PLOT_CHECK(b.axes.x == a.axes.x);
PLOT_CHECK(b.axes.h == a.axes.h);
PLOT_CHECK(b.legend.panel.w == a.legend.panel.w);
PLOT_CHECK(b.legend.panel.h == a.legend.panel.h);
}
+250
View File
@@ -2413,3 +2413,253 @@ PLOT_TEST(legend_does_not_cover_the_plot_when_placed_bottom_left) {
name_ink);
ctx.destroy();
}
// --- one physical size at any DPI --------------------------------------------
namespace {
/// The chrome one render reports, in **device** pixels. Both runs of the two-scale
/// tests below fill this from a frame they really drew, so a comparison of the two
/// figures is a comparison of two frames and not of two derivations of one rule.
struct ChromeFigures {
plot::f32 font_cell = 0.0f; ///< the on-screen text cell the frame drew with
plot::f32 y_label_w = 0.0f; ///< the reserved label strip
plot::f32 x_label_h = 0.0f;
plot::f32 y_title_w = 0.0f;
plot::f32 legend_w = 0.0f;
plot::f32 legend_h = 0.0f;
plot::f32 axes_w = 0.0f;
plot::f32 axes_h = 0.0f;
plot::u32 ink_run = 0u; ///< the widest run of label ink in the y label strip
/// Every figure above in logical units, i.e. divided by the scale the frame was
/// drawn at: the numbers that must agree across displays.
ChromeFigures logical(plot::f32 scale) const noexcept;
};
ChromeFigures ChromeFigures::logical(plot::f32 scale) const noexcept {
ChromeFigures l;
l.font_cell = font_cell / scale;
l.y_label_w = y_label_w / scale;
l.x_label_h = x_label_h / scale;
l.y_title_w = y_title_w / scale;
l.legend_w = legend_w / scale;
l.legend_h = legend_h / scale;
l.axes_w = axes_w / scale;
l.axes_h = axes_h / scale;
l.ink_run = static_cast<plot::u32>(static_cast<plot::f32>(ink_run) / scale + 0.5f);
return l;
}
} // namespace
/// The requirement this task exists for, measured rather than asserted by eye: the
/// same logical plot drawn on a 1x display and on a 2x one. One framebuffer holds
/// both passes -- a 1x plot is a 640x360 viewport in it, a 2x plot the whole thing,
/// exactly as an adapter embeds a plot in a widget -- so the two frames differ only
/// in `ui_scale` and in the device size that goes with it.
///
/// The device figures must double. The logical ones -- the same figures divided by
/// the scale -- must be equal, which is what "the same physical size on any display"
/// means: the labels, strips, title column and legend occupy the same share of the
/// panel at both DPI, and each axis label is drawn with twice the pixels of ink.
PLOT_TEST(logical_metrics_are_dpi_independent) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
// The axes' border line runs down the label strip's right edge and is the same
// chalk-toned colour family as the text, so it is drawn in the background colour
// here: the only ink inside the strip is then the labels themselves.
r.style().axis_line = r.style().background;
std::vector<plot::f32> ys(256);
for (plot::u64 i = 0; i < ys.size(); ++i) {
ys[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.05));
}
plot::data::ArraySeries s(std::move(ys), 0.0, 1.0);
const auto figures_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
const plot::Rect vp{0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)};
plot::Plot p;
p.set_viewport(vp);
const plot::u32 ax = p.add_x_axis({"seconds", s.x_bounds(), true, plot::AxisPlace::Bottom,
false});
const plot::u32 ay = p.add_y_axis({"volts", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay, "sine"});
r.style().ui_scale = scale;
// The framebuffer stays the big one for both runs: the plot occupies the top-left
// `vp` of it, which is where a sub-rect viewport puts it (and what the adapters
// pass), so the same readback arithmetic reads both.
r.begin_frame(kBigW, kBigH, vp);
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
// The layout the renderer drew is the one the renderer laid out: `draw()` sets
// the plot's scale before `layout()`, so this is the propagation being tested.
PLOT_CHECK_NEAR(p.ui_scale(), scale, 1e-6);
PLOT_CHECK_NEAR(p.text_backend()->height(), r.axis_font_px(), 1e-6);
const plot::PlotLayout& lay = p.layout();
ChromeFigures f;
f.font_cell = r.axis_font_px();
f.y_label_w = lay.y_label_area.w;
f.x_label_h = lay.x_label_area.h;
f.y_title_w = lay.y_title_area.w;
f.legend_w = lay.legend.panel.w;
f.legend_h = lay.legend.panel.h;
f.axes_w = lay.axes.w;
f.axes_h = lay.axes.h;
const TextInk ink =
scan_text_ink(ctx, static_cast<plot::u32>(lay.y_label_area.x),
static_cast<plot::u32>(lay.y_label_area.right()),
static_cast<plot::u32>(lay.y_label_area.y),
static_cast<plot::u32>(lay.y_label_area.h), r.style().axis_text);
f.ink_run = ink.widest_row_run;
return f;
};
const ChromeFigures one = figures_at(1.0f);
const ChromeFigures two = figures_at(2.0f);
const plot::f32 k = 2.0f;
// Each run is normalised by *its own* scale -- the logical figures both displays
// must agree on.
const ChromeFigures one_l = one.logical(1.0f);
const ChromeFigures two_l = two.logical(k);
// The device figures double: the same chrome, twice the pixels.
PLOT_CHECK_NEAR(two.font_cell, k * one.font_cell, 1e-3f);
PLOT_CHECK_NEAR(two.y_label_w, k * one.y_label_w, 0.02f);
PLOT_CHECK_NEAR(two.x_label_h, k * one.x_label_h, 0.02f);
PLOT_CHECK_NEAR(two.y_title_w, k * one.y_title_w, 0.02f);
PLOT_CHECK_NEAR(two.legend_w, k * one.legend_w, 0.02f);
PLOT_CHECK_NEAR(two.legend_h, k * one.legend_h, 0.02f);
PLOT_CHECK_NEAR(two.axes_w, k * one.axes_w, 0.02f);
PLOT_CHECK_NEAR(two.axes_h, k * one.axes_h, 0.02f);
// ...and the logical figures agree, which is the requirement itself: a 640x360
// panel and a 1280x720 one show the same logical plot.
PLOT_CHECK_NEAR(two_l.font_cell, one_l.font_cell, 5e-4f);
PLOT_CHECK_NEAR(two_l.y_label_w, one_l.y_label_w, 0.01f);
PLOT_CHECK_NEAR(two_l.x_label_h, one_l.x_label_h, 0.01f);
PLOT_CHECK_NEAR(two_l.y_title_w, one_l.y_title_w, 0.01f);
PLOT_CHECK_NEAR(two_l.legend_w, one_l.legend_w, 0.01f);
PLOT_CHECK_NEAR(two_l.legend_h, one_l.legend_h, 0.01f);
PLOT_CHECK_NEAR(two_l.axes_w, one_l.axes_w, 0.01f);
PLOT_CHECK_NEAR(two_l.axes_h, one_l.axes_h, 0.01f);
// The ink is the pixels' own account: one drawn label occupies twice the device
// extent, and the same extent once normalised -- so the doubling above is drawn
// and not merely reserved.
PLOT_CHECK(one.ink_run > 8u); // labels really were drawn
PLOT_CHECK_NEAR(static_cast<plot::f32>(two.ink_run), k * static_cast<plot::f32>(one.ink_run),
2.0f);
PLOT_CHECK(two_l.ink_run <= one_l.ink_run + 1u);
PLOT_CHECK(two_l.ink_run + 1u >= one_l.ink_run);
std::printf("[ui_scale] 1x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u;"
" 2x: cell %.2f strip %.2f/%.2f title %.2f legend %.2fx%.2f ink %u; logical"
" 1x %.2f/%.2f/%.2f/%.2fx%.2f/%u vs 2x %.2f/%.2f/%.2f/%.2fx%.2f/%u\n",
one.font_cell, one.y_label_w, one.x_label_h, one.y_title_w, one.legend_w,
one.legend_h, one.ink_run, two.font_cell, two.y_label_w, two.x_label_h,
two.y_title_w, two.legend_w, two.legend_h, two.ink_run, one_l.font_cell,
one_l.y_label_w, one_l.x_label_h, one_l.y_title_w, one_l.legend_h, one_l.ink_run,
two_l.font_cell, two_l.y_label_w, two_l.x_label_h, two_l.y_title_w, two_l.legend_h,
two_l.ink_run);
ctx.destroy();
}
/// `dense_threshold_spp` is a **density** -- samples per rasterised pixel -- and not a
/// size, so the display's scale must not move it. This is the failure mode on the
/// over-eager side: scaling the threshold with the panel would call the LOD pyramid
/// less readily at 2x, so the same data would be rasterised by a different path
/// depending on the display.
///
/// The sample count grows with the device width, so both runs present the same
/// samples-per-device-pixel: if the threshold were scaled, the 2x run would fall back
/// to raw samples while the 1x one still took the pyramid. The threshold is set to a
/// value between them so the test can tell the two apart at all -- at the default 1.5
/// the comparison is decided far from the threshold and any scale below 64x would be
/// invisible (`level_for` returns level 0 -- the same raw gather -- until a pixel
/// covers 64 samples).
PLOT_TEST(dense_threshold_stays_a_device_ratio) {
constexpr plot::u32 kBigW = 2u * kW;
constexpr plot::u32 kBigH = 2u * kH;
constexpr plot::f32 kThreshold = 1500.0f;
plottest::HeadlessContext ctx(kBigW, kBigH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
r.style().dense_threshold_spp = kThreshold;
const auto series_of = [](plot::u64 n) {
std::vector<plot::f32> y(n);
for (plot::u64 i = 0; i < n; ++i) {
y[i] = static_cast<plot::f32>(std::sin(static_cast<double>(i) * 0.0001));
}
return y;
};
struct Decision {
plot::u32 lod = 0u;
plot::u32 raw = 0u;
plot::f64 spp = 0.0; ///< samples per *device* pixel the frame was drawn at
plot::u32 level = 0u; ///< the pyramid level that density selects
};
const auto decision_at = [&](plot::f32 scale) {
const plot::u32 w = static_cast<plot::u32>(scale * static_cast<plot::f32>(kW));
const plot::u32 h = static_cast<plot::u32>(scale * static_cast<plot::f32>(kH));
plot::data::ArraySeries s(series_of(static_cast<plot::u64>(scale) * 1'000'000u), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
r.style().ui_scale = scale;
r.begin_frame(kBigW, kBigH, {0.0f, 0.0f, static_cast<plot::f32>(w), static_cast<plot::f32>(h)});
r.draw(p);
r.end_frame();
PLOT_CHECK(!r.last_frame_had_gl_error());
Decision d;
d.lod = r.stats().lod_traces;
d.raw = r.stats().raw_traces;
// What the renderer actually drew at: the samples its gathered window covered
// over the *device* width of the axes it laid out. Recomputed here rather than
// taken on trust, so a spp divided by the logical width instead would show up as
// a doubled density and a different level.
const plot::f32 axes_w = p.layout().axes.w;
PLOT_CHECK(axes_w > 0.0f);
d.spp = static_cast<plot::f64>(r.stats().visible_samples) / static_cast<plot::f64>(axes_w);
d.level = s.pyramid().level_for(s.size(), d.spp);
return d;
};
const Decision one = decision_at(1.0f);
const Decision two = decision_at(2.0f);
// The renderer took the pyramid at both scales...
PLOT_CHECK(one.lod == 1u && one.raw == 0u);
PLOT_CHECK(two.lod == 1u && two.raw == 0u);
// ...and the density that decision rests on is the same in device terms at both
// scales, which is what makes the level below identical rather than merely non-zero.
PLOT_CHECK_NEAR(two.spp, one.spp, 0.5);
PLOT_CHECK(one.spp > static_cast<plot::f64>(kThreshold)); // not vacuous: over the line
PLOT_CHECK(one.level > 0u); // and a level, not the raw path
PLOT_CHECK(two.level == one.level);
std::printf("[dense] 1x spp %.1f -> level %u (lod %u raw %u); 2x spp %.1f -> level %u"
" (lod %u raw %u); threshold %.1f\n",
one.spp, one.level, one.lod, one.raw, two.spp, two.level, two.lod, two.raw,
static_cast<double>(kThreshold));
ctx.destroy();
}