Advanced Particle System Effects

This tutorial will walk you through some more effects, such as weather and rocket blasters.

To go over the basics of a particle system, see the Introduction to Particle Systems tutorial.

Weather

snow rain

The beginning of the snow tutorial follows the implementation used for NORAD Tracks Santa.

Setup

We’ll begin by explaining how to make snow, then how to convert this snow into rain.

To make a snow effect, we’ll add snowflake images for each particle and define the movement behavior and other dynamic elements of the particles in the updateParticle function.

The Images

Regarding the images that represent each particle, we could start with images of any color: red, green, white, etc. We use PNGs because they support transparency, so the uncolored areas of the image remain invisible. The following three images are the PNGs used for the billboards to create particle system effects in these tutorials. On the left is the PNG used for the rain in this tutorial; the center image was used for the snow in this tutorial; the right image is the one used for the fire in the Introduction to Particle Systems tutorial.

all images

Once we’ve selected an image, we can modify its appearance in Cesium, as explained below. For example, we modified the circular particle image on the left to be long and blue to look more like raindrops, as seen at the beginning of this section. The fire image can be changed to green for leaves, yellow for electric sparks, and even white for the water’s splash/foaming effect at the bottom of a waterfall. Creativity is advantageous here!

Additionally, in both the rain and snow systems, we set the starting opacity to 0 and final opacity to whatever is required visually, meaning they are completely invisible when they are first created. This is so we don’t see random particles popping up out of nowhere when they first materialize.

The Update Function

The update function is where we have the most free reign regarding the movement, arrangement, and visualization of the particles. Here we can modify simple things such as the particle’s color, imageSize, particleLife, etc. Use this method for as little or as much modification as needed. We can even modify the particles based on their distance to the camera (as done below), to an imported model, to the earth itself, etc.

Here is our update function for snow:

// snow
var snowGravityScratch = new Cesium.Cartesian3();
var snowUpdate = function(particle, dt) {
    snowGravityScratch = Cesium.Cartesian3.normalize(particle.position, snowGravityScratch);
    snowGravityScratch = Cesium.Cartesian3.multiplyByScalar(snowGravityScratch,
                                                            Cesium.Math.randomBetween(-30.0, -300.0),
                                                            snowGravityScratch);
    particle.velocity = Cesium.Cartesian3.add(particle.velocity, snowGravityScratch, particle.velocity);

    var distance = Cesium.Cartesian3.distance(scene.camera.position, particle.position);
    if (distance > (snowRadius)) {
        particle.endColor.alpha = 0.0;
    } else {
        particle.endColor.alpha = snowSystem.endColor.alpha / (distance / snowRadius + 0.1);
    }
};

The first part of the function makes the particles fall downward as if by gravity.

As an added feature, the update function also includes a distance check that allows particles to disappear the farther away the user is from them. The farther away a particle is, the more invisible it is, allowing for an almost distance fog effect.

disappearing particles

Additional Weather Effects

Along with the visual of particles disappearing by distance, the example also includes a fog and atmosphere effect change to match the type of weather we’re trying to replicate.

The hueShift changes the color along the color spectrum. The saturationShift changes how much color versus black and white the visual actually entails. The brightnessShift changes how vivid the colors are.

The fog density changes how opaque the overcover on the earth is with the fog’s color. The fog minimumBrightness, being the minimum bound for how bright the fog is, acts as a way to darken the fog.

// snow
scene.skyAtmosphere.hueShift = -0.8;
scene.skyAtmosphere.saturationShift = -0.7;
scene.skyAtmosphere.brightnessShift = -0.33;

scene.fog.density = 0.001;
scene.fog.minimumBrightness = 0.8;

The above snow skyAtmosphere is mostly dark gray with very little coloring, and the fog is a strong white.

The Systems

To actually have separate visuals, we create two different Particle Systems, one for the snow and one for the rain.

snow and rain

Snow

The below system creates our particles using a sphere emitter based on the center location. Additionally, the size of the image used for each particle is randomized between the given size and twice its size to allow for more of a variance of particles.

The snow system has the following attributes and all the previous functions we discussed:

var snowParticleSize = scene.drawingBufferWidth / 100.0;
var snowRadius = 100000.0;

