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 playto see them in action — headlessflint rendercaptures 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:
| Shape | Fields | Description |
|---|---|---|
point | (none) | All particles spawn at the emitter origin |
sphere | shape_radius | Random position within a sphere |
cone | shape_angle, shape_radius | Particles emit in a cone around direction |
box | shape_extents, shape_axis_u, shape_axis_v | Random 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
| Mode | Use Case | Description |
|---|---|---|
alpha | Smoke, dust, fog | Standard alpha blending — particles fade naturally |
additive | Fire, sparks, magic | Colors 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. Setcolor_endalpha 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
| Field | Type | Default | Description |
|---|---|---|---|
emission_rate | f32 | 10.0 | Particles per second (0 = burst-only) |
burst_count | i32 | 0 | Particles fired on each burst/loop start |
max_particles | i32 | 256 | Pool capacity (max 10,000) |
lifetime_min | f32 | 1.0 | Minimum particle lifetime in seconds |
lifetime_max | f32 | 2.0 | Maximum particle lifetime in seconds |
speed_min | f32 | 1.0 | Minimum initial speed |
speed_max | f32 | 3.0 | Maximum initial speed |
direction | vec3 | [0,1,0] | Base emission direction (local space) |
spread | f32 | 15.0 | Random deviation angle in degrees |
gravity | vec3 | [0,-9.81,0] | Acceleration applied per frame (world space) |
damping | f32 | 0.0 | Velocity decay per second |
size_start | f32 | 0.1 | Particle size at birth |
size_end | f32 | 0.0 | Particle size at death |
color_start | vec4 | [1,1,1,1] | RGBA color at birth |
color_end | vec4 | [1,1,1,0] | RGBA color at death |
texture | string | “” | Sprite texture (empty = white dot) |
stretch | f32 | 0.0 | Velocity-aligned billboard stretch, in seconds |
frames_x | i32 | 1 | Sprite sheet columns |
frames_y | i32 | 1 | Sprite sheet rows |
animate_frames | bool | false | Auto-advance frames over lifetime |
blend_mode | string | “alpha” | "alpha" or "additive" |
shape | string | “point” | "point", "sphere", "cone", "box" |
shape_radius | f32 | 0.5 | Radius for sphere/cone shapes |
shape_angle | f32 | 30.0 | Half-angle for cone shape (degrees) |
shape_extents | vec3 | [0.5,0.5,0.5] | Half-extents for box shape |
shape_offset | vec3 | [0,0,0] | Spawn-region translation relative to the emitter position |
shape_axis_u | vec3 | [0,0,0] | Box orientation: extents.x runs along this world direction (zero = axis-aligned) |
shape_axis_v | vec3 | [0,0,0] | Box orientation: extents.y along this direction, extents.z along u x v (zero = axis-aligned) |
world_space | bool | true | true: 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) |
duration | f32 | 0.0 | Emitter duration (0 = infinite) |
looping | bool | true | Loop when duration expires |
playing | bool | false | Current playback state |
autoplay | bool | true | Start 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:
| Function | Description |
|---|---|
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_emittercomponents to the simulation, auto-discovers new emitters each frame - ParticleSystem — top-level
RuntimeSystemthat ticks simulation inupdate()(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 × lifetimeis roughly how many are alive. The example is ~4800, under the 6000 cap. - Slant the fall by tilting the
gravityvector rather than by giving particles horizontal velocity — it stays coherent as gusts change. - Particles are unlit. Dim
color_start/color_endto 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