feat(rapier_testbed): autosave testbed configuration + add support for per-example settings (#799)

* feat(rapier_testbed): autosave testbed configuration + add support for per-example parameters

* feat(rapier_testbed): also save the camera position

* feat(rapier_testbed): improve ergonomics of example-specific settings

* chore: cargo fmt

* chore(rapier_testbed): small UI ergonomics improvement

* chore(rapier_testbed): clippy fixes
This commit is contained in:
Sébastien Crozet
2025-02-21 17:52:46 +01:00
committed by GitHub
parent bf8e48e920
commit 5ca6ae9106
23 changed files with 358 additions and 195 deletions

View File

@@ -9,7 +9,7 @@ use bevy::render::camera::Camera;
const LINE_TO_PIXEL_RATIO: f32 = 0.1;
#[derive(Component)]
#[derive(Component, PartialEq, Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OrbitCamera {
pub zoom: f32,
pub center: Vec3,

View File

@@ -11,7 +11,7 @@ use std::ops::RangeInclusive;
const LINE_TO_PIXEL_RATIO: f32 = 0.1;
#[derive(Component)]
#[derive(Component, PartialEq, Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OrbitCamera {
pub x: f32,
pub y: f32,

View File

@@ -13,6 +13,7 @@ use rapier::math::{Isometry, Real, Vector};
//#[cfg(feature = "dim2")]
//use crate::objects::polyline::Polyline;
// use crate::objects::mesh::Mesh;
use crate::testbed::TestbedStateFlags;
use rand::{Rng, SeedableRng};
use rand_pcg::Pcg32;
use std::collections::HashMap;
@@ -362,9 +363,11 @@ impl GraphicsManager {
pub fn draw(
&mut self,
flags: TestbedStateFlags,
_bodies: &RigidBodySet,
colliders: &ColliderSet,
components: &mut Query<&mut Transform>,
visibilities: &mut Query<&mut Visibility>,
_materials: &mut Assets<BevyMaterial>,
) {
for (_, ns) in self.b2sn.iter_mut() {
@@ -386,6 +389,14 @@ impl GraphicsManager {
// }
// }
if let Ok(mut vis) = visibilities.get_mut(n.entity) {
if flags.contains(TestbedStateFlags::DRAW_SURFACES) {
*vis = Visibility::Inherited;
} else {
*vis = Visibility::Hidden;
}
}
n.update(colliders, components, &self.gfx_shift);
}
}

View File

@@ -23,6 +23,8 @@ pub mod physics;
#[cfg(all(feature = "dim3", feature = "other-backends"))]
mod physx_backend;
mod plugin;
mod save;
mod settings;
mod testbed;
mod ui;

34
src_testbed/save.rs Normal file
View File

@@ -0,0 +1,34 @@
#[cfg(feature = "dim2")]
use crate::camera2d::OrbitCamera;
#[cfg(feature = "dim3")]
use crate::camera3d::OrbitCamera;
use crate::settings::ExampleSettings;
use crate::testbed::{RapierSolverType, RunMode, TestbedStateFlags};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, PartialEq, Debug, Default, Clone)]
pub struct SerializableTestbedState {
pub running: RunMode,
pub flags: TestbedStateFlags,
pub selected_example: usize,
pub selected_backend: usize,
pub example_settings: ExampleSettings,
pub solver_type: RapierSolverType,
pub physx_use_two_friction_directions: bool,
pub camera: OrbitCamera,
}
#[cfg(feature = "dim2")]
#[derive(Serialize, Deserialize, PartialEq, Debug, Default, Clone)]
pub struct SerializableCameraState {
pub zoom: f32,
pub center: na::Point2<f32>,
}
#[cfg(feature = "dim3")]
#[derive(Serialize, Deserialize, PartialEq, Debug, Default, Clone)]
pub struct SerializableCameraState {
pub distance: f32,
pub position: na::Point3<f32>,
pub center: na::Point3<f32>,
}

106
src_testbed/settings.rs Normal file
View File

@@ -0,0 +1,106 @@
use std::collections::HashMap;
use std::ops::RangeInclusive;
#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub enum SettingValue {
U32 {
value: u32,
range: RangeInclusive<u32>,
},
F32 {
value: f32,
range: RangeInclusive<f32>,
},
}
#[derive(Default, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ExampleSettings {
values: HashMap<String, SettingValue>,
}
impl ExampleSettings {
pub fn clear(&mut self) {
self.values.clear();
}
pub fn len(&self) -> usize {
self.values.len()
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&String, &mut SettingValue)> {
self.values.iter_mut()
}
pub fn set_u32(&mut self, key: &str, value: u32, range: RangeInclusive<u32>) {
self.values
.insert(key.to_string(), SettingValue::U32 { value, range });
}
pub fn get_or_set_u32(
&mut self,
key: &'static str,
default: u32,
range: RangeInclusive<u32>,
) -> u32 {
let to_insert = SettingValue::U32 {
value: default,
range,
};
let entry = self
.values
.entry(key.to_string())
.or_insert(to_insert.clone());
match entry {
SettingValue::U32 { value, .. } => *value,
_ => {
// The entry doesnt have the right type. Overwrite with the new value.
*entry = to_insert;
default
}
}
}
pub fn set_f32(&mut self, key: &str, value: f32, range: RangeInclusive<f32>) {
self.values
.insert(key.to_string(), SettingValue::F32 { value, range });
}
pub fn get_or_set_f32(
&mut self,
key: &'static str,
value: f32,
range: RangeInclusive<f32>,
) -> f32 {
let to_insert = SettingValue::F32 { value, range };
let entry = self
.values
.entry(key.to_string())
.or_insert(to_insert.clone());
match entry {
SettingValue::F32 { value, .. } => *value,
_ => {
// The entry doesnt have the right type. Overwrite with the new value.
*entry = to_insert;
value
}
}
}
pub fn get_u32(&self, key: &'static str) -> Option<u32> {
match self.values.get(key)? {
SettingValue::U32 { value, .. } => Some(*value),
_ => None,
}
}
pub fn get_f32(&self, key: &'static str) -> Option<f32> {
match self.values.get(key)? {
SettingValue::F32 { value, .. } => Some(*value),
_ => None,
}
}
}

