Search code examples
javascripthtmlperformancehtml5-canvasparticles

Canvas particles, collisions and performance


I'm creating a web app which has an interactive background with particles bouncing around. At all times there are about 200 circular particles on the screen and at most around 800 particles. Some of the collisions and effects that are being run for the particles are the following prototypes. I wonder if I could improve the performance by using web workers to do these calculations?

/**
*   Particles
*/

Jarvis.prototype.genForegroundParticles = function(options, count){

    count = count || this.logoParticlesNum;

    for (var i = 0; i < count; i++) {
        this.logoParticles.push(new Particle());
    }

}

Jarvis.prototype.genBackgroundParticles = function(options, count){

    count = count || this.backgroundParticlesNum;

    for (var i = 0; i < count; i++) {
        this.backgroundParticles.push(new Particle(options));
    }

}

Jarvis.prototype.motion = {
    linear : function(particle, pIndex, particles){
        particle.x += particle.vx
        particle.y += particle.vy
    },
    normalizeVelocity : function(particle, pIndex, particles){

        if (particle.vx - particle.vxInitial > 1) {
            particle.vx -= 0.05;
        } else if (particle.vx - particle.vxInitial < -1) {
            particle.vx += 0.05;
        }

        if (particle.vy - particle.vyInitial > 1) {
            particle.vy -= 0.05;
        } else if (particle.vx - particle.vxInitial < -1) {
            particle.vy += 0.05;
        }

    },
    explode : function(particle, pIndex, particles) {

        if (particle.isBottomOut()) {
            particles.splice(pIndex, 1);
        } else {
            particle.x += particle.vx;
            particle.y += particle.vy;
            particle.vy += 0.1;
        }

        if (particles.length === 0){
            particles.motion.removeMotion("explode");
            this.allowMenu = true;
        }       

    }
}

Jarvis.prototype.collision = {
    boundingBox: function(particle, pIndex, particles){

        if (particle.y > (this.HEIGHT - particle.radius) || particle.y < particle.radius) {
            particle.vy *= -1;
        }

        if(particle.x > (this.WIDTH - particle.radius) || particle.x < particle.radius) {
            particle.vx *= -1;
        }
    },
    boundingBoxGravity: function(particle, pIndex, particles){
        // TODO: FIX GRAVITY TO WORK PROPERLY IN COMBINATION WITH FX AND MOTION
        if (particle.y > (this.HEIGHT - particle.radius) || particle.y < particle.radius) {
            particle.vy *= -1;
            particle.vy += 5;
        } 

        if(particle.x > (this.WIDTH - particle.radius) || particle.x < particle.radius) {
            particle.vx *= -1;
            particle.vx += 5;
        }

    },
    infinity: function(particle, pIndex, particles){

        if (particle.x > this.WIDTH){
            particle.x = 0;
        }

        if (particle.x < 0){
            particle.x = this.WIDTH;
        }

        if (particle.y > this.HEIGHT){
            particle.y = 0;
        }       

        if (particle.y < 0) {
            particle.y = this.HEIGHT;
        }

    }
}

