Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Declarative Rules - Describe what should happen, not how. Rules like Gravity, Separate, and Cohere express intent clearly.

  2. Composability - Rules combine freely. Wrap any rule with Typed for type-specific interactions. Use Custom for anything not built-in.

  3. 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

  1. You define a particle struct with #[derive(Particle)]
  2. You configure a simulation with rules
  3. RDPE generates GPU shaders from your rules
  4. The simulation runs entirely on the GPU
  5. 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 BoidGpu struct 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:

  1. Detects if any rules need neighbor queries
  2. Generates appropriate WGSL compute shaders
  3. Generates render shaders
  4. Spawns particles using your spawner function
  5. Initializes GPU state and runs the event loop

3. Shader Generation

Rules are translated to WGSL code:

RuleGenerated Code Location
Gravity, Drag, BounceWallsMain compute shader body
Separate, Cohere, CollideInside 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:

  1. Morton encoding - Convert 3D position to 1D cell index
  2. Radix sort - Sort particles by cell (dynamic passes based on grid resolution)
  3. 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 TypeWGSL TypeSizeAlignment
Vec3vec3<f32>12 bytes16 bytes
Vec2vec2<f32>8 bytes8 bytes
Vec4vec4<f32>16 bytes16 bytes
f32f324 bytes4 bytes
u32u324 bytes4 bytes
i32i324 bytes4 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:

FieldTypeDefaultPurpose
particle_typeu320Type identifier for typed interactions
agef320.0Time since spawn (updated by Rule::Age)
aliveu3211 = alive, 0 = dead (set by Rule::Lifetime)
scalef321.0Per-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 radius
  • grid_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)
}
ShapeBest For
CircleGeneral purpose, soft edges
CircleHardSharp particles, dots
SquarePixels, grid-based simulations
RingBubbles, force fields
StarMagic effects, sparkles
TriangleArrows, directional particles
HexagonCells, tiles, molecules
DiamondCrystals, gems
PointMaximum 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();
}

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:

  1. Finds dead particles (those with alive == 0)
  2. Respawns them based on the emitter's rate and position
  3. Sets initial velocity according to the emitter type

Emitters work best with lifecycle rules:

  • Rule::Age - increments particle age each frame
  • Rule::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 cone
  • PI/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

MethodDescription
.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

ScenarioTypesInteractions
Predator-PreyPredator, PreyChase/Evade rules
InfectionHealthy, Infected, RecoveredConvert rules for spread
Charged ParticlesPositive, NegativeOpposites attract, same repels
Food ChainPlant, Herbivore, CarnivoreEach level hunts the one below
TeamsTeam A, Team BSame team coheres, enemies separate
Life StagesYoung, Adult, ElderConvert 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:

MethodReturns 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:

MethodReturns 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:

MethodReturns
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_pressed and mouse_pressed only return true for 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.time and uniforms.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

MethodReturnsDescription
new()TimeCreate a new time tracker
update()(f32, f32)Update and return (elapsed, delta)
elapsed()f32Total elapsed seconds
delta()f32Seconds since last frame
frame()u64Total frame count
fps()f32Calculated FPS
is_paused()boolWhether time is paused
time_scale()f32Current 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 ConceptDescription
MemoryState that persists across frames
PerceptionWhat the agent can sense
RelationshipsConnections to other agents
BehaviorsDecision-making and actions
CommunicationInformation 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

  1. State machines are cheap - Integer comparisons are fast
  2. Memory fields add bandwidth - Each field increases particle size
  3. Neighbor perception is expensive - Spatial queries dominate cost
  4. Direct access is fast - particles[index] is a single read
  5. Fields are moderate - 3D texture lookups have some cost

Summary

RDPE particles are agents when you use them as agents:

Agent NeedRDPE Solution
MemoryParticle fields
Local perceptionNeighbor queries
Global perceptionFields
Specific knowledgeDirect buffer access
Physical bondsRule::BondSprings
Type relationshipsInteraction matrix
DecisionsCustom rules with conditionals
Direct messagesInboxes
BroadcastWritable 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:

  1. Standalone structs - Boid and Predator as separate types, each implementing ParticleTrait
  2. Type constants - Creature::BOID and Creature::PREDATOR for use in typed rules
  3. Unified GPU struct - A combined struct with all fields for the GPU
  4. 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: Vec3 and velocity: 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_id is set from the boid's value
  • hunger and target_id are zeroed
  • particle_type is set to 0 (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 CaseApproach
Single particle typeRegular #[derive(Particle)]
Multiple types, same fieldsParticleType 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:

ParticlesNaive ComparisonsWith Spatial Hash
1,0001,000,000~50,000
10,000100,000,000~500,000
100,00010,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:

ResolutionCell SizeCoverage
320.1-1.6 to +1.6
640.1-3.2 to +3.2
320.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::Separate
  • Rule::Cohere
  • Rule::Align
  • Rule::Collide
  • Rule::Convert
  • Rule::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:

BufferSize
Morton codes (×2)4 bytes × particles × 2
Particle indices (×2)4 bytes × particles × 2
Cell start4 bytes × grid_resolution³
Cell end4 bytes × grid_resolution³
Histogram64 bytes

For 10,000 particles with 32³ grid:

  • Morton/indices: 160 KB
  • Cell tables: 256 KB
  • Total: ~416 KB

Performance Tips

  1. Match cell size to interaction radius - Too small wastes work checking empty cells; too large checks too many particles per cell.

  2. Don't over-resolve - 32³ is usually enough. 64³ only helps if particles are very spread out.

  3. Spatial hash runs every frame - It's fast, but the cost is proportional to particle count.

  4. 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:

  1. Start simple - Add one line at a time
  2. Check types - WGSL is strictly typed
  3. Use color - Set p.color to visualize values
  4. 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:

VariableTypeDescription
in.uvvec2<f32>Position within particle quad (-1 to 1, center is 0)
in.colorvec3<f32>Particle's color (from #[color] field)
uniforms.timef32Seconds since simulation start
uniforms.delta_timef32Seconds since last frame
uniforms.*variesAny 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.uv ranges from -1 to 1
  • length(in.uv) = distance from center (0 at center, 1 at edge, >1 at corners)
  • Use in.uv * 0.5 + 0.5 to 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);