var snowSystem = new Cesium.ParticleSystem({
    modelMatrix : new Cesium.Matrix4.fromTranslation(scene.camera.position),
    minimumSpeed : -1.0,
    maximumSpeed : 0.0,
    lifetime : 15.0,
    emitter : new Cesium.SphereEmitter(snowRadius),
    startScale : 0.5,
    endScale : 1.0,
    image : "../../SampleData/snowflake_particle.png",
    emissionRate : 7000.0,
    startColor : Cesium.Color.WHITE.withAlpha(0.0),
    endColor : Cesium.Color.WHITE.withAlpha(1.0),
    minimumImageSize : new Cartesian2(snowParticleSize, snowParticleSize),
    maximumImageSize : new Cartesian2(snowParticleSize * 2.0, snowParticleSize * 2.0),
    updateCallback : snowUpdate
});
scene.primitives.add(snowSystem);

Rain

The rain system is almost exactly like snow with just a few different features.

Just like with snow, the below system creates our particles using a sphere emitter based on the center location. However, we chose a different image to represent the rain, circular_particle.png, which we colored a shade of blue and stretched vertically to give the rain an elongated look. Unlike the snow example where the image sizes were randomized, here we set every particle to have the exact same imageSize, which has a height that is double its width.

rainSystem = new Cesium.ParticleSystem({
    modelMatrix : new Cesium.Matrix4.fromTranslation(scene.camera.position),
    speed : -1.0,
    lifetime : 15.0,
    emitter : new Cesium.SphereEmitter(rainRadius),
    startScale : 1.0,
    endScale : 0.0,
    image : "../../SampleData/circular_particle.png",
    emissionRate : 9000.0,
    startColor :new Cesium.Color(0.27, 0.5, 0.70, 0.0),
    endColor : new Cesium.Color(0.27, 0.5, 0.70, 0.98),
    imageSize : new Cesium.Cartesian2(rainParticleSize, rainParticleSize * 2),
    updateCallback : rainUpdate
});
scene.primitives.add(rainSystem);

Additionally the rain’s update function is also slightly different in that it falls at a much faster speed than snow. The below code shows how we augmented the scalar multiplication for gravity to match this visualization, and we’re modifying particle.position instead of particle.velocity.

// rain
rainGravityScratch = Cesium.Cartesian3.normalize(particle.position, rainGravityScratch);
rainGravityScratch = Cesium.Cartesian3.multiplyByScalar(rainGravityScratch,
                                                        -1050.0,
                                                        rainGravityScratch);

particle.position = Cesium.Cartesian3.add(particle.position, rainGravityScratch, particle.position);

Finally, to make the environment match the mood of the scene, we modify the atmosphere and fog to match the rain. The below code makes a dark blue sky with a thin fog cover.

// rain
scene.skyAtmosphere.hueShift = -0.97;
scene.skyAtmosphere.saturationShift = 0.25;
scene.skyAtmosphere.brightnessShift = -0.4;

scene.fog.density = 0.00025;
scene.fog.minimumBrightness = 0.01;

For additional help, see the Sandcastle example for both snow and rain.

Comet and Rocket Tails

CometRocket

Using Multiple Particle Systems

While the weather system examples required just one particle system, to create comet and rocket trails, we will need multiple particle systems. Each location on a ring of particles created by the examples is actually a completely separate particle system from those around it. That is, we’re creating a circle of particle systems that each emit a particle following a path out from the emitted location. This allows us to better control the direction of the movement of the systems in a more uniform fashion. An easy way to visualize this effect is to limit cometOptions.numberOfSystems to 2 and cometOptions.colorOptions to include just two colors, as shown in the image below. It’s easy to trace out the path of the particles of each system as they are created.

demo of 2

To streamline the different sets of systems, we create arrays to carry the separate systems associated with the comet versus those associated with the rocket example.

var rocketSystems = [];
var cometSystems = [];

Additionally, we create two different options for objects, mostly for ease of organization; one for the comet version and one for the rocket version. This allows for a varied look between the two with different initial number of systems, offset values, etc.

var cometOptions = {
    numberOfSystems : 100.0,
    iterationOffset : 0.003,
    cartographicStep : 0.0000001,
    baseRadius : 0.0005,

    colorOptions : [{
        red : 0.6,
        green : 0.6,
        blue : 0.6,
        alpha : 1.0
    }, {
        red : 0.6,
        green : 0.6,
        blue : 0.9,
        alpha : 0.9
    }, {
        red : 0.5,
        green : 0.5,
        blue : 0.7,
        alpha : 0.5
    }]
};

var rocketOptions = {
    numberOfSystems : 50.0,
    iterationOffset :  0.1,
    cartographicStep : 0.000001,
    baseRadius : 0.0005,

    colorOptions : [{
        minimumRed : 1.0,
        green : 0.5,
        minimumBlue : 0.05,
        alpha : 1.0
    }, {
        red : 0.9,
        minimumGreen : 0.6,
        minimumBlue : 0.01,
        alpha : 1.0
    }, {
        red : 0.8,
        green : 0.05,
        minimumBlue : 0.09,
        alpha : 1.0
    }, {
        minimumRed : 1,
        minimumGreen : 0.05,
        blue : 0.09,
        alpha : 1.0
    }]
};

