Skip to content
RustingEngineGitHub

Tutorial 4: GPU cube rain

RustingEngine pages

Tutorials

All chapters

You will drop 2,000 cubes simulated on the GPU, get an event back from the GPU when each one falls past a line, and show a counter on screen. This is the pattern for debris, swarms, and crowds: the GPU owns the bodies, and the CPU hears only about the moments it cares about.

You need a project made as in Getting started. This page calls it Cube Rain. It needs a Vulkan device: GPU bodies do not move in headless runs.

1. The code

Replace src/main.rs:

rust
use std::sync::atomic::{AtomicUsize, Ordering};

use rusting_engine::prelude::*;

/// Drops that fell past Y = -20 so far.
static FALLEN: AtomicUsize = AtomicUsize::new(0);

fn update(scene: &mut GameScene<'_>, time: &FrameTime) {
    scene.once("spawn_rain", |scene| {
        let drop = CubeSpawn::new().class("rain");
        for index in 0..2_000 {
            let x = (index % 50) as f32 - 25.0;
            let z = (index / 50) as f32 - 40.0;
            let y = 10.0 + (index % 7) as f32 * 2.0;
            scene.spawn_cube(
                format!("Drop {index}"),
                Transform::new([x, y, z]),
                &drop,
            );
        }
        let count =
            scene.apply_gpu_physics_to_class("rain", &GpuBodySettings::default());
        println!("{count} drops on the GPU");

        scene.watch_gpu_class(
            "rain",
            GpuPhysicsRule::new(
                "drop_fell",
                GpuCondition::position_y().less_than(-20.0),
            )
            .mode(GpuEventMode::OnEnter)
            .payload(GpuEventPayload::Position),
        );
    });

    let events = scene.gpu_events("drop_fell");
    let fallen = FALLEN.fetch_add(events.len(), Ordering::Relaxed) + events.len();
    if let Some(first) = events.first() {
        println!("{:?} fell past Y = -20 at {:?}", first.physics_id, first.payload);
    }

    rusting_engine::egui::Window::new("Cube rain").show(&scene.ui(), |ui| {
        ui.label(format!("{:.0} FPS", 1.0 / time.delta_seconds().max(1e-6)));
        ui.label(format!("{fallen} of 2000 drops fell past Y = -20"));
    });
}

rusting_game!(update);

Run it with Play in the editor or rusting run "Cube Rain". The cubes fall, and the counter climbs as they pass Y = -20.

2. What each part does

Classes. CubeSpawn::new().class("rain") tags every spawned cube with the class rain. A class names a group of objects so one call can act on all of them.

Moving bodies to the GPU. apply_gpu_physics_to_class gives every object in the class a GPU body and returns how many it changed. From then on, the Vulkan compute solver moves them, not the CPU. GpuBodySettings chooses how:

FieldDefaultMeaning
solverFullFull, Simplified, NoCollision, Space (mutual gravity), or Custom
custom_shaderNoneproject-relative compute shader when solver is Custom
rigid_bodyRigidBody::default()mass, starting velocity, gravity
colliderCollider::default()shape, friction, restitution
collision_layersallwhich groups collide

You can set the same thing in a scene: set an object’s physics_body.simulation to Gpu. See Core concepts.

Rules instead of readback. The CPU never downloads the 2,000 transforms. watch_gpu_class attaches a rule to every body in the class. The GPU tests the rule each tick and writes an event only when it matches:

  • GpuCondition picks the test: position_y(), velocity_y(), colliding(), sleeping(), timer_elapsed(seconds). Compare values with less_than, greater_than, or inside, and combine conditions with and, or, and inverted.
  • GpuEventMode::OnEnter fires once when the condition becomes true, not on every tick while it stays true. Add .cooldown(seconds) to limit how often a rule can fire again.
  • GpuEventPayload chooses the four numbers sent with the event: Position, Velocity, AngularVelocity, Contact, or Custom.

Reading events. scene.gpu_events("drop_fell") returns this frame’s events for that rule. Each GpuPhysicsEvent has the entity, its physics_id, the tick it happened on, and the payload ([x, y, z, 0] for Position). Events reach the CPU one to three frames after the tick that caused them, because the CPU does not wait for the GPU.

UI. scene.ui() returns the frame’s egui context; rusting_engine::egui is the matching egui version. update is a plain function with no state of its own, so the running total lives in a static. For real game state, use a plugin and a resource, as in Tutorial 3.

3. Watch one object

To follow one body instead of a class, use watch_gpu_object:

rust
scene.watch_gpu_object(
    "Drop 0",
    GpuPhysicsRule::new("first_drop_landed", GpuCondition::colliding())
        .mode(GpuEventMode::OnEnter),
);

Add a floor for it to land on: a cube scaled to [60, 1, 60] at [0, -2, -20] whose physics_body.simulation is Static. Static bodies collide with both the CPU and the GPU solver. With the floor in place the drops stop above Y = -20, so drop_fell no longer fires.

scene.set_linear_velocity(name, [x, y, z]) sets a GPU body’s velocity from code, for example to launch a drop upward.

4. Testing GPU games

Headless runs (rusting run --ticks, rusting test) have no renderer, so GPU bodies stay where they started and GPU rules never fire. Test the CPU side headless, such as spawning and counting the class:

bash
rusting run "Cube Rain" --ticks 30

The run exits with code 0 once the spawn code has worked. Check GPU behaviour by playing the game, or with the engine’s GPU tests (cargo test --features gpu-tests), which run the solvers on a real or software (lavapipe) Vulkan device.

Going further

  • src/examples/hybrid_10k.rs in the engine repository runs 10,000 GPU cubes beside CPU bodies. Cook testGame first (rusting cook testGame), then start it with cargo run --release --example hybrid_10k.
  • testGame/src/main.rs uses the Space solver for an orbiting cloud.
  • Determinism explains why CPU code that reacts to GPU events does not replay exactly yet.