Jarvis.prototype.fx = {
    link : function(particle, pIndex, particles){

        for(var j = pIndex + 1; j < particles.length; j++) {

            var p1 = particle;
            var p2 = particles[j];
            var particleDistance = getDistance(p1, p2);

            if (particleDistance <= this.particleMinLinkDistance) {
                this.backgroundCtx.beginPath();
                this.backgroundCtx.strokeStyle = "rgba("+p1.red+", "+p1.green+", "+p1.blue+","+ (p1.opacity - particleDistance / this.particleMinLinkDistance) +")";
                this.backgroundCtx.moveTo(p1.x, p1.y);
                this.backgroundCtx.lineTo(p2.x, p2.y);
                this.backgroundCtx.stroke();
                this.backgroundCtx.closePath();
            }
        }
    },
    shake : function(particle, pIndex, particles){

        if (particle.xInitial - particle.x >= this.shakeAreaThreshold){
            particle.xOper = (randBtwn(this.shakeFactorMin, this.shakeFactorMax) * 2) % (this.WIDTH);
        } else if (particle.xInitial - particle.x <= -this.shakeAreaThreshold) {
            particle.xOper = (randBtwn(-this.shakeFactorMax, this.shakeFactorMin) * 2) % (this.WIDTH);
        }

        if (particle.yInitial - particle.y >= this.shakeAreaThreshold){
            particle.yOper = (randBtwn(this.shakeFactorMin, this.shakeFactorMax) * 2) % (this.HEIGHT);
        } else if (particle.yInitial - particle.y <= -this.shakeAreaThreshold) {
            particle.yOper = (randBtwn(-this.shakeFactorMax, this.shakeFactorMin) * 2) % (this.HEIGHT);
        }       

        particle.x += particle.xOper;
        particle.y += particle.yOper;

    },
    radialWave : function(particle, pIndex, particles){

        var distance = getDistance(particle, this.center);

        if (particle.radius >= (this.dim * 0.0085)) {
            particle.radiusOper = -0.02;
        } else if (particle.radius <= 1) {
            particle.radiusOper = 0.02;
        }

        particle.radius += particle.radiusOper * particle.radius;
    },
    responsive : function(particle, pIndex, particles){

        var newPosX = (this.logoParticles.logoOffsetX + this.logoParticles.particleRadius) + (this.logoParticles.particleDistance + this.logoParticles.particleRadius) * particle.arrPos.x;
        var newPosY = (this.logoParticles.logoOffsetY + this.logoParticles.particleRadius) + (this.logoParticles.particleDistance + this.logoParticles.particleRadius) * particle.arrPos.y;

        if (particle.xInitial !== newPosX || particle.yInitial !== newPosY){

            particle.xInitial = newPosX;
            particle.yInitial = newPosY;
            particle.x = particle.xInitial;
            particle.y = particle.yInitial;

        }

    },
    motionDetect : function(particle, pIndex, particles){

        var isClose = false;
        var distance = null;

        for (var i = 0; i < this.touches.length; i++) {

            var t = this.touches[i];

            var point = {
                x : t.clientX,
                y : t.clientY
            }

            var d = getDistance(point, particle); 

            if (d <= this.blackhole) {
                isClose = true;

                if (d <= distance || distance === null) {
                    distance = d;
                }

            }  

        }

        if (isClose){
            if (particle.radius < (this.dim * 0.0085)) {
                particle.radius += 0.25;
            }
            if (particle.green >= 0 && particle.blue >= 0) {
                particle.green -= 10;
                particle.blue -= 10;
            }           
        } else {
            if (particle.radius > particle.initialRadius) {
                particle.radius -= 0.25;
            }
            if (particle.green <= 255 && particle.blue <= 255) {
                particle.green += 10;
                particle.blue += 10;
            }           
        }

    },
    reverseBlackhole : function(particle, pIndex, particles){

        for (var i = 0; i < this.touches.length; i++) {

            var t = this.touches[i];

            var point = {
                x : t.clientX,
                y : t.clientY
            } 

            var distance = getDistance(point, particle);

            if (distance <= this.blackhole){

                var diff = getPointsDifference(point, particle);

                particle.vx += -diff.x / distance;
                particle.vy += -diff.y / distance;
            }

        }
    }
}

Furthermore in case anyone wonders I have 3 canvas layers & I'll add the particles rendering function and the clear function for all canvas layers

  1. Background which draws a full screen radial gradient & particles

  2. Menu canvas

  3. Menu button overlay selectors (show which menu is active etc)


Jarvis.prototype.backgroundDraw = function() {

    // particles

    var that = this;

    this.logoParticles.forEach(function(particle, i){

        particle.draw(that.backgroundCtx);

        that.logoParticles.motion.forEach(function(motionType, motionIndex){
            that.motion[motionType].call(that, particle, i, that.logoParticles, "foregroundParticles");
        });
        that.logoParticles.fx.forEach(function(fxType, fxIndex){
            that.fx[fxType].call(that, particle, i, that.logoParticles, "foregroundParticles");
        });
        that.logoParticles.collision.forEach(function(collisionType, collisionIndex){
            that.collision[collisionType].call(that, particle, i, that.logoParticles, "foregroundParticles");
        });
    });

    this.backgroundParticles.forEach(function(particle, i){

        particle.draw(that.backgroundCtx);

        that.backgroundParticles.motion.forEach(function(motionType, motionIndex){
            that.motion[motionType].call(that, particle, i, that.backgroundParticles, "backgroundParticles");
        });
        that.backgroundParticles.fx.forEach(function(fxType, fxIndex){
            that.fx[fxType].call(that, particle, i, that.backgroundParticles, "backgroundParticles");
        });
        that.backgroundParticles.collision.forEach(function(collisionType, collisionIndex){
            that.collision[collisionType].call(that, particle, i, that.backgroundParticles, "backgroundParticles");
        });
    });

}

