Introduction
RDPE (Realtime Data Presentation Engine) is a GPU-accelerated particle simulation library for Rust. It lets you create complex, interactive particle systems with minimal code by combining simple, composable rules.
What Can You Build?
With RDPE, you can simulate:
- Flocking behaviors - Birds, fish, or any swarming entities
- Predator-prey ecosystems - Multiple species with different interactions
- Disease spread - SIR models and infection dynamics
- Physics simulations - Bouncing particles, gravity, collisions
- Chemical reactions - Particles that transform on contact
- Crowd dynamics - Social forces and emergent behavior
Quick Example
use rdpe::prelude::*; #[derive(Particle, Clone)] struct MyParticle { position: Vec3, velocity: Vec3, } fn main() { Simulation::<MyParticle>::new() .with_particle_count(10_000) .with_bounds(1.0) .with_spawner(|i, count| MyParticle { position: random_position(), velocity: random_velocity(), }) .with_rule(Rule::Gravity(9.8)) .with_rule(Rule::BounceWalls) .run(); }
Design Philosophy
RDPE is built around three core ideas:
-
Declarative Rules - Describe what should happen, not how. Rules like
Gravity,Separate, andCohereexpress intent clearly. -
Composability - Rules combine freely. Wrap any rule with
Typedfor type-specific interactions. UseCustomfor anything not built-in. -
GPU-First - Everything runs on the GPU. The derive macro handles memory layout. Spatial hashing accelerates neighbor queries. You write Rust; RDPE generates WGSL shaders.
How It Works
- You define a particle struct with
#[derive(Particle)] - You configure a simulation with rules
- RDPE generates GPU shaders from your rules
- The simulation runs entirely on the GPU
- A window displays the particles in real-time
The next chapters explain each component in detail.
Architecture Overview
RDPE consists of several layers that work together to run particle simulations on the GPU.
High-Level Flow
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ User's Rust │────▶│ RDPE Compile │────▶│ GPU Runtime │
│ Particle + │ │ Time (Derive │ │ (wgpu + │
│ Rules │ │ Macro + Shader │ │ Compute │
│ │ │ Generation) │ │ Shaders) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
Components
1. Particle Derive Macro (rdpe-derive)
The #[derive(Particle)] macro transforms your Rust struct into GPU-compatible form:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct Boid { position: Vec3, velocity: Vec3, #[color] tint: Vec3, particle_type: u32, } }
The macro generates:
- A
BoidGpustruct with correct memory alignment (16-byte for GPU arrays) - WGSL struct definition for shaders
to_gpu()conversion method- Tracking of color field offset for rendering
2. Simulation Builder (simulation.rs)
The builder pattern configures everything before running:
#![allow(unused)] fn main() { Simulation::<MyParticle>::new() .with_particle_count(10_000) .with_bounds(1.0) .with_spatial_config(0.1, 32) // For neighbor rules .with_spawner(|i, count| { ... }) .with_rule(Rule::Gravity(9.8)) .with_rule(Rule::Separate { radius: 0.1, strength: 1.0 }) .run(); }
At .run() time, the simulation:
- Detects if any rules need neighbor queries
- Generates appropriate WGSL compute shaders
- Generates render shaders
- Spawns particles using your spawner function
- Initializes GPU state and runs the event loop
3. Shader Generation
Rules are translated to WGSL code:
| Rule | Generated Code Location |
|---|---|
Gravity, Drag, BounceWalls | Main compute shader body |
Separate, Cohere, Collide | Inside neighbor iteration loop |
Typed { ... } | Wraps inner rule with type checks |
Convert { ... } | Inside neighbor loop with probability |
Custom(code) | Inserted directly |
4. GPU State (gpu/mod.rs)
Manages all GPU resources:
- Particle buffer - Storage buffer with all particle data
- Uniform buffer - View/projection matrix, time, delta_time
- Compute pipeline - Runs the physics simulation
- Render pipeline - Draws particles as billboarded quads
- Spatial hashing (optional) - For neighbor queries
5. Spatial Hashing (gpu/spatial_gpu.rs)
When rules need neighbor queries, RDPE builds a spatial hash:
- Morton encoding - Convert 3D position to 1D cell index
- Radix sort - Sort particles by cell (dynamic passes based on grid resolution)
- Cell table - Build start/end indices for each cell
This accelerates neighbor queries from O(n²) to O(n × average_neighbors).
Render Loop
Each frame:
1. Update uniforms (time, camera)
2. [If spatial] Run spatial hashing passes
3. Run compute shader (apply all rules, integrate velocity)
4. Run render pass (draw particles as quads)
5. Present frame
Memory Layout
The derive macro ensures GPU-compatible alignment:
Particle in Rust GPU Memory (16-byte aligned)
┌──────────────┐ ┌──────────────────────────────┐
│ position: Vec3│ ──▶ │ position: vec3<f32> (12 bytes)│
│ velocity: Vec3│ │ _pad0: f32 (4 bytes) │
└──────────────┘ │ velocity: vec3<f32> (12 bytes)│
│ particle_type: u32 (4 bytes) │
└──────────────────────────────┘
The particle_type field is auto-added if not present.
Particles
Particles are the core data structure in RDPE. Each particle has properties that the GPU updates every frame based on the rules you define.
Defining a Particle
Use the #[derive(Particle)] macro:
#![allow(unused)] fn main() { use rdpe::prelude::*; #[derive(Particle, Clone)] struct MyParticle { position: Vec3, // Required: where the particle is velocity: Vec3, // Required: how it's moving } }
The position and velocity fields are required by convention - rules expect them.
Supported Field Types
| Rust Type | WGSL Type | Size | Alignment |
|---|---|---|---|
Vec3 | vec3<f32> | 12 bytes | 16 bytes |
Vec2 | vec2<f32> | 8 bytes | 8 bytes |
Vec4 | vec4<f32> | 16 bytes | 16 bytes |
f32 | f32 | 4 bytes | 4 bytes |
u32 | u32 | 4 bytes | 4 bytes |
i32 | i32 | 4 bytes | 4 bytes |
The macro automatically adds padding for GPU alignment.
Optional Fields
Color
Mark a Vec3 field with #[color] to control particle color:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct ColoredParticle { position: Vec3, velocity: Vec3, #[color] color: Vec3, // RGB, 0.0-1.0 } }
Without a color field, particles are colored based on their position.
Particle Type
Add particle_type: u32 for type-based interactions:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct TypedParticle { position: Vec3, velocity: Vec3, particle_type: u32, // 0, 1, 2, etc. } }
If you don't add this field, it's auto-added with a default value of 0.
Auto-Injected Lifecycle Fields
The #[derive(Particle)] macro automatically adds these lifecycle fields to every particle:
| Field | Type | Default | Purpose |
|---|---|---|---|
particle_type | u32 | 0 | Type identifier for typed interactions |
age | f32 | 0.0 | Time since spawn (updated by Rule::Age) |
alive | u32 | 1 | 1 = alive, 0 = dead (set by Rule::Lifetime) |
scale | f32 | 1.0 | Per-particle size multiplier (used by Rule::ShrinkOut) |
These are always available in your WGSL code via p.age, p.alive, p.scale, even if you don't define them in your struct.
#![allow(unused)] fn main() { // These fields exist automatically: .with_rule(Rule::Age) // Increments p.age each frame .with_rule(Rule::Lifetime(5.0)) // Sets p.alive = 0 when p.age > 5.0 .with_rule(Rule::ShrinkOut(5.0)) // Scales p.scale from 1.0 to 0.0 }
Spawning Particles
The spawner function is called once per particle at initialization:
#![allow(unused)] fn main() { .with_spawner(|index, total_count| { MyParticle { position: Vec3::new( rand::random::<f32>() - 0.5, rand::random::<f32>() - 0.5, rand::random::<f32>() - 0.5, ), velocity: Vec3::ZERO, } }) }
Parameters:
index- Particle index (0 to count-1)total_count- Total number of particles
Since the spawner must be Send + Sync, pre-generate random values:
#![allow(unused)] fn main() { let mut rng = rand::thread_rng(); let positions: Vec<Vec3> = (0..count) .map(|_| Vec3::new(rng.gen_range(-1.0..1.0), ...)) .collect(); .with_spawner(move |i, _| MyParticle { position: positions[i as usize], velocity: Vec3::ZERO, }) }
Custom Fields
You can add any supported fields for custom logic:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct GameParticle { position: Vec3, velocity: Vec3, #[color] color: Vec3, particle_type: u32, health: f32, // Custom field energy: f32, // Custom field team_id: u32, // Custom field } }
Access these in Rule::Custom WGSL code:
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" // Drain energy over time p.energy -= uniforms.delta_time * 0.1; // Use auto-injected age for time-based effects let fade = 1.0 - (p.age / 5.0); p.color *= fade; // Mark as dead when health depleted if p.health <= 0.0 { p.alive = 0u; } "#.to_string())) }
Rules
Rules define how particles behave. They're applied every frame in the order you add them.
Physics Rules
Gravity
Applies constant downward acceleration:
#![allow(unused)] fn main() { Rule::Gravity(9.8) // Strength in units/second² }
Drag
Slows particles over time (air resistance):
#![allow(unused)] fn main() { Rule::Drag(2.0) // 0.0 = no drag, higher = more friction }
Acceleration
Constant acceleration in any direction:
#![allow(unused)] fn main() { Rule::Acceleration(Vec3::new(0.0, -9.8, 0.0)) }
BounceWalls
Particles bounce off the bounding box:
#![allow(unused)] fn main() { Rule::BounceWalls }
The bounds are set with .with_bounds(size) - creates a cube from -size to +size.
WrapWalls
Particles wrap around to the opposite side (toroidal topology):
#![allow(unused)] fn main() { Rule::WrapWalls }
Creates an infinite-feeling space where particles exiting one edge reappear on the other. Great for simulations where you don't want edge effects or want the arena to feel larger than it is.
Force Rules
AttractTo
Pull particles toward a point:
#![allow(unused)] fn main() { Rule::AttractTo { point: Vec3::ZERO, strength: 5.0, } }
RepelFrom
Push particles away from a point:
#![allow(unused)] fn main() { Rule::RepelFrom { point: Vec3::new(0.0, 0.0, 0.0), strength: 10.0, radius: 0.5, // Only affects particles within this distance } }
Movement Rules
Wander
Random wandering force for organic, natural movement:
#![allow(unused)] fn main() { Rule::Wander { strength: 0.5, // How strong the random force is frequency: 100.0, // How fast direction changes (higher = jittery) } }
Each particle gets its own random direction based on a hash of its index and time.
SpeedLimit
Clamp velocity to min/max bounds:
#![allow(unused)] fn main() { Rule::SpeedLimit { min: 0.1, // Minimum speed (use 0.0 for no minimum) max: 2.0, // Maximum speed } }
Useful for keeping simulations stable and preventing runaway velocities.
Neighbor Rules
These rules require spatial hashing (automatically enabled when used).
Separate
Particles avoid crowding neighbors:
#![allow(unused)] fn main() { Rule::Separate { radius: 0.1, // Detection distance strength: 2.0, // Push force } }
Cohere
Particles steer toward the center of nearby neighbors:
#![allow(unused)] fn main() { Rule::Cohere { radius: 0.3, // Detection distance strength: 1.0, // Pull force } }
Align
Particles match velocity with neighbors:
#![allow(unused)] fn main() { Rule::Align { radius: 0.2, // Detection distance strength: 1.5, // Alignment force } }
Collide
Particle-particle collision response:
#![allow(unused)] fn main() { Rule::Collide { radius: 0.05, // Collision distance response: 0.5, // Bounce strength } }
Type Rules
Typed
Wraps any neighbor rule with type filters:
#![allow(unused)] fn main() { Rule::Typed { self_type: 0, // This rule applies to type 0 particles other_type: Some(1), // Only interact with type 1 neighbors rule: Box::new(Rule::Separate { radius: 0.1, strength: 5.0 }), } }
Use other_type: None to interact with all types.
Convert
Changes particle type on contact:
#![allow(unused)] fn main() { Rule::Convert { from_type: 0, // Healthy trigger_type: 1, // Infected to_type: 1, // Becomes infected radius: 0.08, // Contact distance probability: 0.1, // 10% chance per neighbor per frame } }
Chase
Steer toward the nearest particle of a target type:
#![allow(unused)] fn main() { Rule::Chase { self_type: 1, // Predators (type 1) target_type: 0, // Chase prey (type 0) radius: 0.3, // How far can see targets strength: 2.0, // Steering force } }
Finds the closest visible target and steers toward it. Great for predator-prey dynamics.
Evade
Steer away from the nearest particle of a threat type:
#![allow(unused)] fn main() { Rule::Evade { self_type: 0, // Prey (type 0) threat_type: 1, // Flee from predators (type 1) radius: 0.2, // How far can see threats strength: 3.0, // Steering force (often higher than chase) } }
Finds the closest visible threat and steers away. Combine with Chase for predator-prey simulations.
Custom Rules
For anything not built-in, write raw WGSL:
#![allow(unused)] fn main() { Rule::Custom(r#" // Access particle as 'p' p.velocity.y += sin(uniforms.time) * 0.1; // Available variables: // - p: current particle (read/write) // - index: particle index (u32) // - uniforms.time: elapsed time (f32) // - uniforms.delta_time: frame time (f32) "#.to_string()) }
Rule Order
Rules execute in the order added. A typical order:
#![allow(unused)] fn main() { .with_rule(Rule::Gravity(9.8)) // 1. Apply forces .with_rule(Rule::Wander { ... }) // 2. Random movement .with_rule(Rule::Separate { ... }) // 3. Neighbor interactions .with_rule(Rule::Cohere { ... }) .with_rule(Rule::SpeedLimit { ... }) // 4. Clamp velocity .with_rule(Rule::Drag(1.0)) // 5. Apply drag .with_rule(Rule::BounceWalls) // 6. Boundary conditions }
Velocity integration (position += velocity * dt) happens automatically after all rules.
Spatial Configuration
For neighbor rules, configure the spatial hash:
#![allow(unused)] fn main() { .with_spatial_config(cell_size, grid_resolution) }
cell_size- Should be >= your largest interaction radiusgrid_resolution- Must be power of 2 (16, 32, 64, etc.)
Example: For a simulation with bounds of 1.0 and max interaction radius of 0.1:
#![allow(unused)] fn main() { .with_bounds(1.0) .with_spatial_config(0.1, 32) // 32³ cells covering -1.6 to +1.6 }
Visual Configuration
RDPE provides extensive control over how particles are rendered. The with_visuals method configures the rendering pipeline.
Basic Usage
#![allow(unused)] fn main() { Simulation::<MyParticle>::new() .with_visuals(|v| { v.background(Vec3::new(0.0, 0.0, 0.02)); // Dark blue v.blend_mode(BlendMode::Additive); v.trails(8); v.connections(0.1); }) .run(); }
Options
Particle Shapes
Control the visual shape of each particle:
#![allow(unused)] fn main() { v.shape(ParticleShape::Circle); // Soft circle with smooth falloff (default) v.shape(ParticleShape::CircleHard); // Hard-edged circle v.shape(ParticleShape::Square); // Square/rectangle v.shape(ParticleShape::Ring); // Ring/donut shape v.shape(ParticleShape::Star); // 5-pointed star v.shape(ParticleShape::Triangle); // Equilateral triangle v.shape(ParticleShape::Hexagon); // Regular hexagon v.shape(ParticleShape::Diamond); // Diamond/rhombus v.shape(ParticleShape::Point); // Single pixel (fastest) }
| Shape | Best For |
|---|---|
Circle | General purpose, soft edges |
CircleHard | Sharp particles, dots |
Square | Pixels, grid-based simulations |
Ring | Bubbles, force fields |
Star | Magic effects, sparkles |
Triangle | Arrows, directional particles |
Hexagon | Cells, tiles, molecules |
Diamond | Crystals, gems |
Point | Maximum performance, retro aesthetic |
Background Color
Set the scene backdrop:
#![allow(unused)] fn main() { v.background(Vec3::new(0.0, 0.0, 0.0)); // Black v.background(Vec3::new(0.02, 0.02, 0.04)); // Dark blue v.background(Vec3::new(1.0, 1.0, 1.0)); // White }
Blend Modes
Control how overlapping particles combine:
#![allow(unused)] fn main() { v.blend_mode(BlendMode::Additive); // Bright areas add up (glows, fire) v.blend_mode(BlendMode::Alpha); // Standard transparency (default) }
Additive is ideal for:
- Glowing particles
- Fire, sparks, energy effects
- Light trails
- Anything where overlap should brighten
Alpha is ideal for:
- Solid particles
- Smoke, dust
- Anything where overlap should occlude
Particle Trails
Leave a fading trail behind each particle:
#![allow(unused)] fn main() { v.trails(8); // 8 frames of history }
The number is how many previous positions to render. More = longer trails, but more GPU memory.
Trails work best with:
- Additive blending (trails glow)
- Fast-moving particles
- Dark backgrounds
Connections
Draw lines between nearby particles:
#![allow(unused)] fn main() { v.connections(0.1); // Max distance for connection }
Creates a web/network effect. Particles within the specified distance get connected by lines.
Great for:
- Neural network visualizations
- Constellation effects
- Organic webs
- Network graphs
Post-Processing
Apply screen-space effects to the final image:
#![allow(unused)] fn main() { v.post_process(r#" // Your WGSL code here let color = textureSample(scene, scene_sampler, in.uv); return color; "#); }
See Post-Processing for details.
Complete Example
#![allow(unused)] fn main() { Simulation::<MyParticle>::new() .with_particle_count(5000) .with_visuals(|v| { // Dark background for contrast v.background(Vec3::new(0.01, 0.01, 0.02)); // Additive blending for glow effect v.blend_mode(BlendMode::Additive); // Motion trails v.trails(6); // Connect nearby particles v.connections(0.08); // Add vignette post-processing v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); let dist = length(in.uv - vec2(0.5)); let vignette = 1.0 - smoothstep(0.3, 0.9, dist); return vec4(color.rgb * vignette, 1.0); "#); }) .run(); }
Related
- Fragment Shaders - Customize particle appearance
- Post-Processing - Screen-space effects
Emitters
Emitters continuously spawn new particles into the simulation, replacing dead particles. They enable effects like fountains, explosions, rain, and any other continuous particle generation.
How Emitters Work
When you add an emitter, RDPE:
- Finds dead particles (those with
alive == 0) - Respawns them based on the emitter's rate and position
- Sets initial velocity according to the emitter type
Emitters work best with lifecycle rules:
Rule::Age- increments particle age each frameRule::Lifetime(seconds)- kills particles after a duration
Emitter Types
Point
Emits particles from a single point in all directions.
#![allow(unused)] fn main() { .with_emitter(Emitter::Point { position: Vec3::ZERO, rate: 500.0, // particles per second speed: 1.0, // initial speed (0 = random) }) }
Burst
One-time explosion of particles. Fires once at simulation start.
#![allow(unused)] fn main() { .with_emitter(Emitter::Burst { position: Vec3::new(0.0, 0.5, 0.0), count: 1000, // total particles to spawn speed: 3.0, // outward speed }) }
Cone
Directional emission in a cone shape. Great for fountains, jets, and thrusters.
#![allow(unused)] fn main() { .with_emitter(Emitter::Cone { position: Vec3::new(0.0, -0.5, 0.0), direction: Vec3::Y, // points up speed: 2.5, spread: 0.3, // cone half-angle in radians rate: 800.0, }) }
The spread parameter controls the cone width:
0.0= laser beam (no spread)0.3= tight cone (~17 degrees)PI/4= 45-degree conePI/2= hemisphere
Sphere
Spawns particles on a sphere surface, moving outward (or inward).
#![allow(unused)] fn main() { .with_emitter(Emitter::Sphere { center: Vec3::ZERO, radius: 0.5, speed: 1.0, // positive = outward, negative = inward rate: 1000.0, }) }
Box
Spawns particles at random positions within a box volume.
#![allow(unused)] fn main() { .with_emitter(Emitter::Box { min: Vec3::new(-1.0, 1.0, -1.0), max: Vec3::new(1.0, 1.2, 1.0), velocity: Vec3::new(0.0, -2.0, 0.0), // falling rain rate: 2000.0, }) }
Complete Example
Here's a fountain that continuously emits particles:
use rdpe::prelude::*; #[derive(Particle, Clone)] struct Drop { position: Vec3, velocity: Vec3, #[color] color: Vec3, } fn main() { Simulation::<Drop>::new() .with_particle_count(10_000) .with_bounds(2.0) // Start all particles dead - emitter will spawn them .with_spawner(|_, _| Drop { position: Vec3::ZERO, velocity: Vec3::ZERO, color: Vec3::new(0.3, 0.6, 1.0), }) // Cone emitter shooting upward .with_emitter(Emitter::Cone { position: Vec3::new(0.0, -0.8, 0.0), direction: Vec3::Y, speed: 3.0, spread: 0.2, rate: 1000.0, }) // Lifecycle management .with_rule(Rule::Age) .with_rule(Rule::Lifetime(2.0)) // Physics .with_rule(Rule::Gravity(4.0)) .with_rule(Rule::Drag(0.3)) .with_rule(Rule::BounceWalls) .run(); }
Multiple Emitters
You can add multiple emitters to create complex effects:
#![allow(unused)] fn main() { // Twin fountains .with_emitter(Emitter::Cone { position: Vec3::new(-0.5, -0.8, 0.0), direction: Vec3::Y, speed: 3.0, spread: 0.15, rate: 500.0, }) .with_emitter(Emitter::Cone { position: Vec3::new(0.5, -0.8, 0.0), direction: Vec3::Y, speed: 3.0, spread: 0.15, rate: 500.0, }) }
Tips
- Rate tuning: Match your rate to particle count and lifetime. If
rate * lifetime > particle_count, you'll run out of dead particles to respawn. - Dead start: When using emitters, initialize particles as dead in your spawner (they'll be spawned by the emitter).
- Burst timing: Burst emitters fire at
time < 0.1, so they work immediately on startup.
Sub-Emitters
Sub-emitters spawn child particles when parent particles die. This enables fireworks, explosions, chain reactions, and biological reproduction.
Basic Sub-Emitter
#![allow(unused)] fn main() { #[derive(ParticleType)] enum Firework { Rocket, Spark, } Simulation::<Particle>::new() .with_sub_emitter(SubEmitter::new( Firework::Rocket.into(), // Parent type Firework::Spark.into(), // Child type ) .count(30) // Children per death .speed(1.0..3.0) // Random speed range .spread(std::f32::consts::PI) // Hemisphere .inherit_velocity(0.3)) // 30% of parent velocity .run(); }
Sub-Emitter Options
| Method | Description |
|---|---|
.count(n) | Number of children per parent death |
.speed(min..max) | Random speed range |
.spread(radians) | 0 = laser, PI = hemisphere, TAU = full sphere |
.inherit_velocity(factor) | 0.0 to 1.0, how much parent velocity children get |
.child_lifetime(secs) | Override lifetime for children |
.child_color(Vec3) | Override color for children |
.spawn_radius(r) | Random offset from parent position |
Chaining Sub-Emitters
Create multi-stage effects:
#![allow(unused)] fn main() { // Rockets → Sparks → Embers .with_sub_emitter(SubEmitter::new(Rocket.into(), Spark.into()).count(30)) .with_sub_emitter(SubEmitter::new(Spark.into(), Ember.into()).count(5)) }
Typed Interactions
Typed interactions let different particle types behave differently toward each other. This enables predator-prey dynamics, team-based systems, and state machines.
Defining Particle Types
Use #[derive(ParticleType)] to create a type-safe enum:
#![allow(unused)] fn main() { #[derive(ParticleType, Clone, Copy, PartialEq)] enum Species { Prey, // = 0 Predator, // = 1 } }
The derive macro automatically:
- Implements
Into<u32>(variants get sequential IDs: 0, 1, 2...) - Implements
From<u32>(convert back from runtime values) - Adds a
count()method
Every particle has a particle_type: u32 field. If you don't add it, it's auto-added with value 0.
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct Creature { position: Vec3, velocity: Vec3, particle_type: u32, } }
Set types in the spawner:
#![allow(unused)] fn main() { .with_spawner(|i, count| { let species = if i < 50 { Species::Predator } else { Species::Prey }; Creature { position: random_pos(), velocity: Vec3::ZERO, particle_type: species.into(), } }) }
Chase & Evade
For predator-prey dynamics, use the dedicated rules:
#![allow(unused)] fn main() { // Predators chase nearest prey .with_rule(Rule::Chase { self_type: Species::Predator.into(), target_type: Species::Prey.into(), radius: 0.4, strength: 4.0, }) // Prey evades nearest predator .with_rule(Rule::Evade { self_type: Species::Prey.into(), threat_type: Species::Predator.into(), radius: 0.25, strength: 6.0, }) }
These find the nearest target/threat and steer directly toward/away from it.
The Typed Wrapper
Rule::Typed wraps any neighbor rule with type filters:
#![allow(unused)] fn main() { Rule::Typed { self_type: u32, // Which particles this rule affects other_type: Option<u32>, // Which neighbors to consider rule: Box<Rule>, // The wrapped rule } }
Example: Prey Flocking
#![allow(unused)] fn main() { // Prey flocks with other prey .with_rule(Rule::Typed { self_type: Species::Prey.into(), other_type: Some(Species::Prey.into()), rule: Box::new(Rule::Cohere { radius: 0.15, strength: 1.0 }), }) }
Interacting with All Types
Use other_type: None to interact with everyone:
#![allow(unused)] fn main() { // Everyone avoids collisions with everyone .with_rule(Rule::Typed { self_type: Species::Prey.into(), other_type: None, // All types rule: Box::new(Rule::Collide { radius: 0.05, response: 0.5 }), }) }
Type Conversion
Rule::Convert changes particle types at runtime:
#![allow(unused)] fn main() { #[derive(ParticleType, Clone, Copy, PartialEq)] enum Health { Healthy, Infected, Recovered, } // Healthy can become infected .with_rule(Rule::Convert { from_type: Health::Healthy.into(), trigger_type: Health::Infected.into(), to_type: Health::Infected.into(), radius: 0.08, probability: 0.15, }) // Infected eventually recover .with_rule(Rule::Convert { from_type: Health::Infected.into(), trigger_type: Health::Infected.into(), // Self-trigger to_type: Health::Recovered.into(), radius: 0.01, probability: 0.002, }) }
Updating Visuals
When types change, you'll want colors to update. Use Rule::Custom:
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" if p.particle_type == 0u { p.color = vec3<f32>(0.1, 0.9, 0.2); // Green } else if p.particle_type == 1u { p.color = vec3<f32>(1.0, 0.1, 0.1); // Red } else { p.color = vec3<f32>(0.2, 0.4, 1.0); // Blue } "#.to_string())) }
Use Cases
| Scenario | Types | Interactions |
|---|---|---|
| Predator-Prey | Predator, Prey | Chase/Evade rules |
| Infection | Healthy, Infected, Recovered | Convert rules for spread |
| Charged Particles | Positive, Negative | Opposites attract, same repels |
| Food Chain | Plant, Herbivore, Carnivore | Each level hunts the one below |
| Teams | Team A, Team B | Same team coheres, enemies separate |
| Life Stages | Young, Adult, Elder | Convert based on age |
Performance Note
Typed rules add conditional checks inside the neighbor loop. For best performance:
- Use fewer distinct types when possible
- Group related type interactions
- Consider if untyped rules with Custom code might be simpler
Input Handling
RDPE provides a simple input system for keyboard and mouse interaction. Input state is available in your update callback via UpdateContext.
Basic Usage
#![allow(unused)] fn main() { Simulation::<MyParticle>::new() .with_uniform::<f32>("burst", 0.0) .with_uniform::<[f32; 2]>("attractor", [0.0, 0.0]) .with_update(|ctx| { // React to space bar press if ctx.input.key_pressed(KeyCode::Space) { ctx.set("burst", 1.0); } // Track mouse position while held if ctx.input.mouse_held(MouseButton::Left) { let pos = ctx.input.mouse_ndc(); ctx.set("attractor", [pos.x, pos.y]); } }) .with_rule(Rule::Custom(r#" // Use input in shader if uniforms.burst > 0.5 { p.velocity *= 2.0; } let target = vec3<f32>(uniforms.attractor[0], uniforms.attractor[1], 0.0); p.velocity += normalize(target - p.position) * 0.1; "#.into())) .run(); }
Keyboard Input
Key States
Three types of key queries are available:
| Method | Returns true when |
|---|---|
key_pressed(key) | Key was just pressed this frame |
key_held(key) | Key is currently down |
key_released(key) | Key was just released this frame |
#![allow(unused)] fn main() { .with_update(|ctx| { // Toggle on key press (not hold) if ctx.input.key_pressed(KeyCode::T) { // Toggle something once per press } // Continuous action while held if ctx.input.key_held(KeyCode::W) { // Move forward every frame } // Cleanup on release if ctx.input.key_released(KeyCode::Shift) { // Stop sprint mode } }) }
Available Keys
#![allow(unused)] fn main() { use rdpe::prelude::KeyCode; // Letters KeyCode::A, KeyCode::B, ... KeyCode::Z // Numbers KeyCode::Key0, KeyCode::Key1, ... KeyCode::Key9 // Function keys KeyCode::F1, KeyCode::F2, ... KeyCode::F12 // Arrows KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right // Common keys KeyCode::Space, KeyCode::Enter, KeyCode::Escape KeyCode::Tab, KeyCode::Backspace, KeyCode::Delete KeyCode::Shift, KeyCode::Control, KeyCode::Alt }
Mouse Input
Button States
Mouse buttons work the same as keys:
| Method | Returns true when |
|---|---|
mouse_pressed(button) | Button was just clicked |
mouse_held(button) | Button is currently down |
mouse_released(button) | Button was just released |
#![allow(unused)] fn main() { use rdpe::prelude::MouseButton; .with_update(|ctx| { if ctx.input.mouse_pressed(MouseButton::Left) { // Click action } if ctx.input.mouse_held(MouseButton::Right) { // Drag action } }) }
Available buttons: MouseButton::Left, MouseButton::Right, MouseButton::Middle
Mouse Position
Several position formats are available:
| Method | Returns |
|---|---|
mouse_position() | Screen pixels Vec2 |
mouse_ndc() | Normalized coordinates (-1 to 1) Vec2 |
mouse_delta() | Movement since last frame in pixels Vec2 |
scroll_delta() | Scroll wheel movement f32 |
#![allow(unused)] fn main() { .with_update(|ctx| { // NDC is most useful for particle interactions // Center of screen = (0, 0) // X: -1 (left) to +1 (right) // Y: -1 (bottom) to +1 (top) let pos = ctx.input.mouse_ndc(); // Pass to shader as attractor point ctx.set("mouse_x", pos.x); ctx.set("mouse_y", pos.y); // Check for scroll zoom let scroll = ctx.input.scroll_delta(); if scroll != 0.0 { // Zoom in/out } }) }
Common Patterns
Mouse Attractor
Particles attracted to mouse position:
#![allow(unused)] fn main() { Simulation::<Particle>::new() .with_uniform::<[f32; 2]>("mouse", [0.0, 0.0]) .with_uniform::<f32>("attract_strength", 0.0) .with_update(|ctx| { let pos = ctx.input.mouse_ndc(); ctx.set("mouse", [pos.x, pos.y]); // Only attract while clicking let strength = if ctx.input.mouse_held(MouseButton::Left) { 2.0 } else { 0.0 }; ctx.set("attract_strength", strength); }) .with_rule(Rule::Custom(r#" let target = vec3<f32>(uniforms.mouse[0], uniforms.mouse[1], 0.0); let dir = target - p.position; p.velocity += normalize(dir) * uniforms.attract_strength * uniforms.delta_time; "#.into())) .run(); }
WASD Movement
Move a point of interest with keyboard:
#![allow(unused)] fn main() { Simulation::<Particle>::new() .with_uniform::<[f32; 2]>("focus", [0.0, 0.0]) .with_update(|ctx| { let mut focus = [0.0_f32, 0.0_f32]; let speed = 2.0 * ctx.time.delta_time(); if ctx.input.key_held(KeyCode::W) { focus[1] += speed; } if ctx.input.key_held(KeyCode::S) { focus[1] -= speed; } if ctx.input.key_held(KeyCode::A) { focus[0] -= speed; } if ctx.input.key_held(KeyCode::D) { focus[0] += speed; } // Accumulate movement // In practice, you'd store this in shared state ctx.set("focus", focus); }) .run(); }
Toggle Effects
Toggle particle behavior with key presses:
#![allow(unused)] fn main() { use std::sync::{Arc, Mutex}; let gravity_on = Arc::new(Mutex::new(true)); let gravity_clone = gravity_on.clone(); Simulation::<Particle>::new() .with_uniform::<f32>("gravity", 9.8) .with_update(move |ctx| { let mut on = gravity_clone.lock().unwrap(); // Toggle with G key if ctx.input.key_pressed(KeyCode::G) { *on = !*on; } ctx.set("gravity", if *on { 9.8 } else { 0.0 }); }) .run(); }
Notes
- Input is processed once per frame before the update callback runs
key_pressedandmouse_pressedonly returntruefor one frame- Mouse NDC coordinates assume a standard coordinate system (Y-up)
- The scroll delta is positive for scrolling up/forward
Time
RDPE provides a Time module as the universal source of truth for all timing-related values in the simulation. It uses std::time internally with no external dependencies.
Overview
The Time struct tracks:
- Elapsed time - total time since the simulation started
- Delta time - time since the last frame (for frame-rate independent movement)
- Frame count - total frames rendered
- FPS - calculated frames per second
Basic Usage
#![allow(unused)] fn main() { use rdpe::time::Time; let mut time = Time::new(); // In your game/simulation loop: loop { let (elapsed, delta) = time.update(); // Use elapsed for time-based effects let wave = (elapsed * 2.0).sin(); // Use delta for frame-rate independent movement position += velocity * delta; } }
Accessing Time Values
#![allow(unused)] fn main() { time.update(); // Call once per frame // Get individual values let elapsed = time.elapsed(); // Total seconds since start let delta = time.delta(); // Seconds since last frame let frame = time.frame(); // Frame count (u64) let fps = time.fps(); // Calculated FPS }
Time Control
Pausing
#![allow(unused)] fn main() { time.pause(); // Pause - delta becomes 0, elapsed stops time.resume(); // Resume from where it left off time.toggle_pause(); // Toggle pause state if time.is_paused() { // Handle paused state } }
Time Scale
Slow motion or fast-forward effects:
#![allow(unused)] fn main() { time.set_time_scale(0.5); // Half speed (slow motion) time.set_time_scale(1.0); // Normal speed time.set_time_scale(2.0); // Double speed let scale = time.time_scale(); // Get current scale }
Fixed Timestep
For deterministic physics simulations:
#![allow(unused)] fn main() { // Use fixed 60 FPS timestep regardless of actual frame rate time.set_fixed_delta(Some(1.0 / 60.0)); // Return to real frame timing time.set_fixed_delta(None); }
Reset
#![allow(unused)] fn main() { time.reset(); // Reset to initial state (elapsed = 0, frame = 0, etc.) }
Duration Access
For cases where you need std::time::Duration instead of f32:
#![allow(unused)] fn main() { let elapsed_duration = time.elapsed_duration(); // Duration let delta_duration = time.delta_duration(); // Duration let start = time.start_instant(); // Instant }
Integration with Simulation
The Time module is automatically used internally by Simulation. The values are passed to:
- Your update callback via
UpdateContext - GPU uniforms as
uniforms.timeanduniforms.delta_time
#![allow(unused)] fn main() { Simulation::<MyParticle>::new() .with_update(|ctx| { // ctx.time and ctx.delta_time come from the Time module println!("Time: {:.2}s, Delta: {:.4}s", ctx.time, ctx.delta_time); }) .run(); }
In your WGSL rules, access time via uniforms:
// In custom rules or shaders
let t = uniforms.time;
let dt = uniforms.delta_time;
// Time-based oscillation
p.position.y += sin(t * 2.0) * 0.1 * dt;
API Reference
| Method | Returns | Description |
|---|---|---|
new() | Time | Create a new time tracker |
update() | (f32, f32) | Update and return (elapsed, delta) |
elapsed() | f32 | Total elapsed seconds |
delta() | f32 | Seconds since last frame |
frame() | u64 | Total frame count |
fps() | f32 | Calculated FPS |
is_paused() | bool | Whether time is paused |
time_scale() | f32 | Current time scale multiplier |
pause() | () | Pause time progression |
resume() | () | Resume time progression |
toggle_pause() | () | Toggle pause state |
set_time_scale(f32) | () | Set time scale (0.0+) |
set_fixed_delta(Option<f32>) | () | Set fixed timestep |
reset() | () | Reset to initial state |
Particles as Agents
RDPE particles aren't just physics objects—they can be autonomous agents with memory, perception, relationships, and decision-making. This page explains how existing primitives map to agent concepts.
The Agent Model
Traditional agent-based systems have:
| Agent Concept | Description |
|---|---|
| Memory | State that persists across frames |
| Perception | What the agent can sense |
| Relationships | Connections to other agents |
| Behaviors | Decision-making and actions |
| Communication | Information exchange |
RDPE provides all of these through its existing primitives.
Memory: Particle Fields
Any custom field on your particle struct is persistent memory:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct Creature { position: Vec3, velocity: Vec3, // Memory fields hunger: f32, // Internal state fear_level: f32, // Emotional state age: f32, // Lifetime tracking last_seen_food: Vec3, // Remembered location state: u32, // State machine state } }
These persist frame-to-frame and can be read/written in rules:
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" // Update internal state p.hunger += uniforms.delta_time * 0.1; p.age += uniforms.delta_time; // Decay fear over time p.fear_level *= 0.99; "#.into())) }
Perception: Sensing the World
Neighbors (Local Perception)
Spatial hashing lets agents sense nearby entities:
#![allow(unused)] fn main() { .with_spatial_config(0.3, 32) .with_rule(Rule::NeighborCustom(r#" // Can I see food nearby? if other.particle_type == 1u && neighbor_dist < 0.2 { // Remember where food is p.last_seen_food = other.position; p.hunger -= 0.01; // Eat! } // Is there a predator nearby? if other.particle_type == 2u && neighbor_dist < 0.3 { p.fear_level = 1.0; // Panic! } "#.into())) }
Fields (Environmental Perception)
3D fields provide environmental information:
#![allow(unused)] fn main() { .with_field("temperature", 32, |x, y, z| { // Warmer at center 1.0 - (x*x + y*y + z*z).sqrt() }) .with_rule(Rule::Custom(r#" let temp = field_temperature(p.position); if temp < 0.3 { // Too cold - seek warmth p.velocity.y += 0.1 * uniforms.delta_time; } "#.into())) }
Direct Access (Specific Knowledge)
Particles can read any other particle directly:
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" // Check on my leader (stored index) if p.leader_id != 4294967295u { let leader = particles[p.leader_id]; let to_leader = leader.position - p.position; p.velocity += normalize(to_leader) * 0.5 * uniforms.delta_time; } "#.into())) }
Relationships: Persistent Connections
Bond Indices
Store indices of related particles:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct SocialCreature { position: Vec3, velocity: Vec3, // Relationships parent_id: u32, // Who spawned me friend_ids: [u32; 4], // Social connections enemy_id: u32, // Current rival leader_id: u32, // Pack leader } }
Using Rule::BondSprings
For physical connections (cloth, ropes, molecules):
#![allow(unused)] fn main() { .with_rule(Rule::BondSprings { bonds: vec!["bond_left", "bond_right", "bond_up", "bond_down"], stiffness: 800.0, damping: 15.0, rest_length: 0.05, max_stretch: Some(1.3), }) }
Interaction Matrix
Type-based relationships:
#![allow(unused)] fn main() { .with_interactions(|m| { m.attract(Prey, Prey, 0.3, 0.2); // Prey flocks m.repel(Prey, Predator, 1.0, 0.4); // Prey flees predators m.attract(Predator, Prey, 0.8, 0.5); // Predators hunt prey }) }
Behaviors: Decision Making
State Machines
Use a state field for behavioral modes:
#![allow(unused)] fn main() { const STATE_IDLE: u32 = 0; const STATE_SEEKING: u32 = 1; const STATE_FLEEING: u32 = 2; const STATE_EATING: u32 = 3; .with_rule(Rule::Custom(r#" // State transitions if p.state == 0u { // IDLE if p.hunger > 0.7 { p.state = 1u; // -> SEEKING } if p.fear_level > 0.5 { p.state = 2u; // -> FLEEING } } else if p.state == 1u { // SEEKING // Move toward remembered food location let to_food = p.last_seen_food - p.position; if length(to_food) > 0.01 { p.velocity += normalize(to_food) * 0.3 * uniforms.delta_time; } if p.hunger < 0.3 { p.state = 0u; // -> IDLE (full) } if p.fear_level > 0.5 { p.state = 2u; // -> FLEEING (danger!) } } else if p.state == 2u { // FLEEING // Run away from threat (handled in neighbor rule) p.velocity *= 1.5; // Sprint! if p.fear_level < 0.1 { p.state = 0u; // -> IDLE (safe) } } "#.into())) }
Conditional Behaviors
Simple if/else logic:
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" let speed = length(p.velocity); // Tired? Slow down if p.energy < 0.2 { p.velocity *= 0.95; } // Old? Change color if p.age > 10.0 { p.color = mix(p.color, vec3<f32>(0.5, 0.5, 0.5), 0.01); } // Hungry and near food? Eat // (food detection happens in neighbor rule) "#.into())) }
Communication: Information Exchange
Inboxes (Direct Messages)
Particles can send float values to each other via 4 inbox channels:
#![allow(unused)] fn main() { .with_inbox() // Enable inbox system .with_spatial_config(0.2, 32) // Send values in neighbor rule .with_rule(Rule::NeighborCustom(r#" // Send danger signal to nearby friends if p.fear_level > 0.8 && other.particle_type == p.particle_type { inbox_send(other_idx, 0u, 1.0); // Channel 0: danger level } // Share energy with neighbors if neighbor_dist < 0.05 { inbox_send(other_idx, 1u, p.energy * 0.1); // Channel 1: energy transfer } "#.into())) // Receive accumulated values .with_rule(Rule::Custom(r#" // React to danger signals (channel 0) let danger = inbox_receive_at(index, 0u); if danger > 0.5 { p.fear_level = max(p.fear_level, 0.5); } // Receive transferred energy (channel 1) p.energy += inbox_receive_at(index, 1u); "#.into())) }
Inbox details:
- 4 channels per particle (vec4)
- Values are accumulated atomically across all senders
- Cleared each frame
- ~0.00001 precision in range ±32768
Fields (Broadcast)
Write to fields for area-of-effect communication:
#![allow(unused)] fn main() { .with_field_writable("pheromone", 32, |_, _, _| 0.0) // Leave pheromone trail .with_rule(Rule::Custom(r#" if p.found_food > 0.0 { field_pheromone_add(p.position, 1.0); } "#.into())) // Follow pheromone gradient .with_rule(Rule::Custom(r#" let gradient = field_pheromone_gradient(p.position); p.velocity += gradient * 0.2 * uniforms.delta_time; "#.into())) }
Complete Example: Ecosystem
Here's a full agent-based ecosystem:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct Creature { position: Vec3, velocity: Vec3, #[color] color: Vec3, particle_type: u32, // 0=plant, 1=herbivore, 2=predator energy: f32, age: f32, state: u32, } Simulation::<Creature>::new() .with_particle_count(2000) .with_spawner(|i, _| { let creature_type = (i % 10) as u32; // Mix of types Creature { position: random_position(), velocity: Vec3::ZERO, color: match creature_type { 0 => Vec3::new(0.2, 0.8, 0.2), // Plants: green 1 => Vec3::new(0.2, 0.5, 0.9), // Herbivores: blue _ => Vec3::new(0.9, 0.2, 0.2), // Predators: red }, particle_type: creature_type.min(2), energy: 1.0, age: 0.0, state: 0, } }) .with_spatial_config(0.3, 32) // Type-based interactions .with_interactions(|m| { // Herbivores eat plants, flock together m.attract(1, 0, 0.5, 0.2); // Herbivore -> Plant m.attract(1, 1, 0.2, 0.15); // Herbivore -> Herbivore m.repel(1, 2, 0.8, 0.3); // Herbivore <- Predator // Predators hunt herbivores m.attract(2, 1, 0.7, 0.4); // Predator -> Herbivore m.repel(2, 2, 0.3, 0.2); // Predators spread out }) // Energy and aging .with_rule(Rule::Custom(r#" p.age += uniforms.delta_time; // Plants don't move, slowly regenerate if p.particle_type == 0u { p.velocity = vec3<f32>(0.0); p.energy = min(p.energy + uniforms.delta_time * 0.1, 1.0); } else { // Animals burn energy moving p.energy -= length(p.velocity) * uniforms.delta_time * 0.01; } // Color reflects energy let energy_color = mix(vec3<f32>(0.3), p.color, p.energy); p.color = energy_color; "#.into())) // Eating (in neighbor loop) .with_rule(Rule::NeighborCustom(r#" // Herbivores eat plants if p.particle_type == 1u && other.particle_type == 0u && neighbor_dist < 0.05 { p.energy = min(p.energy + 0.1, 1.0); } // Predators eat herbivores if p.particle_type == 2u && other.particle_type == 1u && neighbor_dist < 0.05 { p.energy = min(p.energy + 0.2, 1.0); } "#.into())) .with_rule(Rule::Drag(1.0)) .with_rule(Rule::WrapWalls) .run(); }
Design Patterns
Pattern: Finite State Machine
#![allow(unused)] fn main() { // States as constants const WANDER: u32 = 0; const CHASE: u32 = 1; const FLEE: u32 = 2; const REST: u32 = 3; // State transitions based on conditions // Actions based on current state }
Pattern: Blackboard (Shared Memory via Fields)
#![allow(unused)] fn main() { // Global information in fields .with_field_writable("danger_zone", 16, |_,_,_| 0.0) // Agents write when they spot danger // Other agents read and react }
Pattern: Stigmergy (Indirect Communication)
#![allow(unused)] fn main() { // Pheromone trails // Agents modify environment // Other agents sense modifications // No direct communication needed }
Performance Considerations
- State machines are cheap - Integer comparisons are fast
- Memory fields add bandwidth - Each field increases particle size
- Neighbor perception is expensive - Spatial queries dominate cost
- Direct access is fast -
particles[index]is a single read - Fields are moderate - 3D texture lookups have some cost
Summary
RDPE particles are agents when you use them as agents:
| Agent Need | RDPE Solution |
|---|---|
| Memory | Particle fields |
| Local perception | Neighbor queries |
| Global perception | Fields |
| Specific knowledge | Direct buffer access |
| Physical bonds | Rule::BondSprings |
| Type relationships | Interaction matrix |
| Decisions | Custom rules with conditionals |
| Direct messages | Inboxes |
| Broadcast | Writable fields |
No special "Agent" API needed—the primitives compose into whatever agent architecture your simulation requires.
Multi-Particle Types
The MultiParticle derive macro lets you define multiple particle types with different fields in a single enum, then use them together in heterogeneous simulations.
The Problem
With regular #[derive(Particle)], all particles in a simulation share the same struct. If you want boids with a flock_id and predators with hunger, you'd need to put both fields on every particle:
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct Creature { position: Vec3, velocity: Vec3, particle_type: u32, flock_id: u32, // Only boids use this hunger: f32, // Only predators use this } }
This works, but it's awkward and wastes memory.
The Solution: MultiParticle
MultiParticle lets you define each type with only its relevant fields:
#![allow(unused)] fn main() { #[derive(MultiParticle, Clone)] enum Creature { Boid { position: Vec3, velocity: Vec3, flock_id: u32, }, Predator { position: Vec3, velocity: Vec3, hunger: f32, target_id: u32, }, } }
From this single definition, the macro generates:
- Standalone structs -
BoidandPredatoras separate types, each implementingParticleTrait - Type constants -
Creature::BOIDandCreature::PREDATORfor use in typed rules - Unified GPU struct - A combined struct with all fields for the GPU
- WGSL helpers - Constants (
BOID,PREDATOR) and functions (is_boid(),is_predator())
Creating Particles
Use clean struct-like enum syntax:
#![allow(unused)] fn main() { // Boid with its specific fields Creature::Boid { position: pos, velocity: vel, flock_id: 0, } // Predator with its specific fields Creature::Predator { position: pos, velocity: vel, hunger: 1.0, target_id: 0, } }
Type Constants in Rules
The generated constants make typed rules self-documenting:
#![allow(unused)] fn main() { // Predators chase boids .with_rule(Rule::Chase { self_type: Creature::PREDATOR, target_type: Creature::BOID, radius: 0.5, strength: 3.5, }) // Boids evade predators .with_rule(Rule::Evade { self_type: Creature::BOID, threat_type: Creature::PREDATOR, radius: 0.3, strength: 5.0, }) // Boids flock with other boids .with_rule(Rule::Typed { self_type: Creature::BOID, other_type: Some(Creature::BOID), rule: Box::new(Rule::Cohere { radius: 0.15, strength: 1.2 }), }) }
WGSL Helpers
In custom rules, use the generated helpers to access variant-specific fields:
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" // Check type with helper function if is_predator(p) { // Access predator-specific field p.hunger = max(0.0, p.hunger - uniforms.delta_time * 0.1); // Hungry predators move faster let speed_boost = 1.0 + (1.0 - p.hunger) * 0.5; p.velocity *= speed_boost; } if is_boid(p) { // Access boid-specific field let flock = p.flock_id; } "#.into())) }
The generated WGSL includes:
// Type constants
const BOID: u32 = 0u;
const PREDATOR: u32 = 1u;
// Helper functions
fn is_boid(p: Particle) -> bool { return p.particle_type == 0u; }
fn is_predator(p: Particle) -> bool { return p.particle_type == 1u; }
Standalone Simulations
The generated structs work independently too:
#![allow(unused)] fn main() { // Mixed simulation Simulation::<Creature>::new() // Boid-only simulation (uses generated Boid struct) Simulation::<Boid>::new() // Predator-only simulation (uses generated Predator struct) Simulation::<Predator>::new() }
Requirements
- The enum must also derive
Clone - Each variant must have
position: Vec3andvelocity: Vec3 - Use struct-like syntax (named fields, not tuple variants)
How It Works
On the GPU, all particles share a unified struct containing every field from every variant:
struct Particle {
position: vec3<f32>,
velocity: vec3<f32>,
flock_id: u32, // From Boid
hunger: f32, // From Predator
target_id: u32, // From Predator
particle_type: u32, // Discriminant
// ... lifecycle fields
}
When a Creature::Boid is converted to GPU format:
flock_idis set from the boid's valuehungerandtarget_idare zeroedparticle_typeis set to0(BOID)
This means accessing the "wrong" variant's fields in WGSL just reads zeros - it's safe but meaningless. Always check particle_type or use the helper functions first.
Complete Example
use rdpe::prelude::*; #[derive(MultiParticle, Clone)] enum Creature { Boid { position: Vec3, velocity: Vec3, flock_id: u32, }, Predator { position: Vec3, velocity: Vec3, hunger: f32, }, } fn main() { Simulation::<Creature>::new() .with_particle_count(1000) .with_spawner(|i, count| { if i < count * 9 / 10 { Creature::Boid { position: random_position(), velocity: Vec3::ZERO, flock_id: i % 3, } } else { Creature::Predator { position: random_position(), velocity: Vec3::ZERO, hunger: 1.0, } } }) // Boid flocking .with_rule(Rule::Typed { self_type: Creature::BOID, other_type: Some(Creature::BOID), rule: Box::new(Rule::Cohere { radius: 0.15, strength: 1.0 }), }) // Predator hunting .with_rule(Rule::Chase { self_type: Creature::PREDATOR, target_type: Creature::BOID, radius: 0.5, strength: 3.0, }) // Prey evasion .with_rule(Rule::Evade { self_type: Creature::BOID, threat_type: Creature::PREDATOR, radius: 0.3, strength: 5.0, }) .run(); }
When to Use MultiParticle
| Use Case | Approach |
|---|---|
| Single particle type | Regular #[derive(Particle)] |
| Multiple types, same fields | ParticleType enum + particle_type field |
| Multiple types, different fields | #[derive(MultiParticle)] |
MultiParticle shines when your types genuinely need different data - predators tracking hunger, boids tracking flock membership, infected particles tracking infection time, etc.
Fields
Spatial Hashing
Spatial hashing accelerates neighbor queries from O(n²) to approximately O(n). It's automatically enabled when you use neighbor-based rules.
Why It's Needed
Without spatial hashing, checking every particle against every other particle is prohibitively slow:
| Particles | Naive Comparisons | With Spatial Hash |
|---|---|---|
| 1,000 | 1,000,000 | ~50,000 |
| 10,000 | 100,000,000 | ~500,000 |
| 100,000 | 10,000,000,000 | ~5,000,000 |
How It Works
1. Morton Encoding (Z-Order Curve)
3D space is divided into a grid of cells. Each cell gets a unique ID using Morton encoding:
3D Position → Cell Coordinates → Morton Code (single u32)
Morton codes preserve spatial locality - nearby cells have similar codes.
2. Radix Sort
Particles are sorted by their Morton code using GPU radix sort:
- Dynamic pass count based on grid resolution (e.g., 32³ grid = 15 bits needed = 4 passes)
- Each pass processes 4 bits: histogram → prefix sum → scatter
- Result: particles ordered by spatial cell
3. Cell Table
After sorting, we build a lookup table:
cell_start[morton_code] = first particle index in this cell
cell_end[morton_code] = one past last particle index
4. Neighbor Iteration
To find neighbors, check the 27 adjacent cells (3×3×3 cube):
for offset in 0..27 {
let neighbor_cell = get_neighbor_cell(my_cell, offset);
for particle in cell_start[neighbor_cell]..cell_end[neighbor_cell] {
// Check distance, apply rule
}
}
Max Neighbors Limit
For dense clusters, you can cap how many neighbors each particle processes:
#![allow(unused)] fn main() { .with_max_neighbors(48) // Stop after 48 neighbors }
This provides an early-exit from the neighbor loop, trading accuracy for performance in pathological cases where particles cluster tightly.
Configuration
Configure spatial hashing with:
#![allow(unused)] fn main() { .with_spatial_config(cell_size, grid_resolution) }
Cell Size
Should be at least as large as your largest interaction radius:
#![allow(unused)] fn main() { // If your largest rule has radius 0.15: .with_spatial_config(0.15, 32) // Or slightly larger for safety: .with_spatial_config(0.2, 32) }
If cell size is smaller than interaction radius, you might miss neighbors in non-adjacent cells.
Grid Resolution
Must be a power of 2 (16, 32, 64, 128, etc.):
#![allow(unused)] fn main() { .with_spatial_config(0.1, 32) // 32³ = 32,768 cells .with_spatial_config(0.1, 64) // 64³ = 262,144 cells }
The grid covers space from -resolution * cell_size / 2 to +resolution * cell_size / 2:
| Resolution | Cell Size | Coverage |
|---|---|---|
| 32 | 0.1 | -1.6 to +1.6 |
| 64 | 0.1 | -3.2 to +3.2 |
| 32 | 0.05 | -0.8 to +0.8 |
Ensure your bounds fit within the grid coverage.
When It's Used
Spatial hashing is automatically enabled when you use any of these rules:
Rule::SeparateRule::CohereRule::AlignRule::CollideRule::ConvertRule::Typed(wrapping a neighbor rule)
Non-neighbor rules (Gravity, Drag, BounceWalls, etc.) don't trigger spatial hashing.
Memory Usage
The spatial hash requires additional GPU buffers:
| Buffer | Size |
|---|---|
| Morton codes (×2) | 4 bytes × particles × 2 |
| Particle indices (×2) | 4 bytes × particles × 2 |
| Cell start | 4 bytes × grid_resolution³ |
| Cell end | 4 bytes × grid_resolution³ |
| Histogram | 64 bytes |
For 10,000 particles with 32³ grid:
- Morton/indices: 160 KB
- Cell tables: 256 KB
- Total: ~416 KB
Performance Tips
-
Match cell size to interaction radius - Too small wastes work checking empty cells; too large checks too many particles per cell.
-
Don't over-resolve - 32³ is usually enough. 64³ only helps if particles are very spread out.
-
Spatial hash runs every frame - It's fast, but the cost is proportional to particle count.
-
Combine interaction radii - If possible, use similar radii for all neighbor rules to optimize cell size.
3D Spatial Fields
Custom Rules
When built-in rules aren't enough, Rule::Custom lets you write raw WGSL shader code.
Basic Usage
#![allow(unused)] fn main() { .with_rule(Rule::Custom(r#" // Your WGSL code here p.velocity.y += sin(uniforms.time) * 0.1; "#.to_string())) }
Available Variables
Particle Data
p.position // vec3<f32> - current position (read/write)
p.velocity // vec3<f32> - current velocity (read/write)
p.color // vec3<f32> - particle color (if defined)
p.particle_type // u32 - particle type
// Plus any custom fields you defined
Context
index // u32 - this particle's index
uniforms.time // f32 - total elapsed time in seconds
uniforms.delta_time // f32 - time since last frame
In Neighbor Loop (for neighbor rules only)
other_idx // u32 - neighbor's index
other // Particle - neighbor's data
neighbor_pos // vec3<f32> - neighbor's position
neighbor_vel // vec3<f32> - neighbor's velocity
neighbor_dist // f32 - distance to neighbor
neighbor_dir // vec3<f32> - normalized direction to neighbor
Examples
Oscillating Force
#![allow(unused)] fn main() { Rule::Custom(r#" let freq = 2.0; let amp = 0.5; p.velocity.y += sin(uniforms.time * freq) * amp * uniforms.delta_time; "#.to_string()) }
Color Based on Speed
#![allow(unused)] fn main() { Rule::Custom(r#" let speed = length(p.velocity); let normalized_speed = clamp(speed / 2.0, 0.0, 1.0); p.color = mix( vec3<f32>(0.0, 0.0, 1.0), // Blue (slow) vec3<f32>(1.0, 0.0, 0.0), // Red (fast) normalized_speed ); "#.to_string()) }
Age-Based Behavior
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct AgingParticle { position: Vec3, velocity: Vec3, age: f32, // Custom field } // In simulation: .with_rule(Rule::Custom(r#" p.age += uniforms.delta_time; // Slow down with age let age_factor = 1.0 / (1.0 + p.age * 0.1); p.velocity *= age_factor; // Change color with age p.color = mix( vec3<f32>(0.2, 1.0, 0.2), // Young: green vec3<f32>(0.6, 0.3, 0.1), // Old: brown clamp(p.age / 10.0, 0.0, 1.0) ); "#.to_string())) }
Vortex Force
#![allow(unused)] fn main() { Rule::Custom(r#" // Circular force around Y axis let to_center = -p.position; let tangent = vec3<f32>(-to_center.z, 0.0, to_center.x); let dist = length(to_center.xz); if dist > 0.01 { let vortex_strength = 1.0 / (dist + 0.1); p.velocity += normalize(tangent) * vortex_strength * uniforms.delta_time; } "#.to_string()) }
Pulsing Size (via Custom Field)
#![allow(unused)] fn main() { #[derive(Particle, Clone)] struct PulsingParticle { position: Vec3, velocity: Vec3, phase: f32, // Each particle has different phase } .with_rule(Rule::Custom(r#" // Update a "size" factor based on time and phase let pulse = sin(uniforms.time * 3.0 + p.phase) * 0.5 + 0.5; // Could use this in a custom renderer... "#.to_string())) }
Random Noise Movement
#![allow(unused)] fn main() { Rule::Custom(r#" // Hash-based pseudo-random let seed = index ^ u32(uniforms.time * 60.0); let hash = (seed * 1103515245u + 12345u); let rx = f32((hash >> 0u) & 0xFFu) / 128.0 - 1.0; let ry = f32((hash >> 8u) & 0xFFu) / 128.0 - 1.0; let rz = f32((hash >> 16u) & 0xFFu) / 128.0 - 1.0; p.velocity += vec3<f32>(rx, ry, rz) * 0.1 * uniforms.delta_time; "#.to_string()) }
WGSL Tips
Type Suffixes
let x = 1.0; // f32
let y = 1u; // u32
let z = 1i; // i32
Vector Construction
let v = vec3<f32>(1.0, 2.0, 3.0);
let v2 = vec3<f32>(0.0); // All zeros
Useful Functions
length(v) // Vector magnitude
normalize(v) // Unit vector
dot(a, b) // Dot product
cross(a, b) // Cross product
clamp(x, lo, hi) // Clamp to range
mix(a, b, t) // Linear interpolation
sin(x), cos(x) // Trig functions
abs(x) // Absolute value
min(a, b), max(a, b)
Debugging
Custom rules can silently fail. Tips:
- Start simple - Add one line at a time
- Check types - WGSL is strictly typed
- Use color - Set
p.colorto visualize values - Check compilation - Shader errors print on startup
#![allow(unused)] fn main() { // Debug: visualize a value as color Rule::Custom(r#" let debug_value = length(p.velocity); p.color = vec3<f32>(debug_value, 0.0, 0.0); "#.to_string()) }
Fragment Shaders
Fragment shaders control how each particle looks - its shape, glow, color effects, and more.
Basic Usage
#![allow(unused)] fn main() { Simulation::<MyParticle>::new() .with_fragment_shader(r#" let dist = length(in.uv); let glow = 1.0 / (dist * dist * 8.0 + 0.3); return vec4<f32>(in.color * glow, glow * 0.5); "#) .run(); }
Available Variables
In your fragment shader snippet, you have access to:
| Variable | Type | Description |
|---|---|---|
in.uv | vec2<f32> | Position within particle quad (-1 to 1, center is 0) |
in.color | vec3<f32> | Particle's color (from #[color] field) |
uniforms.time | f32 | Seconds since simulation start |
uniforms.delta_time | f32 | Seconds since last frame |
uniforms.* | varies | Any custom uniforms defined via .with_uniform() |
How It Works
Your snippet is injected into a fragment shader that runs for every pixel of every particle. The in.uv coordinates tell you where you are within the particle's billboard quad:
(-1,-1) -------- (1,-1)
| |
| (0,0) |
| |
(-1,1) --------- (1,1)
The center is (0,0), so length(in.uv) gives distance from center.
Common Patterns
Soft Circle (Default Look)
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); if dist > 1.0 { discard; } let alpha = 1.0 - smoothstep(0.0, 1.0, dist); return vec4<f32>(in.color, alpha); "#) }
Glowing Particle
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); let glow = 1.0 / (dist * dist * 8.0 + 0.3); let alpha = clamp(glow * 0.5, 0.0, 1.0); return vec4<f32>(in.color * glow, alpha); "#) }
Ring
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); let ring = smoothstep(0.6, 0.7, dist) - smoothstep(0.8, 0.9, dist); return vec4<f32>(in.color, ring); "#) }
Pulsing
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); let pulse = sin(uniforms.time * 4.0) * 0.3 + 0.7; let glow = 1.0 / (dist * dist * 8.0 + 0.2); return vec4<f32>(in.color * glow * pulse, glow * 0.5); "#) }
Animated Interference
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); // Core let core = 1.0 - smoothstep(0.0, 0.3, dist); // Animated rings let rings = sin(dist * 20.0 - uniforms.time * 5.0) * 0.5 + 0.5; let ring_fade = exp(-dist * 3.0); let intensity = core + rings * ring_fade * 0.5; return vec4<f32>(in.color * intensity, intensity * 0.6); "#) }
Color Shift Based on Position
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); let glow = 1.0 / (dist * dist * 6.0 + 0.3); // Shift hue based on angle let angle = atan2(in.uv.y, in.uv.x); let hue_shift = angle / 6.28318; // Simple hue rotation (approximate) let shifted = vec3<f32>( in.color.r * cos(hue_shift * 6.28) - in.color.g * sin(hue_shift * 6.28), in.color.r * sin(hue_shift * 6.28) + in.color.g * cos(hue_shift * 6.28), in.color.b ); return vec4<f32>(shifted * glow, glow * 0.5); "#) }
Sharp Core + Soft Halo
#![allow(unused)] fn main() { .with_fragment_shader(r#" let dist = length(in.uv); // Sharp inner core let core = 1.0 - smoothstep(0.0, 0.2, dist); // Soft outer glow let halo = 1.0 / (dist * dist * 4.0 + 0.5); let intensity = core * 2.0 + halo * 0.5; let alpha = clamp(intensity * 0.4, 0.0, 1.0); return vec4<f32>(in.color * intensity, alpha); "#) }
Tips
Coordinate System
in.uvranges from -1 to 1length(in.uv)= distance from center (0 at center, 1 at edge, >1 at corners)- Use
in.uv * 0.5 + 0.5to get 0-1 range for texture coordinates
Performance
- Fragment shaders run per-pixel per-particle
- Keep math simple for thousands of particles
- Avoid loops if possible
Blending
Fragment shader output interacts with blend mode:
- Additive: RGB values add together (bright + bright = brighter)
- Alpha: Standard alpha compositing
For additive blending, the alpha channel still matters for intensity.
Debugging
Set solid colors to debug:
#![allow(unused)] fn main() { // Debug: show UV coordinates as colors .with_fragment_shader(r#" return vec4<f32>(in.uv * 0.5 + 0.5, 0.0, 1.0); "#) }
Related
- Visual Configuration - Blend modes, trails, connections
- Post-Processing - Screen-space effects
Textures
RDPE supports custom textures that can be sampled in fragment shaders and post-processing effects. This enables color lookup tables, noise-based effects, sprites, and more.
Quick Start
#![allow(unused)] fn main() { use rdpe::prelude::*; Simulation::<MyParticle>::new() .with_texture("noise", TextureConfig::noise(256, 42)) .with_fragment_shader(r#" let n = textureSample(tex_noise, tex_noise_sampler, in.uv * 0.5 + 0.5); return vec4<f32>(in.color * n.r, 1.0); "#) .run(); }
Adding Textures
Use .with_texture(name, config) to add textures to your simulation:
#![allow(unused)] fn main() { .with_texture("gradient", TextureConfig::gradient(256, start, end)) .with_texture("pattern", TextureConfig::from_file("assets/pattern.png")) }
Each texture you add becomes available in shaders as:
tex_name- the texture itselftex_name_sampler- the sampler for that texture
Creating Textures
From Image Files
Load PNG or JPEG images:
#![allow(unused)] fn main() { TextureConfig::from_file("assets/noise.png") TextureConfig::from_file("assets/sprite.jpg") }
Procedural Noise
Generate hash-based noise textures:
#![allow(unused)] fn main() { TextureConfig::noise(256, 42) // 256x256, seed 42 TextureConfig::noise(512, 0) // 512x512, seed 0 }
Color Gradients
Create horizontal gradient textures (great for color lookup tables):
#![allow(unused)] fn main() { TextureConfig::gradient( 256, // width [0, 0, 0, 255], // start color (RGBA) [255, 200, 50, 255], // end color (RGBA) ) }
Solid Colors
Single-pixel solid color textures:
#![allow(unused)] fn main() { TextureConfig::solid(255, 0, 0, 255) // Red TextureConfig::solid(0, 255, 0, 128) // Semi-transparent green }
Checkerboard Patterns
#![allow(unused)] fn main() { TextureConfig::checkerboard( 64, // size (64x64) 8, // cell size [255, 255, 255, 255], // color 1 [0, 0, 0, 255], // color 2 ) }
Raw RGBA Data
Create textures from raw pixel data:
#![allow(unused)] fn main() { let data = vec![ 255, 0, 0, 255, // Red pixel 0, 255, 0, 255, // Green pixel 0, 0, 255, 255, // Blue pixel 255, 255, 0, 255, // Yellow pixel ]; TextureConfig::from_rgba(data, 2, 2) // 2x2 texture }
Texture Configuration
Filter Mode
Control how textures are sampled between pixels:
#![allow(unused)] fn main() { TextureConfig::from_file("sprite.png") .with_filter(FilterMode::Nearest) // Sharp pixels (pixel art) TextureConfig::noise(256, 0) .with_filter(FilterMode::Linear) // Smooth interpolation (default) }
Address Mode
Control what happens when UV coordinates go outside 0-1:
#![allow(unused)] fn main() { TextureConfig::from_file("tile.png") .with_address_mode(AddressMode::Repeat) // Tile the texture .with_address_mode(AddressMode::ClampToEdge) // Use edge pixels (default) .with_address_mode(AddressMode::MirrorRepeat) // Mirror at boundaries }
Sampling in Shaders
In Fragment Shaders
#![allow(unused)] fn main() { .with_fragment_shader(r#" // Sample at particle UV (normalized quad coordinates) let color = textureSample(tex_sprite, tex_sprite_sampler, in.uv * 0.5 + 0.5); // Sample using custom coordinates let noise = textureSample(tex_noise, tex_noise_sampler, in.world_pos.xy); return vec4<f32>(color.rgb * noise.r, color.a); "#) }
In Post-Processing
#![allow(unused)] fn main() { .with_visuals(|v| { v.post_process(r#" let scene_color = textureSample(scene, scene_sampler, in.uv); let noise = textureSample(tex_noise, tex_noise_sampler, in.uv * 10.0); // Film grain effect let grain = (noise.r - 0.5) * 0.1; return vec4<f32>(scene_color.rgb + grain, 1.0); "#); }) }
Common Use Cases
Color Lookup Tables (LUTs)
Use gradients to map values to colors:
#![allow(unused)] fn main() { // Fire gradient: black -> red -> orange -> yellow -> white let fire_lut = TextureConfig::gradient(256, [0, 0, 0, 255], [255, 255, 200, 255]); .with_texture("fire_lut", fire_lut) .with_fragment_shader(r#" // Use particle temperature/intensity to look up color let intensity = length(in.velocity) / max_speed; let color = textureSample(tex_fire_lut, tex_fire_lut_sampler, vec2<f32>(intensity, 0.5)); return color; "#) }
Noise-Based Effects
Add visual variation:
#![allow(unused)] fn main() { .with_texture("noise", TextureConfig::noise(256, 42)) .with_fragment_shader(r#" let n = textureSample(tex_noise, tex_noise_sampler, in.uv * 0.5 + 0.5).r; // Vary particle brightness let brightness = 0.5 + n * 0.5; // Vary particle edges let dist = length(in.uv); let edge = smoothstep(0.5 * n, 0.0, dist); return vec4<f32>(in.color * brightness, edge); "#) }
Sprite Textures
Use image textures for particle appearance:
#![allow(unused)] fn main() { .with_texture("sprite", TextureConfig::from_file("assets/particle.png") .with_filter(FilterMode::Linear)) .with_fragment_shader(r#" let sprite = textureSample(tex_sprite, tex_sprite_sampler, in.uv * 0.5 + 0.5); return vec4<f32>(sprite.rgb * in.color, sprite.a); "#) }
Complete Example
use rdpe::prelude::*; #[derive(Particle, Clone)] struct GlowParticle { position: Vec3, velocity: Vec3, #[color] color: Vec3, } fn main() { // Create textures let noise = TextureConfig::noise(256, 42); let gradient = TextureConfig::gradient( 256, [50, 50, 200, 255], // Blue [255, 100, 50, 255], // Orange ); Simulation::<GlowParticle>::new() .with_particle_count(10_000) .with_texture("noise", noise) .with_texture("gradient", gradient) .with_fragment_shader(r#" // Sample noise for variation let n = textureSample(tex_noise, tex_noise_sampler, in.uv * 0.5 + 0.5).r; // Use noise to look up gradient color let color = textureSample(tex_gradient, tex_gradient_sampler, vec2<f32>(n, 0.5)); // Radial glow let dist = length(in.uv); let glow = 1.0 - smoothstep(0.0, 0.5, dist); return vec4<f32>(color.rgb * glow, glow); "#) .with_visuals(|v| { v.blend_mode(BlendMode::Additive); v.background(Vec3::ZERO); }) .run(); }
Related
- Fragment Shaders - Customize particle appearance
- Post-Processing - Screen-space effects
- Custom Uniforms - Pass dynamic values to shaders
Post-Processing
Post-processing applies screen-space effects to the rendered scene - things like bloom, vignette, chromatic aberration, and CRT scanlines.
Basic Usage
#![allow(unused)] fn main() { .with_visuals(|v| { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); // Modify color here return color; "#); }) }
Available Variables
| Variable | Type | Description |
|---|---|---|
in.uv | vec2<f32> | Screen coordinates (0 to 1, top-left is origin) |
scene | texture_2d<f32> | The rendered particle scene |
scene_sampler | sampler | Sampler for the scene texture |
uniforms.time | f32 | Seconds since simulation start |
uniforms.delta_time | f32 | Seconds since last frame |
uniforms.* | varies | Any custom uniforms defined via .with_uniform() |
How It Works
After all particles are rendered to an offscreen texture, your post-process shader runs once per screen pixel. You sample the scene texture and output a modified color.
#![allow(unused)] fn main() { // The identity post-process (does nothing) v.post_process(r#" return textureSample(scene, scene_sampler, in.uv); "#); }
Common Effects
Vignette
Darken the edges of the screen:
#![allow(unused)] fn main() { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); let center = vec2<f32>(0.5, 0.5); let dist = length(in.uv - center); let vignette = 1.0 - smoothstep(0.3, 0.9, dist); return vec4<f32>(color.rgb * vignette, 1.0); "#); }
Chromatic Aberration
Separate RGB channels for a lens distortion effect:
#![allow(unused)] fn main() { v.post_process(r#" let aberration = 0.005; let r = textureSample(scene, scene_sampler, in.uv + vec2<f32>(aberration, 0.0)).r; let g = textureSample(scene, scene_sampler, in.uv).g; let b = textureSample(scene, scene_sampler, in.uv - vec2<f32>(aberration, 0.0)).b; return vec4<f32>(r, g, b, 1.0); "#); }
Radial Chromatic Aberration
Aberration that increases toward edges:
#![allow(unused)] fn main() { v.post_process(r#" let center = vec2<f32>(0.5, 0.5); let uv_centered = in.uv - center; let dist = length(uv_centered); let dir = normalize(uv_centered); let aberration = 0.003 + dist * 0.01; let r = textureSample(scene, scene_sampler, in.uv + dir * aberration).r; let g = textureSample(scene, scene_sampler, in.uv).g; let b = textureSample(scene, scene_sampler, in.uv - dir * aberration).b; return vec4<f32>(r, g, b, 1.0); "#); }
Film Grain
Add noise for a film-like quality:
#![allow(unused)] fn main() { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); // Hash-based noise let grain = fract(sin(dot(in.uv * 1000.0, vec2<f32>(12.9898, 78.233)) + uniforms.time) * 43758.5453); let noise = (grain - 0.5) * 0.03; return vec4<f32>(color.rgb + noise, 1.0); "#); }
CRT Scanlines
Classic CRT monitor effect:
#![allow(unused)] fn main() { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); let scanline_freq = 400.0; let scanline = sin(in.uv.y * scanline_freq) * 0.5 + 0.5; let scanline_intensity = 0.15; let result = color.rgb * (1.0 - scanline_intensity * (1.0 - scanline)); return vec4<f32>(result, 1.0); "#); }
Barrel Distortion
CRT-style curved screen:
#![allow(unused)] fn main() { v.post_process(r#" let center = vec2<f32>(0.5, 0.5); let uv_centered = in.uv - center; let dist_sq = dot(uv_centered, uv_centered); let barrel = 0.1; let distorted_uv = center + uv_centered * (1.0 + barrel * dist_sq); let color = textureSample(scene, scene_sampler, distorted_uv); return color; "#); }
Bloom (Simple)
Boost bright areas:
#![allow(unused)] fn main() { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); let luminance = dot(color.rgb, vec3<f32>(0.299, 0.587, 0.114)); let bloom = smoothstep(0.4, 1.0, luminance) * 0.4; return vec4<f32>(color.rgb + color.rgb * bloom, 1.0); "#); }
Color Grading
Adjust color balance:
#![allow(unused)] fn main() { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); // Warm tint (boost red, reduce blue) var graded = pow(color.rgb, vec3<f32>(0.95, 1.0, 1.05)); // Contrast boost graded = (graded - 0.5) * 1.1 + 0.5; // Saturation boost let gray = dot(graded, vec3<f32>(0.3, 0.3, 0.3)); graded = mix(vec3<f32>(gray), graded, 1.3); return vec4<f32>(clamp(graded, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0); "#); }
Screen Flicker
Subtle brightness variation:
#![allow(unused)] fn main() { v.post_process(r#" let color = textureSample(scene, scene_sampler, in.uv); let flicker = sin(uniforms.time * 60.0) * 0.02 + 1.0; return vec4<f32>(color.rgb * flicker, 1.0); "#); }
Combining Effects
Chain multiple effects together:
#![allow(unused)] fn main() { v.post_process(r#" let center = vec2<f32>(0.5, 0.5); var uv = in.uv; // 1. Barrel distortion let uv_centered = uv - center; let dist_sq = dot(uv_centered, uv_centered); uv = center + uv_centered * (1.0 + 0.1 * dist_sq); // 2. Chromatic aberration let aberr = 0.004; let r = textureSample(scene, scene_sampler, uv + vec2<f32>(aberr, 0.0)).r; let g = textureSample(scene, scene_sampler, uv).g; let b = textureSample(scene, scene_sampler, uv - vec2<f32>(aberr, 0.0)).b; var color = vec3<f32>(r, g, b); // 3. Scanlines let scanline = sin(in.uv.y * 400.0) * 0.5 + 0.5; color *= 1.0 - 0.1 * (1.0 - scanline); // 4. Vignette let vignette_dist = length(in.uv - center); let vignette = 1.0 - smoothstep(0.4, 1.0, vignette_dist); color *= vignette; // 5. Flicker let flicker = sin(uniforms.time * 60.0) * 0.01 + 1.0; color *= flicker; return vec4<f32>(color, 1.0); "#); }
Performance Tips
- Post-processing runs once per screen pixel
- Texture samples are relatively expensive
- Multiple samples (for blur) can add up quickly
- Keep blur kernel sizes small (4-8 samples)
Related
- Visual Configuration - Blend modes, trails, connections
- Fragment Shaders - Per-particle appearance
Custom Uniforms
Custom uniforms let you pass dynamic values from Rust to your shader code every frame. This enables interactive simulations that respond to time, mouse input, or any other runtime data.
Custom uniforms are available in all shader types:
- Compute shaders (
Rule::Custom) - for particle physics and behavior - Fragment shaders (
.with_fragment_shader()) - for per-particle visuals - Post-process shaders (
.post_process()) - for screen-space effects
Basic Usage
Define uniforms with .with_uniform() and access them in any shader:
#![allow(unused)] fn main() { Simulation::<Particle>::new() .with_uniform("target", Vec3::ZERO) .with_uniform("strength", 1.0f32) .with_rule(Rule::Custom(r#" let dir = uniforms.target - p.position; p.velocity += normalize(dir) * uniforms.strength * uniforms.delta_time; "#.into())) .run(); }
In your shader code, access uniforms via the uniforms struct:
uniforms.time- simulation time in seconds (built-in)uniforms.delta_time- time since last frame (built-in)uniforms.your_name- your custom uniforms
Supported Types
| Rust Type | WGSL Type | Example |
|---|---|---|
f32 | f32 | 1.0f32 |
i32 | i32 | -5i32 |
u32 | u32 | 10u32 |
Vec2 | vec2<f32> | Vec2::new(1.0, 2.0) |
Vec3 | vec3<f32> | Vec3::new(1.0, 2.0, 3.0) |
Vec4 | vec4<f32> | Vec4::new(1.0, 2.0, 3.0, 4.0) |
Updating Uniforms at Runtime
Use .with_update() to modify uniforms every frame:
#![allow(unused)] fn main() { Simulation::<Particle>::new() .with_uniform("attractor", Vec3::ZERO) .with_uniform("active", 0.0f32) .with_update(|ctx| { // Time-based animation let t = ctx.time(); ctx.set("attractor", Vec3::new(t.cos(), 0.0, t.sin())); // Mouse interaction if ctx.mouse_pressed() { ctx.set("active", 1.0f32); } else { ctx.set("active", 0.0f32); } }) .run(); }
UpdateContext API
The ctx parameter provides:
| Method | Returns | Description |
|---|---|---|
ctx.time() | f32 | Simulation time in seconds |
ctx.delta_time() | f32 | Time since last frame |
ctx.mouse_ndc() | Option<Vec2> | Mouse in normalized device coords (-1 to 1) |
ctx.mouse_pressed() | bool | Is left mouse button down? |
ctx.set(name, value) | - | Update a uniform value |
ctx.get(name) | Option<&UniformValue> | Read current uniform value |
Example: Mouse Attractor
Particles are attracted to the mouse when clicked:
#![allow(unused)] fn main() { Simulation::<Mote>::new() .with_particle_count(15_000) .with_spawner(|_, _| /* ... */) .with_uniform("attractor", Vec3::ZERO) .with_uniform("strength", 0.0f32) .with_update(|ctx| { if ctx.mouse_pressed() { if let Some(mouse) = ctx.mouse_ndc() { // Map NDC to world space (approximate) ctx.set("attractor", Vec3::new( mouse.x * 2.0, mouse.y * 2.0, 0.0 )); ctx.set("strength", 5.0f32); } } else { ctx.set("strength", 0.0f32); } }) .with_rule(Rule::Custom(r#" if uniforms.strength > 0.0 { let to_attractor = uniforms.attractor - p.position; let dist = length(to_attractor); if dist > 0.01 { let dir = to_attractor / dist; let force = uniforms.strength / (dist * dist + 0.5); p.velocity += dir * force * uniforms.delta_time; } } "#.into())) .with_rule(Rule::Drag(1.5)) .run(); }
Example: Pulsing Attractor
Automatic attraction/repulsion cycle:
#![allow(unused)] fn main() { .with_uniform("strength", 1.0f32) .with_update(|ctx| { let cycle = ctx.time() % 4.0; let strength = if cycle < 3.0 { 3.0 // Attract for 3 seconds } else { -5.0 // Repel for 1 second }; ctx.set("strength", strength); }) }
Tips
- Initialize all uniforms: Always set initial values with
.with_uniform()before using.with_update(). - Type suffixes: Use
1.0f32not1.0to ensure correct type inference. - NDC coordinates: Mouse NDC ranges from -1 to 1 on both axes, with Y up.
Custom Functions
Custom functions let you define reusable WGSL code that can be called from your rules. This keeps complex logic organized and avoids code duplication.
Defining Functions
Use .with_function() to add WGSL functions:
#![allow(unused)] fn main() { Simulation::<Particle>::new() .with_function(r#" fn swirl(pos: vec3<f32>, strength: f32) -> vec3<f32> { let d = length(pos.xz); return vec3(-pos.z, 0.0, pos.x) * strength / (d + 0.1); } "#) .with_rule(Rule::Custom(r#" p.velocity += swirl(p.position, 2.0) * uniforms.delta_time; "#.into())) .run(); }
Function Scope
Your custom functions have access to:
- All WGSL built-in functions (
sin,cos,length,normalize, etc.) - Built-in utility functions (see Shader Utilities)
- Other custom functions defined before this one
- The
Particlestruct type
They do not have direct access to:
- The
uniformsstruct (pass values as parameters instead) - The particle arrays (operate on values passed to the function)
Multiple Functions
Add multiple functions for complex effects:
#![allow(unused)] fn main() { .with_function(r#" fn wave_height(x: f32, z: f32, t: f32) -> f32 { return sin(x * 3.0 + t) * cos(z * 2.0 + t * 0.7) * 0.2; } "#) .with_function(r#" fn wave_force(pos: vec3<f32>, t: f32) -> vec3<f32> { let h = wave_height(pos.x, pos.z, t); let target_y = h; return vec3(0.0, (target_y - pos.y) * 2.0, 0.0); } "#) .with_rule(Rule::Custom(r#" p.velocity += wave_force(p.position, uniforms.time) * uniforms.delta_time; "#.into())) }
Example: Orbital Mechanics
#![allow(unused)] fn main() { .with_function(r#" fn gravity_force(pos: vec3<f32>, center: vec3<f32>, mass: f32) -> vec3<f32> { let diff = center - pos; let dist_sq = dot(diff, diff); if dist_sq < 0.01 { return vec3(0.0); } let dist = sqrt(dist_sq); return normalize(diff) * mass / dist_sq; } "#) .with_function(r#" fn orbital_velocity(pos: vec3<f32>, center: vec3<f32>, mass: f32) -> vec3<f32> { let r = length(pos - center); let speed = sqrt(mass / r); let radial = normalize(pos - center); // Perpendicular to radial, in XZ plane return vec3(-radial.z, 0.0, radial.x) * speed; } "#) }
Example: Turbulence
Combine custom functions with built-in noise:
#![allow(unused)] fn main() { .with_function(r#" fn turbulence(pos: vec3<f32>, time: f32, strength: f32) -> vec3<f32> { let scale = 2.0; let t = time * 0.5; return vec3( noise3(pos * scale + vec3(t, 0.0, 0.0)), noise3(pos * scale + vec3(0.0, t, 100.0)), noise3(pos * scale + vec3(100.0, 0.0, t)) ) * strength; } "#) .with_rule(Rule::Custom(r#" p.velocity += turbulence(p.position, uniforms.time, 1.5) * uniforms.delta_time; "#.into())) }
Tips
- Parameter passing: Pass uniforms as function parameters rather than accessing them directly.
- Return types: Always specify return types for WGSL functions.
- Order matters: Functions can only call functions defined before them.
- Keep it simple: Complex logic is fine, but avoid excessive branching for GPU performance.
Shader Utilities
RDPE includes built-in utility functions that are automatically available in all compute shaders. Use them in Rule::Custom or your custom functions.
Random & Hash Functions
Pseudo-random number generation based on integer hashing.
hash(n: u32) -> u32
Hash a u32 to a pseudo-random u32.
hash2(p: vec2<u32>) -> u32
Hash a 2D coordinate.
hash3(p: vec3<u32>) -> u32
Hash a 3D coordinate.
rand(seed: u32) -> f32
Returns a random float in the range [0, 1).
let r = rand(index * 12345u); // Different value per particle
rand_range(seed: u32, min_val: f32, max_val: f32) -> f32
Returns a random float in the specified range.
let speed = rand_range(index, 0.5, 2.0);
rand_vec3(seed: u32) -> vec3<f32>
Returns a random vector with components in [-1, 1]. Not normalized.
rand_sphere(seed: u32) -> vec3<f32>
Returns a random point on a unit sphere (normalized).
let direction = rand_sphere(index * 7u);
p.velocity = direction * 2.0;
Noise Functions
Gradient noise for smooth, natural-looking randomness.
noise2(p: vec2<f32>) -> f32
2D simplex noise. Returns values in [-1, 1].
noise3(p: vec3<f32>) -> f32
3D simplex noise. Returns values in [-1, 1].
// Noise-based force field
let force = vec3(
noise3(p.position * 2.0 + uniforms.time),
noise3(p.position * 2.0 + uniforms.time + vec3(100.0, 0.0, 0.0)),
noise3(p.position * 2.0 + uniforms.time + vec3(0.0, 100.0, 0.0))
);
p.velocity += force * uniforms.delta_time;
fbm2(p: vec2<f32>, octaves: i32) -> f32
2D fractal Brownian motion. Layered noise for more detail.
fbm3(p: vec3<f32>, octaves: i32) -> f32
3D fractal Brownian motion.
// More detailed noise with 4 octaves
let turbulence = fbm3(p.position * 1.5, 4);
Color Functions
Convert between color spaces.
hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32>
Convert HSV to RGB.
h: Hue [0, 1] (wraps)s: Saturation [0, 1]v: Value/brightness [0, 1]
// Rainbow based on particle position
let hue = (p.position.x + 1.0) * 0.5; // Map -1..1 to 0..1
p.color = hsv_to_rgb(hue, 0.8, 1.0);
rgb_to_hsv(rgb: vec3<f32>) -> vec3<f32>
Convert RGB to HSV. Returns vec3(h, s, v).
let hsv = rgb_to_hsv(p.color);
let new_hue = hsv.x + 0.1; // Shift hue
p.color = hsv_to_rgb(new_hue, hsv.y, hsv.z);
Complete Example
use rdpe::prelude::*; #[derive(Particle, Clone)] struct Mote { position: Vec3, velocity: Vec3, #[color] color: Vec3, } fn main() { Simulation::<Mote>::new() .with_particle_count(25_000) .with_spawner(|i, _| Mote { position: Vec3::new( rand::random::<f32>() * 2.0 - 1.0, rand::random::<f32>() * 2.0 - 1.0, rand::random::<f32>() * 2.0 - 1.0, ), velocity: Vec3::ZERO, color: Vec3::ONE, }) .with_rule(Rule::Custom(r#" // 3D noise force field let scale = 2.0; let t = uniforms.time * 0.3; let force = vec3<f32>( noise3(p.position * scale + vec3<f32>(t, 0.0, 0.0)), noise3(p.position * scale + vec3<f32>(0.0, t, 100.0)), noise3(p.position * scale + vec3<f32>(0.0, 100.0, t)) ); p.velocity += force * uniforms.delta_time * 2.0; // Color based on FBM noise let color_noise = fbm3(p.position * 1.5 + uniforms.time * 0.2, 3); let hue = (color_noise + 1.0) * 0.25 + 0.5; p.color = hsv_to_rgb(hue, 0.8, 1.0); "#.into())) .with_rule(Rule::Drag(1.0)) .with_rule(Rule::WrapWalls) .run(); }
Performance Notes
- Hash functions are very fast - use liberally
- Noise functions are moderately expensive - a few calls per particle is fine
- FBM multiplies the cost by the number of octaves
- For heavy noise use, consider lowering particle count or octaves
Egui Integration
RDPE supports egui for adding interactive UI controls to your simulations. This enables real-time parameter tuning, debug displays, and rich user interfaces.
Enabling Egui
Add the egui feature to your Cargo.toml:
[dependencies]
rdpe = { version = "0.1", features = ["egui"] }
Or run examples with:
cargo run --example egui_interactive --features egui
Basic Usage
Use .with_ui() to add an egui callback:
#![allow(unused)] fn main() { Simulation::<Particle>::new() // ... particle setup ... .with_ui(|ctx| { egui::Window::new("Controls") .show(ctx, |ui| { ui.label("Hello from egui!"); }); }) .run(); }
The callback receives an &egui::Context and runs every frame. You can create windows, panels, sliders, buttons, and any other egui widgets.
Connecting UI to Simulation
The real power comes from connecting UI controls to simulation parameters. This requires:
- Custom uniforms - GPU-side parameters the shader reads
- Update callback - Syncs Rust values to uniforms each frame
- Shared state - Connects UI to the update callback
The Pattern: Arc<Mutex>
Since both callbacks need access to the same state and must be Send, use Arc<Mutex<T>>:
use std::sync::{Arc, Mutex}; // Define your parameters struct SimState { gravity: f32, speed: f32, } impl Default for SimState { fn default() -> Self { Self { gravity: 0.5, speed: 1.0 } } } fn main() { // Create shared state let state = Arc::new(Mutex::new(SimState::default())); let ui_state = state.clone(); // Clone for UI callback let update_state = state.clone(); // Clone for update callback Simulation::<Particle>::new() // Declare uniforms (must match defaults!) .with_uniform::<f32>("gravity", 0.5) .with_uniform::<f32>("speed", 1.0) // UI callback - modifies shared state .with_ui(move |ctx| { let mut s = ui_state.lock().unwrap(); egui::Window::new("Controls").show(ctx, |ui| { ui.add(egui::Slider::new(&mut s.gravity, 0.0..=2.0).text("Gravity")); ui.add(egui::Slider::new(&mut s.speed, 0.1..=3.0).text("Speed")); }); }) // Update callback - syncs state to GPU uniforms .with_update(move |ctx| { let s = update_state.lock().unwrap(); ctx.set("gravity", s.gravity); ctx.set("speed", s.speed); }) // Shader reads uniforms .with_rule(Rule::Custom(r#" p.velocity.y -= uniforms.gravity * uniforms.delta_time; p.position += p.velocity * uniforms.delta_time * uniforms.speed; "#.into())) .run(); }
Flow Summary
┌─────────────┐ ┌───────────────────┐ ┌─────────────┐
│ Egui UI │────▶│ Arc<Mutex<State>>│────▶│ Uniforms │
│ (sliders) │ │ (shared state) │ │ (GPU) │
└─────────────┘ └───────────────────┘ └─────────────┘
│ │ │
│ .with_ui() │ .with_update() │ Rule::Custom
└─────────────────────┴──────────────────────┴─────────────────▶ Shader
Complete Example
Here's a full interactive simulation:
use rand::Rng; use rdpe::prelude::*; use std::sync::{Arc, Mutex}; #[derive(Particle, Clone)] struct Ball { position: Vec3, velocity: Vec3, #[color] color: Vec3, } struct SimState { gravity: f32, drag: f32, bounce: f32, } impl Default for SimState { fn default() -> Self { Self { gravity: 1.0, drag: 0.5, bounce: 0.8 } } } fn main() { let mut rng = rand::thread_rng(); let state = Arc::new(Mutex::new(SimState::default())); let ui_state = state.clone(); let update_state = state.clone(); let particles: Vec<_> = (0..5000) .map(|_| { let pos = Vec3::new( rng.gen_range(-0.8..0.8), rng.gen_range(0.0..0.8), rng.gen_range(-0.8..0.8), ); let vel = Vec3::ZERO; let hue = rng.gen_range(0.0..1.0); let color = Vec3::new(hue, 0.8, 1.0); // HSV-ish (pos, vel, color) }) .collect(); Simulation::<Ball>::new() .with_particle_count(5000) .with_bounds(1.0) .with_spawner(move |i, _| { let (pos, vel, color) = particles[i as usize]; Ball { position: pos, velocity: vel, color } }) .with_uniform::<f32>("gravity", 1.0) .with_uniform::<f32>("drag", 0.5) .with_uniform::<f32>("bounce", 0.8) .with_ui(move |ctx| { let mut s = ui_state.lock().unwrap(); egui::Window::new("Physics Controls") .default_pos([10.0, 10.0]) .show(ctx, |ui| { ui.heading("Parameters"); ui.add(egui::Slider::new(&mut s.gravity, 0.0..=5.0).text("Gravity")); ui.add(egui::Slider::new(&mut s.drag, 0.0..=2.0).text("Drag")); ui.add(egui::Slider::new(&mut s.bounce, 0.0..=1.0).text("Bounce")); ui.separator(); if ui.button("Reset").clicked() { *s = SimState::default(); } }); }) .with_update(move |ctx| { let s = update_state.lock().unwrap(); ctx.set("gravity", s.gravity); ctx.set("drag", s.drag); ctx.set("bounce", s.bounce); }) .with_rule(Rule::Custom(r#" let dt = uniforms.delta_time; // Gravity p.velocity.y -= uniforms.gravity * dt; // Drag p.velocity *= 1.0 - uniforms.drag * dt; // Integrate p.position += p.velocity * dt; // Floor bounce if p.position.y < -0.95 { p.position.y = -0.95; p.velocity.y = abs(p.velocity.y) * uniforms.bounce; } "#.into())) .with_rule(Rule::BounceWalls) .run(); }
Tips
Mutex Performance
Don't worry about Mutex overhead - both callbacks run on the main thread, so there's no contention. Lock/unlock is ~20ns, negligible at 60fps.
Initial Values Must Match
Always ensure .with_uniform() values match your Default implementation:
#![allow(unused)] fn main() { // These MUST match! .with_uniform::<f32>("gravity", 0.5) // Uniform default // ... impl Default for SimState { fn default() -> Self { Self { gravity: 0.5, ... } // State default } } }
Type Annotations
Use explicit type annotations for uniform values:
#![allow(unused)] fn main() { .with_uniform::<f32>("value", 1.0) // Good .with_uniform("value", 1.0f32) // Also good .with_uniform("value", 1.0) // May cause type inference issues }
Egui Widgets
Common egui widgets for simulations:
#![allow(unused)] fn main() { // Slider with range ui.add(egui::Slider::new(&mut value, 0.0..=10.0).text("Label")); // Checkbox ui.checkbox(&mut enabled, "Enable feature"); // Button if ui.button("Reset").clicked() { // handle click } // Color picker ui.color_edit_button_rgb(&mut color); // Collapsing section ui.collapsing("Advanced", |ui| { // nested widgets }); }
Window Positioning
#![allow(unused)] fn main() { egui::Window::new("Title") .default_pos([10.0, 10.0]) // Initial position .resizable(false) // Fixed size .collapsible(true) // Can collapse .show(ctx, |ui| { ... }); }
Examples
Run the interactive examples:
# Basic UI demo (controls don't affect simulation)
cargo run --example egui_controls --features egui
# Full interactive controls
cargo run --example egui_interactive --features egui
# Creative examples with egui
cargo run --example plasma_storm --features egui
cargo run --example fluid_galaxy --features egui
cargo run --example murmuration --features egui
Performance Tips
RDPE runs on the GPU, but performance still varies based on configuration.
Particle Count
The GPU handles particles in parallel, but performance depends on what you're simulating:
| Scenario | Particles | Typical FPS |
|---|---|---|
| No neighbors (gravity, drag, etc.) | 500,000 | 60+ |
| Full boids (separate, cohere, align) | 50,000 | 20+ |
| Spatial fields | 100,000 | 30+ |
Tips
- Start with fewer particles, increase until performance drops
- Integrated GPUs handle fewer particles than discrete GPUs
- Debug builds are slower; use
--releasefor real performance
cargo run --example boids --release
Spatial Hashing
Neighbor rules trigger spatial hashing every frame.
Cell Size
Match cell size to your largest interaction radius:
#![allow(unused)] fn main() { // If largest radius is 0.15: .with_spatial_config(0.15, 32) // Good .with_spatial_config(0.05, 32) // Bad: checking 27 cells when 1 would do .with_spatial_config(0.5, 32) // Bad: too many particles per cell }
Grid Resolution
Higher resolution = more cells = more memory, but potentially fewer particles per cell:
#![allow(unused)] fn main() { .with_spatial_config(0.1, 32) // 32,768 cells - usually enough .with_spatial_config(0.1, 64) // 262,144 cells - for very spread simulations .with_spatial_config(0.1, 128) // 2,097,152 cells - rarely needed }
When Spatial Hashing Helps
- Many particles, small interaction radius - Huge win
- Few particles - Overhead may not be worth it
- Large interaction radius - Less benefit (checking many neighbors anyway)
Max Neighbors Limit
In dense clusters, particles may have hundreds of neighbors. Cap the iteration:
#![allow(unused)] fn main() { .with_max_neighbors(48) // Stop after processing 48 neighbors }
This trades some accuracy for a significant performance boost (2x or more in pathological cases). Values of 32-64 work well for most simulations.
Rule Complexity
Simple Rules (Fast)
#![allow(unused)] fn main() { Rule::Gravity(9.8) // Single operation Rule::Drag(1.0) // Single multiply Rule::BounceWalls // Few conditionals }
Neighbor Rules (Slower)
#![allow(unused)] fn main() { Rule::Separate { ... } // Loops over neighbors Rule::Cohere { ... } // Accumulates, then applies Rule::Collide { ... } // Distance checks per neighbor }
Typed Rules
Add conditional checks per neighbor:
#![allow(unused)] fn main() { Rule::Typed { self_type: 0, other_type: Some(1), rule: Box::new(Rule::Separate { ... }), } }
Each Typed wrapper adds 1-2 comparisons per neighbor.
Reducing Work
Combine Similar Rules
Instead of:
#![allow(unused)] fn main() { .with_rule(Rule::Typed { self_type: 0, other_type: Some(0), rule: ... }) .with_rule(Rule::Typed { self_type: 0, other_type: Some(1), rule: ... }) .with_rule(Rule::Typed { self_type: 0, other_type: Some(2), rule: ... }) }
Consider if other_type: None works:
#![allow(unused)] fn main() { .with_rule(Rule::Typed { self_type: 0, other_type: None, rule: ... }) }
Limit Interaction Radius
Smaller radius = fewer neighbors checked:
#![allow(unused)] fn main() { // More neighbors to check: Rule::Separate { radius: 0.2, strength: 1.0 } // Fewer neighbors: Rule::Separate { radius: 0.05, strength: 4.0 } // Compensate with strength }
Reduce Particle Count for Complex Interactions
If you have many typed rules:
#![allow(unused)] fn main() { // 5 types × 5 types = 25 potential interaction pairs // Maybe 10,000 particles is enough instead of 50,000 }
Custom Rule Performance
Avoid Expensive Operations
// Expensive:
let dist = length(some_vector); // Square root
// Cheaper (when comparing distances):
let dist_sq = dot(some_vector, some_vector);
if dist_sq < radius * radius { ... }
Minimize Conditionals
// Many branches:
if p.particle_type == 0u { ... }
else if p.particle_type == 1u { ... }
else if p.particle_type == 2u { ... }
// Consider: can you restructure to avoid this?
Profiling
Frame Time
Watch for dropped frames. Target: 16.6ms for 60 FPS.
Identify Bottlenecks
- Remove neighbor rules - does it speed up significantly?
- Reduce particle count - linear slowdown or worse?
- Remove
Typedwrappers - any difference?
GPU vs CPU
RDPE is GPU-bound. CPU does:
- Window event handling
- Uniform updates
- Command submission
These are typically not bottlenecks.
Hardware Considerations
Discrete GPU
Best performance. RDPE uses wgpu which supports:
- Vulkan (Linux, Windows)
- Metal (macOS)
- DX12 (Windows)
Integrated GPU
Works but with lower particle limits. Intel UHD, AMD APUs, Apple Silicon all supported.
Power Settings
Laptops may throttle GPU. Ensure:
- Plugged in (or high-performance mode)
- Not thermal throttling
Examples
RDPE includes many examples demonstrating different features. Each example contains detailed comments explaining the concepts it demonstrates.
Running Examples
cargo run --example <name>
# With egui feature (for interactive examples)
cargo run --example <name> --features egui
Core Examples
These showcase fundamental RDPE capabilities:
| Example | Description |
|---|---|
boids | Classic flocking algorithm (Separate + Cohere + Align) |
aquarium | Fish and sharks with Chase/Evade behaviors |
predator_prey | Chase and evade behaviors between particle types |
infection | SIR epidemic model with type conversion |
connections | Drawing lines between nearby particles |
inbox | Particle-to-particle communication system |
slime_mold | Physarum-inspired emergent patterns (requires --features egui) |
slime_mold_field | Slime mold using 3D spatial fields (requires --features egui) |
multi_field | Multiple competing pheromone fields |
Effect Examples
| Example | Description |
|---|---|
explosion | Burst emitter with particle effects |
fountain | Cone emitter shooting upward |
rain | Box emitter simulating rainfall |
shockwave | Expanding shockwave and pulse effects |
trails | Particle motion trails |
fireflies | Synchronized blinking behavior |
falling_leaves | Organic falling motion |
Physics & Motion
| Example | Description |
|---|---|
swirl | Vortex-like circular motion |
noisy | Noise-based movement |
wave_field | Wave-like oscillating motion |
attractor | Point attraction |
orbiting_attractor | Orbiting gravity source |
gravity_visualizer | Gravity field visualization |
density_fluids | SPH-style fluid simulation |
particle_life | Particle Life cellular automaton |
Visual Examples
| Example | Description |
|---|---|
shapes | All available particle shapes (Circle, Star, Hexagon, etc.) |
custom_shader | Custom fragment shader for particle appearance |
post_process | Screen-space post-processing effects |
texture_example | Custom texture sampling in shaders |
palettes | Built-in color palettes and mappings |
glow | Glowing particle effects |
sphere_shell | Spherical particle distribution |
Interactive Examples
These require --features egui:
| Example | Description |
|---|---|
egui_controls | Basic egui integration |
egui_interactive | Full interactive parameter control |
slime_mold | Physarum simulation with controls |
Other Examples
| Example | Description |
|---|---|
getting_started | Minimal example to get started |
input_demo | Keyboard and mouse input handling |
lifecycle_demo | Particle aging, death, and respawning |
spatial_grid_demo | Spatial hashing visualization |
agent_demo | Particles as autonomous agents |
signal_swarm | Swarm signaling behavior |
neural_network | Neural network-style visualization |
chemistry | Chemical reaction simulation |
cells | Cell-like behavior |
rocket | Rocket with exhaust particles |
volume_render | 3D volume rendering |
Learning Path
1. Start with Basics
getting_started- Minimal setupboids- Core particle simulation
2. Explore Interactions
predator_prey- Typed particlesinfection- Type conversionaquarium- Chase and evade
3. Add Effects
explosion- Emitterstrails- Motion trailsconnections- Visual connections
4. Customize Visuals
custom_shader- Fragment shaderspost_process- Screen effectspalettes- Color schemes
5. Add Interactivity
egui_controls- Basic UIslime_mold- Full interactive example
Running Examples
# Core
cargo run --example boids
cargo run --example aquarium
cargo run --example predator_prey
cargo run --example infection
# Effects
cargo run --example explosion
cargo run --example fountain
cargo run --example shockwave
cargo run --example trails
# Visual
cargo run --example shapes
cargo run --example custom_shader
cargo run --example post_process
cargo run --example palettes
# Interactive (requires egui feature)
cargo run --example slime_mold --features egui
cargo run --example egui_interactive --features egui
Each example file contains //! doc comments explaining what it demonstrates and suggestions for experimentation.