feat(render): logical style metrics with a UI scale, so chrome has a consistent physical size
This commit is contained in:
1 parent
1f03631e98
commit
7500fc90d2
21 files changed
+708
-82
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user