5
\$\begingroup\$

I have modified a particle system to create an effect similar to the one that I want. However, with any substantial amount of particles it performs very slowly on my laptop (around 1.7 fps at the worst when I remove the if statement limiting the creation of new particles). I'm guessing that this is because all the work is on the CPU? Can I batch together the processing somehow?

Demo here.

// Based on https://github.com/benjaminmbrown/particle-system-repeller

var Repeller = function(x, y) {
    this.power = 500;
    this.position = createVector(x, y);

    this.display = function() {
        stroke(0);
        strokeWeight(2);
        fill(127);
        ellipse(this.position.x, this.position.y, 40, 40);
    }

    this.repel = function(p) {
        var dir = p5.Vector.sub(this.position, p.position);
        var d = dir.mag();
        dir.normalize(); //just want the direction
        d = constrain(d, 1, 100);
        var force = -1 * this.power / (d * d);
        dir.mult(force);
        return dir;
    }
}


var Attractor = function(x, y) {
    this.power = 150;
    this.position = createVector(x, y);

    this.display = function() {
        stroke(255);
        strokeWeight(2);
        fill(127);
        ellipse(this.position.x, this.position.y, 40, 40);
    }

    this.attract = function(p) {
        var force = p5.Vector.sub(this.position, p.position);
        var distance = force.mag();
        distance = constrain(distance, 5, 50);
        force.normalize();
        var strength = 1 * this.power / (distance * distance);
        force.mult(strength);
        return force;
    }
}

function Particle(position, velObj, color) {
    this.position = position.copy();
    this.acceleration = createVector(0, 0.00);
    this.velocity = createVector(random(velObj.xMin, velObj.xMax), random(velObj.yMin, velObj.yMax));
    color = color || {r: 0, g: 0, b: 0};

    this.lifespan = 255;
    this.mass = 5;

    this.run = function() {
        this.update();
        this.display();
    }

    this.display = function() {
        stroke(255, this.lifespan); //gets more transparent as it dies
        strokeWeight(2);
        fill(color.r, color.g, color.b, this.lifespan);

        ellipse(this.position.x, this.position.y, 4, 4);
    }

    this.update = function() {
        this.velocity.add(this.acceleration);
        this.position.add(this.velocity);
        this.acceleration.mult(0);
        this.lifespan -= 1.5;
    }

    this.isDead = function() {
        return this.lifespan < 0.0 ? true : false;
    }

    this.applyForce = function(force) {
        var f = force.copy();
        f.div(this.mass);
        this.acceleration.add(f);
    }
}


function ParticleSystem(num, position, velObj, color) {
    this.origin = position.copy();
    this.particles = [];

    this.run = function() {
        for(var i = this.particles.length - 1; i >= 0; i--) {
            var p = this.particles[i];
            p.run();
            if(p.isDead()) {
                this.particles.splice(i, 1);
            }
        }
    };

    this.applyForce = function(force) {
        for(var i = 0; i < this.particles.length; i++) {
            this.particles[i].applyForce(force);
        }
    }

    this.applyRepeller = function(r) {
        for(var i = 0; i < this.particles.length; i++) {
            this.particles[i].applyForce(r.repel(this.particles[i]));
        }
    }

    this.applyAttractor = function(r) {
        for(var i = 0; i < this.particles.length; i++) {
            this.particles[i].applyForce(r.attract(this.particles[i]));
            var p = this.particles[i];
            var force = r.attract(p);
            p.applyForce(force);
        }
    }

    this.addParticle = function() {
        var r = random(1);
        this.particles.push(new Particle(this.origin, velObj, color));
    };
}

var particleSystems = [];
var repeller, attractor;


function setup() {
    createCanvas(windowWidth, windowHeight, WEBGL);
    setFrameRate(60);
    repeller = new Repeller(0, 0);
    leftAttractor = new Attractor(0, height / 2);
    rightAttractor = new Attractor(width, height / 2);

    particleSystems.push(new ParticleSystem(1, createVector(width / 5, height / 2), {xMin: 0, xMax: 2, yMin: -4, yMax: 4}, {r: 0, g: 0, b: 255}));
    particleSystems.push(new ParticleSystem(1, createVector(width * 4 / 5, height / 2), {xMin: -2, xMax: 0, yMin: -4, yMax: 4}, {r: 255, g: 0, b: 0}));

    // Ones not affected by attractor
    particleSystems.push(new ParticleSystem(1, createVector(width / 5, height / 2), {xMin: -2, xMax: 1, yMin: -2, yMax: 2}, {r: 0, g: 0, b: 255}));
    particleSystems.push(new ParticleSystem(1, createVector(width * 4 / 5, height / 2), {xMin: -1, xMax: 2, yMin: -2, yMax: 2}, {r: 255, g: 0, b: 0}));
}

