Docs / How the engine works · v0.117.0

Particles & VFX (`floptle-vfx`)

Particles & VFX (floptle-vfx)

Timeline-driven particle authoring: name an effect, give it a lifetime, lay tracks down a video-editor timeline, and shape every property with a constant, a random range, or a hand-drawn curve. The ✱ Particles tab is where you do it, and it plays live while you drag.

Reads on: Shaders · Editor · Object pooling. Crate: floptle-vfx (depends on floptle-core, floptle-render). Scripting: node:particles() in lua-api.md.

Why this exists

Other engines bury particle authoring under scavenger-hunt panels — you spelunk through twenty collapsible modules to fade an alpha. Floptle's bet is the opposite: a timeline you already understand, plus a curve editor, plus a preview that never stops running. Everything an effect does is visible as something you can see on the timeline and drag.

The data model

An effect serializes to RON under vfx/*.vfx.ron. There are two levels, not four: an effect owns tracks, and a track owns both its look and its lane on the timeline.

ParticleEffect

rust
struct ParticleEffect {
    name: String,             // "OreBreak" — the spawn key
    lifetime: f32,            // seconds the timeline runs (one loop period)
    playback: Playback,       // Looping | OneShot
    end: EndBehavior,         // Destroy | Persist — OneShot only
    tracks: Vec<Track>,
    seed: u32,                // instances offset it, so two campfires don't march in step
    gravity_mode: GravityMode,// WorldDown | Field
}

end is shown in the Inspector only for OneShot; a Looping effect simply restarts at t = 0, so a persist/destroy choice would mean nothing.

gravity_mode is the one field with no counterpart in other engines. WorldDown is the constant every engine assumes; Field reads the live scene gravity field instead, so sparks struck on a small moon fall toward it — see gravity-and-density.md.

Track — one visual layer and its timeline lane

Track and group are one concept. A track owns its look, its emission, its per-particle curves and its position on the timeline: one thing you select, drag, mute and copy. It is the unit the whole tab is built around.

rust
struct Track {
    name: String,
    enabled: bool,            // the mute button
    look: Look,               // render mode, blend, orientation, flipbook, lighting
    space: Space,             // Local (rides the emitter) | World (stays where it was born)

    clips: Vec<Clip>,         // ranged emission spans on the timeline
    automation: Vec<Lane>,    // curves over EFFECT time

    shape: EmitShape,
    max_alive: Option<u32>,   // pool capacity; derived from the clips when None

    // Per-particle: a birth value, times a curve over that particle's own life.
    velocity: ValueOrCurve,   // emitter-space; +Y is "along the emit direction"
    size: ValueOrCurve,
    squash: ValueOrCurve,           // width × / height ÷, volume-preserving (1 = none)
    rotation: ValueOrCurve,         // radians (billboards use roll only)
    angular_velocity: ValueOrCurve, // radians/sec, integrated over age
    color: ValueOrCurve,            // RGBA
    gravity: f32,             // 0 = weightless, 1 = full
    drag: f32,
    inherit_velocity: f32,    // how much of the emitter's motion a newborn keeps
    forces: Vec<Force>,
    trail: Option<Trail>,
    collide: Option<Collide>, // meet the level: bounce, friction, or die there
    // Beam tracks only:
    segments: u32, beam_end: Vec3,
    wave_amplitude: f32, wave_frequency: f32, scroll: f32,
    jitter: f32, jitter_rate: f32, jitter_seed: u32,
}

Two time domains, one rule: automation shapes birth, life-curves shape ageing. An automation lane runs over effect time and multiplies what a particle is born as. A ValueOrCurve on a property runs over one particle's life, [0..1], and shapes what happens to it as it ages. size can carry both, and they multiply — the effect's crescents get smaller as the slash decays, while each crescent still pops in and tapers.

squash is squash-and-stretch: the width is multiplied by it and the height divided, so the particle keeps its area while its proportions change. Above 1 flattens, below 1 lengthens; a curve from 0.6 through 1.4 back to 1 is the classic pop, and a Range gives every particle its own proportions. A mesh squashes the same way along its local Y.

inherit_velocity is the fix for World-space trails on something fast: smoke off a moving vessel used to be left behind in space. At 1 a newborn fully keeps up with the emitter, then drifts as drag bleeds it off. It only means anything for Space::World — a Local track rides the node already.

collide makes a track's particles meet the level: tilemap walls, static colliders and terrain, never triggers. Each moving particle tests the whole step it is about to take, so a fast spark cannot pass a thin wall between frames. On a hit it either ends there (die, for rain and sparks that wink out on contact) or bounces: bounce is how much of the speed into the surface comes back out (0 lands dead), friction how much of the speed along it each hit takes away (1 stops it where it lands). It works the same for World and Local tracks. Only Play has a level to meet, so the Particles tab's preview passes through everything. It costs a ray per moving particle per frame, asked only of the colliders near that step, so turn it on for the tracks that land on something and leave glows and trails off. In the editor it is collides with the level on the track.

A World track's particle that lands and stays — gravity holds it on the surface, it has stopped bouncing, and its friction is enough to hold it on the slope — comes to rest: it stays exactly where it landed and asks the level nothing more until the level changes shape near it: a collider added, removed or resized there wakes the particles resting within its bounds, and terrain dug or re-meshed wakes only the particles on the chunks it changed. A level that only moves (a planet on rails carrying its ground and what lies on it) wakes nothing. A track with force fields or a velocity curve never rests, and neither does a Local one, which rides its emitter, so leave forces off a debris track that should settle. The hit point is worked out in double precision from the step, so debris lies as still megametres from the origin as at it.

Emission — clips, not a rate field

There is no track-level rate or lifetime. A clip is the emission. Its length is the particle lifetime, and its emit says how it spawns:

rust
struct Clip { start: f32, end: f32, lifetime_jitter: f32, emit: Emit }

enum Emit {
    Rate  { rate: f32 },      // a continuous stream across the clip
    Burst { count: u32, count_jitter: f32,
            pulses: u32, interval: f32, interval_jitter: f32 },
}

pulses = 1 is a single burst. More than one is a pulse train — the first at the clip's start, each following one interval later, with jitter so a chain of explosions does not tick like a metronome. A track can hold several clips, so "start late, stop, start again" is dragging two clips rather than keyframing a rate to zero.

Shapes, forces, trails

rust
enum EmitShape {
    Point,
    Cone   { angle: f32, radius: f32 },   // within `angle` of +Y, born on a disc
    Sphere { radius: f32, shell: bool },  // emit direction is radial
    Edge   { length: f32 },               // a line along X — slash arcs
    Ring   { radius: f32 },               // a circle in XZ, radially outward
    Box    { size: Vec3 },                // anywhere inside a box — rain, dust, snow
}

enum Force {                              // added to velocity each step
    Directional { dir: Vec3, strength: f32 },          // wind, updraft
    Point       { center: Vec3, strength: f32 },       // a gravity well (or a push)
    Vortex      { center: Vec3, axis: Vec3, strength: f32 },
    Turbulence  { frequency: f32, strength: f32 },     // value-noise wander
    Wind        { coupling: f32 },                     // the game's wind (weather.addWind)
}

forces is empty by default and costs nothing when it is. Wind is the one force a track opts into rather than authors: the wind the game declared with weather.addWind, sampled at the emitter each frame, carries the particles toward its velocity at coupling per second (a few for smoke, more for dust and rain). The velocity relaxes exactly rather than being pushed, so a strong coupling settles on the wind instead of overshooting it, and a Local track feels the wind turned into its emitter's frame. It relaxes all three axes, the vertical one too: gravity is applied before it and then pulled toward the wind's own vertical speed (usually zero), so a falling track with a strong coupling stops falling. Give such a track a downward wind component, or a smaller coupling, or add the fall back with a Directional force. The Directional, Point and Vortex vectors of a World track are in world axes, not along a planet's up: on a planet, build them from the up where the effect plays, or use a Local track on a node aligned to the ground. Outside Play there is no wind. A Trail is a per-particle ribbon on billboard tracks, spanning time seconds of history at width world units, optionally tapering to nothing (fade); a track with no trail records no history and pays for none.

rust
struct Trail {
    time: f32,                // seconds of history the ribbon spans
    width: f32,               // world units at the head
    fade: bool,               // taper width and alpha to nothing at the tail
    texture: Option<String>,  // None = the track's texture; v runs along the ribbon
    min_distance: f32,        // a point is recorded only after this much motion
    emitter_path: bool,       // follow the emitter's world path (Local tracks)
}

A trail records the particle's own motion — a spark's arc, a shard's tumble. That is nothing for a particle that sits still on a moving node, and a sword trail is exactly that: one particle at the blade tip, with the node doing the moving. emitter_path records the ribbon along the node's world path instead — follows the emitter in the Inspector — so a Local track with one still particle and a trail is a sword trail, a wing-tip streak or a tyre mark in one effect asset, with no script:

ron
(
    name: "SwordTrail",
    lifetime: 10.0,
    playback: Looping,
    tracks: [(
        name: "Ribbon",
        render: Billboard(texture: Some("textures/streak.png")),
        space: Local,
        clips: [(start: 0.0, end: 10.0,
                 emit: Some(Burst(count: 1, count_jitter: 0.0, pulses: 1,
                                  interval: 0.0, interval_jitter: 0.0)))],
        shape: Point,
        velocity: Const(Vec3((0.0, 0.0, 0.0))),
        size: Const(F32(0.25)),
        color: Const(Rgba((1.0, 0.6, 0.2, 1.0))),
        trail: Some((time: 0.45, width: 0.5, fade: true,
                     texture: Some("textures/streak.png"),
                     min_distance: 0.03, emitter_path: true)),
    )],
)

Put that on a node parented to the blade and swing. The ribbon is camera-facing, width wide, and depth-tested like every particle; a World track already leaves its ribbon in the world, so the option is offered for Local tracks only. The Particles tab's ∞ sweep moves the preview emitter through a figure-eight so a trail like this, a World track left behind, or inherited velocity can be seen without leaving the tab.

For a ribbon that lies in the blade's own plane rather than facing the camera — two marker nodes, hilt and tip, and a quad between each frame's pair and the last — the Lua draw.quad draws a textured, depth-tested quad in the world; see lua-api.md for the copyable ribbon.

Random between two curves

A size or a colour can be a constant, a random between two values (drawn per particle at birth), a curve over the particle's life, or a random between two curves: each particle picks where it sits between them at birth and follows that blend for its whole life. One track of puffs then grows, but not all alike. In the Inspector it is the 🎲 on a curve row.

Look

RenderMode decides what a track draws:

  • Billboard { texture } — a textured quad, oriented by Look::orient (camera-facing by default, or aligned to velocity for speed lines). aspect sets width:height so one size curve can drive non-square quads; stretch lengthens a velocity-aligned quad along its motion, and speed_stretch adds length per unit of speed, so a fast spark draws a long streak and a slow one stays a dot.
  • Mesh { asset_path } — instanced geometry through the raster pass. Debris that is actually shaped like debris.
  • Beam { texture } — a single camera-facing ribbon from the effect origin to beam_end, subdivided into segments quads. A beam track has no particles at all: its width and colour are its size and color sampled at the effect's time, and wave_amplitude / wave_frequency / scroll animate it. Lasers, tethers, a mining beam. jitter throws each point along the chain sideways at random, up to that distance, for a jagged channel: lightning, arcing electricity. The shape is thrown again each pulse (each loop, or once for a one-shot) and jitter_rate times a second on top. Tracks with the same jitter_seed and segments draw the same channel, so a bolt's core and its glow line up. The segments are mitred where they meet, so a wide glow is one ribbon rather than a fan of square-ended shards; a kink sharper than the mitre limit (one ribbon width of mitre) keeps a small notch instead of throwing a spike. A beam with the Distortion blend keeps square ends.

blend is Alpha, Additive, Premultiplied, Screen, Multiply or Distortion. Distortion is heat haze: it bends a picture of the scene behind the particle by the texture's red and green (0.5 is no push), by distortion (a share of the screen), faded by the particle's alpha. Use a noise or normal-map texture; a plain quad bends nothing.

flipbook plays a sprite sheet over a particle's life or at a fixed rate, and with blend: true each frame crossfades into the next, so a slow flipbook plays smoothly instead of stepping.

lit lights the track. A mesh particle goes through the full scene lighting: sun, point lights, field shadow, AO. A billboard takes the sun (or the brightest star) and the ambient on a rounded normal, so a puff reads as a ball and not a card, and over a planet's night side, where the ground faces away from the light, it takes only the ambient. On top of that, each lit billboard takes the point lights reaching it, with their colour, range and falloff, and in a flat scene the 2D lights that reach its emitter's sorting layer (a spawnEffect one-shot counts as the Default layer). It takes no shadows from them: dust by a torch is lit by the torch even with a wall between. Use it for smoke and dust, and leave glow and fire unlit. translucency (0..1, a lit billboard's translucency slider) is light from behind: with the key light beyond the particle, that share of it comes through toward the camera, scaled by how thin the particle is there (one minus its alpha), so smoke against a low sun glows at its edges and keeps a dark core. Lit from the front it changes nothing. cast_shadows lets a mesh track's live cloud cast into the field shadow march. soft is the distance, in world units, over which a billboard fades out in front of whatever it intersects — the soft edges field in the Inspector — so smoke crossing a floor or a wall shows no hard line where the depth test cuts it; 0 is a hard edge, and 0.5 is the default.

Automation lanes

A Lane is a curve over effect time against one LaneTarget: Rate, Count, Speed, Size, Tint, ShapeScale or Aspect. Each multiplies the corresponding birth value, so a cone can widen across the effect (ShapeScale), round sparks can stretch into streaks partway through (Aspect), and a swell into a die-off is one drawn curve on Rate.

A real effect

OreBreak — a one-shot burst of shards, thrown outward off a sphere shell:

ron
(
    name: "OreBreak",
    lifetime: 0.85,
    playback: OneShot,
    end: Destroy,
    tracks: [
        (
            name: "Shards",
            render: Billboard(texture: Some("textures/VFXTEX/Shards/1.png")),
            blend: Alpha,
            orient: Velocity,
            stretch: 1.6,
            space: World,
            clips: [(
                start: 0.0, end: 0.08,
                emit: Some(Burst(count: 16, count_jitter: 0.3, pulses: 1,
                                 interval: 0.0, interval_jitter: 0.0)),
            )],
            automation: [],
            shape: Sphere(radius: 0.18, shell: true),
            velocity: Range(Vec3((-4.5, 2.0, -4.5)), Vec3((4.5, 7.5, 4.5))),
            size: Curve((keys: [ /* 0.34 held, then tapering to 0 */ ])),
        ),
    ],
)

