Lesson 19 - Particle Engines

 

Introduction

A particle system is a technique in game physics, motion graphics, and computer graphics that uses a large number of very small sprites, 3D models, or other graphic objects to simulate certain kinds of "fuzzy" phenomena, which are otherwise very hard to reproduce with conventional rendering techniques - usually highly chaotic systems, natural phenomena, or processes caused by chemical reactions. The algorithmic implementation of such particle systems is often referred to as a particle engine. This lesson is a demonstration of two such particle engines.

In contrast to the last lesson, this one is fairly complex for two reasons. First, the support by three.js for particle engines is relatively primitive so the user has to do a bit more work than for quadrics. Secondly, the particle engine support in three.js is relatively simple in that it only support simple image-based sprites. And those sprites (like the sprites we saw in lesson 13) always face the camera, so they're not very flexible.

So we'll also implement our own particle engine which will demonstrate the basic principles. The tradeoff is that while the three.js particle system can handle a huge number of particles and still be performant, our own particle engine will bog down with a far smaller number of particles.

Initialization

We initialize the gfxScene object same as always. We also declare some global vars:

var ELEV         = 25;
var DELTA_ELEV   = 10;
var FLOOR_REPEAT = 5;
var clock;
var particleSystem;
var cannon       = null;
var beachBall    = null;

We'll go over what each of these is for as we go along. Next step is to instantiate the scene, which is pretty standard. We do specify a floor of size 5x5 tiles so we end up with a 20x20 checkerboard. Then we call initDemo, which is where it gets interesting.

First, we instantiate the three.js Clock object:

clock = new THREE.Clock(true);

We use the clock to control the degree of motion of the three.js particle system sprites. Then we create a simple wire-frame box mesh (in red), just as a visual reference. The box is the same size as the checkerboard floor.

var cubeGeometry = new THREE.BoxGeometry( 10, 10, 10 );
var cubeMaterial = new THREE.MeshBasicMaterial( { wireframe:true, color : 0xff0000 } );
cube = new THREE.Mesh( cubeGeometry, cubeMaterial );
cube.position.set(0,0,0);
gfxScene.add(cube);

Setting up the three.js Particle System

First, we instantiate the three.js particle system by calling createParticleSystem, which returns the particle system "mesh" which we add to the scene.

particleSystem = createParticleSystem();
gfxScene.add(particleSystem);

Let's look at the code in createParticleSystem. We first set a "constant" that is the number of particles in the system. Then we allocate a plain geometry that will hold all the verticies which are the "particles".

var PARTICLE_COUNT = 500;
var particles = new THREE.Geometry();

Then we create the vertices and add them to the particle's geometry. This loop creates all the vertices in a range of -20 to 20 (the size of the checkerboard floor) in all directions, centered around the origin.

var DISTRIBX     = 20;
var DISTRIBZ     = 20;
for (var p = 0; p < PARTICLE_COUNT; p++) {
    var y = Math.random() * ELEV - DELTA_ELEV;
    var x = Math.random() * DISTRIBX - FLOOR_REPEAT * 2;
    var z = Math.random() * DISTRIBZ - FLOOR_REPEAT * 2;
	    
    var particle = new THREE.Vector3(x, y, z);
    particles.vertices.push(particle);
}

Then we create a material that will be used to render each vertex of the geometry. The texture we supply is a simple PNG "snowflake" image that is partially transparent:

Snowflake image

var particleMaterial = new THREE.PointsMaterial({ 
    color: 0xffffff,
    size: 0.4,
    map: THREE.ImageUtils.loadTexture("images/snowflake.png"),
    blending: THREE.AdditiveBlending,							transparent: true
    });

We make the snowflake white at 40% of its natural size and set it to be partially transparent so it is blended into the scene. Finally we create the particle system and return it to be added to the scene.

particleSystem = new THREE.Points(particles, particleMaterial);return particleSystem;

Animating the three.js Particle System

Before we look at the other particle system, let's take a brief look at how the three.js particle system gets animated. the work gets done in animateParticles.

function animateParticles() {
    var deltaTime = clock.getDelta();            
    var verts = particleSystem.geometry.vertices;
    
    for (var i = 0; i < verts.length; i++) {
        var vert = verts[i];
        if (vert.y < -10) {
            vert.y = Math.random() * ELEV - DELTA_ELEV;
        }
        
        vert.y = vert.y - deltaTime;
    }
    particleSystem.geometry.verticesNeedUpdate = true;
}

