Search code examples
javascripthtmlcanvasrenderingpixi.js

Optimizing canvas multiple objects rendering


I'm trying to make an ants simulator, I use the basic Javascript canvas renderer

here is a part of the rendering code :

render(simulation) {
    
    let ctx = this.ctx;
    
    // Clear previous frame
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    
    // Pheromones
    let pheromone;
    let pheromoneXPos;
    let pheromoneYPos;
    for (let i = 0; i < simulation.pheromones.length; i++) {
      pheromone = simulation.pheromones[i];
      pheromoneXPos = pheromone.position[0];
      pheromoneYPos = pheromone.position[1];
      ctx.fillRect(pheromoneXPos, pheromoneYPos, 1, 1);
    }
    
    
    // ... rendering other stuff
    
}

here is an example (it runs smoothly just because I'm using ~90% fewer objects) :

enter image description here

The simulation.pheromones.length is quite large (~25000), the time it takes to render 1 frame is too long (250ms on my computer)
What should I do to make it render faster? should I use a rendering engine (like PixiJS) instead? or am I just using it wrong?

Note: most of the objects (Pheromones) are the same as the previous frame (they only update once or twice every second).


Solution

  • Since it seems all your rectangles have the same style, you could combine all these rectangles in a single sub-path and call fill() only once with this more complex sub-path.

    const canvas = document.querySelector("canvas");
    const w = canvas.width = 500;
    const h = canvas.height = 500;
    const ctx = canvas.getContext("2d");
    const makePheromone = () => ({
      x: w / 2,
      y: h / 2,
      dx: Math.random() * 20 - 10,
      dy: Math.random() * 20 - 10
    });
    const pheromones = Array.from( { length: 50000 }, makePheromone );
    ctx.fillStyle = "red";
    
    requestAnimationFrame( draw );
    
    function draw() {
      ctx.clearRect( 0, 0, canvas.width, canvas.height );
      ctx.beginPath();
      pheromones.forEach( (obj) => {
        obj.x += obj.dx;
        if( obj.x < 0 ) {
          obj.x += w; 
        }
        else if( obj.x > w ) {
          obj.x -= w; 
        }
        obj.y += obj.dy;
        if( obj.y < 0 ) {
          obj.y += h; 
        }
        else if( obj.y > h ) {
          obj.y -= h; 
        }
        ctx.rect( obj.x, obj.y, 1, 1 );
      });
      ctx.fill();
      requestAnimationFrame( draw );
    }
    <canvas width="5000" height="5000"></canvas>

    Now, it's quite unclear if in your case you will actually animate these 'pheromones' or not.
    If you don't, then draw them only once and store an ImageBitmap from the canvas that you will render every frame:

    const canvas = document.querySelector("canvas");
    const w = canvas.width = 500;
    const h = canvas.height = 500;
    const ctx = canvas.getContext("2d");
    
    getPheromonesBitmap()
      .then( (bmp) => {
        let x = w / 2;
        let y = h / 2;
        const circle = new Path2D();
        circle.arc( 0, 0, 50, 0, Math.PI * 2 ); 
        function draw( t ) {
          x += Math.sin( t / 2000 );
          y += Math.cos( t / 2000 );  
          ctx.setTransform( 1, 0, 0, 1, 0, 0 );
          ctx.clearRect( 0, 0, w, h );
          // draw all the pheromones as a single bitmap
          ctx.drawImage( bmp, 0, 0 );
          ctx.translate( x, y );
          ctx.fill( circle );
          requestAnimationFrame( draw );
        }
        requestAnimationFrame( draw );
      } );
    
    function getPheromonesBitmap() {
      const makePheromone = () => ({
        x: Math.random() * w,
        y: Math.random() * h
      });
      const pheromones = Array.from( { length: 50000 }, makePheromone );
      ctx.fillStyle = "red";
      ctx.clearRect( 0, 0, canvas.width, canvas.height );
      ctx.beginPath();
      pheromones.forEach( (obj) => {
        ctx.rect( obj.x, obj.y, 1, 1 );
      });
      ctx.fill();
      const prom = createImageBitmap( canvas );
      ctx.beginPath();
      ctx.clearRect( 0, 0, w, h );
      return prom;
    }
    <canvas width="5000" height="5000"></canvas>

    For browsers that don't support createImageBitmap (only Safari for this simple case), I wrote a polyfill available here.

    And finally, in case you do draw your 1x1px rectangles at pixel boundaries, you could also use an ImageData to render these, which is particularly performant for when all entities have their own color:

    const canvas = document.querySelector("canvas");
    const w = canvas.width = 500;
    const h = canvas.height = 500;
    const ctx = canvas.getContext("2d");
    const makePheromone = () => ({
      x: w / 2,
      y: h / 2,
      dx: Math.random() * 20 - 10,
      dy: Math.random() * 20 - 10,
      color: Math.random() * 0xFFFFFF
    });
    const pheromones = Array.from( { length: 50000 }, makePheromone );
    const img = new ImageData( w, h );
    // we use an Uint32Array to set each pixel in one pass
    const arr = new Uint32Array( img.data.buffer );
    
    requestAnimationFrame( draw );
    
    function draw() {
      pheromones.forEach( (obj) => {
        obj.x += obj.dx;
        if( obj.x < 0 ) {
          obj.x += w; 
        }
        else if( obj.x > w ) {
          obj.x -= w; 
        }
        obj.y += obj.dy;
        if( obj.y < 0 ) {
          obj.y += h; 
        }
        else if( obj.y > h ) {
          obj.y -= h; 
        }
        const index = Math.floor( obj.y * w + obj.x );
        arr[ index ] = obj.color + 0xFF000000;
      });
      ctx.putImageData( img, 0, 0 );
      requestAnimationFrame( draw );
    }
    <canvas width="5000" height="5000"></canvas>