featured image
ThreeJS

ThreeJS Particles: Recycling

   - 

Last time we figured out how to do all of the math on the GPU using only time as a per/frame input. This is great but introduces some challenges. Today we will make a nice abstraction that can fire particles one at a time, adjust their properties, and make them look pretty.

The code is going to get a lot longer. More than I can cover here. But most of it is also boring, so I’ll just cover the highlights. The full code is available here.

First I moved all of the code into a reusable class called GPUParticleSystem. It extends Object3D so it has a position and can be added directly to a scene. It takes an options object containing the defaults for things like position, acceleration, and color. It handles geometry the same way as before, with lots of buffer attributes. To keep the code easier to understand I create the attributes with three values first (based on Vector3) then the scalars next. It looks like this. It all happens in the constructor.

// geometry
this.geometry = new THREE.BufferGeometry();

//vec3 attributes
this.geometry.addAttribute('position',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('positionStart', new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('velocity',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('acceleration',  new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('color',         new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));
this.geometry.addAttribute('endColor',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT * 3), 3).setDynamic(true));

//scalar attributes
this.geometry.addAttribute('startTime',     new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT), 1).setDynamic(true));
this.geometry.addAttribute('size',          new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT), 1).setDynamic(true));
this.geometry.addAttribute('lifeTime',      new THREE.BufferAttribute(new Float32Array(this.PARTICLE_COUNT), 1).setDynamic(true));


this.particleSystem = new THREE.Points(this.geometry, this.material);
this.particleSystem.frustumCulled = false;
this.add(this.particleSystem);

To create a new particle you can now call the spawnParticle method which updates the values in the buffer attributes. Again the code is boring. For each attribute it does something like this:

velocity = options.velocity !== undefined ? velocity.copy(options.velocity) : velocity.set(0, 0, 0);
const velocityAttribute = this.geometry.getAttribute('velocity')
velocityAttribute.array[i * 3 + 0] = velocity.x;
velocityAttribute.array[i * 3 + 1] = velocity.y;
velocityAttribute.array[i * 3 + 2] = velocity.z;

The magic part is what happens next. Let’s consider how a particle gets to the GPU.

The GPU draws the particles as a group, drawing the entire array of particles at once. This array is defined by a set of arrays, each defined by buffer attribute. When we add one particle we don’t need to update the entirety of each array, just the slice that is different. To handle this we introduce a new variable on the JS side: particlecursor. This tracks where in the array we last added a particle. To add a new particle we update a count variable. If we add four particles on one tick then the count is incremented four times.

To update the GPU the particles class has an update method. If any particles are added it will update the arrays to the GPU starting at particle_cursor, going through count spaces in the array. Essentially we are using the arrays as a giant ring buffer.

/*
  This updates the geometry on the shader if at least one particle has been spawned.
  It uses the offset and the count to determine which part of the data needs to actually
  be sent to the GPU. This ensures no more data than necessary is sent.
 */
geometryUpdate () {
    if (this.particleUpdate === true) {
        this.particleUpdate = false;
        UPDATEABLE_ATTRIBUTES.forEach(name => {
            const attr = this.geometry.getAttribute(name)
            if (this.offset + this.count < this.PARTICLE_COUNT) {
                attr.updateRange.offset = this.offset * attr.itemSize
                attr.updateRange.count = this.count * attr.itemSize
            } else {
                attr.updateRange.offset = 0
                attr.updateRange.count = -1
            }
            attr.needsUpdate = true
        })
        this.offset = 0;
        this.count = 0;
    }
}

Every time a particle is added the cursor moves through the array. When it reaches the end it goes back to the start. In this way we can add an endless number of particles while recycling the buffer spots. The total number of particles at any given time is fixed. Most importantly, only the part of the buffers that have changed are actually updated, not the entire buffer.

So now we can recycle particles but we still have a problem. The particles all fire at time zero. To fix this we need a ‘startTime’ attribute. This value is set whenever a particle is spawned. Then the vertex shader can decide if a given particle is actually alive yet. If not it will set the point size to 0. If it is alive then it will give it a real size and send it on to the fragment shader. If it has exceeded it’s lifetime then it sets the size back to 0 again.

float timeElapsed = uTime - startTime;
lifeLeft = 1.0 - ( timeElapsed / lifeTime );
gl_PointSize = ( uScale * size ) * lifeLeft;
if (lifeLeft < 0.0) { 
    lifeLeft = 0.0; 
    gl_PointSize = 0.;
}
//while active use the new position
if( timeElapsed > 0.0 ) {
    gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
} else {
    //if dead use the initial position and set point size to 0
    gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    lifeLeft = 0.0;
    gl_PointSize = 0.;
}

And that’s it. Now we can spawn as many particles as we want. I’ve gotten to over 100000 at once pretty easily with this method. To give you an idea of what you can do, I created a magic wand demo here.

Magic Wand with Particles

Enjoy. Next time we’ll take a look at interpolating colors to create fire effects.