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

+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();
}