skimi3d Grid Log in Visit the grid
All scripts
Weather and atmosphere

Particle Emitter

Smoke, steam, dust, spray, fireflies and fire. Every dial named in plain words, and six recipes so it works before you understand any of them.

Goes inThe prim the smoke should come out of
Setupone recipe, or 21 dials
Code361 lines
Open toOpen to everyone

What it does

It runs the region's particle system with settings you can actually read. Set RECIPE to a number between one and six and you have believable smoke, steam, dust, waterfall spray, fireflies or fire straight away. Set it to 0 and the twenty-one dials below are yours, each one with a sentence saying what it changes and what a sensible value looks like. The recipes write into those same dials, so you can also just read one as a worked example of what good numbers look like.

What you can build with it

The chimney on a cottage, steam off a kettle, dust hanging in a shaft of light, spray at the foot of a waterfall, fireflies over a meadow at dusk, a campfire, a smoking wreck, a fog bank, sparks off a forge. Almost every region that feels alive is doing it with a handful of these and nothing else.

How to use it

1 Drop the script into the prim the effect should come out of. It starts straight away.
2 Change RECIPE to try the six: 1 smoke, 2 steam, 3 dust, 4 spray, 5 fireflies, 6 fire.
3 Aim it by turning the prim. Cone patterns come out along the prim's own Z, so the prim points the plume.
4 When one recipe is nearly right, set RECIPE to 0 and tune the dials by hand. The recipe's numbers are already sitting in them.
5 For your own look, drop a texture into the prim's Contents and write its name into TEXTURE.

Settings you can change

24

Edit these at the top of the script. Everything works on the defaults · change them only when you need to.

RECIPE 0 uses exactly what is written below. 1 smoke, 2 steam, 3 dust, 4 spray, 5 fireflies, 6 fire. Any number but 0 overwrites the dials when the script starts. 1
TEXTURE Name of a texture in this prim's Contents. Left empty you get the default soft blob, which is right more often than people expect: smoke and steam are blobs. (empty)
START_COLOUR The colour a particle is born as. It fades towards END_COLOUR across its life. <0.25, 0.25, 0.25>
END_COLOUR The colour a particle dies as. <0.60, 0.60, 0.60>
START_ALPHA How solid a particle is at birth, from 0.0 to 1.0. 0.6
END_ALPHA How solid at death. Ending at 0.0 is what makes a puff dissolve instead of blinking out. 0.0
START_SIZE How big a particle is at birth, in metres. The third number is ignored, because particles are flat squares facing the camera. Nothing above 4.0 has any effect. <0.4, 0.4, 0.0>
END_SIZE How big at death. Growing is smoke; shrinking is fire. Getting this the wrong way round is why most home-made fire looks like orange smoke. <3.5, 3.5, 0.0>
PARTICLE_LIFE How many seconds one particle lives. This and the speeds together decide how far the plume reaches. 10.0
RATE Seconds between puffs. Along with PER_BURST this is what decides how much work you are asking of everyone looking at it. 0.25
PER_BURST How many particles come out in each puff. A small rate with a small count looks smoother, and costs less, than a large one of either. 1
RADIUS How wide the mouth of the emitter is, in metres. 0.0 emits from a point; a few metres scatters the birthplaces, which is how you fill a room with dust rather than a corner. 0.05
SPEED_MIN The slowest a particle leaves at, in metres a second. 0.15
SPEED_MAX The fastest. Giving the two different values is most of what makes a plume look natural rather than sprayed. 0.30
PUSH A steady push on every particle after birth, in metres a second squared. Real gravity is about -9.8 on Z, which is far too strong for anything but rain. Up is how smoke rises, down is how spray falls. <0.0, 0.0, 0.12>
PATTERN 1 straight down with no push, 2 a cone aimed along the prim's Z, 3 every direction at once, 4 a hollow cone which is a ring rather than a beam. 2
CONE_INNER The inside of the cone, in degrees from its middle. Give it a value close to CONE_OUTER for a thin ring. 0.0
CONE_OUTER The outside of the cone, in degrees. 10 is a narrow plume, 90 is a hemisphere. 10.0
FOLLOW_WIND Let the region's wind carry them. On for anything airborne and slow, off for anything with a mind of its own. TRUE
BRIGHT Ignore the region's light so particles stay bright at midnight. Right for fire, fireflies and neon; wrong for smoke and dust, which should go dark when the sun does. FALSE
GLOW Extra glow, from 0.0 to 1.0. Small numbers only. 0.0
ON_AT_START Whether it is emitting the moment it is rezzed. TRUE
TOUCH_TO_TOGGLE Touch to stop and start it. FALSE
WHO_MAY_TOUCH 0 for everyone, 1 for the owner alone, 2 for anyone wearing the same group tag. 1
The code LSL · 361 lines · updated 8 Sep 2026 Download .lsl
// =====================================================================
//  Particle Emitter
//  skimi3d script library
//  https://grid.skimi3d.com/scripts/particle-emitter
//
//  Smoke, steam, dust, spray, fireflies, fire. One emitter with every
//  dial named in plain words, and six recipes so you can have something
//  believable running before you understand any of them.
//
//  Almost everybody's first particle script is one they were given, and
//  most people keep using it for years without ever finding out what the
//  numbers do, because the usual version is a wall of constants with no
//  names. This one is meant to be the last one you need to copy: set
//  RECIPE to a number and it works; set RECIPE to 0 and every dial below
//  is yours.
//
//  Plain LSL.
// =====================================================================


