feat(imgui): embeddable plot widget and demo

This commit is contained in:
Martino Ferrari committed 2026-09-28 00:38:31 +02:00
1 parent fb2c2f5e4d
commit 15931a2405
9 files changed
+1072 -38

No files matched your search

+561
View File
@@ -0,0 +1,561 @@
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
// widget inside an ImGui window that also owns a few controls, with ImGui drawing
// its own chrome into the same framebuffer.
//
// What it demonstrates:
//
// - `plot::imgui::PlotWidget`: a child region whose framebuffer rectangle the
// plot renders into directly (no texture, no FBO), while ImGui's chrome is
// drawn around it in the same pass;
// - the demo-side controls the widget deliberately knows nothing about: a
// slider for the producer's ingest rate, a button that injects a spike into
// the `StreamSeries`, and a checkbox toggling the LOD statistics overlay;
// - the frame order the embedding needs -- NewFrame, build UI (the widget draws
// the plot here), ImGui::Render, then the backend's draw data -- and that
// ImGui still renders correctly after the plot renderer has come and gone;
// - the headless smoke mode every demo here supports
// (PLOT_DEMO_EXIT_AFTER_MS / PLOT_DEMO_SCREENSHOT), whose screenshot this
// demo writes as the *whole* framebuffer (dump_full_framebuffer_ppm below), so
// the PPM holds the ImGui chrome as well as the plot.
//
// The producer is joined before the stream it writes into is destroyed, and
// before the GL context goes away.
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
// would rewrite the enumerators of plot/gl/api.hpp, which this file includes.
#define GLFW_INCLUDE_NONE
#include <GLFW/glfw3.h>
#include <backends/imgui_impl_glfw.h>
#include <backends/imgui_impl_opengl3.h>
#include <imgui.h>
#include <imgui/imgui_plot.hpp>
#include <plot/data/stream_series.hpp>
#include <plot/gl/loader.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <numbers>
#include <optional>
#include <thread>
#include <utility>
#include <vector>
namespace {
using plot::f32;
using plot::f64;
using plot::u32;
using plot::u64;
/// The sample *spacing* of the series: 1 microsecond, as in the GLFW demo. The
/// rate slider below changes how fast samples arrive, not this spacing (a
/// `StreamSeries`' dx is fixed at construction), so a slower ingest simply
/// advances the window more slowly.
constexpr f64 kDx = 1.0e-6;
/// The retained window: 1 << 20 samples = 1.048576 s of samples.
constexpr u64 kCapacity = 1u << 20;
/// Producer wakeup: about a millisecond of samples per push batch.
constexpr u64 kBatch = 1u << 10;
/// Cap on one push batch, so a process stopped and resumed catches up over
/// several iterations instead of allocating a batch for the whole gap.
constexpr u64 kMaxBatch = 1u << 16;
/// Longest the producer sleeps in one go, so a slider move is picked up promptly.
constexpr f64 kMaxSleepS = 0.05;
constexpr f64 kSineHz = 437.0;
constexpr f64 kNoiseAmp = 0.02;
constexpr f64 kSpikePeriodS = 0.1;
constexpr f64 kSpikeWidthS = 2.0e-6;
constexpr f64 kSpikeAmp = 3.0;
/// The manual spike the button injects: tall enough to be the maximum of every
/// LOD bucket it falls in, so it survives decimation.
constexpr f64 kInjectedSpikeAmp = 5.0;
/// The trace colour the render smoke test's blue-dominance predicate expects, so
/// the same predicate reads this demo's screenshot.
constexpr plot::Color kTraceColor = plot::Color::hex(0x40C0FF);
/// The host's own background, behind everything ImGui and the plot do: kept
/// distinct from the renderer's background so the PPM shows that the plot cleared
/// only its own rectangle.
constexpr plot::Color kHostClearColor = plot::Color::hex(0x263145);
constexpr f32 kWindowWidth = 960.0f;
constexpr f32 kWindowHeight = 680.0f;
/// y(t) for sample index `i`: sine + deterministic noise + a periodic narrow
/// spike. Same signal as the GLFW demo, so the two screenshots show the same
/// trace from the same source.
f32 sample_at(u64 i) noexcept {
const f64 t = static_cast<f64>(i) * kDx;
f64 y = std::sin(2.0 * std::numbers::pi * kSineHz * t);
u32 h = static_cast<u32>(i) * 2654435761u;
h ^= h >> 15;
h *= 2246822519u;
h ^= h >> 13;
h *= 3266489917u;
h ^= h >> 16;
y += kNoiseAmp * (static_cast<f64>(h & 0xFFFFFFu) / 8388608.0 - 1.0);
const f64 phase = std::fmod(t, kSpikePeriodS);
if (phase < kSpikeWidthS) {
y += kSpikeAmp * (1.0 - phase / kSpikeWidthS);
}
return static_cast<f32>(y);
}
/// Feeds a `StreamSeries` from its own thread at a settable rate, paced by
/// absolute deadlines off a steady clock: every wakeup produces every sample
/// whose time has already come, so a late wakeup is caught up instead of pushing
/// the phase back. The rate is settable from the render thread, and a requested
/// spike rides the middle sample of the batch in flight.
class Producer {
public:
explicit Producer(plot::data::StreamSeries& series)
: series_(&series), thread_([this] { run(); }) {}
~Producer() { stop(); }
Producer(const Producer&) = delete;
Producer& operator=(const Producer&) = delete;
/// Sets the stop flag and joins the thread. Idempotent; the destructor calls
/// it, so the thread is never detached and never outlives the stream.
void stop() noexcept {
stop_.store(true, std::memory_order_relaxed);
if (thread_.joinable()) thread_.join();
}
/// Samples per second the producer aims for. Written by the render thread.
void set_rate(f64 samples_per_second) noexcept {
rate_.store(samples_per_second > 0.0 ? samples_per_second : 1.0, std::memory_order_relaxed);
}
/// Asks the next full batch to carry one tall sample. Accumulates, so a rapid
/// double click injects two.
void request_spike() noexcept { spikes_.fetch_add(1, std::memory_order_relaxed); }
/// Samples pushed so far. Written by the producer, read by the render thread.
u64 produced() const noexcept { return produced_.load(std::memory_order_relaxed); }
private:
void run() {
using clock = std::chrono::steady_clock;
const auto start = clock::now();
u64 next = 0;
while (!stop_.load(std::memory_order_relaxed)) {
const f64 elapsed = std::chrono::duration<f64>(clock::now() - start).count();
const f64 rate = rate_.load(std::memory_order_relaxed);
const u64 due = static_cast<u64>(elapsed * rate) + 1; // samples whose time has come
if (due > next) {
u64 n = due - next;
if (n > kMaxBatch) n = kMaxBatch;
batch_.resize(static_cast<plot::usize>(n));
for (u64 k = 0; k < n; ++k) {
batch_[static_cast<plot::usize>(k)] = sample_at(next + k);
}
if (n > 0 && spikes_.load(std::memory_order_relaxed) > 0) {
batch_[static_cast<plot::usize>(n / 2)] += static_cast<f32>(kInjectedSpikeAmp);
spikes_.fetch_sub(1, std::memory_order_relaxed);
}
series_->push(batch_.data(), n);
next += n;
produced_.store(next, std::memory_order_relaxed);
}
// Sleep until a batch past the last produced sample, capped so a rate
// change is noticed quickly instead of at the old rate's next deadline.
const f64 wake_s =
(static_cast<f64>(next) + static_cast<f64>(kBatch)) / rate;
const f64 sleep_s = wake_s < kMaxSleepS ? wake_s : kMaxSleepS;
std::this_thread::sleep_until(
start + std::chrono::duration_cast<clock::duration>(std::chrono::duration<f64>(sleep_s)));
}
}
plot::data::StreamSeries* series_;
std::vector<f32> batch_;
std::atomic<f64> rate_{1.0e6};
std::atomic<u64> produced_{0};
std::atomic<int> spikes_{0};
std::atomic<bool> stop_{false};
std::thread thread_; ///< declared last: the lambda runs once everything is up
};
/// The live readout: the achieved push rate, the frame time and the frame count.
struct Meter {
f64 frame_ms = 0.0; ///< exponential moving average of the whole frame
f64 elapsed = 0.0; ///< seconds of loop time
f64 last_report = 0.0;
u64 last_produced = 0;
u64 frames = 0;
f64 rate = 0.0; ///< samples/s over the last metered interval
};
/// `glfwGetProcAddress` returns GLFW's `void (*)(void)`, `plot::gl::ProcLoader`
/// returns `void*`: a function pointer respelled as an object pointer. POSIX
/// guarantees the two have the same representation, and the bit copy states that
/// where a `reinterpret_cast` would be merely conditionally supported.
void* resolve_gl(const char* name) noexcept {
GLFWglproc p = glfwGetProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(p), "function and object pointers must match");
std::memcpy(&out, &p, sizeof(out));
return out;
}
void report_failure(const plot::Status& st, const char* what) noexcept {
std::fprintf(stderr, "plot_imgui_demo: %s: %s: %s\n", what, plot::to_string(st.code), st.message);
}
/// Writes the *whole* bound framebuffer as a PPM (P6).
///
/// Demo-side, not library API: `Renderer::dump_frame_ppm()` covers the plot's
/// viewport only, and this smoke mode has to show the ImGui chrome as well. Reads
/// RGBA (4-byte pixels have no row padding whatever the pack alignment) and
/// writes the rows top-down, since glReadPixels hands them back bottom-up.
bool dump_full_framebuffer_ppm(const char* path, int fb_w, int fb_h) {
if (path == nullptr || path[0] == '\0' || fb_w <= 0 || fb_h <= 0) return false;
const plot::usize w = static_cast<plot::usize>(fb_w);
const plot::usize h = static_cast<plot::usize>(fb_h);
std::vector<unsigned char> pixels(w * h * 4u, 0u);
plot::gl::glPixelStorei(plot::gl::GL_PACK_ALIGNMENT, 4);
plot::gl::glReadPixels(0, 0, fb_w, fb_h, plot::gl::GL_RGBA, plot::gl::GL_UNSIGNED_BYTE,
pixels.data());
std::FILE* file = std::fopen(path, "wb");
if (file == nullptr) return false;
std::fprintf(file, "P6\n%d %d\n255\n", fb_w, fb_h);
std::vector<unsigned char> row(w * 3u);
for (plot::usize y = 0; y < h; ++y) {
const unsigned char* src = pixels.data() + (h - 1u - y) * w * 4u;
for (plot::usize x = 0; x < w; ++x) {
row[x * 3u] = src[x * 4u];
row[x * 3u + 1u] = src[x * 4u + 1u];
row[x * 3u + 2u] = src[x * 4u + 2u];
}
if (std::fwrite(row.data(), 1u, row.size(), file) != row.size()) {
std::fclose(file);
return false;
}
}
return std::fclose(file) == 0;
}
/// `PLOT_DEMO_EXIT_AFTER_MS` as seconds: 0.0 when unset, unparsable or not
/// positive, which is "run until the window is closed".
f64 exit_after_seconds() noexcept {
const char* v = std::getenv("PLOT_DEMO_EXIT_AFTER_MS");
if (v == nullptr || v[0] == '\0') return 0.0;
char* end = nullptr;
const f64 ms = std::strtod(v, &end);
if (end == v || !(ms > 0.0)) return 0.0;
return ms * 1.0e-3;
}
} // namespace
int main() {
const f64 exit_after_s = exit_after_seconds();
const bool automated = exit_after_s > 0.0;
const char* const screenshot = std::getenv("PLOT_DEMO_SCREENSHOT");
// --- window and 3.3 core context ------------------------------------
if (glfwInit() != GLFW_TRUE) {
std::fprintf(stderr, "plot_imgui_demo: glfwInit() failed\n");
return 1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
// A run with a deadline is the automated path: it stays off-screen, so a CI run
// never flashes a window on a real display.
if (automated) glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
GLFWwindow* const window =
glfwCreateWindow(1280, 720, "plot imgui demo", nullptr, nullptr);
if (window == nullptr) {
const char* desc = nullptr;
(void)glfwGetError(&desc);
std::fprintf(stderr, "plot_imgui_demo: glfwCreateWindow() failed: %s\n",
desc != nullptr ? desc : "no error description");
glfwTerminate();
return 1;
}
glfwMakeContextCurrent(window);
// The bounded run is not throttled by a display refresh, so its frame times are
// the CPU's and not a vblank's.
glfwSwapInterval(automated ? 0 : 1);
plot::Status st;
if (!plot::gl::load(&resolve_gl)) {
st = plot::Status{plot::ErrorCode::Unsupported,
"plot::gl::load() failed: missing OpenGL 3.3 entry point"};
report_failure(st, "gl::load()");
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
std::optional<plot::Renderer> renderer;
renderer.emplace();
if (!renderer->init(&st)) {
if (st.code == plot::ErrorCode::Ok) {
st = plot::Status{plot::ErrorCode::GLFailure, "Renderer::init() failed"};
}
report_failure(st, "Renderer::init()");
renderer.reset();
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// --- Dear ImGui -----------------------------------------------------
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::GetIO().IniFilename = nullptr; // no imgui.ini written next to the binary
ImGui::StyleColorsDark();
const bool platform_ok = ImGui_ImplGlfw_InitForOpenGL(window, true);
const bool backend_ok = platform_ok && ImGui_ImplOpenGL3_Init("#version 330 core");
if (!backend_ok) {
st = plot::Status{plot::ErrorCode::Unsupported,
"Dear ImGui backend init failed (ImGui_ImplGlfw_InitForOpenGL / "
"ImGui_ImplOpenGL3_Init): no GL loader for the current context"};
report_failure(st, "imgui backend");
if (platform_ok) ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
renderer.reset();
glfwDestroyWindow(window);
glfwTerminate();
return 1;
}
// --- the plot and its stream ----------------------------------------
plot::data::StreamSeries stream(kCapacity, 0.0, kDx);
Producer producer(stream);
producer.set_rate(1.0e6);
plot::Plot graph;
const u32 axis_x = graph.add_x_axis(
{"time", {-0.5, 0.5}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
const u32 axis_y = graph.add_y_axis(
{"signal", {-4.0, 4.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
graph.add_trace({&stream, kTraceColor, 1.5f, axis_x, axis_y});
plot::imgui::PlotWidget widget;
// Demo state: the widget knows about none of it.
f32 rate_msamples = 1.0f;
bool show_lod_stats = true;
Meter meter;
const f64 start = glfwGetTime();
f64 previous = start;
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
glfwPollEvents();
int fb_w = 0;
int fb_h = 0;
glfwGetFramebufferSize(window, &fb_w, &fb_h);
if (fb_w <= 0 || fb_h <= 0) {
// Minimised or otherwise undrawable: nothing to render and no screenshot to
// take. Still honour the deadline, so a bounded run ends rather than idling
// past the one it was given.
if (automated && glfwGetTime() - start >= exit_after_s) {
std::fprintf(stderr,
"[imgui_demo] exit deadline reached with no drawable (framebuffer 0x0);"
" nothing to dump\n");
break;
}
glfwWaitEventsTimeout(0.05);
continue;
}
stream.acquire(); // once per frame, on the render thread
const f64 now = glfwGetTime();
const f64 dt = now - previous;
previous = now;
// The host clears its own frame first: the renderer clears only the plot's
// rectangle, so everything outside it is ours to paint.
plot::gl::glViewport(0, 0, fb_w, fb_h);
plot::gl::glClearColor(kHostClearColor.r, kHostClearColor.g, kHostClearColor.b,
kHostClearColor.a);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
ImGui_ImplGlfw_NewFrame();
ImGui_ImplOpenGL3_NewFrame(); // builds the font atlas/texture on its first call
ImGui::NewFrame();
// Re-frame the live stream while the user has not overridden the ranges:
// auto_fit_* re-arms auto scale, so it may only run while that is still on.
if (graph.x_axis(axis_x).auto_scale) graph.auto_fit_x();
if (graph.y_axis(axis_y).auto_scale) graph.auto_fit_y(axis_y);
const std::chrono::steady_clock::time_point frame_begin =
std::chrono::steady_clock::now();
ImGui::SetNextWindowPos(ImVec2(24.0f, 24.0f), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(kWindowWidth, kWindowHeight), ImGuiCond_Once);
// NoBackground: the window's own fill would otherwise be drawn over the
// child's rectangle after the widget has already rendered the plot there.
// This is the host's side of the widget's documented precondition.
ImGui::Begin("plot imgui demo", nullptr, ImGuiWindowFlags_NoBackground);
ImGui::Text("%.3f Msamples/s | %.2f ms/frame | %llu frames | %dx%d fb",
meter.rate * 1.0e-6, meter.frame_ms,
static_cast<unsigned long long>(meter.frames), fb_w, fb_h);
ImGui::SliderFloat("ingest rate", &rate_msamples, 0.05f, 2.0f, "%.2f Msamples/s");
ImGui::SameLine();
if (ImGui::Button("inject spike")) producer.request_spike();
ImGui::Checkbox("LOD statistics overlay", &show_lod_stats);
ImGui::Separator();
producer.set_rate(static_cast<f64>(rate_msamples) * 1.0e6);
// The widget takes the rest of the window, and draws the plot into exactly
// that rectangle of the framebuffer, now, before ImGui renders its chrome.
const ImVec2 plot_origin = ImGui::GetCursorScreenPos();
const ImVec2 plot_size = ImGui::GetContentRegionAvail();
widget.draw("plot", graph, plot::Vec2{plot_size.x, plot_size.y}, *renderer);
if (show_lod_stats) {
// Demo-side overlay: the renderer's own statistics for the frame it just
// drew, painted on top of the plot through ImGui's draw list. Nothing here
// is library API, and the widget is unaware of it.
const plot::FrameStats stats = renderer->stats();
const plot::Rect axes = std::as_const(graph).layout().axes;
const f64 spp = axes.w > 0.0f ? static_cast<f64>(stats.visible_samples) /
static_cast<f64>(axes.w)
: 0.0;
char line_a[160];
char line_b[160];
std::snprintf(line_a, sizeof line_a,
"LOD %s | %llu samples in window | %.0f samples/px | axes %.0fx%.0f",
stats.lod_traces > 0 ? "pyramid" : (stats.raw_traces > 0 ? "raw" : "--"),
static_cast<unsigned long long>(stats.visible_samples), spp,
static_cast<double>(axes.w), static_cast<double>(axes.h));
std::snprintf(line_b, sizeof line_b,
"%u segments | %u draw calls | lod %u raw %u | %.2f ms/frame",
stats.staged_segments, stats.draw_calls, stats.lod_traces, stats.raw_traces,
meter.frame_ms);
const ImVec2 size_a = ImGui::CalcTextSize(line_a);
const ImVec2 size_b = ImGui::CalcTextSize(line_b);
const ImVec2 pad{6.0f, 4.0f};
const ImVec2 box{std::max(size_a.x, size_b.x), size_a.y + size_b.y};
const ImVec2 at{plot_origin.x + 10.0f, plot_origin.y + 10.0f};
ImDrawList* const dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(ImVec2(at.x - pad.x, at.y - pad.y),
ImVec2(at.x + box.x + pad.x, at.y + box.y + pad.y),
IM_COL32(8, 12, 18, 190), 3.0f);
dl->AddText(at, IM_COL32(235, 240, 248, 255), line_a);
dl->AddText(ImVec2(at.x, at.y + size_a.y), IM_COL32(150, 200, 240, 255), line_b);
}
ImGui::End();
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
meter.frames += 1;
const f64 frame_ms =
std::chrono::duration<f64>(std::chrono::steady_clock::now() - frame_begin).count() * 1.0e3;
meter.frame_ms += 0.1 * (frame_ms - meter.frame_ms); // EMA
meter.elapsed += dt;
if (meter.elapsed - meter.last_report >= 0.5) {
const u64 produced = producer.produced();
meter.rate = static_cast<f64>(produced - meter.last_produced) /
(meter.elapsed - meter.last_report);
meter.last_produced = produced;
meter.last_report = meter.elapsed;
const plot::Range xr = graph.x_range(axis_x);
const bool auto_x = graph.x_axis(axis_x).auto_scale;
const bool auto_y = graph.y_axis(axis_y).auto_scale;
std::printf("[imgui_demo] t=%.1fs %.3f Msamples/s %.2f ms/frame %llu frames"
" x=[%.4f, %.4f] s (span %.4f) auto x=%d y=%d\n",
meter.elapsed, meter.rate * 1.0e-6, meter.frame_ms,
static_cast<unsigned long long>(meter.frames), xr.min, xr.max, xr.span(),
auto_x ? 1 : 0, auto_y ? 1 : 0);
std::fflush(stdout);
}
const bool exiting = automated && (glfwGetTime() - start) >= exit_after_s;
if (exiting && screenshot != nullptr && screenshot[0] != '\0') {
const bool dumped = dump_full_framebuffer_ppm(screenshot, fb_w, fb_h);
std::printf("[imgui_demo] screenshot %s: %s\n", screenshot,
dumped ? "written" : "FAILED");
std::fflush(stdout);
}
glfwSwapBuffers(window);
if (exiting) break;
}
// --- teardown, in the order the resources depend on each other --------
int last_fb_w = 0;
int last_fb_h = 0;
int last_window_w = 0;
int last_window_h = 0;
float scale_x = 1.0f;
float scale_y = 1.0f;
glfwGetFramebufferSize(window, &last_fb_w, &last_fb_h);
glfwGetWindowSize(window, &last_window_w, &last_window_h);
glfwGetWindowContentScale(window, &scale_x, &scale_y);
const plot::FrameStats last_stats = renderer->stats();
const f64 dense_spp = renderer->style().dense_threshold_spp;
const bool persistent_upload = renderer->using_persistent_upload();
const bool gl_error = renderer->last_frame_had_gl_error();
producer.stop(); // no thread pushes into the stream after this point
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
// The renderer's RAII handles call glDelete*, so the context must be current --
// and alive -- when they run.
renderer.reset();
glfwDestroyWindow(window);
glfwTerminate();
const f64 mean_ms =
meter.frames > 0 ? meter.elapsed / static_cast<f64>(meter.frames) * 1.0e3 : 0.0;
std::printf("\n[imgui_demo] ---- run summary ----\n");
std::printf("[imgui_demo] producer %llu samples pushed, last metered %.3f Msamples/s\n",
static_cast<unsigned long long>(producer.produced()), meter.rate * 1.0e-6);
std::printf("[imgui_demo] frames %llu in %.3f s, mean %.2f ms/frame (%.1f fps),"
" last metered %.2f ms/frame\n",
static_cast<unsigned long long>(meter.frames), meter.elapsed, mean_ms,
mean_ms > 0.0 ? 1.0e3 / mean_ms : 0.0, meter.frame_ms);
const plot::Rect last_axes = std::as_const(graph).layout().axes;
const f64 last_spp = last_axes.w > 0.0f ? static_cast<f64>(last_stats.visible_samples) /
static_cast<f64>(last_axes.w)
: 0.0;
std::printf("[imgui_demo] retained %llu samples, x [%.6f, %.6f] s\n",
static_cast<unsigned long long>(stream.size()), stream.x_bounds().min,
stream.x_bounds().max);
std::printf("[imgui_demo] last frame %.1f samples/px (dense threshold %.1f) -> %s\n", last_spp,
dense_spp, last_stats.lod_traces > 0 ? "LOD pyramid" : "raw samples");
std::printf("[imgui_demo] last frame visible_samples %llu, axes %.0fx%.0f, staged_segments %u,"
" draw_calls %u, lod %u raw %u\n",
static_cast<unsigned long long>(last_stats.visible_samples),
static_cast<double>(last_axes.w), static_cast<double>(last_axes.h),
last_stats.staged_segments, last_stats.draw_calls, last_stats.lod_traces,
last_stats.raw_traces);
std::printf("[imgui_demo] pixel spaces framebuffer %dx%d, window %dx%d, content scale %.2fx%.2f\n",
last_fb_w, last_fb_h, last_window_w, last_window_h, static_cast<double>(scale_x),
static_cast<double>(scale_y));
std::printf("[imgui_demo] upload path %s, gl error on the last frame %d\n",
persistent_upload ? "persistent-mapped ring" : "orphan", gl_error ? 1 : 0);
return 0;
}