feat: add support for Voxels collider (#823)

* feat: start adding voxels support and some additional testbed demo settings

* feat: add support for parry’s new Voxels collider shape

* fix voxels demos

* feat: support rectangular voxels and additional voxels initialization

* chore: switch to parry 0.20

* chore: fix cargo doc

* Fix testbed build
This commit is contained in:
Sébastien Crozet
2025-04-24 12:11:53 +02:00
committed by GitHub
parent 1c67c5e7f2
commit e44f636249
27 changed files with 891 additions and 223 deletions

View File

@@ -19,6 +19,8 @@ wasm-bindgen = "0.2"
obj-rs = { version = "0.7", default-features = false }
serde = "1"
bincode = "1"
serde_json = "1"
dot_vox = "5"
[dependencies.rapier_testbed3d]
path = "../crates/rapier_testbed3d"

View File

@@ -1,4 +1,5 @@
#![allow(dead_code)]
#![allow(clippy::type_complexity)]
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
@@ -58,6 +59,7 @@ mod trimesh3;
mod urdf3;
mod vehicle_controller3;
mod vehicle_joints3;
mod voxels3;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen(start))]
pub fn main() {
@@ -87,6 +89,7 @@ pub fn main() {
("Spring Joints", spring_joints3::init_world),
("TriMesh", trimesh3::init_world),
("Urdf", urdf3::init_world),
("Voxels", voxels3::init_world),
("Vehicle controller", vehicle_controller3::init_world),
("Vehicle joints", vehicle_joints3::init_world),
("Keva tower", keva3::init_world),

324
examples3d/voxels3.rs Normal file
View File

@@ -0,0 +1,324 @@
use obj::raw::object::Polygon;
use rapier3d::parry::bounding_volume;
use rapier3d::parry::transformation::voxelization::FillMode;
use rapier3d::prelude::*;
use rapier_testbed3d::KeyCode;
use rapier_testbed3d::Testbed;
use std::fs::File;
use std::io::BufReader;
pub fn init_world(testbed: &mut Testbed) {
/*
* Voxel geometry type selection.
*/
let settings = testbed.example_settings_mut();
let geometry_mode = settings.get_or_set_string(
"Voxels mode",
0,
vec!["PseudoCube".to_string(), "PseudoBall".to_string()],
);
let falling_objects = settings.get_or_set_string(
"Falling objects",
5, // Defaults to Mixed.
vec![
"Ball".to_string(),
"Cuboid".to_string(),
"Cylinder".to_string(),
"Cone".to_string(),
"Capsule".to_string(),
"Mixed".to_string(),
],
);
let voxel_size_y = settings.get_or_set_f32("Voxel size y", 1.0, 0.5..=2.0);
let voxel_size = Vector::new(1.0, voxel_size_y, 1.0);
// TODO: give a better placement to the objs.
// settings.get_or_set_bool("Load .obj", false);
let load_obj = false;
let primitive_geometry = if geometry_mode == 0 {
VoxelPrimitiveGeometry::PseudoCube
} else {
VoxelPrimitiveGeometry::PseudoBall
};
/*
* World
*/
let mut bodies = RigidBodySet::new();
let mut colliders = ColliderSet::new();
let impulse_joints = ImpulseJointSet::new();
let multibody_joints = MultibodyJointSet::new();
/*
* Create a bowl for the ground
*/
/*
* Create the convex decompositions.
*/
if load_obj {
let geoms = models();
let ngeoms = geoms.len();
let width = (ngeoms as f32).sqrt() as usize;
let num_duplications = 1; // 4;
let shift = 7.0f32;
for (igeom, obj_path) in geoms.into_iter().enumerate() {
let deltas = Isometry::identity();
let mut shapes = Vec::new();
println!("Parsing and decomposing: {}", obj_path);
let input = BufReader::new(File::open(obj_path).unwrap());
if let Ok(model) = obj::raw::parse_obj(input) {
let mut vertices: Vec<_> = model
.positions
.iter()
.map(|v| point![v.0, v.1, v.2])
.collect();
let indices: Vec<_> = model
.polygons
.into_iter()
.flat_map(|p| match p {
Polygon::P(idx) => idx.into_iter(),
Polygon::PT(idx) => {
Vec::from_iter(idx.into_iter().map(|i| i.0)).into_iter()
}
Polygon::PN(idx) => {
Vec::from_iter(idx.into_iter().map(|i| i.0)).into_iter()
}
Polygon::PTN(idx) => {
Vec::from_iter(idx.into_iter().map(|i| i.0)).into_iter()
}
})
.collect();
// Compute the size of the model, to scale it and have similar size for everything.
let aabb = bounding_volume::details::point_cloud_aabb(&deltas, &vertices);
let center = aabb.center();
let diag = (aabb.maxs - aabb.mins).norm();
vertices
.iter_mut()
.for_each(|p| *p = (*p - center.coords) * 6.0 / diag);
let indices: Vec<_> = indices
.chunks(3)
.map(|idx| [idx[0] as u32, idx[1] as u32, idx[2] as u32])
.collect();
let decomposed_shape = SharedShape::voxelized_mesh(
primitive_geometry,
&vertices,
&indices,
0.1,
FillMode::default(),
);
shapes.push(decomposed_shape);
for k in 1..num_duplications + 1 {
let x = (igeom % width) as f32 * shift - 3.0;
let y = (igeom / width) as f32 * shift + 4.0;
let z = k as f32 * shift - 3.0;
let body = RigidBodyBuilder::fixed().translation(vector![x, y, z]);
let handle = bodies.insert(body);
for shape in &shapes {
let collider = ColliderBuilder::new(shape.clone());
colliders.insert_with_parent(collider, handle, &mut bodies);
}
}
}
}
}
/*
* Create a voxelized wavy floor.
*/
let mut samples = vec![];
let n = 200;
for i in 0..n {
for j in 0..n {
let y = (i as f32 / n as f32 * 10.0).sin().clamp(-0.8, 0.8)
* (j as f32 / n as f32 * 10.0).cos().clamp(-0.8, 0.8)
* 16.0;
samples.push(point![i as f32, y * voxel_size_y, j as f32]);
if i == 0 || i == n - 1 || j == 0 || j == n - 1 {
// Create walls so the object at the edge dont fall into the infinite void.
for k in 0..4 {
samples.push(point![i as f32, (y + k as f32) * voxel_size_y, j as f32]);
}
}
}
}
let collider =
ColliderBuilder::voxels_from_points(primitive_geometry, voxel_size, &samples).build();
let floor_aabb = collider.compute_aabb();
colliders.insert(collider);
/*
* Some dynamic primitives.
*/
let nik = 30;
let extents = floor_aabb.extents() * 0.75;
let margin = (floor_aabb.extents() - extents) / 2.0;
let ball_radius = 0.5;
for i in 0..nik {
for j in 0..5 {
for k in 0..nik {
let rb = RigidBodyBuilder::dynamic().translation(vector![
floor_aabb.mins.x + margin.x + i as f32 * extents.x / nik as f32,
floor_aabb.maxs.y + j as f32 * 2.0,
floor_aabb.mins.z + margin.z + k as f32 * extents.z / nik as f32,
]);
let rb_handle = bodies.insert(rb);
let falling_objects = if falling_objects == 5 {
j % 5
} else {
falling_objects
};
let co = match falling_objects {
0 => ColliderBuilder::ball(ball_radius),
1 => ColliderBuilder::cuboid(ball_radius, ball_radius, ball_radius),
2 => ColliderBuilder::cylinder(ball_radius, ball_radius),
3 => ColliderBuilder::cone(ball_radius, ball_radius),
4 => ColliderBuilder::capsule_y(ball_radius, ball_radius),
_ => unreachable!(),
};
colliders.insert_with_parent(co, rb_handle, &mut bodies);
}
}
}
// Add callback for handling voxels edition, and highlighting the voxel
// pointed at by the mouse. We spawn two fake colliders that dont interact
// with anything. They are used as gizmos to indicate where the ray hits on voxels
// by highlighting the voxel and drawing a small ball at the intersection.
let hit_indicator_handle =
colliders.insert(ColliderBuilder::ball(0.1).collision_groups(InteractionGroups::none()));
let hit_highlight_handle = colliders.insert(
ColliderBuilder::cuboid(0.51, 0.51, 0.51).collision_groups(InteractionGroups::none()),
);
testbed.set_initial_collider_color(hit_indicator_handle, [0.5, 0.5, 0.1]);
testbed.set_initial_collider_color(hit_highlight_handle, [0.1, 0.5, 0.1]);
testbed.add_callback(move |graphics, physics, _, _| {
let Some(graphics) = graphics else { return };
let Some((mouse_orig, mouse_dir)) = graphics.mouse().ray else {
return;
};
let ray = Ray::new(mouse_orig, mouse_dir);
let filter = QueryFilter {
predicate: Some(&|_, co: &Collider| co.shape().as_voxels().is_some()),
..Default::default()
};
if let Some((handle, hit)) = physics.query_pipeline.cast_ray_and_get_normal(
&physics.bodies,
&physics.colliders,
&ray,
Real::MAX,
true,
filter,
) {
// Highlight the voxel.
let hit_collider = &physics.colliders[handle];
let hit_local_normal = hit_collider
.position()
.inverse_transform_vector(&hit.normal);
let voxels = hit_collider.shape().as_voxels().unwrap();
let FeatureId::Face(id) = hit.feature else {
unreachable!()
};
let voxel_key = voxels.voxel_key_at(id);
let voxel_center = hit_collider.position() * voxels.voxel_center(voxel_key);
let voxel_size = voxels.voxel_size();
let hit_highlight = physics.colliders.get_mut(hit_highlight_handle).unwrap();
hit_highlight.set_translation(voxel_center.coords);
hit_highlight
.shape_mut()
.as_cuboid_mut()
.unwrap()
.half_extents = voxel_size / 2.0 + Vector::repeat(0.001);
graphics.update_collider(hit_highlight_handle, &physics.colliders);
// Show the hit point.
let hit_pt = ray.point_at(hit.time_of_impact);
let hit_indicator = physics.colliders.get_mut(hit_indicator_handle).unwrap();
hit_indicator.set_translation(hit_pt.coords);
hit_indicator.shape_mut().as_ball_mut().unwrap().radius = voxel_size.norm() / 3.5;
graphics.update_collider(hit_indicator_handle, &physics.colliders);
// If a relevant key was pressed, edit the shape.
if graphics.keys().pressed(KeyCode::Space) {
let removal_mode = graphics.keys().pressed(KeyCode::ShiftLeft);
let voxels = physics
.colliders
.get_mut(handle)
.unwrap()
.shape_mut()
.as_voxels_mut()
.unwrap();
let mut affected_key = voxel_key;
if !removal_mode {
let imax = hit_local_normal.iamax();
if hit_local_normal[imax] >= 0.0 {
affected_key[imax] += 1;
} else {
affected_key[imax] -= 1;
}
}
voxels.insert_voxel_at_key(affected_key, !removal_mode);
graphics.update_collider(handle, &physics.colliders);
}
} else {
// When there is no hit, move the indicators behind the camera.
let behind_camera = mouse_orig - mouse_dir * 1000.0;
let hit_indicator = physics.colliders.get_mut(hit_indicator_handle).unwrap();
hit_indicator.set_translation(behind_camera.coords);
let hit_highlight = physics.colliders.get_mut(hit_highlight_handle).unwrap();
hit_highlight.set_translation(behind_camera.coords);
}
});
/*
* Set up the testbed.
*/
testbed.set_world(bodies, colliders, impulse_joints, multibody_joints);
testbed.look_at(point![100.0, 100.0, 100.0], Point::origin());
}
fn models() -> Vec<String> {
vec![
// "assets/3d/camel_decimated.obj".to_string(),
"assets/3d/chair.obj".to_string(),
"assets/3d/cup_decimated.obj".to_string(),
"assets/3d/dilo_decimated.obj".to_string(),
"assets/3d/feline_decimated.obj".to_string(),
"assets/3d/genus3_decimated.obj".to_string(),
"assets/3d/hand2_decimated.obj".to_string(),
"assets/3d/hand_decimated.obj".to_string(),
"assets/3d/hornbug.obj".to_string(),
"assets/3d/octopus_decimated.obj".to_string(),
"assets/3d/rabbit_decimated.obj".to_string(),
// "assets/3d/rust_logo.obj".to_string(),
"assets/3d/rust_logo_simplified.obj".to_string(),
"assets/3d/screwdriver_decimated.obj".to_string(),
"assets/3d/table.obj".to_string(),
"assets/3d/tstTorusModel.obj".to_string(),
// "assets/3d/tstTorusModel2.obj".to_string(),
// "assets/3d/tstTorusModel3.obj".to_string(),
]
}