Skip to content

Particle effects: smoke, sparks, fire and more

kke::ParticleEffects (kke/ParticleEffects.h) is for the smoke and sparks of action: tyre smoke from a burnout or a slide, a cloud from a wrecked engine, dust, sparks off metal scraping a wall. A few thousand particles at most, simulated on the CPU (position, velocity, drag, rise, growth, fade) and drawn as camera-facing quads:

  • Smoke is soft, lit by the sun (with its shadow) and the sky, and sorted back to front in every view, so thick smoke reads as a volume and darkens where the sun can't reach it. It thins out within a few metres of the camera, so a camera passing through a puff (a chase camera behind its own smoke trail) still sees.
  • Sparks are additive streaks along their velocity, in linear HDR colour, so they bloom.
  • Glows are soft round light, added: flames, embers, magic, a muzzle flash. Overlapping glows add up and bloom.
  • Flakes are small solid cards lit by the sun and sky: confetti, snow, leaves, wood chips, grit, glass slivers, coins. They tumble (flip), sway (flutter), and come to rest on their floor.
  • Rings are expanding bands of light, facing the camera or lying flat on the ground: a shockwave, a splash's ripple, a portal's pulse.

kke::ParticleLibrary (kke/ParticleLibrary.h) plays ready-made effects by name ("explosion", "campfire", "confetti", below), each written as a few lines of YAML or JSON you can copy and change. games/particles_demo shows every one of them.

For a fountain of tens of thousands of GPU particles, see ParticleModule (compute shaders) instead; for liquids, ParticleFluid.

Use it

From Lua, every effect in the library is fx.play(name, pos) or fx.start(name, pos) away, and fx.define adds your own (docs/SCRIPTING.md, the cookbook's effects.lua). From C++:

#include "kke/ParticleEffects.h"

// init():
m_fx = std::make_unique<kke::ParticleEffects>(*m_app, 8000); // at most 8000 alive

// update(dt), wherever something smokes or scrapes:
m_fx->smoke(wheelPosition, carVelocity * 0.3f, { 0.85f, 0.85f, 0.85f });      // tyre smoke
m_fx->smoke(bonnet, glm::vec3(0, 1, 0), { 0.05f, 0.05f, 0.05f }, 0.5f, 3.0f); // a dying engine
m_fx->sparks(hit.point, hit.normal, 24, 7.0f, carVelocity);                 // metal on a wall
m_fx->update(dt, wind);                                                      // moves and ages all

// renderTranslucent(ctx): once per view in split screen
m_fx->draw(ctx);

// shutdown(), or a new level:
m_fx->clear();

The calls

Call What
smoke(position, velocity, color, radius = 0.35, life = 2.2, opacity = 0.55) one puff; color is an sRGB albedo: grey-white for tyres, near-black for an engine fire, brown for dust
sparks(position, normal, count, speed, along = 0) a burst leaving the surface (normal points away from it) at about speed m/s, carried by along (the moving car's velocity)
emit(Particle) any particle, every field yours (below)
update(dt, wind) integrates everything; smoke drifts with wind (m/s)
draw(ctx) from renderTranslucent, after everything opaque
clear(), count(), capacity()

When full, a new particle replaces the oldest.

Particle's fields: kind (Smoke or Spark), position, velocity, color (smoke: sRGB albedo; spark: linear light such as (6, 3, 1)), radius (m at birth), growth (m/s the radius grows), opacity (at birth, fading to 0 over its life), life (s), drag (1/s), rise (m/s² up for hot smoke; sparks fall with gravity instead), spin (rad/s), stretch (a spark's streak, in seconds of travel), seed (0..1, the puff's shape).

Particle's newer fields: colorEnd (the colour it turns to over its life; negative keeps color), flip (rad/s a flake turns over), flutter (m/s of sway while a flake falls), thickness (a ring's band, as a fraction of its radius), floor (y of the ground: sparks bounce off it, flakes rest on it), shape (a flake's outline: Square, Disc or Shard), flat (a ring lies on the ground).

Ready-made effects: kke::ParticleLibrary

#include "kke/ParticleLibrary.h"

// init():
m_fx = std::make_unique<kke::ParticleEffects>(*m_app);
m_library = std::make_unique<kke::ParticleLibrary>(*m_fx);   // the built-in effects
m_library->load("assets/effects/my_effects.yaml");             // yours (adds or replaces)

// A burst, once, where something happened:
m_library->play("explosion", { hit.point, hit.normal });
m_library->play("wood_chips", { crate.position, glm::vec3(0, 1, 0), glm::vec3(0), /*scale=*/1.5f });

// Something that keeps going until you stop it:
auto fire = m_library->start("campfire", { glm::vec3(2, 0, 0) });
m_library->move(fire, { torch.position, {0, 1, 0}, torch.velocity }); // follow something
m_library->stop(fire);                                               // what's in the air finishes

// update(dt): emits for everything running, then moves every particle
m_library->update(dt, wind);
// renderTranslucent(ctx):
m_fx->draw(ctx);

A Spot is where it plays: position, normal (away from the surface; bursts spray around it), velocity (of what it came from), scale (x speed, size and area), floor (y of the ground). play() runs an effect once; start() runs it until stop(). looping(name) says whether an effect has a steady stream (start it) or only bursts (play it).

The built-in effects

Name What
fire flames licking up, smoke above them (1.5 s)
campfire a fire that keeps burning, with embers and smoke
torch a flame on a stick: move() it with the torch
explosion a fireball, a shockwave, sparks, debris and a column of smoke
smoke_column thick black smoke rising from something burning
steam white steam puffing up
dust a puff of dust: a landing, a footstep, a falling crate
impact_dirt earth and stones kicked up by a hit
sparks a shower of sparks off metal
welding steady sparks and a blue-white glow
muzzle_flash a gun's flash, sparks and a wisp of smoke, along the normal
wood_chips splinters and dust from wood breaking
stone_chips grit and grey dust from stone breaking
glass_shatter glinting slivers of glass
splash droplets and a ripple on the surface
rain rain over a 20 m square: move() it with the camera
snow snow drifting down over a 20 m square
leaves autumn leaves tumbling down
confetti a burst of party confetti
fireworks a firework bursting into coloured stars
magic sparkles swirling up and a soft pulsing ring
heal green sparkles rising round a character
shockwave a ring of force across the ground, kicking up dust
coins gold coins bursting out and spinning to the ground
firework_rocket a rocket climbing on a trail of sparks that bursts into fireworks where it runs out
dust_devil a whirl of dust and leaves spinning up round its spot
fireflies fireflies drifting on turbulent air round their spot: start() it
spark_shower a stream of welding sparks bouncing off whatever is on screen

ParticleLibrary::builtInYaml() is the whole set as YAML: the best place to start your own.

Writing an effect

A file is a map of effect name to effect; an effect is a description and a list of layers. Each layer is one kind of particle, either a burst (count) or a steady stream (rate a second):

lantern:
  description: A small flame with a soft glow round it
  layers:
    - kind: glow                     # smoke, spark, glow, flake or ring
      rate: 25                       # a stream; `count: 30` would be a burst
      speed: [0.2, 0.5]              # m/s, a random value between the two (or one number)
      spread: 15                     # degrees around the normal (180: every way)
      up: true                       # around world up instead of the normal
      jitter: 0.03                   # born up to this far from the spot (m)
      colors: [[6, 2.8, 0.8]]        # one picked at random: light for glow/spark/ring, sRGB for smoke/flake
      colorEnd: [1.5, 0.2, 0.03]     # what it turns to over its life
      radius: [0.05, 0.08]           # m
      growth: -0.05                  # m/s the radius grows
      life: [0.3, 0.5]               # s
      drag: 3                        # 1/s
      rise: 1.5                      # m/s² up (negative falls: -9.81 for chips and drops)

Every layer field: kind, count, rate, duration (a stream's length when played once; a second if not given), delay, speed, spread, up, inherit (x the spot's velocity), jitter, box (born anywhere in this box around the spot, half sizes: rain and snow), colors (or one color), colorEnd, brightness (+- random brightness, 0..1), radius, growth, opacity, life, drag, rise, spin, flip, flutter, stretch, thickness, shape (square, disc, shard), flat. Ranges (speed, radius, life, spin, flip) take one number or [min, max].

Since ENG-G18 a layer can also have:

Field What
sizeOverLife: [0.5, 1.5, 2] the radius times these, from birth to death (keys evenly spaced, 1 to 16)
colorOverLife: [[6, 3, 1], [1, 0.2, 0]] the colour through its life, instead of colors/colorEnd
opacityOverLife: [0, 1, 1, 0] the fade, instead of the kind's own (in, hold, out)
vortex: 3 m/s of swirl round the vertical line through the spot (negative turns the other way): a dust devil, a portal
attract: 1 m/s² pull toward the spot (negative pushes away)
collide: true bounces off the world (sparks, glows), settles on it (flakes), slides along it (smoke), where the game has physics: one ray per particle per frame, at most 512 a frame
onDeath: fireworks plays that effect where each particle dies (a rocket bursting, a drop splashing); at most 64 a frame
turbulence: 1.2 m/s of swirling air carrying it (ENG-F11), with turbulenceSize: 3 the size of its eddies in m (2 if not given)
gpu: true the GPU moves this layer's particles (part 4, below): hundreds of thousands cost what a few hundred did; not with onDeath or the curves, and never sorted. With collide, attract or turbulence the GPU steps them each frame (ENG-F11)

dust_devil and firework_rocket use them, and the impact effects (explosion, sparks, the chips and the glass) collide. rain, snow, leaves, confetti, the stars of fireworks and downpour (a cloudburst of tens of thousands of drops) are gpu.

GPU particles

A gpu layer's particle is written once, when it is born, into a slot on the GPU, and the vertex shader (shaders/effect_gpu.vert) works out where it is from its birth and the time, in closed form: drag toward the wind, rise or gravity, growth, spin, tumbling, flutter and swirl. Nothing is simulated or uploaded per frame, so a downpour of 28,000 drops runs about six times faster than the same layer on the CPU (OPTIMIZATION.md #42). The CPU copy of the same formulas, kke::particleMotion (kke/ParticleMotion.h), is tested against the stepped simulation. A GPU spark that reaches its floor is gone (rain hitting the ground) and a flake rests there. ParticleEffects::setGpuParticles(false) or KKE_PARTICLES_CPU=1 moves them on the CPU instead, as ordinary particles.

Stepped on the GPU (ENG-F11). A gpu particle that attracts, has turbulence or collides has no closed form, so a compute pass (shaders/effect_step.glsl) steps it each frame before the 3D pass, from a state kept on the GPU; its record is still written only once. Nothing comes back to the CPU. collide there needs no physics: each particle is tested against the last frame's depth, so it bounces off (sparks, glows), lands on (flakes) or slides along (smoke) whatever was on screen, one frame late, at no cost per collider. Off screen or far behind what is on screen, it flies on. Turbulence is four travelling waves of curl (no noise texture), divergence free, so it swirls without bunching up or thinning out. The CPU copy, kke::particleStep (kke/ParticleStep.h), is tested against kke::ParticleSim. A game with none of these pays nothing: the pass only runs while there are stepped particles. Measured on the RX 9070 XT (particles demo, 4x MSAA): about 100,000 glows born at 50,000 a second cost 0.516 ms in closed form and 0.595 ms stepped with turbulence and depth collisions (+0.08 ms); the CPU's frame time stays the same, since nothing comes back from the GPU.

A mistake names the effect and what's wrong ("effect 'puff': unknown kind 'plasma'"), and nothing from that file is added. ParticleLibrary::validate() checks a file without adding it (for a mod loader or a tool).

Learning from the Synty particle pack

The library's set was drawn up from the effects in Synty's POLYGON Particle FX pack (fire, smoke, explosions, dust, debris, sparks, magic, weather, confetti, coins, hearts, portals). The pack's Unity effect setups aren't in its source files, so the effects are our own; its meshes (shards, chips, coins) and textures can't be shipped in the public repo anyway (docs/ASSETS.md), and flakes draw those shapes themselves.

Cost

The simulation (kke::ParticleSim, the CPU half, tested without a GPU) and the quads are rebuilt on the CPU every frame, so keep the count in the thousands; gpu layers cost the CPU nothing after their birth, so they can run in the tens of thousands. On the GPU the cost that matters is overdraw: large puffs close to the camera are what to watch. Smoke is drawn at full resolution with no extra passes. Glows, flakes and rings cost the same as smoke and sparks; each kind's pipeline is made only the first time that kind is drawn. The racing demo allows 8000 alive; a 16-car oval race stays well under that (it logs the count with KKE_RACE_QUIT). The particles demo with every effect at once runs about 1000 to 2500.

Where it's used

games/particles_demo: every effect in the library, one at a time or all at once round a circle, with size and wind sliders (README.md there).

games/racing: tyre smoke from wheelspin and slides, burnouts on the grid, engine smoke and fire below 45% health, sparks from hits and from scraping walls and other cars.