View File

@@ -1,12 +1,11 @@
#![allow(clippy::bad_bit_mask)] // otherwise clippy complains because of TestbedStateFlags::NONE which is 0.
#![allow(clippy::unnecessary_cast)] // allowed for f32 -> f64 cast for the f64 testbed.
use bevy::prelude::*;
use std::env;
use std::mem;
use std::num::NonZeroUsize;
use bevy::prelude::*;
use crate::debug_render::{DebugRenderPipelineResource, RapierDebugRenderPlugin};
use crate::graphics::BevyMaterialComponent;
use crate::physics::{DeserializedPhysicsSnapshot, PhysicsEvents, PhysicsSnapshot, PhysicsState};
@@ -51,17 +50,21 @@ const BOX2D_BACKEND: usize = 1;
pub(crate) const PHYSX_BACKEND_PATCH_FRICTION: usize = 1;
pub(crate) const PHYSX_BACKEND_TWO_FRICTION_DIR: usize = 2;
#[derive(PartialEq)]
pub fn save_file_path() -> String {
format!("testbed_state_{}.autosave.json", env!("CARGO_CRATE_NAME"))
}
#[derive(Default, PartialEq, Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum RunMode {
Running,
#[default]
Stop,
Step,
}
bitflags::bitflags! {
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
#[derive(Copy, Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct TestbedStateFlags: u32 {
const NONE = 0;
const SLEEP = 1 << 0;
const SUB_STEPPING = 1 << 1;
const SHAPES = 1 << 2;
@@ -72,6 +75,13 @@ bitflags::bitflags! {
const CENTER_OF_MASSES = 1 << 7;
const WIREFRAME = 1 << 8;
const STATISTICS = 1 << 9;
const DRAW_SURFACES = 1 << 10;
}
}
impl Default for TestbedStateFlags {
fn default() -> Self {
TestbedStateFlags::DRAW_SURFACES | TestbedStateFlags::SLEEP
}
}
@@ -84,10 +94,11 @@ bitflags::bitflags! {
const BACKEND_CHANGED = 1 << 3;
const TAKE_SNAPSHOT = 1 << 4;
const RESTORE_SNAPSHOT = 1 << 5;
const APP_STARTED = 1 << 6;
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum RapierSolverType {
#[default]
TgsSoft,
@@ -118,13 +129,42 @@ pub struct TestbedState {
pub example_names: Vec<&'static str>,
pub selected_example: usize,
pub selected_backend: usize,
pub example_settings: ExampleSettings,
pub solver_type: RapierSolverType,
pub physx_use_two_friction_directions: bool,
pub snapshot: Option<PhysicsSnapshot>,
pub nsteps: usize,
prev_save_data: SerializableTestbedState,
camera_locked: bool, // Used so that the camera can remain the same before and after we change backend or press the restart button.
}
impl TestbedState {
fn save_data(&self, camera: OrbitCamera) -> SerializableTestbedState {
SerializableTestbedState {
running: self.running,
flags: self.flags,
selected_example: self.selected_example,
selected_backend: self.selected_backend,
example_settings: self.example_settings.clone(),
solver_type: self.solver_type,
physx_use_two_friction_directions: self.physx_use_two_friction_directions,
camera,
}
}
pub fn apply_saved_data(&mut self, state: SerializableTestbedState, camera: &mut OrbitCamera) {
self.prev_save_data = state.clone();
self.running = state.running;
self.flags = state.flags;
self.selected_example = state.selected_example;
self.selected_backend = state.selected_backend;
self.example_settings = state.example_settings;
self.solver_type = state.solver_type;
self.physx_use_two_friction_directions = state.physx_use_two_friction_directions;
*camera = state.camera;
}
}
#[derive(Resource)]
struct SceneBuilders(SimulationBuilders);
@@ -172,7 +212,7 @@ pub struct TestbedApp {
impl TestbedApp {
pub fn new_empty() -> Self {
let graphics = GraphicsManager::new();
let flags = TestbedStateFlags::SLEEP;
let flags = TestbedStateFlags::default();
#[allow(unused_mut)]
let mut backend_names = vec!["rapier"];
@@ -199,15 +239,17 @@ impl TestbedApp {
snapshot: None,
prev_flags: flags,
flags,
action_flags: TestbedActionFlags::empty(),
action_flags: TestbedActionFlags::APP_STARTED | TestbedActionFlags::EXAMPLE_CHANGED,
backend_names,
example_names: Vec::new(),
example_settings: ExampleSettings::default(),
selected_example: 0,
selected_backend: RAPIER_BACKEND,
solver_type: RapierSolverType::default(),
physx_use_two_friction_directions: true,
nsteps: 1,
camera_locked: false,
prev_save_data: SerializableTestbedState::default(),
};
let harness = Harness::new_empty();
@@ -230,12 +272,8 @@ impl TestbedApp {
}
}
pub fn from_builders(default: usize, builders: SimulationBuilders) -> Self {
pub fn from_builders(builders: SimulationBuilders) -> Self {
let mut res = TestbedApp::new_empty();
res.state
.action_flags
.set(TestbedActionFlags::EXAMPLE_CHANGED, true);
res.state.selected_example = default;
res.set_builders(builders);
res
}
@@ -566,6 +604,10 @@ impl Testbed<'_, '_, '_, '_, '_, '_> {
self.harness
}
pub fn example_settings_mut(&mut self) -> &mut ExampleSettings {
&mut self.state.example_settings
}
pub fn set_world(
&mut self,
bodies: RigidBodySet,
@@ -1171,6 +1213,8 @@ fn egui_focus(mut ui_context: EguiContexts, mut cameras: Query<&mut OrbitCamera>
}
use crate::mouse::{track_mouse_state, MainCamera, SceneMouse};
use crate::save::SerializableTestbedState;
use crate::settings::ExampleSettings;
use bevy::window::PrimaryWindow;
#[allow(clippy::type_complexity)]
@@ -1189,8 +1233,9 @@ fn update_testbed(
#[cfg(feature = "other-backends")] mut other_backends: NonSendMut<OtherBackends>,
mut plugins: NonSendMut<Plugins>,
mut ui_context: EguiContexts,
(mut gfx_components, mut cameras, mut material_handles): (
(mut gfx_components, mut visibilities, mut cameras, mut material_handles): (
Query<&mut Transform>,
Query<&mut Visibility>,
Query<(&Camera, &GlobalTransform, &mut OrbitCamera)>,
Query<&mut BevyMaterialComponent>,
),
@@ -1278,6 +1323,24 @@ fn update_testbed(
.set(TestbedActionFlags::EXAMPLE_CHANGED, true);
}
#[cfg(not(target_arch = "wasm32"))]
{
let app_started = state.action_flags.contains(TestbedActionFlags::APP_STARTED);
if app_started {
state
.action_flags
.set(TestbedActionFlags::APP_STARTED, false);
if let Some(saved_state) = std::fs::read(save_file_path())
.ok()
.and_then(|data| serde_json::from_slice::<SerializableTestbedState>(&data).ok())
{
state.apply_saved_data(saved_state, &mut cameras.single_mut().2);
state.camera_locked = true;
}
}
}
let example_changed = state
.action_flags
.contains(TestbedActionFlags::EXAMPLE_CHANGED);
@@ -1296,6 +1359,10 @@ fn update_testbed(
let graphics = &mut *graphics;
let meshes = &mut *meshes;
if !restarted {
state.example_settings.clear();
}
let graphics_context = TestbedGraphics {
graphics: &mut *graphics,
commands: &mut commands,
@@ -1543,10 +1610,13 @@ fn update_testbed(
}
};
// Draw
graphics.draw(
state.flags,
&harness.physics.bodies,
&harness.physics.colliders,
&mut gfx_components,
&mut visibilities,
&mut *materials,
);
@@ -1568,6 +1638,20 @@ fn update_testbed(
if state.running == RunMode::Step {
state.running = RunMode::Stop;
}
// If any saveable settings changed, save them again.
#[cfg(not(target_arch = "wasm32"))]
{
let new_save_data = state.save_data(cameras.single().2.clone());
if state.prev_save_data != new_save_data {
// Save the data in a file.
let data = serde_json::to_string_pretty(&new_save_data).unwrap();
if let Err(e) = std::fs::write(save_file_path(), &data) {
error!("Failed to write autosave file: {}", e);
}
state.prev_save_data = new_save_data;
}
}
}
fn clear(

View File

@@ -10,6 +10,7 @@ use crate::testbed::{
PHYSX_BACKEND_PATCH_FRICTION, PHYSX_BACKEND_TWO_FRICTION_DIR,
};
use crate::settings::SettingValue;
use crate::PhysicsState;
use bevy_egui::egui::{Slider, Ui};
use bevy_egui::{egui, EguiContexts};
@@ -24,39 +25,11 @@ pub fn update_ui(
) {
#[cfg(feature = "profiler_ui")]
{
let window = egui::Window::new("Profiling");
let window = window.default_open(false);
#[cfg(feature = "unstable-puffin-pr-235")]
{
use std::sync::Once;
static START: Once = Once::new();
fn set_default_rapier_filter() {
let mut profile_ui = puffin_egui::PROFILE_UI.lock();
profile_ui
.profiler_ui
.flamegraph_options
.scope_name_filter
.set_filter("Harness::step_with_graphics".to_string());
}
START.call_once(|| {
set_default_rapier_filter();
});
window.show(ui_context.ctx_mut(), |ui| {
if ui.button("🔍 Rapier filter").clicked() {
set_default_rapier_filter();
}
puffin_egui::profiler_ui(ui);
});
}
#[cfg(not(feature = "unstable-puffin-pr-235"))]
window.show(ui_context.ctx_mut(), |ui| {
puffin_egui::profiler_ui(ui);
});
profiler_ui(ui_context);
}
example_settings_ui(ui_context, state);
egui::Window::new("Parameters").show(ui_context.ctx_mut(), |ui| {
if state.backend_names.len() > 1 && !state.example_names.is_empty() {
let mut changed = false;
@@ -264,14 +237,17 @@ pub fn update_ui(
integration_parameters.set_inv_dt(frequency as Real);
let mut sleep = state.flags.contains(TestbedStateFlags::SLEEP);
let mut draw_surfaces = state.flags.contains(TestbedStateFlags::DRAW_SURFACES);
// let mut contact_points = state.flags.contains(TestbedStateFlags::CONTACT_POINTS);
// let mut wireframe = state.flags.contains(TestbedStateFlags::WIREFRAME);
ui.checkbox(&mut sleep, "sleep enabled");
// ui.checkbox(&mut contact_points, "draw contacts");
// ui.checkbox(&mut wireframe, "draw wireframes");
ui.checkbox(&mut draw_surfaces, "surface render enabled");
ui.checkbox(&mut debug_render.enabled, "debug render enabled");
state.flags.set(TestbedStateFlags::SLEEP, sleep);
state.flags.set(TestbedStateFlags::DRAW_SURFACES, draw_surfaces);
// state
// .flags
// .set(TestbedStateFlags::CONTACT_POINTS, contact_points);
@@ -481,3 +457,77 @@ Hashes at frame: {}
format!("{:?}", hash_joints).split_at(10).0,
)
}
fn example_settings_ui(ui_context: &mut EguiContexts, state: &mut TestbedState) {
if state.example_settings.is_empty() {
// Dont show any window if there is no settings for the
// example.
return;
}
egui::Window::new("Example settings").show(ui_context.ctx_mut(), |ui| {
let mut any_changed = false;
for (name, value) in state.example_settings.iter_mut() {
let prev_value = value.clone();
match value {
SettingValue::F32 { value, range } => {
ui.add(Slider::new(value, range.clone()).text(name));
}
SettingValue::U32 { value, range } => {
ui.horizontal(|ui| {
if ui.button("<").clicked() && *value > *range.start() {
*value -= 1;
}
if ui.button(">").clicked() && *value <= *range.end() {
*value += 1;
}
ui.add(Slider::new(value, range.clone()).text(name));
});
}
}
any_changed = any_changed || *value != prev_value;
}
if any_changed {
// The value changed, request a restart.
state.action_flags.set(TestbedActionFlags::RESTART, true);
}
});
}
#[cfg(feature = "profiler_ui")]
fn profiler_ui(ui_context: &mut EguiContexts) {
let window = egui::Window::new("Profiling");
let window = window.default_open(false);
#[cfg(feature = "unstable-puffin-pr-235")]
{
use std::sync::Once;
static START: Once = Once::new();
fn set_default_rapier_filter() {
let mut profile_ui = puffin_egui::PROFILE_UI.lock();
profile_ui
.profiler_ui
.flamegraph_options
.scope_name_filter
.set_filter("Harness::step_with_graphics".to_string());
}
START.call_once(|| {
set_default_rapier_filter();
});
window.show(ui_context.ctx_mut(), |ui| {
if ui.button("🔍 Rapier filter").clicked() {
set_default_rapier_filter();
}
puffin_egui::profiler_ui(ui);
});
}
#[cfg(not(feature = "unstable-puffin-pr-235"))]
window.show(ui_context.ctx_mut(), |ui| {
puffin_egui::profiler_ui(ui);
});
}