nickelman
nickelman

Reputation: 742

About 120 000 particles on canvas?

I have about 120 000 particles (each particle 1px size) that I need to find the best and most important: fastest way to draw to my canvas.

How would you do that?

Right now I'm basically getting my pixels into an Array, and then I loop over these particles, do some x and y calculations and draw them out using fillRect. But the framerate is like 8-9 fps right now.

Any ideas? Please example.

Thank you

LATEST UPDATE (my code)

function init(){

    window.addEventListener("mousemove", onMouseMove);

    let mouseX, mouseY, ratio = 2;

    const canvas = document.getElementById("textCanvas");
    const context = canvas.getContext("2d");
    canvas.width = window.innerWidth * ratio;
    canvas.height = window.innerHeight * ratio;

    canvas.style.width = window.innerWidth + "px";
    canvas.style.height = window.innerHeight + "px";

    context.imageSmoothingEnabled = false;
    context.fillStyle = `rgba(255,255,255,1)`;
    context.setTransform(ratio, 0, 0, ratio, 0, 0);

    const width = canvas.width;
    const height = canvas.height;

    context.font = "normal normal normal 232px EB Garamond";
    context.fillText("howdy", 0, 160);

    var pixels = context.getImageData(0, 0, width, height).data;
    var data32 = new Uint32Array(pixels.buffer);

    const particles = new Array();

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

        if (data32[i] & 0xffff0000) {
            particles.push({
                x: (i % width),
                y: ((i / width)|0),
                ox: (i % width),
                oy: ((i / width)|0),
                xVelocity: 0,
                yVelocity: 0,
                a: pixels[i*4 + 3] / 255
            });
        }
    }

    /*const particles = Array.from({length: 120000}, () => [
        Math.round(Math.random() * (width - 1)),
        Math.round(Math.random() * (height - 1))
    ]);*/

    function onMouseMove(e){
        mouseX = parseInt((e.clientX-canvas.offsetLeft) * ratio);
        mouseY = parseInt((e.clientY-canvas.offsetTop) * ratio);
    }

    function frame(timestamp) {

        context.clearRect(0, 0, width, height);
        const imageData = context.getImageData(0, 0, width, height);
        const data = imageData.data;
        for (let i = 0; i < particles.length; i++) {
            const particle = particles[i];
            const index = 4 * Math.round((particle.x + particle.y * width));

            data[index + 0] = 0;
            data[index + 1] = 0;
            data[index + 2] = 0;
            data[index + 3] = 255;
        }
        context.putImageData(imageData, 0, 0);

        for (let i = 0; i < particles.length; i++) {
            const p = particles[i];

            var homeDX = p.ox - p.x;
            var homeDY = p.oy - p.y;

            var cursorForce = 0;
            var cursorAngle = 0;

            if(mouseX && mouseX > 0){
                var cursorDX = p.ox - mouseX;
                var cursorDY = p.oy - mouseY;
                var cursorDistanceSquared = (cursorDX * cursorDX + cursorDY * cursorDY);
                cursorForce = Math.min(10/cursorDistanceSquared,10);

                cursorAngle = -Math.atan2(cursorDY, cursorDX);
            }else{
                cursorForce = 0;
                cursorAngle = 0;
            }

            p.xVelocity += 0.2 * homeDX + cursorForce * Math.cos(cursorAngle);
            p.yVelocity += 0.2 * homeDY + cursorForce * Math.sin(cursorAngle);

            p.xVelocity *= 0.55;
            p.yVelocity *= 0.55;

            p.x += p.xVelocity;
            p.y += p.yVelocity;
        }
        requestAnimationFrame(frame);
    }

    requestAnimationFrame(frame);
}

Upvotes: 2

Views: 1798

Answers (2)

Blindman67
Blindman67

Reputation: 54026

Moving 7.2Million particles a second

Not using webGL and shaders and you want 120K particles per frame at 60fps you need a throughput of 7.2million points per second. You need a fast machine.

Web workers multi-core CPUs

Quick solutions. On multi core machines web workers give linear performance increase for each hardware core. Eg On a 8 Core i7 you can run 7 workers sharing data via sharedArrayBuffers (shame that its all turned of ATM due to CPU security risk see MDN sharedArrayBuffer) and get slightly lower than 7 times performance improvement. Note benifits are only from actual hardware cores, JS threads tend to run flat out, Running two workers in one core results in an overall decreased throughput.