Jarvis.prototype.clearCanvas = function() {

    switch(this.background.type){
        case "radial_gradient":
            this.setBackgroundRadialGradient(this.background.color1, this.background.color2);
            break;
        case "plane_color":
            this.setBackgroundColor(this.background.red, this.background.green, this.background.blue, this.background.opacity);
            break;
        default:
            this.setBackgroundColor(142, 214, 255, 1);
    }

    this.foregroundCtx.clearRect(this.clearStartX, this.clearStartY, this.clearDistance, this.clearDistance);
    this.middlegroundCtx.clearRect(this.clearStartX, this.clearStartY, this.clearDistance, this.clearDistance);
}

Jarvis.prototype.mainLoop = function() {
    this.clearCanvas();
    this.backgroundDraw();
    this.drawMenu();
    window.requestAnimFrame(this.mainLoop.bind(this));
}

Any other optimization tips will be greatly appreciated. I've read a couple of articles but I'm not sure how to optimize this code further.


Solution

  • If you are looking to speed up code, here are some micro-optimizations:

    • for(var i = 0, l = bla.length; i < l; i++) { ... } instead of bla.forEach(...)
    • reduce callback usage. Inline simple stuff.
    • comparing against distance is slow because of the SQRT. radius <= distance is slow, radius*radius <= distanceSquared is fast.
    • calculating the distance is done by calculating the difference. You now do 2 function calls, first to get the distance, then to get the difference. here's a small rewrite: No function calls, No unnecessary calculations.
    reverseBlackhole : function(particle, pIndex, particles)
    {
        var blackholeSqr = this.blackhole * this.blackhole,
            touches = this.touches,
            fnSqrt = Math.sqrt,
            t, diffX, diffY, dstSqr;
        for (var i = 0, l = touches.length; i < l; i++) {
            t = touches[i];
            diffX = particle.x - t.clientX;
            diffY = particle.y - t.clientY;
            distSqr = (diffX * diffX + diffY * diffY);
            // comparing distance without a SQRT needed
            if (dstSqr <= blackholeSqr){
                var dist = Math.sqrt(dstSqr); 
                particle.vx -= diffX / dist;
                particle.vy -= diffY / dist;
            }
        }
    }
    

    To speed up drawing (or make it lag less during drawing):

    • Separate your calculations from your drawing
    • Only request a redraw after you have updated your calculations

    And for the whole animation:

    • this.backgroundParticles.forEach(..): in case of 200 particles, this will do
    • 200 particles times ( this.backgroundParticles.forEach( )
      • 200 particles ( that.backgroundParticles.motion.forEach )
      • 200 particles ( that.backgroundParticles.fx.forEach )
      • 200 particles ( that.backgroundParticles.collision.forEach )
    • same goes for this.foregroundparticles.forEach(..)
    • let's say we have 200 background and 100 foreground, that is (2002003) + (1001003) callbacks, which is 150000 callbacks, per tick. And we haven't actually calculated a single thing yet, haven't displayed anything either.
    • Run it at 60fps and you are up to 9million callbacks a second. I think you can spot a problem here.
    • Stop passing strings around in those function calls too.

    To get this more performance, remove the OOP stuff and go for ugly spaghetti code, only where it makes sense.

    Collision detection can be optimized by not testing every particle against each other. Just look up quadtrees. Not that hard to implement, and the basics of it can be used to come up with a custom solution.

    Since you are doing quite some vector math, try out the glmatrix library. Optimized vector math :-)