Skip to content

Particles

Particles are Awaken's system for fire, smoke, sparks, dust and impact flashes: a ParticleEffect asset describes the effect, a ParticleEmitter component plays it on an entity, and api.burst fires a one-shot from a script - all simulated on the CPU and drawn as camera-facing billboards in a dedicated pass.

The three-part model

Everything in the system is one of three things, and they are deliberately separable:

PartWhere it livesWhat it is
ParticleEffectan asset in the project (@awaken/core)the blueprint - emission, shape, forces, colour/size over life, blend, texture. Authored in the Particles tab or imported as a .awakenfx file.
ParticleEmittera component on an entity (@awaken/core)plays an effect at an object. Two fields: effect (the effect asset id) and playing.
api.burst(effect)the ScriptApia fire-and-forget one-shot with no entity - it plays out and retires itself.

An effect is referenced by its stable id, never by file or display name. Renaming an effect keeps every emitter and every api.burst("…") working.

How a frame flows

The particle engine (ParticleEngine, packages/runtime/src/particleSystem.ts) runs once per frame, right after the animation system. It walks every ParticleEmitter, keeps a live-particle pool per emitter, advances it, and hands the packed result to the renderer.

An emitter contributes particles only when the whole subtree is active, the component is enabled, and playing is true. Fail any of those and the emitter is torn down: its GPU buffer is released and its pool dropped, so nothing stale is left on screen. Re-enabling it starts a fresh pool - particles are not preserved across a stop.

The pool is sized to maxParticles and is rebuilt whenever the emitter's effect id or that cap changes, so editing maxParticles restarts the effect.

The effect fields

Everything below is one field of the ParticleEffect blueprint. Defaults are defaultEffect().

Emission

FieldUnitDefaultWhat it does
rateparticles/second10Continuous emission. A fractional accumulator carries the remainder between frames, so a rate of 0.5/s really does emit once every two seconds.
bursts{ time, count }[][]Timed one-shot spawns at effect-age time (seconds). Each fires once; the list must be sorted ascending by time.
maxParticlescount1000Live-pool cap. Spawns past the cap are dropped (the rate accumulator still drains, so no backlog floods in when slots free up).
durationseconds5For a non-looping effect, rate emission stops once the effect age passes this. Bursts are not gated by it.
loopbooltrueEmit forever (ignores duration).
oneShotboolfalseA lifecycle hint carried by the asset and by importers. Nothing reads it today - the integrator ignores it, and a transient burst retires on "all bursts fired and no particle left alive".

Per-particle initial state

FieldUnitDefaultWhat it does
lifetimeseconds1How long a particle lives.
lifetimeVariance0–10± this fraction of lifetime, per particle.
shapesee belowpointThe volume a particle spawns in (and, for a cone, part of its direction).
speedunits/second1Initial speed along the launch direction.
speedVariance0–10± this fraction of speed.
spreadDegdegrees0Half-angle of the cone the launch direction is jittered within (clamped 0–180 on import). 0 = a perfectly straight jet.
sizeStartworld units1Base sprite half-extent - the billboard quad spans 2 × size across.
rotationSpeedradians/second0Sprite spin about the view axis.
rotationVariance0–10± this fraction of rotationSpeed.

The launch direction is the emitter's local +Y, transformed by its world matrix and normalized - rotate the entity and the jet aims with it. spreadDeg (plus a cone shape's angleDeg) jitters it inside a cone around that axis.

Forces

FieldUnitDefaultWhat it does
gravityunits/second²[0,0,0]A constant acceleration, per axis. It is not tied to physics gravity - a fire uses positive Y (hot air rises), sparks use -9.8.
drag1/second0Exponential velocity damping: v *= exp(-drag · dt). Higher = the particle brakes faster.

There is no collision, no wind, no turbulence and no force fields - gravity and drag are the whole force model.

Over life

FieldDefaultWhat it does
colorwhite → transparent whiteA ColorGradient (stops[] of { t, color: [r,g,b,a] }) sampled by the particle's age fraction (0 at spawn, 1 at death).
sizeCurveflat 1A Curve (keys[] of { t, v }) whose value multiplies sizeStart, sampled by the same age fraction.

Both are sampled on the CPU, per particle, per frame - the final rgba and size are written straight into the instance record, so the GPU never needs a gradient LUT. Stops and keys must be sorted ascending by t, within [0,1]; the .awakenfx parser rejects out-of-order or out-of-range entries rather than sampling them wrong. Sampling clamps outside the stop range to the nearest endpoint, so a gradient that starts at t = 0.2 simply holds its first colour until then.

Gradient RGB is left unclamped above 1 (additive blending legitimately over-drives a hot core); alpha is clamped to 0–1.

