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

Particles

Flint’s particle system provides GPU-instanced visual effects through the flint-particles crate. Fire, smoke, sparks, dust motes, magic effects — any volumetric visual that needs hundreds or thousands of small, short-lived elements.

Note: Particle effects are dynamic simulations that accumulate over time. Use flint play to see them in action — headless flint render captures a single frame and won’t show accumulated particles.

How It Works

Each entity with a particle_emitter component owns a pool of particles simulated on the CPU and rendered as camera-facing quads via GPU instancing. The pipeline is:

TOML component                CPU simulation              GPU rendering
particle_emitter   ──►  ParticleSync reads config  ──►  ParticlePipeline
  emission_rate         spawn/integrate/kill             instanced draw
  gravity               pack into instance buffer        storage buffer
  color_start/end       (swap-remove pool)               alpha or additive

Unlike billboard sprites (which are individual ECS entities), particles are pooled per-emitter — a single entity can own thousands of particles without overwhelming the ECS.

Adding Particles to a Scene

Add a particle_emitter component to any entity:

[entities.campfire]
[entities.campfire.transform]
position = [0, 0.2, 0]

[entities.campfire.particle_emitter]
emission_rate = 40.0
max_particles = 200
lifetime_min = 0.3
lifetime_max = 0.8
speed_min = 1.5
speed_max = 3.0
direction = [0, 1, 0]
spread = 20.0
gravity = [0, 2.0, 0]
size_start = 0.15
size_end = 0.02
color_start = [1.0, 0.7, 0.1, 0.9]
color_end = [1.0, 0.1, 0.0, 0.0]
blend_mode = "additive"
shape = "sphere"
shape_radius = 0.15
autoplay = true

Emission Shapes

The shape field controls where new particles spawn relative to the emitter:

ShapeFieldsDescription
point(none)All particles spawn at the emitter origin
sphereshape_radiusRandom position within a sphere
coneshape_angle, shape_radiusParticles emit in a cone around direction
boxshape_extents, shape_axis_u, shape_axis_vRandom position within a box. Axis-aligned by default; give shape_axis_u and shape_axis_v world directions to orient it (see below)

Every shape is translated by shape_offset before the emitter position is applied, so a trail can spawn a little behind the entity that owns it.

Oriented Boxes

A box emitter can be rotated without rotating the entity (ADR 0061, prologue flight trails). shape_extents.x runs along shape_axis_u, shape_extents.y along shape_axis_v, and shape_extents.z along their cross product. The two axes are normalised and orthogonalised for you; if either is zero, or they are parallel, the box falls back to axis-aligned. Because both axes are live fields, a script can point the spawn slab along an entity’s lateral motion every frame:

fn on_update() {
    let me = self_entity();
    let p = get_position(me);
    let last = get_field(me, "trail_state", "last_pos");   // your own component
    let dx = p.x - last.x;
    let dz = p.z - last.z;
    if dx * dx + dz * dz > 0.0001 {
        set_field(me, "particle_emitter", "shape_axis_u", [dx, 0.0, dz]);
    }
    set_field(me, "trail_state", "last_pos", #{ x: p.x, y: p.y, z: p.z });
}

Blend Modes

ModeUse CaseDescription
alphaSmoke, dust, fogStandard alpha blending — particles fade naturally
additiveFire, sparks, magicColors add together — bright, glowing effects

Additive blending is order-independent, making it ideal for dense effects. Alpha blending looks best for soft, diffuse effects.

Value Over Lifetime

Particles interpolate linearly between start and end values over their lifetime:

  • size_start / size_end — particles can grow (smoke expanding) or shrink (sparks dying)
  • color_start / color_end — RGBA transition. Set color_end alpha to 0 for fade-out

Sprite Sheet Animation

For textured particles (flame sprites, explosion frames), use sprite sheets:

[entities.explosion.particle_emitter]
texture = "explosion_sheet.png"
frames_x = 4
frames_y = 4
animate_frames = true   # Auto-advance frames over particle lifetime

With animate_frames = true, each particle plays through the sprite sheet from birth to death.

Bursts and Duration

For one-shot effects (explosions, impacts), combine bursts with limited duration:

[entities.explosion.particle_emitter]
emission_rate = 0.0      # No continuous emission
burst_count = 50         # 50 particles on each burst
duration = 0.5           # Emitter runs for 0.5 seconds
looping = false          # Don't repeat
autoplay = true          # Fire immediately

For periodic bursts (fountain, heartbeat), set looping = true with a duration.

Component Schema

FieldTypeDefaultDescription
emission_ratef3210.0Particles per second (0 = burst-only)
burst_counti320Particles fired on each burst/loop start
max_particlesi32256Pool capacity (max 10,000)
lifetime_minf321.0Minimum particle lifetime in seconds
lifetime_maxf322.0Maximum particle lifetime in seconds
speed_minf321.0Minimum initial speed
speed_maxf323.0Maximum initial speed
directionvec3[0,1,0]Base emission direction (local space)
spreadf3215.0Random deviation angle in degrees
gravityvec3[0,-9.81,0]Acceleration applied per frame (world space)
dampingf320.0Velocity decay per second
size_startf320.1Particle size at birth
size_endf320.0Particle size at death
color_startvec4[1,1,1,1]RGBA color at birth
color_endvec4[1,1,1,0]RGBA color at death
texturestring“”Sprite texture (empty = white dot)
stretchf320.0Velocity-aligned billboard stretch, in seconds
frames_xi321Sprite sheet columns
frames_yi321Sprite sheet rows
animate_framesboolfalseAuto-advance frames over lifetime
blend_modestring“alpha”"alpha" or "additive"
shapestring“point”"point", "sphere", "cone", "box"
shape_radiusf320.5Radius for sphere/cone shapes
shape_anglef3230.0Half-angle for cone shape (degrees)
shape_extentsvec3[0.5,0.5,0.5]Half-extents for box shape
shape_offsetvec3[0,0,0]Spawn-region translation relative to the emitter position
shape_axis_uvec3[0,0,0]Box orientation: extents.x runs along this world direction (zero = axis-aligned)
shape_axis_vvec3[0,0,0]Box orientation: extents.y along this direction, extents.z along u x v (zero = axis-aligned)
world_spacebooltruetrue: particles detach and stay where they were born. false: particles are simulated relative to the emitter and ride with it (resolved to world space when the instance buffer is packed)
durationf320.0Emitter duration (0 = infinite)
loopingbooltrueLoop when duration expires
playingboolfalseCurrent playback state
autoplaybooltrueStart emitting on scene load

Live Fields

The particle system re-reads the component every frame, so a script can retune a running emitter with set_field. These fields take effect on the next frame without restarting the emitter:

emission_rate, gravity, damping, size_start, size_end, color_start, color_end, blend_mode, texture, direction, spread, speed_min, speed_max, lifetime_min, lifetime_max, stretch, shape, shape_offset, shape_axis_u, shape_axis_v.

Everything else (max_particles, the sprite-sheet layout, duration, looping, world_space) is read when the emitter is created. Changing playing or autoplay starts or stops it, and a start resets the emitter clock.

Scripting Integration

Particles can be controlled from Rhai scripts:

FunctionDescription
emit_burst(entity_id, count)Fire N particles immediately
start_emitter(entity_id)Start continuous emission
stop_emitter(entity_id)Stop emission (existing particles finish)
set_emission_rate(entity_id, rate)Change emission rate dynamically
#![allow(unused)]
fn main() {
// Rhai script: burst of sparks on impact
fn on_collision() {
    let me = self_entity();
    emit_burst(me, 30);
}

// Rhai script: toggle emitter with interaction
fn on_interact() {
    let me = self_entity();
    let playing = get_field(me, "particle_emitter", "playing");
    if playing {
        stop_emitter(me);
    } else {
        start_emitter(me);
    }
}
}

Architecture

  • ParticlePool — swap-remove array for O(1) particle death, contiguous alive iteration
  • ParticleSync — bridges ECS particle_emitter components to the simulation, auto-discovers new emitters each frame
  • ParticleSystem — top-level RuntimeSystem that ticks simulation in update() (variable-rate, not fixed-step)
  • ParticlePipeline — wgpu render pipeline with alpha and additive variants, storage buffer for instances

The particle system runs after animation (emitter transforms may be animated) and before the renderer refresh. Instance data is packed contiguously and uploaded to a GPU storage buffer for efficient instanced drawing.

Recipes

Fire

emission_rate = 40.0
gravity = [0, 2.0, 0]
color_start = [1.0, 0.7, 0.1, 0.9]
color_end = [1.0, 0.1, 0.0, 0.0]
blend_mode = "additive"
shape = "sphere"
shape_radius = 0.15

Smoke

emission_rate = 8.0
gravity = [0, 0.5, 0]
damping = 0.3
size_start = 0.1
size_end = 0.6
color_start = [0.4, 0.4, 0.4, 0.3]
color_end = [0.6, 0.6, 0.6, 0.0]
blend_mode = "alpha"

Sparks

emission_rate = 15.0
speed_min = 3.0
speed_max = 6.0
spread = 45.0
gravity = [0, -9.81, 0]
size_start = 0.03
size_end = 0.01
color_start = [1.0, 0.9, 0.3, 1.0]
color_end = [1.0, 0.3, 0.0, 0.0]
blend_mode = "additive"

Dust Motes

emission_rate = 5.0
speed_min = 0.05
speed_max = 0.2
spread = 180.0
gravity = [0, 0.02, 0]
damping = 0.5
size_start = 0.02
size_end = 0.02
color_start = [1.0, 1.0, 0.9, 0.5]
color_end = [1.0, 1.0, 0.9, 0.0]
shape = "box"
shape_extents = [2.0, 1.0, 2.0]

Rain

Rain is the case stretch exists for. A camera-facing quad makes rain look like falling confetti; a velocity-aligned one makes it look like rain.

emission_rate = 4000.0
lifetime = 1.2
gravity = [0.6, -14.0, 0.0]      # tilt X/Z to slant with the wind
speed_min = 0.0
speed_max = 0.5
size_start = 0.012
size_end = 0.012
color_start = [0.72, 0.78, 0.86, 0.5]
color_end = [0.72, 0.78, 0.86, 0.0]
texture = "textures/rain_drop.png"
stretch = 0.03                   # elongate along on-screen motion
shape = "box"
shape_extents = [14.0, 0.5, 14.0]
world_space = true

stretch is in seconds: the quad elongates along its on-screen motion by |velocity| * stretch, so faster drops draw longer streaks with no extra authoring. Keep it small — 0.03 already reads as heavy rain, and large values smear particles into ribbons.

Practical notes:

  • Attach the emitter to something that follows the camera and reposition it each frame. Rain only needs to exist where it can be seen.
  • Budget properly: emission_rate × lifetime is roughly how many are alive. The example is ~4800, under the 6000 cap.
  • Slant the fall by tilting the gravity vector rather than by giving particles horizontal velocity — it stays coherent as gusts change.
  • Particles are unlit. Dim color_start/color_end to match your scene’s light, or night rain will glow.

Particle textures are resolved against the scene directory first, then its parent, and are loaded at scene load.

Further Reading

  • Scripting — full scripting API including particle functions
  • Animation — animate emitter transforms with property tweens
  • Rendering — the GPU pipeline that draws particles
  • Physics and Runtime — the game loop that drives particle simulation