2

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);
}
nickelman
  • 702
  • 6
  • 24
  • If you don't want to use a 3D context, you could first call `context.getImageData()`, then manipulate the pixels in the returned image array and finally put them back using `context.putImageData()` – le_m Jan 19 '18 at 20:27
  • @le_m hm, okay. can you please look at my updated code and give an example to that? Im not really following – nickelman Jan 19 '18 at 20:30
  • You could maybe double the speed, the math in the second loop is somewhat sloppy. To square a number `(homeDX * homeDX)` is faster than `Math.pow(homeDX)` You can avoid all the trig functions `atan2`, `sin`.`cos`. You have `var homeAngle = Math.atan2(homeDY,homeDX);` then `homeForce * Math.cos(homeAngle) ` and for y. Remove `homeAngle`,`homeForce`, and replace `homeForce * Math.cos(homeAngle)` with `0.2 * homeDX` and for y `0.2 * homeDY` it does exactly the same less 2 vars and 3 trig calls. Similar for `cursorAngle` Draw pixel in same loop as calculations saves time. Uint32Array for pixels – Blindman67 Jan 20 '18 at 07:19
  • @Blindman67 thanks for pointing that out. I did update on my local version, and yes, it saves some calls, and works like it did before. However, the fps is still way too low, and I guess I need to find a way le_m is suggesting.. Please see my comment on his post. – nickelman Jan 20 '18 at 10:16
  • @Blindman67 yes I know, it is a lot of points... Please have a look at my code, and see if you can see anything I need to change in order to get the result I expect on the movement? The particles are running in about 30 fps with le_m suggestion, but the movement of them is not as it was before anymore..? – nickelman Jan 20 '18 at 12:09

2 Answers2

2

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.

le_m
  • 19,302
  • 9
  • 64
  • 74
  • thanks for example. I think I'm close, but as soon as I move the mouse, everything goes away in a blink... Any ideas on why? My code has been updated. – nickelman Jan 19 '18 at 21:47
  • @nickelman First guess: You need to round `4 * (particle.x + particle.y * (this.width*this.ratio))` – le_m Jan 19 '18 at 23:00
  • Yes, it's getting closer =) But now the particles are really moving in big moves and each particle gets some rgb different colors... I have updated my code with the round, please have a look. Any ideas on why I get this result? – nickelman Jan 20 '18 at 10:18
  • My code has been updated again. It is running smooth, but i see 2 problems right now: 1) the cursorforce is getting unclear to me, the particles are moving like there is some kind of angle problem, making the particles got like a swirl when getting close. 2) when getting in close distance, there is like a "1px gap" being rendered in the text, basically empty pixels.. you will see if you run my code, it is like the "force circle" you can see? – nickelman Jan 20 '18 at 13:32
  • @nickelman Ah, you need to round the x and y component individually within the for-loop, i.e. `const particle = particles[i]; const x = Math.round(particle.x); const y = Math.round(particle.y); const index = 4 * (x + y * width);` – le_m Jan 20 '18 at 14:45
  • HI, @le_m, your solution is awesome! But I found that as the # of particles increasing to millions, the FPS drops severely (maybe just 8~10 on my computer), what can I do to improve the performance if I still want to use Canvas? What is the ceiling of Canvas in particle rendering? – Ukonn Ra Jul 26 '19 at 06:20
2

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.

Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • Oh lord, excellent post. Thank you. I will read through this, and try it out, and probably come back with a question =) thanks man! – nickelman Jan 20 '18 at 15:17
  • Okay. Please have a look at my original version here, that one is slow but the movement is correct: [https://jsfiddle.net/7fxwrkgw/](https://jsfiddle.net/7fxwrkgw/) then I have the fast version based on your code, but where the movement is off, I cant figure out why really? Please if you can compare: [https://jsfiddle.net/L8vtLqhu/1/](https://jsfiddle.net/L8vtLqhu/1/) – nickelman Jan 20 '18 at 16:33
  • @nickelman Ok I missed `ratio` and its value. Are you aware that you are rendering at twice the device display resolution. Devices are configured so that they can handle the processing load of their various peripheries. The canvas you render to is 4 times larger than the screen, the pixels you are rendering cover only a quarter of an actual physical pixel. Set `ratio` to 1 and you will instantly go from 8fps to 30fps. – Blindman67 Jan 20 '18 at 18:27
  • did you update any code? Okay, so ratio should be set to 1, but how is that working with retina then? Thats why I did that... did you see the difference in animation between ”my slow version” and the ”fast version”? I dont get why its different between the two? – nickelman Jan 20 '18 at 18:58
  • Okay, so setting ratio to 1 really improves the fps. but still 2 questions remain: 1) how do I get it at a retina resolution then? 2) do you have any ideas on why the animation/movement (based on the mouse cursor) is being different between the fast version (where I use your technique)? It is both movement which is not the same, and also that there is some kind of "pixel gap" in the rendering? – nickelman Jan 21 '18 at 19:40
  • @nickelman The pixel gap is likely due to the mouse being floored, with pixels lined up to the mouse pushed along the axis and pixel above or to the left being pushed a tiny bit up or left, so that they are floored to the next location. Possible fix. When setting pixel home pos `ox,oy` offset the coord by 0.5 eg `particle.ox = particle.x + 0.5;` same for y. – Blindman67 Jan 21 '18 at 21:29
  • @nickelman Many of the retina devices (especially the earlier models) are under powered in terms of CPU to pixel count, part of the reason that they have the 2 to 1 CSS pixel size. You can not get more speed from the algorithm. If you are using aspect 1 you can reinstate the `cursorForce` code (though lose some fps) as the code I gave is an approximation, Or play with the value 1000 to find the value that more closely matches the original. – Blindman67 Jan 21 '18 at 21:39
  • Hm, okay. The one I would like to "fix" is this version: [https://jsfiddle.net/L8vtLqhu/1/](https://jsfiddle.net/L8vtLqhu/1/). If you compare the two, you see that the "fast version" is acting weird on angle, you see? It is like the particles move really different compared to the "slow version"? I also tried by adding 0.5 to offset the home pos, but that still gives me the gap rendering.. :S – nickelman Jan 22 '18 at 18:13
  • Okay, I'm getting closer... Still that gap between some pixels I cant figure out where to change, please have a look at this version: [https://jsfiddle.net/jxrra8bo/](https://jsfiddle.net/jxrra8bo/) you see clearly with the green background...? – nickelman Jan 22 '18 at 18:38
  • Updated version, a bit better: [https://jsfiddle.net/jxrra8bo/1/](https://jsfiddle.net/jxrra8bo/1/) however, the gap that occurs when Im in a distance enough to effect the characters is bugging me.. It makes sense kind of since they ARE effected, but the annoying part is that it doesn't look good on those characters where they are affected by 1px line/break.. you see what I mean? I would like them to be affected but more together in that case? (I bet you are getting tired of me now, haha) – nickelman Jan 22 '18 at 22:36
  • Update: I got it working by setting pixel color value to more than data32[index], meaning I set also data32[index+1] etc to avoid the gap. That being combined with colors, and opacity was the way to go for me. If anyone is need of perfect pixel fill, then I still dont have the correct answer based on my animation. I think you need to adjust the cursor force to be something else then, because it all is moving as it should based on my calculations. – nickelman Jan 27 '18 at 21:51