Range is the third spelling of a property, beside a constant and a curve: each particle draws its own value between the two ends. It is what keeps sixteen shards from being one shard drawn sixteen times.

Runtime

An effect is compiled before it runs — every curve baked to a LUT, every derived value precomputed — into a CompiledEffect of CompiledTracks. The sim is structure-of-arrays per track, and every track has a hard capacity, either authored as max_alive (the Inspector's pool) or derived from the clips: a stream's rate × lifetime, a burst's count × pulses, and in a looping effect whose particles outlive the loop, one generation per loop still alive. The Particles tab measures the effect and names the track when the pool is short. That ceiling is why a busy frame cannot ask for unbounded work: perf.counts() reports particles, effects and effectsDropped, and a non-zero effectsDropped means a ceiling refused something this frame — a number rather than a screenshot.

Instances are pooled (ADR-0008). seed is offset per instance, so two of the same effect in one scene do not run in lockstep.

From a script

An effect is a Particle System component on a node, and node:particles() is the handle:

lua
local fx = node:particles()
fx:restart()                     -- re-fire a one-shot burst on every hit
fx:setIntensity(throttle)        -- live emission scale off a control input
fx:setTint(0.8, 0.55, 0.4)       -- one dust effect, each world's ground colour
fx:setBeamEnd(target:pos())      -- aim every Beam track, in WORLD space
fx:setBeamControl(sagPoint)      -- and bow it through a point: a rope, a tether
fx:setVolume(targetRadius)       -- fill a bigger or smaller space, same motes
if fx:isPlaying() then … end     -- and :alive(), :asset(), :play(), :stop()

setBeamEnd takes a world point and converts it to effect-local itself, so a beam keeps tracking its target as the emitter moves. setBeamControl does the same for a control point: the beam is then a quadratic curve from the emitter to its end, pulled toward the point and passing halfway to it. A track can author one too (the bow row of a Beam track), as a local offset like its end.

setVolume(v) scales where particles are born, the emission shape, and nothing else: size, count, speed and brightness stay as authored. That is what fits one mark effect to a small target and a large one. setIntensity changes count and size, and the node's scale changes particle size along with the space. spawnEffect takes the same as a volume option.

Editor integration

The ✱ Particles tab is the timeline: tracks down the left, clips and bursts you drag along each lane, automation lanes underneath, and the curve editor for any property. The preview runs continuously and re-compiles as you edit, so there is no "apply" step between a change and seeing it.

A Particle System component on a node references an effect by key and can play on start; node:particles() is the same instance the tab previews.