From voxel meshes to a playful physics sandbox

From voxel meshes to a playful physics sandbox
Voxely 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:

  1. 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.
  2. 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()
        },
    ));
}
Physics fun

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!