Even with shared buffers turned of it is still a viable solution if you are in control of what hardware you run on.

Make a movie.

LOL but no it is an option, and there is no upper limit to particle count. Though not as interactive as I think you may want. If you are selling something via the FX you are after a wow, not a how?

Optimize

Easy to say hard to do. You need to go over the code with a fine tooth comb. Remember that removing a single line if running at full speed is 7.2million lines removed per second.

I have gone over the code one more time. I can not test it so it may or may not work. But its to give you ideas. You could even consider using integer only math. JS can do fixed point math. The integer size is 32bits way more than you need for even a 4K display.

Second optimization pass.

// call this just once outside the animation loop.
const imageData = this.context.getImageData(0, 0, this.width * this.ratio, this.height * this.ratio);
// create a 32bit buffer
const data32 = new Uint32Array(imageData.data.buffer);
const pixel = 0xFF000000; // pixel to fill
const width = imageData.width;


// inside render loop
data32.fill(0); // clear the pixel buffer

// this line may be a problem I have no idea what it does. I would
// hope its only passing a reference and not creating a copy 
var particles = this.particleTexts[0].getParticles();

var cDX,cDY,mx,my,p,cDistSqr,cForce,i;
mx = this.mouseX | 0; // may not need the floor bitwize or 0
my = this.mouseY | 0; // if mouse coords already integers

if(mX > 0){  // do mouse test outside the loop. Need loop duplication
             // But at 60fps thats 7.2million less if statements
    for (let i = 0; i < particles.length; i++) {
        var p = particles[i];
        p.xVelocity += 0.2 * (p.ox - p.x);
        p.yVelocity += 0.2 * (p.oy - p.y);
        p.xVelocity *= 0.55;
        p.yVelocity *= 0.55;
        data32[((p.x += p.xVelocity) | 0) + ((p.y += p.yVelocity) | 0) * width] = pixel;
    }
}else{
    for (let i = 0; i < particles.length; i++) {
        var p = particles[i];
        cDX = p.x - mx;
        cDY = p.y - my;
        cDist = Math.sqrt(cDistSqr = cDX*cDX + cDY*cDY + 1);
        cForce = 1000 / (cDistSqr * cDist)
        p.xVelocity += cForce * cDx +  0.2 * (p.ox - p.x);
        p.yVelocity += cForce * cDY +  0.2 * (p.oy - p.y);
        p.xVelocity *= 0.55;
        p.yVelocity *= 0.55;
        data32[((p.x += p.xVelocity) | 0) + ((p.y += p.yVelocity) | 0) * width] = pixel;

    }
}
// put pixel onto the display.
this.context.putImageData(imageData, 0, 0);

Above is about as much as I can cut it down. (Cant test it so may or may not suit your need) It may give you a few more frames a second.

Interleaving

Another solution may suit you and that is to trick the eye. This increases frame rate but not points processed and requires that the points be randomly distributed or artifacts will be very noticeable.

Each frame you only process half the particles. Each time you process a particle you calculate the pixel index, set that pixel and then add the pixel velocity to the pixel index and particle position.

The effect is that each frame only half the particles are moved under force and the other half coast for a frame..

This may double the frame rate. If your particles are very organised and you get clumping flickering type artifacts, you can randomize the distribution of particles by applying a random shuffle to the particle array on creation. Again this need good random distribution.