Additionally, colorOptions for each is an array of colors to allow for a more randomized visual. That is, rather than having a select initialization color, we have each system start with one specific color dependent on the current system being created. In the below example, i represents the current iteration.

var color = Cesium.Color.fromRandom(options.colorOptions[i % options.colorOptions.length]);

Setup

We use the below function as the initializer for each system:

function createParticleSystems(options, systemsArray) {
    var length = options.numberOfSystems;
    for (var i = 0; i < length; ++i) {
        scratchAngleForOffset = Math.PI * 2.0 * i / options.numberOfSystems;
        scratchOffset.x += options.baseRadius * Math.cos(scratchAngleForOffset);
        scratchOffset.y += options.baseRadius * Math.sin(scratchAngleForOffset);

        var emitterModelMatrix = Cesium.Matrix4.fromTranslation(scratchOffset, matrix4Scratch);
        var color = Cesium.Color.fromRandom(options.colorOptions[i % options.colorOptions.length]);
        var force = forceFunction(options, i);

        var item = viewer.scene.primitives.add(new Cesium.ParticleSystem({
            image : getImage(),
            startColor : color,
            endColor : color.withAlpha(0.0),
            particleLife : 3.5,
            speed : 0.00005,
            imageSize : new Cesium.Cartesian2(15.0, 15.0),
            emissionRate : 30.0,
            emitter : new Cesium.CircleEmitter(0.1),
            bursts : [ ],
            lifetime : 0.1,
            forces : force,
            modelMatrix : particlesModelMatrix,
            emitterModelMatrix : emitterModelMatrix
        }));
        systemsArray.push(item);
    }
}

Stepping through this system creation function, options represents whether we are currently building a comet’s tail or a rocket’s tail. As mentioned in The Systems Collections section, systemsArray is just so that once we create all our ParticleSystems, we have a way of working with all the systems associated with the input options.

Since both tail versions are very similar to one another, we can have the same inputs for everything except for color and force, both of which change between the comet and rocket examples. Additionally, the emitterModelMatrix is also completely separate for each system to create that “rotational offset” in which each newly created ring seems to have its particles shifted slightly offset from the previous one.

Additionally, here we’re not actually loading in an image file. Instead, we’re creating an image directly using the HTML canvas. Though we’re using it to directly draw a circle, this function makes the image creation a lot more malleable. For example, by adding a parameter to getImage that stands for the current iteration, we can have each image be created slightly differently depending on the parameter, allowing for multiple different visual outputs.

var particleCanvas;
function getImage() {
    if (!Cesium.defined(particleCanvas)) {
        particleCanvas = document.createElement('canvas');
        particleCanvas.width = 20;
        particleCanvas.height = 20;
        var context2D = particleCanvas.getContext('2d');
        context2D.beginPath();
        context2D.arc(8, 8, 8, 0, Cesium.Math.TWO_PI, true);
        context2D.closePath();
        context2D.fillStyle = 'rgb(255, 255, 255)';
        context2D.fill();
    }
    return particleCanvas;
}

Create the Particle Image from Scratch

Now that we have the main idea of what we want to build, we also need a way to actually visualize the particles being created. Instead of loading in an image as before, here we create the image. This approach removes the dependency on loading a file, allowing for a more code-based approach.

var particleCanvas;
function getImage() {
    if (!Cesium.defined(particleCanvas)) {
        particleCanvas = document.createElement('canvas');
        particleCanvas.width = 20;
        particleCanvas.height = 20;
        var context2D = particleCanvas.getContext('2d');
        context2D.beginPath();
        context2D.arc(8, 8, 8, 0, Cesium.Math.TWO_PI, true);
        context2D.closePath();
        context2D.fillStyle = 'rgb(255, 255, 255)';
        context2D.fill();
    }
    return particleCanvas;
}

Move the Particles in the Systems

Hold onto your seats, we’re getting to the exciting part of actually moving the particles. Here is where we need to fill in our updateCallback function:

var func = function(particle) {
    scratchCartesian3 = Cesium.Cartesian3.normalize(particle.position, new Cesium.Cartesian3());
    scratchCartesian3 = Cesium.Cartesian3.multiplyByScalar(scratchCartesian3, -1.0, scratchCartesian3);

    particle.position = Cesium.Cartesian3.add(particle.position, scratchCartesian3, particle.position);

    scratchCartographic = Cesium.Cartographic.fromCartesian(particle.position,
                                                            Cesium.Ellipsoid.WGS84,
                                                            scratchCartographic);

    var angle = Cesium.Math.PI * 2.0 * iterationOffset / options.numberOfSystems;
    iterationOffset += options.iterationOffset;
    scratchCartographic.longitude += Math.cos(angle) * options.cartographicStep;
    scratchCartographic.latitude += Math.sin(angle) * options.cartographicStep;

    particle.position = Cesium.Cartographic.toCartesian(scratchCartographic);
};

But, what’s this? This function doesn’t match the name of what’s actually input into the particle system. When we created our particle system before, we filled in force with var force = forceFunction(options, i);. This calls the below helper function that returns our actual force function.

var scratchCartesian3 = new Cesium.Cartesian3();
var scratchCartographic = new Cesium.Cartographic();
var forceFunction = function(options, iteration) {
    var iterationOffset = iteration;
    var func = function(particle) {
        scratchCartesian3 = Cesium.Cartesian3.normalize(particle.position, new Cesium.Cartesian3());
        scratchCartesian3 = Cesium.Cartesian3.multiplyByScalar(scratchCartesian3, -1.0, scratchCartesian3);

        particle.position = Cesium.Cartesian3.add(particle.position, scratchCartesian3, particle.position);

        scratchCartographic = Cesium.Cartographic.fromCartesian(particle.position,
                                                                Cesium.Ellipsoid.WGS84,
                                                                scratchCartographic);

        var angle = Cesium.Math.PI * 2.0 * iterationOffset / options.numberOfSystems;
        iterationOffset += options.iterationOffset;
        scratchCartographic.longitude += Math.cos(angle) * options.cartographicStep;
        scratchCartographic.latitude += Math.sin(angle) * options.cartographicStep;

        particle.position = Cesium.Cartographic.toCartesian(scratchCartographic);
    };
    return func;
};

We did this for two reasons. First, in JavaScript, though it is possible, it’s not recommended for the user to create a function inside of a for-loop. Second, our update function needed access to the iteration with which it was created to allow for the proper spinning offset (based on the angle and iterationOffset) whenever the function is called by a particle in the system. To fix this, we created a helper function that returns the proper function we need.

The Force Function Deconstructed

Now what does our updateCallback function, forceFunction, actually do? Just as with createParticleSystems, where we create each system with a circular offset from the one before it, we also want each particle to be updated in that direction to provide an even further circular effect as the system moves away from its initialization point.

The iteration offset on the particle not only creates the spinning look but also allows for an either smooth or rougher looking spinning, as seen comparing both the comet and rocket visual. Instead of setting the new position to the cosine and sine of the newly calculated angle, we actually add this new amount to the previous position. Therefore, smaller iteration offsets won’t adjust the angle enough, allowing the radius to grow steadily larger as the system continues. In turn, larger iteration offsets will change the angle much faster and added to the original position; this will make a much tighter, jittery, and more cylindrical output such as in the rocket example.

In this tutorial, we’re mostly using just sine and cosine functions for circular effects; however, the user can take these a step further making shapes such as the Lissajous curve, the Gibbs phenomenon, or even creating a square wave as needed. Additionally, the user can scrap trigonometry altogether and do position-based noise manipulations on the particle’s movement and even more fun stuff. This is where the user’s ingenuity will shine!

Relative Positioning

rocket and comet

Just as we did in the original Particle Systems Tutorial, now that we have the effect we want to produce, we want to incorporate it in its proper location behind the plane. Since our systems are vertical, to get the proper positioning of the system relative to the plane, we need to do a slight offset by using our particleOffset value. We use this to create our particlesModelMatrix to act as each system’s overall global positioning matrix. As shown in our createParticleSystems function, for each system we create, we fill in the emitterModelMatrix with the offset calculated depending on which iteration in the circle this system is creating.

// positioning the plane
var planePosition = Cesium.Cartesian3.fromDegrees(-75.59777, 40.03883, 800.0);
var particlesOffset = new Cesium.Cartesian3(-8.950115473940969, 34.852766731753945, -30.235411095432937);

// creating the particles model matrix
var transl = Cesium.Matrix4.fromTranslation(particlesOffset, new Cesium.Matrix4());
var translPosition = Cesium.Matrix4.fromTranslation(planePosition, new Cesium.Matrix4());
var particlesModelMatrix = Cesium.Matrix4.multiplyTransformation(translPosition, transl, new Cesium.Matrix4());

For additional help see the Sandcastle example for both tails examples.

For more example code, see: