5
\$\begingroup\$

I'm making a particle engine that can spawn new explosions while keeping particles from previous ones active (until they despawn).

The code works well on its own but within the game, two or more active explosions at once causes stutters, especially on laptops that are a few years old. I'm wondering if there's something inherently inefficient with my code, or if any of the coding practices used are bad.

In in the code snippet below, you can click anywhere within the canvas element to see the particle effect. Let me know if there's something that's unclear.

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var explosions = [];
var mouseX;
var mouseY;

canvas.addEventListener('mousemove', setMouse, false);

canvas.addEventListener('click', function() {
    explosions.push(new explosion(mouseX, mouseY));
}, false);

function loop() {
    ctx.clearRect(0, 0, 500, 500);
    drawExplosion();
    requestAnimationFrame(loop);
}

loop();

function drawExplosion() {
    if (explosions.length === 0) {
        return;
    }

    for (let i = 0; i < explosions.length; i++) {
        const explosion = explosions[i];
        const projectiles = explosion.projectiles;

        if (projectiles.length === 0) {
            explosions.splice(i, 1);
            return;
        }

        const projectilesRemove = projectiles.slice();

        for (let ii = 0; ii < projectiles.length; ii++) {
            const projectile = projectiles[ii];

            // remove projectile if radius is below 0
            if (projectile.radius < 0) {
                projectilesRemove.splice(ii, 1);
                continue;
            }

            // draw
            ctx.beginPath();
            ctx.arc(projectile.x, projectile.y, projectile.radius, Math.PI * 2, 0, false);
            ctx.closePath();
            ctx.fillStyle = 'hsl(' + projectile.h + ',' + projectile.s + '%,' + projectile.l + '%)';
            ctx.fill();


            // update
            projectile.x -= projectile.vx * 1;
            projectile.y -= projectile.vy * 1;
            projectile.radius -= 0.02;


            // collisions
            if (projectile.x > 500) {
                projectile.x = 500;
                projectile.vx *= -1;
            }

            if (projectile.x < 0) {
                projectile.x = 0;
                projectile.vx *= -1;
            }

            if (projectile.y > 500) {
                projectile.y = 500;
                projectile.vy *= -1;
            }

            if (projectile.y < 0) {
                projectile.y = 0;
                projectile.vy *= -1;
            }
        }

        explosion.projectiles = projectilesRemove;
    }
}

function explosion(x, y) {
    this.projectiles = [];

    for (let i = 0; i < 100; i++) {
        this.projectiles.push(
            new projectile(x, y)
        );
    }
}

function projectile(x, y) {
    this.x = x;
    this.y = y;
    this.radius = 2 + Math.random() * 4;
    this.vx = -10 + Math.random() * 20;
    this.vy = -10 + Math.random() * 20;
    this.h = 200;
    this.s = Math.floor((Math.random() * 100) + 70);
    this.l = Math.floor((Math.random() * 70) + 30);
}

function setMouse(e) {
    mouseX = e.offsetX;
    mouseY = e.offsetY;
}
<!DOCTYPE html>
<html>
<body>
<canvas id="canvas" width="500" height="500" style="border:1px solid #000;"></canvas>
</body>
</html>

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Just a minor thing: Jon Burton, who has worked on a lot of games since the 16 bit era, has a pretty thorough video on his YouTube channel about how he developed a really efficient particle engine: youtube.com/watch?v=bIjrSvGddDQ This is for 3D graphics, but a lot of the principles he describes can be applied to 2D as well. In general I can recommend his channel to learn about optimization, it's really insightful. \$\endgroup\$ Oct 29, 2018 at 9:47

1 Answer 1

2
\$\begingroup\$

Games and Animation.

Writing games means managing the compromise between the creative and artistic need, performance practicalities, and coding style.

When developing you usually have a top end gaming machine, sadly this is the wrong machine to develop on.

You need to have the minimum spec device you want your game to work well on as the development machine (or at least run on each test cycle) or you will always be frustrated with poor performance. It sucks, but it is easier to add features at run time that take advantage of a high end machine than to remove them to have it run on a low end machine.

Slow downs.

There are three main reasons that your code is slowing down.

  1. Switching colors causes a state change that can be more expensive than the single draw function. It also forces you to draw using a new path for each item.
  2. Rendering arcs is slow.
  3. Rendering invisible or near invisible shapes still requires CPU cycles. You cull particles when their size is less than zero. But your update reduces the size by 0.02 per step so between radius 0.5 and 0 there are 25 near invisible particles rendered.

Also avoid ctx.closePath as for some reason it is mega slow. For arcs that will connect a path if you draw several it is far quicker to draw using

ctx.moveTo(x + r, y);
ctx.arc(x, y, r, 0, Math.PI * 2);

// An order of magnitude faster than

ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.closePath();

Drawing arcs is slow, for small arcs you can not tell the difference between an arc and a rectangle. Test the size and draw rectangles when you are below the size of a few pixels.

Reduce color state changes

To reduce the state change overhead caused by color changes you need to reduce the number of colors used. This can be achieved using a color table with a limited number of colors. You then create the particles in the explosion so that they are grouped by color. This lets you combine renders and reduce state changes.

Seperat concerns

Separating the explosion logic Explosion.update (movement, decay, bouncing) from the rendering allows you to optimise the code for the particular need. The rendering of particles can be independent of the explosion allowing all particles of the same color to be rendered in the same path.

Each time you call ctx.beginPath() you instigate a whole pile of internal functions. This overhead can be avoided by grouping as many path items together. All particles of the same color should be between a ctx.beginPath() and ctx.fill() or ctx.stroke()

Memory management

Creating and de-referencing (deleting) objects adds additional work for the CPU. The deleting workload will manifest as GC hits (garbage collection) that are uncontrollable.

GC hit can be a major problem when writing animations and require attention to how you use memory.

Memory is plentiful, CPU cycles are not so the rule is don't delete during the animation. You can do this using object pools.

Object pools

Object pools are a store of unused object that can be used when needed. When you need to create a new particle you check the pool, if there are available items in the pool you use them, if not you create a new object. When an object's life is up, you copy the reference to the pool for later use.

If well balanced the pools will grow to a size that represents the max number of particles that are visible at one time. This means the pools do not add additional memory load past the max needed to run without pools.

Bubble sort

When handling array of objects that change size rather than splice them out you can use a bubble sort to sort the objects as you process them. The explosion object does this to manage its array of particles. That means the explosion's particle array ids always full and will not incur GC hits, or need allocation when pulled from the pool.

There are many more ways to optimise but the answer would grow out of control.

Significant performance gain

Using the above concepts can give Significant performance benefits. The example below can render 10 times as many particles than your code. Though there are subtle differences, you will be hard pushed to see them and to the casual observer they will appear identical.

Example

To describe all that is contained in the code below would take to long so will let the code speak.

To get the performance there is added complexity, but performance is more important for a game than simple code.

There are two additional objects. explosions and particles that are the pools and management roles for particles and explosions.

The particle behaviour is almost identical apart from,

  • (12 * 8 = 96) particles per explosion rather than 100
  • Different decay rates per particle (this is to spread out pooling operations)
  • 8 particle colors rather than many.
  • Particles are removed when radius is less than half a pixel.
  • Particles that have a radius less than 2 pixels are rendered as rectangles
  • Order of color rendering is fixed, the same color will always be on top.

Non performance change

  • The explosion picks a random direction and speed per particle to give a more spherical appearance to the explosion.

I have added a performance indicator that give time to render, per particle time (PPT) which is the mean render time of a particle, and particles per frame (PPF). This is so you can compare performance between the example code and your code (added under this example).

const width = canvas.width;
const height = canvas.height;
const ctx = canvas.getContext("2d");
Math.PI2 = Math.PI * 2;

requestAnimationFrame(mainLoop);  // start with a frame dont call the function directly

var pCount = 0;
function mainLoop() {
    ctx.clearRect(0, 0, width, height);
    pCount = 0;
    const now = performance.now();
    explosions.update();
    particles.draw();
    const time = performance.now() - now;
    
    info.textContent = "Render Time: " + time.toFixed(3) + "ms PPT: " + (time / pCount).toFixed(4) + "ms PPF:" + pCount;
    requestAnimationFrame(mainLoop);
}

   
canvas.addEventListener('click', (e) => {
    const explosion = explosions.create();
    explosion.init(e.offsetX, e.offsetY);
});

// Number of particles per explosion is renderStyleCount * particlesPerStyle  
const explosion = {
    renderStyleCount : 8,   // Number of color styles to use. 
                            // Can't be more than explosion.colors.length 
    particlesPerStyle : 16, // Number of particles to add to explosion
                            // per color style
    colors : ["#0070a8", "#0077b3", "#0081c2", "#1282ba", "#57c7ff", "#66ccff", "#99ddff", "#a5f6ff"],
};


const explosions = Object.assign([], {
    pool : [],
    create() {
        var exp;
        if (explosions.pool.length) {
            exp = explosions.pool.pop();            
        } else {
            exp = new Explosion();
        }
        explosions.push(exp);
        return exp;
    }, 
    update() {
        var i;
        for (i = 0; i < explosions.length; i ++) {
            if (explosions[i].update() === false) { // if false all particles are dead
                explosions.pool.push(explosions.splice(i--, 1)[0]); // send to pool
            }
        }
    },
    
});
const particles = Object.assign([], {
    pool : [],
    init() {
        var i;
        for (i = 0; i < explosion.renderStyleCount; i++) {
            particles.push(Object.assign([], {color : explosion.colors[i]})); // create style buckets
        }
    },
    create(style) {
        var p;
        if (particles.pool.length) {
            p = particles.pool.pop();            
        } else {
            p = new Projectile();
        }
        
        p.style = style;
        particles[style].push(p);
        return p;
    },    
    draw() {
        var i;
        for (const style of particles) {  // If you have many style you should create a pool for the styles as well
            if (style.length > 0) {
                ctx.beginPath();
                ctx.fillStyle = style.color;
                for (i = 0; i < style.length; i ++) {                
                    const p = style[i];
                    if (p.dead) {
                        particles.pool.push(style.splice(i--,1)[0]);
                    } else {
                        pCount += 1;
                        const r = p.radius
                        if (r < 2) {
                            ctx.rect(p.x - r,p.y - r,r * 2, r * 2);
                        } else {                    
                            ctx.moveTo(p.x + r, p.y);
                            ctx.arc(p.x,p.y,r,0,Math.PI2);
                        }
                    }
                }
                ctx.fill();
            }
        }
    },
});
particles.init();







function Explosion(x, y) {
    this.particles = [];
    this.size = 0;
}
Explosion.prototype = {
    init(x, y) {
        var i, j, p, idx = 0;
        for (i = 0; i < explosion.renderStyleCount; i ++) {
            for (j = 0; j < explosion.particlesPerStyle; j ++) {
                this.particles[idx++] = p = particles.create(i);
                p.init(x, y);
            }
        }
        this.size = idx;
    },
    update() {  // implements a bubble sort to keep live particles grouped at the start of the array
    
        var head = 0, tail = 0;
        const particles = this.particles;
        const size = this.size;
        while (head < this.size) {
            const p = particles[head];
            p.x -= p.vx * 1;
            p.y -= p.vy * 1;
            p.radius -= p.decayRate;
            if (p.radius < 0.5) {  // particle is too small to see
                p.dead = true; // semaphore for particles object  to know when to pool particle
                head += 1;
            } else {                
                p.collide();                  
                if (tail < head) { // bubble dead particles up
                    const temp = particles[head];
                    particles[head] = particles[tail];
                    particles[tail] = temp;
                }
                head += 1;
                tail += 1;
            }
        }
        return (this.size = tail) > 0;        
    },

}

// Capitalize Object name
function Projectile() {
    // hints the optimiser to type
    this.decayRate = this.style = this.radius = this.vy = this.vx = this.y = this.x = 0;
    this.dead = true;    
}    
Projectile.prototype = {
    init(x, y) {  // creates using direction and speed to make explosion more
                  // spherical in appearance.
        this.x = x;
        this.y = y;
        const dir = Math.random() * Math.PI2;
        const speed = Math.random() ** 0.5 * 10
        this.radius = 2 + Math.random() * 4;
        this.decayRate = Math.random() * 0.01 + 0.01;
        this.vx = Math.cos(dir) * speed;
        this.vy = Math.sin(dir) * speed;
        this.dead = false;
    },
    collide() {
        const p = this;
        if (p.x > width) {
            p.x = width;
            p.vx *= -1;
        }
        if (p.x < 0) {
            p.x = 0;
            p.vx *= -1;
        }
        if (p.y > height) {
            p.y = height;
            p.vy *= -1;
        }
        if (p.y < 0) {
            p.y = 0;
            p.vy *= -1;
        }     
    },
}
#info {
position: absolute;
top: 0px;
left: 0px;
}
<canvas id="canvas" width="500"  height="500"></canvas>
<span id="info"></span>

Orignal code.

The next snippet is the original question code with some timing information added to compare the performance.

I made some minor changes to your code to add the timing information.

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var explosions = [];
var mouseX;
var mouseY;

canvas.addEventListener('mousemove', setMouse, false);

canvas.addEventListener('click', function() {
    explosions.push(new explosion(mouseX, mouseY));
}, false);

var pCount = 0;
function loop() {
    ctx.clearRect(0, 0, 500, 500);
    pCount = 0;
    const now = performance.now();
    drawExplosion();
    const time = performance.now() - now;

    info.textContent = "Render Time: " + time.toFixed(3) + "ms PPT: " + (time / pCount).toFixed(4) + "ms PPF:" + pCount;
    requestAnimationFrame(loop);
}
loop();

function drawExplosion() {
    if (explosions.length === 0) {
        return;
    }

    for (let i = 0; i < explosions.length; i++) {
        const explosion = explosions[i];
        const projectiles = explosion.projectiles;

        if (projectiles.length === 0) {
            explosions.splice(i, 1);
            return;
        }

        const projectilesRemove = projectiles.slice();

        for (let ii = 0; ii < projectiles.length; ii++) {
            const projectile = projectiles[ii];

            // remove projectile if radius is below 0
            if (projectile.radius < 0) {
                projectilesRemove.splice(ii, 1);
                continue;
            }

            // draw
            ctx.beginPath();
            ctx.arc(projectile.x, projectile.y, projectile.radius, Math.PI * 2, 0, false);
            ctx.closePath();
            ctx.fillStyle = 'hsl(' + projectile.h + ',' + projectile.s + '%,' + projectile.l + '%)';
            ctx.fill();

            pCount++;
            // update
            projectile.x -= projectile.vx * 1;
            projectile.y -= projectile.vy * 1;
            projectile.radius -= 0.02;


            // collisions
            if (projectile.x > 500) {
                projectile.x = 500;
                projectile.vx *= -1;
            }

            if (projectile.x < 0) {
                projectile.x = 0;
                projectile.vx *= -1;
            }

            if (projectile.y > 500) {
                projectile.y = 500;
                projectile.vy *= -1;
            }

            if (projectile.y < 0) {
                projectile.y = 0;
                projectile.vy *= -1;
            }
        }

        explosion.projectiles = projectilesRemove;
    }
}

function explosion(x, y) {
    this.projectiles = [];

    for (let i = 0; i < 100; i++) {
        this.projectiles.push(
            new projectile(x, y)
        );
    }
}

function projectile(x, y) {
    this.x = x;
    this.y = y;
    this.radius = 2 + Math.random() * 4;
    this.vx = -10 + Math.random() * 20;
    this.vy = -10 + Math.random() * 20;
    this.h = 200;
    this.s = Math.floor((Math.random() * 100) + 70);
    this.l = Math.floor((Math.random() * 70) + 30);
}

function setMouse(e) {
    mouseX = e.offsetX;
    mouseY = e.offsetY;
}
#info {
    position: absolute;
    top: 0px;
    left: 0px;
    }
<canvas id="canvas" width="500"  height="500"></canvas>
    <span id="info"></span>

\$\endgroup\$
14
  • 1
    \$\begingroup\$ I really appreciate the thorough answer, mate. From now on, I'll be analyzing your source code as if it was religious scriptures. On a serious note: this is just for a hobby project of mine, but it teaches me a lot about coding for the future. Can't thank you enough! Anyways, I also built on your code by adding a max amount of balls on-screen (let's say 500), so if there are 476 balls active, only 24 new balls will spawn. \$\endgroup\$
    – superlaks
    Oct 29, 2018 at 3:30
  • \$\begingroup\$ This is probably going into micro optimizations, but I also changed the if-if's to if else-if's and the color codes to RGBA because it weirdly enough performs better than all the other colors types, for some strange reason: measurethat.net/Benchmarks/Show/3995/0/… \$\endgroup\$
    – superlaks
    Oct 29, 2018 at 5:11
  • \$\begingroup\$ @superlaks There is something wrong with your tests. I have my own performance testing code and it shows very little difference between the various formats. Use the same canvas for each test. Remove the clearing function and set the color to be the same for each hsl(15, 90%, 64%) === #f67a51 === rgb(246,122,81) === rgba(246,122,81,1) and reduce the size of the rendering ctx.arc(10,10,2,0,Math.PI*2) Note that the last argument is not added to let the default direction be use (clockwise) \$\endgroup\$
    – Blindman67
    Oct 29, 2018 at 5:58
  • \$\begingroup\$ @superlaks Here is a test for just switching styles using the various formats. Hex is a little quicker than the others. measurethat.net/Benchmarks/Show/4101/0/… \$\endgroup\$
    – Blindman67
    Oct 29, 2018 at 6:16
  • 1
    \$\begingroup\$ You're pretty much the John Carmack of JavaScript. Nice testing, and thanks again! \$\endgroup\$
    – superlaks
    Oct 29, 2018 at 7:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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