Render

FieldUnitDefaultWhat it does
blendadditive | alphaadditiveWhich billboard pipeline draws this emitter - see below.
textureasset id""Sprite texture. "" - or an id the renderer has no texture for - falls back to a procedural round sprite.
frameColscount1Sprite-sheet columns. With frameRows, treats texture as a grid of animation cells. 1 × 1 = the whole image is one sprite.
frameRowscount1Sprite-sheet rows. Independent of the columns - a 10 × 4 sheet is a valid shape.
frameSpeedplaythroughs1How many times the sheet plays over one particle's lifetime. 1 finishes on the last cell exactly as the particle dies.
frameLoopboolfalsePast the last cell: wrap to the first, or hold the last. Only differs when frameSpeed pushes playback past the end.
worldSpacebooltrueStored, imported and shown in the UI, but not implemented as a choice: in this version every particle lives in world space once spawned. See Limitations.
softness0–10Soft depth-fade distance. 0 = hard billboard edges.
stretchseconds0Velocity stretch (streaks). 0 = round sprites.

Emission shapes

shape is a tagged union. It sets where a particle spawns; only cone also affects direction.

KindFieldsSpawn volume
point-The emitter origin.
sphereradiusUniform inside the sphere (cube-root radius distribution, not just the shell).
boxhalf: [x, y, z]Uniform inside the box, half being half-extents.
coneangleDeg, radiusA disc of radius in the emitter's local XZ plane; angleDeg is a half-angle added to spreadDeg for the launch direction.

Spawn positions are transformed by the emitter's full world matrix, so scaling the entity spreads the spawn volume - it does not scale sizeStart or speed.

The billboard pass

Particles get their own render pipeline (PARTICLE_WGSL), not the mesh shader. Per emitter, the renderer keeps a storage buffer of 16-float instance records - pos.xyz, size, rgba, rotation, vel.xyz, ageFrac plus 3 reserved lanes, 64 bytes, matching the CPU integrator's layout exactly - and issues one draw(4, count): no vertex buffer, a 4-vertex triangle strip expanded per particle in the vertex shader. The buffer grows in power-of-two steps (minimum 64 particles), so a ramping emitter reallocates a handful of times rather than every frame.

Each quad is built in the camera's right/up plane, so particles always face the viewer. rotation spins the corners in that plane before they expand. Uploading particles never invalidates the renderer's scene cache - a per-frame particle sim cannot bust the static-merge or cached-shadow work.

The pass runs after all opaque and transparent geometry, before the editor gizmos and overlays. It is depth-tested but does not write depth, so particles are hidden behind solid geometry but never occlude each other or anything drawn later.

📸 Screenshot - save as img/render-particles.png

A Viewport scene with the starter Fire emitter burning at the base of a wall (soft particles on, so the flame sinks into the floor without a hard seam) and a Sparks burst mid-flight showing stretched streaks.

Blend modes

  • additive - src.rgb · src.a + dst.rgb. Order-independent, which is why emitters are drawn in registry order with no sorting at all. The right choice for fire, sparks, glow, magic.
  • alpha - standard src-alpha / one-minus-src-alpha. Correct for smoke, dust and anything that should darken what is behind it - but because nothing is sorted (not between emitters, not between particles within one emitter), overlapping alpha sprites can layer in the wrong order. Keep alpha emitters sparse or thin.

Particles are unlit: their colour comes entirely from the gradient (times the sprite texture). They do not receive light, do not cast shadows, and are not picked up by SSAO.

Sprites

With no texture the fragment shader draws a procedural round disc - a radial smoothstep on the quad's alpha, which is enough for sparks, embers and soft blobs and costs no asset. With a texture, the quad's [-1,1]² corner is mapped to [0,1]² UV and the albedo texture multiplies the particle colour.

Velocity stretch

stretch elongates the quad along the particle's screen-space velocity by speed × stretch, giving spark trails and rain streaks. It behaves like a shutter time (hence seconds): a particle moving straight toward or away from the camera has almost no screen velocity and stays round. The sprite's UV is left unstretched, so a textured sprite doesn't smear.

Soft particles

With softness > 0, the fragment shader reads the opaque scene-position prepass and fades a particle out as it approaches the geometry directly behind it, killing the hard seam where a flat billboard intersects a wall or the ground. The fade distance is the softness value in world units (clamped to 0–1 by the file parser and the editor UI).

This is not free: the renderer only runs the scene-position prepass when it is needed, and a soft emitter with live particles switches it on - the same prepass SSAO and the water material use. If it is already on for one of those, softness is effectively free; otherwise it adds a prepass to the frame. Set softness: 0 on effects that never touch geometry (a spark shower in mid-air).