We start by fetching the "deltaTime", which is the time in seconds since the animation routine was last called. This is usually about 0.016666 since the animation typically runs around 60 fps 1 second divided by 60 is ~0.016. One of the advantages of using the delta time is that it more or less automatically adjusts the rate of descent of the snowflakes so they fall at the same steady rate since the longer it has been since the last call to delta time, the farther the snowflakes drops. Since the routine is called ~60 times a second, each snowflake falls about 1 unit per second and therefore take about 5-10 seconds to fall through the lower limit (-10).

Then we simply loop through all the verticies adjust the elevation by the deltaTime factor. For each vertex we check if they have already reached the bottom limit. If so, we move the vertex back up near the top. But note that the z and x coordinates are not changed so each vertex just cycles up and down in straight line.

Finally, we notify three.js that we have adjust the vertex positions. Otherwise the mesh would not get updated. And that's it for the three.js particle system.

Our Own Particle System: The Balls

So now, let's take a look at the significantly more complex home-brewed particle system. This one consists of a "cannon" poking up through the checkerboard floor. It fires a steady stream of colored spheres into the air, randomly altering the speed and direction of the launched "beach balls". Once launched, the balls are subject to gravity and fall down to bounce across the floor until they fall off, plunging into the nether regions.

So let's go through the code - there's a fair amount of it. First, let's take a look at the beach-balls, as they are quite simple. All the beach-balls consist of a three.js Sphere, it's radius, and two vectors holding the location and velocity of the ball. Here is the constructor:

BALL.BeachBall = function ( parameters ) {
    var SEGMENTS        = 8;
    var BASE_RADIUS     = 0.1;
    var DELTA_RADIUS    = 0.025;
    var RESTITUTION     = 0.75;
    
    this.vel      = new THREE.Vector3(0,0,0);
    this.loc      = new THREE.Vector3(0,0,0);
    this.gravity  = new THREE.Vector3(0,-0.002,0);
    this.mesh     = null;
    
    GFX.setParameters(this, parameters);
    
    this.radius = BASE_RADIUS + DELTA_RADIUS * Math.random();
    
    var geometry = new THREE.SphereGeometry( this.radius, SEGMENTS, SEGMENTS );
    var material = new THREE.MeshLambertMaterial( { transparent: true });
    material.color.setRGB(Math.random(), Math.random(), Math.random());
    
    this.mesh = new THREE.Mesh( geometry, material );    this.restitution = RESTITUTION;
};

Most of this is self-explanatory such as velocity and location, the number of segments on the sphere and the base and delta radius of the balls. RESTITUTION is the amount that the ball will rebound each time it bounces. If the ball were perfectly elastic (always rebounding to the same height), resititution would be 1.0. In this case, it loses 25% of its bounce each time it hits the floor. So the bouncing looks like this:

 

The update function for the beach-ball is pretty simple since all it does is update the location using the current velocity then update the velocity with the gravitational pull and finally updates the mesh's position:

beachball restitution
update: function() {
    this.loc.add( this.vel );
    this.vel.add( this.gravity );
    this.mesh.position.set(this.loc.x, this.loc.y, this.loc.z);
}

The Cannon

The cannon is more complex since it both launches and manages all the beach-balls. First, we instantiate the cannon itself:

cannon = new CANNON.Cannon( {
    scene : gfxScene,
    deltaT : 10,
    xLimit : FLOOR_REPEAT,
    zLimit : FLOOR_REPEAT
});
    
gfxScene.add( cannon.mesh );

The parameters are the scene itself (since the cannon creates the beach-balls and adds them to the scene). We also pass in the size of the floor so the cannon knows when the balls have fallen off the floor. deltaT basically controls how fast the cannon tries to fire balls. Making the parameter less than 10 has little effect since the code will only launch one ball per frame no matter how fast the computer. Making it larger will greatly reduce the number of balls since the cannon won't launch a ball at every frame. We'll cover this shortly.

The Cannon object has the following members:

this.deltaT   = 0.0;
this.lastT    = 0.0;
this.mesh     = null;
this.magazine = [];
this.active   = [];
this.xLimit   = 0;
this.zLimit   = 0;
this.scene    = null;
this.gravity  = new THREE.Vector3(0, -0.002, 0);

Most are self-explanatory, simply providing local storage. The mesh is the cannon itself. The magazine is what it implies, the magazine in which the balls not currently launched are stored. Those balls that have been launched are kept in the active array. When a ball falls off the floor and into the nether regions, it is moved from the active array into the magazine. When a new ball is needed and the magazine is empty one is created. If there is one in the magazine, it is just popped out and re-used. In this way, we don't waste memory and performance by removing and re-creating beach-balls. Each ball is created just once. lastT is the time when we last updated all the balls. As you'll see, we throttle the updates since there is no sense to updating faster than 60 fps.