var numDraws = 0;
function draw() {
    background(255);
    // var gravity = createVector(0.0, 0.1);
    translate(-windowWidth / 2, -windowHeight / 2);

    for(var i = 0; i < particleSystems.length; i++) {
        // particleSystems[i].applyForce(gravity);
        particleSystems[i].applyRepeller(repeller);
        if(i === 0) {
            particleSystems[i].applyAttractor(rightAttractor);
        } else if(i === 1) {
            particleSystems[i].applyAttractor(leftAttractor);
        }
        // if(numDraws % 3 === 0 || i < 2)
            particleSystems[i].addParticle();
        particleSystems[i].run();
    }
    repeller.display();
    numDraws++;
}

function mouseMoved() {
    repeller.position = createVector(mouseX, mouseY);
}
function touchMoved() {
    repeller.position = createVector(mouseX, mouseY);
}

Any insight as to how to improve this performance is appreciated.

\$\endgroup\$
6
\$\begingroup\$

Performance

  1. The main reason for the slow down are the state changes you incur each time you change the color and transparency of the particle (which is each particle). Unless you are prepared to write some shaders (webGL) you are not going to get past this problem. You will need to sacrifice some features to get performance.

  2. The second reason for the slowdown is that P5 is as slow as they get, I mean snails can draw faster than p5.

    For example the call to var dir = p5.Vector.sub(this.position, p.position); subtract one vector from another. With P5 you have to execute 13 lines of code, assign memory for new two arrays, one new vector, go through 4 branching statements and you get back a 3D vector.

    It can be done in 2 expressions and 100 times faster.

  3. You loop over ever particle ever time you do something to it. If you want performance you need to have as little overhead as you can manage. Loops are overhead so do everything in one pass.

  4. JavaScripts memory management is s slow. If you are creating many short lived objects you have to avoid deleting them when you are done with them.

    Eg You splice out a particle when its dead with this.particles.splice(i, 1); so GC must clean it up. Then you create a new particle with this.particles.push(new Particle(this.origin, velObj, color));

    Memory is cheap and plentifully, CPU cycles are not, so for performance sections of your code you should reuse objects rather than delete then. Only grow arrays never shorten them.

    Nor should you splice from arrays in performance code. When you splice Javascript has to move every item above the splice point down one. And if the array size falls below half its size, it will dump the reserved space for GC to clean up. Then if you add one item javascript will ask for all that memory back. This can be very costly.

Rewrite

This can be done in webGL but with a few modifications the standard 2D API does it very well.

  • To avoid state changes particles do not fade out but rather change size. That means all the particles from a single particle system can be rendered in one go (one state change). This gives you 10 time + improvement in rendering speed.

  • To avoid memory allocation and GC overhead particles are in two arrays. There is a pool that contains dead particles. When a particle is dead it is moved to the pool, the iterator in Particles.run has two indexes and keeps the main array packed with live particles. It never deletes anything while in the main loop.

    Rather than use the array length property, count and deadCount hold the number of usable particles. Note do not use particles above there counts as what they reference is unknown and can not be trusted.

  • To avoid overhead for vector math most of its done inline and vectors are stored in a very simple Vec object or anything that has a x, or y property. (unlike p5 which refuses to handle anything but defined vectors (Crazzy))

I have doubled the particle count in this example compared to the one you provided in the question.

const ctx = canvas.getContext("2d");

const mouse  = {x : 0, y : 0}
function mouseEvents(e){
	mouse.x = e.pageX;
	mouse.y = e.pageY;
}
document.addEventListener("mousemove", mouseEvents);

Math.TAU = Math.PI * 2;
Math.rand = (min, max) => Math.random() * (max - min) + min;
const Vec = (x, y) => ({x,y}); // Simple vector object
const mousePos = Vec(0,0);
const particleSystems = [];
var repeller, numDraws = 0, width, height;

setTimeout(setup, 0);  // Just waits for all objects to be defined
function setup() {
    width = canvas.width = innerWidth;
    height = canvas.height = innerHeight;
    particleSystems.length = 0;

    
    repeller = Force(mouse, -500, 2, 100);
    const leftAttractor = Force(Vec(0, height / 2), 1150, 5, 50);
    const rightAttractor = Force(Vec(width, height / 2), 1150, 5, 50);


    particleSystems.push(Particles({origin : Vec(width / 5, height / 2), min: Vec(-4,-4), max: Vec(0, 4)}, "#00F",  [repeller, rightAttractor]));
    particleSystems.push(Particles({origin : Vec(width * 4 / 5, height / 2), min: Vec(0,-4), max: Vec(4, 4)}, "#F00", [repeller, leftAttractor]));
    
    // Ones not affected by attractor
    particleSystems.push(Particles({origin : Vec(width / 5, height / 2), min: Vec(-2,-2), max: Vec(1, 2)}, "#0FF", [repeller]));
    particleSystems.push(Particles({origin : Vec(width * 4 / 5, height / 2), min: Vec(-1,-2), max: Vec(2, 2)}, "#FF0", [repeller]));

    requestAnimationFrame(draw);
}

