Docs / How the engine works · v0.95.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>,
    // Beam tracks only:
    segments: u32, beam_end: Vec3,
    wave_amplitude: f32, wave_frequency: f32, scroll: f32,
}

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.

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
}

forces is empty by default and costs nothing when it is. 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.

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.

blend is Alpha, Additive, Premultiplied, Screen or Multiply. flipbook plays a sprite sheet over a particle's life. lit puts a particle through the full scene lighting — sun, point lights, field shadow, AO — and cast_shadows lets the 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:setBeamEnd(target:pos())      -- aim every Beam track, in WORLD space
if fx:isPlaying() thenend     -- 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.

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.