// ---------------------------------------------------------------------
//  The quick way
// ---------------------------------------------------------------------

// 0 = ignore this line, use exactly what is written below
// 1 = smoke, from a chimney
// 2 = steam, from a kettle or a vent
// 3 = dust, motes hanging in a sunbeam
// 4 = spray, at the foot of a waterfall
// 5 = fireflies, on a summer evening
// 6 = fire
//
// Any number but 0 overwrites the settings below when the script starts.
// Change the number, save, and look. When one of them is nearly right,
// set RECIPE back to 0 and tune the lines by hand.
integer RECIPE = 1;


// ---------------------------------------------------------------------
//  The dials
// ---------------------------------------------------------------------

// Name of a texture in this prim's Contents. Left empty you get the
// default soft blob, which is the right answer far more often than
// people expect: smoke and steam are blobs.
string TEXTURE = "";

// The colour each particle starts and ends as. It fades from one to the
// other across its life.
vector START_COLOUR = <0.25, 0.25, 0.25>;
vector END_COLOUR   = <0.60, 0.60, 0.60>;

// How solid it is at birth and at death, from 0.0 to 1.0. Ending at 0.0
// is what makes a puff dissolve instead of blinking out.
float START_ALPHA = 0.6;
float END_ALPHA   = 0.0;

// How big each particle is at birth and at death, in metres. The third
// number is ignored; particles are flat squares facing you. Nothing
// above 4.0 has any effect.
vector START_SIZE = <0.4, 0.4, 0.0>;
vector END_SIZE   = <3.5, 3.5, 0.0>;

// How many seconds a single particle lives. This and SPEED together
// decide how far the plume reaches.
float PARTICLE_LIFE = 10.0;

// Seconds between puffs, and how many particles come out in each one.
// A small RATE with a small PER_BURST is smoother, and cheaper, than a
// large one of either.
float   RATE      = 0.25;
integer PER_BURST = 1;

// How wide the mouth of the emitter is, in metres. 0.0 emits from a
// point; a few metres scatters the birthplaces over an area, which is
// how you fill a room with dust rather than a corner.
float RADIUS = 0.05;

// How fast particles leave, in metres a second. Giving the two numbers
// different values is most of what makes a plume look natural.
float SPEED_MIN = 0.15;
float SPEED_MAX = 0.30;

// A steady push on every particle after it is born, in metres a second
// squared. <0.0, 0.0, -9.8> is real gravity, which is far too strong
// for anything but rain. Up is how smoke rises, down is how spray falls.
vector PUSH = <0.0, 0.0, 0.12>;

