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 onfloptle-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
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.
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:
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
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.
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:
(
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 byLook::orient(camera-facing by default, or aligned to velocity for speed lines).aspectsets width:height so one size curve can drive non-square quads;stretchlengthens a velocity-aligned quad along its motion, andspeed_stretchadds 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 tobeam_end, subdivided intosegmentsquads. A beam track has no particles at all: its width and colour are itssizeandcolorsampled at the effect's time, andwave_amplitude/wave_frequency/scrollanimate it. Lasers, tethers, a mining beam.jitterthrows 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) andjitter_ratetimes a second on top. Tracks with the samejitter_seedandsegmentsdraw 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 theDistortionblend 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:
(
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:
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.