The next snippet is just as an example. Each particle needs to hold the pixelIndex into the pixel data32 array. Note that the very first frame needs to be a full frame to setup all indexes etc.

    const interleave = 2; // example only setup for 2 frames
                          // but can be extended to 3 or 4

    // create frameCount outside loop
    frameCount += 1;

    // do half of all particals
    for (let i = frameCount % frameCount  ; i < particles.length; i += interleave ) {
        var p = particles[i];
        cDX = p.x - mx;
        cDY = p.y - my;
        cDist = Math.sqrt(cDistSqr = cDX*cDX + cDY*cDY + 1);
        cForce = 1000 / (cDistSqr * cDist)
        p.xVelocity += cForce * cDx +  0.2 * (p.ox - p.x);
        p.yVelocity += cForce * cDY +  0.2 * (p.oy - p.y);
        p.xVelocity *= 0.55;
        p.yVelocity *= 0.55;

        // add pixel index to particle's property 
        p.pixelIndex = ((p.x += p.xVelocity) | 0) + ((p.y += p.yVelocity) | 0) * width;
        // write this frames pixel
        data32[p.pixelIndex] = pixel;

        // speculate the pixel index position in the next frame. This need to be as simple as possible.
        p.pixelIndex += (p.xVelocity | 0) + (p.yVelocity | 0) * width;

        p.x += p.xVelocity;  // as the next frame this particle is coasting
        p.y += p.yVelocity;  // set its position now
     }

     // do every other particle. Just gets the pixel index and sets it
     // this needs to remain as simple as possible.
     for (let i = (frameCount + 1) % frameCount  ; i < particles.length; i += interleave)
         data32[particles[i].pixelIndex] = pixel;
     }

Less particles

Seams obvious, but is often over looked as a viable solution. Less particles does not mean less visual elements/pixels.

If you reduce the particle count by 8 and at setup create a large buffer of offset indexes. These buffers hold animated pixel movements that closely match the behavior of pixels.

This can be very effective and give the illusion that each pixels is in fact independent. But the work is in the pre processing and setting up the offset animations.

eg

   // for each particle after updating position
   // get index of pixel

   p.pixelIndex = (p.x | 0 + p.y | 0) * width;
   // add pixel
   data32[p.pixelIndex] = pixel;

   // now you get 8 more pixels for the price of one particle 
   var ind = p.offsetArrayIndex; 
   //  offsetArray is an array of pixel offsets both negative and positive
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   data32[p.pixelIndex + offsetArray[ind++]]  = pixel;
   // offset array arranged as sets of 8, each set of 8 is a frame in 
   // looping pre calculated offset animation
   // offset array length is 65536 or any bit mask able size.
   p.offsetArrayIndex = ind & 0xFFFF ; // ind now points at first pixel of next
                                       // set of eight pixels

This and an assortment of other similar tricks can give you the 7.2million pixels per second you want.

Last note.

Remember every device these days has a dedicated GPU. Your best bet is to use it, this type of thing is what they are good at.

Upvotes: 2

le_m
le_m

Reputation: 20228

Computing those particles within a shader on a webgl context will provide the most performant solution. See e. g. https://www.shadertoy.com/view/MdtGDX for an example.

If you prefer to continue using a 2d context, you could speed up rendering particles by doing so off-screen:

  1. Get the image data array by calling context.getImageData()
  2. Draw pixels by manipulating the data array
  3. Put the data array back with context.putImageData()

A simplified example:

const output = document.getElementById("output");
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");
const width = canvas.width;
const height = canvas.height;

const particles = Array.from({length: 120000}, () => [
  Math.round(Math.random() * (width - 1)),
  Math.round(Math.random() * (height - 1))
]);

let previous = 0;
function frame(timestamp) {
  // Print frames per second:
  const delta = timestamp - previous;
  previous = timestamp;
  output.textContent = `${(1000 / delta).toFixed(1)} fps`;
  
  // Draw particles:
  context.clearRect(0, 0, width, height);
  const imageData = context.getImageData(0, 0, width, height);
  const data = imageData.data;
  for (let i = 0; i < particles.length; i++) {
    const particle = particles[i];
    const index = 4 * (particle[0] + particle[1] * width);
    data[index + 0] = 0;
    data[index + 1] = 0;
    data[index + 2] = 0;
    data[index + 3] = 255;
  }
  context.putImageData(imageData, 0, 0);
  
  // Move particles randomly:
  for (let i = 0; i < particles.length; i++) {
    const particle = particles[i];
    particle[0] = Math.max(0, Math.min(width - 1, Math.round(particle[0] + Math.random() * 2 - 1)));
    particle[1] = Math.max(0, Math.min(height - 1, Math.round(particle[1] + Math.random() * 2 - 1)));
  }
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);
<canvas id="canvas" width="500" height="500"></canvas>
<output id="output"></output>

Instead of drawing individual pixels, you might also want to consider drawing and moving a few textures with a lot of particles on each of them. This might come close to a full particle effect at better performance.

Upvotes: 2

Related Questions