From 9cd89eec2fd2ffce76206945736457deaa180fea Mon Sep 17 00:00:00 2001 From: Matija Kecman Date: Mon, 27 Apr 2026 07:42:16 -0700 Subject: [PATCH] Make Studio's monitoring charts responsive and full-window * **Dynamic Full-Window Scaling**: Charts in the Performance and Solver panels now expand to fill the entire pane, automatically arranging side-by-side or stacked based on window orientation to eliminate scrollbars. * **Responsive Decorators**: Added reusable C++ ImPlot helper functions that auto-hide titles, legends, and tick labels when the plot surface is small. * **Cleaner Visualization**: Refined chart titles (e.g., "CPU msec vs frame", "Convergence vs iter") and removed redundant axis labels for improved scannability. These changes were based on the plot behavior of //third_party/mujoco/src/experimental/py/sample/implot.py This is an incremental improvement, we can make the plots even nicer in future. PiperOrigin-RevId: 906351914 Change-Id: I8cb19c19bf807f0107c717066250a71bcf7e325f --- src/experimental/platform/sim/sim_profiler.cc | 41 +++++------ src/experimental/platform/sim/sim_profiler.h | 5 +- src/experimental/platform/ux/gui.cc | 21 +++--- src/experimental/platform/ux/gui.h | 6 +- src/experimental/platform/ux/imgui_widgets.cc | 70 +++++++++++++++++++ src/experimental/platform/ux/imgui_widgets.h | 41 +++++++++++ src/experimental/studio/app.cc | 17 +++-- 7 files changed, 162 insertions(+), 39 deletions(-) diff --git a/src/experimental/platform/sim/sim_profiler.cc b/src/experimental/platform/sim/sim_profiler.cc index 7aca0c4c..e3b794b8 100644 --- a/src/experimental/platform/sim/sim_profiler.cc +++ b/src/experimental/platform/sim/sim_profiler.cc @@ -17,12 +17,11 @@ #include #include #include +#include "experimental/platform/ux/imgui_widgets.h" namespace mujoco::platform { -SimProfiler::SimProfiler() { - Clear(); -} +SimProfiler::SimProfiler() { Clear(); } void SimProfiler::Clear() { constexpr int kProfilerMaxFrames = 200; @@ -88,21 +87,21 @@ void SimProfiler::Update(const mjModel* model, const mjData* data) { // Solver diagnostics. mjtNum sqrt_nnz = 0; int solver_niter = 0; - const int nisland = data->nefc ? mjMAX(1, mjMIN(data->nisland, mjNISLAND)) : 0; - for (int island=0; island < nisland; island++) { + const int nisland = + data->nefc ? mjMAX(1, mjMIN(data->nisland, mjNISLAND)) : 0; + for (int island = 0; island < nisland; island++) { sqrt_nnz += data->solver_nnz[island]; solver_niter += data->solver_niter[island]; } sqrt_nnz = mju_sqrt(sqrt_nnz); dim_dof_.erase(dim_dof_.begin()); - int nv = (model->opt.enableflags & mjENBL_SLEEP) ? data->nv_awake - : model->nv; + int nv = (model->opt.enableflags & mjENBL_SLEEP) ? data->nv_awake : model->nv; dim_dof_.push_back(nv); dim_body_.erase(dim_body_.begin()); int nbody = (model->opt.enableflags & mjENBL_SLEEP) ? data->nbody_awake - : model->nbody; + : model->nbody; dim_body_.push_back(nbody); dim_constraint_.erase(dim_constraint_.begin()); @@ -115,16 +114,17 @@ void SimProfiler::Update(const mjModel* model, const mjData* data) { dim_contact_.push_back(data->ncon); dim_iteration_.erase(dim_iteration_.begin()); - dim_iteration_.push_back(static_cast(solver_niter) / nisland); + dim_iteration_.push_back(static_cast(solver_niter) / + mjMAX(1, nisland)); } - -void SimProfiler::CpuTimeGraph() { - if (ImPlot::BeginPlot("CPU Time", ImVec2(-1, 0), ImPlotFlags_NoMouseText)) { +void SimProfiler::CpuTimeGraph(ImVec2 plot_size) { + ImPlotFlags flags = + ImPlot_SetupPlotFlags(plot_size) | ImPlotFlags_NoMouseText; + if (ImPlot::BeginPlot("CPU msec vs frame", plot_size, flags)) { ImPlot::PushStyleVar(ImPlotStyleVar_LineWeight, 2.0f); - ImPlot::SetupAxis(ImAxis_X1, "frame", ImPlotAxisFlags_AutoFit); - ImPlot::SetupAxis(ImAxis_Y1, "msec", ImPlotAxisFlags_AutoFit); - ImPlot::SetupAxisFormat(ImAxis_Y1, "%.2f"); + ImPlot_SetupTimeAxis(plot_size, ""); + ImPlot_SetupValueAxis(plot_size, "", "%.2f"); ImPlot::SetupLegend(ImPlotLocation_NorthEast); ImPlot::SetupFinish(); @@ -143,12 +143,13 @@ void SimProfiler::CpuTimeGraph() { } } -void SimProfiler::DimensionsGraph() { - if (ImPlot::BeginPlot("Dimensions", ImVec2(-1, 0), ImPlotFlags_NoMouseText)) { +void SimProfiler::DimensionsGraph(ImVec2 plot_size) { + ImPlotFlags flags = + ImPlot_SetupPlotFlags(plot_size) | ImPlotFlags_NoMouseText; + if (ImPlot::BeginPlot("Dimensions vs frame", plot_size, flags)) { ImPlot::PushStyleVar(ImPlotStyleVar_LineWeight, 2.0f); - ImPlot::SetupAxis(ImAxis_X1, "frame", ImPlotAxisFlags_AutoFit); - ImPlot::SetupAxis(ImAxis_Y1, "count", ImPlotAxisFlags_AutoFit); - ImPlot::SetupAxisFormat(ImAxis_Y1, "%.0f"); + ImPlot_SetupTimeAxis(plot_size, ""); + ImPlot_SetupValueAxis(plot_size, "", "%.0f"); ImPlot::SetupLegend(ImPlotLocation_NorthEast); ImPlot::SetupFinish(); diff --git a/src/experimental/platform/sim/sim_profiler.h b/src/experimental/platform/sim/sim_profiler.h index 4e9cce46..c1f1ef8e 100644 --- a/src/experimental/platform/sim/sim_profiler.h +++ b/src/experimental/platform/sim/sim_profiler.h @@ -17,6 +17,7 @@ #include +#include #include namespace mujoco::platform { @@ -33,8 +34,8 @@ class SimProfiler { void Update(const mjModel* model, const mjData* data); // Displays the profiling data using ImPlot. - void CpuTimeGraph(); - void DimensionsGraph(); + void CpuTimeGraph(ImVec2 plot_size = ImVec2(-1, 0)); + void DimensionsGraph(ImVec2 plot_size = ImVec2(-1, 0)); private: std::vector cpu_total_; diff --git a/src/experimental/platform/ux/gui.cc b/src/experimental/platform/ux/gui.cc index e70bf13c..9098092c 100644 --- a/src/experimental/platform/ux/gui.cc +++ b/src/experimental/platform/ux/gui.cc @@ -506,7 +506,7 @@ bool CameraSelectionGui(const mjModel* model, mjData* data, mjvCamera& camera, auto select = [&](int type, int idx) { if (ImGui::Selectable(GetCameraName(model, camera, type).c_str(), - (type == idx))) { + (type == idx))) { return true; } return false; @@ -1148,13 +1148,13 @@ static int GetPlotXLimit(const mjData* data) { return mjMAX(10, ((max_niter + 9) / 10) * 10); } -void ConvergenceGui(const mjModel* model, mjData* data) { +void ConvergenceGui(const mjModel* model, mjData* data, ImVec2 plot_size) { int xlim = GetPlotXLimit(data); - - if (ImPlot::BeginPlot("Convergence (log 10)", ImVec2(-1, 0), - ImPlotFlags_NoMouseText)) { + ImPlotFlags flags = + ImPlot_SetupPlotFlags(plot_size) | ImPlotFlags_NoMouseText; + if (ImPlot::BeginPlot("Convergence (log 10) vs iter", plot_size, flags)) { ImPlot::PushStyleVar(ImPlotStyleVar_LineWeight, 2.0f); - ImPlot::SetupAxis(ImAxis_X1, "iteration", ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxis(ImAxis_X1, "", ImPlotAxisFlags_AutoFit); ImPlot::SetupAxisLimits(ImAxis_X1, 0, xlim, ImPlotCond_Always); ImPlot::SetupAxisFormat(ImAxis_Y1, "%.1f"); ImPlot::SetupAxisLimits(ImAxis_Y1, -20, 5, ImPlotCond_Always); @@ -1211,12 +1211,13 @@ void ConvergenceGui(const mjModel* model, mjData* data) { } } -void CountsGui(const mjModel* model, mjData* data) { +void CountsGui(const mjModel* model, mjData* data, ImVec2 plot_size) { int xlim = GetPlotXLimit(data); - - if (ImPlot::BeginPlot("Counts", ImVec2(-1, 0), ImPlotFlags_NoMouseText)) { + ImPlotFlags flags = + ImPlot_SetupPlotFlags(plot_size) | ImPlotFlags_NoMouseText; + if (ImPlot::BeginPlot("Counts vs iter", plot_size, flags)) { ImPlot::PushStyleVar(ImPlotStyleVar_LineWeight, 2.0f); - ImPlot::SetupAxis(ImAxis_X1, "iteration", ImPlotAxisFlags_AutoFit); + ImPlot::SetupAxis(ImAxis_X1, "", ImPlotAxisFlags_AutoFit); ImPlot::SetupAxisLimits(ImAxis_X1, 0, xlim, ImPlotCond_Always); ImPlot::SetupAxisFormat(ImAxis_Y1, "%.0f"); ImPlot::SetupAxisLimits(ImAxis_Y1, 0, 80, ImPlotCond_Always); diff --git a/src/experimental/platform/ux/gui.h b/src/experimental/platform/ux/gui.h index 938466bb..4dc8b69c 100644 --- a/src/experimental/platform/ux/gui.h +++ b/src/experimental/platform/ux/gui.h @@ -138,10 +138,12 @@ void NoiseGui(const mjModel* model, const mjData* data, float& noise_scale, float& noise_rate); // UX for the solver convergence chart. -void ConvergenceGui(const mjModel* model, mjData* data); +void ConvergenceGui(const mjModel* model, mjData* data, + ImVec2 plot_size = ImVec2(-1, 0)); // UX for the solver counts chart. -void CountsGui(const mjModel* model, mjData* data); +void CountsGui(const mjModel* model, mjData* data, + ImVec2 plot_size = ImVec2(-1, 0)); // UX for displaying basic simulation information. Note that the pause state and // FPS needs to be tracked by the caller and passed here to be displayed. diff --git a/src/experimental/platform/ux/imgui_widgets.cc b/src/experimental/platform/ux/imgui_widgets.cc index 61d2989a..7d0f1cba 100644 --- a/src/experimental/platform/ux/imgui_widgets.cc +++ b/src/experimental/platform/ux/imgui_widgets.cc @@ -14,6 +14,7 @@ #include "experimental/platform/ux/imgui_widgets.h" +#include #include #include #include @@ -23,6 +24,7 @@ #include #include +#include #include namespace mujoco::platform { @@ -378,4 +380,72 @@ void MaybeSaveToClipboard(const std::string& contents) { } } +ImPlotFlags ImPlot_SetupPlotFlags(ImVec2 plot_size) { + ImPlotFlags flags = ImPlotFlags_None; + if (plot_size.x > 0 && plot_size.y > 0) { + const float min_dim = std::min(plot_size.x, plot_size.y); + if (min_dim < 300) { + flags |= ImPlotFlags_NoTitle; + } + if (min_dim < 200) { + flags |= ImPlotFlags_NoLegend; + } + } + return flags; +} + +void ImPlot_SetupTimeAxis(ImVec2 plot_size, const char* label, + ImPlotAxisFlags extra_flags) { + ImPlotAxisFlags flags = extra_flags; + if (plot_size.x > 0 && plot_size.x < 300) { + flags |= ImPlotAxisFlags_NoTickLabels; + } + ImPlot::SetupAxis(ImAxis_X1, label, flags); +} + +void ImPlot_SetupValueAxis(ImVec2 plot_size, const char* label, + const char* format, ImPlotAxisFlags extra_flags) { + ImPlotAxisFlags flags = extra_flags; + if (plot_size.y > 0 && plot_size.y < 150) { + flags |= ImPlotAxisFlags_NoTickLabels; + } + ImPlot::SetupAxis(ImAxis_Y1, label, flags); + if (format) { + ImPlot::SetupAxisFormat(ImAxis_Y1, format); + } +} + +void ImPlot_SetupFixedAxis(ImVec2 plot_size, double y_min, double y_max, + const char* label, const char* format, + const double* tick_values, + const char* const* tick_labels, int n_ticks) { + ImPlotAxisFlags flags = ImPlotAxisFlags_None; + if (plot_size.y > 0 && plot_size.y < 150) { + flags |= ImPlotAxisFlags_NoTickLabels; + } + ImPlot::SetupAxis(ImAxis_Y1, label, flags); + ImPlot::SetupAxisLimits(ImAxis_Y1, y_min, y_max, ImPlotCond_Always); + if (format) { + ImPlot::SetupAxisFormat(ImAxis_Y1, format); + } + if (tick_values && n_ticks > 0) { + ImPlot::SetupAxisTicks(ImAxis_Y1, tick_values, n_ticks, tick_labels); + } +} + +ImPlotPairLayout ImPlot_ComputePairLayout() { + ImVec2 avail = ImGui::GetContentRegionAvail(); + bool is_wide = avail.x > avail.y; + + const float item_spacing = ImGui::GetStyle().ItemSpacing.y; + ImVec2 plot_size(is_wide ? (avail.x - item_spacing) * 0.5f : avail.x, + is_wide ? avail.y : (avail.y - item_spacing) * 0.5f); + + return { + plot_size, + is_wide ? ImPlotLayoutDirection::kHorizontal + : ImPlotLayoutDirection::kVertical, + }; +} + } // namespace mujoco::platform diff --git a/src/experimental/platform/ux/imgui_widgets.h b/src/experimental/platform/ux/imgui_widgets.h index 619e1055..187dabba 100644 --- a/src/experimental/platform/ux/imgui_widgets.h +++ b/src/experimental/platform/ux/imgui_widgets.h @@ -27,6 +27,7 @@ #include #include #include +#include #include #include "experimental/platform/ux/enum_utils.h" @@ -566,6 +567,46 @@ inline void EndBoxSection() { ImGui::EndTable(); } // Saves the given contents to the clipboard if the clipboard is available. void MaybeSaveToClipboard(const std::string& contents); +// Returns plot flags with title/legend conditionally hidden when the plot +// area is too small. `plot_size` is the final rendered size of the plot. +ImPlotFlags ImPlot_SetupPlotFlags(ImVec2 plot_size); + +// Sets up the X axis as a "time/frame" axis. +// Hides tick labels when the plot is narrow. +// Uses `label` as the axis label (empty string to hide) and auto-fit limits. +void ImPlot_SetupTimeAxis( + ImVec2 plot_size, const char* label = "", + ImPlotAxisFlags extra_flags = ImPlotAxisFlags_AutoFit); + +// Sets up a Y axis with auto-fit limits. +// Hides tick labels when the plot is short. +void ImPlot_SetupValueAxis( + ImVec2 plot_size, const char* label = "", const char* format = nullptr, + ImPlotAxisFlags extra_flags = ImPlotAxisFlags_AutoFit); + +// Sets up a Y axis with fixed limits and optional explicit ticks. +// Hides tick labels when the plot is short. +void ImPlot_SetupFixedAxis(ImVec2 plot_size, double y_min, double y_max, + const char* label = "", const char* format = nullptr, + const double* tick_values = nullptr, + const char* const* tick_labels = nullptr, + int n_ticks = 0); + +enum class ImPlotLayoutDirection { + kHorizontal, + kVertical, +}; + +struct ImPlotPairLayout { + ImVec2 plot_size; // Size for each individual plot. + ImPlotLayoutDirection direction; +}; + +// Computes a responsive layout for two plots that share the available +// content region. When the region is wider than tall, the plots are placed +// side-by-side; otherwise they are stacked vertically. +ImPlotPairLayout ImPlot_ComputePairLayout(); + } // namespace mujoco::platform #endif // MUJOCO_SRC_EXPERIMENTAL_PLATFORM_UX_IMGUI_WIDGETS_H_ diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc index 24a43649..dec270ae 100644 --- a/src/experimental/studio/app.cc +++ b/src/experimental/studio/app.cc @@ -884,8 +884,12 @@ void App::BuildGui() { ImGui::SetNextWindowPos(chart_pos, ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(chart_size, ImGuiCond_FirstUseEver); if (ImGui::Begin("Performance", &tmp_.chart_performance)) { - profiler_.CpuTimeGraph(); - profiler_.DimensionsGraph(); + auto layout = platform::ImPlot_ComputePairLayout(); + profiler_.CpuTimeGraph(layout.plot_size); + if (layout.direction == platform::ImPlotLayoutDirection::kHorizontal) { + ImGui::SameLine(); + } + profiler_.DimensionsGraph(layout.plot_size); } ImGui::End(); } @@ -894,8 +898,12 @@ void App::BuildGui() { ImGui::SetNextWindowPos(chart_pos, ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(chart_size, ImGuiCond_FirstUseEver); if (ImGui::Begin("Solver", &tmp_.chart_solver)) { - platform::CountsGui(model(), data()); - platform::ConvergenceGui(model(), data()); + auto layout = platform::ImPlot_ComputePairLayout(); + platform::CountsGui(model(), data(), layout.plot_size); + if (layout.direction == platform::ImPlotLayoutDirection::kHorizontal) { + ImGui::SameLine(); + } + platform::ConvergenceGui(model(), data(), layout.plot_size); } ImGui::End(); } @@ -1452,7 +1460,6 @@ void App::ToolBarGui() { } ImGui::SetItemTooltip("%s", "Reset"); - // Combined (Normal Pause, Viscous Pause, Play) widget and Speed selection. ImGui::SameLine(0, separator_width); platform::StepControlGui(model(), &step_control_, tmp_.speed_index);