// Which way they come out:
//   1 = straight down, no push at all, for a leak or a drip
//   2 = a cone, aimed along the prim's Z. Turn the prim to aim it.
//   3 = every direction at once, for dust and fireflies
//   4 = a hollow cone, a ring rather than a beam
integer PATTERN = 2;

// The cone, in degrees, from its middle outwards. 0 and 10 is a narrow
// plume; 0 and 90 is a hemisphere; 40 and 45 is a thin ring.
float CONE_INNER = 0.0;
float CONE_OUTER = 10.0;

// Let the region's wind carry them. On for anything airborne and slow,
// off for anything with a mind of its own.
integer FOLLOW_WIND = TRUE;

// Ignore the region's light, so the particles stay bright at midnight.
// Right for fire, fireflies and neon; wrong for smoke and dust, which
// should go dark when the sun does.
integer BRIGHT = FALSE;

// Extra glow, from 0.0 to 1.0. Small numbers only.
float GLOW = 0.0;

// Is it emitting as soon as it is rezzed?
integer ON_AT_START = TRUE;

// Touch to stop and start it.
integer TOUCH_TO_TOGGLE = FALSE;

// Who may touch it. 0 = everyone, 1 = only the owner, 2 = same group.
integer WHO_MAY_TOUCH = 1;


// ---------------------------------------------------------------------
//  Everything below here is the machinery
// ---------------------------------------------------------------------

integer gOn = FALSE;


