From voxel meshes to a playful physics sandbox

In a previous post, I created a tool to generate blocky 3D shapes from pure code. For some reason I thought it would be fun to have these voxel based meshes interacting in a physics sandbox as if they were their pure mathematical shapes. As in, the blocky looking "ball" rolls around like a smooth marble would. Of course the next step is leaning in to that physics chaos and adding the ability to launch high-speed marbles at stacks of objects.
Step 1: Adding the Laws of Physics with bevy_rapier
To make my objects move, I needed a physics engine. For a Bevy project, the choice was easy: bevy_rapier
. It's fast, integrates perfectly with Bevy’s ECS, and adding it is as simple as a single line in my Cargo.toml
.
To enable it, I just added the RapierPhysicsPlugin
to my Bevy App
. I also configured a fixed timestep for the simulation, which is crucial for keeping physics stable and deterministic, regardless of the rendering framerate.
use bevy_rapier3d::prelude::*;
fn main() {
App::new()
// ... other plugins
.add_plugins(RapierPhysicsPlugin::<NoUserData>::default())
// For debugging the colliders
.add_plugins(RapierDebugRenderPlugin::default())
// ... my systems and setup
.run();
}
With that, I could create an arena for the simulation: a simple floor and three walls with static colliders.
Step 2: The Technical Core: Visuals vs. Physics
This was the most critical part of the project: making the physics shape different from the visual shape. The bevy::Mesh
I generate for rendering is just a bag of vertices; the physics engine needs its own shape, a Collider
.
My goal required using two different kinds of colliders:
- Primitive Colliders: For my marble, I wanted it to roll perfectly smoothly. The solution was to give it a
Collider::ball(radius)
. This creates a mathematically perfect sphere for the physics engine to use, completely ignoring the blocky visual mesh. The result is a ball that looks like it's made of cubes but rolls like glass. - Mesh-Derived Colliders: For my other shapes (cylinders, cones, etc.), I wanted the physics to match their blocky appearance. For this, I used
Collider::convex_hull
. This computes a "shrink-wrapped" collider from the mesh's vertices. It’s perfect for dynamic objects that should tumble and interact based on their actual voxel shape.
I wrote a function that takes the vertices from my generated Mesh
and computes this convex hull collider.
use bevy::prelude::*;
use bevy_rapier3d::prelude::*;
// Takes a Bevy Mesh and computes a convex hull collider for it.
fn create_collider_from_mesh(mesh: &Mesh) -> Option<Collider> {
// Get the vertex positions from the mesh asset.
let vertices: Vec<Vec3> =
mesh.attribute(Mesh::ATTRIBUTE_POSITION)?.as_float3()?.to_vec();
// Compute the convex hull from the vertex data.
Collider::convex_hull(&vertices)
}
With this function, I could now spawn a complete physics-enabled entity with a Bundle
of components.
// The component bundle for a dynamic, physical object.
#[derive(Bundle)]
struct VoxelObjectBundle {
pbr: PbrBundle, // The visual mesh
rigid_body: RigidBody,
collider: Collider,
velocity: Velocity,
// Other physics components...
}
Step 3: Making a Glorious Mess (Time to Shoot!)
With the core logic in place, I focused on making it interactive. I wrote a Bevy system that listens for keyboard input and, when a key is pressed, "shoots" an object from the camera.
This is done by spawning one of my VoxelObjectBundle
entities and applying an ExternalImpulse
. This is a one-time force that propels the object forward, like a cannonball.
// Bevy system to handle shooting input.
fn shoot_from_camera_system(
mut commands: Commands,
// ... asset handles, camera query, etc.
keyboard_input: Res<ButtonInput<KeyCode>>,
) {
let shape_to_spawn = if keyboard_input.just_pressed(KeyCode::Space) {
// Get handle to marble mesh
} else if keyboard_input.just_pressed(KeyCode::Digit2) {
// Get handle to cylinder mesh
} else { return; };
// Get camera's position and forward vector
let transform = get_camera_transform();
// Spawn the entity and apply an impulse.
commands.spawn((
VoxelObjectBundle { ... },
ExternalImpulse {
impulse: transform.forward() * 50.0, // Force vector
..default()
},
));
}

The result feels like a mini-game. I can test my aim, try to clear the stacks, and watch the physics engine create beautiful, tumbling messes.
What I Learned
- Decoupling Visuals and Physics is Powerful: The most interesting result came from deliberately making the physics shape different from the visual mesh. This is a core concept in game development that opens up tons of creative possibilities.
- Choose the Right Collider for the Job: Using primitive colliders (
Collider::ball
) for idealized motion and mesh-derived colliders (Collider::convex_hull
) for more complex interactions was key to achieving my goal. - "Shoot from the camera" is instant fun: This one interaction transformed the project from a passive viewer into a playful, repeatable experiment.
What’s Next?
I'm learning a lot about procedural generation and want to explore generating obstacle courses for my marbles to traverse. Stay tuned!