function draw() {
    if (width !== innerWidth || height !== innerHeight) { // check for resize
       setTimeout(setup, 0); 
       return;
    }
    ctx.fillStyle = "#FFF";
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    for(const system of  particleSystems) {
        system.addParticle();
        system.addParticle();
        system.run();
    }
    repeller.display();
    numDraws++;
    requestAnimationFrame(draw); // At fixed rate of 60Fps if it can
}

function Force(pos, force, min, max) { // force negative to repel, positive to attract
    min *= min;  // raise to power 2
    max *= max;
    var x,y,d,d2; // working variables created so they do not need to be created each call to repel
    return {
        display() {
            ctx.strokeStyle = "#000";
            ctx.fillStyle = "#9999" // yes 4 the last digit is alpha
            ctx.globalAlpha = 1;
            ctx.lineWidth = 2;
            ctx.beginPath();
            ctx.arc(pos.x, pos.y, 30, 0, Math.TAU);
            ctx.fill();
            ctx.stroke();
        },
        apply(p) { // p is particle 
            x = pos.x - p.x;
            y = pos.y - p.y;
            d = Math.sqrt(d2 = x * x + y * y) * p.mass; 
            d2 = force / (d2 < min ? min : d2 > max ? max : d2) / d;
            p.ax += x * d2;
            p.ay += y * d2;
        }
    };
}

function Particle() {}
Particle.prototype = { // Use prototype when creating many instances
    init(emitter) {
        this.x = emitter.origin.x;
        this.y = emitter.origin.y;
        this.life = 255;
        this.mass = 5;
        this.vx = Math.rand(emitter.min.x, emitter.max.x); 
        this.vy = Math.rand(emitter.min.y, emitter.max.y); 
        this.ay = this.ax = 0;
    },
    display() {
        const size = (this.life / 255) * 4; 
        if (size < 2) { // draw faster rect when too small to discern
            ctx.rect(this.x - size / 2, this.y - size / 2, size, size)
        } else {
            ctx.moveTo(this.x + size, this.y);
            ctx.arc(this.x, this.y, size, 0, Math.TAU);
        }
    },
    update() { // return true if dead
        this.x += (this.vx += this.ax);
        this.y += (this.vy += this.ay);
        this.ay = this.ax = 0;
        return (this.life -= 1.5) <= 0;
    },
}


function Particles(emitter, color, forces) {
    const particles = []; // holds live particles
    var count = 0;
    var deadCount = 0;
    const pool = []; // holds dead particles
    return {
        run() {
            var i = 0, top = 0;
            ctx.beginPath();      // prep state change and start defining paths
            ctx.fillStyle = color;
            while (top < count) {
                const p = particles[i] = particles[top];
                for (const force of forces) { force.apply(p) }
                if (p.update()) {                  
                    pool[deadCount++] = p;
                } else {
                    p.display();
                    i ++;
                }
                top++;
            }
            ctx.fill();  // applies the state and renders
            count = i;
        },
        addParticle() {
            if (deadCount > 0) { 
                p = pool[--deadCount]; // get from pool if available
            } else {
                p = new Particle();    // else create a new one
            }
            p.init(emitter);
            particles[count++] = p;
        },
    };
}
canvas { position : absolute; top : 0px; left : 0px; }
<canvas id="canvas"></canvas>

\$\endgroup\$
  • \$\begingroup\$ Thanks for the feedback and help! I will be looking into the details but this seems like a much better approach. \$\endgroup\$ – Zach Saucier Jun 8 at 14:19
  • \$\begingroup\$ I'm trying to make it responsive in a perfectly circular shape by sizing the width and height on the minimum of the two and changing the force parameter of the attractors by a polynomial (size * i). However, the force does not seem to scale in this way. Where'd you get the Force.apply math from? That would help me understand how to scale the force based on the size. Demo of where I'm at here \$\endgroup\$ – Zach Saucier Jun 10 at 0:07
  • \$\begingroup\$ @ZachSaucier Its just a simplification of your original force function. I applied the particle mass and then added directly to the acceleration. Rather than normalize the direction vector separately I put the normalization scalar into d2 that then multiplies x,y to give the acceleration \$\endgroup\$ – Blindman67 Jun 10 at 3:02
2
\$\begingroup\$

You might be able to improve performance by removing particles once they have left the boundaries of the screen. This should reduce your particle count substantially.

Another option to pursue would be to use getFrameRate to check your current frame rate against a target value and reduce the number of particles you generate based on that.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.