// The recipes write straight into the dials above, so that everything
// downstream only ever reads one set of numbers. It also means you can
// read a recipe as an example of what sensible values look like.
cook(integer n)
{
    if (n == 1)
    {
        // Smoke · a chimney. Grey, slow, growing as it climbs, and let
        // the wind bend it.
        TEXTURE = "";
        START_COLOUR = <0.25, 0.25, 0.25>;  END_COLOUR = <0.60, 0.60, 0.60>;
        START_ALPHA  = 0.60;                END_ALPHA  = 0.0;
        START_SIZE   = <0.40, 0.40, 0.0>;   END_SIZE   = <3.50, 3.50, 0.0>;
        PARTICLE_LIFE = 10.0;
        RATE = 0.25;   PER_BURST = 1;   RADIUS = 0.05;
        SPEED_MIN = 0.15;  SPEED_MAX = 0.30;
        PUSH = <0.0, 0.0, 0.12>;
        PATTERN = 2;   CONE_INNER = 0.0;  CONE_OUTER = 10.0;
        FOLLOW_WIND = TRUE;  BRIGHT = FALSE;  GLOW = 0.0;
    }
    else if (n == 2)
    {
        // Steam · a kettle or a vent. White, quick, gone in a moment.
        TEXTURE = "";
        START_COLOUR = <1.0, 1.0, 1.0>;     END_COLOUR = <1.0, 1.0, 1.0>;
        START_ALPHA  = 0.70;                END_ALPHA  = 0.0;
        START_SIZE   = <0.15, 0.15, 0.0>;   END_SIZE   = <1.20, 1.20, 0.0>;
        PARTICLE_LIFE = 3.0;
        RATE = 0.08;   PER_BURST = 2;   RADIUS = 0.02;
        SPEED_MIN = 0.40;  SPEED_MAX = 0.80;
        PUSH = <0.0, 0.0, 0.40>;
        PATTERN = 2;   CONE_INNER = 0.0;  CONE_OUTER = 12.0;
        FOLLOW_WIND = TRUE;  BRIGHT = FALSE;  GLOW = 0.0;
    }
    else if (n == 3)
    {
        // Dust · motes in a sunbeam. Barely moving, barely there, and
        // scattered over a wide birthplace so they fill a room.
        TEXTURE = "";
        START_COLOUR = <1.0, 0.95, 0.85>;   END_COLOUR = <1.0, 0.95, 0.85>;
        START_ALPHA  = 0.35;                END_ALPHA  = 0.0;
        START_SIZE   = <0.05, 0.05, 0.0>;   END_SIZE   = <0.05, 0.05, 0.0>;
        PARTICLE_LIFE = 20.0;
        RATE = 0.40;   PER_BURST = 4;   RADIUS = 2.50;
        SPEED_MIN = 0.01;  SPEED_MAX = 0.06;
        PUSH = <0.0, 0.0, -0.005>;
        PATTERN = 3;   CONE_INNER = 0.0;  CONE_OUTER = 0.0;
        FOLLOW_WIND = TRUE;  BRIGHT = TRUE;   GLOW = 0.02;
    }
    else if (n == 4)
    {
        // Spray · the foot of a waterfall. Many, short-lived, thrown up
        // and pulled hard back down.
        TEXTURE = "";
        START_COLOUR = <0.85, 0.92, 1.0>;   END_COLOUR = <1.0, 1.0, 1.0>;
        START_ALPHA  = 0.50;                END_ALPHA  = 0.0;
        START_SIZE   = <0.25, 0.25, 0.0>;   END_SIZE   = <0.90, 0.90, 0.0>;
        PARTICLE_LIFE = 2.5;
        RATE = 0.04;   PER_BURST = 6;   RADIUS = 0.60;
        SPEED_MIN = 0.30;  SPEED_MAX = 1.20;
        PUSH = <0.0, 0.0, -2.50>;
        PATTERN = 2;   CONE_INNER = 0.0;  CONE_OUTER = 25.0;
        FOLLOW_WIND = FALSE;  BRIGHT = FALSE;  GLOW = 0.0;
    }
    else if (n == 5)
    {
        // Fireflies · a summer evening. Few, slow, glowing, and spread
        // over several metres so they read as a swarm and not a fountain.
        TEXTURE = "";
        START_COLOUR = <0.90, 1.0, 0.40>;   END_COLOUR = <0.40, 0.80, 0.20>;
        START_ALPHA  = 1.0;                 END_ALPHA  = 0.0;
        START_SIZE   = <0.06, 0.06, 0.0>;   END_SIZE   = <0.03, 0.03, 0.0>;
        PARTICLE_LIFE = 12.0;
        RATE = 0.50;   PER_BURST = 2;   RADIUS = 4.0;
        SPEED_MIN = 0.05;  SPEED_MAX = 0.25;
        PUSH = <0.0, 0.0, 0.0>;
        PATTERN = 3;   CONE_INNER = 0.0;  CONE_OUTER = 0.0;
        FOLLOW_WIND = FALSE;  BRIGHT = TRUE;   GLOW = 0.25;
    }
    else if (n == 6)
    {
        // Fire · orange at the base, red as it dies, shrinking rather
        // than growing. Shrinking is the part people leave out, and it
        // is why their fire looks like orange smoke.
        TEXTURE = "";
        START_COLOUR = <1.0, 0.75, 0.25>;   END_COLOUR = <0.90, 0.15, 0.05>;
        START_ALPHA  = 0.85;                END_ALPHA  = 0.0;
        START_SIZE   = <0.35, 0.35, 0.0>;   END_SIZE   = <0.08, 0.08, 0.0>;
        PARTICLE_LIFE = 1.4;
        RATE = 0.05;   PER_BURST = 3;   RADIUS = 0.12;
        SPEED_MIN = 0.25;  SPEED_MAX = 0.55;
        PUSH = <0.0, 0.0, 0.60>;
        PATTERN = 2;   CONE_INNER = 0.0;  CONE_OUTER = 15.0;
        FOLLOW_WIND = FALSE;  BRIGHT = TRUE;   GLOW = 0.12;
    }
}


