diff --git a/src/experimental/platform/gui.cc b/src/experimental/platform/gui.cc index 7436b912..5cfc9f2b 100644 --- a/src/experimental/platform/gui.cc +++ b/src/experimental/platform/gui.cc @@ -627,7 +627,7 @@ void PhysicsGui(mjModel* model, float min_width) { ImGui_InputN("Wind", opt.wind, 3); ImGui_InputN("Magnetic", opt.magnetic, 3); ImGui_Input("Density", &opt.density, {.min = .1, .max = 1}); - ImGui_Input("Viscosity", &opt.viscosity, {.min = .1, .max = 1}); + ImGui_Input("Viscosity", &opt.viscosity, {.min = .1, .max = 10}); ImGui_Input("Imp Ratio", &opt.impratio, {.min = .1, .max = 1}); ImGui::TreePop(); }; diff --git a/src/experimental/platform/imgui_widgets.h b/src/experimental/platform/imgui_widgets.h index 0b9f7d58..e8988369 100644 --- a/src/experimental/platform/imgui_widgets.h +++ b/src/experimental/platform/imgui_widgets.h @@ -39,6 +39,7 @@ static constexpr const char ICON_FA_DIAMOND[] = "\xEF\x88\x99"; static constexpr const char ICON_FA_EJECT[] = "\xEF\x81\x92"; static constexpr const char ICON_FA_FAST_FORWARD[] = "\xEF\x81\x90"; static constexpr const char ICON_FA_MOON[] = "\xEF\x86\x86"; +static constexpr const char ICON_FA_MAGIC[] = "\xEF\x83\x90"; static constexpr const char ICON_FA_PAUSE[] = "\xEF\x81\x8C"; static constexpr const char ICON_FA_PLAY[] = "\xEF\x81\x8B"; static constexpr const char ICON_FA_REFRESH[] = "\xEF\x80\xA1"; @@ -262,6 +263,24 @@ inline bool ImGui_IsChordJustPressed(ImGuiKeyChord chord) { return ImGui::IsKeyChordPressed(chord, 0); } +// Stateful button that displays the given color when active, and shows a +// semi-transparent hover color (controlled by hover_alpha) when inactive. +inline bool ImGui_ColorButton(const char* label, bool active, ImColor color, + const ImVec2& size = ImVec2(0, 0), + float hover_alpha = 0.5f) { + ScopedStyle style; + const ImColor hover(color.Value.x, color.Value.y, color.Value.z, + color.Value.w * hover_alpha); + if (active) { + style.Color(ImGuiCol_Button, color); + style.Color(ImGuiCol_ButtonHovered, color); + } else { + style.Color(ImGuiCol_ButtonHovered, hover); + } + style.Color(ImGuiCol_ButtonActive, color); + return ImGui::Button(label, size); +} + // Begin a boxed section with outer borders - use EndBoxSection to close. inline bool BeginBoxSection(const char* id, ImGuiTableFlags extra_flags = 0) { ImGuiTableFlags flags = ImGuiTableFlags_BordersOuter | extra_flags; diff --git a/src/experimental/platform/step_control.cc b/src/experimental/platform/step_control.cc index 01d8856f..4c8b58cf 100644 --- a/src/experimental/platform/step_control.cc +++ b/src/experimental/platform/step_control.cc @@ -13,10 +13,12 @@ // limitations under the License. #include "experimental/platform/step_control.h" + #include #include #include #include + #include namespace mujoco::platform { @@ -28,17 +30,11 @@ static mjtNum Timer() { return Milliseconds(Clock::now() - start).count(); } -StepControl::StepControl() { - mjcb_time = Timer; -} +StepControl::StepControl() { mjcb_time = Timer; } -float StepControl::GetSpeedMeasured() const { - return speed_measured_; -} +float StepControl::GetSpeedMeasured() const { return speed_measured_; } -float StepControl::GetSpeed() const { - return speed_; -} +float StepControl::GetSpeed() const { return speed_; } void StepControl::SetSpeed(float speed_percent_real_time) { speed_ = std::clamp(speed_percent_real_time, .1f, 100.f); @@ -59,12 +55,34 @@ void StepControl::SetNoiseParameters(float ctrl_noise_scale, ctrl_noise_rate_ = ctrl_noise_rate; } -StepControl::Status StepControl::Advance(const mjModel* m, mjData* d) { +void StepControl::SetPauseState(PauseState state, mjModel* m) { + if (pause_state_ == PauseState::kViscousPaused && + state != PauseState::kViscousPaused && m) { + mju_copy3(m->opt.gravity, saved_gravity_); + m->opt.viscosity = saved_viscosity_; + m->opt.disableflags = saved_disableflags_; + } + + if (state == PauseState::kViscousPaused && + pause_state_ != PauseState::kViscousPaused && m) { + mju_copy3(saved_gravity_, m->opt.gravity); + saved_viscosity_ = m->opt.viscosity; + saved_disableflags_ = m->opt.disableflags; + mju_zero3(m->opt.gravity); + m->opt.viscosity = 10; + m->opt.disableflags |= mjDSBL_SPRING; + } + + pause_state_ = state; +} + +StepControl::Status StepControl::Advance(mjModel* m, mjData* d) { if (!m) { return Status::kOk; } - if (paused_) { + + if (pause_state_ == PauseState::kNormalPaused) { // When we eventually unpause, we need to make sure we sync to immediately // and step once. Without this we could step many times before rendering // resulting in a noticeable delay before the simulation restarts @@ -151,7 +169,7 @@ StepControl::Status StepControl::Advance(const mjModel* m, mjData* d) { for (mjtWarning w : kDivergedWarnings) { if (d->warning[w].number > 0) { // Stop stepping if the simulation diverged. - paused_ = true; + pause_state_ = PauseState::kNormalPaused; return Status::kDiverged; } } @@ -179,7 +197,7 @@ void StepControl::InjectNoise(const mjModel* m, mjData* d) { // convert rate and scale to discrete time (Ornstein–Uhlenbeck) mjtNum rate = mju_exp(-m->opt.timestep / ctrl_noise_rate_); - mjtNum scale = ctrl_noise_std_ * mju_sqrt(1-rate*rate); + mjtNum scale = ctrl_noise_std_ * mju_sqrt(1 - rate * rate); for (int i = 0; i < m->nu; i++) { mjtNum bottom = 0; @@ -187,14 +205,14 @@ void StepControl::InjectNoise(const mjModel* m, mjData* d) { mjtNum midpoint = 0; mjtNum halfrange = 1; if (m->actuator_ctrllimited[i]) { - bottom = m->actuator_ctrlrange[2*i]; - top = m->actuator_ctrlrange[2*i+1]; - midpoint = 0.5 * (top + bottom); // target of exponential decay + bottom = m->actuator_ctrlrange[2 * i]; + top = m->actuator_ctrlrange[2 * i + 1]; + midpoint = 0.5 * (top + bottom); // target of exponential decay halfrange = 0.5 * (top - bottom); // scales noise } // exponential convergence to midpoint at ctrl_noise_rate - d->ctrl[i] = rate * d->ctrl[i] + (1-rate) * midpoint; + d->ctrl[i] = rate * d->ctrl[i] + (1 - rate) * midpoint; // add noise d->ctrl[i] += scale * halfrange * mju_standardNormal(nullptr); diff --git a/src/experimental/platform/step_control.h b/src/experimental/platform/step_control.h index 4abb2483..916a23c7 100644 --- a/src/experimental/platform/step_control.h +++ b/src/experimental/platform/step_control.h @@ -36,6 +36,10 @@ class StepControl { // Simulation was not stepped because it is paused. kPaused, + // Simulation is viscously paused (stepping with zero gravity, high + // viscosity, and no spring forces). + kViscousPaused, + // Simulation diverged with autoreset enabled. kAutoReset, @@ -49,7 +53,7 @@ class StepControl { mjWARN_BADQACC, mjWARN_BADQVEL, mjWARN_BADQPOS}; // Steps physics forward, respecting speed settings and refresh budget. - Status Advance(const mjModel* m, mjData* d); + Status Advance(mjModel* m, mjData* d); // Ensures the next call to Advance() will synchronize time and step once. void ForceSync(); @@ -63,13 +67,14 @@ class StepControl { void GetNoiseParameters(float& noise_scale, float& noise_rate) const; void SetNoiseParameters(float noise_scale, float noise_rate); - // Returns true if the simulation is paused. - bool IsPaused() { return paused_; } + enum class PauseState { kUnpaused, kNormalPaused, kViscousPaused }; - // Pauses/unpauses the simulation. - void Pause() { paused_ = true; } - void Unpause() { paused_ = false; } - void TogglePause() { paused_ = !paused_; } + // Sets the pause state of the simulation. + // m must be non-null for viscous pausing. + void SetPauseState(PauseState state, mjModel* m = nullptr); + + // Gets the current pause state of the simulation. + PauseState GetPauseState() const { return pause_state_; } // If the simulation is paused, will perform a single step on the next // Advance() call. @@ -104,8 +109,12 @@ class StepControl { // Maximum mis-alignment before re-sync (simulation seconds) double sync_misalign_ = .1; - // Whether or not the simulation is paused. - bool paused_ = false; + PauseState pause_state_ = PauseState::kUnpaused; + + // Viscous pause state variables + mjtNum saved_gravity_[3] = {0}; + mjtNum saved_viscosity_ = 0; + int saved_disableflags_ = 0; // Perform only a single step on the next call to Advance() if the simulation // is paused. diff --git a/src/experimental/studio/app.cc b/src/experimental/studio/app.cc index 1f67f020..ac6d8825 100644 --- a/src/experimental/studio/app.cc +++ b/src/experimental/studio/app.cc @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -32,8 +33,8 @@ #include #include #include -#include "experimental/platform/gui.h" #include "experimental/platform/file_dialog.h" +#include "experimental/platform/gui.h" #include "experimental/platform/helpers.h" #include "experimental/platform/imgui_widgets.h" #include "experimental/platform/interaction.h" @@ -46,6 +47,8 @@ namespace mujoco::studio { +using PauseState = platform::StepControl::PauseState; + static void ToggleFlag(mjtByte& flag) { flag = flag ? 0 : 1; } static void ToggleWindow(bool& window) { @@ -66,6 +69,7 @@ static void SelectParentPerturb(const mjModel* model, mjvPerturb& perturb) { static constexpr const char* ICON_PLAY = platform::ICON_FA_PLAY; static constexpr const char* ICON_PAUSE = platform::ICON_FA_PAUSE; +static constexpr const char* ICON_VISCOUS_PAUSE = platform::ICON_FA_MAGIC; static constexpr const char* ICON_COPY_CAMERA = platform::ICON_FA_COPY; static constexpr const char* ICON_UNLOAD_MODEL = platform::ICON_FA_EJECT; static constexpr const char* ICON_RELOAD_MODEL = platform::ICON_FA_REFRESH; @@ -101,8 +105,7 @@ static constexpr std::array kPercentRealTime = { }; // clang-format on -App::App(Config config) - : ini_path_(std::move(config.ini_path)) { +App::App(Config config) : ini_path_(std::move(config.ini_path)) { platform::Window::Config window_config; window_config.renderer_backend = platform::Renderer::GetBackend(); window_config.offscreen_mode = config.offscreen_mode; @@ -173,11 +176,11 @@ void App::OnModelLoaded(std::string filename, ModelKind model_kind) { model_path_ = std::move(filename); if (model_kind_ == kEmptyModel) { - step_control_.Unpause(); + step_control_.SetPauseState(PauseState::kUnpaused); } model_kind_ = model_kind; if (model_kind_ == kEmptyModel) { - step_control_.Pause(); + step_control_.SetPauseState(PauseState::kNormalPaused); } // Reset/reinitialize everything that depends on the new mjModel. @@ -251,7 +254,7 @@ void App::UpdatePhysics() { }); if (!stepped) { - if (!step_control_.IsPaused()) { + if (step_control_.GetPauseState() != PauseState::kNormalPaused) { mju_zero(data()->xfrc_applied, 6 * model()->nbody); mjv_applyPerturbPose(model(), data(), &perturb_, 0); mjv_applyPerturbForce(model(), data(), &perturb_); @@ -299,7 +302,7 @@ void App::LoadHistory(int offset) { std::span state = history_.SetIndex(offset); if (!state.empty()) { // Pause simulation when entering history mode. - step_control_.Pause(); + step_control_.SetPauseState(PauseState::kNormalPaused); // Load the state into the data buffer. mj_setState(model(), data(), state.data(), mjSTATE_INTEGRATION); @@ -540,19 +543,32 @@ void App::HandleKeyboardEvents() { } else if (ImGui_IsChordJustPressed(ImGuiKey_Equal)) { SetSpeedIndex(tmp_.speed_index - 1); } else if (ImGui_IsChordJustPressed(ImGuiKey_LeftArrow)) { - if (step_control_.IsPaused()) { + if (step_control_.GetPauseState() == PauseState::kNormalPaused) { LoadHistory(history_.GetIndex() - 1); } } else if (ImGui_IsChordJustPressed(ImGuiKey_RightArrow)) { - if (step_control_.IsPaused()) { + if (step_control_.GetPauseState() == PauseState::kNormalPaused) { if (history_.GetIndex() == 0) { step_control_.RequestSingleStep(); } else { LoadHistory(history_.GetIndex() + 1); } } + } else if (ImGui_IsChordJustPressed(ImGuiMod_Ctrl | ImGuiKey_Space)) { + if (step_control_.GetPauseState() == PauseState::kViscousPaused) { + step_control_.SetPauseState(PauseState::kUnpaused, model()); + } else { + step_control_.SetPauseState(PauseState::kViscousPaused, model()); + tmp_.viscous_pause_time = ImGui::GetTime(); + } } else if (ImGui_IsChordJustPressed(ImGuiKey_Space)) { - step_control_.TogglePause(); + if (step_control_.GetPauseState() == PauseState::kViscousPaused) { + step_control_.SetPauseState(PauseState::kNormalPaused, model()); + } else if (step_control_.GetPauseState() == PauseState::kUnpaused) { + step_control_.SetPauseState(PauseState::kNormalPaused); + } else { + step_control_.SetPauseState(PauseState::kUnpaused); + } } else if (ImGui_IsChordJustPressed(ImGuiKey_Backspace)) { ResetPhysics(); } else if (ImGui_IsChordJustPressed(ImGuiKey_PageUp)) { @@ -854,14 +870,16 @@ void App::BuildGui() { style.Var(ImGuiStyleVar_Alpha, 0.6f); if (ImGui::Begin("Stats", &tmp_.stats)) { const float fps = renderer_->GetFps(); - platform::StatsGui(model(), data(), step_control_.IsPaused(), fps); + platform::StatsGui( + model(), data(), + step_control_.GetPauseState() == PauseState::kNormalPaused, fps); } ImGui::End(); } // Display a drag-and-drop message if no model is loaded. if (model_kind_ == kEmptyModel) { - #ifndef __EMSCRIPTEN__ +#ifndef __EMSCRIPTEN__ const char* text = "Load model file or drag-and-drop model file here."; const float width = window_->GetWidth() * ImGui::GetWindowDpiScale(); @@ -881,7 +899,7 @@ void App::BuildGui() { ImGui::Text("%s", text); } ImGui::End(); - #endif // !__EMSCRIPTEN__ +#endif // !__EMSCRIPTEN__ } FileDialogGui(); @@ -1159,6 +1177,7 @@ void App::HelpGui() { ImGui::Text("Toggle Fullscreen"); ImGui::Text("Free Camera"); ImGui::Text("Toggle Pause"); + ImGui::Text("Toggle Visc Pause"); ImGui::Text("Reset Sim"); ImGui::Text("Toggle Left UI"); ImGui::Text("Toggle Right UI"); @@ -1184,6 +1203,7 @@ void App::HelpGui() { ImGui::Text("F11"); ImGui::Text("Esc"); ImGui::Text("Spc"); + ImGui::Text("Ctrl+Spc"); ImGui::Text("Bksp"); ImGui::Text("Tab"); ImGui::Text("Sh+Tab"); @@ -1265,7 +1285,7 @@ void App::ToolBarGui() { const float scale = ImGui::GetWindowDpiScale(); const float right_width = 520.f * scale; const ImVec2 button_size(48.f * scale, 32.f * scale); - const ImVec2 play_button_size(120.f * scale, 32.f * scale); + const ImVec2 play_button_size(80.f * scale, 32.f * scale); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, right_width); @@ -1273,21 +1293,35 @@ void App::ToolBarGui() { ImGui::TableNextColumn(); ImGui::Text("%s", " "); - // Unload button. - ImGui::SameLine(); - style.Color(ImGuiCol_ButtonHovered, red); - if (ImGui::Button(ICON_UNLOAD_MODEL, button_size)) { - InitEmptyModel(); - } - ImGui::SetItemTooltip("%s", "Unload"); - style.Reset(); + // Combined (Unload, Reload) widget + { + style.Var(ImGuiStyleVar_FrameRounding, 2.0f); - // Reload button. - ImGui::SameLine(); - if (ImGui::Button(ICON_RELOAD_MODEL, button_size)) { - RequestModelReload(); + // Unload button. + ImGui::SameLine(); + { + const ImColor a = red; + const ImColor h(a.Value.x, a.Value.y, a.Value.z, a.Value.w * 0.6f); + style.Color(ImGuiCol_ButtonHovered, h); + style.Color(ImGuiCol_ButtonActive, a); + + if (ImGui::Button(ICON_UNLOAD_MODEL, button_size)) { + InitEmptyModel(); + } + ImGui::SetItemTooltip("%s", "Unload"); + style.Reset(); + } + + // Reload button. + ImGui::SameLine(0, 0); + if (ImGui::Button(ICON_RELOAD_MODEL, button_size)) { + RequestModelReload(); + } + ImGui::SetItemTooltip("%s", "Reload"); } - ImGui::SetItemTooltip("%s", "Reload"); + + ImGui::SameLine(0, 0); + ImGui::Text(" "); // Reset button. ImGui::SameLine(); @@ -1296,15 +1330,54 @@ void App::ToolBarGui() { } ImGui::SetItemTooltip("%s", "Reset"); - // Play/pause button. - ImGui::SameLine(); - const bool paused = step_control_.IsPaused(); - style.Color(ImGuiCol_Button, paused ? yellow : green); - if (ImGui::Button(paused ? ICON_PLAY : ICON_PAUSE, play_button_size)) { - step_control_.TogglePause(); + ImGui::SameLine(0, 0); + ImGui::Text(" "); + + // Combined (Normal Pause, Viscous Pause, Play) widget + { + style.Var(ImGuiStyleVar_FrameRounding, 2.0f); + + // Normal pause button. + ImGui::SameLine(); + ImColor paused_color = yellow; + bool paused = step_control_.GetPauseState() == PauseState::kNormalPaused; + if (platform::ImGui_ColorButton(ICON_PAUSE, paused, paused_color, + button_size)) { + if (!paused) { + step_control_.SetPauseState(PauseState::kNormalPaused, model()); + } + } + ImGui::SetItemTooltip("%s", "Pause"); + + // Viscous pause button. + ImGui::SameLine(0, 0); + ImColor vpaused_color = green; + float t = 0.f; + bool vpaused = + step_control_.GetPauseState() == PauseState::kViscousPaused; + if (vpaused) { + t = ImGui::GetTime() - tmp_.viscous_pause_time; + t = std::sqrt(std::min(t / 0.75f, 1.0f)); + vpaused_color = ImColor(ImLerp(green.Value, yellow.Value, t)); + } + if (platform::ImGui_ColorButton(ICON_VISCOUS_PAUSE, vpaused, + vpaused_color, button_size, + t < 1.0f ? 1.0f : 0.5f)) { + if (!vpaused) { + step_control_.SetPauseState(PauseState::kViscousPaused, model()); + tmp_.viscous_pause_time = ImGui::GetTime(); + } + } + ImGui::SetItemTooltip("%s", "Viscous Pause"); + + // Play button. + ImGui::SameLine(0, 0); + if (platform::ImGui_ColorButton( + ICON_PLAY, step_control_.GetPauseState() == PauseState::kUnpaused, + green, button_size, 0.6f)) { + step_control_.SetPauseState(PauseState::kUnpaused, model()); + } } - ImGui::SetItemTooltip("%s", paused ? "Play" : "Pause"); - style.Reset(); ImGui::SameLine(); ImGui::Text("%s", " |"); @@ -1433,7 +1506,10 @@ void App::StatusBarGui() { if (!has_model()) { ImGui::Text("No model loaded"); - } else if (step_control_.IsPaused()) { + } else if (step_control_.GetPauseState() == PauseState::kViscousPaused) { + ImGui::Text("Viscous Pause"); + ImGui::SetItemTooltip("Zero gravity, high viscosity, no spring forces"); + } else if (step_control_.GetPauseState() == PauseState::kNormalPaused) { ImGui::Text("Paused"); } else { const float desired_realtime = step_control_.GetSpeed(); @@ -1504,7 +1580,7 @@ void App::StatusBarGui() { void App::MainMenuGui() { if (ImGui::BeginMainMenuBar()) { if (ImGui::BeginMenu("File")) { - #ifndef __EMSCRIPTEN__ +#ifndef __EMSCRIPTEN__ if (ImGui::MenuItem("Open Model File", "Ctrl+O")) { tmp_.file_dialog = UiTempState::FileDialog_Load; } @@ -1526,22 +1602,28 @@ void App::MainMenuGui() { tmp_.file_dialog = UiTempState::FileDialog_PrintData; } ImGui::Separator(); - #endif // !__EMSCRIPTEN__ +#endif // !__EMSCRIPTEN__ if (ImGui::MenuItem("Unload", "Ctrl+U")) { InitEmptyModel(); } - #ifndef __EMSCRIPTEN__ +#ifndef __EMSCRIPTEN__ ImGui::Separator(); if (ImGui::MenuItem("Quit", "Ctrl+Q")) { tmp_.should_exit = true; } - #endif // !__EMSCRIPTEN__ +#endif // !__EMSCRIPTEN__ ImGui::EndMenu(); } if (ImGui::BeginMenu("Simulation")) { - if (ImGui::MenuItem("Pause", "Space", step_control_.IsPaused())) { - step_control_.TogglePause(); + if (ImGui::MenuItem( + "Pause", "Space", + step_control_.GetPauseState() == PauseState::kNormalPaused)) { + if (step_control_.GetPauseState() != PauseState::kNormalPaused) { + step_control_.SetPauseState(PauseState::kNormalPaused); + } else { + step_control_.SetPauseState(PauseState::kUnpaused, model()); + } } if (ImGui::MenuItem("Reset", "Backspace")) { ResetPhysics(); diff --git a/src/experimental/studio/app.h b/src/experimental/studio/app.h index bbab8bac..d8b8fd23 100644 --- a/src/experimental/studio/app.h +++ b/src/experimental/studio/app.h @@ -123,6 +123,9 @@ class App { // Controls. bool perturb_active = false; + // Time at which viscous pause was activated, for the green→yellow button + // color animation. + double viscous_pause_time = 0; int speed_index = 0; float cam_speed = 0.0f;