Simulation details

simulateEmitter (in @awaken/core) is a pure, import-free integrator over a structure-of-arrays pool. Each dt it:

  1. ages the effect;
  2. emits by rate through a fractional accumulator (while looping, or until duration elapses);
  3. fires any bursts the age has just crossed, once each;
  4. integrates - gravity, exponential drag, position, rotation - ages every particle and swap-removes the dead;
  5. samples the gradient + size curve per survivor and writes the instance buffer.

It is deterministic: every random draw comes from a seeded mulberry32 PRNG (never Math.random), seeded from the entity id for scene emitters and from an incrementing counter for bursts. The same effect on the same entity replays identically; two different entities look different from each other.

At dt = 0 the whole sim is a no-op - emission and bursts are gated on dt > 0 specifically so a frozen tick can't fire a t = 0 burst and then never re-fire it.

The GPU-ready seam

The simulation is CPU-side today. It is deliberately arranged so that a GPU-compute backend replaces only the integrator and the upload: the config asset, the component, and the 16-float instance record all stay put. ParticleDeps (effect lookup + buffer upload/remove) is injected, so @awaken/runtime imports neither the renderer nor the asset store. Nothing GPU-simulated exists yet - this is a seam, not a feature.

Particles are live in the editor

Unlike the animation system - which holds its pose at dt = 0 so a character shows a stable frame - the particle engine ticks with real delta time in both edit and Play. That is a deliberate exception: emission is gated on dt > 0, so a frozen emitter would show literally nothing, and an author placing a campfire would stare at an empty scene until they pressed Play. Continuous effects are only authorable if they run while you edit.

Only the sim-owning viewport ticks (the visible one), and tick() early-outs when there are no emitters, so the cost is bounded.

api.burst is a different story: it is called from scripts, and scripts run in Play (or from an editor script's Run). Bursts do not appear spontaneously while you edit.

Where effects come from

  • Author one in the editor's Particles tab, or with + New Effect in the Asset Browser.
  • Import a .awakenfx file - the starter content ships particles/Fire.awakenfx (a continuous flame) and particles/Sparks.awakenfx (a one-shot 40-spark burst), plus a Fire.awakenprefab with the emitter already wired.
  • Import a scene from Unity or Godot. A Unity ParticleSystem or a Godot GPUParticles3D/CPUParticles3D node becomes a ParticleEffect asset (id derived from the node name) plus a ParticleEmitter component. Only what the shared import IR can express carries over - rate, lifetime, shape, spread, gravity, drag, colour, size, blend, texture - and where a source ramp or curve can't be fully decoded, the importer keeps its endpoints. Treat an imported effect as a starting point, not a faithful reproduction.

There are no built-in effects. The engine ships none: every effect is content, so tuning the starter Sparks edits your copy and the project save keeps it.

Shipping

Particle effects ride the project file and the export. They are plain JSON (gradient stops and curve keys are arrays), so they persist in the .awaken project's metadata and are carried straight into the shipped SceneFile - the standalone player registers them and runs the same ParticleEngine every frame with real delta time. An exported game's particles behave exactly as they do in editor Play.

Unlike meshes and textures, effects are not filtered down to what the scene references: an export ships every authored effect. The reason is api.burst("sparks") - the id lives inside a string that no walk of the component data can see, so a reference-only export would silently ship a game whose scripts fire nothing. Effects are a handful of gradient and curve keys each, so shipping the lot costs nothing measurable. Their sprite textures are added to the shipped texture set too. (A prefab pack, by contrast, carries only the effects its emitters actually reference.)

Limitations

Stated plainly, because these are the edges you will hit:

  • CPU simulation only. There is no GPU-compute path yet - just the seam for one.
  • World space only. worldSpace is stored and shown, but every particle is integrated in world space after spawn; the emitter's transform is applied at spawn only. Moving an emitter therefore drags a trail behind it rather than carrying its particles along.
  • No sorting. Additive doesn't need it; alpha-blended emitters can and will layer incorrectly.
  • Unlit, shadowless. Particles are not lit, cast no shadows, receive none, and don't write depth.
  • No collision, no sub-emitters, no trails, no particle-space noise. The per-particle random seed exists in the pool but nothing consumes it yet.
  • oneShot is inert. It's a stored hint; no code branches on it.
  • Sprite sheets animate, but only against age. A sheet advances strictly with the particle's normalized age × frameSpeed; there is no per-particle random start cell and no independent frame rate in seconds.
  • No editor gizmo for the emission shape. You judge the shape from the tab's preview, not from an outline in the Viewport.

See also

Awaken — browser-native WebGPU game engine.