emit(integer on)
{
    if (!on)
    {
        // An empty list is how you stop. There is no separate off.
        llParticleSystem([]);
        gOn = FALSE;
        return;
    }

    integer flags = PSYS_PART_INTERP_COLOR_MASK | PSYS_PART_INTERP_SCALE_MASK;
    if (BRIGHT)      flags = flags | PSYS_PART_EMISSIVE_MASK;
    if (FOLLOW_WIND) flags = flags | PSYS_PART_WIND_MASK;

    integer pattern = PSYS_SRC_PATTERN_ANGLE_CONE;
    if (PATTERN == 1) pattern = PSYS_SRC_PATTERN_DROP;
    if (PATTERN == 3) pattern = PSYS_SRC_PATTERN_EXPLODE;
    if (PATTERN == 4) pattern = PSYS_SRC_PATTERN_ANGLE_CONE_EMPTY;

    string tex = "";
    if (TEXTURE != "")
    {
        if (llGetInventoryType(TEXTURE) == INVENTORY_TEXTURE)
        {
            tex = TEXTURE;
        }
        else
        {
            // Naming a texture that is not there gives you the default
            // blob and no explanation, which is a bad afternoon.
            llOwnerSay("There is no texture called \"" + TEXTURE + "\" in my Contents, so I am using the default one.");
        }
    }

    llParticleSystem([
        PSYS_PART_FLAGS,            flags,
        PSYS_SRC_PATTERN,           pattern,
        PSYS_SRC_TEXTURE,           tex,

        PSYS_PART_START_COLOR,      START_COLOUR,
        PSYS_PART_END_COLOR,        END_COLOUR,
        PSYS_PART_START_ALPHA,      START_ALPHA,
        PSYS_PART_END_ALPHA,        END_ALPHA,
        PSYS_PART_START_SCALE,      START_SIZE,
        PSYS_PART_END_SCALE,        END_SIZE,
        PSYS_PART_START_GLOW,       GLOW,
        PSYS_PART_END_GLOW,         GLOW,
        PSYS_PART_MAX_AGE,          PARTICLE_LIFE,

        PSYS_SRC_BURST_RATE,        RATE,
        PSYS_SRC_BURST_PART_COUNT,  PER_BURST,
        PSYS_SRC_BURST_RADIUS,      RADIUS,
        PSYS_SRC_BURST_SPEED_MIN,   SPEED_MIN,
        PSYS_SRC_BURST_SPEED_MAX,   SPEED_MAX,
        PSYS_SRC_ACCEL,             PUSH,
        PSYS_SRC_ANGLE_BEGIN,       CONE_INNER * DEG_TO_RAD,
        PSYS_SRC_ANGLE_END,         CONE_OUTER * DEG_TO_RAD,

        // Zero means the emitter itself never stops. Everything else
        // here is about the particles, this one line is about the
        // source.
        PSYS_SRC_MAX_AGE,           0.0
    ]);

    gOn = TRUE;
}


integer mayTouch(key who)
{
    if (WHO_MAY_TOUCH == 1) return who == llGetOwner();
    if (WHO_MAY_TOUCH == 2) return llSameGroup(who);
    return TRUE;
}


default
{
    state_entry()
    {
        if (RECIPE != 0) cook(RECIPE);
        emit(ON_AT_START);
    }

    touch_start(integer total)
    {
        if (!TOUCH_TO_TOGGLE) return;

        key who = llDetectedKey(0);
        if (!mayTouch(who))
        {
            llRegionSayTo(who, 0, "This one is not yours to switch.");
            return;
        }

        emit(!gOn);
    }

    changed(integer what)
    {
        // A texture dropped in after the fact should be picked up
        // without anyone having to remember to reset the script.
        if (what & CHANGED_INVENTORY)
        {
            if (gOn) emit(TRUE);
        }
    }

    on_rez(integer start)
    {
        llResetScript();
    }
}


// ---------------------------------------------------------------------
//  Three things worth knowing before you spend an evening on this
//
//  Particles are drawn by the viewer, not by the region, so they cost
//  the sim almost nothing and cost the person looking at them quite a
//  lot. A hundred emitters is not a lag problem for your region; it is a
//  lag problem for everyone who visits it. RATE and PER_BURST are the
//  two numbers that decide how expensive you are being.
//
//  Every viewer has a limit on how many particles it will draw at once,
//  and visitors set it themselves. Your careful plume may be thinner for
//  somebody else, or absent. Never build something that only reads
//  correctly if the particles are there.
//
//  A particle is always a flat square facing the camera. It cannot be
//  turned, it cannot be lit, and it does not go behind things properly
//  when it overlaps other transparent surfaces. Fighting any of those
//  three is how afternoons disappear.
// ---------------------------------------------------------------------

Copy it, make a new script inworld, paste over what is there and save. Nothing here phones home.