"#)
}

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 itself
  • tex_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();
}

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

VariableTypeDescription
in.uvvec2<f32>Screen coordinates (0 to 1, top-left is origin)
scenetexture_2d<f32>The rendered particle scene
scene_samplersamplerSampler for the scene texture
uniforms.timef32Seconds since simulation start
uniforms.delta_timef32Seconds since last frame
uniforms.*variesAny 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)

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 TypeWGSL TypeExample
f32f321.0f32
i32i32-5i32
u32u3210u32
Vec2vec2<f32>Vec2::new(1.0, 2.0)
Vec3vec3<f32>Vec3::new(1.0, 2.0, 3.0)
Vec4vec4<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:

MethodReturnsDescription
ctx.time()f32Simulation time in seconds
ctx.delta_time()f32Time since last frame
ctx.mouse_ndc()Option<Vec2>Mouse in normalized device coords (-1 to 1)
ctx.mouse_pressed()boolIs 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.0f32 not 1.0 to 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 Particle struct type

They do not have direct access to:

  • The uniforms struct (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:

  1. Custom uniforms - GPU-side parameters the shader reads
  2. Update callback - Syncs Rust values to uniforms each frame
  3. 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:

ScenarioParticlesTypical FPS
No neighbors (gravity, drag, etc.)500,00060+
Full boids (separate, cohere, align)50,00020+
Spatial fields100,00030+

Tips

  • Start with fewer particles, increase until performance drops
  • Integrated GPUs handle fewer particles than discrete GPUs
  • Debug builds are slower; use --release for 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

  1. Remove neighbor rules - does it speed up significantly?
  2. Reduce particle count - linear slowdown or worse?
  3. Remove Typed wrappers - 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:

ExampleDescription
boidsClassic flocking algorithm (Separate + Cohere + Align)
aquariumFish and sharks with Chase/Evade behaviors
predator_preyChase and evade behaviors between particle types
infectionSIR epidemic model with type conversion
connectionsDrawing lines between nearby particles
inboxParticle-to-particle communication system
slime_moldPhysarum-inspired emergent patterns (requires --features egui)
slime_mold_fieldSlime mold using 3D spatial fields (requires --features egui)
multi_fieldMultiple competing pheromone fields

Effect Examples

ExampleDescription
explosionBurst emitter with particle effects
fountainCone emitter shooting upward
rainBox emitter simulating rainfall
shockwaveExpanding shockwave and pulse effects
trailsParticle motion trails
firefliesSynchronized blinking behavior
falling_leavesOrganic falling motion

Physics & Motion

ExampleDescription
swirlVortex-like circular motion
noisyNoise-based movement
wave_fieldWave-like oscillating motion
attractorPoint attraction
orbiting_attractorOrbiting gravity source
gravity_visualizerGravity field visualization
density_fluidsSPH-style fluid simulation
particle_lifeParticle Life cellular automaton

Visual Examples

ExampleDescription
shapesAll available particle shapes (Circle, Star, Hexagon, etc.)
custom_shaderCustom fragment shader for particle appearance
post_processScreen-space post-processing effects
texture_exampleCustom texture sampling in shaders
palettesBuilt-in color palettes and mappings
glowGlowing particle effects
sphere_shellSpherical particle distribution

Interactive Examples

These require --features egui:

ExampleDescription
egui_controlsBasic egui integration
egui_interactiveFull interactive parameter control
slime_moldPhysarum simulation with controls

Other Examples

ExampleDescription
getting_startedMinimal example to get started
input_demoKeyboard and mouse input handling
lifecycle_demoParticle aging, death, and respawning
spatial_grid_demoSpatial hashing visualization
agent_demoParticles as autonomous agents
signal_swarmSwarm signaling behavior
neural_networkNeural network-style visualization
chemistryChemical reaction simulation
cellsCell-like behavior
rocketRocket with exhaust particles
volume_render3D volume rendering

Learning Path

1. Start with Basics

  • getting_started - Minimal setup
  • boids - Core particle simulation

2. Explore Interactions

  • predator_prey - Typed particles
  • infection - Type conversion
  • aquarium - Chase and evade

3. Add Effects

  • explosion - Emitters
  • trails - Motion trails
  • connections - Visual connections

4. Customize Visuals

  • custom_shader - Fragment shaders
  • post_process - Screen effects
  • palettes - Color schemes

5. Add Interactivity

  • egui_controls - Basic UI
  • slime_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.