First, we create the cannon:

var geometry = new THREE.CylinderGeometry( 
    BASE_RADIUS * 2.0
    BASE_RADIUS * 3.0,
    BARREL_LENGTH,
    32, 1, true);
                        
var material = new THREE.MeshPhongMaterial( { 
    color : 0xdddddd,
    specular: 0x009900, 
    shininess: 30, 
    side:THREE.DoubleSide});
    
this.mesh = new THREE.Mesh( geometry, material );

Now, let's look at the update method for the cannon, which is called from the animate() method in the lesson19. It's pretty simple, it first calls fireCannon() hen updateBalls().

update: function() {
    this.fireCannon();
    this.updateBalls();
}

The method fireCannon() is relatively simple, but has a couple of interesting aspects:

fireCannon : function {
    var now = Performance.now();
    if ((now - this.lastT) < this.deltaT)
        return;
    this.lastT = now;
    
    var newBall;
    if (this.magazine.length > 0) {
        newBall = this.magazine.pop();    
    } 
    else {
        newBall = new BALL.BeachBall( { gravity : this.gravity } );
        this.scene.add( newBall.mesh );
    }
    
    this.initTrajectory( newBall );
    this.active.push( newBall );
}

The first step is to see if the delay since the last firing is long enough, i.e. longer than deltaT. If not, we just return. If has been long enough (which is usually the case), we need to fire the cannon. To do this, we first check if there is already one or more balls in the magazine. If so, we just pop one out of the magazine. If the magazine is empty (which is only true in the first few seconds of the demo) we need to allocate a brand new beach ball and add it to the scene.

Then we initialize the trajectory of the beach-ball, i.e. we "fire" the beach-ball. We want the balls to come out the mouth of the cannon of course, but at different vertical angles and with some random variation. The geometry of this looks like this:

Cannon Rho

This is the code.

initTrajectory: function ( ball ) {
    var MUZZLE_VELOCITY = 0.125;
    
    var MIN_RHO         = 45.0 * Math.PI / 180.0;
    var DELTA_RHO       = 30.0 * Math.PI / 180.0;


    var rho = MIN_RHO + DELTA_RHO * Math.random();
    var velY = Math.sin(rho) * MUZZLE_VELOCITY;
    var velH = MUZZLE_VELOCITY - velY;


    var theta = Math.PI * 2.0 * Math.random();
    var velX = Math.sin(theta) * velH;
    var velZ = Math.cos(theta) * velH;


    ball.vel.set( velX, velY, velZ);
    ball.loc.set(0,ball.radius, 0);

    ball.mesh.material.opacity = 1;
}

This is pretty straightforward. The trajectory of the ball is detemined by the muzzle velocity and the angle of the trajectory. Rho determines both the vertical and horizontal velocities. Theta determines the direction of the ball. Note that deltaRho is less than 45 degrees. Otherwise too many of the balls go more or less vertical and end up rolling too slowly towards the edge of the floor. Finally, we push the newly fired beach ball into the active array.

Updating the Particle System

In updateBalls, we walk through the array of active balls. For each ball, we first check if the ball is now completely transparent. If so it has fallen off the floor and disappeared. So we move it from the active array to the magazine to be re-used.

If it is not transparent, we update the ball's velocity and position by calling the ball's update method. If the ball's height above the floor is less than the radius, we check if the ball is still on the floor. If it is we bounce it by inverting the vertical (y) velocity less the ball's restitution. Then we place the ball exactly on the floor.

Alternatively, if the ball is NOT over the floor anymore, then it is not bounced but instead accelerates downward under the force of gravity. Each time it's transparency is reduced by 2.5% until it finally disappears.


for ( var i=this.active.length-1; i>=0; i-- ) {

    var ball = this.active[i];

    if (ball.mesh.material.opacity <= 0) {
        this.active.splice(i, 1);
        this.magazine.push( ball );
    }
    else {
        ball.update();

        if (ball.loc.y < ball.radius) {
                
            if (Math.abs(ball.loc.x) <= this.xLimit && Math.abs(ball.loc.z) <= this.zLimit) {
                ball.vel.y = -ball.vel.y * ball.restitution;
                ball.loc.y = ball.radius;
            }
            else { 
                ball.mesh.material.opacity -= 0.025;
            }
        }
    }
}

And that's it! Click on this link to see the actual rendered demo in all it's particulate glory!

As always, the original sources are on github here.



About Us | Contact Us | ©2017 Geo-F/X